Updated UI to use patternfly

* Removed unused css files
* Converted all UI elements to patternfly 4
* Implemented config page under same app
* Replaced slider with patternfly `Progress` component
This commit is contained in:
Benjamin Graham 2020-07-23 22:08:12 -04:00 committed by Justin Stephenson
parent 46ad9834b3
commit aa63c3871c
21 changed files with 1612 additions and 2381 deletions

View file

@ -20,28 +20,24 @@
import cockpit from 'cockpit'; import cockpit from 'cockpit';
import React from 'react'; import React from 'react';
import './app.scss'; import './app.scss';
import View from "./recordings.jsx";
const _ = cockpit.gettext; const _ = cockpit.gettext;
export class Application extends React.Component { export class Application extends React.Component {
constructor() { constructor() {
super(); super();
this.state = { 'hostname': _("Unknown") }; this.state = { hostname: _("Unknown") };
cockpit.file('/etc/hostname').read() cockpit.file('/etc/hostname').read()
.done((content) => { .done((content) => {
this.setState({ 'hostname': content.trim() }); this.setState({ hostname: content.trim() });
}); });
} }
render() { render() {
return ( return (
<div className="container-fluid"> <View />
<h2>Starter Kit</h2>
<p>
{ cockpit.format(_("Running on $0"), this.state.hostname) }
</p>
</div>
); );
} }
} }

3
src/app.scss Normal file
View file

@ -0,0 +1,3 @@
p {
font-weight: bold;
}

View file

@ -1,37 +0,0 @@
<!DOCTYPE html>
<!--
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/>.
-->
<html>
<head>
<title translate>Journal</title>
<meta charset="utf-8">
<link href="../base1/patternfly.css" rel="stylesheet">
<link href="table.css" rel="stylesheet">
<link href="recordings.css" rel="stylesheet">
<script type="text/javascript" src="../base1/cockpit.js"></script>
<script src="../*/po.js"></script>
</head>
<body>
<div id="view"></div>
<script type="text/javascript" src="config.js"></script>
</body>
</html>

View file

@ -18,16 +18,38 @@
*/ */
"use strict"; "use strict";
let cockpit = require("cockpit"); import React from "react";
let React = require("react"); import {
let ReactDOM = require("react-dom"); Button,
let json = require('comment-json'); Form,
let ini = require('ini'); FormGroup,
FormSelect,
FormSelectOption,
TextInput,
ActionGroup,
Spinner,
Card,
CardTitle,
CardBody,
Checkbox,
Bullseye,
EmptyState,
EmptyStateIcon,
Title,
EmptyStateBody,
EmptyStateVariant
} from "@patternfly/react-core";
import { AngleLeftIcon, ExclamationCircleIcon } from "@patternfly/react-icons";
import { global_danger_color_200 } from "@patternfly/react-tokens";
class Config extends React.Component { const json = require('comment-json');
const ini = require('ini');
const cockpit = require('cockpit');
const _ = cockpit.gettext;
class GeneralConfig extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.handleInputChange = this.handleInputChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
this.setConfig = this.setConfig.bind(this); this.setConfig = this.setConfig.bind(this);
this.fileReadFailed = this.fileReadFailed.bind(this); this.fileReadFailed = this.fileReadFailed.bind(this);
@ -37,7 +59,7 @@ class Config extends React.Component {
this.state = { this.state = {
config_loaded: false, config_loaded: false,
file_error: false, file_error: false,
submitting: "none", submitting: false,
shell: "", shell: "",
notice: "", notice: "",
latency: "", latency: "",
@ -57,17 +79,9 @@ class Config extends React.Component {
}; };
} }
handleInputChange(e) {
const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
const name = e.target.name;
const state = {};
state[name] = value;
this.setState(state);
}
handleSubmit(event) { handleSubmit(event) {
this.setState({submitting:"block"}); this.setState({ submitting: true });
let config = { const config = {
shell: this.state.shell, shell: this.state.shell,
notice: this.state.notice, notice: this.state.notice,
latency: parseInt(this.state.latency), latency: parseInt(this.state.latency),
@ -96,7 +110,7 @@ class Config extends React.Component {
writer: this.state.writer writer: this.state.writer
}; };
this.file.replace(config).done(() => { this.file.replace(config).done(() => {
this.setState({submitting:"none"}); this.setState({ submitting: false });
}) })
.fail((error) => { .fail((error) => {
console.log(error); console.log(error);
@ -111,12 +125,12 @@ class Config extends React.Component {
var toReturn = {}; var toReturn = {};
for (var i in ob) { for (var i in ob) {
if (!ob.hasOwnProperty(i)) continue; if (!Object.prototype.hasOwnProperty.call(ob, i)) continue;
if ((typeof ob[i]) == 'object') { if ((typeof ob[i]) == 'object') {
var flatObject = flattenObject(ob[i]); var flatObject = flattenObject(ob[i]);
for (var x in flatObject) { for (var x in flatObject) {
if (!flatObject.hasOwnProperty(x)) continue; if (!Object.prototype.hasOwnProperty.call(flatObject, x)) continue;
toReturn[i + '_' + x] = flatObject[x]; toReturn[i + '_' + x] = flatObject[x];
} }
@ -126,13 +140,13 @@ class Config extends React.Component {
} }
return toReturn; return toReturn;
}; };
let state = flattenObject(data); const state = flattenObject(data);
state.config_loaded = true; state.config_loaded = true;
this.setState(state); this.setState(state);
} }
getConfig() { getConfig() {
let proc = cockpit.spawn(["tlog-rec-session", "--configuration"]); const proc = cockpit.spawn(["tlog-rec-session", "--configuration"]);
proc.stream((data) => { proc.stream((data) => {
this.setConfig(json.parse(data, null, true)); this.setConfig(json.parse(data, null, true));
@ -146,15 +160,15 @@ class Config extends React.Component {
} }
readConfig() { readConfig() {
let parseFunc = function(data) { const parseFunc = function(data) {
return json.parse(data, null, true); return json.parse(data, null, true);
}; };
let stringifyFunc = function(data) { const stringifyFunc = function(data) {
return json.stringify(data, null, true); return json.stringify(data, null, true);
}; };
// needed for cockpit.file usage // needed for cockpit.file usage
let syntax_object = { const syntax_object = {
parse: parseFunc, parse: parseFunc,
stringify: stringifyFunc, stringify: stringifyFunc,
}; };
@ -163,17 +177,6 @@ class Config extends React.Component {
syntax: syntax_object, syntax: syntax_object,
superuser: true, superuser: true,
}); });
/*
let promise = this.file.read();
promise.done((data) => {
if (data === null) {
this.fileReadFailed();
}
}).fail((data) => {
this.fileReadFailed(data);
});
*/
} }
fileReadFailed(reason) { fileReadFailed(reason) {
@ -187,160 +190,201 @@ class Config extends React.Component {
} }
render() { render() {
if (this.state.config_loaded === false && this.state.file_error === false) { const form =
return ( (this.state.config_loaded === false && this.state.file_error === false)
<div>Loading</div> ? <Spinner />
: (this.state.config_loaded === true && this.state.file_error === false)
? (
<Form isHorizontal>
<FormGroup label={_("Shell")}>
<TextInput
id="shell"
value={this.state.shell}
onChange={shell => this.setState({ shell })} />
</FormGroup>
<FormGroup label={_("Notice")}>
<TextInput
id="notice"
value={this.state.notice}
onChange={notice => this.setState({ notice })} />
</FormGroup>
<FormGroup label={_("Latency")}>
<TextInput
id="latency"
type="number"
step="1"
value={this.state.latency}
onChange={latency => this.setState({ latency })} />
</FormGroup>
<FormGroup label={_("Payload Size, bytes")}>
<TextInput
id="payload"
type="number"
step="1"
value={this.state.payload}
onChange={payload => this.setState({ payload })} />
</FormGroup>
<FormGroup label={_("Logging")}>
<Checkbox
id="log_input"
isChecked={this.state.log_input}
onChange={log_input => this.setState({ log_input })}
label={_("User's Input")} />
<Checkbox
id="log_output"
isChecked={this.state.log_output}
onChange={log_output => this.setState({ log_output })}
label={_("User's Output")} />
<Checkbox
id="log_window"
isChecked={this.state.log_window}
onChange={log_window => this.setState({ log_window })}
label={_("Window Resize")} />
</FormGroup>
<FormGroup label={_("Limit Rate, bytes/sec")}>
<TextInput
id="limit_rate"
type="number"
step="1"
value={this.state.limit_rate}
onChange={limit_rate => this.setState({ limit_rate })} />
</FormGroup>
<FormGroup label={_("Burst, bytes")}>
<TextInput
id="limit_burst"
type="number"
step="1"
value={this.state.limit_burst}
onChange={limit_burst => this.setState({ limit_burst })} />
</FormGroup>
<FormGroup label={_("Logging Limit Action")}>
<FormSelect
id="limit_action"
value={this.state.limit_action}
onChange={limit_action => this.setState({ limit_action })}>
{[
{ value: "", label: "" },
{ value: "pass", label: _("Pass") },
{ value: "delay", label: _("Delay") },
{ value: "drop", label: _("Drop") }
].map((option, index) =>
<FormSelectOption
key={index}
value={option.value}
label={option.label} />
)}
</FormSelect>
</FormGroup>
<FormGroup label={_("File Path")}>
<TextInput
id="file_path"
value={this.state.file_path}
onChange={file_path => this.setState({ file_path })} />
</FormGroup>
<FormGroup label={_("Syslog Facility")}>
<TextInput
id="syslog_facility"
value={this.state.syslog_facility}
onChange={syslog_facility =>
this.setState({ syslog_facility })} />
</FormGroup>
<FormGroup label={_("Syslog Priority")}>
<FormSelect
id="syslog_priority"
value={this.state.syslog_priority}
onChange={syslog_priority =>
this.setState({ syslog_priority })}>
{[
{ value: "", label: "" },
{ value: "info", label: _("Info") },
].map((option, index) =>
<FormSelectOption
key={index}
value={option.value}
label={option.label} />
)}
</FormSelect>
</FormGroup>
<FormGroup label={_("Journal Priority")}>
<FormSelect
id="journal_priority"
value={this.state.journal_priority}
onChange={journal_priority =>
this.setState({ journal_priority })}>
{[
{ value: "", label: "" },
{ value: "info", label: _("Info") },
].map((option, index) =>
<FormSelectOption
key={index}
value={option.value}
label={option.label} />
)}
</FormSelect>
</FormGroup>
<FormGroup>
<Checkbox
id="journal_augment"
isChecked={this.state.journal_augment}
onChange={journal_augment =>
this.setState({ journal_augment })}
label={_("Augment")} />
</FormGroup>
<FormGroup label={_("Writer")}>
<FormSelect
id="writer"
value={this.state.writer}
onChange={writer =>
this.setState({ writer })}>
{[
{ value: "", label: "" },
{ value: "journal", label: _("Journal") },
{ value: "syslog", label: _("Syslog") },
{ value: "file", label: _("File") },
].map((option, index) =>
<FormSelectOption
key={index}
value={option.value}
label={option.label} />
)}
</FormSelect>
</FormGroup>
<ActionGroup>
<Button
id="btn-save-tlog-conf"
variant="primary"
onClick={this.handleSubmit}>
{_("Save")}
</Button>
{this.state.submitting === true && <Spinner size="lg" />}
</ActionGroup>
</Form>
)
: (
<Bullseye>
<EmptyState variant={EmptyStateVariant.small}>
<EmptyStateIcon
icon={ExclamationCircleIcon}
color={global_danger_color_200.value} />
<Title headingLevel="h4" size="lg">
{_("There is no configuration file of tlog present in your system.")}
</Title>
<Title headingLevel="h4" size="lg">
{_("Please, check the /etc/tlog/tlog-rec-session.conf or if tlog is installed.")}
</Title>
<EmptyStateBody>
{this.state.file_error}
</EmptyStateBody>
</EmptyState>
</Bullseye>
); );
} else if (this.state.config_loaded === true && this.state.file_error === false) {
return ( return (
<form onSubmit={this.handleSubmit}> <Card>
<table className="form-table-ct col-sm-3"> <CardTitle>General Config</CardTitle>
<tbody> <CardBody style={{ maxWidth: "500px" }}>{form}</CardBody>
<tr> </Card>
<td className="top"><label htmlFor="shell" className="control-label">Shell</label></td>
<td>
<input type="text" id="shell" name="shell" value={this.state.shell}
className="form-control" onChange={this.handleInputChange} />
</td>
</tr>
<tr>
<td className="top"><label htmlFor="notice" className="control-label">Notice</label></td>
<td>
<input type="text" id="notice" name="notice" value={this.state.notice}
className="form-control" onChange={this.handleInputChange} />
</td>
</tr>
<tr>
<td className="top"><label htmlFor="latency" className="control-label">Latency</label></td>
<td>
<input type="number" step="1" id="latency" name="latency" value={this.state.latency}
className="form-control" onChange={this.handleInputChange} />
</td>
</tr>
<tr>
<td className="top"><label htmlFor="latency" className="control-label">Payload Size, bytes</label></td>
<td>
<input type="number" step="1" id="payload" name="payload" value={this.state.payload}
className="form-control" onChange={this.handleInputChange} />
</td>
</tr>
<tr>
<td className="top"><label htmlFor="log_input" className="control-label">Log User's Input</label></td>
<td>
<input type="checkbox" id="log_input" name="log_input" defaultChecked={this.state.log_input} onChange={this.handleInputChange} />
</td>
</tr>
<tr>
<td className="top"><label htmlFor="log_output" className="control-label">Log User's Output</label></td>
<td>
<input type="checkbox" id="log_output" name="log_output" defaultChecked={this.state.log_output} onChange={this.handleInputChange} />
</td>
</tr>
<tr>
<td className="top"><label htmlFor="log_window" className="control-label">Log Window Resize</label></td>
<td>
<input type="checkbox" id="log_window" name="log_window" defaultChecked={this.state.log_window} onChange={this.handleInputChange} />
</td>
</tr>
<tr>
<td className="top"><label htmlFor="limit_rate" className="control-label">Limit Rate, bytes/sec</label></td>
<td>
<input type="number" step="1" id="limit_rate" name="limit_rate" value={this.state.limit_rate}
className="form-control" onChange={this.handleInputChange} />
</td>
</tr>
<tr>
<td className="top"><label htmlFor="limit_burst" className="control-label">Burst, bytes</label></td>
<td>
<input type="number" step="1" id="limit_burst" name="limit_burst" value={this.state.limit_burst}
className="form-control" onChange={this.handleInputChange} />
</td>
</tr>
<tr>
<td className="top"><label htmlFor="limit_action" className="control-label">Logging Limit Action</label></td>
<td>
<select name="limit_action" id="limit_action" onChange={this.handleInputChange} value={this.state.limit_action} className="form-control">
<option value="" />
<option value="pass">Pass</option>
<option value="delay">Delay</option>
<option value="drop">Drop</option>
</select>
</td>
</tr>
<tr>
<td className="top"><label htmlFor="file_path" className="control-label">File Path</label></td>
<td>
<input type="text" id="file_path" name="file_path" defaultChecked={this.state.file_path}
className="form-control" onChange={this.handleInputChange} />
</td>
</tr>
<tr>
<td className="top"><label htmlFor="syslog_facility" className="control-label">Syslog Facility</label></td>
<td>
<input type="text" id="syslog_facility" name="syslog_facility" value={this.state.syslog_facility}
className="form-control" onChange={this.handleInputChange} />
</td>
</tr>
<tr>
<td className="top"><label htmlFor="syslog_priority" className="control-label">Syslog Priority</label></td>
<td>
<select name="syslog_priority" id="syslog_priority" onChange={this.handleInputChange} value={this.state.syslog_priority} className="form-control">
<option value="" />
<option value="info">Info</option>
</select>
</td>
</tr>
<tr>
<td className="top"><label htmlFor="journal_priority" className="control-label">Journal Priority</label></td>
<td>
<select name="journal_priority" id="journal_priority" onChange={this.handleInputChange} value={this.state.journal_priority} className="form-control">
<option value="" />
<option value="info">Info</option>
</select>
</td>
</tr>
<tr>
<td className="top"><label htmlFor="journal_augment" className="control-label">Journal Augment</label></td>
<td>
<input type="checkbox" id="journal_augment" name="journal_augment" defaultChecked={this.state.journal_augment} onChange={this.handleInputChange} />
</td>
</tr>
<tr>
<td className="top"><label htmlFor="writer" className="control-label">Writer</label></td>
<td>
<select name="writer" id="writer" onChange={this.handleInputChange} value={this.state.writer} className="form-control">
<option value="" />
<option value="journal">Journal</option>
<option value="syslog">Syslog</option>
<option value="file">File</option>
</select>
</td>
</tr>
<tr>
<td className="top">
<button id="btn-save-tlog-conf" className="btn btn-default" type="submit">Save</button>
</td>
<td>
<span style={{display: this.state.submitting}}>Saving...</span>
</td>
</tr>
</tbody>
</table>
</form>
); );
} else {
return (
<div className="alert alert-danger">
<span className="pficon pficon-error-circle-o" />
<p><strong>There is no configuration file of tlog present in your system.</strong></p>
<p>Please, check the /etc/tlog/tlog-rec-session.conf or if tlog is installed.</p>
<p><strong>{this.state.file_error}</strong></p>
</div>
);
}
} }
} }
@ -348,7 +392,6 @@ class SssdConfig extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
this.handleInputChange = this.handleInputChange.bind(this);
this.setConfig = this.setConfig.bind(this); this.setConfig = this.setConfig.bind(this);
this.confSave = this.confSave.bind(this); this.confSave = this.confSave.bind(this);
this.file = null; this.file = null;
@ -356,16 +399,20 @@ class SssdConfig extends React.Component {
scope: "", scope: "",
users: "", users: "",
groups: "", groups: "",
submitting: "none", submitting: false,
}; };
} }
confSave(obj) { confSave(obj) {
this.setState({submitting:"block"}); this.setState({ submitting: true });
this.file.replace(obj).done(() => { this.file.replace(obj).done(() => {
cockpit.spawn(["chmod", "600", "/etc/sssd/conf.d/sssd-session-recording.conf"], { "superuser": "require" }).done(() => { cockpit.spawn(
cockpit.spawn(["systemctl", "restart", "sssd"], { "superuser": "require" }).done(() => { ["chmod", "600", "/etc/sssd/conf.d/sssd-session-recording.conf"],
this.setState({submitting:"none"}); { superuser: "require" }).done(() => {
cockpit.spawn(
["systemctl", "restart", "sssd"],
{ superuser: "require" }).done(() => {
this.setState({ submitting: false });
}) })
.fail((data) => console.log(data)); .fail((data) => console.log(data));
}) })
@ -373,14 +420,6 @@ class SssdConfig extends React.Component {
}); });
} }
handleInputChange(e) {
const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
const name = e.target.name;
const state = {};
state[name] = value;
this.setState(state);
}
setConfig(data) { setConfig(data) {
if (data === null) { if (data === null) {
const obj = {}; const obj = {};
@ -388,13 +427,13 @@ class SssdConfig extends React.Component {
obj.session_recording.scope = "none"; obj.session_recording.scope = "none";
this.confSave(obj); this.confSave(obj);
} else { } else {
const config = {...data['session_recording']}; const config = { ...data.session_recording };
this.setState(config); this.setState(config);
} }
} }
componentDidMount() { componentDidMount() {
let syntax_object = { const syntax_object = {
parse: ini.parse, parse: ini.parse,
stringify: ini.stringify stringify: ini.stringify
}; };
@ -404,7 +443,7 @@ class SssdConfig extends React.Component {
superuser: true, superuser: true,
}); });
let promise = this.file.read(); const promise = this.file.read();
promise.done(() => this.file.watch(this.setConfig)); promise.done(() => this.file.watch(this.setConfig));
@ -424,95 +463,75 @@ class SssdConfig extends React.Component {
} }
render() { render() {
return ( const form = (
<form onSubmit={this.handleSubmit}> <Form isHorizontal>
<table className="info-table-ct col-md-12"> <FormGroup label="Scope">
<tbody> <FormSelect
<tr> id="scope"
<td><label htmlFor="scope">Scope</label></td>
<td>
<select name="scope" id="scope" className="form-control"
value={this.state.scope} value={this.state.scope}
onChange={this.handleInputChange} > onChange={scope => this.setState({ scope })}>
<option value="none">None</option> {[
<option value="some">Some</option> { value: "none", label: _("None") },
<option value="all">All</option> { value: "some", label: _("Some") },
</select> { value: "all", label: _("All") }
</td> ].map((option, index) =>
</tr> <FormSelectOption
key={index}
value={option.value}
label={option.label} />
)}
</FormSelect>
</FormGroup>
{this.state.scope === "some" && {this.state.scope === "some" &&
<tr> <>
<td><label htmlFor="users">Users</label></td> <FormGroup label={_("Users")}>
<td> <TextInput
<input type="text" id="users" name="users" id="users"
value={this.state.users} value={this.state.users}
className="form-control" onChange={this.handleInputChange} /> onChange={users => this.setState({ users })}
</td> />
</tr> </FormGroup>
} <FormGroup label={_("Groups")}>
{this.state.scope === "some" && <TextInput
<tr> id="groups"
<td><label htmlFor="groups">Groups</label></td>
<td>
<input type="text" id="groups" name="groups"
value={this.state.groups} value={this.state.groups}
className="form-control" onChange={this.handleInputChange} /> onChange={groups => this.setState({ groups })}
</td> />
</tr> </FormGroup>
} </>}
<tr> <ActionGroup>
<td><button id="btn-save-sssd-conf" className="btn btn-default" type="submit">Save</button></td> <Button
<td> id="btn-save-sssd-conf"
<span style={{display: this.state.submitting}}>Saving...</span> variant="primary"
</td> onClick={this.handleSubmit}>
</tr> {_("Save")}
</tbody> </Button>
</table> {this.state.submitting === true && <Spinner size="lg" />}
</form> </ActionGroup>
</Form>
);
return (
<Card>
<CardTitle>SSSD Config</CardTitle>
<CardBody style={{ maxWidth: "500px" }}>{form}</CardBody>
</Card>
); );
} }
} }
class ConfigView extends React.Component { export function Config () {
render() {
const goBack = () => { const goBack = () => {
cockpit.jump(['session-recording']); cockpit.location.go("/");
}; };
return ( return (
<div className="container-fluid"> <>
<div className="row"> <Button variant="link" icon={<AngleLeftIcon />} onClick={goBack}>
<div className="col-md-12"> {_("Session Recording")}
<ol className="breadcrumb"> </Button>
<li><a onClick={goBack}>Session <GeneralConfig />
Recording</a></li>
<li className="active">Configuration</li>
</ol>
</div>
</div>
<div className="row">
<div className="col-md-6">
<div className="panel panel-default">
<div className="panel-heading"><span>General Configuration</span></div>
<div className="panel-body" id="sr_config">
<Config />
</div>
</div>
</div>
</div>
<div className="row">
<div className="col-md-4">
<div className="panel panel-default">
<div className="panel-heading"><span>SSSD Configuration</span></div>
<div className="panel-body" id="sssd_config">
<SssdConfig /> <SssdConfig />
</div> </>
</div>
</div>
</div>
</div>
); );
} }
}
ReactDOM.render(<ConfigView />, document.getElementById('view'));

View file

@ -20,18 +20,20 @@ along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
<html> <html>
<head> <head>
<title translate>Journal</title> <title translateable="yes">Session Recording</title>
<meta charset="utf-8"> <meta charset="utf-8">
<link href="../base1/patternfly.css" rel="stylesheet"> <meta name="description" content="">
<link href="recordings.css" rel="stylesheet"> <meta name="viewport" content="width=device-width, initial-scale=1">
<script type="text/javascript" src="../base1/jquery.js"></script>
<link rel="stylesheet" href="index.css">
<script type="text/javascript" src="../base1/cockpit.js"></script> <script type="text/javascript" src="../base1/cockpit.js"></script>
<script src="../*/po.js"></script> <script type="text/javascript" src="../*/po.js"></script>
<script type="text/javascript" src="index.js"></script>
</head> </head>
<body> <body class="pf-m-redhat-form">
<div id="view"></div> <div id="app"></div>
<script type="text/javascript" src="recordings.js"></script>
</body> </body>
</html> </html>

View file

@ -17,9 +17,21 @@
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>. * along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
*/ */
import "./lib/patternfly-4-cockpit.scss";
import "core-js/stable";
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { Application } from './app.jsx'; import { Application } from './app.jsx';
/*
* PF4 overrides need to come after the JSX components imports because
* these are importing CSS stylesheets that we are overriding
* Having the overrides here will ensure that when mini-css-extract-plugin will extract the CSS
* out of the dist/index.js and since it will maintain the order of the imported CSS,
* the overrides will be correctly in the end of our stylesheet.
*/
import "./lib/patternfly-4-overrides.scss";
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
ReactDOM.render(React.createElement(Application, {}), document.getElementById('app')); ReactDOM.render(React.createElement(Application, {}), document.getElementById('app'));

View file

@ -1,134 +0,0 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
.cockpit-log-panel {
border: 0;
}
.cockpit-log-panel .panel-heading {
background-color: #333;
border-color: #333;
color: #fff;
padding-left: 10px;
padding-top: 5px;
padding-bottom: 5px;
height: auto;
}
.cockpit-log-panel .panel-body {
padding: 0;
border-bottom: 1px #ddd solid;
}
.cockpit-log-panel .panel-body .panel-heading {
border-left: 1px #ddd solid;
border-right: 1px #ddd solid;
border-top: 0;
border-bottom: 1px #ddd solid;
background-color: #f5f5f5;
font-weight: bold;
padding-top: 2px;
padding-bottom: 2px;
width: auto;
color: #333;
}
.cockpit-log-panel > .panel-heading {
margin-top: 15px;
}
.cockpit-log-panel .cockpit-logline {
border-left: 1px #ddd solid;
border-right: 1px #ddd solid;
background-color: #f5f5f5;
padding-top: 2px;
padding-bottom: 2px;
padding-left: 10px;
}
.cockpit-logline {
font-family: monospace;
min-width: 310px;
border-bottom: 1px solid #DDD;
border-top: none;
}
.cockpit-logline > .row > div:first-child {
padding-left: 20px;
}
.cockpit-log-panel .cockpit-logline:hover {
background-color: #d4edfa;
}
.cockpit-log-panel > .cockpit-logline:hover {
cursor: pointer;
}
.cockpit-logmsg-reboot {
font-style: italic;
}
.cockpit-log-warning {
display: inline-block;
width: 20px;
vertical-align: middle;
}
.cockpit-log-warning > i {
color: black;
}
.cockpit-log-time {
display: inline-block;
width: 40px;
vertical-align: middle;
}
.cockpit-log-service {
width: 200px;
margin-left: 10px;
}
.cockpit-log-service-container {
display: inline-block;
width: 200px;
margin-left: 10px;
}
.cockpit-log-service-reduced {
width: -moz-calc(100% - 70px);
width: -webkit-calc(100% - 70px);
width: calc(100% - 70px);
}
.cockpit-log-message {
width: -moz-calc(100% - 300px);
width: -webkit-calc(100% - 300px);
width: calc(100% - 300px);
}
.cockpit-log-message, .cockpit-log-service, .cockpit-log-service-reduced {
text-overflow: ellipsis;
overflow: hidden;
display: inline-block;
white-space: nowrap;
vertical-align: middle;
}

36
src/lib/_fonts.scss Normal file
View file

@ -0,0 +1,36 @@
/*
* Keep in sync with https://github.com/cockpit-project/cockpit/tree/master/src/base1/_fonts.scss
*/
@mixin printRedHatFont(
$weightValue: 400,
$weightName: "Regular",
$familyName: "RedHatText",
$style: "normal",
$relative: true
) {
$filePath: "../../static/fonts" + "/" + $familyName + "-" + $weightName;
@font-face {
font-family: $familyName;
src: url('#{$filePath}.woff2') format('woff2');
font-style: #{$style};
font-weight: $weightValue;
text-rendering: optimizeLegibility;
}
}
@include printRedHatFont(700, "Bold", $familyName: "RedHatDisplay");
@include printRedHatFont(700, "BoldItalic", $style: "italic", $familyName: "RedHatDisplay");
@include printRedHatFont(300, "Black", $familyName: "RedHatDisplay");
@include printRedHatFont(300, "BlackItalic", $style: "italic", $familyName: "RedHatDisplay");
@include printRedHatFont(300, "Italic", $style: "italic", $familyName: "RedHatDisplay");
@include printRedHatFont(400, "Medium", $familyName: "RedHatDisplay");
@include printRedHatFont(400, "MediumItalic", $style: "italic", $familyName: "RedHatDisplay");
@include printRedHatFont(300, "Regular", $familyName: "RedHatDisplay");
@include printRedHatFont(300, "Bold");
@include printRedHatFont(300, "BoldItalic", $style: "italic");
@include printRedHatFont(300, "Italic");
@include printRedHatFont(700, "Medium");
@include printRedHatFont(700, "MediumItalic", $style: "italic");
@include printRedHatFont(400, "Regular");

View file

@ -0,0 +1,14 @@
/*
* Keep in sync with https://github.com/cockpit-project/cockpit/tree/master/src/base1/patternfly-4-cockpit.scss
*/
/* Set fake font and icon path variables - we are going to indentify these through
* string replacement and remove the relevant font-face declarations
*/
$pf-global--font-path: 'patternfly-fonts-fake-path';
$pf-global--fonticon-path: 'patternfly-icons-fake-path';
$pf-global--disable-fontawesome: true !default; // Disable Font Awesome 5 Free
@import '@patternfly/patternfly/patternfly-base.scss';
/* Import our own fonts since the PF4 font-face rules are filtered out with patternfly.sed */
@import "./fonts";

View file

@ -0,0 +1,43 @@
/*
* Keep in sync with https://github.com/cockpit-project/cockpit/tree/master/pkg/lib/patternfly-4-overrides.scss
*/
/*** PF4 overrides ***/
/* WORKAROUND: Override word-break bug */
/* See: https://github.com/patternfly/patternfly-next/issues/2325 */
.pf-c-table td {
word-break: normal;
overflow-wrap: break-word;
}
/* WORKAROUND: Dropdown (PF4): Caret is not properly aligned bug */
/* See: https://github.com/patternfly/patternfly/issues/2715 */
/* Align the icons inside of all dropdown toggles. */
/* Part 1 of 2 */
.pf-c-dropdown__toggle-button {
display: flex;
align-items: center;
}
/* Make split button dropdowns the same height as their sibling. */
/* Part 2 of 2 */
.pf-m-split-button {
align-items: stretch;
}
/* WORKAROUND: Navigation problems with Tertiary Nav widget on mobile */
/* See: https://github.com/patternfly/patternfly-design/issues/840 */
/* Helper mod to wrap pf-c-nav__tertiary */
.ct-m-nav__tertiary-wrap {
flex-wrap: wrap;
.pf-c-nav__scroll-button {
display: none;
}
}
/* Helper mod to center pf-c-nav__tertiary when it wraps */
.ct-m-nav__tertiary-center {
justify-content: center;
}

View file

@ -1,235 +0,0 @@
a {
cursor: pointer;
}
.disabled {
pointer-events: auto;
}
.btn {
min-height: 26px;
min-width: 26px;
}
.btn.disabled {
pointer-events: auto;
}
.btn.disabled:hover {
z-index: auto;
}
a.disabled {
cursor: not-allowed !important;
text-decoration: none;
pointer-events: none;
color: #8b8d8f;
}
a.disabled:hover {
text-decoration: none;
}
.dropdown-menu > li > a.disabled,
.dropdown-menu > li > a.disabled:hover,
.dropdown-menu > li > a.disabled:focus {
color: #999999;
}
.dropdown-menu > li > a.disabled:hover,
.dropdown-menu > li > a.disabled:focus {
text-decoration: none;
background-color: transparent;
background-image: none;
border-color: transparent;
filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
cursor: default;
}
/* Limit dropdown menus to 90% of the viewport size */
.dropdown-menu {
height: auto;
overflow-x: hidden;
max-height: 90vh;
}
/* Align these buttons more nicely */
.btn.fa-minus,
.btn.fa-plus {
padding-top: 4px;
}
/* HACK: Workaround for https://github.com/patternfly/patternfly/issues/174*/
.page-ct {
margin-top: 20px;
}
.highlight-ct {
background-color: #d4edfa;
}
/* Well and Blankslate */
.curtains-ct {
top: 0px;
height: 100%;
width: 100%;
position: fixed;
}
.panel .well {
margin-bottom: 0px;
border: none;
border-radius: 0px;
background-color: #FAFAFA;
}
.well.blank-slate-pf {
box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.05) inset;
padding-top: 40px;
}
.blank-slate-pf .spinner-lg {
height: 58px;
width: 58px;
}
/*
* Control buttons such as play and stop
* Used with .btn .btn-default
*/
.btn-control-ct,
.btn-control-ct:hover {
background-position: center center;
background-size: 16px 16px;
background-repeat: no-repeat;
background-image: none;
-webkit-border-radius: 2;
-moz-border-radius: 2;
border-radius: 2px;
height: 28px;
width: 28px;
box-shadow: none;
}
.btn-control-ct {
background-color: #eeeeee;
}
.btn-control-ct:hover {
background-color: #e0e0e0;
}
/* On/off switch */
.btn-onoff-ct {
margin: 1px 0px;
text-transform: uppercase;
}
.btn-onoff-ct .btn {
color: transparent;
border-color: #B7B7B7;
padding: 2px 6px 1px 6px;
background-color: white;
background-image: linear-gradient(to bottom, rgb(250, 250, 250) 0px, rgb(237, 237, 237) 100%);
-webkit-box-shadow: none;
box-shadow: none;
width: 37px;
}
.btn-onoff-ct .btn:first-child {
border-right: #00435F;
}
.btn-onoff-ct .btn:last-child {
border-left: #00435F;
padding-left: 5px;
}
.btn-onoff-ct .btn.active {
background-image: none;
width: 36px;
}
.btn-onoff-ct .btn.active:first-child {
background-color: #0086CF;
color: white;
border-right: 1px solid #0071b0;
}
.btn-onoff-ct .btn.active:last-child {
color: #000;
border-left: 1px solid #d6d6d6;
}
.btn-onoff-ct .btn.disabled {
pointer-events: none;
color: transparent !important;
}
.btn-onoff-ct .btn.active.disabled {
background-color: #888 !important;
color: white !important;
}
/* Small list inside a dialog */
/* Alert fixups */
/* HACK: word-wrap workaround for long alerts https://github.com/patternfly/patternfly/issues/491 */
.modal-content .alert {
text-align: left;
padding-top: 5px;
padding-bottom: 5px;
word-wrap: break-word;
}
.modal-content .alert .fa {
position: absolute;
left: 10px;
top: 6px;
font-size: 20px;
}
.modal-content .alert .pficon {
top: 5px;
}
.alert.alert-danger .fa {
color: #af151a;
}
/* Dialog patterns */
.dialog-wait-ct {
margin-top: 3px;
}
.dialog-wait-ct .spinner {
display: inline-block;
}
.dialog-wait-ct span {
vertical-align: 4px;
padding-left: 10px;
}
.dialog-list-ct {
max-height: 230px;
overflow-x: auto;
border: 1px solid #CCC;
margin-bottom: 0px;
}
/* HACK: https://github.com/patternfly/patternfly/issues/255 */
input[type=number] {
padding: 0 0 0 5px;
}
/* Make a dialog visible */
.dialog-ct-visible {
display: block;
}

View file

@ -19,7 +19,6 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import './listing.less';
/* entry for an alert in the listing, can be expanded (with details) or standard /* entry for an alert in the listing, can be expanded (with details) or standard
* rowId optional: an identifier for the row which will be set as "data-row-id" attribute on the <tr> * rowId optional: an identifier for the row which will be set as "data-row-id" attribute on the <tr>
@ -73,10 +72,10 @@ export class ListingRow extends React.Component {
if (!e || e.button !== 0) if (!e || e.button !== 0)
return; return;
let willBeExpanded = !this.state.expanded && this.props.tabRenderers.length > 0; const willBeExpanded = !this.state.expanded && this.props.tabRenderers.length > 0;
this.setState({ expanded: willBeExpanded }); this.setState({ expanded: willBeExpanded });
let loadedTabs = {}; const loadedTabs = {};
// unload all tabs if not expanded // unload all tabs if not expanded
if (willBeExpanded) { if (willBeExpanded) {
// see if we should preload some tabs // see if we should preload some tabs
@ -108,7 +107,7 @@ export class ListingRow extends React.Component {
if (!e || e.button !== 0) if (!e || e.button !== 0)
return; return;
let selected = !this.state.selected; const selected = !this.state.selected;
this.setState({ selected: selected }); this.setState({ selected: selected });
if (this.props.selectChanged) if (this.props.selectChanged)
@ -122,9 +121,9 @@ export class ListingRow extends React.Component {
// only consider primary mouse button // only consider primary mouse button
if (!e || e.button !== 0) if (!e || e.button !== 0)
return; return;
let prevTab = this.state.activeTab; const prevTab = this.state.activeTab;
let prevTabPresence = 'default'; let prevTabPresence = 'default';
let loadedTabs = this.state.loadedTabs; const loadedTabs = this.state.loadedTabs;
if (prevTab !== tabIdx) { if (prevTab !== tabIdx) {
// see if we need to unload the previous tab // see if we need to unload the previous tab
if ('presence' in this.props.tabRenderers[prevTab]) if ('presence' in this.props.tabRenderers[prevTab])
@ -142,11 +141,11 @@ export class ListingRow extends React.Component {
} }
render() { render() {
let self = this; const self = this;
// only enable navigation if a function is provided and the row isn't expanded (prevent accidental navigation) // only enable navigation if a function is provided and the row isn't expanded (prevent accidental navigation)
let allowNavigate = !!this.props.navigateToItem && !this.state.expanded; const allowNavigate = !!this.props.navigateToItem && !this.state.expanded;
let headerEntries = this.props.columns.map((itm, index) => { const headerEntries = this.props.columns.map((itm, index) => {
if (typeof itm === 'string' || typeof itm === 'number' || itm === null || itm === undefined || itm instanceof String || React.isValidElement(itm)) if (typeof itm === 'string' || typeof itm === 'number' || itm === null || itm === undefined || itm instanceof String || React.isValidElement(itm))
return (<td key={index}>{itm}</td>); return (<td key={index}>{itm}</td>);
else if ('header' in itm && itm.header) else if ('header' in itm && itm.header)
@ -157,23 +156,21 @@ export class ListingRow extends React.Component {
return (<td key={index}>{itm.name}</td>); return (<td key={index}>{itm.name}</td>);
}); });
let allowExpand = (this.props.tabRenderers.length > 0); const allowExpand = (this.props.tabRenderers.length > 0);
let expandToggle; let expandToggle;
if (allowExpand) { if (allowExpand) {
expandToggle = <td key="expandToggle" className="listing-ct-toggle" onClick={ allowNavigate ? this.handleExpandClick : undefined }> expandToggle = <td key="expandToggle" className="listing-ct-toggle" onClick={ allowNavigate ? this.handleExpandClick : undefined }><i className="fa fa-fw" /></td>;
<i className="fa fa-fw" />
</td>;
} else { } else {
expandToggle = <td key="expandToggle-empty" className="listing-ct-toggle" />; expandToggle = <td key="expandToggle-empty" className="listing-ct-toggle" />;
} }
let listingItemClasses = ["listing-ct-item"]; const listingItemClasses = ["listing-ct-item"];
if (!allowNavigate) if (!allowNavigate)
listingItemClasses.push("listing-ct-nonavigate"); listingItemClasses.push("listing-ct-nonavigate");
if (!allowExpand) if (!allowExpand)
listingItemClasses.push("listing-ct-noexpand"); listingItemClasses.push("listing-ct-noexpand");
let allowSelect = !(allowNavigate || allowExpand) && (this.state.selected !== undefined); const allowSelect = !(allowNavigate || allowExpand) && (this.state.selected !== undefined);
let clickHandler; let clickHandler;
if (allowSelect) { if (allowSelect) {
clickHandler = this.handleSelectClick; clickHandler = this.handleSelectClick;
@ -186,24 +183,26 @@ export class ListingRow extends React.Component {
clickHandler = this.handleExpandClick; clickHandler = this.handleExpandClick;
} }
let listingItem = ( const listingItem = (
<tr data-row-id={ this.props.rowId } <tr
data-row-id={ this.props.rowId }
className={ listingItemClasses.join(' ') } className={ listingItemClasses.join(' ') }
onClick={clickHandler}> onClick={clickHandler}
>
{expandToggle} {expandToggle}
{headerEntries} {headerEntries}
</tr> </tr>
); );
if (this.state.expanded) { if (this.state.expanded) {
let links = this.props.tabRenderers.map((itm, idx) => { const links = this.props.tabRenderers.map((itm, idx) => {
return ( return (
<li key={idx} className={ (idx === self.state.activeTab) ? "active" : ""}> <li key={idx} className={ (idx === self.state.activeTab) ? "active" : ""}>
<a href="#" tabIndex="0" onClick={ self.handleTabClick.bind(self, idx) }>{itm.name}</a> <a href="#" tabIndex="0" onClick={ self.handleTabClick.bind(self, idx) }>{itm.name}</a>
</li> </li>
); );
}); });
let tabs = []; const tabs = [];
let tabIdx; let tabIdx;
let Renderer; let Renderer;
let rendererData; let rendererData;
@ -289,7 +288,7 @@ ListingRow.propTypes = {
* - actions: additional listing-wide actions (displayed next to the list's title) * - actions: additional listing-wide actions (displayed next to the list's title)
*/ */
export const Listing = (props) => { export const Listing = (props) => {
let bodyClasses = ["listing", "listing-ct"]; const bodyClasses = ["listing", "listing-ct"];
if (props.fullWidth) if (props.fullWidth)
bodyClasses.push("listing-ct-wide"); bodyClasses.push("listing-ct-wide");
let headerClasses; let headerClasses;

View file

@ -22,9 +22,9 @@
var cockpit = require("cockpit"); var cockpit = require("cockpit");
var Mustache = require("mustache"); var Mustache = require("mustache");
var day_header_template = require('raw-loader!journal_day_header.mustache'); var day_header_template = require("raw-loader!journal_day_header.mustache");
var line_template = require('raw-loader!journal_line.mustache'); var line_template = require("raw-loader!journal_line.mustache");
var reboot_template = require('raw-loader!journal_reboot.mustache'); var reboot_template = require("raw-loader!journal_reboot.mustache");
var _ = cockpit.gettext; var _ = cockpit.gettext;
var C_ = cockpit.gettext; var C_ = cockpit.gettext;
@ -74,7 +74,9 @@
journal.journalctl = function journalctl(/* ... */) { journal.journalctl = function journalctl(/* ... */) {
var matches = []; var matches = [];
var i, arg, options = { follow: true }; var i;
var arg;
var options = { follow: true };
for (i = 0; i < arguments.length; i++) { for (i = 0; i < arguments.length; i++) {
arg = arguments[i]; arg = arguments[i];
if (typeof arg == "string") { if (typeof arg == "string") {
@ -92,41 +94,26 @@
} }
if (options.count === undefined) { if (options.count === undefined) {
if (options.follow) if (options.follow) options.count = 10;
options.count = 10; else options.count = null;
else
options.count = null;
} }
var cmd = ["journalctl", "--all", "-q", "--output=json"]; var cmd = ["journalctl", "--all", "-q", "--output=json"];
if (!options.count) if (!options.count) cmd.push("--no-tail");
cmd.push("--no-tail"); else cmd.push("--lines=" + options.count);
else if (options.directory) cmd.push("--directory=" + options.directory);
cmd.push("--lines=" + options.count); if (options.boot) cmd.push("--boot=" + options.boot);
if (options.directory) else if (options.boot !== undefined) cmd.push("--boot");
cmd.push("--directory=" + options.directory); if (options.since) cmd.push("--since=" + options.since);
if (options.boot) if (options.until) cmd.push("--until=" + options.until);
cmd.push("--boot=" + options.boot); if (options.cursor) cmd.push("--cursor=" + options.cursor);
else if (options.boot !== undefined) if (options.after) cmd.push("--after=" + options.after);
cmd.push("--boot"); if (options.merge) cmd.push("-m");
if (options.since) if (options.grep) cmd.push("--grep=" + options.grep);
cmd.push("--since=" + options.since);
if (options.until)
cmd.push("--until=" + options.until);
if (options.cursor)
cmd.push("--cursor=" + options.cursor);
if (options.after)
cmd.push("--after=" + options.after);
if (options.merge)
cmd.push("-m");
if (options.grep)
cmd.push("--grep=" + options.grep);
/* journalctl doesn't allow reverse and follow together */ /* journalctl doesn't allow reverse and follow together */
if (options.reverse) if (options.reverse) cmd.push("--reverse");
cmd.push("--reverse"); else if (options.follow) cmd.push("--follow");
else if (options.follow)
cmd.push("--follow");
cmd.push("--"); cmd.push("--");
cmd.push.apply(cmd, matches); cmd.push.apply(cmd, matches);
@ -151,11 +138,15 @@
} }
} }
var proc = cockpit.spawn(cmd, { host: options.host, batch: 8192, latency: 300, superuser: "try" }). var proc = cockpit
stream(function(data) { .spawn(cmd, {
host: options.host,
if (buffer) batch: 8192,
data = buffer + data; latency: 300,
superuser: "try",
})
.stream(function (data) {
if (buffer) data = buffer + data;
buffer = ""; buffer = "";
var lines = data.split("\n"); var lines = data.split("\n");
@ -174,23 +165,22 @@
if (streamers.length && interval === null) if (streamers.length && interval === null)
interval = window.setInterval(fire_streamers, 300); interval = window.setInterval(fire_streamers, 300);
}). })
done(function() { .done(function () {
fire_streamers(); fire_streamers();
dfd.resolve(entries); dfd.resolve(entries);
}). })
fail(function(ex) { .fail(function (ex) {
/* The journalctl command fails when no entries are matched /* The journalctl command fails when no entries are matched
* so we just ignore this status code */ * so we just ignore this status code */
if (ex.problem == "cancelled" || if (ex.problem == "cancelled" || ex.exit_status === 1) {
ex.exit_status === 1) {
fire_streamers(); fire_streamers();
dfd.resolve(entries); dfd.resolve(entries);
} else { } else {
dfd.reject(ex); dfd.reject(ex);
} }
}). })
always(function() { .always(function () {
window.clearInterval(interval); window.clearInterval(interval);
}); });
@ -206,20 +196,16 @@
}; };
journal.printable = function printable(value) { journal.printable = function printable(value) {
if (value === undefined || value === null) if (value === undefined || value === null) return _("[no data]");
return _("[no data]"); else if (typeof value == "string") return value;
else if (typeof(value) == "string")
return value;
else if (value.length !== undefined) else if (value.length !== undefined)
return cockpit.format(_("[$0 bytes of binary data]"), value.length); return cockpit.format(_("[$0 bytes of binary data]"), value.length);
else else return _("[binary data]");
return _("[binary data]");
}; };
function output_funcs_for_box(box) { function output_funcs_for_box(box) {
/* Dereference any jQuery object here */ /* Dereference any jQuery object here */
if (box.jquery) if (box.jquery) box = box[0];
box = box[0];
Mustache.parse(day_header_template); Mustache.parse(day_header_template);
Mustache.parse(line_template); Mustache.parse(line_template);
@ -227,31 +213,28 @@
function render_line(ident, prio, message, count, time, entry) { function render_line(ident, prio, message, count, time, entry) {
var parts = { var parts = {
'cursor': entry["__CURSOR"], cursor: entry.__CURSOR,
'time': time, time: time,
'message': message, message: message,
'service': ident service: ident,
}; };
if (count > 1) if (count > 1) parts.count = count;
parts['count'] = count; if (ident === "abrt-notification") {
if (ident === 'abrt-notification') { parts.problem = true;
parts['problem'] = true; parts.service = entry.PROBLEM_BINARY;
parts['service'] = entry['PROBLEM_BINARY']; } else if (prio < 4) parts.warning = true;
}
else if (prio < 4)
parts['warning'] = true;
return Mustache.render(line_template, parts); return Mustache.render(line_template, parts);
} }
var reboot = _("Reboot"); var reboot = _("Reboot");
var reboot_line = Mustache.render(reboot_template, {'message': reboot} ); var reboot_line = Mustache.render(reboot_template, { message: reboot });
function render_reboot_separator() { function render_reboot_separator() {
return reboot_line; return reboot_line;
} }
function render_day_header(day) { function render_day_header(day) {
return Mustache.render(day_header_template, {'day': day} ); return Mustache.render(day_header_template, { day: day });
} }
function parse_html(string) { function parse_html(string) {
@ -266,42 +249,36 @@
render_reboot_separator: render_reboot_separator, render_reboot_separator: render_reboot_separator,
append: function (elt) { append: function (elt) {
if (typeof (elt) == "string") if (typeof elt == "string") elt = parse_html(elt);
elt = parse_html(elt);
box.appendChild(elt); box.appendChild(elt);
}, },
prepend: function (elt) { prepend: function (elt) {
if (typeof (elt) == "string") if (typeof elt == "string") elt = parse_html(elt);
elt = parse_html(elt); if (box.firstChild) box.insertBefore(elt, box.firstChild);
if (box.firstChild) else box.appendChild(elt);
box.insertBefore(elt, box.firstChild);
else
box.appendChild(elt);
}, },
remove_last: function () { remove_last: function () {
if (box.lastChild) if (box.lastChild) box.removeChild(box.lastChild);
box.removeChild(box.lastChild);
}, },
remove_first: function () { remove_first: function () {
if (box.firstChild) if (box.firstChild) box.removeChild(box.firstChild);
box.removeChild(box.firstChild);
}, },
}; };
} }
var month_names = [ var month_names = [
C_("month-name", 'January'), C_("month-name", "January"),
C_("month-name", 'February'), C_("month-name", "February"),
C_("month-name", 'March'), C_("month-name", "March"),
C_("month-name", 'April'), C_("month-name", "April"),
C_("month-name", 'May'), C_("month-name", "May"),
C_("month-name", 'June'), C_("month-name", "June"),
C_("month-name", 'July'), C_("month-name", "July"),
C_("month-name", 'August'), C_("month-name", "August"),
C_("month-name", 'September'), C_("month-name", "September"),
C_("month-name", 'October'), C_("month-name", "October"),
C_("month-name", 'November'), C_("month-name", "November"),
C_("month-name", 'December') C_("month-name", "December"),
]; ];
/* Render the journal entries by passing suitable HTML strings back to /* Render the journal entries by passing suitable HTML strings back to
@ -369,13 +346,13 @@
journal.renderer = function renderer(funcs_or_box) { journal.renderer = function renderer(funcs_or_box) {
var output_funcs; var output_funcs;
if (funcs_or_box.render_line) if (funcs_or_box.render_line) output_funcs = funcs_or_box;
output_funcs = funcs_or_box; else output_funcs = output_funcs_for_box(funcs_or_box);
else
output_funcs = output_funcs_for_box(funcs_or_box);
function copy_object(o) { function copy_object(o) {
var c = { }; for(var p in o) c[p] = o[p]; return c; var c = {};
for (var p in o) c[p] = o[p];
return c;
} }
// A 'entry' object describes a journal entry in formatted form. // A 'entry' object describes a journal entry in formatted form.
@ -385,31 +362,38 @@
function format_entry(journal_entry) { function format_entry(journal_entry) {
function pad(n) { function pad(n) {
var str = n.toFixed(); var str = n.toFixed();
if(str.length == 1) if (str.length == 1) str = "0" + str;
str = '0' + str;
return str; return str;
} }
var d = new Date(journal_entry["__REALTIME_TIMESTAMP"] / 1000); var d = new Date(journal_entry.__REALTIME_TIMESTAMP / 1000);
return { return {
cursor: journal_entry["__CURSOR"], cursor: journal_entry.__CURSOR,
full: journal_entry, full: journal_entry,
day: month_names[d.getMonth()] + ' ' + d.getDate().toFixed() + ', ' + d.getFullYear().toFixed(), day:
time: pad(d.getHours()) + ':' + pad(d.getMinutes()), month_names[d.getMonth()] +
bootid: journal_entry["_BOOT_ID"], " " +
ident: journal_entry["SYSLOG_IDENTIFIER"] || journal_entry["_COMM"], d.getDate().toFixed() +
prio: journal_entry["PRIORITY"], ", " +
message: journal.printable(journal_entry["MESSAGE"]) d.getFullYear().toFixed(),
time: pad(d.getHours()) + ":" + pad(d.getMinutes()),
bootid: journal_entry._BOOT_ID,
ident: journal_entry.SYSLOG_IDENTIFIER || journal_entry._COMM,
prio: journal_entry.PRIORITY,
message: journal.printable(journal_entry.MESSAGE),
}; };
} }
function entry_is_equal(a, b) { function entry_is_equal(a, b) {
return (a && b && return (
a &&
b &&
a.day == b.day && a.day == b.day &&
a.bootid == b.bootid && a.bootid == b.bootid &&
a.ident == b.ident && a.ident == b.ident &&
a.prio == b.prio && a.prio == b.prio &&
a.message == b.message); a.message == b.message
);
} }
// A state object describes a line that should be eventually // A state object describes a line that should be eventually
@ -428,12 +412,14 @@
// output, followed by the line. // output, followed by the line.
function render_state_line(state) { function render_state_line(state) {
return output_funcs.render_line(state.entry.ident, return output_funcs.render_line(
state.entry.ident,
state.entry.prio, state.entry.prio,
state.entry.message, state.entry.message,
state.count, state.count,
state.last_time, state.last_time,
state.entry.full); state.entry.full
);
} }
// We keep the state of the first and last journal lines, // We keep the state of the first and last journal lines,
@ -483,7 +469,9 @@
if (entry.bootid != top_state.entry.bootid) if (entry.bootid != top_state.entry.bootid)
output_funcs.prepend(output_funcs.render_reboot_separator()); output_funcs.prepend(output_funcs.render_reboot_separator());
if (entry.day != top_state.entry.day) if (entry.day != top_state.entry.day)
output_funcs.prepend(output_funcs.render_day_header(top_state.entry.day)); output_funcs.prepend(
output_funcs.render_day_header(top_state.entry.day)
);
} }
start_new_line(); start_new_line();
@ -497,7 +485,9 @@
function prepend_flush() { function prepend_flush() {
top_output(); top_output();
if (top_state.entry) { if (top_state.entry) {
output_funcs.prepend(output_funcs.render_day_header(top_state.entry.day)); output_funcs.prepend(
output_funcs.render_day_header(top_state.entry.day)
);
top_state.header_present = true; top_state.header_present = true;
} }
} }
@ -541,10 +531,11 @@
bottom_output(); bottom_output();
} }
return { prepend: prepend, return {
prepend: prepend,
prepend_flush: prepend_flush, prepend_flush: prepend_flush,
append: append, append: append,
append_flush: append_flush append_flush: append_flush,
}; };
}; };
@ -554,28 +545,25 @@
function render() { function render() {
var renderer = journal.renderer(box); var renderer = journal.renderer(box);
while(box.firstChild) while (box.firstChild) box.removeChild(box.firstChild);
box.removeChild(box.firstChild);
for (var i = 0; i < entries.length; i++) { for (var i = 0; i < entries.length; i++) {
renderer.prepend(entries[i]); renderer.prepend(entries[i]);
} }
renderer.prepend_flush(); renderer.prepend_flush();
if (entries.length > 0) if (entries.length > 0) box.removeAttribute("hidden");
box.removeAttribute("hidden"); else box.setAttribute("hidden", "hidden");
else
box.setAttribute("hidden", "hidden");
} }
render(); render();
var promise = journal.journalctl(match, { count: max_entries }). var promise = journal
stream(function(tail) { .journalctl(match, { count: max_entries })
.stream(function (tail) {
entries = entries.concat(tail); entries = entries.concat(tail);
if (entries.length > max_entries) if (entries.length > max_entries) entries = entries.slice(-max_entries);
entries = entries.slice(-max_entries);
render(); render();
}). })
fail(function(error) { .fail(function (error) {
box.appendChild(document.createTextNode(error.message)); box.appendChild(document.createTextNode(error.message));
box.removeAttribute("hidden"); box.removeAttribute("hidden");
}); });
@ -585,4 +573,4 @@
}; };
module.exports = journal; module.exports = journal;
}()); })();

View file

@ -37,7 +37,7 @@
#input-textarea { #input-textarea {
width: 100%; width: 100%;
heigth: 100%; height: 100%;
font-family: monospace; font-family: monospace;
resize: none; resize: none;
} }
@ -74,3 +74,15 @@
.session_time { .session_time {
margin-right:5px; margin-right:5px;
} }
.pf-c-progress__indicator:after {
content: "";
position: relative;
width: 20px;
height: 20px;
border-radius: 10px;
background-color: var(--pf-c-progress__indicator--BackgroundColor);
top: -2px;
left: 10px;
float: right;
}

View file

@ -20,16 +20,47 @@
import React from 'react'; import React from 'react';
import './player.css'; import './player.css';
import { Terminal as Term } from 'xterm'; import { Terminal as Term } from 'xterm';
let cockpit = require("cockpit"); import {
let _ = cockpit.gettext; Button,
let moment = require("moment"); Chip,
let Journal = require("journal"); ChipGroup,
let $ = require("jquery"); DataList,
require("bootstrap-slider"); DataListCell,
DataListItem,
DataListItemCells,
DataListItemRow,
ExpandableSection,
InputGroup,
Progress,
TextInput,
Toolbar,
ToolbarContent,
ToolbarItem,
ToolbarGroup,
} from '@patternfly/react-core';
import {
ArrowRightIcon,
ExpandIcon,
PauseIcon,
PlayIcon,
RedoIcon,
SearchMinusIcon,
SearchPlusIcon,
SearchIcon,
MinusIcon,
UndoIcon,
ThumbtackIcon,
MigrationIcon,
} from '@patternfly/react-icons';
const cockpit = require("cockpit");
const _ = cockpit.gettext;
const moment = require("moment");
const Journal = require("journal");
const $ = require("jquery");
let padInt = function (n, w) { const padInt = function (n, w) {
let i = Math.floor(n); const i = Math.floor(n);
let a = Math.abs(i); const a = Math.abs(i);
let s = a.toString(); let s = a.toString();
for (w -= s.length; w > 0; w--) { for (w -= s.length; w > 0; w--) {
s = '0' + s; s = '0' + s;
@ -37,21 +68,21 @@ let padInt = function (n, w) {
return ((i < 0) ? '-' : '') + s; return ((i < 0) ? '-' : '') + s;
}; };
let formatDateTime = function (ms) { const formatDateTime = function (ms) {
return moment(ms).format("YYYY-MM-DD HH:mm:ss"); return moment(ms).format("YYYY-MM-DD HH:mm:ss");
}; };
/* /*
* Format a time interval from a number of milliseconds. * Format a time interval from a number of milliseconds.
*/ */
let formatDuration = function (ms) { const formatDuration = function (ms) {
let v = Math.floor(ms / 1000); let v = Math.floor(ms / 1000);
let s = Math.floor(v % 60); const s = Math.floor(v % 60);
v = Math.floor(v / 60); v = Math.floor(v / 60);
let m = Math.floor(v % 60); const m = Math.floor(v % 60);
v = Math.floor(v / 60); v = Math.floor(v / 60);
let h = Math.floor(v % 24); const h = Math.floor(v % 24);
let d = Math.floor(v / 24); const d = Math.floor(v / 24);
let str = ''; let str = '';
if (d > 0) { if (d > 0) {
@ -67,7 +98,7 @@ let formatDuration = function (ms) {
return (ms < 0 ? '-' : '') + str; return (ms < 0 ? '-' : '') + str;
}; };
let scrollToBottom = function(id) { const scrollToBottom = function(id) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) { if (el) {
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;
@ -82,9 +113,9 @@ function ErrorList(props) {
} }
return ( return (
<React.Fragment> <>
{list} {list}
</React.Fragment> </>
); );
} }
@ -100,7 +131,7 @@ function ErrorItem(props) {
); );
} }
let ErrorService = class { const ErrorService = class {
constructor() { constructor() {
this.addMessage = this.addMessage.bind(this); this.addMessage = this.addMessage.bind(this);
this.errors = []; this.errors = [];
@ -125,7 +156,7 @@ let ErrorService = class {
/* /*
* An auto-loading buffer of recording's packets. * An auto-loading buffer of recording's packets.
*/ */
let PacketBuffer = class { const PacketBuffer = class {
/* /*
* Initialize a buffer. * Initialize a buffer.
*/ */
@ -300,7 +331,7 @@ let PacketBuffer = class {
this.pktList.push(pkt); this.pktList.push(pkt);
/* Notify any matching listeners */ /* Notify any matching listeners */
while (this.idxDfdList.length > 0) { while (this.idxDfdList.length > 0) {
let idxDfd = this.idxDfdList[0]; const idxDfd = this.idxDfdList[0];
if (idxDfd[0] < this.pktList.length) { if (idxDfd[0] < this.pktList.length) {
this.idxDfdList.shift(); this.idxDfdList.shift();
idxDfd[1].resolve(); idxDfd[1].resolve();
@ -363,10 +394,12 @@ let PacketBuffer = class {
break; break;
} }
if (io.length > 0) { if (io.length > 0) {
this.addPacket({pos: this.pos, this.addPacket({
pos: this.pos,
is_io: true, is_io: true,
is_output: is_output, is_output: is_output,
io: io.join()}); io: io.join()
});
io = []; io = [];
} }
this.pos += x; this.pos += x;
@ -379,10 +412,12 @@ let PacketBuffer = class {
break; break;
} }
if (io.length > 0 && is_output) { if (io.length > 0 && is_output) {
this.addPacket({pos: this.pos, this.addPacket({
pos: this.pos,
is_io: true, is_io: true,
is_output: is_output, is_output: is_output,
io: io.join()}); io: io.join()
});
io = []; io = [];
} }
is_output = false; is_output = false;
@ -401,10 +436,12 @@ let PacketBuffer = class {
break; break;
} }
if (io.length > 0 && !is_output) { if (io.length > 0 && !is_output) {
this.addPacket({pos: this.pos, this.addPacket({
pos: this.pos,
is_io: true, is_io: true,
is_output: is_output, is_output: is_output,
io: io.join()}); io: io.join()
});
io = []; io = [];
} }
is_output = true; is_output = true;
@ -423,16 +460,20 @@ let PacketBuffer = class {
break; break;
} }
if (io.length > 0) { if (io.length > 0) {
this.addPacket({pos: this.pos, this.addPacket({
pos: this.pos,
is_io: true, is_io: true,
is_output: is_output, is_output: is_output,
io: io.join()}); io: io.join()
});
io = []; io = [];
} }
this.addPacket({pos: this.pos, this.addPacket({
pos: this.pos,
is_io: false, is_io: false,
width: x, width: x,
height: y}); height: y
});
this.width = x; this.width = x;
this.height = y; this.height = y;
break; break;
@ -447,10 +488,12 @@ let PacketBuffer = class {
} }
if (io.length > 0) { if (io.length > 0) {
this.addPacket({pos: this.pos, this.addPacket({
pos: this.pos,
is_io: true, is_io: true,
is_output: is_output, is_output: is_output,
io: io.join()}); io: io.join()
});
} }
} }
@ -517,7 +560,7 @@ let PacketBuffer = class {
if (!('__CURSOR' in e)) { if (!('__CURSOR' in e)) {
this.handleError("No cursor in a Journal entry"); this.handleError("No cursor in a Journal entry");
} }
this.cursor = e['__CURSOR']; this.cursor = e.__CURSOR;
} }
/* TODO Refer to entry number/cursor in errors */ /* TODO Refer to entry number/cursor in errors */
if (!('MESSAGE' in e)) { if (!('MESSAGE' in e)) {
@ -525,16 +568,16 @@ let PacketBuffer = class {
} }
/* Parse the entry message */ /* Parse the entry message */
try { try {
let utf8decoder = new TextDecoder(); const utf8decoder = new TextDecoder();
/* Journalctl stores fields with non-printable characters /* Journalctl stores fields with non-printable characters
* in an array of raw bytes formatted as unsigned * in an array of raw bytes formatted as unsigned
* integers */ * integers */
if (Array.isArray(e['MESSAGE'])) { if (Array.isArray(e.MESSAGE)) {
let u8arr = new Uint8Array(e['MESSAGE']); const u8arr = new Uint8Array(e.MESSAGE);
this.parseMessage(JSON.parse(utf8decoder.decode(u8arr))); this.parseMessage(JSON.parse(utf8decoder.decode(u8arr)));
} else { } else {
this.parseMessage(JSON.parse(e['MESSAGE'])); this.parseMessage(JSON.parse(e.MESSAGE));
} }
} catch (error) { } catch (error) {
this.handleError(error); this.handleError(error);
@ -555,8 +598,10 @@ let PacketBuffer = class {
/* Continue with the "following" run */ /* Continue with the "following" run */
this.journalctl = Journal.journalctl( this.journalctl = Journal.journalctl(
this.matchList, this.matchList,
{cursor: this.cursor, {
follow: true, merge: true, count: "all"}); cursor: this.cursor,
follow: true, merge: true, count: "all"
});
this.journalctl.fail(this.handleError); this.journalctl.fail(this.handleError);
this.journalctl.stream(this.handleStream); this.journalctl.stream(this.handleStream);
/* NOTE: no "done" handler on purpose */ /* NOTE: no "done" handler on purpose */
@ -582,11 +627,9 @@ class Search extends React.Component {
}; };
} }
handleInputChange(event) { handleInputChange(name, value) {
event.preventDefault(); event.preventDefault();
const name = event.target.name; const state = {};
const value = event.target.value;
let state = {};
state[name] = value; state[name] = value;
this.setState(state); this.setState(state);
cockpit.location.go(cockpit.location.path[0], $.extend(cockpit.location.options, { search_rec: value })); cockpit.location.go(cockpit.location.path[0], $.extend(cockpit.location.options, { search_rec: value }));
@ -605,7 +648,13 @@ class Search extends React.Component {
return JSON.parse(item.MESSAGE); return JSON.parse(item.MESSAGE);
}); });
items = items.map(item => { items = items.map(item => {
return <SearchEntry key={item.id} fastForwardToTS={this.props.fastForwardToTS} pos={item.pos} />; return (
<SearchEntry
key={item.id}
fastForwardToTS={this.props.fastForwardToTS}
pos={item.pos}
/>
);
}); });
this.setState({ items: items }); this.setState({ items: items });
} }
@ -629,18 +678,28 @@ class Search extends React.Component {
render() { render() {
return ( return (
<div className="search-wrap"> <ToolbarItem>
<div className="input-group search-component"> <InputGroup>
<input type="text" className="form-control" name="search" value={this.state.search} onChange={this.handleInputChange} /> <TextInput
<span className="input-group-btn"> id="search_rec"
<button className="btn btn-default" onClick={this.handleSearchSubmit}><span className="glyphicon glyphicon-search" /></button> type="search"
<button className="btn btn-default" onClick={this.clearSearchResults}><span className="glyphicon glyphicon-remove" /></button> value={this.state.search}
</span> onChange={value => this.handleInputChange("search", value)} />
</div> <Button
<div className="search-results"> variant="control"
onClick={this.handleSearchSubmit}>
<SearchIcon />
</Button>
<Button
variant="control"
onClick={this.clearSearchResults}>
<MinusIcon />
</Button>
</InputGroup>
<ToolbarItem>
{this.state.items} {this.state.items}
</div> </ToolbarItem>
</div> </ToolbarItem>
); );
} }
} }
@ -655,12 +714,6 @@ class InputPlayer extends React.Component {
} }
} }
function Slider(props) {
return (
<input id="slider" type="text" />
);
}
export class Player extends React.Component { export class Player extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
@ -677,9 +730,6 @@ export class Player extends React.Component {
this.speedReset = this.speedReset.bind(this); this.speedReset = this.speedReset.bind(this);
this.fastForwardToEnd = this.fastForwardToEnd.bind(this); this.fastForwardToEnd = this.fastForwardToEnd.bind(this);
this.skipFrame = this.skipFrame.bind(this); this.skipFrame = this.skipFrame.bind(this);
this.initSlider = this.initSlider.bind(this);
this.slideStart = this.slideStart.bind(this);
this.slideStop = this.slideStop.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this);
this.sync = this.sync.bind(this); this.sync = this.sync.bind(this);
this.zoomIn = this.zoomIn.bind(this); this.zoomIn = this.zoomIn.bind(this);
@ -692,6 +742,8 @@ export class Player extends React.Component {
this.fastForwardToTS = this.fastForwardToTS.bind(this); this.fastForwardToTS = this.fastForwardToTS.bind(this);
this.sendInput = this.sendInput.bind(this); this.sendInput = this.sendInput.bind(this);
this.clearInputPlayer = this.clearInputPlayer.bind(this); this.clearInputPlayer = this.clearInputPlayer.bind(this);
this.handleInfoClick = this.handleInfoClick.bind(this);
this.handleProgressClick = this.handleProgressClick.bind(this);
this.state = { this.state = {
cols: 80, cols: 80,
@ -722,6 +774,8 @@ export class Player extends React.Component {
scale: 1, scale: 1,
input: "", input: "",
mark: 0, mark: 0,
infoEnabled: false,
curTS: 0,
}; };
this.containerHeight = 400; this.containerHeight = 400;
@ -731,9 +785,6 @@ export class Player extends React.Component {
this.reportError = this.error_service.addMessage; this.reportError = this.error_service.addMessage;
this.buf = new PacketBuffer(this.props.matchList, this.reportError); this.buf = new PacketBuffer(this.props.matchList, this.reportError);
/* Slider component */
this.slider = null;
/* Current recording time, ms */ /* Current recording time, ms */
this.recTS = 0; this.recTS = 0;
/* Corresponding local time, ms */ /* Corresponding local time, ms */
@ -746,8 +797,6 @@ export class Player extends React.Component {
/* Timeout ID of the current packet, null if none */ /* Timeout ID of the current packet, null if none */
this.timeout = null; this.timeout = null;
this.currentTsPost = 0;
/* True if the next packet should be output without delay */ /* True if the next packet should be output without delay */
this.skip = false; this.skip = false;
/* Playback speed */ /* Playback speed */
@ -757,9 +806,6 @@ export class Player extends React.Component {
* Recording time, ms, or null if not fast-forwarding. * Recording time, ms, or null if not fast-forwarding.
*/ */
this.fastForwardTo = null; this.fastForwardTo = null;
/* Track paused state prior to slider movement */
this.pausedBeforeSlide = true;
} }
reset() { reset() {
@ -781,7 +827,6 @@ export class Player extends React.Component {
/* Move to beginning of recording */ /* Move to beginning of recording */
this.recTS = 0; this.recTS = 0;
this.currentTsPost = parseInt(this.recTS);
/* Start the playback time */ /* Start the playback time */
this.locTS = performance.now(); this.locTS = performance.now();
@ -866,7 +911,7 @@ export class Player extends React.Component {
for (;;) { for (;;) {
/* Get another packet to output, if none */ /* Get another packet to output, if none */
for (; this.pkt === null; this.pktIdx++) { for (; this.pkt === null; this.pktIdx++) {
let pkt = this.buf.pktList[this.pktIdx]; const pkt = this.buf.pktList[this.pktIdx];
/* If there are no more packets */ /* If there are no more packets */
if (pkt === undefined) { if (pkt === undefined) {
/* /*
@ -886,7 +931,7 @@ export class Player extends React.Component {
} }
/* Get the current local time */ /* Get the current local time */
let nowLocTS = performance.now(); const nowLocTS = performance.now();
/* Ignore the passed time, if we're paused */ /* Ignore the passed time, if we're paused */
if (this.state.paused) { if (this.state.paused) {
@ -898,7 +943,6 @@ export class Player extends React.Component {
/* Sync to the local time */ /* Sync to the local time */
this.locTS = nowLocTS; this.locTS = nowLocTS;
this.slider.slider('setAttribute', 'max', this.buf.pos);
/* If we are skipping one packet's delay */ /* If we are skipping one packet's delay */
if (this.skip) { if (this.skip) {
this.skip = false; this.skip = false;
@ -918,10 +962,8 @@ export class Player extends React.Component {
return; return;
} else { } else {
this.recTS += locDelay * this.speed; this.recTS += locDelay * this.speed;
let pktRecDelay = this.pkt.pos - this.recTS; const pktRecDelay = this.pkt.pos - this.recTS;
let pktLocDelay = pktRecDelay / this.speed; const pktLocDelay = pktRecDelay / this.speed;
this.currentTsPost = parseInt(this.recTS);
this.slider.slider('setValue', this.currentTsPost);
/* If we're more than 5 ms early for this packet */ /* If we're more than 5 ms early for this packet */
if (pktLocDelay > 5) { if (pktLocDelay > 5) {
/* Call us again on time, later */ /* Call us again on time, later */
@ -934,8 +976,7 @@ export class Player extends React.Component {
if (this.props.logsEnabled) { if (this.props.logsEnabled) {
this.props.onTsChange(this.pkt.pos); this.props.onTsChange(this.pkt.pos);
} }
this.currentTsPost = parseInt(this.pkt.pos); this.setState({ curTS: this.pkt.pos });
this.slider.slider('setValue', this.currentTsPost);
/* Output the packet */ /* Output the packet */
if (this.pkt.is_io && !this.pkt.is_output) { if (this.pkt.is_io && !this.pkt.is_output) {
@ -967,14 +1008,14 @@ export class Player extends React.Component {
} }
speedUp() { speedUp() {
let speedExp = this.state.speedExp; const speedExp = this.state.speedExp;
if (speedExp < 4) { if (speedExp < 4) {
this.setState({ speedExp: speedExp + 1 }); this.setState({ speedExp: speedExp + 1 });
} }
} }
speedDown() { speedDown() {
let speedExp = this.state.speedExp; const speedExp = this.state.speedExp;
if (speedExp > -4) { if (speedExp > -4) {
this.setState({ speedExp: speedExp - 1 }); this.setState({ speedExp: speedExp - 1 });
} }
@ -1015,49 +1056,19 @@ export class Player extends React.Component {
this.sync(); this.sync();
} }
initSlider() {
this.slider = $("#slider").slider({
value: 0,
tooltip: "hide",
enabled: false,
});
this.slider.slider('on', 'slideStart', this.slideStart);
this.slider.slider('on', 'slideStop', this.slideStop);
this.slider.slider('enable');
}
slideStart(e) {
/*
* Necessary because moving the slider position updates state.paused,
* which won't represent the actual paused state after this event is
* triggered
*/
this.pausedBeforeSlide = this.state.paused;
this.pause();
}
slideStop(e) {
if (this.fastForwardToTS) {
this.fastForwardToTS(e);
if (this.pausedBeforeSlide === false) {
this.play();
}
}
}
handleKeyDown(event) { handleKeyDown(event) {
let keyCodesFuncs = { const keyCodesFuncs = {
"P": this.playPauseToggle, P: this.playPauseToggle,
"}": this.speedUp, "}": this.speedUp,
"{": this.speedDown, "{": this.speedDown,
"Backspace": this.speedReset, Backspace: this.speedReset,
".": this.skipFrame, ".": this.skipFrame,
"G": this.fastForwardToEnd, G: this.fastForwardToEnd,
"R": this.rewindToStart, R: this.rewindToStart,
"+": this.zoomIn, "+": this.zoomIn,
"=": this.zoomIn, "=": this.zoomIn,
"-": this.zoomOut, "-": this.zoomOut,
"Z": this.fitIn, Z: this.fitIn,
}; };
if (event.target.nodeName.toLowerCase() !== 'input') { if (event.target.nodeName.toLowerCase() !== 'input') {
if (keyCodesFuncs[event.key]) { if (keyCodesFuncs[event.key]) {
@ -1090,28 +1101,28 @@ export class Player extends React.Component {
dragPanEnable() { dragPanEnable() {
this.setState({ drag_pan: true }); this.setState({ drag_pan: true });
let scrollwrap = this.refs.scrollwrap; const scrollwrap = this.refs.scrollwrap;
let clicked = false; let clicked = false;
let clickX; let clickX;
let clickY; let clickY;
$(this.refs.scrollwrap).on({ $(this.refs.scrollwrap).on({
'mousemove': function(e) { mousemove: function(e) {
clicked && updateScrollPos(e); clicked && updateScrollPos(e);
}, },
'mousedown': function(e) { mousedown: function(e) {
clicked = true; clicked = true;
clickY = e.pageY; clickY = e.pageY;
clickX = e.pageX; clickX = e.pageX;
}, },
'mouseup': function() { mouseup: function() {
clicked = false; clicked = false;
$('html').css('cursor', 'auto'); $('html').css('cursor', 'auto');
} }
}); });
let updateScrollPos = function(e) { const updateScrollPos = function(e) {
$('html').css('cursor', 'move'); $('html').css('cursor', 'move');
$(scrollwrap).scrollTop($(scrollwrap).scrollTop() + (clickY - e.pageY)); $(scrollwrap).scrollTop($(scrollwrap).scrollTop() + (clickY - e.pageY));
$(scrollwrap).scrollLeft($(scrollwrap).scrollLeft() + (clickX - e.pageX)); $(scrollwrap).scrollLeft($(scrollwrap).scrollLeft() + (clickX - e.pageX));
@ -1120,7 +1131,7 @@ export class Player extends React.Component {
dragPanDisable() { dragPanDisable() {
this.setState({ drag_pan: false }); this.setState({ drag_pan: false });
let scrollwrap = this.refs.scrollwrap; const scrollwrap = this.refs.scrollwrap;
$(scrollwrap).off("mousemove"); $(scrollwrap).off("mousemove");
$(scrollwrap).off("mousedown"); $(scrollwrap).off("mousedown");
$(scrollwrap).off("mouseup"); $(scrollwrap).off("mouseup");
@ -1171,7 +1182,6 @@ export class Player extends React.Component {
/* Reset playback */ /* Reset playback */
this.reset(); this.reset();
this.fastForwardToTS(0); this.fastForwardToTS(0);
this.initSlider();
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
@ -1189,11 +1199,21 @@ export class Player extends React.Component {
} }
} }
render() { handleInfoClick() {
let r = this.props.recording; this.setState({ infoEnabled: !this.state.infoEnabled });
}
let speedExp = this.state.speedExp; handleProgressClick(e) {
let speedFactor = Math.pow(2, Math.abs(speedExp)); const progress = Math.min(1, Math.max(0, e.clientX / $(".pf-c-progress__bar").width()));
const ts = Math.round(progress * this.buf.pos);
this.fastForwardToTS(ts);
}
render() {
const r = this.props.recording;
const speedExp = this.state.speedExp;
const speedFactor = Math.pow(2, Math.abs(speedExp));
let speedStr; let speedStr;
if (speedExp > 0) { if (speedExp > 0) {
@ -1205,156 +1225,246 @@ export class Player extends React.Component {
} }
const style = { const style = {
"transform": "scale(" + this.state.scale + ") translate(" + this.state.term_translate + ")", transform: "scale(" + this.state.scale + ") translate(" + this.state.term_translate + ")",
"transformOrigin": "top left", transformOrigin: "top left",
"display": "inline-block", display: "inline-block",
"margin": "0 auto", margin: "0 auto",
"position": "absolute", position: "absolute",
"top": this.state.term_top_style, top: this.state.term_top_style,
"left": this.state.term_left_style, left: this.state.term_left_style,
}; };
const scrollwrap = { const scrollwrap = {
"minWidth": "630px", minWidth: "630px",
"height": this.containerHeight + "px", height: this.containerHeight + "px",
"backgroundColor": "#f5f5f5", backgroundColor: "#f5f5f5",
"overflow": this.state.term_scroll, overflow: this.state.term_scroll,
"position": "relative", position: "relative",
}; };
const to_right = { const timeStr = formatDuration(this.state.curTS) +
"float": "right", " / " +
}; formatDuration(this.buf.pos);
const progress = (
<Progress
min={0}
max={this.buf.pos}
valueText={timeStr}
label={timeStr}
value={this.state.curTS}
onClick={this.handleProgressClick} />
);
const playbackControls = (
<ToolbarGroup variant="icon-button-group">
<ToolbarItem>
<Button
variant="plain"
id="player-play-pause"
title="Play/Pause - Hotkey: p"
type="button"
onClick={this.playPauseToggle}
>
{this.state.paused ? <PlayIcon /> : <PauseIcon />}
</Button>
</ToolbarItem>
<ToolbarItem>
<Button
variant="plain"
id="player-skip-frame"
title="Skip Frame - Hotkey: ."
type="button"
onClick={this.skipFrame}
>
<ArrowRightIcon />
</Button>
</ToolbarItem>
<ToolbarItem>
<Button
variant="plain"
id="player-restart"
title="Restart Playback - Hotkey: Shift-R"
type="button"
onClick={this.rewindToStart}
>
<UndoIcon />
</Button>
</ToolbarItem>
<ToolbarItem>
<Button
variant="plain"
id="player-fast-forward"
title="Fast-forward to end - Hotkey: Shift-G"
type="button"
onClick={this.fastForwardToEnd}
>
<RedoIcon />
</Button>
</ToolbarItem>
<ToolbarItem>
<Button
variant="plain"
id="player-speed-down"
title="Speed /2 - Hotkey: {"
type="button"
onClick={this.speedDown}
>
/2
</Button>
</ToolbarItem>
<ToolbarItem>
<Button
variant="plain"
id="player-speed-up"
title="Speed x2 - Hotkey: }"
type="button"
onClick={this.speedUp}
>
x2
</Button>
</ToolbarItem>
{speedStr !== "" &&
<ToolbarItem>
<ChipGroup categoryName="speed">
<Chip onClick={this.speedReset}>
<span id="player-speed">{speedStr}</span>
</Chip>
</ChipGroup>
</ToolbarItem>}
</ToolbarGroup>
);
const visualControls = (
<ToolbarGroup variant="icon-button-group" alignment={{ default: 'alignRight' }}>
<ToolbarItem>
<Button
variant="plain"
id="player-drag-pan"
title="Drag'n'Pan"
onClick={this.dragPan}
>
{this.state.drag_pan ? <ThumbtackIcon /> : <MigrationIcon />}
</Button>
</ToolbarItem>
<ToolbarItem>
<Button
variant="plain"
id="player-zoom-in"
title="Zoom In - Hotkey: ="
type="button"
onClick={this.zoomIn}
disabled={this.state.term_zoom_max}
>
<SearchPlusIcon />
</Button>
</ToolbarItem>
<ToolbarItem>
<Button
variant="plain"
id="player-fit-to"
title="Fit To - Hotkey: Z"
type="button"
onClick={this.fitTo}
>
<ExpandIcon />
</Button>
</ToolbarItem>
<ToolbarItem>
<Button
variant="plain"
id="player-zoom-out"
title="Zoom Out - Hotkey: -"
type="button"
onClick={this.zoomOut}
disabled={this.state.term_zoom_min}
>
<SearchMinusIcon />
</Button>
</ToolbarItem>
</ToolbarGroup>
);
const panel = (
<Toolbar>
<ToolbarContent>
{playbackControls}
{visualControls}
<InputPlayer input={this.state.input} />
<Search
matchList={this.props.matchList}
fastForwardToTS={this.fastForwardToTS}
play={this.play}
pause={this.pause}
paused={this.state.paused}
errorService={this.error_service} />
<ErrorList list={this.error_service.errors} />
</ToolbarContent>
</Toolbar>
);
const recordingInfo = (
<DataList isCompact>
{
[
{ name: _("ID"), value: r.id },
{ name: _("Hostname"), value: r.hostname },
{ name: _("Boot ID"), value: r.boot_id },
{ name: _("Session ID"), value: r.session_id },
{ name: _("PID"), value: r.pid },
{ name: _("Start"), value: formatDateTime(r.start) },
{ name: _("End"), value: formatDateTime(r.end) },
{ name: _("Duration"), value: formatDuration(r.end - r.start) },
{ name: _("User"), value: r.user }
].map((item, index) =>
<DataListItem key={index}>
<DataListItemRow>
<DataListItemCells
dataListCells={[
<DataListCell key="name">{item.name}</DataListCell>,
<DataListCell key="value">{item.value}</DataListCell>
]} />
</DataListItemRow>
</DataListItem>
)
}
</DataList>
);
const infoSection = (
<ExpandableSection
id="btn-recording-info"
toggleText={_("Recording Info")}
onToggle={this.handleInfoClick}
isExpanded={this.state.infoEnabled === true}>
{recordingInfo}
</ExpandableSection>
);
// ensure react never reuses this div by keying it with the terminal widget // ensure react never reuses this div by keying it with the terminal widget
return ( return (
<React.Fragment> <>
<div className="row">
<div id="recording-wrap">
<div className="col-md-7 player-wrap">
<div ref="wrapper" className="panel panel-default"> <div ref="wrapper" className="panel panel-default">
<div className="panel-heading"> <div className="panel-heading">
<span>{this.state.title}</span> <span>{this.state.title}</span>
</div> </div>
<div className="panel-body"> <div className="panel-body">
<div className={(this.state.drag_pan ? "dragnpan" : "")} style={scrollwrap} ref="scrollwrap"> <div
<div ref="term" className="console-ct" key={this.state.term} style={style} /> className={(this.state.drag_pan ? "dragnpan" : "")}
style={scrollwrap}
ref="scrollwrap">
<div
ref="term"
className="console-ct"
key={this.state.term}
style={style} />
</div> </div>
</div> </div>
<div className="panel-footer"> {progress}
<Slider /> {panel}
<button id="player-play-pause" title="Play/Pause - Hotkey: p" type="button" ref="playbtn"
className="btn btn-default btn-lg margin-right-btn play-btn"
onClick={this.playPauseToggle}>
<i className={"fa fa-" + (this.state.paused ? "play" : "pause")}
aria-hidden="true" />
</button>
<button id="player-skip-frame" title="Skip Frame - Hotkey: ." type="button"
className="btn btn-default btn-lg margin-right-btn"
onClick={this.skipFrame}>
<i className="fa fa-step-forward" aria-hidden="true" />
</button>
<button id="player-restart" title="Restart Playback - Hotkey: Shift-R" type="button"
className="btn btn-default btn-lg" onClick={this.rewindToStart}>
<i className="fa fa-fast-backward" aria-hidden="true" />
</button>
<button id="player-fast-forward" title="Fast-forward to end - Hotkey: Shift-G" type="button"
className="btn btn-default btn-lg margin-right-btn"
onClick={this.fastForwardToEnd}>
<i className="fa fa-fast-forward" aria-hidden="true" />
</button>
<button id="player-speed-down" title="Speed /2 - Hotkey: {" type="button"
className="btn btn-default btn-lg" onClick={this.speedDown}>
/2
</button>
<button id="player-speed-reset" title="Reset Speed - Hotkey: Backspace" type="button"
className="btn btn-default btn-lg" onClick={this.speedReset}>
1:1
</button>
<button id="player-speed-up" title="Speed x2 - Hotkey: }" type="button"
className="btn btn-default btn-lg margin-right-btn"
onClick={this.speedUp}>
x2
</button>
<span id="player-speed">{speedStr}</span>
<span style={to_right}>
<span className="session_time">{formatDuration(this.currentTsPost)} / {formatDuration(this.buf.pos)}</span>
<button id="player-drag-pan" title="Drag'n'Pan" type="button" className="btn btn-default btn-lg"
onClick={this.dragPan}>
<i className={"fa fa-" + (this.state.drag_pan ? "hand-rock-o" : "hand-paper-o")}
aria-hidden="true" /></button>
<button id="player-zoom-in" title="Zoom In - Hotkey: =" type="button" className="btn btn-default btn-lg"
onClick={this.zoomIn} disabled={this.state.term_zoom_max}>
<i className="fa fa-search-plus" aria-hidden="true" /></button>
<button id="player-fit-to" title="Fit To - Hotkey: Z" type="button" className="btn btn-default btn-lg"
onClick={this.fitTo}><i className="fa fa-expand" aria-hidden="true" /></button>
<button id="player-zoom-out" title="Zoom Out - Hotkey: -" type="button" className="btn btn-default btn-lg"
onClick={this.zoomOut} disabled={this.state.term_zoom_min}>
<i className="fa fa-search-minus" aria-hidden="true" /></button>
</span>
<div id="input-player-wrap">
<InputPlayer input={this.state.input} />
</div> </div>
<div> {infoSection}
<Search matchList={this.props.matchList} fastForwardToTS={this.fastForwardToTS} play={this.play} pause={this.pause} paused={this.state.paused} errorService={this.error_service} /> </>
</div>
<div className="clearfix" />
<ErrorList list={this.error_service.errors} />
</div>
</div>
</div>
</div>
<div className="col-md-5">
<div className="panel panel-default">
<div className="panel-heading">
<span>{_("Recording")}</span>
</div>
<div className="panel-body">
<table className="form-table-ct">
<tbody>
<tr>
<td>{_("ID")}</td>
<td>{r.id}</td>
</tr>
<tr>
<td>{_("Hostname")}</td>
<td>{r.hostname}</td>
</tr>
<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>
</tbody>
</table>
</div>
</div>
</div>
</div>
</React.Fragment>
); );
} }

View file

@ -1,40 +0,0 @@
.plot-unit {
display: inline-block;
width: 28px;
font-size: smaller;
text-align: right;
color: #545454;
margin-right: 7px;
}
.plot-title {
color: black;
}
.flot-y-axis .flot-tick-label {
width: 28px;
margin-right: 7px;
}
.flot-x-axis .flot-tick-label {
margin-top: 3px;
}
.zoom-controls {
visibility: hidden;
}
.show-zoom-controls .zoom-controls {
visibility: visible;
}
.show-zoom-cursor .zoomable-plot {
cursor: ew-resize;
}
.standard-zoom-controls {
text-align: right; /* on the right */
margin-bottom: -15px; /* overlapping with the title */
z-index: 1; /* but on top of it */
position: relative;
}

View file

@ -1,373 +0,0 @@
@import "/page.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%;
z-index: 900;
top: 0;
}
.content-header-extra .btn-group:not(:first-child) {
padding-left: 20px;
}
/* Override cockpit */
.form-table-ct {
width: 80%;
}
.form-table-ct td.top {
color: #151515;
text-align: left;
}
/* Align table caption/title */
table.listing.listing-ct > caption {
padding: 5px 10px 10px !important;
font-weight: 300;
margin-top: 0;
}
#motd {
background-color: transparent;
border: none;
font-size: 14px;
padding: 0px;
margin: 0px;
white-space: pre-wrap;
}
.listing > tbody > tr {
cursor: pointer;
}
.margin-right-btn {
margin-right: 10px;
}
.play-btn {
min-width: 34px;
}
.sort {
cursor: pointer;
}
.sort span {
text-decoration: underline;
}
.sort-icon {
width:7px;
display:inline-block;
}
/* Align table title */
table.listing-ct -> caption {
padding-left: 10px !important;
}
table.listing-ct > thead th:last-child, tr.listing-ct-item td:last-child {
text-align: left !important;
}
.date {
width: 185px;
}
.invalid {
background-color: #ffdddd;
}
.valid {
background-color: #ffffff;
}
.highlighted {
background-color: #ededed !important;
}

View file

@ -19,26 +19,61 @@
"use strict"; "use strict";
import React from "react"; import React from "react";
import ReactDOM from "react-dom"; import {
Bullseye,
Button,
Card,
CardBody,
DataList,
DataListCell,
DataListItem,
DataListItemCells,
DataListItemRow,
EmptyState,
EmptyStateBody,
EmptyStateIcon,
EmptyStateVariant,
ExpandableSection,
Spinner,
Title,
TextInput,
Toolbar,
ToolbarContent,
ToolbarItem,
ToolbarGroup,
} from "@patternfly/react-core";
import {
sortable,
SortByDirection,
Table,
TableHeader,
TableBody
} from "@patternfly/react-table";
import {
AngleLeftIcon,
CogIcon,
ExclamationCircleIcon,
ExclamationTriangleIcon,
PlusIcon,
SearchIcon
} from "@patternfly/react-icons";
import { global_danger_color_200 } from "@patternfly/react-tokens";
let $ = require("jquery"); const $ = require("jquery");
let cockpit = require("cockpit"); const cockpit = require("cockpit");
let _ = cockpit.gettext; const _ = cockpit.gettext;
let moment = require("moment"); const moment = require("moment");
let Journal = require("journal"); const Journal = require("journal");
let Listing = require("cockpit-components-listing.jsx"); const Player = require("./player.jsx");
let Player = require("./player.jsx"); const Config = require("./config.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 * Convert a number to integer number string and pad with zeroes to
* specified width. * specified width.
*/ */
let padInt = function (n, w) { const padInt = function (n, w) {
let i = Math.floor(n); const i = Math.floor(n);
let a = Math.abs(i); const a = Math.abs(i);
let s = a.toString(); let s = a.toString();
for (w -= s.length; w > 0; w--) { for (w -= s.length; w > 0; w--) {
s = '0' + s; s = '0' + s;
@ -49,16 +84,16 @@ let padInt = function (n, w) {
/* /*
* Format date and time for a number of milliseconds since Epoch. * Format date and time for a number of milliseconds since Epoch.
*/ */
let formatDateTime = function (ms) { const formatDateTime = function (ms) {
return moment(ms).format("YYYY-MM-DD HH:mm:ss"); return moment(ms).format("YYYY-MM-DD HH:mm:ss");
}; };
let formatDateTimeOffset = function (ms, offset) { const formatDateTimeOffset = function (ms, offset) {
return moment(ms).utcOffset(offset) return moment(ms).utcOffset(offset)
.format("YYYY-MM-DD HH:mm:ss"); .format("YYYY-MM-DD HH:mm:ss");
}; };
let formatUTC = function(date) { const formatUTC = function(date) {
return moment(date).utc() return moment(date).utc()
.format("YYYY-MM-DD HH:mm:ss") + " UTC"; .format("YYYY-MM-DD HH:mm:ss") + " UTC";
}; };
@ -66,14 +101,14 @@ let formatUTC = function(date) {
/* /*
* Format a time interval from a number of milliseconds. * Format a time interval from a number of milliseconds.
*/ */
let formatDuration = function (ms) { const formatDuration = function (ms) {
let v = Math.floor(ms / 1000); let v = Math.floor(ms / 1000);
let s = Math.floor(v % 60); const s = Math.floor(v % 60);
v = Math.floor(v / 60); v = Math.floor(v / 60);
let m = Math.floor(v % 60); const m = Math.floor(v % 60);
v = Math.floor(v / 60); v = Math.floor(v / 60);
let h = Math.floor(v % 24); const h = Math.floor(v % 24);
let d = Math.floor(v / 24); const d = Math.floor(v / 24);
let str = ''; let str = '';
if (d > 0) { if (d > 0) {
@ -89,100 +124,13 @@ let formatDuration = function (ms) {
return (ms < 0 ? '-' : '') + str; 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();
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) { function LogElement(props) {
const entry = props.entry; const entry = props.entry;
const start = props.start; const start = props.start;
const end = props.end;
const cursor = entry.__CURSOR; const cursor = entry.__CURSOR;
const entry_timestamp = parseInt(entry.__REALTIME_TIMESTAMP / 1000); const entry_timestamp = parseInt(entry.__REALTIME_TIMESTAMP / 1000);
const timeClick = function(e) { const timeClick = function(_e) {
const ts = entry_timestamp - start; const ts = entry_timestamp - start;
if (ts > 0) { if (ts > 0) {
props.jumpToTs(ts); props.jumpToTs(ts);
@ -196,33 +144,38 @@ function LogElement(props) {
win.focus(); win.focus();
}; };
let className = 'cockpit-logline'; const cells = <DataListItemCells
if (start < entry_timestamp && end > entry_timestamp) { dataListCells={[
className = 'cockpit-logline highlighted'; <DataListCell key="row">
} <ExclamationTriangleIcon />
<Button variant="link" onClick={timeClick}>
{formatDateTime(entry_timestamp)}
</Button>
<Card isSelectable onClick={messageClick}>
<CardBody>{entry.MESSAGE}</CardBody>
</Card>
</DataListCell>
]} />;
return ( return (
<div className={className} data-cursor={cursor} key={cursor}> <DataListItem>
<div className="cockpit-log-warning"> <DataListItemRow>{cells}</DataListItemRow>
<i className="fa fa-exclamation-triangle" /> </DataListItem>
</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) { function LogsView(props) {
const entries = props.entries; const { entries, start, end } = props;
const start = props.start;
const end = props.end;
const rows = entries.map((entry) => const rows = entries.map((entry) =>
<LogElement key={entry.__CURSOR} entry={entry} start={start} end={end} jumpToTs={props.jumpToTs} /> <LogElement
key={entry.__CURSOR}
entry={entry}
start={start}
end={end}
jumpToTs={props.jumpToTs} />
); );
return ( return (
<div className="panel panel-default cockpit-log-panel" id="logs-view"> <DataList>{rows}</DataList>
{rows}
</div>
); );
} }
@ -259,16 +212,6 @@ class Logs extends React.Component {
}); });
} }
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) { journalctlError(error) {
console.warn(cockpit.message(error)); console.warn(cockpit.message(error));
} }
@ -278,7 +221,6 @@ class Logs extends React.Component {
this.entries.push(...entryList); this.entries.push(...entryList);
const after = this.entries[this.entries.length - 1].__CURSOR; const after = this.entries[this.entries.length - 1].__CURSOR;
this.setState({ entries: this.entries, after: after }); this.setState({ entries: this.entries, after: after });
this.scrollToBottom();
} }
} }
@ -294,7 +236,7 @@ class Logs extends React.Component {
this.journalCtl = null; this.journalCtl = null;
} }
let matches = []; const matches = [];
if (this.hostname) { if (this.hostname) {
matches.push("_HOSTNAME=" + this.hostname); matches.push("_HOSTNAME=" + this.hostname);
} }
@ -310,7 +252,7 @@ class Logs extends React.Component {
end = formatDateTime(this.end); end = formatDateTime(this.end);
} }
let options = { const options = {
since: start, since: start,
until: end, until: end,
follow: false, follow: false,
@ -319,7 +261,7 @@ class Logs extends React.Component {
}; };
if (this.state.after != null) { if (this.state.after != null) {
options["after"] = this.state.after; options.after = this.state.after;
delete options.since; delete options.since;
} }
@ -375,20 +317,36 @@ class Logs extends React.Component {
} }
render() { render() {
let r = this.props.recording; const r = this.props.recording;
if (r == null) { if (r == null) {
return <span>Loading...</span>; return (
<Bullseye>
<EmptyState variant={EmptyStateVariant.small}>
<Spinner />
<Title headingLevel="h2" size="lg">
{_("Loading...")}
</Title>
</EmptyState>
</Bullseye>
);
} else { } else {
return ( return (
<div className="panel panel-default"> <>
<div className="panel-heading"> <LogsView
<span>{_("Logs")}</span> id="logs-view"
<button className="btn btn-default" style={{"float":"right"}} onClick={this.loadLater}>{_("Load later entries")}</button> entries={this.state.entries}
</div> start={this.props.recording.start}
<LogsView entries={this.state.entries} start={this.props.recording.start} end={this.props.recording.end}
end={this.props.recording.end} jumpToTs={this.props.jumpToTs} /> jumpToTs={this.props.jumpToTs} />
<div className="panel-heading" /> <Bullseye>
</div> <Button
variant="secondary"
icon={<PlusIcon />}
onClick={this.loadLater}>
{_("Load later entries")}
</Button>
</Bullseye>
</>
); );
} }
} }
@ -445,12 +403,25 @@ class Recording extends React.Component {
} }
render() { render() {
let r = this.props.recording; const r = this.props.recording;
if (r == null) { if (r == null) {
return <span>Loading...</span>; return (
<Bullseye>
<EmptyState variant={EmptyStateVariant.small}>
<Spinner />
<Title headingLevel="h2" size="lg">
{_("Loading...")}
</Title>
</EmptyState>
</Bullseye>
);
} else { } else {
let player = return (
(<Player.Player <>
<Button variant="link" icon={<AngleLeftIcon />} onClick={this.goBackToList}>
{_("Session Recording")}
</Button>
<Player.Player
ref="player" ref="player"
matchList={this.props.recording.matchList} matchList={this.props.recording.matchList}
logsTs={this.logsTs} logsTs={this.logsTs}
@ -458,34 +429,18 @@ class Recording extends React.Component {
onTsChange={this.handleTsChange} onTsChange={this.handleTsChange}
recording={r} recording={r}
logsEnabled={this.state.logsEnabled} logsEnabled={this.state.logsEnabled}
onRewindStart={this.handleLogsReset} />); onRewindStart={this.handleLogsReset} />
<ExpandableSection
return ( id="btn-logs-view"
<React.Fragment> toggleText={_("Logs View")}
<div className="container-fluid"> onToggle={this.handleLogsClick}
<div className="row"> isExpanded={this.state.logsEnabled === true}>
<div className="col-md-12"> <Logs
<ol className="breadcrumb"> recording={this.props.recording}
<li><a onClick={this.goBackToList}>{_("Session Recording")}</a></li> curTs={this.state.curTs}
<li className="active">{_("Session")}</li> jumpToTs={this.handleLogTsChange} />
</ol> </ExpandableSection>
</div> </>
{player}
</div>
<div className="row">
<div className="col-md-12">
<button id="btn-logs-view" 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>
); );
} }
} }
@ -499,119 +454,82 @@ class Recording extends React.Component {
class RecordingList extends React.Component { class RecordingList extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.handleColumnClick = this.handleColumnClick.bind(this);
this.getSortedList = this.getSortedList.bind(this); this.onSort = this.onSort.bind(this);
this.drawSortDir = this.drawSortDir.bind(this); this.rowClickHandler = this.rowClickHandler.bind(this);
this.getColumnTitles = this.getColumnTitles.bind(this);
this.getColumns = this.getColumns.bind(this);
this.state = { this.state = {
sorting_field: "start", sortBy: {
sorting_asc: true, index: 1,
direction: SortByDirection.asc
}
}; };
} }
drawSortDir() { onSort(_event, index, direction) {
$('#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({ this.setState({
sorting_field: event.currentTarget.id, sortBy: {
sorting_asc: true, index,
direction
},
}); });
} }
}
getSortedList() { rowClickHandler(_event, row) {
let field = this.state.sorting_field; cockpit.location.go([row.id], cockpit.location.options);
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() { render() {
let columnTitles = this.getColumnTitles(); const { sortBy } = this.state;
let list = this.getSortedList(); const { index, direction } = sortBy;
let rows = [];
// generate columns
let 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 => {
let 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: cells
};
}).sort((a, b) => a.cells[index].localeCompare(b.cells[index]));
rows = direction === SortByDirection.asc ? rows : rows.reverse();
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 ( return (
<Listing.Listing title={_("Sessions")} <>
columnTitles={columnTitles} <Table
emptyCaption={_("No recorded sessions")} aria-label={_("Recordings")}
fullWidth={false}> cells={columnTitles}
{rows} rows={rows}
</Listing.Listing> sortBy={sortBy}
onSort={this.onSort}>
<TableHeader />
<TableBody onRowClick={this.rowClickHandler} />
</Table>
{!rows.length &&
<EmptyState variant={EmptyStateVariant.small}>
<EmptyStateIcon icon={SearchIcon} />
<Title headingLevel="h2" size="lg">
{_("No recordings found")}
</Title>
<EmptyStateBody>
{_("No recordings matched the filter criteria.")}
</EmptyStateBody>
</EmptyState>}
</>
); );
} }
} }
@ -621,13 +539,12 @@ class RecordingList extends React.Component {
* single recording. Extracts the ID of the recording to display from * single recording. Extracts the ID of the recording to display from
* cockpit.location.path[0]. If it's zero, displays the list. * cockpit.location.path[0]. If it's zero, displays the list.
*/ */
class View extends React.Component { export default class View extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.onLocationChanged = this.onLocationChanged.bind(this); this.onLocationChanged = this.onLocationChanged.bind(this);
this.journalctlIngest = this.journalctlIngest.bind(this); this.journalctlIngest = this.journalctlIngest.bind(this);
this.handleInputChange = this.handleInputChange.bind(this); this.handleInputChange = this.handleInputChange.bind(this);
this.handleDateSinceChange = this.handleDateSinceChange.bind(this);
this.openConfig = this.openConfig.bind(this); this.openConfig = this.openConfig.bind(this);
/* Journalctl instance */ /* Journalctl instance */
this.journalctl = null; this.journalctl = null;
@ -637,11 +554,12 @@ class View extends React.Component {
this.recordingMap = {}; this.recordingMap = {};
/* tlog UID in system set in ComponentDidMount */ /* tlog UID in system set in ComponentDidMount */
this.uid = null; this.uid = null;
const path = cockpit.location.path[0];
this.state = { this.state = {
/* List of recordings in start order */ /* List of recordings in start order */
recordingList: [], recordingList: [],
/* ID of the recording to display, or null for all */ /* ID of the recording to display, or null for all */
recordingID: cockpit.location.path[0] || null, recordingID: path === "config" ? null : path || null,
/* filter values start */ /* filter values start */
date_since: cockpit.location.options.date_since || "", date_since: cockpit.location.options.date_since || "",
date_until: cockpit.location.options.date_until || "", date_until: cockpit.location.options.date_until || "",
@ -651,6 +569,8 @@ class View extends React.Component {
/* filter values end */ /* filter values end */
error_tlog_uid: false, error_tlog_uid: false,
diff_hosts: false, diff_hosts: false,
/* if config is open */
config: path === "config",
}; };
} }
@ -666,6 +586,10 @@ class View extends React.Component {
* displayed recording ID. * displayed recording ID.
*/ */
onLocationChanged() { onLocationChanged() {
const path = cockpit.location.path[0];
if (path === "config")
this.setState({ config: true });
else
this.setState({ this.setState({
recordingID: cockpit.location.path[0] || null, recordingID: cockpit.location.path[0] || null,
date_since: cockpit.location.options.date_since || "", date_since: cockpit.location.options.date_since || "",
@ -673,6 +597,7 @@ class View extends React.Component {
username: cockpit.location.options.username || "", username: cockpit.location.options.username || "",
hostname: cockpit.location.options.hostname || "", hostname: cockpit.location.options.hostname || "",
search: cockpit.location.options.search || "", search: cockpit.location.options.search || "",
config: false
}); });
} }
@ -680,49 +605,51 @@ class View extends React.Component {
* Ingest journal entries sent by journalctl. * Ingest journal entries sent by journalctl.
*/ */
journalctlIngest(entryList) { journalctlIngest(entryList) {
let recordingList = this.state.recordingList.slice(); const recordingList = this.state.recordingList.slice();
let i; let i;
let j; let j;
let hostname; let hostname;
if (entryList[0]) { if (entryList[0]) {
if (entryList[0]["_HOSTNAME"]) { if (entryList[0]._HOSTNAME) {
hostname = entryList[0]["_HOSTNAME"]; hostname = entryList[0]._HOSTNAME;
} }
} }
for (i = 0; i < entryList.length; i++) { for (i = 0; i < entryList.length; i++) {
let e = entryList[i]; const e = entryList[i];
let id = e['TLOG_REC']; const id = e.TLOG_REC;
/* Skip entries with missing recording ID */ /* Skip entries with missing recording ID */
if (id === undefined) { if (id === undefined) {
continue; continue;
} }
let ts = Math.floor( const ts = Math.floor(
parseInt(e["__REALTIME_TIMESTAMP"], 10) / parseInt(e.__REALTIME_TIMESTAMP, 10) /
1000); 1000);
let r = this.recordingMap[id]; let r = this.recordingMap[id];
/* If no recording found */ /* If no recording found */
if (r === undefined) { if (r === undefined) {
/* Create new recording */ /* Create new recording */
if (hostname !== e["_HOSTNAME"]) { if (hostname !== e._HOSTNAME) {
this.setState({ diff_hosts: true }); this.setState({ diff_hosts: true });
} }
r = {id: id, r = {
id: id,
matchList: ["TLOG_REC=" + id], matchList: ["TLOG_REC=" + id],
user: e["TLOG_USER"], user: e.TLOG_USER,
boot_id: e["_BOOT_ID"], boot_id: e._BOOT_ID,
session_id: parseInt(e["TLOG_SESSION"], 10), session_id: parseInt(e.TLOG_SESSION, 10),
pid: parseInt(e["_PID"], 10), pid: parseInt(e._PID, 10),
start: ts, start: ts,
/* FIXME Should be start + message duration */ /* FIXME Should be start + message duration */
end: ts, end: ts,
hostname: e["_HOSTNAME"], hostname: e._HOSTNAME,
duration: 0}; duration: 0
};
/* Map the recording */ /* Map the recording */
this.recordingMap[id] = r; this.recordingMap[id] = r;
/* Insert the recording in order */ /* Insert the recording in order */
@ -765,7 +692,7 @@ class View extends React.Component {
* Assumes journalctl is not running. * Assumes journalctl is not running.
*/ */
journalctlStart() { journalctlStart() {
let matches = ["_COMM=tlog-rec", const matches = ["_COMM=tlog-rec",
/* Strings longer than TASK_COMM_LEN (16) characters /* Strings longer than TASK_COMM_LEN (16) characters
* are truncated (man proc) */ * are truncated (man proc) */
"_COMM=tlog-rec-sessio"]; "_COMM=tlog-rec-sessio"];
@ -777,22 +704,22 @@ class View extends React.Component {
matches.push("_HOSTNAME=" + this.state.hostname); matches.push("_HOSTNAME=" + this.state.hostname);
} }
let options = {follow: false, count: "all", merge: true}; const options = { follow: false, count: "all", merge: true };
if (this.state.date_since && this.state.date_since !== "") { if (this.state.date_since && this.state.date_since !== "") {
options['since'] = formatUTC(this.state.date_since); options.since = formatUTC(this.state.date_since);
} }
if (this.state.date_until && this.state.date_until !== "") { if (this.state.date_until && this.state.date_until !== "") {
options['until'] = formatUTC(this.state.date_until); options.until = formatUTC(this.state.date_until);
} }
if (this.state.search && this.state.search !== "" && this.state.recordingID === null) { if (this.state.search && this.state.search !== "" && this.state.recordingID === null) {
options["grep"] = this.state.search; options.grep = this.state.search;
} }
if (this.state.recordingID !== null) { if (this.state.recordingID !== null) {
delete options["grep"]; delete options.grep;
matches.push("TLOG_REC=" + this.state.recordingID); matches.push("TLOG_REC=" + this.state.recordingID);
} }
@ -838,29 +765,19 @@ class View extends React.Component {
this.setState({ recordingList: [] }); this.setState({ recordingList: [] });
} }
handleInputChange(event) { handleInputChange(name, value) {
const name = event.target.name; const state = {};
const value = event.target.value;
let state = {};
state[name] = value; state[name] = value;
this.setState(state); this.setState(state);
cockpit.location.go([], $.extend(cockpit.location.options, 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() { openConfig() {
cockpit.jump(['session-recording/config']); cockpit.location.go("/config");
} }
componentDidMount() { componentDidMount() {
let proc = cockpit.spawn(["getent", "passwd", "tlog"]); const proc = cockpit.spawn(["getent", "passwd", "tlog"]);
proc.stream((data) => { proc.stream((data) => {
this.uid = data.split(":", 3)[2]; this.uid = data.split(":", 3)[2];
@ -882,7 +799,7 @@ class View extends React.Component {
} }
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(_prevProps, prevState) {
/* /*
* If we're running a specific (non-wildcard) journalctl * If we're running a specific (non-wildcard) journalctl
* and recording ID has changed * and recording ID has changed
@ -906,75 +823,94 @@ class View extends React.Component {
} }
render() { render() {
if (this.state.error_tlog_uid === true) { if (this.state.config === true) {
return <Config.Config />;
} else if (this.state.error_tlog_uid === true) {
return ( return (
<div className="container-fluid"> <Bullseye>
Error getting tlog UID from system. <EmptyState variant={EmptyStateVariant.small}>
</div> <EmptyStateIcon
icon={ExclamationCircleIcon}
color={global_danger_color_200.value} />
<Title headingLevel="h2" size="lg">
{_("Error")}
</Title>
<EmptyStateBody>
{_("Unable to retrieve tlog UID from system.")}
</EmptyStateBody>
</EmptyState>
</Bullseye>
); );
} } else if (this.state.recordingID === null) {
if (this.state.recordingID === null) { const toolbar = (
<ToolbarContent>
<ToolbarGroup>
<ToolbarItem variant="label">{_("Since")}</ToolbarItem>
<ToolbarItem>
<TextInput
id="filter-since"
placeholder={_("Filter since")}
value={this.state.date_since}
type="search"
onChange={value => this.handleInputChange("date_since", value)} />
</ToolbarItem>
</ToolbarGroup>
<ToolbarGroup>
<ToolbarItem variant="label">{_("Until")}</ToolbarItem>
<ToolbarItem>
<TextInput
id="filter-until"
placeholder={_("Filter until")}
value={this.state.date_until}
type="search"
onChange={value => this.handleInputChange("date_until", value)} />
</ToolbarItem>
</ToolbarGroup>
<ToolbarGroup>
<ToolbarItem variant="label">{_("Search")}</ToolbarItem>
<ToolbarItem>
<TextInput
id="filter-search"
placeholder={_("Filter by content")}
value={this.state.search}
type="search"
onChange={value => this.handleInputChange("search", value)} />
</ToolbarItem>
</ToolbarGroup>
<ToolbarGroup>
<ToolbarItem variant="label">{_("Username")}</ToolbarItem>
<ToolbarItem>
<TextInput
id="filter-username"
placeholder={_("Filter by username")}
value={this.state.username}
type="search"
onChange={value => this.handleInputChange("username", value)} />
</ToolbarItem>
</ToolbarGroup>
{this.state.diff_hosts === true &&
<ToolbarGroup>
<ToolbarItem variant="label">{_("Hostname")}</ToolbarItem>
<ToolbarItem>
<TextInput
id="filter-hostname"
placeholder={_("Filter by hostname")}
value={this.state.hostname}
type="search"
onChange={value => this.handleInputChange("hostname", value)} />
</ToolbarItem>
</ToolbarGroup>}
<ToolbarItem>
<Button id="btn-config" onClick={this.openConfig}>
<CogIcon />
</Button>
</ToolbarItem>
</ToolbarContent>
);
return ( return (
<React.Fragment> <>
<div className="content-header-extra"> <Toolbar>{toolbar}</Toolbar>
<table className="form-table-ct">
<thead>
<tr>
<td className="top">
<label className="control-label" htmlFor="date_since">{_("Since")}</label>
</td>
<td id="since-search">
<Datetimepicker value={this.state.date_since} onChange={this.handleDateSinceChange} />
</td>
<td className="top">
<label className="control-label" htmlFor="date_until">{_("Until")}</label>
</td>
<td id="until-search">
<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" id="recording-search" 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" id="username-search" 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 id="btn-config" className="btn btn-default" onClick={this.openConfig}><i className="fa fa-cog" aria-hidden="true" /></button>
</td>
</tr>
</thead>
</table>
</div>
<RecordingList <RecordingList
date_since={this.state.date_since} date_since={this.state.date_since}
date_until={this.state.date_until} date_until={this.state.date_until}
@ -982,16 +918,14 @@ class View extends React.Component {
hostname={this.state.hostname} hostname={this.state.hostname}
list={this.state.recordingList} list={this.state.recordingList}
diff_hosts={this.state.diff_hosts} /> diff_hosts={this.state.diff_hosts} />
</React.Fragment> </>
); );
} else { } else {
return ( return (
<React.Fragment> <Recording
<Recording recording={this.recordingMap[this.state.recordingID]} search={this.state.search} /> recording={this.recordingMap[this.state.recordingID]}
</React.Fragment> search={this.state.search} />
); );
} }
} }
} }
ReactDOM.render(<View />, document.getElementById('view'));

View file

@ -1,149 +0,0 @@
/* Panels don't draw borders between them */
.panel > .table > tbody:first-child td {
border-top: 1px solid rgb(221, 221, 221);
}
/* Table headers should not generate a double border */
.panel .table thead tr th {
border-bottom: none;
}
.panel-heading {
background: #F5F5F5;
height: 44px;
}
/* Vertically center dropdown buttons in panel headers */
.panel-heading .btn {
margin-top: -3px;
}
/*
* Fix up table row hovering.
*
* When you hover over table rows it's because they're clickable.
* Make the table row hover color match the list-group-item.
*/
.table-hover > tbody > tr > td,
.table-hover > tbody > tr > th,
.dialog-list-ct .list-group-item {
cursor: pointer;
}
.table-hover > tbody > tr:hover > td,
.table-hover > tbody > tr:hover > th,
.dialog-list-ct .list-group-item:hover:not(.active) {
background-color: #d4edfa;
}
/* Override patternfly to fit buttons and such */
.table > thead > tr > th,
.table > tbody > tr > td {
padding: 8px;
}
/* Override the heavy patternfly headers */
.table > thead {
background-image: none;
background-color: #fff;
}
/* Make things line up */
.table tbody tr td:first-child,
.table thead tr th:first-child {
padding-left: 15px;
}
.table tbody tr td:last-child,
.table thead tr th:last-child {
padding-right: 15px;
}
.info-table-ct > tr > td,
.info-table-ct > tbody > tr > td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-left: 0.75em;
padding-top: 0.25em;
vertical-align: top;
line-height: 26px;
}
/* Match the form-table-ct CSS equivalent */
.info-table-ct > tr > td:first-child,
.info-table-ct > tbody > tr > td:first-child {
text-align: left;
white-space: nowrap;
width: 5px;
color: #888888;
}
.info-table-ct > tr > td:not(:first-child),
.info-table-ct > tbody > tr > td:not(:first-child) {
color: black;
}
.info-table-ct > tr > td button,
.info-table-ct > tbody > tr > td button {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.form-table-ct {
width: 100%;
}
.form-table-ct td {
padding-left: 0.75em;
padding-top: 0.25em;
line-height: 26px;
}
.form-table-ct td.top {
vertical-align: top;
}
.form-table-ct td:first-child {
text-align: right;
white-space: nowrap;
color: #888888;
width: 5px; /* will be expanded by nowrap */
}
.form-table-ct td[colspan] {
text-align: inherit;
}
.form-table-ct td {
height: 26px;
}
.form-table-ct td.header {
font-weight: bold;
text-align: left;
color: #4D5258;
padding: 20px 0 10px 0;
}
.form-table-ct label input[type='radio'],
.form-table-ct label input[type='checkbox'] {
margin-right: 4px;
}
.form-table-ct label {
margin-bottom: 0px;
}
.form-table-ct label span {
vertical-align: super;
}
/* Break up sidebar in columns in smaller sizes*/
@media (min-width: 992px) {
.info-table-ct-container .info-table-ct {
table-layout: fixed;
width: 100%;
}
}

View file

@ -18,12 +18,16 @@ class TestApplication(MachineCase):
self.login_and_go("/session-recording") self.login_and_go("/session-recording")
b = self.browser b = self.browser
m = self.machine m = self.machine
b.wait_present(".content-header-extra") b.wait_present("#app")
b.wait_present("#user")
return b, m return b, m
def _sel_rec(self, cond=":first()"): def _sel_rec(self, index=0):
self.browser.click(f".listing-ct-item{cond}") page = (
"0f25700a28c44b599869745e5fda8b0c-7106-121e79"
if not index
else "0f25700a28c44b599869745e5fda8b0c-7623-135541"
)
self.browser.go(f"/session-recording#/{page}")
def _term_line(self, lineno): def _term_line(self, lineno):
return f".xterm-accessibility-tree div:nth-child({lineno})" return f".xterm-accessibility-tree div:nth-child({lineno})"
@ -35,18 +39,18 @@ class TestApplication(MachineCase):
b.wait_in_text(self._term_line(1), "localhost") b.wait_in_text(self._term_line(1), "localhost")
def testFastforwardControls(self): def testFastforwardControls(self):
slider = ".slider > .min-slider-handle" progress = ".pf-c-progress__indicator"
b, _ = self._login() b, _ = self._login()
self._sel_rec() self._sel_rec()
# fast forward # fast forward
b.click("#player-fast-forward") b.click("#player-fast-forward")
b.wait_in_text(self._term_line(12), "exit") b.wait_in_text(self._term_line(12), "exit")
b.wait_attr(slider, "style", "left: 100%;") b.wait_attr(progress, "style", "width: 100%;")
# test restart playback # test restart playback
b.click("#player-restart") b.click("#player-restart")
b.wait_text(self._term_line(1), "Blank line") b.wait_text(self._term_line(1), "Blank line")
b.wait_attr(slider, "style", "left: 100%;") b.wait_attr(progress, "style", "width: 100%;")
def testSpeedControls(self): def testSpeedControls(self):
b, _ = self._login() b, _ = self._login()
@ -54,7 +58,6 @@ class TestApplication(MachineCase):
# increase speed # increase speed
b.wait_present("#player-speed-up") b.wait_present("#player-speed-up")
b.click("#player-speed-up") b.click("#player-speed-up")
b.wait_present("#player-speed")
b.wait_text("#player-speed", "x2") b.wait_text("#player-speed", "x2")
b.click("#player-speed-up") b.click("#player-speed-up")
b.wait_text("#player-speed", "x4") b.wait_text("#player-speed", "x4")
@ -70,7 +73,6 @@ class TestApplication(MachineCase):
b.click("#player-speed-down") b.click("#player-speed-down")
b.wait_text("#player-speed", "x2") b.wait_text("#player-speed", "x2")
b.click("#player-speed-down") b.click("#player-speed-down")
b.wait_present("#player-speed")
b.click("#player-speed-down") b.click("#player-speed-down")
b.wait_text("#player-speed", "/2") b.wait_text("#player-speed", "/2")
b.click("#player-speed-down") b.click("#player-speed-down")
@ -80,8 +82,7 @@ class TestApplication(MachineCase):
b.click("#player-speed-down") b.click("#player-speed-down")
b.wait_text("#player-speed", "/16") b.wait_text("#player-speed", "/16")
# restore speed # restore speed
b.click("#player-speed-reset") b.click(".pf-c-chip .pf-c-button")
b.wait_present("#player-speed")
b.click("#player-speed-down") b.click("#player-speed-down")
b.wait_text("#player-speed", "/2") b.wait_text("#player-speed", "/2")
@ -142,9 +143,7 @@ class TestApplication(MachineCase):
import time, json, configparser import time, json, configparser
b, m = self._login() b, m = self._login()
# Ensure that the button leads to the config page
b.click("#btn-config") b.click("#btn-config")
b.enter_page("/session-recording/config")
# TLOG config # TLOG config
conf_file_path = "/etc/tlog/" conf_file_path = "/etc/tlog/"
@ -179,7 +178,9 @@ class TestApplication(MachineCase):
m.upload([save_file], conf_file_path) m.upload([save_file], conf_file_path)
# Check that the config reflects the changes # Check that the config reflects the changes
conf = json.load(open(test_file, "r")) conf = json.load(open(test_file, "r"))
assert json.dumps(conf) == json.dumps( self.assertEqual(
json.dumps(conf),
json.dumps(
{ {
"shell": "/test/path/shell", "shell": "/test/path/shell",
"notice": "Test Notice", "notice": "Test Notice",
@ -192,6 +193,7 @@ class TestApplication(MachineCase):
"journal": {"priority": "info", "augment": False}, "journal": {"priority": "info", "augment": False},
"writer": "file", "writer": "file",
} }
),
) )
# SSSD config # SSSD config
@ -228,13 +230,13 @@ class TestApplication(MachineCase):
# Check that the configs reflected the changes # Check that the configs reflected the changes
conf = configparser.ConfigParser() conf = configparser.ConfigParser()
conf.read_file(open(test_none_file, "r")) conf.read_file(open(test_none_file, "r"))
assert conf["session_recording"]["scope"] == "none" self.assertEqual(conf["session_recording"]["scope"], "none")
conf.read_file(open(test_some_file, "r")) conf.read_file(open(test_some_file, "r"))
assert conf["session_recording"]["scope"] == "some" self.assertEqual(conf["session_recording"]["scope"], "some")
assert conf["session_recording"]["users"] == "test users" self.assertEqual(conf["session_recording"]["users"], "test users")
assert conf["session_recording"]["groups"] == "test groups" self.assertEqual(conf["session_recording"]["groups"], "test groups")
conf.read_file(open(test_all_file, "r")) conf.read_file(open(test_all_file, "r"))
assert conf["session_recording"]["scope"] == "all" self.assertEqual(conf["session_recording"]["scope"], "all")
def testDisplayDrag(self): def testDisplayDrag(self):
b, _ = self._login() b, _ = self._login()
@ -248,23 +250,22 @@ class TestApplication(MachineCase):
b.click("#player-zoom-in") b.click("#player-zoom-in")
# select and ensure drag'n'pan mode # select and ensure drag'n'pan mode
b.click("#player-drag-pan") b.click("#player-drag-pan")
b.wait_present(".fa-hand-rock-o")
# scroll and check for screen movement # scroll and check for screen movement
b.mouse(".dragnpan", "mousedown", 200, 200) b.mouse(".dragnpan", "mousedown", 200, 200)
b.mouse(".dragnpan", "mousemove", 10, 10) b.mouse(".dragnpan", "mousemove", 10, 10)
assert b.attr(".dragnpan", "scrollTop") != 0 self.assertNotEqual(b.attr(".dragnpan", "scrollTop"), 0)
assert b.attr(".dragnpan", "scrollLeft") != 0 self.assertNotEqual(b.attr(".dragnpan", "scrollLeft"), 0)
def testLogCorrelation(self): def testLogCorrelation(self):
b, _ = self._login() b, _ = self._login()
# select the recording with the extra logs # select the recording with the extra logs
self._sel_rec(":contains('01:07')") self._sel_rec(1)
b.click("#btn-logs-view") b.click("#btn-logs-view")
# fast forward until the end # fast forward until the end
while "exit" not in b.text(self._term_line(22)): while "exit" not in b.text(self._term_line(22)):
b.click("#player-skip-frame") b.click("#player-skip-frame")
# check for extra log entries # check for extra log entries
b.wait_present(".cockpit-log-message:contains('authentication failure')") b.wait_present(".pf-c-data-list:contains('authentication failure')")
def testZoomSpeedControls(self): def testZoomSpeedControls(self):
default_scale_sel = '.console-ct[style^="transform: scale(1)"]' default_scale_sel = '.console-ct[style^="transform: scale(1)"]'
@ -290,17 +291,20 @@ class TestApplication(MachineCase):
def _filter(self, inp, occ_dict): def _filter(self, inp, occ_dict):
import time import time
# allow temporary timestamp failures
self.allow_journal_messages(".*timestamp.*")
# login and test inputs
b, _ = self._login() b, _ = self._login()
for occ in occ_dict: for occ in occ_dict:
for term in occ_dict[occ]: for term in occ_dict[occ]:
# enter the search term and wait for the results to return # enter the search term and wait for the results to return
b.set_input_text(inp, term) b.set_input_text(inp, term)
time.sleep(0.5) time.sleep(2)
assert b.text(".listing-ct").count("contractor") == occ self.assertEqual(b.text(".pf-c-table").count("contractor"), occ)
def testSearch(self): def testSearch(self):
self._filter( self._filter(
"#recording-search", "#filter-search",
{ {
0: { 0: {
"this should return nothing", "this should return nothing",
@ -327,7 +331,7 @@ class TestApplication(MachineCase):
def testFilterUsername(self): def testFilterUsername(self):
self._filter( self._filter(
"#username-search", "#filter-username",
{ {
0: {"test", "contact", "contractor", "contractor11", "contractor4"}, 0: {"test", "contact", "contractor", "contractor11", "contractor4"},
2: {"contractor1"}, 2: {"contractor1"},
@ -336,7 +340,7 @@ class TestApplication(MachineCase):
def testFilterSince(self): def testFilterSince(self):
self._filter( self._filter(
"#since-search .bootstrap-datepicker", "#filter-since",
{ {
0: {"2020-06-02", "2020-06-01 12:31:00"}, 0: {"2020-06-02", "2020-06-01 12:31:00"},
1: {"2020-06-01 12:17:01", "2020-06-01 12:30:50"}, 1: {"2020-06-01 12:17:01", "2020-06-01 12:30:50"},
@ -346,10 +350,10 @@ class TestApplication(MachineCase):
def testFilterUntil(self): def testFilterUntil(self):
self._filter( self._filter(
"#until-search .bootstrap-datepicker", "#filter-until",
{ {
0: {"2020-06-01", "2020-06-01 12:16:00"}, 0: {"2020-06-01", "2020-06-01 12:16"},
1: {"2020-06-01 12:17:00", "2020-06-01 12:29:42"}, 1: {"2020-06-01 12:17", "2020-06-01 12:29"},
2: {"2020-06-02", "2020-06-01 12:31:00"}, 2: {"2020-06-02", "2020-06-01 12:31:00"},
}, },
) )

View file

@ -1,49 +1,34 @@
const path = require("path"); const path = require("path");
const copy = require("copy-webpack-plugin"); const copy = require("copy-webpack-plugin");
const extract = require("extract-text-webpack-plugin"); const extract = require("mini-css-extract-plugin");
const fs = require("fs"); const fs = require("fs");
const webpack = require("webpack"); const webpack = require("webpack");
const CompressionPlugin = require("compression-webpack-plugin"); const CompressionPlugin = require("compression-webpack-plugin");
var externals = { var externals = {
"cockpit": "cockpit", cockpit: "cockpit",
}; };
/* These can be overridden, typically from the Makefile.am */ /* These can be overridden, typically from the Makefile.am */
const srcdir = (process.env.SRCDIR || __dirname) + path.sep + "src"; const srcdir = (process.env.SRCDIR || __dirname) + path.sep + "src";
const builddir = (process.env.SRCDIR || __dirname); const builddir = process.env.SRCDIR || __dirname;
const distdir = builddir + path.sep + "dist"; const distdir = builddir + path.sep + "dist";
const section = process.env.ONLYDIR || null; const section = process.env.ONLYDIR || null;
const libdir = path.resolve(srcdir, "pkg" + path.sep + "lib"); const libdir = path.resolve(srcdir, "pkg" + path.sep + "lib")
const nodedir = path.resolve((process.env.SRCDIR || __dirname), "node_modules"); const nodedir = path.resolve(process.env.SRCDIR || __dirname, "node_modules");
/* A standard nodejs and webpack pattern */ /* A standard nodejs and webpack pattern */
var production = process.env.NODE_ENV === 'production'; var production = process.env.NODE_ENV === "production";
var info = { var info = {
entries: { entries: {
"recordings": [ index: [
"./recordings.jsx", "./index.js",
"./recordings.css",
"./pkg/lib/listing.less",
], ],
"config": [
"./config.jsx",
"./recordings.css",
"./table.css",
]
}, },
files: [ files: [
"index.html", "index.html",
"config.html", "manifest.json"
"player.jsx",
"player.css",
"recordings.jsx",
"recordings.css",
"table.css",
"manifest.json",
"timer.css",
"./pkg/lib/listing.less",
], ],
}; };
@ -64,8 +49,7 @@ var output = {
function vpath(/* ... */) { function vpath(/* ... */) {
var filename = Array.prototype.join.call(arguments, path.sep); var filename = Array.prototype.join.call(arguments, path.sep);
var expanded = builddir + path.sep + filename; var expanded = builddir + path.sep + filename;
if (fs.existsSync(expanded)) if (fs.existsSync(expanded)) return expanded;
return expanded;
expanded = srcdir + path.sep + filename; expanded = srcdir + path.sep + filename;
return expanded; return expanded;
} }
@ -78,10 +62,8 @@ Object.keys(info.entries).forEach(function(key) {
} }
info.entries[key] = info.entries[key].map(function (value) { info.entries[key] = info.entries[key].map(function (value) {
if (value.indexOf("/") === -1) if (value.indexOf("/") === -1) return value;
return value; else return vpath(value);
else
return vpath(value);
}); });
}); });
@ -93,79 +75,124 @@ info.files.forEach(function(value) {
}); });
info.files = files; info.files = files;
var plugins = [ var plugins = [new copy(info.files), new extract({ filename: "[name].css" })];
new copy(info.files),
new extract("[name].css")
];
/* Only minimize when in production mode */ /* Only minimize when in production mode */
if (production) { if (production) {
/* Rename output files when minimizing */ /* Rename output files when minimizing */
output.filename = "[name].min.js"; output.filename = "[name].min.js";
plugins.unshift(new CompressionPlugin({ plugins.unshift(
new CompressionPlugin({
asset: "[path].gz[query]", asset: "[path].gz[query]",
test: /\.(js|html)$/, test: /\.(js|html)$/,
minRatio: 0.9, minRatio: 0.9,
deleteOriginalAssets: true deleteOriginalAssets: true,
})); })
);
} }
var babel_loader = {
loader: "babel-loader",
options: {
presets: [
[
"@babel/env",
{
targets: {
chrome: "57",
firefox: "52",
safari: "10.3",
edge: "16",
opera: "44",
},
},
],
"@babel/preset-react",
],
},
};
module.exports = { module.exports = {
mode: production ? 'production' : 'development', mode: production ? "production" : "development",
resolve: {
modules: [libdir, nodedir],
},
entry: info.entries, entry: info.entries,
externals: externals, externals: externals,
output: output, output: output,
devtool: "source-map", devtool: "source-map",
resolve: {
alias: {
"fs": path.resolve(nodedir, "fs-extra"),
},
modules: [libdir, nodedir],
},
module: { module: {
rules: [ rules: [
{ {
enforce: 'pre', enforce: "pre",
exclude: /node_modules/, exclude: /node_modules/,
loader: 'eslint-loader', loader: "eslint-loader",
test: /\.jsx$/ test: /\.(js|jsx)$/,
},
{
enforce: 'pre',
exclude: /node_modules/,
loader: 'eslint-loader',
test: /\.es6$/
}, },
{ {
exclude: /node_modules/, exclude: /node_modules/,
loader: 'babel-loader', use: babel_loader,
test: /\.js$/ test: /\.(js|jsx)$/,
},
/* HACK: remove unwanted fonts from PatternFly's css */
{
test: /patternfly-4-cockpit.scss$/,
use: [
extract.loader,
{
loader: "css-loader",
options: {
sourceMap: true,
url: false,
},
}, },
{ {
exclude: /node_modules/, loader: "string-replace-loader",
loader: 'babel-loader', options: {
test: /\.jsx$/ multiple: [
{
search: /src:url\("patternfly-icons-fake-path\/pficon[^}]*/g,
replace: "src:url('fonts/patternfly.woff')format('woff');",
}, },
{ {
exclude: /node_modules/, search: /@font-face[^}]*patternfly-fonts-fake-path[^}]*}/g,
loader: 'babel-loader', replace: "",
test: /\.es6$/ },
],
},
}, },
{ {
test: /\.less$/, loader: "sass-loader",
loader: extract.extract("css-loader!less-loader") options: {
sourceMap: true,
outputStyle: "compressed",
},
},
],
}, },
{ {
exclude: /node_modules/, test: /\.s?css$/,
loader: extract.extract('css-loader!sass-loader'), exclude: /patternfly-4-cockpit.scss/,
test: /\.scss$/ use: [
extract.loader,
{
loader: "css-loader",
options: {
sourceMap: true,
url: false,
},
}, },
{ {
loader: extract.extract("css-loader?minimize=&root=" + libdir), loader: "sass-loader",
test: /\.css$/, options: {
} sourceMap: true,
] outputStyle: "compressed",
}, },
plugins: plugins },
} ],
},
],
},
plugins: plugins,
};