From a20e3c5a81141e7377598134875c08f2023ef4f6 Mon Sep 17 00:00:00 2001
From: Kyrylo Gliebov
Date: Mon, 2 Jul 2018 18:03:59 +0200
Subject: [PATCH 001/208] Session recording module for Cockpit initial commit
---
...ec.in => cockpit-session-recording.spec.in | 10 +-
...pit-project.session-recording.metainfo.xml | 6 +-
package.json | 19 +-
src/app.scss | 3 -
src/config.html | 54 +
src/config.jsx | 279 +++++
src/index.html | 29 +-
src/manifest.json | 11 +-
src/manifest.json.in | 15 +
src/pkg/lib/cockpit-components-listing.jsx | 360 ++++++
src/pkg/lib/console.css | 59 +
src/pkg/lib/journal.css | 134 ++
src/pkg/lib/journal.js | 584 +++++++++
src/pkg/lib/journal_day_header.mustache | 1 +
src/pkg/lib/journal_line.mustache | 19 +
src/pkg/lib/journal_reboot.mustache | 5 +
src/pkg/lib/listing.less | 502 ++++++++
src/pkg/lib/page.css | 235 ++++
src/pkg/lib/table.css | 146 +++
src/pkg/lib/term.css | 522 ++++++++
src/pkg/lib/variables.less | 22 +
src/player.jsx | 1080 +++++++++++++++++
src/recordings.css | 368 ++++++
src/recordings.jsx | 748 ++++++++++++
src/terminal.jsx | 191 +++
src/timer.css | 163 +++
webpack.config.js | 31 +-
27 files changed, 5564 insertions(+), 32 deletions(-)
rename cockpit-starter-kit.spec.in => cockpit-session-recording.spec.in (54%)
rename org.cockpit-project.starter-kit.metainfo.xml => org.cockpit-project.session-recording.metainfo.xml (63%)
delete mode 100644 src/app.scss
create mode 100644 src/config.html
create mode 100644 src/config.jsx
create mode 100644 src/manifest.json.in
create mode 100644 src/pkg/lib/cockpit-components-listing.jsx
create mode 100644 src/pkg/lib/console.css
create mode 100644 src/pkg/lib/journal.css
create mode 100644 src/pkg/lib/journal.js
create mode 100644 src/pkg/lib/journal_day_header.mustache
create mode 100644 src/pkg/lib/journal_line.mustache
create mode 100644 src/pkg/lib/journal_reboot.mustache
create mode 100644 src/pkg/lib/listing.less
create mode 100644 src/pkg/lib/page.css
create mode 100644 src/pkg/lib/table.css
create mode 100644 src/pkg/lib/term.css
create mode 100644 src/pkg/lib/variables.less
create mode 100644 src/player.jsx
create mode 100644 src/recordings.css
create mode 100644 src/recordings.jsx
create mode 100644 src/terminal.jsx
create mode 100644 src/timer.css
diff --git a/cockpit-starter-kit.spec.in b/cockpit-session-recording.spec.in
similarity index 54%
rename from cockpit-starter-kit.spec.in
rename to cockpit-session-recording.spec.in
index 53e8b70..0359706 100644
--- a/cockpit-starter-kit.spec.in
+++ b/cockpit-session-recording.spec.in
@@ -1,19 +1,19 @@
-Name: cockpit-starter-kit
+Name: cockpit-session-recording
Version: @VERSION@
Release: 1%{?dist}
-Summary: Cockpit Starter Kit Example Module
+Summary: Cockpit Session Recording
License: LGPLv2+
-Source: cockpit-starter-kit-%{version}.tar.gz
+Source: cockpit-session-recording-%{version}.tar.gz
BuildArch: noarch
%define debug_package %{nil}
%description
-Cockpit Starter Kit Example Module
+Cockpit Session Recording
%prep
-%setup -n cockpit-starter-kit
+%setup -n cockpit-session-recording
%install
%make_install
diff --git a/org.cockpit-project.starter-kit.metainfo.xml b/org.cockpit-project.session-recording.metainfo.xml
similarity index 63%
rename from org.cockpit-project.starter-kit.metainfo.xml
rename to org.cockpit-project.session-recording.metainfo.xml
index ad720d8..1cd1106 100644
--- a/org.cockpit-project.starter-kit.metainfo.xml
+++ b/org.cockpit-project.session-recording.metainfo.xml
@@ -1,7 +1,7 @@
- org.cockpit-project.starter-kit
+ org.cockpit-project.session-recording
CC0-1.0
- Starter Kit
+ Session Recording
Scaffolding for a cockpit module.
@@ -11,5 +11,5 @@
+
+
+
+
+
diff --git a/src/config.jsx b/src/config.jsx
new file mode 100644
index 0000000..0277128
--- /dev/null
+++ b/src/config.jsx
@@ -0,0 +1,279 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2017 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see
.
+ */
+
+(function() {
+ "use strict";
+
+ let cockpit = require("cockpit");
+ let React = require("react");
+ let json = require('comment-json');
+
+ let Config = class extends React.Component {
+ constructor(props) {
+ super(props);
+ this.handleInputChange = this.handleInputChange.bind(this);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ this.setConfig = this.setConfig.bind(this);
+ this.prepareConfig = this.prepareConfig.bind(this);
+ this.fileReadFailed = this.fileReadFailed.bind(this);
+ this.file = null;
+ this.state = {
+ config: null,
+ file_error: null,
+ submitting: "none",
+ }
+ }
+
+ handleInputChange(e) {
+ const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
+ const name = e.target.name;
+ const config = this.state.config;
+ config[name] = value;
+
+ this.forceUpdate();
+ }
+
+ prepareConfig() {
+ this.state.config.latency = parseInt(this.state.config.latency);
+ if (this.state.config.input === true) {
+ // log.input
+ }
+ }
+
+ handleSubmit(event) {
+ this.setState({submitting:"block"});
+ console.log(event);
+ this.prepareConfig();
+ this.file.replace(this.state.config).done(() => {
+ console.log('updated');
+ this.setState({submitting:"none"});
+ })
+ .fail((error) => {
+ console.log(error);
+ });
+ event.preventDefault();
+ }
+
+ setConfig(data) {
+ console.log(data);
+ this.setState({config: data});
+ }
+
+ fileReadFailed(reason) {
+ console.log(reason);
+ this.setState({file_error: reason});
+ console.log('failed to read file');
+ }
+
+ componentDidMount() {
+ let parseFunc = function(data) {
+ console.log(data);
+ // return data;
+ return json.parse(data, null, true);
+ };
+
+ let stringifyFunc = function(data) {
+ return json.stringify(data, null, true);
+ };
+ // needed for cockpit.file usage
+ let syntax_object = {
+ parse: parseFunc,
+ stringify: stringifyFunc,
+ };
+
+ this.file = cockpit.file("/etc/tlog/tlog-rec-session.conf", {
+ syntax: syntax_object,
+ // binary: boolean,
+ // max_read_size: int,
+ superuser: true,
+ // host: string
+ });
+
+ console.log(this.file);
+
+ let promise = this.file.read();
+
+ promise.done((data) => {
+ if (data === null) {
+ this.fileReadFailed();
+ return;
+ }
+ this.setConfig(data);
+ }).fail((data) => {
+ this.fileReadFailed(data);
+ });
+ }
+
+ render() {
+ if (this.state.config != null && this.state.file_error === null) {
+ return (
+
+ );
+ } else {
+ return (
+
+
+
There is no configuration file of tlog present in your system.
+
Please, check the /etc/tlog/tlog-rec-session.conf or if tlog is installed.
+
{this.state.file_error}
+
+ );
+ }
+ }
+ }
+
+ React.render(
, document.getElementById('view'));
+}());
diff --git a/src/index.html b/src/index.html
index 5182d9b..e23d550 100644
--- a/src/index.html
+++ b/src/index.html
@@ -1,5 +1,7 @@
-
+
+
-
Cockpit Starter Kit
-
-
-
-
-
-
-
-
-
-
+
Journal
+
+
+
+
+
+
-
+
+
+
diff --git a/src/manifest.json b/src/manifest.json
index 3e2454a..2fe650b 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -1,12 +1,15 @@
{
- "version": "0.1",
+ "version": "163.x",
+ "name": "session_recording",
+
"requires": {
- "cockpit": "137"
+ "cockpit": "122"
},
- "tools": {
+ "menu": {
"index": {
- "label": "Starter Kit"
+ "label": "Session Recording",
+ "order": 100
}
}
}
diff --git a/src/manifest.json.in b/src/manifest.json.in
new file mode 100644
index 0000000..bab1160
--- /dev/null
+++ b/src/manifest.json.in
@@ -0,0 +1,15 @@
+{
+ "version": "@VERSION@",
+ "name": "session_recording",
+
+ "requires": {
+ "cockpit": "122"
+ },
+
+ "menu": {
+ "index": {
+ "label": "Session Recording",
+ "order": 100
+ }
+ }
+}
diff --git a/src/pkg/lib/cockpit-components-listing.jsx b/src/pkg/lib/cockpit-components-listing.jsx
new file mode 100644
index 0000000..fe69f07
--- /dev/null
+++ b/src/pkg/lib/cockpit-components-listing.jsx
@@ -0,0 +1,360 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2016 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see
.
+ */
+
+"use strict";
+
+var React = require('react');
+
+require('./listing.less');
+
+/* 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
+ * columns list of columns to show in the header
+ * columns to show, can be a string, react component or object with { name: 'name', 'header': false }
+ * 'header' (or if simple string) defaults to false
+ * in case 'header' is true, is used for the entries, otherwise
+ * tabRenderers optional: list of tab renderers for inline expansion, array of objects with
+ * - name tab name (has to be unique in the entry, used as react key)
+ * - renderer react component
+ * - data render data passed to the tab renderer
+ * - presence 'always', 'onlyActive', 'loadOnDemand', default: 'loadOnDemand'
+ * - 'always' once a row is expanded, this tab is always rendered, but invisible if not active
+ * - 'onlyActive' the tab is only rendered when active
+ * - 'loadOnDemand' the tab is first rendered when it becomes active, then follows 'always' behavior
+ * if tabRenderers isn't set, item can't be expanded inline
+ * navigateToItem optional: callback triggered when a row is clicked, pattern suggests navigation
+ * to view expanded item details, if not set, navigation isn't available
+ * listingDetail optional: text rendered next to action buttons, similar style to the tab headers
+ * listingActions optional: buttons that are presented as actions for the expanded item
+ * selectChanged optional: callback will be used when the "selected" state changes
+ * selected optional: true if the item is selected, can't be true if row has navigation or expansion
+ * initiallyExpanded optional: the entry will be initially rendered as expanded, but then behaves normally
+ * expandChanged optional: callback will be used if the row is either expanded or collapsed passing single `isExpanded` boolean argument
+ */
+var ListingRow = React.createClass({
+ propTypes: {
+ rowId: React.PropTypes.string,
+ columns: React.PropTypes.array.isRequired,
+ tabRenderers: React.PropTypes.array,
+ navigateToItem: React.PropTypes.func,
+ listingDetail: React.PropTypes.node,
+ listingActions: React.PropTypes.arrayOf(React.PropTypes.node),
+ selectChanged: React.PropTypes.func,
+ selected: React.PropTypes.bool,
+ initiallyExpanded: React.PropTypes.bool,
+ expandChanged: React.PropTypes.func,
+ initiallyActiveTab: React.PropTypes.bool,
+ },
+ getDefaultProps: function () {
+ return {
+ tabRenderers: [],
+ navigateToItem: null,
+ };
+ },
+ getInitialState: function() {
+ return {
+ expanded: this.props.initiallyExpanded, // show expanded view if true, otherwise one line compact
+ activeTab: this.props.initiallyActiveTab ? this.props.initiallyActiveTab : 0, // currently active tab in expanded mode, defaults to first tab
+ loadedTabs: {}, // which tabs were already loaded - this is important for 'loadOnDemand' setting
+ // contains tab indices
+ selected: this.props.selected, // whether the current row is selected
+ };
+ },
+ handleNavigateClick: function(e) {
+ // only consider primary mouse button
+ if (!e || e.button !== 0)
+ return;
+ this.props.navigateToItem();
+ },
+ handleExpandClick: function(e) {
+ // only consider primary mouse button
+ if (!e || e.button !== 0)
+ return;
+
+ var willBeExpanded = !this.state.expanded && this.props.tabRenderers.length > 0;
+ this.setState({ expanded: willBeExpanded });
+
+ var loadedTabs = {};
+ // unload all tabs if not expanded
+ if (willBeExpanded) {
+ // see if we should preload some tabs
+ var tabIdx;
+ var tabPresence;
+ for (tabIdx = 0; tabIdx < this.props.tabRenderers.length; tabIdx++) {
+ if ('presence' in this.props.tabRenderers[tabIdx])
+ tabPresence = this.props.tabRenderers[tabIdx].presence;
+ else
+ tabPresence = 'default';
+ // the active tab is covered by separate logic
+ if (tabPresence == 'always')
+ loadedTabs[tabIdx] = true;
+ }
+ // ensure the active tab is loaded
+ loadedTabs[this.state.activeTab] = true;
+ }
+
+ this.setState({ loadedTabs: loadedTabs });
+
+ this.props.expandChanged && this.props.expandChanged(willBeExpanded);
+
+ e.stopPropagation();
+ e.preventDefault();
+ },
+ handleSelectClick: function(e) {
+ // only consider primary mouse button
+ if (!e || e.button !== 0)
+ return;
+
+ var selected = !this.state.selected;
+ this.setState({ selected: selected });
+
+ if (this.props.selectChanged)
+ this.props.selectChanged(selected);
+
+ e.stopPropagation();
+ e.preventDefault();
+ },
+ handleTabClick: function(tabIdx, e) {
+ // only consider primary mouse button
+ if (!e || e.button !== 0)
+ return;
+ var prevTab = this.state.activeTab;
+ var prevTabPresence = 'default';
+ var loadedTabs = this.state.loadedTabs;
+ if (prevTab !== tabIdx) {
+ // see if we need to unload the previous tab
+ if ('presence' in this.props.tabRenderers[prevTab])
+ prevTabPresence = this.props.tabRenderers[prevTab].presence;
+
+ if (prevTabPresence == 'onlyActive')
+ delete loadedTabs[prevTab];
+
+ // ensure the new tab is loaded and update state
+ loadedTabs[tabIdx] = true;
+ this.setState({ loadedTabs: loadedTabs, activeTab: tabIdx });
+ }
+ e.stopPropagation();
+ e.preventDefault();
+ },
+ render: function() {
+ var self = this;
+ // only enable navigation if a function is provided and the row isn't expanded (prevent accidental navigation)
+ var allowNavigate = !!this.props.navigateToItem && !this.state.expanded;
+
+ var headerEntries = this.props.columns.map(function(itm) {
+ if (typeof itm === 'string' || typeof itm === 'number' || itm === null || itm === undefined || itm instanceof String || React.isValidElement(itm))
+ return ( {itm} );
+ else if ('header' in itm && itm.header)
+ return ({itm.name} );
+ else if ('tight' in itm && itm.tight)
+ return ({itm.name || itm.element} );
+ else
+ return ({itm.name} );
+ });
+
+ var allowExpand = (this.props.tabRenderers.length > 0);
+ var expandToggle;
+ if (allowExpand) {
+ expandToggle =
+
+ ;
+ } else {
+ expandToggle = ;
+ }
+
+ var listingItemClasses = ["listing-ct-item"];
+ if (!allowNavigate)
+ listingItemClasses.push("listing-ct-nonavigate");
+ if (!allowExpand)
+ listingItemClasses.push("listing-ct-noexpand");
+
+ var allowSelect = !(allowNavigate || allowExpand) && (this.state.selected !== undefined);
+ var clickHandler;
+ if (allowSelect) {
+ clickHandler = this.handleSelectClick;
+ if (this.state.selected)
+ listingItemClasses.push("listing-ct-selected");
+ } else {
+ if (allowNavigate)
+ clickHandler = this.handleNavigateClick;
+ else
+ clickHandler = this.handleExpandClick;
+ }
+
+ var listingItem = (
+
+ {expandToggle}
+ {headerEntries}
+
+ );
+
+ if (this.state.expanded) {
+ var links = this.props.tabRenderers.map(function(itm, idx) {
+ return (
+
+ {itm.name}
+
+ );
+ });
+ var tabs = [];
+ var tabIdx;
+ var Renderer;
+ var rendererData;
+ var row;
+ for (tabIdx = 0; tabIdx < this.props.tabRenderers.length; tabIdx++) {
+ Renderer = this.props.tabRenderers[tabIdx].renderer;
+ rendererData = this.props.tabRenderers[tabIdx].data;
+ if (tabIdx !== this.state.activeTab && !(tabIdx in this.state.loadedTabs))
+ continue;
+ row =
;
+ if (tabIdx === this.state.activeTab)
+ tabs.push(
{row}
);
+ else
+ tabs.push(
{row}
);
+ }
+
+ var listingDetail;
+ if ('listingDetail' in this.props) {
+ listingDetail = (
+
+ {this.props.listingDetail}
+
+ );
+ }
+
+ return (
+
+ {listingItem}
+
+
+
+
+ {listingDetail}
+ {this.props.listingActions}
+
+
+
+ {tabs}
+
+
+
+ );
+ } else {
+ return (
+
+ {listingItem}
+
+
+ );
+ }
+ }
+});
+
+/* Implements a PatternFly 'List View' pattern
+ * https://www.patternfly.org/list-view/
+ * Properties:
+ * - title
+ * - fullWidth optional: set width to 100% of parent, defaults to true
+ * - emptyCaption header caption to show if list is empty, defaults to "No entries"
+ * - columnTitles: array of column titles, as strings
+ * - columnTitleClick: optional callback for clicking on column title (for sorting)
+ * receives the column index as argument
+ * - actions: additional listing-wide actions (displayed next to the list's title)
+ */
+var Listing = React.createClass({
+ propTypes: {
+ title: React.PropTypes.string.isRequired,
+ fullWidth: React.PropTypes.bool,
+ emptyCaption: React.PropTypes.string.isRequired,
+ columnTitles: React.PropTypes.arrayOf(React.PropTypes.string),
+ columnTitleClick: React.PropTypes.func,
+ actions: React.PropTypes.arrayOf(React.PropTypes.node)
+ },
+ getDefaultProps: function () {
+ return {
+ fullWidth: true,
+ columnTitles: [],
+ actions: []
+ };
+ },
+ render: function() {
+ var self = this;
+ var bodyClasses = ["listing", "listing-ct"];
+ if (this.props.fullWidth)
+ bodyClasses.push("listing-ct-wide");
+ var headerClasses;
+ var headerRow;
+ var selectableRows;
+ if (!this.props.children || this.props.children.length === 0) {
+ headerClasses = "listing-ct-empty";
+ headerRow =
{this.props.emptyCaption} ;
+ } else if (this.props.columnTitles.length) {
+ // check if any of the children are selectable
+ selectableRows = false;
+ this.props.children.forEach(function(r) {
+ if (r.props.selected !== undefined)
+ selectableRows = true;
+ });
+
+ if (selectableRows) {
+ // now make sure that if one is set, it's available on all items
+ this.props.children.forEach(function(r) {
+ if (r.props.selected === undefined)
+ r.props.selected = false;
+ });
+ }
+
+ headerRow = (
+
+
+ { this.props.columnTitles.map(function (title, index) {
+ var clickHandler = null;
+ if (self.props.columnTitleClick)
+ clickHandler = function() { self.props.columnTitleClick(index) };
+ return {title} ;
+ }) }
+
+ );
+ } else {
+ headerRow =
+ }
+ var caption;
+ if (this.props.title || (this.props.actions && this.props.actions.length > 0))
+ caption =
{this.props.title}{this.props.actions} ;
+
+ return (
+
+ {caption}
+
+ {headerRow}
+
+ {this.props.children}
+
+ );
+ },
+});
+
+module.exports = {
+ ListingRow: ListingRow,
+ Listing: Listing,
+};
diff --git a/src/pkg/lib/console.css b/src/pkg/lib/console.css
new file mode 100644
index 0000000..b8ee78b
--- /dev/null
+++ b/src/pkg/lib/console.css
@@ -0,0 +1,59 @@
+@import "term.css";
+
+/* Our terminal or logs */
+.console-ct {
+ font-family: Menlo, Monaco, Consolas, monospace;
+ margin-top: 0;
+ margin-bottom: 0;
+ font-size: 10px;
+ text-align: center;
+ line-height: normal;
+}
+
+@media (min-width: 568px) {
+ .console-ct {
+ font-size: 12px;
+ }
+}
+
+.console-ct > pre {
+ padding: 10px;
+ text-align: left;
+ display: block;
+ font-family: inherit;
+ font-size: inherit;
+ width: 48em;
+ height: 310px;
+ overflow-y: scroll;
+ white-space: pre-wrap;
+ margin: 0 auto;
+}
+
+.console-ct > .terminal {
+ color: #F0F0F0;
+ text-align: left;
+ outline: medium none;
+ background-color: black;
+ border: 1px solid black;
+ padding: 10px;
+}
+
+.terminal .terminal-cursor {
+ border: 1px solid #f0f0f0;
+}
+
+.terminal:focus .terminal-cursor {
+ border: none;
+ animation: blink 1s step-end infinite;
+}
+
+@keyframes blink {
+ from {
+ color: #000;
+ background: #f0f0f0;
+ }
+ 50% {
+ color: #f0f0f0;
+ background: #000;
+ }
+}
diff --git a/src/pkg/lib/journal.css b/src/pkg/lib/journal.css
new file mode 100644
index 0000000..8d023f9
--- /dev/null
+++ b/src/pkg/lib/journal.css
@@ -0,0 +1,134 @@
+/*
+ * 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
.
+ */
+
+.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;
+}
+
diff --git a/src/pkg/lib/journal.js b/src/pkg/lib/journal.js
new file mode 100644
index 0000000..2567a00
--- /dev/null
+++ b/src/pkg/lib/journal.js
@@ -0,0 +1,584 @@
+/*
+ * 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
.
+ */
+
+(function() {
+ "use strict";
+
+ var cockpit = require("cockpit");
+ var Mustache = require("mustache");
+ var day_header_template = require('raw-loader!journal_day_header.mustache');
+ var line_template = require('raw-loader!journal_line.mustache');
+ var reboot_template = require('raw-loader!journal_reboot.mustache');
+
+ var _ = cockpit.gettext;
+ var C_ = cockpit.gettext;
+
+ var journal = { };
+
+ /**
+ * journalctl([match, ...], [options])
+ * @match: any number of journal match strings
+ * @options: an object containing further options
+ *
+ * Load and (by default) stream journal entries as
+ * json objects. This function returns a jQuery deferred
+ * object which delivers the various journal entries.
+ *
+ * The various @match strings are journalctl matches.
+ * Zero, one or more can be specified. They must be in
+ * string format, or arrays of strings.
+ *
+ * The optional @options object can contain the following:
+ * * "host": the host to load journal from
+ * * "count": number of entries to load and/or pre-stream.
+ * Default is 10
+ * * "follow": if set to false just load entries and don't
+ * stream further journal data. Default is true.
+ * * "directory": optional directory to load journal files
+ * * "boot": when set only list entries from this specific
+ * boot id, or if null then the current boot.
+ * * "since": if specified list entries since the date/time
+ * * "until": if specified list entries until the date/time
+ * * "cursor": a cursor to start listing entries from
+ * * "after": a cursor to start listing entries after
+ *
+ * Returns a jQuery deferred promise. You can call these
+ * functions on the deferred to handle the responses. Note that
+ * there are additional non-jQuery methods.
+ *
+ * .done(function(entries) { }): Called when done, @entries is
+ * an array of all journal entries loaded. If .stream()
+ * has been invoked then @entries will be empty.
+ * .fail(funciton(ex) { }): called if the operation fails
+ * .stream(function(entries) { }): called when we receive entries
+ * entries. Called once per batch of journal @entries,
+ * whether following or not.
+ * .stop(): stop following or retrieving entries.
+ */
+
+ journal.journalctl = function journalctl(/* ... */) {
+ var matches = [];
+ var i, arg, options = { follow: true };
+ for (i = 0; i < arguments.length; i++) {
+ arg = arguments[i];
+ if (typeof arg == "string") {
+ matches.push(arg);
+ } else if (typeof arg == "object") {
+ if (arg instanceof Array) {
+ matches.push.apply(matches, arg);
+ } else {
+ cockpit.extend(options, arg);
+ break;
+ }
+ } else {
+ console.warn("journal.journalctl called with invalid argument:", arg);
+ }
+ }
+
+ if (options.count === undefined) {
+ if (options.follow)
+ options.count = 10;
+ else
+ options.count = null;
+ }
+
+ var cmd = [ "journalctl", "-q", "--output=json" ];
+ if (!options.count)
+ cmd.push("--no-tail");
+ else
+ cmd.push("--lines=" + options.count);
+ if (options.directory)
+ cmd.push("--directory=" + options.directory);
+ if (options.boot)
+ cmd.push("--boot=" + options.boot);
+ else if (options.boot !== undefined)
+ cmd.push("--boot");
+ if (options.since)
+ 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);
+
+ /* journalctl doesn't allow reverse and follow together */
+ if (options.reverse)
+ cmd.push("--reverse");
+ else if (options.follow)
+ cmd.push("--follow");
+
+ cmd.push("--");
+ cmd.push.apply(cmd, matches);
+
+ var dfd = new cockpit.defer();
+ var promise;
+ var buffer = "";
+ var entries = [];
+ var streamers = [];
+ var interval = null;
+
+ function fire_streamers() {
+ var ents, i;
+ if (streamers.length && entries.length > 0) {
+ ents = entries;
+ entries = [];
+ for (i = 0; i < streamers.length; i++)
+ streamers[i].apply(promise, [ents]);
+ } else {
+ window.clearInterval(interval);
+ interval = null;
+ }
+ }
+
+ var proc = cockpit.spawn(cmd, { host: options.host, batch: 8192, latency: 300, superuser: "try" }).
+ stream(function(data) {
+
+ if (buffer)
+ data = buffer + data;
+ buffer = "";
+
+ var lines = data.split("\n");
+ var last = lines.length - 1;
+ lines.forEach(function(line, i) {
+ if (i == last) {
+ buffer = line;
+ } else if (line && line.indexOf("-- ") !== 0) {
+ try {
+ entries.push(JSON.parse(line));
+ } catch (e) {
+ console.warn(e, line);
+ }
+ }
+ });
+
+ if (streamers.length && interval === null)
+ interval = window.setInterval(fire_streamers, 300);
+ }).
+ done(function() {
+ fire_streamers();
+ dfd.resolve(entries);
+ }).
+ fail(function(ex) {
+ /* The journalctl command fails when no entries are matched
+ * so we just ignore this status code */
+ if (ex.problem == "cancelled" ||
+ ex.exit_status === 1) {
+ fire_streamers();
+ dfd.resolve(entries);
+ } else {
+ dfd.reject(ex);
+ }
+ }).
+ always(function() {
+ window.clearInterval(interval);
+ });
+
+ promise = dfd.promise();
+ promise.stream = function stream(callback) {
+ streamers.push(callback);
+ return this;
+ };
+ promise.stop = function stop() {
+ proc.close("cancelled");
+ };
+ return promise;
+ };
+
+ journal.printable = function printable(value) {
+ if (value === undefined || value === null)
+ return _("[no data]");
+ else if (typeof(value) == "string")
+ return value;
+ else if (value.length !== undefined)
+ return cockpit.format(_("[$0 bytes of binary data]"), value.length);
+ else
+ return _("[binary data]");
+ };
+
+ function output_funcs_for_box(box) {
+ /* Dereference any jQuery object here */
+ if (box.jquery)
+ box = box[0];
+
+ Mustache.parse(day_header_template);
+ Mustache.parse(line_template);
+ Mustache.parse(reboot_template);
+
+ function render_line(ident, prio, message, count, time, entry) {
+ var parts = {
+ 'cursor': entry["__CURSOR"],
+ 'time': time,
+ 'message': message,
+ 'service': ident
+ };
+ if (count > 1)
+ parts['count'] = count;
+ if (ident === 'abrt-notification') {
+ parts['problem'] = true;
+ parts['service'] = entry['PROBLEM_BINARY'];
+ }
+ else if (prio < 4)
+ parts['warning'] = true;
+ return Mustache.render(line_template, parts);
+ }
+
+ var reboot = _("Reboot");
+ var reboot_line = Mustache.render(reboot_template, {'message': reboot} );
+
+ function render_reboot_separator() {
+ return reboot_line;
+ }
+
+ function render_day_header(day) {
+ return Mustache.render(day_header_template, {'day': day} );
+ }
+
+ function parse_html(string) {
+ var div = document.createElement("div");
+ div.innerHTML = string.trim();
+ return div.children[0];
+ }
+
+ return {
+ render_line: render_line,
+ render_day_header: render_day_header,
+ render_reboot_separator: render_reboot_separator,
+
+ append: function(elt) {
+ if (typeof (elt) == "string")
+ elt = parse_html(elt);
+ box.appendChild(elt);
+ },
+ prepend: function(elt) {
+ if (typeof (elt) == "string")
+ elt = parse_html(elt);
+ if (box.firstChild)
+ box.insertBefore(elt, box.firstChild);
+ else
+ box.appendChild(elt);
+ },
+ remove_last: function() {
+ if (box.lastChild)
+ box.removeChild(box.lastChild);
+ },
+ remove_first: function() {
+ if (box.firstChild)
+ box.removeChild(box.firstChild);
+ },
+ };
+ }
+
+ var month_names = [
+ C_("month-name", 'January'),
+ C_("month-name", 'February'),
+ C_("month-name", 'March'),
+ C_("month-name", 'April'),
+ C_("month-name", 'May'),
+ C_("month-name", 'June'),
+ C_("month-name", 'July'),
+ C_("month-name", 'August'),
+ C_("month-name", 'September'),
+ C_("month-name", 'October'),
+ C_("month-name", 'November'),
+ C_("month-name", 'December')
+ ];
+
+ /* Render the journal entries by passing suitable HTML strings back to
+ the caller via the 'output_funcs'.
+
+ Rendering is context aware. It will insert 'reboot' markers, for
+ example, and collapse repeated lines. You can extend the output at
+ the bottom and also at the top.
+
+ A new renderer is created by calling 'journal.renderer' like
+ so:
+
+ var renderer = journal.renderer(funcs);
+
+ You can feed new entries into the renderer by calling various
+ methods on the returned object:
+
+ - renderer.append(journal_entry)
+ - renderer.append_flush()
+ - renderer.prepend(journal_entry)
+ - renderer.prepend_flush()
+
+ A 'journal_entry' is one element of the result array returned by a
+ call to 'Query' with the 'cockpit.journal_fields' as the fields to
+ return.
+
+ Calling 'append' will append the given entry to the end of the
+ output, naturally, and 'prepend' will prepend it to the start.
+
+ The output might lag behind what has been input via 'append' and
+ 'prepend', and you need to call 'append_flush' and 'prepend_flush'
+ respectively to ensure that the output is up-to-date. Flushing a
+ renderer does not introduce discontinuities into the output. You
+ can continue to feed entries into the renderer after flushing and
+ repeated lines will be correctly collapsed across the flush, for
+ example.
+
+ The renderer will call methods of the 'output_funcs' object to
+ produce the desired output:
+
+ - output_funcs.append(rendered)
+ - output_funcs.remove_last()
+ - output_funcs.prepend(rendered)
+ - output_funcs.remove_first()
+
+ The 'rendered' argument is the return value of one of the rendering
+ functions described below. The 'append' and 'prepend' methods
+ should add this element to the output, naturally, and 'remove_last'
+ and 'remove_first' should remove the indicated element.
+
+ If you never call 'prepend' on the renderer, 'output_func.prepend'
+ isn't called either. If you never call 'renderer.prepend' after
+ 'renderer.prepend_flush', then 'output_func.remove_first' will
+ never be called. The same guarantees exist for the 'append' family
+ of functions.
+
+ The actual rendering is also done by calling methods on
+ 'output_funcs':
+
+ - output_funcs.render_line(ident, prio, message, count, time, cursor)
+ - output_funcs.render_day_header(day)
+ - output_funcs.render_reboot_separator()
+
+ */
+
+ journal.renderer = function renderer(funcs_or_box) {
+ var output_funcs;
+ if (funcs_or_box.render_line)
+ output_funcs = funcs_or_box;
+ else
+ output_funcs = output_funcs_for_box(funcs_or_box);
+
+ function copy_object(o) {
+ var c = { }; for(var p in o) c[p] = o[p]; return c;
+ }
+
+ // A 'entry' object describes a journal entry in formatted form.
+ // It has fields 'bootid', 'ident', 'prio', 'message', 'time',
+ // 'day', all of which are strings.
+
+ function format_entry(journal_entry) {
+ function pad(n) {
+ var str = n.toFixed();
+ if(str.length == 1)
+ str = '0' + str;
+ return str;
+ }
+
+ var d = new Date(journal_entry["__REALTIME_TIMESTAMP"] / 1000);
+ return {
+ cursor: journal_entry["__CURSOR"],
+ full: journal_entry,
+ day: month_names[d.getMonth()] + ' ' + d.getDate().toFixed() + ', ' + 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) {
+ return (a && b &&
+ a.day == b.day &&
+ a.bootid == b.bootid &&
+ a.ident == b.ident &&
+ a.prio == b.prio &&
+ a.message == b.message);
+ }
+
+ // A state object describes a line that should be eventually
+ // output. It has an 'entry' field as per description above, and
+ // also 'count', 'last_time', and 'first_time', which record
+ // repeated entries. Additionally:
+ //
+ // line_present: When true, the line has been output already with
+ // some preliminary data. It needs to be removed before
+ // outputting more recent data.
+ //
+ // header_present: The day header has been output preliminarily
+ // before the actual log lines. It needs to be removed before
+ // prepending more lines. If both line_present and
+ // header_present are true, then the header comes first in the
+ // output, followed by the line.
+
+ function render_state_line(state) {
+ return output_funcs.render_line(state.entry.ident,
+ state.entry.prio,
+ state.entry.message,
+ state.count,
+ state.last_time,
+ state.entry.full);
+ }
+
+ // We keep the state of the first and last journal lines,
+ // respectively, in order to collapse repeated lines, and to
+ // insert reboot markers and day headers.
+ //
+ // Normally, there are two state objects, but if only a single
+ // line has been output so far, top_state and bottom_state point
+ // to the same object.
+
+ var top_state, bottom_state;
+
+ top_state = bottom_state = { };
+
+ function start_new_line() {
+ // If we now have two lines, split the state
+ if (top_state === bottom_state && top_state.entry) {
+ top_state = copy_object(bottom_state);
+ }
+ }
+
+ function top_output() {
+ if (top_state.header_present) {
+ output_funcs.remove_first();
+ top_state.header_present = false;
+ }
+ if (top_state.line_present) {
+ output_funcs.remove_first();
+ top_state.line_present = false;
+ }
+ if (top_state.entry) {
+ output_funcs.prepend(render_state_line(top_state));
+ top_state.line_present = true;
+ }
+ }
+
+ function prepend(journal_entry) {
+ var entry = format_entry(journal_entry);
+
+ if (entry_is_equal(top_state.entry, entry)) {
+ top_state.count += 1;
+ top_state.first_time = entry.time;
+ } else {
+ top_output();
+
+ if (top_state.entry) {
+ if (entry.bootid != top_state.entry.bootid)
+ output_funcs.prepend(output_funcs.render_reboot_separator());
+ if (entry.day != top_state.entry.day)
+ output_funcs.prepend(output_funcs.render_day_header(top_state.entry.day));
+ }
+
+ start_new_line();
+ top_state.entry = entry;
+ top_state.count = 1;
+ top_state.first_time = top_state.last_time = entry.time;
+ top_state.line_present = false;
+ }
+ }
+
+ function prepend_flush() {
+ top_output();
+ if (top_state.entry) {
+ output_funcs.prepend(output_funcs.render_day_header(top_state.entry.day));
+ top_state.header_present = true;
+ }
+ }
+
+ function bottom_output() {
+ if (bottom_state.line_present) {
+ output_funcs.remove_last();
+ bottom_state.line_present = false;
+ }
+ if (bottom_state.entry) {
+ output_funcs.append(render_state_line(bottom_state));
+ bottom_state.line_present = true;
+ }
+ }
+
+ function append(journal_entry) {
+ var entry = format_entry(journal_entry);
+
+ if (entry_is_equal(bottom_state.entry, entry)) {
+ bottom_state.count += 1;
+ bottom_state.last_time = entry.time;
+ } else {
+ bottom_output();
+
+ if (!bottom_state.entry || entry.day != bottom_state.entry.day) {
+ output_funcs.append(output_funcs.render_day_header(entry.day));
+ bottom_state.header_present = true;
+ }
+ if (bottom_state.entry && entry.bootid != bottom_state.entry.bootid)
+ output_funcs.append(output_funcs.render_reboot_separator());
+
+ start_new_line();
+ bottom_state.entry = entry;
+ bottom_state.count = 1;
+ bottom_state.first_time = bottom_state.last_time = entry.time;
+ bottom_state.line_present = false;
+ }
+ }
+
+ function append_flush() {
+ bottom_output();
+ }
+
+ return { prepend: prepend,
+ prepend_flush: prepend_flush,
+ append: append,
+ append_flush: append_flush
+ };
+ };
+
+ journal.logbox = function logbox(match, max_entries) {
+ var entries = [ ];
+ var box = document.createElement("div");
+
+ function render() {
+ var renderer = journal.renderer(box);
+ while(box.firstChild)
+ box.removeChild(box.firstChild);
+ for (var i = 0; i < entries.length; i++) {
+ renderer.prepend(entries[i]);
+ }
+ renderer.prepend_flush();
+ if (entries.length > 0)
+ box.removeAttribute("hidden");
+ else
+ box.setAttribute("hidden", "hidden");
+ }
+
+ render();
+
+ var promise = journal.journalctl(match, { count: max_entries }).
+ stream(function(tail) {
+ entries = entries.concat(tail);
+ if (entries.length > max_entries)
+ entries = entries.slice(-max_entries);
+ render();
+ }).
+ fail(function(error) {
+ box.appendChild(document.createTextNode(error.message));
+ box.removeAttribute("hidden");
+ });
+
+ /* Both a DOM element and a promise */
+ return promise.promise(box);
+ };
+
+ module.exports = journal;
+}());
diff --git a/src/pkg/lib/journal_day_header.mustache b/src/pkg/lib/journal_day_header.mustache
new file mode 100644
index 0000000..708ae04
--- /dev/null
+++ b/src/pkg/lib/journal_day_header.mustache
@@ -0,0 +1 @@
+
{{day}}
diff --git a/src/pkg/lib/journal_line.mustache b/src/pkg/lib/journal_line.mustache
new file mode 100644
index 0000000..d552baa
--- /dev/null
+++ b/src/pkg/lib/journal_line.mustache
@@ -0,0 +1,19 @@
+
+
{{#warning}}
+
+ {{/warning}}{{#problem}}
+
+ {{/problem}}
+
+
{{time}}
+
{{message}}
+ {{! if we have count (repeated messages), show service name and badge - otherwise just the service }}
+ {{#count}}
+
+
{{service}}
+
{{count}}
+
+ {{/count}}{{^count}}
+
{{service}}
+ {{/count}}
+
diff --git a/src/pkg/lib/journal_reboot.mustache b/src/pkg/lib/journal_reboot.mustache
new file mode 100644
index 0000000..25713fe
--- /dev/null
+++ b/src/pkg/lib/journal_reboot.mustache
@@ -0,0 +1,5 @@
+
+ {{! placeholders for correct message alignment }}
+
+
{{message}}
+
diff --git a/src/pkg/lib/listing.less b/src/pkg/lib/listing.less
new file mode 100644
index 0000000..509c1fc
--- /dev/null
+++ b/src/pkg/lib/listing.less
@@ -0,0 +1,502 @@
+/* Listing pattern */
+
+@import "./variables.less";
+
+table.listing-ct {
+ margin-top: @listing-ct-spacing;
+ min-width: 65%;
+}
+
+table.listing-ct > caption,
+table.listing-ct > thead h3 {
+ font-size: @font-size-h2;
+ padding: @listing-ct-padding 0px @listing-ct-padding;
+ font-weight: 300;
+ margin-top: 0;
+}
+
+table.listing-ct > caption {
+ color: inherit;
+ line-height: 1.1;
+}
+
+table.listing-ct > thead td {
+ padding-top: @listing-ct-padding * 2;
+}
+
+table.listing-ct > thead:first-child td {
+ padding-top: 0;
+}
+
+table.listing-ct > thead td > a {
+ line-height: 30px;
+ padding: @listing-ct-padding 0 @listing-ct-padding;
+}
+
+table.listing-ct > thead th {
+ border-top: 1px solid @gray-lighter;
+ font-weight: normal;
+ padding: @listing-ct-padding;
+ color: @listing-ct-metadata;
+}
+
+table.listing-ct > thead th:first-child {
+ padding-left: @listing-ct-padding * 2;
+}
+
+table.listing-ct > thead th:last-child {
+ text-align: right;
+}
+
+/* A listing item is a row in the table */
+tbody > tr.listing-ct-item {
+ border-top: 1px solid @gray-lighter;
+ border-bottom: 1px solid @gray-lighter;
+ cursor: pointer;
+}
+
+table.listing-ct > tbody:last-child {
+ border-bottom: 1px solid @gray-lighter;
+}
+table.listing-ct > tbody + thead {
+ border-top: 1px solid @gray-lighter;
+}
+table.listing-ct > tbody.open:last-child {
+ border-bottom: none;
+}
+table.listing-ct > tbody.open + thead {
+ border-top: none;
+}
+
+tbody > tr.listing-ct-item.listing-ct-warning {
+ background-color: @listing-ct-warning-color;
+}
+
+tbody.open > tr.listing-ct-item {
+ background-color: @color-pf-black-200;
+ border-bottom: none;
+ border-top: none;
+ border-left: 1px solid @listing-ct-border;
+ border-right: 1px solid @listing-ct-border;
+}
+
+tbody.open > tr.listing-ct-item td,
+tbody.open > tr.listing-ct-item th {
+ border-top: 1px solid @listing-ct-border;
+}
+
+tbody.open td div.listing-ct-head {
+ background-color: @color-pf-white;
+}
+
+tbody.open .listing-ct-panel {
+ border: 1px solid @listing-ct-border;
+}
+
+tbody.open .listing-ct-panel .listing-ct-body {
+ border: none;
+}
+
+tbody.open > tr.listing-ct-panel + tr.listing-ct-body {
+ border-top: none;
+}
+
+tbody.open > tr.listing-ct-panel td div.listing-ct-head {
+ border: none;
+ border-bottom: 1px solid @listing-ct-border;
+}
+
+/* only highlight the row if navigation is available */
+tbody:not(.open) > tr.listing-ct-item:not(.listing-ct-nonavigate):hover {
+ background-color: @listing-ct-hover;
+}
+
+/* if we can't navigate to a row but expand is available, highlight the caret */
+tbody:not(.open) > tr.listing-ct-item.listing-ct-nonavigate:hover td.listing-ct-toggle {
+ color: @listing-ct-active;
+}
+
+/* use gray for a row that's expanded or if navigation isn't available */
+tbody.open > tr.listing-ct-item:hover,
+tr.listing-ct-item.listing-ct-nonavigate:hover {
+ background-color: @color-pf-black-200;
+}
+
+/* always highlight caret when hovering over an expanded row */
+tbody.open > tr.listing-ct-item:hover td.listing-ct-toggle {
+ color: @listing-ct-active;
+}
+
+tr.listing-ct-item .listing-ct-toggle {
+ padding: 0 !important;
+ width: 35px;
+ color: @color-pf-black;
+}
+
+table.listing-ct thead .listing-ct-toggle + th,
+tr.listing-ct-item .listing-ct-toggle + td,
+tr.listing-ct-item .listing-ct-toggle + th {
+ padding-left: 0;
+}
+
+tr:not(.listing-ct-selected) {
+ td.listing-ct-toggle:hover {
+ color: @listing-ct-active;
+ background-color: @color-pf-black-200;
+
+ ~ td, ~ th {
+ background-color: @color-pf-black-200;
+ }
+ }
+ }
+td.listing-ct-toggle i {
+ font-size: 24px;
+ visibility: hidden;
+}
+
+tr.listing-ct-item:hover td.listing-ct-toggle i,
+tr.listing-ct-item td.listing-ct-toggle:hover i {
+ visibility: visible;
+}
+
+td.listing-ct-toggle i:before {
+ content: "\f105";
+}
+
+tbody.open td.listing-ct-toggle i {
+ visibility: visible;
+}
+
+tbody.open td.listing-ct-toggle i:before {
+ content: "\f107";
+}
+
+/* Listing items have decent padding ... */
+tr.listing-ct-item td {
+ padding: @listing-ct-padding;
+}
+
+tr.listing-ct-item th {
+ padding: @listing-ct-padding @listing-ct-padding @listing-ct-padding @listing-ct-spacing;
+}
+
+/* Listing caption is text next to the actions, text should be similar to nav (.nav-tabs-pf > li > a)*/
+.listing-ct-actions > .listing-ct-caption {
+ font-size: @font-size-h5;
+ vertical-align: middle;
+ color: @link-color;
+ padding-right: @listing-ct-padding / 2;
+}
+
+/* Listing actions can be used directly as a cell */
+tr.listing-ct-item td.listing-ct-actions,
+td.listing-ct-actions {
+ padding: @listing-ct-padding / 2 @listing-ct-padding;
+ text-align: right;
+ float: none;
+}
+
+/* if the entire row is selected, highlight */
+tr.listing-ct-item.listing-ct-selected {
+ background-color: @color-pf-blue-400;
+ color: @color-pf-white;
+ border-color: multiply(@color-pf-black-200, @color-pf-blue-400);
+
+ &:hover {
+ background-color: multiply(@color-pf-black-200, @color-pf-blue-400);
+ border-color: multiply(@color-pf-black-200, @color-pf-blue-400);
+ }
+}
+
+tr.listing-ct-item.listing-ct-selected .badge {
+ background-color: multiply(@badge-bg, @color-pf-blue-400);
+
+ &:hover {
+ background-color: multiply(@badge-bg, @color-pf-blue-500);
+ }
+}
+
+.listing-ct-head .listing-ct-actions {
+ margin-top: -7px;
+}
+
+tr.listing-ct-item td:first-child {
+ padding-left: @listing-ct-padding * 2;
+}
+
+/* The last column of a listing is always right aligned */
+tr.listing-ct-item td:last-child {
+ text-align: right;
+}
+
+div.listing-ct-panel {
+ box-shadow: 1px 1px 1px 1px @listing-ct-open;
+ margin-bottom: @listing-ct-spacing;
+}
+
+div.listing-ct-maybe {
+ border: 1px dashed @listing-ct-border-maybe;
+ box-shadow: none;
+}
+
+div.listing-ct-head {
+ padding: @listing-ct-padding @listing-ct-padding 0 @listing-ct-padding;
+ background-color: @listing-ct-open;
+ border-color: @listing-ct-border;
+ border-style: solid;
+ border-width: 1px 1px 0 1px;
+ overflow: hidden;
+}
+
+div.listing-ct-head:last-child {
+ padding-bottom: @listing-ct-padding;
+}
+
+div.listing-ct-maybe div.listing-ct-head,
+div.listing-ct-maybe div.listing-ct-body {
+ background-color: @color-pf-white;
+ border: none;
+}
+
+tbody.active .listing-ct-head {
+ border-top: @listing-ct-open-width solid #0099d3;
+}
+
+.listing-ct-head h3 {
+ font-weight: normal;
+ font-size: 18px;
+ margin-top: 0px;
+ margin-left: @listing-ct-padding / 2;
+ margin-bottom: @listing-ct-padding;
+}
+
+.listing-ct-head h3 i {
+ float: left;
+ padding-right: 7px;
+}
+
+.listing-ct-head .nav li a {
+ padding-top: 0px;
+ font-size: 13px;
+}
+
+.listing-ct-head .nav-tabs {
+ border-bottom: none;
+}
+
+.listing-ct-head .nav-tabs-pf {
+ margin-left: -@listing-ct-padding;
+}
+
+/* To display info instead of tabs */
+.listing-ct-head dl {
+ display: inline-block;
+ height: 1.6em;
+ margin-bottom: 5px;
+ white-space: nowrap;
+ margin-right: 45px;
+ margin-left: 5px;
+}
+
+.listing-ct-head dt {
+ font-weight: normal;
+ display: inline;
+ margin-right: 0.5em;
+ color: @listing-ct-metadata;
+}
+
+.listing-ct-head dd {
+ display: inline;
+ color: black;
+}
+
+.listing-ct-body {
+ padding: @listing-ct-padding * 2 @listing-ct-padding + @listing-ct-spacing;
+ font-size: 13px;
+ border: 1px solid @listing-ct-border;
+ background-color: @color-pf-white;
+}
+
+.listing-ct-inline > .listing-ct-body {
+ border: none;
+ padding-top: 0px;
+ padding-left: @listing-ct-padding * 2;
+ padding-bottom: 0px;
+}
+
+.listing-ct-inline > h3 {
+ border-top: 1px solid @listing-ct-border;
+ padding-top: 20px;
+ margin-top: 30px;
+}
+
+.listing-ct-inline > h3:first-child {
+ border-top: none;
+ padding-top: 0px;
+ margin-top: 20px;
+}
+
+.listing-ct-actions {
+ float: right;
+ min-height: 26px;
+}
+
+.listing-ct-status {
+ float: right;
+ clear: right;
+}
+
+.listing-ct-error {
+ border-top: 1px solid @listing-ct-border;
+ border-left: 1px solid @listing-ct-border;
+ border-right: 1px solid @listing-ct-border;
+}
+
+.listing-ct-error.alert {
+ margin-bottom: 0;
+}
+
+.listing-ct-body tt {
+ font-size: 12px
+}
+
+.listing-ct-body dl {
+ margin: 0;
+}
+
+.listing-ct-body dl dd dl.inline-dl dt,
+.listing-ct-body dt {
+ clear: left;
+ float: left;
+ width: 100px;
+ min-height: 26px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: @listing-ct-metadata;
+ font-weight: normal;
+}
+
+.listing-ct-body dt {
+ text-align: right;
+}
+
+.listing-ct-body dl dd dl dt {
+ text-align: left;
+}
+
+.listing-ct-body dd {
+ margin-left: 110px;
+ min-height: 26px;
+ max-width: 1000px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.listing-ct-body dl.full-width dt {
+ text-align: left;
+ min-width: none;
+ max-width: none;
+ float: none;
+ width: auto;
+}
+
+.listing-ct-body dl.full-width dd {
+ margin-left: 0px;
+}
+
+table.listing-ct tbody.open {
+ box-shadow: 1px 1px 1px 1px @color-pf-black-150;
+}
+
+/* By default these thincgs are hidden */
+tbody tr.listing-ct-panel,
+tbody tr.listing-ct-body {
+ display: none;
+}
+
+/* ... unless they are in the right state */
+tbody.open tr.listing-ct-head,
+tbody.open tr.listing-ct-panel,
+tbody.open tr.listing-ct-body {
+ display: table-row;
+}
+
+tbody.open tr.listing-ct-head {
+ border-color: @listing-ct-border;
+ border-style: solid;
+ border-width: 1px 1px 0 1px;
+ border-top: @listing-ct-open-width solid #0099d3;
+ background-color: @listing-ct-open;
+}
+
+tr.listing-ct-head + tr.listing-ct-head {
+ border-top: none;
+}
+
+tr.listing-ct-head + tr.listing-ct-head td {
+ padding: 0px @listing-ct-padding 0px @listing-ct-padding;
+}
+
+tr.listing-ct-body td {
+ padding: @listing-ct-padding * 2 @listing-ct-padding + @listing-ct-spacing;
+ font-size: 13px;
+}
+
+.listing-ct-empty {
+ color: #888;
+ text-align: center;
+ border-top: 1px solid @listing-ct-border-light;
+ border-bottom: 1px solid @listing-ct-border-light;
+}
+
+/* Used at the end of a group of tbody to show an 'empty' message */
+thead.listing-ct-empty td,
+table.listing-ct > thead.listing-ct-empty td {
+ padding: @listing-ct-padding;
+}
+
+tbody + thead.listing-ct-empty {
+ display: none;
+}
+
+/* Listing pattern defaults to using full width of parent */
+.listing-ct-wide {
+ width: 100%;
+}
+
+div.listing-ct-head {
+ overflow: visible;
+}
+
+.listing-ct-head .btn-group,
+.listing-ct-head button:not(.dropdown-toggle) {
+ margin-left: 0.3em;
+}
+
+table.listing-ct > caption a {
+ font-size: 16px;
+}
+
+tbody tr.listing-ct-noexpand {
+ cursor: default;
+}
+
+/* Fix up nav-tabs-pf to work properly */
+
+.nav-tabs-pf > li:first-child a {
+ margin-left: 0px !important;
+ padding-left: @listing-ct-spacing !important;
+}
+
+.nav-tabs-pf > li a:before {
+ right: 0px !important;
+}
+
+.nav-tabs-pf > li > a:active:before,
+.nav-tabs-pf > li > a:focus:before,
+.nav-tabs-pf > li > a:hover:before,
+.nav-tabs-pf > li.active a:before {
+ height: @listing-ct-open-width;
+ left: 0px;
+}
diff --git a/src/pkg/lib/page.css b/src/pkg/lib/page.css
new file mode 100644
index 0000000..e5690eb
--- /dev/null
+++ b/src/pkg/lib/page.css
@@ -0,0 +1,235 @@
+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;
+}
diff --git a/src/pkg/lib/table.css b/src/pkg/lib/table.css
new file mode 100644
index 0000000..e60493a
--- /dev/null
+++ b/src/pkg/lib/table.css
@@ -0,0 +1,146 @@
+/* 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;
+}
+
+.info-table-ct > tr > td:first-child,
+.info-table-ct > tbody > tr > td:first-child {
+ text-align: right;
+ 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%;
+ }
+}
diff --git a/src/pkg/lib/term.css b/src/pkg/lib/term.css
new file mode 100644
index 0000000..eb69642
--- /dev/null
+++ b/src/pkg/lib/term.css
@@ -0,0 +1,522 @@
+.term-bg-color-0 { background-color: #2e3436; }
+.term-fg-color-0 { color: #2e3436; }
+.term-bg-color-1 { background-color: #cc0000; }
+.term-fg-color-1 { color: #cc0000; }
+.term-bg-color-2 { background-color: #4e9a06; }
+.term-fg-color-2 { color: #4e9a06; }
+.term-bg-color-3 { background-color: #c4a000; }
+.term-fg-color-3 { color: #c4a000; }
+.term-bg-color-4 { background-color: #3465a4; }
+.term-fg-color-4 { color: #3465a4; }
+.term-bg-color-5 { background-color: #75507b; }
+.term-fg-color-5 { color: #75507b; }
+.term-bg-color-6 { background-color: #06989a; }
+.term-fg-color-6 { color: #06989a; }
+.term-bg-color-7 { background-color: #d3d7cf; }
+.term-fg-color-7 { color: #d3d7cf; }
+.term-bg-color-8 { background-color: #555753; }
+.term-fg-color-8 { color: #555753; }
+.term-bg-color-9 { background-color: #ef2929; }
+.term-fg-color-9 { color: #ef2929; }
+.term-bg-color-10 { background-color: #8ae234; }
+.term-fg-color-10 { color: #8ae234; }
+.term-bg-color-11 { background-color: #fce94f; }
+.term-fg-color-11 { color: #fce94f; }
+.term-bg-color-12 { background-color: #729fcf; }
+.term-fg-color-12 { color: #729fcf; }
+.term-bg-color-13 { background-color: #ad7fa8; }
+.term-fg-color-13 { color: #ad7fa8; }
+.term-bg-color-14 { background-color: #34e2e2; }
+.term-fg-color-14 { color: #34e2e2; }
+.term-bg-color-15 { background-color: #eeeeec; }
+.term-fg-color-15 { color: #eeeeec; }
+.term-bg-color-16 { background-color: #000000; }
+.term-fg-color-16 { color: #000000; }
+.term-bg-color-17 { background-color: #00005f; }
+.term-fg-color-17 { color: #00005f; }
+.term-bg-color-18 { background-color: #000087; }
+.term-fg-color-18 { color: #000087; }
+.term-bg-color-19 { background-color: #0000af; }
+.term-fg-color-19 { color: #0000af; }
+.term-bg-color-20 { background-color: #0000d7; }
+.term-fg-color-20 { color: #0000d7; }
+.term-bg-color-21 { background-color: #0000ff; }
+.term-fg-color-21 { color: #0000ff; }
+.term-bg-color-22 { background-color: #005f00; }
+.term-fg-color-22 { color: #005f00; }
+.term-bg-color-23 { background-color: #005f5f; }
+.term-fg-color-23 { color: #005f5f; }
+.term-bg-color-24 { background-color: #005f87; }
+.term-fg-color-24 { color: #005f87; }
+.term-bg-color-25 { background-color: #005faf; }
+.term-fg-color-25 { color: #005faf; }
+.term-bg-color-26 { background-color: #005fd7; }
+.term-fg-color-26 { color: #005fd7; }
+.term-bg-color-27 { background-color: #005fff; }
+.term-fg-color-27 { color: #005fff; }
+.term-bg-color-28 { background-color: #008700; }
+.term-fg-color-28 { color: #008700; }
+.term-bg-color-29 { background-color: #00875f; }
+.term-fg-color-29 { color: #00875f; }
+.term-bg-color-30 { background-color: #008787; }
+.term-fg-color-30 { color: #008787; }
+.term-bg-color-31 { background-color: #0087af; }
+.term-fg-color-31 { color: #0087af; }
+.term-bg-color-32 { background-color: #0087d7; }
+.term-fg-color-32 { color: #0087d7; }
+.term-bg-color-33 { background-color: #0087ff; }
+.term-fg-color-33 { color: #0087ff; }
+.term-bg-color-34 { background-color: #00af00; }
+.term-fg-color-34 { color: #00af00; }
+.term-bg-color-35 { background-color: #00af5f; }
+.term-fg-color-35 { color: #00af5f; }
+.term-bg-color-36 { background-color: #00af87; }
+.term-fg-color-36 { color: #00af87; }
+.term-bg-color-37 { background-color: #00afaf; }
+.term-fg-color-37 { color: #00afaf; }
+.term-bg-color-38 { background-color: #00afd7; }
+.term-fg-color-38 { color: #00afd7; }
+.term-bg-color-39 { background-color: #00afff; }
+.term-fg-color-39 { color: #00afff; }
+.term-bg-color-40 { background-color: #00d700; }
+.term-fg-color-40 { color: #00d700; }
+.term-bg-color-41 { background-color: #00d75f; }
+.term-fg-color-41 { color: #00d75f; }
+.term-bg-color-42 { background-color: #00d787; }
+.term-fg-color-42 { color: #00d787; }
+.term-bg-color-43 { background-color: #00d7af; }
+.term-fg-color-43 { color: #00d7af; }
+.term-bg-color-44 { background-color: #00d7d7; }
+.term-fg-color-44 { color: #00d7d7; }
+.term-bg-color-45 { background-color: #00d7ff; }
+.term-fg-color-45 { color: #00d7ff; }
+.term-bg-color-46 { background-color: #00ff00; }
+.term-fg-color-46 { color: #00ff00; }
+.term-bg-color-47 { background-color: #00ff5f; }
+.term-fg-color-47 { color: #00ff5f; }
+.term-bg-color-48 { background-color: #00ff87; }
+.term-fg-color-48 { color: #00ff87; }
+.term-bg-color-49 { background-color: #00ffaf; }
+.term-fg-color-49 { color: #00ffaf; }
+.term-bg-color-50 { background-color: #00ffd7; }
+.term-fg-color-50 { color: #00ffd7; }
+.term-bg-color-51 { background-color: #00ffff; }
+.term-fg-color-51 { color: #00ffff; }
+.term-bg-color-52 { background-color: #5f0000; }
+.term-fg-color-52 { color: #5f0000; }
+.term-bg-color-53 { background-color: #5f005f; }
+.term-fg-color-53 { color: #5f005f; }
+.term-bg-color-54 { background-color: #5f0087; }
+.term-fg-color-54 { color: #5f0087; }
+.term-bg-color-55 { background-color: #5f00af; }
+.term-fg-color-55 { color: #5f00af; }
+.term-bg-color-56 { background-color: #5f00d7; }
+.term-fg-color-56 { color: #5f00d7; }
+.term-bg-color-57 { background-color: #5f00ff; }
+.term-fg-color-57 { color: #5f00ff; }
+.term-bg-color-58 { background-color: #5f5f00; }
+.term-fg-color-58 { color: #5f5f00; }
+.term-bg-color-59 { background-color: #5f5f5f; }
+.term-fg-color-59 { color: #5f5f5f; }
+.term-bg-color-60 { background-color: #5f5f87; }
+.term-fg-color-60 { color: #5f5f87; }
+.term-bg-color-61 { background-color: #5f5faf; }
+.term-fg-color-61 { color: #5f5faf; }
+.term-bg-color-62 { background-color: #5f5fd7; }
+.term-fg-color-62 { color: #5f5fd7; }
+.term-bg-color-63 { background-color: #5f5fff; }
+.term-fg-color-63 { color: #5f5fff; }
+.term-bg-color-64 { background-color: #5f8700; }
+.term-fg-color-64 { color: #5f8700; }
+.term-bg-color-65 { background-color: #5f875f; }
+.term-fg-color-65 { color: #5f875f; }
+.term-bg-color-66 { background-color: #5f8787; }
+.term-fg-color-66 { color: #5f8787; }
+.term-bg-color-67 { background-color: #5f87af; }
+.term-fg-color-67 { color: #5f87af; }
+.term-bg-color-68 { background-color: #5f87d7; }
+.term-fg-color-68 { color: #5f87d7; }
+.term-bg-color-69 { background-color: #5f87ff; }
+.term-fg-color-69 { color: #5f87ff; }
+.term-bg-color-70 { background-color: #5faf00; }
+.term-fg-color-70 { color: #5faf00; }
+.term-bg-color-71 { background-color: #5faf5f; }
+.term-fg-color-71 { color: #5faf5f; }
+.term-bg-color-72 { background-color: #5faf87; }
+.term-fg-color-72 { color: #5faf87; }
+.term-bg-color-73 { background-color: #5fafaf; }
+.term-fg-color-73 { color: #5fafaf; }
+.term-bg-color-74 { background-color: #5fafd7; }
+.term-fg-color-74 { color: #5fafd7; }
+.term-bg-color-75 { background-color: #5fafff; }
+.term-fg-color-75 { color: #5fafff; }
+.term-bg-color-76 { background-color: #5fd700; }
+.term-fg-color-76 { color: #5fd700; }
+.term-bg-color-77 { background-color: #5fd75f; }
+.term-fg-color-77 { color: #5fd75f; }
+.term-bg-color-78 { background-color: #5fd787; }
+.term-fg-color-78 { color: #5fd787; }
+.term-bg-color-79 { background-color: #5fd7af; }
+.term-fg-color-79 { color: #5fd7af; }
+.term-bg-color-80 { background-color: #5fd7d7; }
+.term-fg-color-80 { color: #5fd7d7; }
+.term-bg-color-81 { background-color: #5fd7ff; }
+.term-fg-color-81 { color: #5fd7ff; }
+.term-bg-color-82 { background-color: #5fff00; }
+.term-fg-color-82 { color: #5fff00; }
+.term-bg-color-83 { background-color: #5fff5f; }
+.term-fg-color-83 { color: #5fff5f; }
+.term-bg-color-84 { background-color: #5fff87; }
+.term-fg-color-84 { color: #5fff87; }
+.term-bg-color-85 { background-color: #5fffaf; }
+.term-fg-color-85 { color: #5fffaf; }
+.term-bg-color-86 { background-color: #5fffd7; }
+.term-fg-color-86 { color: #5fffd7; }
+.term-bg-color-87 { background-color: #5fffff; }
+.term-fg-color-87 { color: #5fffff; }
+.term-bg-color-88 { background-color: #870000; }
+.term-fg-color-88 { color: #870000; }
+.term-bg-color-89 { background-color: #87005f; }
+.term-fg-color-89 { color: #87005f; }
+.term-bg-color-90 { background-color: #870087; }
+.term-fg-color-90 { color: #870087; }
+.term-bg-color-91 { background-color: #8700af; }
+.term-fg-color-91 { color: #8700af; }
+.term-bg-color-92 { background-color: #8700d7; }
+.term-fg-color-92 { color: #8700d7; }
+.term-bg-color-93 { background-color: #8700ff; }
+.term-fg-color-93 { color: #8700ff; }
+.term-bg-color-94 { background-color: #875f00; }
+.term-fg-color-94 { color: #875f00; }
+.term-bg-color-95 { background-color: #875f5f; }
+.term-fg-color-95 { color: #875f5f; }
+.term-bg-color-96 { background-color: #875f87; }
+.term-fg-color-96 { color: #875f87; }
+.term-bg-color-97 { background-color: #875faf; }
+.term-fg-color-97 { color: #875faf; }
+.term-bg-color-98 { background-color: #875fd7; }
+.term-fg-color-98 { color: #875fd7; }
+.term-bg-color-99 { background-color: #875fff; }
+.term-fg-color-99 { color: #875fff; }
+.term-bg-color-100 { background-color: #878700; }
+.term-fg-color-100 { color: #878700; }
+.term-bg-color-101 { background-color: #87875f; }
+.term-fg-color-101 { color: #87875f; }
+.term-bg-color-102 { background-color: #878787; }
+.term-fg-color-102 { color: #878787; }
+.term-bg-color-103 { background-color: #8787af; }
+.term-fg-color-103 { color: #8787af; }
+.term-bg-color-104 { background-color: #8787d7; }
+.term-fg-color-104 { color: #8787d7; }
+.term-bg-color-105 { background-color: #8787ff; }
+.term-fg-color-105 { color: #8787ff; }
+.term-bg-color-106 { background-color: #87af00; }
+.term-fg-color-106 { color: #87af00; }
+.term-bg-color-107 { background-color: #87af5f; }
+.term-fg-color-107 { color: #87af5f; }
+.term-bg-color-108 { background-color: #87af87; }
+.term-fg-color-108 { color: #87af87; }
+.term-bg-color-109 { background-color: #87afaf; }
+.term-fg-color-109 { color: #87afaf; }
+.term-bg-color-110 { background-color: #87afd7; }
+.term-fg-color-110 { color: #87afd7; }
+.term-bg-color-111 { background-color: #87afff; }
+.term-fg-color-111 { color: #87afff; }
+.term-bg-color-112 { background-color: #87d700; }
+.term-fg-color-112 { color: #87d700; }
+.term-bg-color-113 { background-color: #87d75f; }
+.term-fg-color-113 { color: #87d75f; }
+.term-bg-color-114 { background-color: #87d787; }
+.term-fg-color-114 { color: #87d787; }
+.term-bg-color-115 { background-color: #87d7af; }
+.term-fg-color-115 { color: #87d7af; }
+.term-bg-color-116 { background-color: #87d7d7; }
+.term-fg-color-116 { color: #87d7d7; }
+.term-bg-color-117 { background-color: #87d7ff; }
+.term-fg-color-117 { color: #87d7ff; }
+.term-bg-color-118 { background-color: #87ff00; }
+.term-fg-color-118 { color: #87ff00; }
+.term-bg-color-119 { background-color: #87ff5f; }
+.term-fg-color-119 { color: #87ff5f; }
+.term-bg-color-120 { background-color: #87ff87; }
+.term-fg-color-120 { color: #87ff87; }
+.term-bg-color-121 { background-color: #87ffaf; }
+.term-fg-color-121 { color: #87ffaf; }
+.term-bg-color-122 { background-color: #87ffd7; }
+.term-fg-color-122 { color: #87ffd7; }
+.term-bg-color-123 { background-color: #87ffff; }
+.term-fg-color-123 { color: #87ffff; }
+.term-bg-color-124 { background-color: #af0000; }
+.term-fg-color-124 { color: #af0000; }
+.term-bg-color-125 { background-color: #af005f; }
+.term-fg-color-125 { color: #af005f; }
+.term-bg-color-126 { background-color: #af0087; }
+.term-fg-color-126 { color: #af0087; }
+.term-bg-color-127 { background-color: #af00af; }
+.term-fg-color-127 { color: #af00af; }
+.term-bg-color-128 { background-color: #af00d7; }
+.term-fg-color-128 { color: #af00d7; }
+.term-bg-color-129 { background-color: #af00ff; }
+.term-fg-color-129 { color: #af00ff; }
+.term-bg-color-130 { background-color: #af5f00; }
+.term-fg-color-130 { color: #af5f00; }
+.term-bg-color-131 { background-color: #af5f5f; }
+.term-fg-color-131 { color: #af5f5f; }
+.term-bg-color-132 { background-color: #af5f87; }
+.term-fg-color-132 { color: #af5f87; }
+.term-bg-color-133 { background-color: #af5faf; }
+.term-fg-color-133 { color: #af5faf; }
+.term-bg-color-134 { background-color: #af5fd7; }
+.term-fg-color-134 { color: #af5fd7; }
+.term-bg-color-135 { background-color: #af5fff; }
+.term-fg-color-135 { color: #af5fff; }
+.term-bg-color-136 { background-color: #af8700; }
+.term-fg-color-136 { color: #af8700; }
+.term-bg-color-137 { background-color: #af875f; }
+.term-fg-color-137 { color: #af875f; }
+.term-bg-color-138 { background-color: #af8787; }
+.term-fg-color-138 { color: #af8787; }
+.term-bg-color-139 { background-color: #af87af; }
+.term-fg-color-139 { color: #af87af; }
+.term-bg-color-140 { background-color: #af87d7; }
+.term-fg-color-140 { color: #af87d7; }
+.term-bg-color-141 { background-color: #af87ff; }
+.term-fg-color-141 { color: #af87ff; }
+.term-bg-color-142 { background-color: #afaf00; }
+.term-fg-color-142 { color: #afaf00; }
+.term-bg-color-143 { background-color: #afaf5f; }
+.term-fg-color-143 { color: #afaf5f; }
+.term-bg-color-144 { background-color: #afaf87; }
+.term-fg-color-144 { color: #afaf87; }
+.term-bg-color-145 { background-color: #afafaf; }
+.term-fg-color-145 { color: #afafaf; }
+.term-bg-color-146 { background-color: #afafd7; }
+.term-fg-color-146 { color: #afafd7; }
+.term-bg-color-147 { background-color: #afafff; }
+.term-fg-color-147 { color: #afafff; }
+.term-bg-color-148 { background-color: #afd700; }
+.term-fg-color-148 { color: #afd700; }
+.term-bg-color-149 { background-color: #afd75f; }
+.term-fg-color-149 { color: #afd75f; }
+.term-bg-color-150 { background-color: #afd787; }
+.term-fg-color-150 { color: #afd787; }
+.term-bg-color-151 { background-color: #afd7af; }
+.term-fg-color-151 { color: #afd7af; }
+.term-bg-color-152 { background-color: #afd7d7; }
+.term-fg-color-152 { color: #afd7d7; }
+.term-bg-color-153 { background-color: #afd7ff; }
+.term-fg-color-153 { color: #afd7ff; }
+.term-bg-color-154 { background-color: #afff00; }
+.term-fg-color-154 { color: #afff00; }
+.term-bg-color-155 { background-color: #afff5f; }
+.term-fg-color-155 { color: #afff5f; }
+.term-bg-color-156 { background-color: #afff87; }
+.term-fg-color-156 { color: #afff87; }
+.term-bg-color-157 { background-color: #afffaf; }
+.term-fg-color-157 { color: #afffaf; }
+.term-bg-color-158 { background-color: #afffd7; }
+.term-fg-color-158 { color: #afffd7; }
+.term-bg-color-159 { background-color: #afffff; }
+.term-fg-color-159 { color: #afffff; }
+.term-bg-color-160 { background-color: #d70000; }
+.term-fg-color-160 { color: #d70000; }
+.term-bg-color-161 { background-color: #d7005f; }
+.term-fg-color-161 { color: #d7005f; }
+.term-bg-color-162 { background-color: #d70087; }
+.term-fg-color-162 { color: #d70087; }
+.term-bg-color-163 { background-color: #d700af; }
+.term-fg-color-163 { color: #d700af; }
+.term-bg-color-164 { background-color: #d700d7; }
+.term-fg-color-164 { color: #d700d7; }
+.term-bg-color-165 { background-color: #d700ff; }
+.term-fg-color-165 { color: #d700ff; }
+.term-bg-color-166 { background-color: #d75f00; }
+.term-fg-color-166 { color: #d75f00; }
+.term-bg-color-167 { background-color: #d75f5f; }
+.term-fg-color-167 { color: #d75f5f; }
+.term-bg-color-168 { background-color: #d75f87; }
+.term-fg-color-168 { color: #d75f87; }
+.term-bg-color-169 { background-color: #d75faf; }
+.term-fg-color-169 { color: #d75faf; }
+.term-bg-color-170 { background-color: #d75fd7; }
+.term-fg-color-170 { color: #d75fd7; }
+.term-bg-color-171 { background-color: #d75fff; }
+.term-fg-color-171 { color: #d75fff; }
+.term-bg-color-172 { background-color: #d78700; }
+.term-fg-color-172 { color: #d78700; }
+.term-bg-color-173 { background-color: #d7875f; }
+.term-fg-color-173 { color: #d7875f; }
+.term-bg-color-174 { background-color: #d78787; }
+.term-fg-color-174 { color: #d78787; }
+.term-bg-color-175 { background-color: #d787af; }
+.term-fg-color-175 { color: #d787af; }
+.term-bg-color-176 { background-color: #d787d7; }
+.term-fg-color-176 { color: #d787d7; }
+.term-bg-color-177 { background-color: #d787ff; }
+.term-fg-color-177 { color: #d787ff; }
+.term-bg-color-178 { background-color: #d7af00; }
+.term-fg-color-178 { color: #d7af00; }
+.term-bg-color-179 { background-color: #d7af5f; }
+.term-fg-color-179 { color: #d7af5f; }
+.term-bg-color-180 { background-color: #d7af87; }
+.term-fg-color-180 { color: #d7af87; }
+.term-bg-color-181 { background-color: #d7afaf; }
+.term-fg-color-181 { color: #d7afaf; }
+.term-bg-color-182 { background-color: #d7afd7; }
+.term-fg-color-182 { color: #d7afd7; }
+.term-bg-color-183 { background-color: #d7afff; }
+.term-fg-color-183 { color: #d7afff; }
+.term-bg-color-184 { background-color: #d7d700; }
+.term-fg-color-184 { color: #d7d700; }
+.term-bg-color-185 { background-color: #d7d75f; }
+.term-fg-color-185 { color: #d7d75f; }
+.term-bg-color-186 { background-color: #d7d787; }
+.term-fg-color-186 { color: #d7d787; }
+.term-bg-color-187 { background-color: #d7d7af; }
+.term-fg-color-187 { color: #d7d7af; }
+.term-bg-color-188 { background-color: #d7d7d7; }
+.term-fg-color-188 { color: #d7d7d7; }
+.term-bg-color-189 { background-color: #d7d7ff; }
+.term-fg-color-189 { color: #d7d7ff; }
+.term-bg-color-190 { background-color: #d7ff00; }
+.term-fg-color-190 { color: #d7ff00; }
+.term-bg-color-191 { background-color: #d7ff5f; }
+.term-fg-color-191 { color: #d7ff5f; }
+.term-bg-color-192 { background-color: #d7ff87; }
+.term-fg-color-192 { color: #d7ff87; }
+.term-bg-color-193 { background-color: #d7ffaf; }
+.term-fg-color-193 { color: #d7ffaf; }
+.term-bg-color-194 { background-color: #d7ffd7; }
+.term-fg-color-194 { color: #d7ffd7; }
+.term-bg-color-195 { background-color: #d7ffff; }
+.term-fg-color-195 { color: #d7ffff; }
+.term-bg-color-196 { background-color: #ff0000; }
+.term-fg-color-196 { color: #ff0000; }
+.term-bg-color-197 { background-color: #ff005f; }
+.term-fg-color-197 { color: #ff005f; }
+.term-bg-color-198 { background-color: #ff0087; }
+.term-fg-color-198 { color: #ff0087; }
+.term-bg-color-199 { background-color: #ff00af; }
+.term-fg-color-199 { color: #ff00af; }
+.term-bg-color-200 { background-color: #ff00d7; }
+.term-fg-color-200 { color: #ff00d7; }
+.term-bg-color-201 { background-color: #ff00ff; }
+.term-fg-color-201 { color: #ff00ff; }
+.term-bg-color-202 { background-color: #ff5f00; }
+.term-fg-color-202 { color: #ff5f00; }
+.term-bg-color-203 { background-color: #ff5f5f; }
+.term-fg-color-203 { color: #ff5f5f; }
+.term-bg-color-204 { background-color: #ff5f87; }
+.term-fg-color-204 { color: #ff5f87; }
+.term-bg-color-205 { background-color: #ff5faf; }
+.term-fg-color-205 { color: #ff5faf; }
+.term-bg-color-206 { background-color: #ff5fd7; }
+.term-fg-color-206 { color: #ff5fd7; }
+.term-bg-color-207 { background-color: #ff5fff; }
+.term-fg-color-207 { color: #ff5fff; }
+.term-bg-color-208 { background-color: #ff8700; }
+.term-fg-color-208 { color: #ff8700; }
+.term-bg-color-209 { background-color: #ff875f; }
+.term-fg-color-209 { color: #ff875f; }
+.term-bg-color-210 { background-color: #ff8787; }
+.term-fg-color-210 { color: #ff8787; }
+.term-bg-color-211 { background-color: #ff87af; }
+.term-fg-color-211 { color: #ff87af; }
+.term-bg-color-212 { background-color: #ff87d7; }
+.term-fg-color-212 { color: #ff87d7; }
+.term-bg-color-213 { background-color: #ff87ff; }
+.term-fg-color-213 { color: #ff87ff; }
+.term-bg-color-214 { background-color: #ffaf00; }
+.term-fg-color-214 { color: #ffaf00; }
+.term-bg-color-215 { background-color: #ffaf5f; }
+.term-fg-color-215 { color: #ffaf5f; }
+.term-bg-color-216 { background-color: #ffaf87; }
+.term-fg-color-216 { color: #ffaf87; }
+.term-bg-color-217 { background-color: #ffafaf; }
+.term-fg-color-217 { color: #ffafaf; }
+.term-bg-color-218 { background-color: #ffafd7; }
+.term-fg-color-218 { color: #ffafd7; }
+.term-bg-color-219 { background-color: #ffafff; }
+.term-fg-color-219 { color: #ffafff; }
+.term-bg-color-220 { background-color: #ffd700; }
+.term-fg-color-220 { color: #ffd700; }
+.term-bg-color-221 { background-color: #ffd75f; }
+.term-fg-color-221 { color: #ffd75f; }
+.term-bg-color-222 { background-color: #ffd787; }
+.term-fg-color-222 { color: #ffd787; }
+.term-bg-color-223 { background-color: #ffd7af; }
+.term-fg-color-223 { color: #ffd7af; }
+.term-bg-color-224 { background-color: #ffd7d7; }
+.term-fg-color-224 { color: #ffd7d7; }
+.term-bg-color-225 { background-color: #ffd7ff; }
+.term-fg-color-225 { color: #ffd7ff; }
+.term-bg-color-226 { background-color: #ffff00; }
+.term-fg-color-226 { color: #ffff00; }
+.term-bg-color-227 { background-color: #ffff5f; }
+.term-fg-color-227 { color: #ffff5f; }
+.term-bg-color-228 { background-color: #ffff87; }
+.term-fg-color-228 { color: #ffff87; }
+.term-bg-color-229 { background-color: #ffffaf; }
+.term-fg-color-229 { color: #ffffaf; }
+.term-bg-color-230 { background-color: #ffffd7; }
+.term-fg-color-230 { color: #ffffd7; }
+.term-bg-color-231 { background-color: #ffffff; }
+.term-fg-color-231 { color: #ffffff; }
+.term-bg-color-232 { background-color: #080808; }
+.term-fg-color-232 { color: #080808; }
+.term-bg-color-233 { background-color: #121212; }
+.term-fg-color-233 { color: #121212; }
+.term-bg-color-234 { background-color: #1c1c1c; }
+.term-fg-color-234 { color: #1c1c1c; }
+.term-bg-color-235 { background-color: #262626; }
+.term-fg-color-235 { color: #262626; }
+.term-bg-color-236 { background-color: #303030; }
+.term-fg-color-236 { color: #303030; }
+.term-bg-color-237 { background-color: #3a3a3a; }
+.term-fg-color-237 { color: #3a3a3a; }
+.term-bg-color-238 { background-color: #444444; }
+.term-fg-color-238 { color: #444444; }
+.term-bg-color-239 { background-color: #4e4e4e; }
+.term-fg-color-239 { color: #4e4e4e; }
+.term-bg-color-240 { background-color: #585858; }
+.term-fg-color-240 { color: #585858; }
+.term-bg-color-241 { background-color: #626262; }
+.term-fg-color-241 { color: #626262; }
+.term-bg-color-242 { background-color: #6c6c6c; }
+.term-fg-color-242 { color: #6c6c6c; }
+.term-bg-color-243 { background-color: #767676; }
+.term-fg-color-243 { color: #767676; }
+.term-bg-color-244 { background-color: #808080; }
+.term-fg-color-244 { color: #808080; }
+.term-bg-color-245 { background-color: #8a8a8a; }
+.term-fg-color-245 { color: #8a8a8a; }
+.term-bg-color-246 { background-color: #949494; }
+.term-fg-color-246 { color: #949494; }
+.term-bg-color-247 { background-color: #9e9e9e; }
+.term-fg-color-247 { color: #9e9e9e; }
+.term-bg-color-248 { background-color: #a8a8a8; }
+.term-fg-color-248 { color: #a8a8a8; }
+.term-bg-color-249 { background-color: #b2b2b2; }
+.term-fg-color-249 { color: #b2b2b2; }
+.term-bg-color-250 { background-color: #bcbcbc; }
+.term-fg-color-250 { color: #bcbcbc; }
+.term-bg-color-251 { background-color: #c6c6c6; }
+.term-fg-color-251 { color: #c6c6c6; }
+.term-bg-color-252 { background-color: #d0d0d0; }
+.term-fg-color-252 { color: #d0d0d0; }
+.term-bg-color-253 { background-color: #dadada; }
+.term-fg-color-253 { color: #dadada; }
+.term-bg-color-254 { background-color: #e4e4e4; }
+.term-fg-color-254 { color: #e4e4e4; }
+.term-bg-color-255 { background-color: #eeeeee; }
+.term-fg-color-255 { color: #eeeeee; }
+.term-bg-color-default { background-color: #000000; }
+.term-bg-color-256 { background-color: #000000; }
+.term-fg-color-256 { color: #000000; }
+.term-fg-color-default { color: #f0f0f0; }
+.term-bg-color-257 { background-color: #f0f0f0; }
+.term-fg-color-257 { color: #f0f0f0; }
+.term-bold { font-weight: bold; }
+.term-underline { text-decoration: underline; }
+.term-blink { text-decoration: blink; }
+.term-hidden { visibility: hidden; }
diff --git a/src/pkg/lib/variables.less b/src/pkg/lib/variables.less
new file mode 100644
index 0000000..8939625
--- /dev/null
+++ b/src/pkg/lib/variables.less
@@ -0,0 +1,22 @@
+@import (less) "../../../node_modules/bootstrap/less/variables.less";
+@import (less) "../../../node_modules/patternfly/dist/less/variables.less";
+
+@metadata-color: #888;
+
+@listing-ct-hover: @list-group-hover-bg;
+@listing-ct-active: #65bedf;
+@listing-ct-padding: 10px;
+@listing-ct-spacing: 15px;
+@listing-ct-open: #f5f5f5;
+@listing-ct-open-width: 3px;
+@listing-ct-metadata: @metadata-color;
+@listing-ct-warning-color: #fbc7c7;
+@listing-ct-border: #ccc;
+@listing-ct-border-light: #eee;
+@listing-ct-border-maybe: #ddd;
+
+@screen-lg-max: (@screen-xlg-min - 1);
+@screen-xlg-min: 1600px;
+@screen-xs: 480px;
+@screen-xs-min: @screen-xs;
+@screen-xxs-max: (@screen-xs-min - 1);
diff --git a/src/player.jsx b/src/player.jsx
new file mode 100644
index 0000000..128228d
--- /dev/null
+++ b/src/player.jsx
@@ -0,0 +1,1080 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2017 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see
.
+ */
+
+(function() {
+ "use strict";
+
+ let cockpit = require("cockpit");
+ let _ = cockpit.gettext;
+ let React = require("react");
+ let Term = require("term.js-cockpit");
+ let Journal = require("journal");
+ let $ = require("jquery");
+
+ require("console.css");
+
+ /*
+ * Get an object field, verifying its presence and type.
+ */
+ let getValidField = function (object, field, type) {
+ let value;
+ if (!(field in object)) {
+ throw Error("\"" + field + "\" field is missing");
+ }
+ value = object[field];
+ if (typeof (value) != typeof (type)) {
+ throw Error("invalid \"" + field + "\" field type: " + typeof (value));
+ }
+ return value;
+ }
+
+ /*
+ * An auto-loading buffer of recording's packets.
+ */
+ let PacketBuffer = class {
+ /*
+ * Initialize a buffer.
+ */
+ constructor(matchList) {
+ this.handleError = this.handleError.bind(this);
+ this.handleStream = this.handleStream.bind(this);
+ this.handleDone = this.handleDone.bind(this);
+ /* RegExp used to parse message's timing field */
+ this.timingRE = new RegExp(
+ /* Delay (1) */
+ "\\+(\\d+)|" +
+ /* Text input (2) */
+ "<(\\d+)|" +
+ /* Binary input (3, 4) */
+ "\\[(\\d+)/(\\d+)|" +
+ /* Text output (5) */
+ ">(\\d+)|" +
+ /* Binary output (6, 7) */
+ "\\](\\d+)/(\\d+)|" +
+ /* Window (8, 9) */
+ "=(\\d+)x(\\d+)|" +
+ /* End of string */
+ "$",
+ /* Continue after the last match only */
+ /* FIXME Support likely sparse */
+ "y"
+ );
+ /* List of matches to apply when loading the buffer from Journal */
+ this.matchList = matchList;
+ /*
+ * An array of two-element arrays (tuples) each containing a
+ * packet index and a deferred object. The list is kept sorted to
+ * have tuples with lower packet indices first. Once the buffer
+ * receives a packet at the specified index, the matching tuple is
+ * removed from the list, and its deferred object is resolved.
+ * This is used to keep users informed about packets arriving.
+ */
+ this.idxDfdList = [];
+ /* Last seen message ID */
+ this.id = 0;
+ /* Last seen time position */
+ this.pos = 0;
+ /* Last seen window width */
+ this.width = null;
+ /* Last seen window height */
+ this.height = null;
+ /* List of packets read */
+ this.pktList = [];
+ /* Error which stopped the loading */
+ this.error = null;
+ /* The journalctl reading the recording */
+ this.journalctl = Journal.journalctl(
+ this.matchList,
+ {count: "all", follow: false});
+ this.journalctl.fail(this.handleError);
+ this.journalctl.stream(this.handleStream);
+ this.journalctl.done(this.handleDone);
+ /*
+ * Last seen cursor of the first, non-follow, journalctl run.
+ * Null if no entry was received yet, or the second run has
+ * skipped the entry received last by the first run.
+ */
+ this.cursor = null;
+ /* True if the first, non-follow, journalctl run has completed */
+ this.done = false;
+ }
+
+ /*
+ * Return a promise which is resolved when a packet at a particular
+ * index is received by the buffer. The promise is rejected with a
+ * non-null argument if an error occurs or has occurred previously.
+ * The promise is rejected with null, when the buffer is stopped. If
+ * the packet index is not specified, assume it's the next packet.
+ */
+ awaitPacket(idx) {
+ let i;
+ let idxDfd;
+
+ /* If an error has occurred previously */
+ if (this.error !== null) {
+ /* Reject immediately */
+ return $.Deferred().reject(this.error)
+ .promise();
+ }
+
+ /* If the buffer was stopped */
+ if (this.journalctl === null) {
+ return $.Deferred().reject(null)
+ .promise();
+ }
+
+ /* If packet index is not specified */
+ if (idx === undefined) {
+ /* Assume it's the next one */
+ idx = this.pktList.length;
+ } else {
+ /* If it has already been received */
+ if (idx < this.pktList.length) {
+ /* Return resolved promise */
+ return $.Deferred().resolve()
+ .promise();
+ }
+ }
+
+ /* Try to find an existing, matching tuple */
+ for (i = 0; i < this.idxDfdList.length; i++) {
+ idxDfd = this.idxDfdList[i];
+ if (idxDfd[0] == idx) {
+ return idxDfd[1].promise();
+ } else if (idxDfd[0] > idx) {
+ break;
+ }
+ }
+
+ /* Not found, create and insert a new tuple */
+ idxDfd = [idx, $.Deferred()];
+ this.idxDfdList.splice(i, 0, idxDfd);
+
+ /* Return its promise */
+ return idxDfd[1].promise();
+ }
+
+ /*
+ * Return true if the buffer was done loading everything logged to
+ * journal so far and is now waiting for and loading new entries.
+ * Return false if the buffer is loading existing entries so far.
+ */
+ isDone() {
+ return this.done;
+ }
+
+ /*
+ * Stop receiving the entries
+ */
+ stop() {
+ if (this.journalctl === null) {
+ return;
+ }
+ /* Destroy journalctl */
+ this.journalctl.stop();
+ this.journalctl = null;
+ /* Notify everyone we stopped */
+ for (let i = 0; i < this.idxDfdList.length; i++) {
+ this.idxDfdList[i][1].reject(null);
+ }
+ this.idxDfdList = [];
+ }
+
+ /*
+ * Add a packet to the received packet list.
+ */
+ addPacket(pkt) {
+ /* TODO Validate the packet */
+ /* Add the packet */
+ this.pktList.push(pkt)
+ /* Notify any matching listeners */
+ while (this.idxDfdList.length > 0) {
+ let idxDfd = this.idxDfdList[0];
+ if (idxDfd[0] < this.pktList.length) {
+ this.idxDfdList.shift();
+ idxDfd[1].resolve();
+ } else {
+ break;
+ }
+ }
+ }
+
+ /*
+ * Handle an error.
+ */
+ handleError(error) {
+ /* Remember the error */
+ this.error = error;
+ /* Destroy journalctl, don't try to recover */
+ if (this.journalctl !== null) {
+ this.journalctl.stop();
+ this.journalctl = null;
+ }
+ /* Notify everyone we had an error */
+ for (let i = 0; i < this.idxDfdList.length; i++) {
+ this.idxDfdList[i][1].reject(error);
+ }
+ this.idxDfdList = [];
+ }
+
+ /*
+ * Parse packets out of a tlog message data and add them to the buffer.
+ */
+ parseMessageData(timing, in_txt, out_txt) {
+ let matches;
+ let in_txt_pos = 0;
+ let out_txt_pos = 0;
+ let t;
+ let x;
+ let y;
+ let s;
+ let io = [];
+ let is_output;
+
+ /* While matching entries in timing */
+ this.timingRE.lastIndex = 0;
+ for (;;) {
+ /* Match next timing entry */
+ matches = this.timingRE.exec(timing);
+ if (matches === null) {
+ throw Error("invalid timing string");
+ } else if (matches[0] == "") {
+ break;
+ }
+
+ /* Switch on entry type character */
+ switch (t = matches[0][0]) {
+ /* Delay */
+ case "+":
+ x = parseInt(matches[1], 10);
+ if (x == 0) {
+ break;
+ }
+ if (io.length > 0) {
+ this.addPacket({pos: this.pos,
+ is_io: true,
+ is_output: is_output,
+ io: io.join()});
+ io = [];
+ }
+ this.pos += x;
+ break;
+ /* Text or binary input */
+ case "<":
+ case "[":
+ x = parseInt(matches[(t == "<") ? 2 : 3], 10);
+ if (x == 0) {
+ break;
+ }
+ if (io.length > 0 && is_output) {
+ this.addPacket({pos: this.pos,
+ is_io: true,
+ is_output: is_output,
+ io: io.join()});
+ io = [];
+ }
+ is_output = false;
+ /* Add (replacement) input characters */
+ s = in_txt.slice(in_txt_pos, in_txt_pos += x);
+ if (s.length != x) {
+ throw Error("timing entry out of input bounds");
+ }
+ io.push(s);
+ break;
+ /* Text or binary output */
+ case ">":
+ case "]":
+ x = parseInt(matches[(t == ">") ? 5 : 6], 10);
+ if (x == 0) {
+ break;
+ }
+ if (io.length > 0 && !is_output) {
+ this.addPacket({pos: this.pos,
+ is_io: true,
+ is_output: is_output,
+ io: io.join()});
+ io = [];
+ }
+ is_output = true;
+ /* Add (replacement) output characters */
+ s = out_txt.slice(out_txt_pos, out_txt_pos += x);
+ if (s.length != x) {
+ throw Error("timing entry out of output bounds");
+ }
+ io.push(s);
+ break;
+ /* Window */
+ case "=":
+ x = parseInt(matches[8], 10);
+ y = parseInt(matches[9], 10);
+ if (x == this.width && y == this.height) {
+ break;
+ }
+ if (io.length > 0) {
+ this.addPacket({pos: this.pos,
+ is_io: true,
+ is_output: is_output,
+ io: io.join()});
+ io = [];
+ }
+ this.addPacket({pos: this.pos,
+ is_io: false,
+ width: x,
+ height: y});
+ this.width = x;
+ this.height = y;
+ break;
+ }
+ }
+
+ if (in_txt_pos < in_txt.length) {
+ throw Error("extra input present");
+ }
+ if (out_txt_pos < out_txt.length) {
+ throw Error("extra output present");
+ }
+
+ if (io.length > 0) {
+ this.addPacket({pos: this.pos,
+ is_io: true,
+ is_output: is_output,
+ io: io.join()});
+ }
+ }
+
+ /*
+ * Parse packets out of a tlog message and add them to the buffer.
+ */
+ parseMessage(message) {
+ let matches;
+ let ver;
+ let id;
+ let pos;
+
+ const number = Number();
+ const string = String();
+
+ /* Check version */
+ ver = getValidField(message, "ver", string);
+ matches = ver.match("^(\\d+)\\.(\\d+)$");
+ if (matches === null || matches[1] > 2) {
+ throw Error("\"ver\" field has invalid value: " + ver);
+ }
+
+ /* TODO Perhaps check host, rec, user, term, and session fields */
+
+ /* Extract message ID */
+ id = getValidField(message, "id", number);
+ if (id <= this.id) {
+ throw Error("out of order \"id\" field value: " + id);
+ }
+
+ /* Extract message time position */
+ pos = getValidField(message, "pos", number);
+ if (pos < this.message_pos) {
+ throw Error("out of order \"pos\" field value: " + pos);
+ }
+
+ /* Update last received message ID and time position */
+ this.id = id;
+ this.pos = pos;
+
+ /* Parse message data */
+ this.parseMessageData(
+ getValidField(message, "timing", string),
+ getValidField(message, "in_txt", string),
+ getValidField(message, "out_txt", string));
+ }
+
+ /*
+ * Handle journalctl "stream" event.
+ */
+ handleStream(entryList) {
+ let i;
+ let e;
+ for (i = 0; i < entryList.length; i++) {
+ e = entryList[i];
+ /* If this is the second, "follow", run */
+ if (this.done) {
+ /* Skip the last entry we added on the first run */
+ if (this.cursor !== null) {
+ this.cursor = null;
+ continue;
+ }
+ } else {
+ if (!('__CURSOR' in e)) {
+ this.handleError("No cursor in a Journal entry");
+ }
+ this.cursor = e['__CURSOR'];
+ }
+ /* TODO Refer to entry number/cursor in errors */
+ if (!('MESSAGE' in e)) {
+ this.handleError("No message in Journal entry");
+ }
+ /* Parse the entry message */
+ try {
+ this.parseMessage(JSON.parse(e['MESSAGE']));
+ } catch (error) {
+ this.handleError(error);
+ return;
+ }
+ }
+ }
+
+ /*
+ * Handle journalctl "done" event.
+ */
+ handleDone() {
+ this.done = true;
+ this.journalctl.stop();
+ /* Continue with the "following" run */
+ this.journalctl = Journal.journalctl(
+ this.matchList,
+ {cursor: this.cursor,
+ follow: true, count: "all"});
+ this.journalctl.fail(this.handleError);
+ this.journalctl.stream(this.handleStream);
+ /* NOTE: no "done" handler on purpose */
+ }
+ };
+
+ let ProgressBar = class extends React.Component {
+ constructor(props) {
+ super(props);
+ this.jumpTo = this.jumpTo.bind(this);
+ }
+
+ jumpTo(e) {
+ if (this.props.fastForwardFunc) {
+ let percent = parseInt((e.offsetX * 100) / e.currentTarget.clientWidth);
+ let ts = parseInt((this.props.length * percent) / 100);
+ this.props.fastForwardFunc(ts);
+ }
+ }
+
+ render() {
+ let progress = {
+ "width": parseInt((this.props.mark * 100) / this.props.length) + "%"
+ };
+
+ return (
+
+ );
+ }
+ };
+
+ let Player = class extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.handleTimeout = this.handleTimeout.bind(this);
+ this.handlePacket = this.handlePacket.bind(this);
+ this.handleError = this.handleError.bind(this);
+ this.handleTitleChange = this.handleTitleChange.bind(this);
+ this.rewindToStart = this.rewindToStart.bind(this);
+ this.playPauseToggle = this.playPauseToggle.bind(this);
+ this.speedUp = this.speedUp.bind(this);
+ this.speedDown = this.speedDown.bind(this);
+ this.speedReset = this.speedReset.bind(this);
+ this.fastForwardToEnd = this.fastForwardToEnd.bind(this);
+ this.skipFrame = this.skipFrame.bind(this);
+ this.handleKeyDown = this.handleKeyDown.bind(this);
+ this.sync = this.sync.bind(this);
+ this.zoomIn = this.zoomIn.bind(this);
+ this.zoomOut = this.zoomOut.bind(this);
+ this.fitTo = this.fitTo.bind(this);
+ this.dragPan = this.dragPan.bind(this);
+ this.dragPanEnable = this.dragPanEnable.bind(this);
+ this.dragPanDisable = this.dragPanDisable.bind(this);
+ this.zoom = this.zoom.bind(this);
+ this.fastForwardToTS = this.fastForwardToTS.bind(this);
+
+ this.state = {
+ cols: 80,
+ rows: 25,
+ title: _("Player"),
+ term: null,
+ paused: true,
+ /* Speed exponent */
+ speedExp: 0,
+ container_width: 630,
+ scale_initial: 1,
+ scale_lock: false,
+ term_top_style: "50%",
+ term_left_style: "50%",
+ term_translate: "-50%, -50%",
+ term_scroll: "hidden",
+ term_zoom_max: false,
+ term_zoom_min: false,
+ drag_pan: false,
+ containerWidth: 630,
+ currentTsPost: 0,
+ scale: 1,
+ error: null
+ };
+
+ this.containerHeight = 290;
+
+ /* Auto-loading buffer of recording's packets */
+ this.buf = new PacketBuffer(this.props.matchList);
+
+ /* Current recording time, ms */
+ this.recTS = 0;
+ /* Corresponding local time, ms */
+ this.locTS = 0;
+
+ /* Index of the current packet */
+ this.pktIdx = 0;
+ /* Current packet, or null if not retrieved */
+ this.pkt = null;
+ /* Timeout ID of the current packet, null if none */
+ this.timeout = null;
+
+ /* True if the next packet should be output without delay */
+ this.skip = false;
+ /* Playback speed */
+ this.speed = 1;
+ /*
+ * Timestamp playback should fast-forward to.
+ * Recording time, ms, or null if not fast-forwarding.
+ */
+ this.fastForwardTo = null;
+ }
+
+ reset() {
+ /* Clear any pending timeouts */
+ this.clearTimeout();
+
+ /* Reset the terminal */
+ this.state.term.reset();
+
+ /* Move to beginning of buffer */
+ this.pktIdx = 0;
+ /* No packet loaded */
+ this.pkt = null;
+
+ /* We are not skipping */
+ this.skip = false;
+ /* We are not fast-forwarding */
+ this.fastForwardTo = null;
+
+ /* Move to beginning of recording */
+ this.recTS = 0;
+ /* Start the playback time */
+ this.locTS = performance.now();
+
+ /* Wait for the first packet */
+ this.awaitPacket(0);
+ }
+
+ componentWillMount() {
+ let term = new Term({
+ cols: this.state.cols,
+ rows: this.state.rows,
+ screenKeys: true,
+ useStyle: true
+ });
+
+ term.on('title', this.handleTitleChange);
+
+ this.setState({ term: term });
+
+ window.addEventListener("keydown", this.handleKeyDown, false);
+ }
+
+ componentDidMount() {
+ if (this.refs.wrapper.offsetWidth) {
+ this.setState({containerWidth: this.refs.wrapper.offsetWidth});
+ }
+ /* Open the terminal */
+ this.state.term.open(this.refs.term);
+ window.setInterval(this.sync, 100);
+ /* Reset playback */
+ this.reset();
+ }
+
+ /* Subscribe for a packet at specified index */
+ awaitPacket(idx) {
+ this.buf.awaitPacket(idx).done(this.handlePacket)
+ .fail(this.handleError);
+ }
+
+ /* Set next packet timeout, ms */
+ setTimeout(ms) {
+ this.timeout = window.setTimeout(this.handleTimeout, ms);
+ }
+
+ /* Clear next packet timeout */
+ clearTimeout() {
+ if (this.timeout !== null) {
+ window.clearTimeout(this.timeout);
+ this.timeout = null;
+ }
+ }
+
+ /* Handle packet retrieval error */
+ handleError(error) {
+ if (error !== null) {
+ this.setState({error: error});
+ console.warn(error);
+ }
+ }
+
+ /* Handle packet retrieval success */
+ handlePacket() {
+ this.sync();
+ }
+
+ /* Handle arrival of packet output time */
+ handleTimeout() {
+ this.timeout = null;
+ this.sync();
+ }
+
+ /* Handle terminal title change */
+ handleTitleChange(title) {
+ this.setState({ title: _("Player") + ": " + title });
+ }
+
+ _transform(width, height) {
+ var relation = Math.min(
+ this.state.containerWidth / this.state.term.element.offsetWidth,
+ this.containerHeight / this.state.term.element.offsetHeight
+ );
+ this.setState({
+ term_top_style: "50%",
+ term_left_style: "50%",
+ term_translate: "-50%, -50%",
+ scale: relation,
+ scale_initial: relation,
+ cols: width,
+ rows: height
+ });
+ }
+
+ /* Synchronize playback */
+ sync() {
+ let locDelay;
+
+ /* We are already called, don't call us with timeout */
+ this.clearTimeout();
+
+ /* Forever */
+ for (;;) {
+ /* Get another packet to output, if none */
+ for (; this.pkt === null; this.pktIdx++) {
+ let pkt = this.buf.pktList[this.pktIdx];
+ /* If there are no more packets */
+ if (pkt === undefined) {
+ /*
+ * If we're done loading existing packets and we were
+ * fast-forwarding.
+ */
+ if (this.fastForwardTo != null && this.buf.isDone()) {
+ /* Stop fast-forwarding */
+ this.fastForwardTo = null;
+ }
+ /* Call us when we get one */
+ this.awaitPacket();
+ return;
+ }
+
+ /* Skip packets we don't output */
+ if (pkt.is_io && !pkt.is_output) {
+ continue;
+ }
+
+ this.pkt = pkt;
+ }
+
+ /* Get the current local time */
+ let nowLocTS = performance.now();
+
+ /* Ignore the passed time, if we're paused */
+ if (this.state.paused) {
+ locDelay = 0;
+ } else {
+ locDelay = nowLocTS - this.locTS;
+ }
+
+ /* Sync to the local time */
+ this.locTS = nowLocTS;
+
+ /* If we are skipping one packet's delay */
+ if (this.skip) {
+ this.skip = false;
+ this.recTS = this.pkt.pos;
+ /* Else, if we are fast-forwarding */
+ } else if (this.fastForwardTo !== null) {
+ /* If we haven't reached fast-forward destination */
+ if (this.pkt.pos < this.fastForwardTo) {
+ this.recTS = this.pkt.pos;
+ } else {
+ this.recTS = this.fastForwardTo;
+ this.fastForwardTo = null;
+ continue;
+ }
+ /* Else, if we are paused */
+ } else if (this.state.paused) {
+ return;
+ } else {
+ this.recTS += locDelay * this.speed;
+ let pktRecDelay = this.pkt.pos - this.recTS;
+ let pktLocDelay = pktRecDelay / this.speed;
+ this.setState({currentTsPost: parseInt(this.recTS)});
+ /* If we're more than 5 ms early for this packet */
+ if (pktLocDelay > 5) {
+ /* Call us again on time, later */
+ this.setTimeout(pktLocDelay);
+ return;
+ }
+ }
+
+ /* Output the packet */
+ if (this.pkt.is_io) {
+ this.state.term.write(this.pkt.io);
+ } else {
+ this.state.term.resize(this.pkt.width, this.pkt.height);
+ if (!this.state.scale_lock) {
+ this._transform(this.pkt.width, this.pkt.height);
+ }
+ }
+
+ /* We no longer have a packet */
+ this.pkt = null;
+ }
+ }
+
+ playPauseToggle() {
+ this.setState({paused: !this.state.paused});
+ }
+
+ speedUp() {
+ let speedExp = this.state.speedExp;
+ if (speedExp < 4) {
+ this.setState({speedExp: speedExp + 1});
+ }
+ }
+
+ speedDown() {
+ let speedExp = this.state.speedExp;
+ if (speedExp > -4) {
+ this.setState({speedExp: speedExp - 1});
+ }
+ }
+
+ speedReset() {
+ this.setState({speedExp: 0});
+ }
+
+ rewindToStart() {
+ this.reset();
+ this.sync();
+ }
+
+ fastForwardToEnd() {
+ this.fastForwardTo = Infinity;
+ this.sync();
+ }
+
+ fastForwardToTS(ts) {
+ if (ts < this.recTS) {
+ this.reset();
+ }
+ this.fastForwardTo = ts;
+ this.sync();
+ }
+
+ skipFrame() {
+ this.skip = true;
+ this.sync();
+ }
+
+ handleKeyDown(event) {
+ let keyCodesFuncs = {
+ "p": this.playPauseToggle,
+ "}": this.speedUp,
+ "{": this.speedDown,
+ "Backspace": this.speedReset,
+ ".": this.skipFrame,
+ "G": this.fastForwardToEnd,
+ "R": this.rewindToStart,
+ "+": this.zoomIn,
+ "=": this.zoomIn,
+ "-": this.zoomOut,
+ "Z": this.fitIn,
+ };
+ if (keyCodesFuncs[event.key]) {
+ (keyCodesFuncs[event.key](event));
+ }
+ }
+
+ zoom(scale) {
+ if (scale.toFixed(6) === this.state.scale_initial.toFixed(6)) {
+ this.fitTo();
+ } else {
+ this.setState({
+ term_top_style: "0",
+ term_left_style: "0",
+ term_translate: "0, 0",
+ scale_lock: true,
+ term_scroll: "auto",
+ scale: scale,
+ term_zoom_max: false,
+ term_zoom_min: false,
+ });
+ }
+ }
+
+ dragPan() {
+ (this.state.drag_pan ? this.dragPanDisable() : this.dragPanEnable());
+ }
+
+ dragPanEnable() {
+ this.setState({drag_pan: true});
+
+ let scrollwrap = this.refs.scrollwrap;
+
+ let clicked = false;
+ let clickX;
+ let clickY;
+
+ $(this.refs.scrollwrap).on({
+ 'mousemove': function(e) {
+ clicked && updateScrollPos(e);
+ },
+ 'mousedown': function(e) {
+ clicked = true;
+ clickY = e.pageY;
+ clickX = e.pageX;
+ },
+ 'mouseup': function() {
+ clicked = false;
+ $('html').css('cursor', 'auto');
+ }
+ });
+
+ let updateScrollPos = function(e) {
+ $('html').css('cursor', 'move');
+ $(scrollwrap).scrollTop($(scrollwrap).scrollTop() + (clickY - e.pageY));
+ $(scrollwrap).scrollLeft($(scrollwrap).scrollLeft() + (clickX - e.pageX));
+ };
+ }
+
+ dragPanDisable() {
+ this.setState({drag_pan: false});
+ let scrollwrap = this.refs.scrollwrap;
+ $(scrollwrap).off("mousemove");
+ $(scrollwrap).off("mousedown");
+ $(scrollwrap).off("mouseup");
+ }
+
+ zoomIn() {
+ let scale = this.state.scale;
+ if (scale < 2.1) {
+ scale = scale + 0.1;
+ this.zoom(scale);
+ } else {
+ this.setState({term_zoom_max: true});
+ }
+ }
+
+ zoomOut() {
+ let scale = this.state.scale;
+ if (scale >= 0.2) {
+ scale = scale - 0.1;
+ this.zoom(scale);
+ } else {
+ this.setState({term_zoom_min: true});
+ }
+ }
+
+ fitTo() {
+ this.setState({
+ term_top_style: "50%",
+ term_left_style: "50%",
+ term_translate: "-50%, -50%",
+ scale_lock: false,
+ term_scroll: "hidden",
+ });
+ this._transform();
+ }
+
+ componentWillUpdate(nextProps, nextState) {
+ /* If we changed pause state or speed exponent */
+ if (nextState.paused != this.state.paused ||
+ nextState.speedExp != this.state.speedExp) {
+ this.sync();
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ /* If we changed pause state or speed exponent */
+ if (this.state.paused != prevState.paused ||
+ this.state.speedExp != prevState.speedExp) {
+ this.speed = Math.pow(2, this.state.speedExp);
+ this.sync();
+ }
+ }
+
+ render() {
+ let speedExp = this.state.speedExp;
+ let speedFactor = Math.pow(2, Math.abs(speedExp));
+ let speedStr;
+
+ if (speedExp > 0) {
+ speedStr = "x" + speedFactor;
+ } else if (speedExp < 0) {
+ speedStr = "/" + speedFactor;
+ } else {
+ speedStr = "";
+ }
+
+ const style = {
+ "transform": "scale(" + this.state.scale + ") translate(" + this.state.term_translate + ")",
+ "transform-origin": "top left",
+ "display": "inline-block",
+ "margin": "0 auto",
+ "position": "absolute",
+ "top": this.state.term_top_style,
+ "left": this.state.term_left_style,
+ };
+
+ const scrollwrap = {
+ "min-width": "630px",
+ "height": this.containerHeight + "px",
+ "background-color": "#f5f5f5",
+ "overflow": this.state.term_scroll,
+ "position": "relative",
+ };
+
+ const to_right = {
+ "float": "right",
+ };
+
+ const progressbar_style = {
+ 'margin-top': '10px',
+ };
+
+ const currentTsPost = function(currentTS, bufLength) {
+ if (currentTS > bufLength) {
+ return bufLength;
+ }
+ return currentTS;
+ };
+
+ let error = "";
+ if (this.state.error) {
+ error = (
+
+
+
+
+
+ {this.state.error}.
+
);
+ }
+
+ // ensure react never reuses this div by keying it with the terminal widget
+ return (
+
+
+ {this.state.title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /2
+
+
+ 1:1
+
+
+ x2
+
+
{speedStr}
+
+
+
+
+
+
+
+
+
+
+
+ {error}
+
+ );
+ }
+
+ componentWillUnmount() {
+ this.buf.stop();
+ window.removeEventListener("keydown", this.handleKeyDown, false);
+ this.state.term.destroy();
+ }
+ };
+
+ Player.propTypes = {
+ matchList: React.PropTypes.array,
+ // onTitleChanged: React.PropTypes.func
+ };
+
+ module.exports = { Player: Player };
+}());
diff --git a/src/recordings.css b/src/recordings.css
new file mode 100644
index 0000000..e9293cf
--- /dev/null
+++ b/src/recordings.css
@@ -0,0 +1,368 @@
+@import "/page.css";
+
+@import "/console.css";
+@import "/journal.css";
+@import "/plot.css";
+@import "/table.css";
+
+@import "./timer.css";
+
+a.disabled {
+ text-decoration: none;
+ pointer-events: none;
+ cursor: default;
+ color: #000;
+}
+
+.popover {
+ max-width: none;
+ white-space: nowrap;
+}
+
+.systime-inline form .pficon-close,
+.systime-inline form .fa-plus {
+ height: 26px;
+ width: 26px;
+ padding: 4px;
+ float: right;
+ margin-left: 5px;
+}
+
+.systime-inline .form-inline {
+ background: #f4f4f4;
+ border-width: 0 1px 1px 1px;
+ border-style: solid;
+ border-color: #bababa;
+ padding: 4px;
+}
+
+.systime-inline .form-inline:first-of-type {
+ border-top: 1px solid #bababa;
+}
+
+.systime-inline .form-control {
+ margin: 0 4px;
+}
+
+.systime-inline .form-group:first-of-type .form-control {
+ margin: 0 4px 0 0;
+}
+
+.systime-inline .form-group .form-control {
+ width: 214px;
+}
+
+/* Make sure error message don't overflow the dialog */
+
+.realms-op-diagnostics {
+ max-width: 550px;
+ text-align: left;
+ max-height: 200px;
+}
+
+.realms-op-wait-message {
+ margin-left: 10px;
+ float: left;
+ margin-top: 3px;
+}
+
+.realms-op-address-spinner {
+ margin-left: -30px;
+ margin-top: 2px;
+}
+
+.realms-op-address-error {
+ margin-left: -30px;
+ margin-top: 2px;
+ color: red;
+}
+
+.realms-op-zero-width {
+ width: 0px;
+}
+
+.realms-op-error {
+ text-align: left;
+ font-weight: bold;
+ overflow: auto;
+ max-width: 550px;
+ max-height: 200px;
+}
+
+/* Other styles */
+
+.fa-red {
+ color: red;
+}
+
+.small-messages {
+ font-size: smaller;
+}
+
+#server-graph-toolbar .dropdown {
+ display: inline-block;
+}
+
+#server-graph-toolbar .dropdown-toggle span {
+ width: 6em;
+ text-align: left;
+ padding-left: 5px;
+ display: inline-block;
+}
+
+.server-graph {
+ height: 120px;
+}
+
+.server-graph-legend {
+ list-style-type: none;
+ padding: 30px 40px;
+ float: right;
+}
+
+.server-graph-legend i {
+ padding-right: 3px;
+ font-size: 14px;
+}
+
+.server-graph-legend .cpu-io-wait i {
+ color: #e41a1c;
+}
+
+.server-graph-legend .cpu-kernel i {
+ color: #ff7f00;
+}
+
+.server-graph-legend .cpu-user i {
+ color: #377eb8;
+}
+
+.server-graph-legend .cpu-nice i {
+ color: #4daf4a;
+}
+
+.server-graph-legend .memory-swap i {
+ color: #e41a1c;
+}
+
+.server-graph-legend .memory-cached i {
+ color: #ff7f00;
+}
+
+.server-graph-legend .memory-used i {
+ color: #377eb8;
+}
+
+.server-graph-legend .memory-free i {
+ color: #4daf4a;
+}
+
+#cpu_status_graph,
+#memory_status_graph {
+ height: 400px;
+ padding: 20px;
+}
+
+#sich-note-1,
+#sich-note-2 {
+ margin: 0;
+}
+#shutdown-dialog td {
+ padding-right: 20px;
+ vertical-align: top;
+}
+
+#shutdown-dialog .opt {
+ padding: 1px 10px;
+}
+
+#shutdown-dialog .dropdown {
+ min-width: 150px;
+}
+
+#shutdown-group {
+ overflow: visible;
+}
+
+#shutdown-dialog textarea {
+ resize: none;
+ margin-bottom: 10px;
+}
+
+#shutdown-dialog input {
+ display: inline;
+ width: 10em;
+}
+
+#shutdown-dialog .shutdown-hours,
+#shutdown-dialog .shutdown-minutes {
+ width: 3em;
+}
+
+#system_information_ssh_keys .list-group-item {
+ cursor: auto;
+}
+
+#system_information_hardware_text,
+#system_information_os_text {
+ overflow: visible;
+ white-space: normal;
+ word-wrap: break-word;
+}
+
+@media (min-width: 500px) {
+ .cockpit-modal-md {
+ width: 400px;
+ }
+}
+
+/* Make sure to not break log message lines in order to preserve information */
+#journal-entry .info-table-ct td {
+ white-space: normal;
+ word-break: break-all;
+}
+
+.service-unit-description {
+ font-weight: bold;
+}
+
+.service-unit-data {
+ text-align: right;
+ white-space: nowrap;
+}
+
+.service-unit-failed {
+ color: red;
+}
+
+.service-action-btn ul {
+ right: 0px;
+ left: auto;
+ min-width: 0;
+ text-align: left;
+}
+
+.service-panel td:first-child {
+ text-align: left;
+}
+
+.service-panel span {
+ font-weight: bold;
+}
+
+.service-panel td:last-child {
+ text-align: right;
+}
+
+.service-panel table {
+ width: 100%;
+}
+
+#services > .container-fluid {
+ margin-top: 5em;
+}
+
+.service-template input {
+ width: 50em;
+}
+
+#journal-current-day-menu dropdown-toggle {
+ padding-left: 10px;
+}
+
+#journal-box {
+ margin-top: 5em;
+}
+
+#journal-entry-message {
+ margin: 10px;
+}
+
+#journal-entry-fields {
+ margin-bottom: 10px;
+}
+
+/* Extra content header */
+
+.content-header-extra {
+ background: #f5f5f5;
+ border-bottom: 1px solid #ddd;
+ padding: 10px 20px;
+ width: 100%;
+ position: fixed;
+ z-index: 900;
+ top: 0;
+}
+
+.content-header-extra .btn-group:not(:first-child) {
+ padding-left: 20px;
+}
+
+#motd {
+ background-color: transparent;
+ border: none;
+ font-size: 14px;
+ padding: 0px;
+ margin: 0px;
+ white-space: pre-wrap;
+}
+
+.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;
+}
+
+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;
+}
+
+.player-wrap {
+ min-width: 672px;
+ height: auto;
+ overflow: hidden;
+}
+
+.player-wrap .panel-body, .player-wrap .console-ct > .terminal {
+ padding: 0;
+}
+
+.dragnpan {
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
diff --git a/src/recordings.jsx b/src/recordings.jsx
new file mode 100644
index 0000000..43f88ec
--- /dev/null
+++ b/src/recordings.jsx
@@ -0,0 +1,748 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2017 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see
.
+ */
+
+(function() {
+ "use strict";
+
+ let $ = require("jquery");
+ let cockpit = require("cockpit");
+ let _ = cockpit.gettext;
+ let moment = require("moment");
+ let Journal = require("journal");
+ let React = require("react");
+ let Listing = require("cockpit-components-listing.jsx");
+ let Player = require("./player.jsx");
+
+ require("bootstrap-datetime-picker/js/bootstrap-datetimepicker.js");
+ require("bootstrap-datetime-picker/css/bootstrap-datetimepicker.css");
+
+ /*
+ * Convert a number to integer number string and pad with zeroes to
+ * specified width.
+ */
+ let padInt = function (n, w) {
+ let i = Math.floor(n);
+ let a = Math.abs(i);
+ let s = a.toString();
+ for (w -= s.length; w > 0; w--) {
+ s = '0' + s;
+ }
+ return ((i < 0) ? '-' : '') + s;
+ }
+
+ /*
+ * Format date and time for a number of milliseconds since Epoch.
+ */
+ let formatDateTime = function (ms) {
+ let d = new Date(ms);
+ return (
+ padInt(d.getFullYear(), 4) + '-' +
+ padInt(d.getMonth() + 1, 2) + '-' +
+ padInt(d.getDate(), 2) + ' ' +
+ padInt(d.getHours(), 2) + ':' +
+ padInt(d.getMinutes(), 2) + ':' +
+ padInt(d.getSeconds(), 2)
+ );
+ };
+
+ /*
+ * Format a time interval from a number of milliseconds.
+ */
+ let formatDuration = function (ms) {
+ let v = Math.floor(ms / 1000);
+ let s = Math.floor(v % 60);
+ v = Math.floor(v / 60);
+ let m = Math.floor(v % 60);
+ v = Math.floor(v / 60);
+ let h = Math.floor(v % 24);
+ let d = Math.floor(v / 24);
+ let str = '';
+
+ if (d > 0) {
+ str += d + ' ' + _("days") + ' ';
+ }
+
+ if (h > 0 || str.length > 0) {
+ str += padInt(h, 2) + ':';
+ }
+
+ str += padInt(m, 2) + ':' + padInt(s, 2);
+
+ return (ms < 0 ? '-' : '') + str;
+ };
+
+ let parseDate = function(date) {
+ let regex = new RegExp(/^\s*(\d\d\d\d-\d\d-\d\d)(\s+(\d\d:\d\d(:\d\d)?))?\s*$/);
+
+ let captures = regex.exec(date);
+
+ if (captures != null) {
+ let date = captures[1];
+ if (captures[3]) {
+ date = date + " " + captures[3];
+ }
+ if (moment(date, ["YYYY-M-D H:m:s", "YYYY-M-D H:m", "YYYY-M-D"], true).isValid()) {
+ return date;
+ }
+ }
+
+ if (date === "" || date === null) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /*
+ * A component representing a date & time picker based on bootstrap-datetime-picker.
+ * Requires jQuery, bootstrap-datetime-picker, moment.js
+ * Properties:
+ * - onDateChange: function to call on date change event of datepicker.
+ * - date: variable to pass which will be used as initial value.
+ */
+ let Datetimepicker = class extends React.Component {
+ constructor(props) {
+ super(props);
+ this.handleDateChange = this.handleDateChange.bind(this);
+ this.clearField = this.clearField.bind(this);
+ this.markDateField = this.markDateField.bind(this);
+ this.state = {
+ invalid: false,
+ date: this.props.date,
+ dateLastValid: null,
+ };
+ }
+
+ componentDidMount() {
+ let funcDate = this.handleDateChange;
+ let datepicker = $(this.refs.datepicker).datetimepicker({
+ format: 'yyyy-mm-dd hh:ii:00',
+ autoclose: true,
+ todayBtn: true,
+ });
+ datepicker.on('changeDate', function(e) {
+ funcDate(e);
+ });
+ $(this.refs.datepicker_input).datetimepicker('remove');
+ this.markDateField();
+ }
+
+ componentWillUnmount() {
+ $(this.textInput).datetimepicker('remove');
+ }
+
+ handleDateChange(e) {
+ if (e.type === "changeDate") {
+ let event = new Event('input', { bubbles: true });
+ e.currentTarget.firstChild.dispatchEvent(event);
+ }
+
+ if (e.type === "input") {
+ this.setState({date: e.target.value});
+ if (parseDate(e.target.value)) {
+ this.setState({dateLastValid: e.target.value});
+ this.setState({invalid: false});
+ this.props.onDateChange(e.target.value, e.target.value.trim());
+ } else {
+ this.setState({invalid: true});
+ this.props.onDateChange(e.target.value, this.state.dateLastValid.trim());
+ }
+ }
+ }
+
+ clearField() {
+ $(this.refs.datepicker_input).val("");
+ let event = new Event('input', { bubbles: true });
+ this.refs.datepicker_input.dispatchEvent(event);
+ this.handleDateChange(event);
+ this.setState({invalid: false});
+ }
+
+ markDateField() {
+ let date = $(this.refs.datepicker_input).val()
+ .trim();
+ if (!parseDate(date)) {
+ this.setState({invalid: true});
+ } else {
+ this.setState({dateLastValid: date});
+ this.setState({invalid: false});
+ }
+ }
+
+ render() {
+ return (
+
+
+
+
+
+
+ );
+ }
+ }
+
+ /*
+ * A component representing a username input text field.
+ * TODO make as a select / drop-down with list of exisiting users.
+ */
+ let UserPicker = class extends React.Component {
+ constructor(props) {
+ super(props);
+ this.handleUsernameChange = this.handleUsernameChange.bind(this);
+ }
+
+ handleUsernameChange(e) {
+ this.props.onUsernameChange(e.target.value);
+ }
+
+ render() {
+ return (
+
+
+
+ );
+ }
+ }
+
+ /*
+ * A component representing a single recording view.
+ * Properties:
+ * - recording: either null for no recording data available yet, or a
+ * recording object, as created by the View below.
+ */
+ let Recording = class extends React.Component {
+ constructor(props) {
+ super(props);
+ this.goBackToList = this.goBackToList.bind(this);
+ }
+
+ goBackToList() {
+ if (cockpit.location.path[0]) {
+ cockpit.location.go([], cockpit.location.options);
+ } else {
+ cockpit.location.go('/');
+ }
+ }
+
+ render() {
+ let r = this.props.recording;
+ if (r == null) {
+ return
Loading... ;
+ } else {
+ let player =
+ (
);
+
+ return (
+
+
+
+
+
+
+ {_("Recording")}
+
+
+
+
+ {_("ID")}
+ {r.id}
+
+
+ {_("Boot ID")}
+ {r.boot_id}
+
+
+ {_("Session ID")}
+ {r.session_id}
+
+
+ {_("PID")}
+ {r.pid}
+
+
+ {_("Start")}
+ {formatDateTime(r.start)}
+
+
+ {_("End")}
+ {formatDateTime(r.end)}
+
+
+ {_("Duration")}
+ {formatDuration(r.end - r.start)}
+
+
+ {_("User")}
+ {r.user}
+
+
+
+
+
+
+ {player}
+
+
+
+ );
+ }
+ }
+ };
+
+ /*
+ * A component representing a list of recordings.
+ * Properties:
+ * - list: an array with recording objects, as created by the View below
+ */
+ let RecordingList = class extends React.Component {
+ constructor(props) {
+ super(props);
+ this.handleColumnClick = this.handleColumnClick.bind(this);
+ this.getSortedList = this.getSortedList.bind(this);
+ this.drawSortDir = this.drawSortDir.bind(this);
+ this.state = {
+ sorting_field: "start",
+ sorting_asc: true,
+ };
+ }
+
+ drawSortDir() {
+ $('#sort_arrow').remove();
+ let type = this.state.sorting_asc ? "asc" : "desc";
+ let arrow = '
';
+ $(this.refs[this.state.sorting_field]).append(arrow);
+ }
+
+ handleColumnClick(event) {
+ if (this.state.sorting_field === event.currentTarget.id) {
+ this.setState({sorting_asc: !this.state.sorting_asc});
+ } else {
+ this.setState({
+ sorting_field: event.currentTarget.id,
+ sorting_asc: 'asc'
+ });
+ }
+ }
+
+ getSortedList() {
+ let field = this.state.sorting_field;
+ let asc = this.state.sorting_asc;
+ let list = this.props.list.slice();
+
+ if (this.state.sorting_field != null) {
+ if (asc) {
+ list.sort(function(a, b) {
+ return a[field] > b[field];
+ });
+ } else {
+ list.sort(function(a, b) {
+ return a[field] < b[field];
+ });
+ }
+ }
+
+ return list;
+ }
+
+ /*
+ * Set the cockpit location to point to the specified recording.
+ */
+ navigateToRecording(recording) {
+ cockpit.location.go([recording.id], cockpit.location.options);
+ }
+
+ componentDidUpdate() {
+ this.drawSortDir();
+ }
+
+ render() {
+ let columnTitles = [
+ (
),
+ (
),
+ (
),
+ (
),
+ ];
+ let list = this.getSortedList();
+ let rows = [];
+
+ for (let i = 0; i < list.length; i++) {
+ let r = list[i];
+ let columns = [r.user,
+ formatDateTime(r.start),
+ formatDateTime(r.end),
+ formatDuration(r.end - r.start)];
+ rows.push(
);
+ }
+ return (
+
+
+
+
+
+ Since
+
+
+
+
+
+ Until
+
+
+
+
+
+ Username
+
+
+
+
+
+ Configuration
+
+
+
+
+
+
+
+
+
+ {rows}
+
+
+ );
+ }
+ };
+
+ /*
+ * A component representing the view upon a list of recordings, or a
+ * single recording. Extracts the ID of the recording to display from
+ * cockpit.location.path[0]. If it's zero, displays the list.
+ */
+ let View = class extends React.Component {
+ constructor(props) {
+ super(props);
+ this.onLocationChanged = this.onLocationChanged.bind(this);
+ this.journalctlIngest = this.journalctlIngest.bind(this);
+ this.handleDateSinceChange = this.handleDateSinceChange.bind(this);
+ this.handleDateUntilChange = this.handleDateUntilChange.bind(this);
+ this.handleUsernameChange = this.handleUsernameChange.bind(this);
+ /* Journalctl instance */
+ this.journalctl = null;
+ /* Recording ID journalctl instance is invoked with */
+ this.journalctlRecordingID = null;
+ /* Recording ID -> data map */
+ this.recordingMap = {};
+ /* tlog UID in system set in ComponentDidMount */
+ this.uid = null;
+ this.state = {
+ /* List of recordings in start order */
+ recordingList: [],
+ /* ID of the recording to display, or null for all */
+ recordingID: cockpit.location.path[0] || null,
+ dateSince: cockpit.location.options.dateSince || null,
+ dateSinceLastValid: null,
+ dateUntil: cockpit.location.options.dateUntil || null,
+ dateUntilLastValid: null,
+ /* value to filter recordings by username */
+ username: cockpit.location.options.username || null,
+ error_tlog_uid: false,
+ }
+ }
+
+ /*
+ * Display a journalctl error
+ */
+ journalctlError(error) {
+ console.warn(cockpit.message(error));
+ }
+
+ /*
+ * Respond to cockpit location change by extracting and setting the
+ * displayed recording ID.
+ */
+ onLocationChanged() {
+ this.setState({
+ recordingID: cockpit.location.path[0] || null,
+ dateSince: cockpit.location.options.dateSince || null,
+ dateUntil: cockpit.location.options.dateUntil || null,
+ username: cockpit.location.options.username || null,
+ });
+ }
+
+ /*
+ * Ingest journal entries sent by journalctl.
+ */
+ journalctlIngest(entryList) {
+ let recordingList = this.state.recordingList.slice();
+ let i;
+ let j;
+
+ for (i = 0; i < entryList.length; i++) {
+ let e = entryList[i];
+ let id = e['TLOG_REC'];
+
+ /* Skip entries with missing recording ID */
+ if (id === undefined) {
+ continue;
+ }
+
+ let ts = Math.floor(
+ parseInt(e["__REALTIME_TIMESTAMP"], 10) /
+ 1000);
+
+ let r = this.recordingMap[id];
+ /* If no recording found */
+ if (r === undefined) {
+ /* Create new recording */
+ r = {id: id,
+ matchList: ["_UID=" + this.uid,
+ "TLOG_REC=" + id],
+ user: e["TLOG_USER"],
+ boot_id: e["_BOOT_ID"],
+ session_id: parseInt(e["TLOG_SESSION"], 10),
+ pid: parseInt(e["_PID"], 10),
+ start: ts,
+ /* FIXME Should be start + message duration */
+ end: ts,
+ duration: 0};
+ /* Map the recording */
+ this.recordingMap[id] = r;
+ /* Insert the recording in order */
+ for (j = recordingList.length - 1;
+ j >= 0 && r.start < recordingList[j].start;
+ j--);
+ recordingList.splice(j + 1, 0, r);
+ } else {
+ /* Adjust existing recording */
+ if (ts > r.end) {
+ r.end = ts;
+ r.duration = r.end - r.start;
+ }
+ if (ts < r.start) {
+ r.start = ts;
+ r.duration = r.end - r.start;
+ /* Find the recording in the list */
+ for (j = recordingList.length - 1;
+ j >= 0 && recordingList[j] != r;
+ j--);
+ /* If found */
+ if (j >= 0) {
+ /* Remove */
+ recordingList.splice(j, 1);
+ }
+ /* Insert the recording in order */
+ for (j = recordingList.length - 1;
+ j >= 0 && r.start < recordingList[j].start;
+ j--);
+ recordingList.splice(j + 1, 0, r);
+ }
+ }
+ }
+
+ this.setState({recordingList: recordingList});
+ }
+
+ /*
+ * Start journalctl, retrieving entries for the current recording ID.
+ * Assumes journalctl is not running.
+ */
+ journalctlStart() {
+ let matches = ["_UID=" + this.uid];
+ if (this.state.username) {
+ matches.push("TLOG_USER=" + this.state.username);
+ }
+
+ let options = {follow: true, count: "all"};
+
+ if (this.state.dateSinceLastValid) {
+ options['since'] = this.state.dateSinceLastValid;
+ }
+
+ if (this.state.dateUntil) {
+ options['until'] = this.state.dateUntilLastValid;
+ }
+
+ if (this.state.recordingID !== null) {
+ matches.push("TLOG_REC=" + this.state.recordingID);
+ }
+
+ this.journalctlRecordingID = this.state.recordingID;
+ this.journalctl = Journal.journalctl(matches, options)
+ .fail(this.journalctlError)
+ .stream(this.journalctlIngest);
+ }
+
+ /*
+ * Check if journalctl is running.
+ */
+ journalctlIsRunning() {
+ return this.journalctl != null;
+ }
+
+ /*
+ * Stop current journalctl.
+ * Assumes journalctl is running.
+ */
+ journalctlStop() {
+ this.journalctl.stop();
+ this.journalctl = null;
+ }
+
+ /*
+ * Restarts journalctl.
+ * Will stop journalctl if it's running.
+ */
+ journalctlRestart() {
+ if (this.journalctlIsRunning()) {
+ this.journalctl.stop();
+ }
+ this.journalctlStart();
+ }
+
+ /*
+ * Clears previous recordings list.
+ * Will clear service obj recordingMap and state.
+ */
+ clearRecordings() {
+ this.recordingMap = {};
+ this.setState({recordingList: []});
+ }
+
+ handleDateSinceChange(date, last_valid) {
+ this.setState({dateSinceLastValid: last_valid});
+ cockpit.location.go([], $.extend(cockpit.location.options, { dateSince: date }));
+ }
+
+ handleDateUntilChange(date, last_valid) {
+ this.setState({dateUntilLastValid: last_valid});
+ cockpit.location.go([], $.extend(cockpit.location.options, { dateUntil: date }));
+ }
+
+ handleUsernameChange(username) {
+ cockpit.location.go([], $.extend(cockpit.location.options, { username: username }));
+ }
+
+ componentDidMount() {
+ let proc = cockpit.spawn(["getent", "passwd", "tlog"]);
+
+ proc.stream((data) => {
+ this.uid = data.split(":", 3)[2];
+ this.journalctlStart();
+ proc.close();
+ });
+
+ proc.fail(() => {
+ this.setState({error_tlog_uid: true});
+ });
+
+ let dateSince = parseDate(this.state.dateSince);
+
+ if (dateSince && dateSince != true) {
+ this.setState({dateSinceLastValid: dateSince});
+ }
+
+ let dateUntil = parseDate(this.state.dateUntil);
+
+ if (dateUntil && dateUntil != true) {
+ this.setState({dateUntilLastValid: dateUntil});
+ }
+
+ cockpit.addEventListener("locationchanged",
+ this.onLocationChanged);
+ }
+
+ componentWillUnmount() {
+ if (this.journalctlIsRunning()) {
+ this.journalctlStop();
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ /*
+ * If we're running a specific (non-wildcard) journalctl
+ * and recording ID has changed
+ */
+ if (this.journalctlRecordingID !== null &&
+ this.state.recordingID != prevState.recordingID) {
+ if (this.journalctlIsRunning()) {
+ this.journalctlStop();
+ }
+ this.journalctlStart();
+ }
+ if (this.state.dateSinceLastValid != prevState.dateSinceLastValid ||
+ this.state.dateUntilLastValid != prevState.dateUntilLastValid ||
+ this.state.username != prevState.username
+ ) {
+ this.clearRecordings();
+ this.journalctlRestart();
+ }
+ }
+
+ render() {
+ if (this.state.error_tlog_uid === true) {
+ return (
+
+ Error getting tlog UID from system.
+
+ );
+ }
+ if (this.state.recordingID === null) {
+ return (
+
+ );
+ } else {
+ return (
+
+ );
+ }
+ }
+ };
+
+ React.render(
, document.getElementById('view'));
+}());
diff --git a/src/terminal.jsx b/src/terminal.jsx
new file mode 100644
index 0000000..5b7c487
--- /dev/null
+++ b/src/terminal.jsx
@@ -0,0 +1,191 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2016 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see
.
+ */
+
+(function() {
+ "use strict";
+
+ var React = require("react");
+ var Term = require("term");
+ let $ = require("jquery");
+
+ require("console.css");
+ require("jquery-resizable");
+ require("jquery-resizable/resizable.css");
+
+ /*
+ * A terminal component that communicates over a cockpit channel.
+ *
+ * The only required property is 'channel', which must point to a cockpit
+ * stream channel.
+ *
+ * The size of the terminal can be set with the 'rows' and 'cols'
+ * properties. If those properties are not given, the terminal will fill
+ * its container.
+ *
+ * If the 'onTitleChanged' callback property is set, it will be called whenever
+ * the title of the terminal changes.
+ *
+ * Call focus() to set the input focus on the terminal.
+ */
+ var Terminal = React.createClass({
+ propTypes: {
+ cols: React.PropTypes.number,
+ rows: React.PropTypes.number,
+ channel: React.PropTypes.object.isRequired,
+ onTitleChanged: React.PropTypes.func
+ },
+
+ componentWillMount: function () {
+ var term = new Term({
+ cols: this.state.cols || 80,
+ rows: this.state.rows || 25,
+ screenKeys: true,
+ useStyle: true
+ });
+
+ term.on('data', function(data) {
+ if (this.props.channel.valid)
+ this.props.channel.send(data);
+ }.bind(this));
+
+ if (this.props.onTitleChanged)
+ term.on('title', this.props.onTitleChanged);
+
+ this.setState({ terminal: term });
+ },
+
+ componentDidMount: function () {
+ this.state.terminal.open(this.refs.terminal);
+ this.connectChannel();
+
+ let term = this.refs.terminal;
+ let onWindowResize = this.onWindowResize;
+
+ $(function() {
+ $(term).resizable({
+ direction: ['right', 'bottom'],
+ stop: function() {
+ onWindowResize();
+ },
+ });
+ });
+
+ if (!this.props.rows) {
+ window.addEventListener('resize', this.onWindowResize);
+ this.onWindowResize();
+ }
+ },
+
+ componentWillUpdate: function (nextProps, nextState) {
+ if (nextState.cols !== this.state.cols || nextState.rows !== this.state.rows) {
+ this.state.terminal.resize(nextState.cols, nextState.rows);
+ this.props.channel.control({
+ window: {
+ rows: nextState.rows,
+ cols: nextState.cols
+ }
+ });
+ }
+
+ if (nextProps.channel !== this.props.channel) {
+ this.state.terminal.reset();
+ this.disconnectChannel();
+ }
+ },
+
+ componentDidUpdate: function (prevProps) {
+ if (prevProps.channel !== this.props.channel)
+ this.connectChannel();
+ },
+
+ render: function () {
+ let style = {
+ 'min-width': '300px',
+ 'min-height': '100px',
+ }
+ // ensure react never reuses this div by keying it with the terminal widget
+ return
;
+ },
+
+ componentWillUnmount: function () {
+ this.disconnectChannel();
+ this.state.terminal.destroy();
+ },
+
+ onChannelMessage: function (event, data) {
+ if (this.state.terminal) {
+ this.state.terminal.write(data);
+ }
+ },
+
+ onChannelClose: function (event, options) {
+ var term = this.state.terminal;
+ term.write('\x1b[31m' + (options.problem || 'disconnected') + '\x1b[m\r\n');
+ term.cursorHidden = true;
+ term.refresh(term.y, term.y);
+ },
+
+ connectChannel: function () {
+ var channel = this.props.channel;
+ if (channel && channel.valid) {
+ channel.addEventListener('message', this.onChannelMessage.bind(this));
+ channel.addEventListener('close', this.onChannelClose.bind(this));
+ }
+ },
+
+ disconnectChannel: function () {
+ if (this.props.channel) {
+ this.props.channel.removeEventListener('message', this.onChannelMessage);
+ this.props.channel.removeEventListener('close', this.onChannelClose);
+ }
+ },
+
+ focus: function () {
+ if (this.state.terminal)
+ this.state.terminal.focus();
+ },
+
+ onWindowResize: function () {
+ if (this.refs) {
+ var padding = 2 * 11;
+ var node = this.getDOMNode();
+ var terminal = this.refs.terminal.querySelector('.terminal');
+
+ var ch = document.createElement('div');
+ ch.textContent = 'M';
+ terminal.appendChild(ch);
+ var height = ch.offsetHeight; // offsetHeight is only correct for block elements
+ ch.style.display = 'inline';
+ var width = ch.offsetWidth;
+ terminal.removeChild(ch);
+
+ this.setState({
+ rows: Math.floor((node.parentElement.clientHeight - padding) / height),
+ cols: Math.floor((node.parentElement.clientWidth - padding) / width)
+ });
+ }
+ },
+
+ send: function(value) {
+ this.state.terminal.send(value);
+ }
+ });
+
+ module.exports = { Terminal: Terminal };
+}());
diff --git a/src/timer.css b/src/timer.css
new file mode 100644
index 0000000..d0deec4
--- /dev/null
+++ b/src/timer.css
@@ -0,0 +1,163 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2015 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see
.
+ */
+#create-timer {
+ display: none;
+}
+.vertical-scroll {
+ max-height: 150px;
+ overflow-y: scroll;
+}
+
+.position-colon {
+ display: inline-block;
+}
+
+div#boot {
+ display: inline-block;
+ float: right;
+}
+
+div#boot-or-specific-time {
+ width: 170px;
+ display: inline-block;
+}
+
+div#drop-time {
+ width: 100px;
+ display: inline-block;
+}
+
+input#boot-time {
+ width: 50px;
+ display: inline-block;
+ position: relative;
+ top: 2px;
+}
+
+.hr, .min {
+ width:30px;
+ display: inline-block;
+}
+
+.form-inline {
+ background: #f4f4f4;
+ border-width: 0 1px 1px 1px;
+ border-style: solid;
+ border-color: #bababa;
+ padding: 4px;
+}
+
+#boot-label {
+ position: relative;
+ right: 8px;
+ white-space: nowrap;
+ color: #888888;
+}
+
+#repeat-time .form-inline:first-of-type {
+ border-top: 1px solid #bababa;
+}
+
+#repeat-time [data-content="month-days"] {
+ width: 75px;
+}
+
+#repeat-time [data-content="week-days"] {
+ width: 100px;
+}
+
+#repeat-time [data-content="close"] {
+ position: relative;
+ float: right;
+ right: 8px;
+ top: 2px;
+}
+
+#repeat-time [data-content="add"] {
+ position: relative;
+ float: right;
+ right: 4px;
+ top: 2px;
+}
+
+#repeat-time [data-provide="datepicker"] {
+ width: 120px;
+}
+
+[data-content='day-error'].repeat-error {
+ display: block;
+ font-size: 11px;
+ color: #4d5258;
+ line-height: 14px;
+}
+
+.has-error {
+ border-color: #cc0000;
+}
+
+.has-error:hover {
+ border-color: #990000;
+}
+
+.has-error:focus {
+ border-color: #990000;
+ -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ff3333;
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ff3333;
+}
+
+.repeat-error {
+ display: block;
+ font-size: 11px;
+ color: #cc0000;
+ line-height: 14px;
+}
+
+#services-page .datepicker-dropdown .prev,
+#services-page .datepicker-dropdown .next {
+ display: none;
+ visibility: hidden;
+}
+
+.date {
+ width:120px;
+}
+
+#hr-error, #min-error {
+ font-size: 11px;
+ line-height: 13px;
+}
+
+.help-block {
+ position: relative;
+ bottom: 6px;
+}
+
+@media (min-width: 500px) {
+ .cockpit-timer-modal-md {
+ width: 500px;
+ }
+ .form-inline .form-control {
+ display: inline-block;
+ width: 30px;
+ vertical-align: middle;
+ }
+ .form-inline .date .bootstrap-datepicker {
+ width: 100px;
+ }
+}
diff --git a/webpack.config.js b/webpack.config.js
index fce4863..53032c7 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -14,6 +14,7 @@ const srcdir = (process.env.SRCDIR || __dirname) + path.sep + "src";
const builddir = (process.env.SRCDIR || __dirname);
const distdir = builddir + path.sep + "dist";
const section = process.env.ONLYDIR || null;
+const libdir = path.resolve(srcdir, "pkg" + path.sep + "lib");
const nodedir = path.resolve((process.env.SRCDIR || __dirname), "node_modules");
/* A standard nodejs and webpack pattern */
@@ -21,13 +22,25 @@ var production = process.env.NODE_ENV === 'production';
var info = {
entries: {
- "index": [
- "./index.es6"
+ "recordings": [
+ "./recordings.jsx",
+ "./recordings.css",
+ "./pkg/lib/listing.less",
+ ],
+ "config": [
+ "./config.jsx",
+ "./recordings.css",
]
},
files: [
"index.html",
+ "config.html",
+ "player.jsx",
+ "recordings.jsx",
+ "recordings.css",
+ "terminal.jsx",
"manifest.json",
+ "timer.css",
],
};
@@ -101,6 +114,12 @@ module.exports = {
externals: externals,
output: output,
devtool: "source-map",
+ resolve: {
+ alias: {
+ "fs": path.resolve(nodedir, "fs-extra"),
+ },
+ modules: [libdir, nodedir],
+ },
module: {
rules: [
{
@@ -130,10 +149,18 @@ module.exports = {
loader: 'babel-loader',
test: /\.es6$/
},
+ {
+ test: /\.less$/,
+ loader: extract.extract("css-loader!less-loader")
+ },
{
exclude: /node_modules/,
loader: extract.extract('css-loader!sass-loader'),
test: /\.scss$/
+ },
+ {
+ loader: extract.extract("css-loader"),
+ test: /\.css$/,
}
]
},
From 471d2c160b8ace4a36bc1eba8070a57628e717df Mon Sep 17 00:00:00 2001
From: Kyrylo Gliebov
Date: Thu, 26 Jul 2018 13:59:47 +0200
Subject: [PATCH 002/208] Add SSSD config
---
package.json | 1 +
src/config.html | 18 +++++---
src/config.jsx | 115 +++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 127 insertions(+), 7 deletions(-)
diff --git a/package.json b/package.json
index fbb9665..93b49da 100644
--- a/package.json
+++ b/package.json
@@ -61,6 +61,7 @@
"term.js-cockpit": "0.0.10",
"fs.extra": "^1.3.2",
"fs.realpath": "^1.0.0",
+ "ini": "^1.3.5",
"node-sass": "^4.9.0",
"raw-loader": "^0.5.1"
}
diff --git a/src/config.html b/src/config.html
index 757a1ee..8799839 100644
--- a/src/config.html
+++ b/src/config.html
@@ -32,7 +32,7 @@ along with Cockpit; If not, see .
-
+
Session Recording
Configuration
@@ -40,10 +40,18 @@ along with Cockpit; If not, see .
-
-
-
Configuration
-
+
+
+
General Configuration
+
+
+
+
+
diff --git a/src/config.jsx b/src/config.jsx
index 0277128..ae02053 100644
--- a/src/config.jsx
+++ b/src/config.jsx
@@ -23,6 +23,7 @@
let cockpit = require("cockpit");
let React = require("react");
let json = require('comment-json');
+ let ini = require('ini');
let Config = class extends React.Component {
constructor(props) {
@@ -273,7 +274,117 @@
);
}
}
- }
+ };
- React.render(
, document.getElementById('view'));
+ let SssdConfig = class extends React.Component {
+ constructor(props) {
+ super(props);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ this.handleInputChange = this.handleInputChange.bind(this);
+ this.setConfig = this.setConfig.bind(this);
+ this.file = null;
+ this.state = {
+ config: {
+ session_recording: {
+ scope: null,
+ users: null,
+ groups: null,
+ },
+ },
+ };
+ }
+
+ handleInputChange(e){
+ const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
+ const name = e.target.name;
+ const config = this.state.config;
+ config.session_recording[name] = value;
+
+ this.forceUpdate();
+ }
+
+ setConfig(data) {
+ this.setState({config: data});
+ }
+
+ componentDidMount() {
+ let syntax_object = {
+ parse: ini.parse,
+ stringify: ini.stringify
+ };
+
+ this.file = cockpit.file("/etc/sssd/conf.d/sssd-session-recording.conf", {
+ syntax: syntax_object,
+ superuser: true,
+ });
+
+ let promise = this.file.read();
+
+ promise.done(this.setConfig);
+
+ promise.fail(function(error) {
+ console.log(error);
+ });
+ }
+
+ handleSubmit() {
+ this.file.replace(this.state.config).done( function() {
+ console.log('updated');
+ })
+ .fail( function(error) {
+ console.log(error);
+ });
+ event.preventDefault();
+ }
+
+ render() {
+ return (
+
+ );
+ }
+ };
+
+ React.render(
, document.getElementById('sr_config'));
+ React.render(
, document.getElementById('sssd_config'));
}());
From 9c605da2f63f327c663bacba6d28def10d370480 Mon Sep 17 00:00:00 2001
From: Kyrylo Gliebov
Date: Thu, 26 Jul 2018 14:47:30 +0200
Subject: [PATCH 003/208] Add Input playback
---
src/player.jsx | 160 ++++++++++++++++++++++++++++---------------------
1 file changed, 91 insertions(+), 69 deletions(-)
diff --git a/src/player.jsx b/src/player.jsx
index 128228d..929ab5d 100644
--- a/src/player.jsx
+++ b/src/player.jsx
@@ -481,6 +481,19 @@
}
};
+ let InputPlayer = class extends React.Component {
+ constructor(props) {
+ super(props);
+ }
+
+ render() {
+ return(
+
+ );
+ }
+
+ };
+
let Player = class extends React.Component {
constructor(props) {
super(props);
@@ -506,6 +519,7 @@
this.dragPanDisable = this.dragPanDisable.bind(this);
this.zoom = this.zoom.bind(this);
this.fastForwardToTS = this.fastForwardToTS.bind(this);
+ this.sendInput = this.sendInput.bind(this);
this.state = {
cols: 80,
@@ -528,7 +542,8 @@
containerWidth: 630,
currentTsPost: 0,
scale: 1,
- error: null
+ error: null,
+ input: ""
};
this.containerHeight = 290;
@@ -670,6 +685,13 @@
});
}
+ sendInput(pkt) {
+ if (pkt) {
+ const current_input = this.state.input;
+ this.setState({input: current_input + pkt.io});
+ }
+ }
+
/* Synchronize playback */
sync() {
let locDelay;
@@ -697,11 +719,6 @@
return;
}
- /* Skip packets we don't output */
- if (pkt.is_io && !pkt.is_output) {
- continue;
- }
-
this.pkt = pkt;
}
@@ -749,7 +766,9 @@
}
/* Output the packet */
- if (this.pkt.is_io) {
+ if (this.pkt.is_io && !this.pkt.is_output) {
+ this.sendInput(this.pkt);
+ } else if (this.pkt.is_io) {
this.state.term.write(this.pkt.io);
} else {
this.state.term.resize(this.pkt.width, this.pkt.height);
@@ -995,71 +1014,74 @@
// ensure react never reuses this div by keying it with the terminal widget
return (
-
-
- {this.state.title}
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /2
-
-
- 1:1
-
-
- x2
-
-
{speedStr}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /2
+
+
+ 1:1
+
+
+ x2
+
+
{speedStr}
+
+
+
+
+
+
+
+
+
+
+
+ {error}
- {error}
+
);
}
From b1a44e337ada15fc607ab57225c1c92be37825e5 Mon Sep 17 00:00:00 2001
From: Kyrylo Gliebov
Date: Thu, 26 Jul 2018 17:41:49 +0200
Subject: [PATCH 004/208] Add Hostname filter and column
---
src/recordings.jsx | 126 +++++++++++++++++++++++++++++++++++++++++----
1 file changed, 115 insertions(+), 11 deletions(-)
diff --git a/src/recordings.jsx b/src/recordings.jsx
index 43f88ec..3153ee4 100644
--- a/src/recordings.jsx
+++ b/src/recordings.jsx
@@ -223,6 +223,26 @@
}
}
+ let HostnamePicker = class extends React.Component {
+ constructor(props) {
+ super(props);
+ this.handleHostnameChange = this.handleHostnameChange.bind(this);
+ }
+
+ handleHostnameChange(e) {
+ this.props.onHostnameChange(e.target.value);
+ }
+
+ render() {
+ return (
+
+
+
+ );
+ }
+ }
+
/*
* A component representing a single recording view.
* Properties:
@@ -233,6 +253,9 @@
constructor(props) {
super(props);
this.goBackToList = this.goBackToList.bind(this);
+ this.getHostname = this.getHostname.bind(this);
+ this.Hostname = this.Hostname.bind(this);
+ this.hostname = null;
}
goBackToList() {
@@ -243,6 +266,35 @@
}
}
+ getHostname() {
+ cockpit.spawn(["hostname"], { err: "ignore" })
+ .done(function(output) {
+ this.hostname = $.trim(output);
+ })
+ .fail(function(ex) {
+ console.log(ex);
+ });
+ }
+
+ Hostname(props) {
+ let style = {
+ display: "none"
+ };
+ if (this.hostname != null && this.hostname != props.hostname) {
+ style = {};
+ }
+ return (
+
+ {_("Hostname")}
+ {props.hostname}
+
+ );
+ }
+
+ componentWillMount() {
+ this.getHostname();
+ }
+
render() {
let r = this.props.recording;
if (r == null) {
@@ -275,6 +327,7 @@
{_("ID")}
{r.id}
+
{_("Boot ID")}
{r.boot_id}
@@ -328,6 +381,8 @@
this.handleColumnClick = this.handleColumnClick.bind(this);
this.getSortedList = this.getSortedList.bind(this);
this.drawSortDir = this.drawSortDir.bind(this);
+ this.getColumnTitles = this.getColumnTitles.bind(this);
+ this.getColumns = this.getColumns.bind(this);
this.state = {
sorting_field: "start",
sorting_asc: true,
@@ -383,26 +438,43 @@
this.drawSortDir();
}
- render() {
+ getColumnTitles() {
let columnTitles = [
(),
+ ref="user" className="sort-icon">),
(),
+ ref="start" className="sort-icon">),
(),
+ ref="end" className="sort-icon">),
(),
+ ref="duration" className="sort-icon">),
];
+ if (this.props.diff_hosts === true) {
+ columnTitles.push(());
+ }
+ return columnTitles;
+ }
+
+ getColumns(r) {
+ let columns = [r.user,
+ formatDateTime(r.start),
+ formatDateTime(r.end),
+ formatDuration(r.end - r.start)]
+ if (this.props.diff_hosts === true) {
+ columns.push(r.hostname);
+ }
+ return columns;
+ }
+
+ render() {
+ let columnTitles = this.getColumnTitles();
let list = this.getSortedList();
let rows = [];
for (let i = 0; i < list.length; i++) {
let r = list[i];
- let columns = [r.user,
- formatDateTime(r.start),
- formatDateTime(r.end),
- formatDuration(r.end - r.start)];
+ let columns = this.getColumns(r);
rows.push(
+
+ Hostname
+
+
+
+
Configuration
@@ -468,6 +547,7 @@
this.handleDateSinceChange = this.handleDateSinceChange.bind(this);
this.handleDateUntilChange = this.handleDateUntilChange.bind(this);
this.handleUsernameChange = this.handleUsernameChange.bind(this);
+ this.handleHostnameChange = this.handleHostnameChange.bind(this);
/* Journalctl instance */
this.journalctl = null;
/* Recording ID journalctl instance is invoked with */
@@ -487,7 +567,9 @@
dateUntilLastValid: null,
/* value to filter recordings by username */
username: cockpit.location.options.username || null,
+ hostname: cockpit.location.options.hostname || null,
error_tlog_uid: false,
+ diff_hosts: false,
}
}
@@ -508,6 +590,7 @@
dateSince: cockpit.location.options.dateSince || null,
dateUntil: cockpit.location.options.dateUntil || null,
username: cockpit.location.options.username || null,
+ hostname: cockpit.location.options.hostname || null,
});
}
@@ -518,6 +601,13 @@
let recordingList = this.state.recordingList.slice();
let i;
let j;
+ let hostname;
+
+ if (entryList[0]) {
+ if (entryList[0]["_HOSTNAME"]) {
+ hostname = entryList[0]["_HOSTNAME"];
+ }
+ }
for (i = 0; i < entryList.length; i++) {
let e = entryList[i];
@@ -536,6 +626,10 @@
/* If no recording found */
if (r === undefined) {
/* Create new recording */
+ if (hostname != e["_HOSTNAME"]) {
+ this.setState({diff_hosts: true});
+ }
+
r = {id: id,
matchList: ["_UID=" + this.uid,
"TLOG_REC=" + id],
@@ -593,6 +687,10 @@
if (this.state.username) {
matches.push("TLOG_USER=" + this.state.username);
}
+ if (this.state.hostname && this.state.hostname != null &&
+ this.state.hostname != "") {
+ matches.push("_HOSTNAME=" + this.state.hostname);
+ }
let options = {follow: true, count: "all"};
@@ -664,6 +762,10 @@
cockpit.location.go([], $.extend(cockpit.location.options, { username: username }));
}
+ handleHostnameChange(hostname) {
+ cockpit.location.go([], $.extend(cockpit.location.options, { hostname: hostname }));
+ }
+
componentDidMount() {
let proc = cockpit.spawn(["getent", "passwd", "tlog"]);
@@ -713,7 +815,8 @@
}
if (this.state.dateSinceLastValid != prevState.dateSinceLastValid ||
this.state.dateUntilLastValid != prevState.dateUntilLastValid ||
- this.state.username != prevState.username
+ this.state.username != prevState.username ||
+ this.state.hostname != prevState.hostname
) {
this.clearRecordings();
this.journalctlRestart();
@@ -734,7 +837,8 @@
onDateSinceChange={this.handleDateSinceChange} dateSince={this.state.dateSince}
onDateUntilChange={this.handleDateUntilChange} dateUntil={this.state.dateUntil}
onUsernameChange={this.handleUsernameChange} username={this.state.username}
- list={this.state.recordingList} />
+ onHostnameChange={this.handleHostnameChange} hostname={this.state.hostname}
+ list={this.state.recordingList} diff_hosts={this.state.diff_hosts} />
);
} else {
return (
From ea3eb80c07e5650dff93bfdd33a2e9fee67a9fc0 Mon Sep 17 00:00:00 2001
From: Kyrylo Gliebov
Date: Thu, 9 Aug 2018 18:26:16 +0200
Subject: [PATCH 005/208] Add correlated Logs view
---
src/player.jsx | 3 +
src/recordings.css | 16 ++++
src/recordings.jsx | 198 ++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 215 insertions(+), 2 deletions(-)
diff --git a/src/player.jsx b/src/player.jsx
index 929ab5d..b8a77f8 100644
--- a/src/player.jsx
+++ b/src/player.jsx
@@ -765,6 +765,9 @@
}
}
+ /* Send packet ts to the top */
+ this.props.onTsChange(this.pkt.pos);
+
/* Output the packet */
if (this.pkt.is_io && !this.pkt.is_output) {
this.sendInput(this.pkt);
diff --git a/src/recordings.css b/src/recordings.css
index e9293cf..2d27ab4 100644
--- a/src/recordings.css
+++ b/src/recordings.css
@@ -366,3 +366,19 @@ table.listing-ct > thead th:last-child, tr.listing-ct-item td:last-child {
-ms-user-select: none;
user-select: none;
}
+
+.highlighted {
+ background-color: #ededed !important;
+}
+
+#logs-view {
+ height: 300px;
+ overflow-y: scroll;
+ margin-bottom: 0;
+}
+
+.logs-view-log-time {
+ display: inline-block;
+ width: 150px;
+ vertical-align: middle;
+}
diff --git a/src/recordings.jsx b/src/recordings.jsx
index 3153ee4..25c7e79 100644
--- a/src/recordings.jsx
+++ b/src/recordings.jsx
@@ -243,6 +243,184 @@
}
}
+ function LogElement(props) {
+ const entry = props.entry;
+ const start = props.start;
+ const end = props.end;
+ const entry_timestamp = entry.__REALTIME_TIMESTAMP / 1000;
+ let className = 'cockpit-logline';
+ if (start < entry_timestamp && end > entry_timestamp) {
+ className = 'cockpit-logline highlighted';
+ }
+ return (
+
+
+
+
+
{formatDateTime(parseInt(entry.__REALTIME_TIMESTAMP / 1000))}
+
{entry.MESSAGE}
+
+ );
+ }
+
+ function LogsView(props) {
+ const entries = props.entries;
+ const start = props.start;
+ const end = props.end;
+ const rows = entries.map((entry) =>
+
+ );
+ return (
+
+ {rows}
+
+ );
+ }
+
+ let Logs = class extends React.Component {
+ constructor(props) {
+ super(props);
+ this.journalctlError = this.journalctlError.bind(this);
+ this.journalctlIngest = this.journalctlIngest.bind(this);
+ this.journalctlPrepend = this.journalctlPrepend.bind(this);
+ this.getLogs = this.getLogs.bind(this);
+ this.loadLater = this.loadLater.bind(this);
+ this.loadEarlier = this.loadEarlier.bind(this);
+ this.loadForTs = this.loadForTs.bind(this);
+ this.journalCtl = null;
+ this.entries = [];
+ this.start = null;
+ this.end = null;
+ this.earlier_than = null;
+ this.load_earlier = false;
+ this.state = {
+ cursor: null,
+ after: null,
+ entries: [],
+ };
+ }
+
+ scrollToTop() {
+ const logs_view = document.getElementById("logs-view");
+ logs_view.scrollTop = 0;
+ }
+
+ scrollToBottom() {
+ const logs_view = document.getElementById("logs-view");
+ logs_view.scrollTop = logs_view.scrollHeight;
+ }
+
+ journalctlError(error) {
+ console.warn(cockpit.message(error));
+ }
+
+ journalctlIngest(entryList) {
+ if (this.load_earlier === true) {
+ entryList.push(...this.entries);
+ this.entries = entryList;
+ this.setState({entries: this.entries});
+ this.load_earlier = false;
+ this.scrollToTop();
+ } else {
+ if (entryList.length > 0) {
+ this.entries.push(...entryList);
+ const after = this.entries[this.entries.length - 1].__CURSOR;
+ this.setState({entries: this.entries, after: after});
+ this.scrollToBottom();
+ }
+ }
+ }
+
+ journalctlPrepend(entryList) {
+ entryList.push(...this.entries);
+ this.setState({entries: this.entries});
+ }
+
+ getLogs() {
+ if (this.start != null && this.end != null) {
+ if (this.journalCtl != null) {
+ this.journalCtl.stop();
+ this.journalCtl = null;
+ }
+
+ let matches = [];
+
+ let options = {
+ since: formatDateTime(this.start),
+ until: formatDateTime(this.end),
+ follow: false,
+ count: "all",
+ };
+
+ if (this.load_earlier === true) {
+ options["until"] = formatDateTime(this.earlier_than);
+ } else if (this.state.after != null) {
+ options["after"] = this.state.after;
+ delete options.since;
+ }
+
+ const self = this;
+ this.journalCtl = Journal.journalctl(matches, options)
+ .fail(this.journalctlError)
+ .done(function(data) {
+ self.journalctlIngest(data);
+ });
+ }
+ }
+
+ loadEarlier() {
+ this.load_earlier = true;
+ this.start = this.start - 3600;
+ this.getLogs();
+ }
+
+ loadLater() {
+ this.start = this.end;
+ this.end = this.end + 3600;
+ this.getLogs();
+ }
+
+ loadForTs(ts) {
+ this.end = this.start + ts;
+ this.getLogs();
+ }
+
+ componentDidUpdate() {
+ if (this.props.recording) {
+ if (this.start === null && this.end === null) {
+ this.end = this.props.recording.start + 3600;
+ this.start = this.props.recording.start;
+ this.earlier_than = this.props.recording.start;
+ }
+ this.getLogs();
+ }
+ if (this.props.curTs) {
+ const ts = this.props.curTs;
+ this.loadForTs(ts);
+ }
+ }
+
+ render() {
+ if (this.props.recording) {
+ return (
+
+
+ Logs
+ Load earlier entries
+
+
+
+ Load later entries
+
+
+ );
+ } else {
+ return (Loading...
);
+ }
+ }
+ }
+
/*
* A component representing a single recording view.
* Properties:
@@ -303,7 +481,8 @@
let player =
( );
+ matchList={this.props.recording.matchList}
+ onTsChange={this.props.onTsChange} />);
return (
@@ -548,6 +727,7 @@
this.handleDateUntilChange = this.handleDateUntilChange.bind(this);
this.handleUsernameChange = this.handleUsernameChange.bind(this);
this.handleHostnameChange = this.handleHostnameChange.bind(this);
+ this.handleTsChange = this.handleTsChange.bind(this);
/* Journalctl instance */
this.journalctl = null;
/* Recording ID journalctl instance is invoked with */
@@ -570,6 +750,7 @@
hostname: cockpit.location.options.hostname || null,
error_tlog_uid: false,
diff_hosts: false,
+ curTs: null,
}
}
@@ -766,6 +947,10 @@
cockpit.location.go([], $.extend(cockpit.location.options, { hostname: hostname }));
}
+ handleTsChange(ts) {
+ this.setState({curTs: ts});
+ }
+
componentDidMount() {
let proc = cockpit.spawn(["getent", "passwd", "tlog"]);
@@ -842,7 +1027,16 @@
);
} else {
return (
-
+
);
}
}
From b7c21ae1046c46471f9bb41f8d6814512e30841d Mon Sep 17 00:00:00 2001
From: Kyrylo Gliebov
Date: Mon, 3 Sep 2018 13:37:02 +0200
Subject: [PATCH 006/208] Fix CSS
---
src/console.css | 59 +++++
src/index.html | 1 +
src/journal.css | 134 ++++++++++++
src/manifest.json | 4 +-
src/page.css | 235 ++++++++++++++++++++
src/plot.css | 40 ++++
src/recordings.css | 8 +-
src/table.css | 146 +++++++++++++
src/term.css | 522 +++++++++++++++++++++++++++++++++++++++++++++
webpack.config.js | 3 +-
10 files changed, 1145 insertions(+), 7 deletions(-)
create mode 100644 src/console.css
create mode 100644 src/journal.css
create mode 100644 src/page.css
create mode 100644 src/plot.css
create mode 100644 src/table.css
create mode 100644 src/term.css
diff --git a/src/console.css b/src/console.css
new file mode 100644
index 0000000..b8ee78b
--- /dev/null
+++ b/src/console.css
@@ -0,0 +1,59 @@
+@import "term.css";
+
+/* Our terminal or logs */
+.console-ct {
+ font-family: Menlo, Monaco, Consolas, monospace;
+ margin-top: 0;
+ margin-bottom: 0;
+ font-size: 10px;
+ text-align: center;
+ line-height: normal;
+}
+
+@media (min-width: 568px) {
+ .console-ct {
+ font-size: 12px;
+ }
+}
+
+.console-ct > pre {
+ padding: 10px;
+ text-align: left;
+ display: block;
+ font-family: inherit;
+ font-size: inherit;
+ width: 48em;
+ height: 310px;
+ overflow-y: scroll;
+ white-space: pre-wrap;
+ margin: 0 auto;
+}
+
+.console-ct > .terminal {
+ color: #F0F0F0;
+ text-align: left;
+ outline: medium none;
+ background-color: black;
+ border: 1px solid black;
+ padding: 10px;
+}
+
+.terminal .terminal-cursor {
+ border: 1px solid #f0f0f0;
+}
+
+.terminal:focus .terminal-cursor {
+ border: none;
+ animation: blink 1s step-end infinite;
+}
+
+@keyframes blink {
+ from {
+ color: #000;
+ background: #f0f0f0;
+ }
+ 50% {
+ color: #f0f0f0;
+ background: #000;
+ }
+}
diff --git a/src/index.html b/src/index.html
index e23d550..1fa9cb3 100644
--- a/src/index.html
+++ b/src/index.html
@@ -23,6 +23,7 @@ along with Cockpit; If not, see .
Journal
+
diff --git a/src/journal.css b/src/journal.css
new file mode 100644
index 0000000..8d023f9
--- /dev/null
+++ b/src/journal.css
@@ -0,0 +1,134 @@
+/*
+ * 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 .
+ */
+
+.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;
+}
+
diff --git a/src/manifest.json b/src/manifest.json
index 2fe650b..2f84d1d 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -1,6 +1,6 @@
{
"version": "163.x",
- "name": "session_recording",
+ "name": "session-recording",
"requires": {
"cockpit": "122"
@@ -9,7 +9,7 @@
"menu": {
"index": {
"label": "Session Recording",
- "order": 100
+ "order": 110
}
}
}
diff --git a/src/page.css b/src/page.css
new file mode 100644
index 0000000..e5690eb
--- /dev/null
+++ b/src/page.css
@@ -0,0 +1,235 @@
+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;
+}
diff --git a/src/plot.css b/src/plot.css
new file mode 100644
index 0000000..167a863
--- /dev/null
+++ b/src/plot.css
@@ -0,0 +1,40 @@
+.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;
+}
diff --git a/src/recordings.css b/src/recordings.css
index 2d27ab4..f7d6965 100644
--- a/src/recordings.css
+++ b/src/recordings.css
@@ -1,9 +1,9 @@
@import "/page.css";
-@import "/console.css";
-@import "/journal.css";
-@import "/plot.css";
-@import "/table.css";
+@import "./console.css";
+@import "./journal.css";
+@import "./plot.css";
+@import "./table.css";
@import "./timer.css";
diff --git a/src/table.css b/src/table.css
new file mode 100644
index 0000000..e60493a
--- /dev/null
+++ b/src/table.css
@@ -0,0 +1,146 @@
+/* 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;
+}
+
+.info-table-ct > tr > td:first-child,
+.info-table-ct > tbody > tr > td:first-child {
+ text-align: right;
+ 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%;
+ }
+}
diff --git a/src/term.css b/src/term.css
new file mode 100644
index 0000000..eb69642
--- /dev/null
+++ b/src/term.css
@@ -0,0 +1,522 @@
+.term-bg-color-0 { background-color: #2e3436; }
+.term-fg-color-0 { color: #2e3436; }
+.term-bg-color-1 { background-color: #cc0000; }
+.term-fg-color-1 { color: #cc0000; }
+.term-bg-color-2 { background-color: #4e9a06; }
+.term-fg-color-2 { color: #4e9a06; }
+.term-bg-color-3 { background-color: #c4a000; }
+.term-fg-color-3 { color: #c4a000; }
+.term-bg-color-4 { background-color: #3465a4; }
+.term-fg-color-4 { color: #3465a4; }
+.term-bg-color-5 { background-color: #75507b; }
+.term-fg-color-5 { color: #75507b; }
+.term-bg-color-6 { background-color: #06989a; }
+.term-fg-color-6 { color: #06989a; }
+.term-bg-color-7 { background-color: #d3d7cf; }
+.term-fg-color-7 { color: #d3d7cf; }
+.term-bg-color-8 { background-color: #555753; }
+.term-fg-color-8 { color: #555753; }
+.term-bg-color-9 { background-color: #ef2929; }
+.term-fg-color-9 { color: #ef2929; }
+.term-bg-color-10 { background-color: #8ae234; }
+.term-fg-color-10 { color: #8ae234; }
+.term-bg-color-11 { background-color: #fce94f; }
+.term-fg-color-11 { color: #fce94f; }
+.term-bg-color-12 { background-color: #729fcf; }
+.term-fg-color-12 { color: #729fcf; }
+.term-bg-color-13 { background-color: #ad7fa8; }
+.term-fg-color-13 { color: #ad7fa8; }
+.term-bg-color-14 { background-color: #34e2e2; }
+.term-fg-color-14 { color: #34e2e2; }
+.term-bg-color-15 { background-color: #eeeeec; }
+.term-fg-color-15 { color: #eeeeec; }
+.term-bg-color-16 { background-color: #000000; }
+.term-fg-color-16 { color: #000000; }
+.term-bg-color-17 { background-color: #00005f; }
+.term-fg-color-17 { color: #00005f; }
+.term-bg-color-18 { background-color: #000087; }
+.term-fg-color-18 { color: #000087; }
+.term-bg-color-19 { background-color: #0000af; }
+.term-fg-color-19 { color: #0000af; }
+.term-bg-color-20 { background-color: #0000d7; }
+.term-fg-color-20 { color: #0000d7; }
+.term-bg-color-21 { background-color: #0000ff; }
+.term-fg-color-21 { color: #0000ff; }
+.term-bg-color-22 { background-color: #005f00; }
+.term-fg-color-22 { color: #005f00; }
+.term-bg-color-23 { background-color: #005f5f; }
+.term-fg-color-23 { color: #005f5f; }
+.term-bg-color-24 { background-color: #005f87; }
+.term-fg-color-24 { color: #005f87; }
+.term-bg-color-25 { background-color: #005faf; }
+.term-fg-color-25 { color: #005faf; }
+.term-bg-color-26 { background-color: #005fd7; }
+.term-fg-color-26 { color: #005fd7; }
+.term-bg-color-27 { background-color: #005fff; }
+.term-fg-color-27 { color: #005fff; }
+.term-bg-color-28 { background-color: #008700; }
+.term-fg-color-28 { color: #008700; }
+.term-bg-color-29 { background-color: #00875f; }
+.term-fg-color-29 { color: #00875f; }
+.term-bg-color-30 { background-color: #008787; }
+.term-fg-color-30 { color: #008787; }
+.term-bg-color-31 { background-color: #0087af; }
+.term-fg-color-31 { color: #0087af; }
+.term-bg-color-32 { background-color: #0087d7; }
+.term-fg-color-32 { color: #0087d7; }
+.term-bg-color-33 { background-color: #0087ff; }
+.term-fg-color-33 { color: #0087ff; }
+.term-bg-color-34 { background-color: #00af00; }
+.term-fg-color-34 { color: #00af00; }
+.term-bg-color-35 { background-color: #00af5f; }
+.term-fg-color-35 { color: #00af5f; }
+.term-bg-color-36 { background-color: #00af87; }
+.term-fg-color-36 { color: #00af87; }
+.term-bg-color-37 { background-color: #00afaf; }
+.term-fg-color-37 { color: #00afaf; }
+.term-bg-color-38 { background-color: #00afd7; }
+.term-fg-color-38 { color: #00afd7; }
+.term-bg-color-39 { background-color: #00afff; }
+.term-fg-color-39 { color: #00afff; }
+.term-bg-color-40 { background-color: #00d700; }
+.term-fg-color-40 { color: #00d700; }
+.term-bg-color-41 { background-color: #00d75f; }
+.term-fg-color-41 { color: #00d75f; }
+.term-bg-color-42 { background-color: #00d787; }
+.term-fg-color-42 { color: #00d787; }
+.term-bg-color-43 { background-color: #00d7af; }
+.term-fg-color-43 { color: #00d7af; }
+.term-bg-color-44 { background-color: #00d7d7; }
+.term-fg-color-44 { color: #00d7d7; }
+.term-bg-color-45 { background-color: #00d7ff; }
+.term-fg-color-45 { color: #00d7ff; }
+.term-bg-color-46 { background-color: #00ff00; }
+.term-fg-color-46 { color: #00ff00; }
+.term-bg-color-47 { background-color: #00ff5f; }
+.term-fg-color-47 { color: #00ff5f; }
+.term-bg-color-48 { background-color: #00ff87; }
+.term-fg-color-48 { color: #00ff87; }
+.term-bg-color-49 { background-color: #00ffaf; }
+.term-fg-color-49 { color: #00ffaf; }
+.term-bg-color-50 { background-color: #00ffd7; }
+.term-fg-color-50 { color: #00ffd7; }
+.term-bg-color-51 { background-color: #00ffff; }
+.term-fg-color-51 { color: #00ffff; }
+.term-bg-color-52 { background-color: #5f0000; }
+.term-fg-color-52 { color: #5f0000; }
+.term-bg-color-53 { background-color: #5f005f; }
+.term-fg-color-53 { color: #5f005f; }
+.term-bg-color-54 { background-color: #5f0087; }
+.term-fg-color-54 { color: #5f0087; }
+.term-bg-color-55 { background-color: #5f00af; }
+.term-fg-color-55 { color: #5f00af; }
+.term-bg-color-56 { background-color: #5f00d7; }
+.term-fg-color-56 { color: #5f00d7; }
+.term-bg-color-57 { background-color: #5f00ff; }
+.term-fg-color-57 { color: #5f00ff; }
+.term-bg-color-58 { background-color: #5f5f00; }
+.term-fg-color-58 { color: #5f5f00; }
+.term-bg-color-59 { background-color: #5f5f5f; }
+.term-fg-color-59 { color: #5f5f5f; }
+.term-bg-color-60 { background-color: #5f5f87; }
+.term-fg-color-60 { color: #5f5f87; }
+.term-bg-color-61 { background-color: #5f5faf; }
+.term-fg-color-61 { color: #5f5faf; }
+.term-bg-color-62 { background-color: #5f5fd7; }
+.term-fg-color-62 { color: #5f5fd7; }
+.term-bg-color-63 { background-color: #5f5fff; }
+.term-fg-color-63 { color: #5f5fff; }
+.term-bg-color-64 { background-color: #5f8700; }
+.term-fg-color-64 { color: #5f8700; }
+.term-bg-color-65 { background-color: #5f875f; }
+.term-fg-color-65 { color: #5f875f; }
+.term-bg-color-66 { background-color: #5f8787; }
+.term-fg-color-66 { color: #5f8787; }
+.term-bg-color-67 { background-color: #5f87af; }
+.term-fg-color-67 { color: #5f87af; }
+.term-bg-color-68 { background-color: #5f87d7; }
+.term-fg-color-68 { color: #5f87d7; }
+.term-bg-color-69 { background-color: #5f87ff; }
+.term-fg-color-69 { color: #5f87ff; }
+.term-bg-color-70 { background-color: #5faf00; }
+.term-fg-color-70 { color: #5faf00; }
+.term-bg-color-71 { background-color: #5faf5f; }
+.term-fg-color-71 { color: #5faf5f; }
+.term-bg-color-72 { background-color: #5faf87; }
+.term-fg-color-72 { color: #5faf87; }
+.term-bg-color-73 { background-color: #5fafaf; }
+.term-fg-color-73 { color: #5fafaf; }
+.term-bg-color-74 { background-color: #5fafd7; }
+.term-fg-color-74 { color: #5fafd7; }
+.term-bg-color-75 { background-color: #5fafff; }
+.term-fg-color-75 { color: #5fafff; }
+.term-bg-color-76 { background-color: #5fd700; }
+.term-fg-color-76 { color: #5fd700; }
+.term-bg-color-77 { background-color: #5fd75f; }
+.term-fg-color-77 { color: #5fd75f; }
+.term-bg-color-78 { background-color: #5fd787; }
+.term-fg-color-78 { color: #5fd787; }
+.term-bg-color-79 { background-color: #5fd7af; }
+.term-fg-color-79 { color: #5fd7af; }
+.term-bg-color-80 { background-color: #5fd7d7; }
+.term-fg-color-80 { color: #5fd7d7; }
+.term-bg-color-81 { background-color: #5fd7ff; }
+.term-fg-color-81 { color: #5fd7ff; }
+.term-bg-color-82 { background-color: #5fff00; }
+.term-fg-color-82 { color: #5fff00; }
+.term-bg-color-83 { background-color: #5fff5f; }
+.term-fg-color-83 { color: #5fff5f; }
+.term-bg-color-84 { background-color: #5fff87; }
+.term-fg-color-84 { color: #5fff87; }
+.term-bg-color-85 { background-color: #5fffaf; }
+.term-fg-color-85 { color: #5fffaf; }
+.term-bg-color-86 { background-color: #5fffd7; }
+.term-fg-color-86 { color: #5fffd7; }
+.term-bg-color-87 { background-color: #5fffff; }
+.term-fg-color-87 { color: #5fffff; }
+.term-bg-color-88 { background-color: #870000; }
+.term-fg-color-88 { color: #870000; }
+.term-bg-color-89 { background-color: #87005f; }
+.term-fg-color-89 { color: #87005f; }
+.term-bg-color-90 { background-color: #870087; }
+.term-fg-color-90 { color: #870087; }
+.term-bg-color-91 { background-color: #8700af; }
+.term-fg-color-91 { color: #8700af; }
+.term-bg-color-92 { background-color: #8700d7; }
+.term-fg-color-92 { color: #8700d7; }
+.term-bg-color-93 { background-color: #8700ff; }
+.term-fg-color-93 { color: #8700ff; }
+.term-bg-color-94 { background-color: #875f00; }
+.term-fg-color-94 { color: #875f00; }
+.term-bg-color-95 { background-color: #875f5f; }
+.term-fg-color-95 { color: #875f5f; }
+.term-bg-color-96 { background-color: #875f87; }
+.term-fg-color-96 { color: #875f87; }
+.term-bg-color-97 { background-color: #875faf; }
+.term-fg-color-97 { color: #875faf; }
+.term-bg-color-98 { background-color: #875fd7; }
+.term-fg-color-98 { color: #875fd7; }
+.term-bg-color-99 { background-color: #875fff; }
+.term-fg-color-99 { color: #875fff; }
+.term-bg-color-100 { background-color: #878700; }
+.term-fg-color-100 { color: #878700; }
+.term-bg-color-101 { background-color: #87875f; }
+.term-fg-color-101 { color: #87875f; }
+.term-bg-color-102 { background-color: #878787; }
+.term-fg-color-102 { color: #878787; }
+.term-bg-color-103 { background-color: #8787af; }
+.term-fg-color-103 { color: #8787af; }
+.term-bg-color-104 { background-color: #8787d7; }
+.term-fg-color-104 { color: #8787d7; }
+.term-bg-color-105 { background-color: #8787ff; }
+.term-fg-color-105 { color: #8787ff; }
+.term-bg-color-106 { background-color: #87af00; }
+.term-fg-color-106 { color: #87af00; }
+.term-bg-color-107 { background-color: #87af5f; }
+.term-fg-color-107 { color: #87af5f; }
+.term-bg-color-108 { background-color: #87af87; }
+.term-fg-color-108 { color: #87af87; }
+.term-bg-color-109 { background-color: #87afaf; }
+.term-fg-color-109 { color: #87afaf; }
+.term-bg-color-110 { background-color: #87afd7; }
+.term-fg-color-110 { color: #87afd7; }
+.term-bg-color-111 { background-color: #87afff; }
+.term-fg-color-111 { color: #87afff; }
+.term-bg-color-112 { background-color: #87d700; }
+.term-fg-color-112 { color: #87d700; }
+.term-bg-color-113 { background-color: #87d75f; }
+.term-fg-color-113 { color: #87d75f; }
+.term-bg-color-114 { background-color: #87d787; }
+.term-fg-color-114 { color: #87d787; }
+.term-bg-color-115 { background-color: #87d7af; }
+.term-fg-color-115 { color: #87d7af; }
+.term-bg-color-116 { background-color: #87d7d7; }
+.term-fg-color-116 { color: #87d7d7; }
+.term-bg-color-117 { background-color: #87d7ff; }
+.term-fg-color-117 { color: #87d7ff; }
+.term-bg-color-118 { background-color: #87ff00; }
+.term-fg-color-118 { color: #87ff00; }
+.term-bg-color-119 { background-color: #87ff5f; }
+.term-fg-color-119 { color: #87ff5f; }
+.term-bg-color-120 { background-color: #87ff87; }
+.term-fg-color-120 { color: #87ff87; }
+.term-bg-color-121 { background-color: #87ffaf; }
+.term-fg-color-121 { color: #87ffaf; }
+.term-bg-color-122 { background-color: #87ffd7; }
+.term-fg-color-122 { color: #87ffd7; }
+.term-bg-color-123 { background-color: #87ffff; }
+.term-fg-color-123 { color: #87ffff; }
+.term-bg-color-124 { background-color: #af0000; }
+.term-fg-color-124 { color: #af0000; }
+.term-bg-color-125 { background-color: #af005f; }
+.term-fg-color-125 { color: #af005f; }
+.term-bg-color-126 { background-color: #af0087; }
+.term-fg-color-126 { color: #af0087; }
+.term-bg-color-127 { background-color: #af00af; }
+.term-fg-color-127 { color: #af00af; }
+.term-bg-color-128 { background-color: #af00d7; }
+.term-fg-color-128 { color: #af00d7; }
+.term-bg-color-129 { background-color: #af00ff; }
+.term-fg-color-129 { color: #af00ff; }
+.term-bg-color-130 { background-color: #af5f00; }
+.term-fg-color-130 { color: #af5f00; }
+.term-bg-color-131 { background-color: #af5f5f; }
+.term-fg-color-131 { color: #af5f5f; }
+.term-bg-color-132 { background-color: #af5f87; }
+.term-fg-color-132 { color: #af5f87; }
+.term-bg-color-133 { background-color: #af5faf; }
+.term-fg-color-133 { color: #af5faf; }
+.term-bg-color-134 { background-color: #af5fd7; }
+.term-fg-color-134 { color: #af5fd7; }
+.term-bg-color-135 { background-color: #af5fff; }
+.term-fg-color-135 { color: #af5fff; }
+.term-bg-color-136 { background-color: #af8700; }
+.term-fg-color-136 { color: #af8700; }
+.term-bg-color-137 { background-color: #af875f; }
+.term-fg-color-137 { color: #af875f; }
+.term-bg-color-138 { background-color: #af8787; }
+.term-fg-color-138 { color: #af8787; }
+.term-bg-color-139 { background-color: #af87af; }
+.term-fg-color-139 { color: #af87af; }
+.term-bg-color-140 { background-color: #af87d7; }
+.term-fg-color-140 { color: #af87d7; }
+.term-bg-color-141 { background-color: #af87ff; }
+.term-fg-color-141 { color: #af87ff; }
+.term-bg-color-142 { background-color: #afaf00; }
+.term-fg-color-142 { color: #afaf00; }
+.term-bg-color-143 { background-color: #afaf5f; }
+.term-fg-color-143 { color: #afaf5f; }
+.term-bg-color-144 { background-color: #afaf87; }
+.term-fg-color-144 { color: #afaf87; }
+.term-bg-color-145 { background-color: #afafaf; }
+.term-fg-color-145 { color: #afafaf; }
+.term-bg-color-146 { background-color: #afafd7; }
+.term-fg-color-146 { color: #afafd7; }
+.term-bg-color-147 { background-color: #afafff; }
+.term-fg-color-147 { color: #afafff; }
+.term-bg-color-148 { background-color: #afd700; }
+.term-fg-color-148 { color: #afd700; }
+.term-bg-color-149 { background-color: #afd75f; }
+.term-fg-color-149 { color: #afd75f; }
+.term-bg-color-150 { background-color: #afd787; }
+.term-fg-color-150 { color: #afd787; }
+.term-bg-color-151 { background-color: #afd7af; }
+.term-fg-color-151 { color: #afd7af; }
+.term-bg-color-152 { background-color: #afd7d7; }
+.term-fg-color-152 { color: #afd7d7; }
+.term-bg-color-153 { background-color: #afd7ff; }
+.term-fg-color-153 { color: #afd7ff; }
+.term-bg-color-154 { background-color: #afff00; }
+.term-fg-color-154 { color: #afff00; }
+.term-bg-color-155 { background-color: #afff5f; }
+.term-fg-color-155 { color: #afff5f; }
+.term-bg-color-156 { background-color: #afff87; }
+.term-fg-color-156 { color: #afff87; }
+.term-bg-color-157 { background-color: #afffaf; }
+.term-fg-color-157 { color: #afffaf; }
+.term-bg-color-158 { background-color: #afffd7; }
+.term-fg-color-158 { color: #afffd7; }
+.term-bg-color-159 { background-color: #afffff; }
+.term-fg-color-159 { color: #afffff; }
+.term-bg-color-160 { background-color: #d70000; }
+.term-fg-color-160 { color: #d70000; }
+.term-bg-color-161 { background-color: #d7005f; }
+.term-fg-color-161 { color: #d7005f; }
+.term-bg-color-162 { background-color: #d70087; }
+.term-fg-color-162 { color: #d70087; }
+.term-bg-color-163 { background-color: #d700af; }
+.term-fg-color-163 { color: #d700af; }
+.term-bg-color-164 { background-color: #d700d7; }
+.term-fg-color-164 { color: #d700d7; }
+.term-bg-color-165 { background-color: #d700ff; }
+.term-fg-color-165 { color: #d700ff; }
+.term-bg-color-166 { background-color: #d75f00; }
+.term-fg-color-166 { color: #d75f00; }
+.term-bg-color-167 { background-color: #d75f5f; }
+.term-fg-color-167 { color: #d75f5f; }
+.term-bg-color-168 { background-color: #d75f87; }
+.term-fg-color-168 { color: #d75f87; }
+.term-bg-color-169 { background-color: #d75faf; }
+.term-fg-color-169 { color: #d75faf; }
+.term-bg-color-170 { background-color: #d75fd7; }
+.term-fg-color-170 { color: #d75fd7; }
+.term-bg-color-171 { background-color: #d75fff; }
+.term-fg-color-171 { color: #d75fff; }
+.term-bg-color-172 { background-color: #d78700; }
+.term-fg-color-172 { color: #d78700; }
+.term-bg-color-173 { background-color: #d7875f; }
+.term-fg-color-173 { color: #d7875f; }
+.term-bg-color-174 { background-color: #d78787; }
+.term-fg-color-174 { color: #d78787; }
+.term-bg-color-175 { background-color: #d787af; }
+.term-fg-color-175 { color: #d787af; }
+.term-bg-color-176 { background-color: #d787d7; }
+.term-fg-color-176 { color: #d787d7; }
+.term-bg-color-177 { background-color: #d787ff; }
+.term-fg-color-177 { color: #d787ff; }
+.term-bg-color-178 { background-color: #d7af00; }
+.term-fg-color-178 { color: #d7af00; }
+.term-bg-color-179 { background-color: #d7af5f; }
+.term-fg-color-179 { color: #d7af5f; }
+.term-bg-color-180 { background-color: #d7af87; }
+.term-fg-color-180 { color: #d7af87; }
+.term-bg-color-181 { background-color: #d7afaf; }
+.term-fg-color-181 { color: #d7afaf; }
+.term-bg-color-182 { background-color: #d7afd7; }
+.term-fg-color-182 { color: #d7afd7; }
+.term-bg-color-183 { background-color: #d7afff; }
+.term-fg-color-183 { color: #d7afff; }
+.term-bg-color-184 { background-color: #d7d700; }
+.term-fg-color-184 { color: #d7d700; }
+.term-bg-color-185 { background-color: #d7d75f; }
+.term-fg-color-185 { color: #d7d75f; }
+.term-bg-color-186 { background-color: #d7d787; }
+.term-fg-color-186 { color: #d7d787; }
+.term-bg-color-187 { background-color: #d7d7af; }
+.term-fg-color-187 { color: #d7d7af; }
+.term-bg-color-188 { background-color: #d7d7d7; }
+.term-fg-color-188 { color: #d7d7d7; }
+.term-bg-color-189 { background-color: #d7d7ff; }
+.term-fg-color-189 { color: #d7d7ff; }
+.term-bg-color-190 { background-color: #d7ff00; }
+.term-fg-color-190 { color: #d7ff00; }
+.term-bg-color-191 { background-color: #d7ff5f; }
+.term-fg-color-191 { color: #d7ff5f; }
+.term-bg-color-192 { background-color: #d7ff87; }
+.term-fg-color-192 { color: #d7ff87; }
+.term-bg-color-193 { background-color: #d7ffaf; }
+.term-fg-color-193 { color: #d7ffaf; }
+.term-bg-color-194 { background-color: #d7ffd7; }
+.term-fg-color-194 { color: #d7ffd7; }
+.term-bg-color-195 { background-color: #d7ffff; }
+.term-fg-color-195 { color: #d7ffff; }
+.term-bg-color-196 { background-color: #ff0000; }
+.term-fg-color-196 { color: #ff0000; }
+.term-bg-color-197 { background-color: #ff005f; }
+.term-fg-color-197 { color: #ff005f; }
+.term-bg-color-198 { background-color: #ff0087; }
+.term-fg-color-198 { color: #ff0087; }
+.term-bg-color-199 { background-color: #ff00af; }
+.term-fg-color-199 { color: #ff00af; }
+.term-bg-color-200 { background-color: #ff00d7; }
+.term-fg-color-200 { color: #ff00d7; }
+.term-bg-color-201 { background-color: #ff00ff; }
+.term-fg-color-201 { color: #ff00ff; }
+.term-bg-color-202 { background-color: #ff5f00; }
+.term-fg-color-202 { color: #ff5f00; }
+.term-bg-color-203 { background-color: #ff5f5f; }
+.term-fg-color-203 { color: #ff5f5f; }
+.term-bg-color-204 { background-color: #ff5f87; }
+.term-fg-color-204 { color: #ff5f87; }
+.term-bg-color-205 { background-color: #ff5faf; }
+.term-fg-color-205 { color: #ff5faf; }
+.term-bg-color-206 { background-color: #ff5fd7; }
+.term-fg-color-206 { color: #ff5fd7; }
+.term-bg-color-207 { background-color: #ff5fff; }
+.term-fg-color-207 { color: #ff5fff; }
+.term-bg-color-208 { background-color: #ff8700; }
+.term-fg-color-208 { color: #ff8700; }
+.term-bg-color-209 { background-color: #ff875f; }
+.term-fg-color-209 { color: #ff875f; }
+.term-bg-color-210 { background-color: #ff8787; }
+.term-fg-color-210 { color: #ff8787; }
+.term-bg-color-211 { background-color: #ff87af; }
+.term-fg-color-211 { color: #ff87af; }
+.term-bg-color-212 { background-color: #ff87d7; }
+.term-fg-color-212 { color: #ff87d7; }
+.term-bg-color-213 { background-color: #ff87ff; }
+.term-fg-color-213 { color: #ff87ff; }
+.term-bg-color-214 { background-color: #ffaf00; }
+.term-fg-color-214 { color: #ffaf00; }
+.term-bg-color-215 { background-color: #ffaf5f; }
+.term-fg-color-215 { color: #ffaf5f; }
+.term-bg-color-216 { background-color: #ffaf87; }
+.term-fg-color-216 { color: #ffaf87; }
+.term-bg-color-217 { background-color: #ffafaf; }
+.term-fg-color-217 { color: #ffafaf; }
+.term-bg-color-218 { background-color: #ffafd7; }
+.term-fg-color-218 { color: #ffafd7; }
+.term-bg-color-219 { background-color: #ffafff; }
+.term-fg-color-219 { color: #ffafff; }
+.term-bg-color-220 { background-color: #ffd700; }
+.term-fg-color-220 { color: #ffd700; }
+.term-bg-color-221 { background-color: #ffd75f; }
+.term-fg-color-221 { color: #ffd75f; }
+.term-bg-color-222 { background-color: #ffd787; }
+.term-fg-color-222 { color: #ffd787; }
+.term-bg-color-223 { background-color: #ffd7af; }
+.term-fg-color-223 { color: #ffd7af; }
+.term-bg-color-224 { background-color: #ffd7d7; }
+.term-fg-color-224 { color: #ffd7d7; }
+.term-bg-color-225 { background-color: #ffd7ff; }
+.term-fg-color-225 { color: #ffd7ff; }
+.term-bg-color-226 { background-color: #ffff00; }
+.term-fg-color-226 { color: #ffff00; }
+.term-bg-color-227 { background-color: #ffff5f; }
+.term-fg-color-227 { color: #ffff5f; }
+.term-bg-color-228 { background-color: #ffff87; }
+.term-fg-color-228 { color: #ffff87; }
+.term-bg-color-229 { background-color: #ffffaf; }
+.term-fg-color-229 { color: #ffffaf; }
+.term-bg-color-230 { background-color: #ffffd7; }
+.term-fg-color-230 { color: #ffffd7; }
+.term-bg-color-231 { background-color: #ffffff; }
+.term-fg-color-231 { color: #ffffff; }
+.term-bg-color-232 { background-color: #080808; }
+.term-fg-color-232 { color: #080808; }
+.term-bg-color-233 { background-color: #121212; }
+.term-fg-color-233 { color: #121212; }
+.term-bg-color-234 { background-color: #1c1c1c; }
+.term-fg-color-234 { color: #1c1c1c; }
+.term-bg-color-235 { background-color: #262626; }
+.term-fg-color-235 { color: #262626; }
+.term-bg-color-236 { background-color: #303030; }
+.term-fg-color-236 { color: #303030; }
+.term-bg-color-237 { background-color: #3a3a3a; }
+.term-fg-color-237 { color: #3a3a3a; }
+.term-bg-color-238 { background-color: #444444; }
+.term-fg-color-238 { color: #444444; }
+.term-bg-color-239 { background-color: #4e4e4e; }
+.term-fg-color-239 { color: #4e4e4e; }
+.term-bg-color-240 { background-color: #585858; }
+.term-fg-color-240 { color: #585858; }
+.term-bg-color-241 { background-color: #626262; }
+.term-fg-color-241 { color: #626262; }
+.term-bg-color-242 { background-color: #6c6c6c; }
+.term-fg-color-242 { color: #6c6c6c; }
+.term-bg-color-243 { background-color: #767676; }
+.term-fg-color-243 { color: #767676; }
+.term-bg-color-244 { background-color: #808080; }
+.term-fg-color-244 { color: #808080; }
+.term-bg-color-245 { background-color: #8a8a8a; }
+.term-fg-color-245 { color: #8a8a8a; }
+.term-bg-color-246 { background-color: #949494; }
+.term-fg-color-246 { color: #949494; }
+.term-bg-color-247 { background-color: #9e9e9e; }
+.term-fg-color-247 { color: #9e9e9e; }
+.term-bg-color-248 { background-color: #a8a8a8; }
+.term-fg-color-248 { color: #a8a8a8; }
+.term-bg-color-249 { background-color: #b2b2b2; }
+.term-fg-color-249 { color: #b2b2b2; }
+.term-bg-color-250 { background-color: #bcbcbc; }
+.term-fg-color-250 { color: #bcbcbc; }
+.term-bg-color-251 { background-color: #c6c6c6; }
+.term-fg-color-251 { color: #c6c6c6; }
+.term-bg-color-252 { background-color: #d0d0d0; }
+.term-fg-color-252 { color: #d0d0d0; }
+.term-bg-color-253 { background-color: #dadada; }
+.term-fg-color-253 { color: #dadada; }
+.term-bg-color-254 { background-color: #e4e4e4; }
+.term-fg-color-254 { color: #e4e4e4; }
+.term-bg-color-255 { background-color: #eeeeee; }
+.term-fg-color-255 { color: #eeeeee; }
+.term-bg-color-default { background-color: #000000; }
+.term-bg-color-256 { background-color: #000000; }
+.term-fg-color-256 { color: #000000; }
+.term-fg-color-default { color: #f0f0f0; }
+.term-bg-color-257 { background-color: #f0f0f0; }
+.term-fg-color-257 { color: #f0f0f0; }
+.term-bold { font-weight: bold; }
+.term-underline { text-decoration: underline; }
+.term-blink { text-decoration: blink; }
+.term-hidden { visibility: hidden; }
diff --git a/webpack.config.js b/webpack.config.js
index 53032c7..ae2d35c 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -41,6 +41,7 @@ var info = {
"terminal.jsx",
"manifest.json",
"timer.css",
+ "./pkg/lib/listing.less",
],
};
@@ -159,7 +160,7 @@ module.exports = {
test: /\.scss$/
},
{
- loader: extract.extract("css-loader"),
+ loader: extract.extract("css-loader?minimize=&root=" + libdir),
test: /\.css$/,
}
]
From 0852de42227f1392786257941a1b6d1ad543fa2d Mon Sep 17 00:00:00 2001
From: Kyrylo Gliebov
Date: Tue, 4 Sep 2018 12:49:31 +0200
Subject: [PATCH 007/208] Fix InputPlayer
---
src/player.jsx | 163 ++++++++++++++++++++++++++-------------------
src/recordings.css | 18 +++++
src/recordings.jsx | 6 +-
3 files changed, 115 insertions(+), 72 deletions(-)
diff --git a/src/player.jsx b/src/player.jsx
index b8a77f8..53c75c3 100644
--- a/src/player.jsx
+++ b/src/player.jsx
@@ -44,6 +44,13 @@
return value;
}
+ let scrollToBottom = function(id) {
+ const el = document.getElementById(id);
+ if (el) {
+ el.scrollTop = el.scrollHeight;
+ }
+ }
+
/*
* An auto-loading buffer of recording's packets.
*/
@@ -488,7 +495,14 @@
render() {
return(
-
+
);
}
@@ -520,6 +534,7 @@
this.zoom = this.zoom.bind(this);
this.fastForwardToTS = this.fastForwardToTS.bind(this);
this.sendInput = this.sendInput.bind(this);
+ this.clearInputPlayer = this.clearInputPlayer.bind(this);
this.state = {
cols: 80,
@@ -807,7 +822,12 @@
this.setState({speedExp: 0});
}
+ clearInputPlayer() {
+ this.setState({input: null});
+ }
+
rewindToStart() {
+ this.clearInputPlayer();
this.reset();
this.sync();
}
@@ -955,6 +975,9 @@
this.speed = Math.pow(2, this.state.speedExp);
this.sync();
}
+ if (this.state.input != prevState.input) {
+ scrollToBottom("input-textarea");
+ }
}
render() {
@@ -1017,75 +1040,79 @@
// ensure react never reuses this div by keying it with the terminal widget
return (
-
-
-
- {this.state.title}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /2
-
-
- 1:1
-
-
- x2
-
-
{speedStr}
-
-
-
-
-
-
-
-
-
-
-
- {error}
-
+
+
+
+
+
+ {this.state.title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /2
+
+
+ 1:1
+
+
+ x2
+
+
{speedStr}
+
+
+
+
+
+
+
+
+
+
+
+ {error}
+
+
+
);
}
diff --git a/src/recordings.css b/src/recordings.css
index f7d6965..80b7be5 100644
--- a/src/recordings.css
+++ b/src/recordings.css
@@ -377,8 +377,26 @@ table.listing-ct > thead th:last-child, tr.listing-ct-item td:last-child {
margin-bottom: 0;
}
+#recording-wrap {
+ height: 100%;
+}
+
+#input-textarea {
+ width: 100%;
+ heigth: 100%;
+ font-family: monospace;
+}
+
+#input-player-wrap {
+ height: 100%;
+}
+
.logs-view-log-time {
display: inline-block;
width: 150px;
vertical-align: middle;
}
+
+#input-player textarea{
+ resize: none;
+}
diff --git a/src/recordings.jsx b/src/recordings.jsx
index 25c7e79..748c6d8 100644
--- a/src/recordings.jsx
+++ b/src/recordings.jsx
@@ -495,7 +495,7 @@
-
+
{_("Recording")}
@@ -539,9 +539,7 @@
-
- {player}
-
+ {player}
);
From 0ce08a4420be1a71535d4ff2501b2117a89c47df Mon Sep 17 00:00:00 2001
From: Kyrylo Gliebov
Date: Fri, 7 Sep 2018 16:33:20 +0200
Subject: [PATCH 008/208] Fix Config path
---
src/recordings.jsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/recordings.jsx b/src/recordings.jsx
index 748c6d8..af74e9e 100644
--- a/src/recordings.jsx
+++ b/src/recordings.jsx
@@ -694,7 +694,7 @@
Configuration
-
+
From 4ca9b76b231fc7a6dbee143d6ae51785438ab2fe Mon Sep 17 00:00:00 2001
From: Kyrylo Gliebov
Date: Tue, 11 Sep 2018 13:49:49 +0200
Subject: [PATCH 009/208] Fix URL and CSS links
---
src/config.html | 3 ++-
src/index.html | 1 -
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/config.html b/src/config.html
index 8799839..b0c78c0 100644
--- a/src/config.html
+++ b/src/config.html
@@ -24,6 +24,7 @@ along with Cockpit; If not, see .
+
@@ -34,7 +35,7 @@ along with Cockpit; If not, see .
diff --git a/src/index.html b/src/index.html
index 1fa9cb3..e23d550 100644
--- a/src/index.html
+++ b/src/index.html
@@ -23,7 +23,6 @@ along with Cockpit; If not, see
.
Journal
-
From 1abe64fe0c8d4612f4599ac8af7bbd50220ead73 Mon Sep 17 00:00:00 2001
From: Kyrylo Gliebov
Date: Fri, 14 Sep 2018 18:34:33 +0200
Subject: [PATCH 010/208] Rebase and migration to full React instead of
react-lite
---
package.json | 21 +-
src/config.jsx | 137 +-
src/pkg/lib/cockpit-components-listing.jsx | 284 +--
src/player.jsx | 2051 ++++++++++----------
src/recordings.jsx | 1696 ++++++++--------
src/terminal.jsx | 330 ++--
webpack.config.js | 2 +
7 files changed, 2275 insertions(+), 2246 deletions(-)
diff --git a/package.json b/package.json
index 93b49da..cfb1fc9 100644
--- a/package.json
+++ b/package.json
@@ -42,27 +42,26 @@
"sass-loader": "^7.0.3",
"sizzle": "^2.3.3",
"stdio": "^0.2.7",
- "webpack": "^4.17.1",
+ "webpack": "^4.19.0",
"webpack-cli": "^3.1.0"
},
"dependencies": {
"@babel/polyfill": "^7.0.0",
- "node-sass": "^4.9.0",
- "react": "^16.4.2",
- "react-dom": "^16.4.2"
"bootstrap": "3.3.7",
- "patternfly": "3.35.1",
- "webpack": "^2.6.1",
- "jquery": "3.3.1",
- "moment": "2.22.2",
- "mustache": "2.3.0",
"bootstrap-datetime-picker": "2.4.4",
"comment-json": "^1.1.3",
- "term.js-cockpit": "0.0.10",
"fs.extra": "^1.3.2",
"fs.realpath": "^1.0.0",
"ini": "^1.3.5",
+ "jquery": "3.3.1",
+ "moment": "2.22.2",
+ "mustache": "2.3.0",
"node-sass": "^4.9.0",
- "raw-loader": "^0.5.1"
+ "patternfly": "3.35.1",
+ "prop-types": "^15.6.2",
+ "raw-loader": "^0.5.1",
+ "react": "^16.4.2",
+ "react-dom": "^16.4.2",
+ "term.js-cockpit": "0.0.10"
}
}
diff --git a/src/config.jsx b/src/config.jsx
index ae02053..6367663 100644
--- a/src/config.jsx
+++ b/src/config.jsx
@@ -22,10 +22,11 @@
let cockpit = require("cockpit");
let React = require("react");
+ let ReactDOM = require("react-dom");
let json = require('comment-json');
let ini = require('ini');
- let Config = class extends React.Component {
+ class Config extends React.Component {
constructor(props) {
super(props);
this.handleInputChange = this.handleInputChange.bind(this);
@@ -38,7 +39,7 @@
config: null,
file_error: null,
submitting: "none",
- }
+ };
}
handleInputChange(e) {
@@ -59,10 +60,8 @@
handleSubmit(event) {
this.setState({submitting:"block"});
- console.log(event);
this.prepareConfig();
this.file.replace(this.state.config).done(() => {
- console.log('updated');
this.setState({submitting:"none"});
})
.fail((error) => {
@@ -72,20 +71,16 @@
}
setConfig(data) {
- console.log(data);
this.setState({config: data});
}
fileReadFailed(reason) {
console.log(reason);
this.setState({file_error: reason});
- console.log('failed to read file');
}
componentDidMount() {
let parseFunc = function(data) {
- console.log(data);
- // return data;
return json.parse(data, null, true);
};
@@ -106,8 +101,6 @@
// host: string
});
- console.log(this.file);
-
let promise = this.file.read();
promise.done((data) => {
@@ -253,10 +246,9 @@
-
Save
-
+
@@ -274,9 +266,9 @@
);
}
}
- };
+ }
- let SssdConfig = class extends React.Component {
+ class SssdConfig extends React.Component {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
@@ -284,27 +276,24 @@
this.setConfig = this.setConfig.bind(this);
this.file = null;
this.state = {
- config: {
- session_recording: {
- scope: null,
- users: null,
- groups: null,
- },
- },
+ scope: "",
+ users: "",
+ groups: "",
+ submitting: "none",
};
}
- handleInputChange(e){
+ handleInputChange(e) {
const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
const name = e.target.name;
- const config = this.state.config;
- config.session_recording[name] = value;
-
- this.forceUpdate();
+ const state = {};
+ state[name] = value;
+ this.setState(state);
}
setConfig(data) {
- this.setState({config: data});
+ const config = {...data['session_recording']};
+ this.setState(config);
}
componentDidMount() {
@@ -327,14 +316,21 @@
});
}
- handleSubmit() {
- this.file.replace(this.state.config).done( function() {
- console.log('updated');
+ handleSubmit(e) {
+ this.setState({submitting:"block"});
+ const obj = {};
+ obj.users = this.state.users;
+ obj.groups = this.state.groups;
+ obj.scope = this.state.scope;
+ obj['session_recording'] = this.state;
+ let _this = this;
+ this.file.replace(obj).done(function() {
+ _this.setState({submitting:"none"});
})
- .fail( function(error) {
- console.log(error);
- });
- event.preventDefault();
+ .fail(function(error) {
+ console.log(error);
+ });
+ e.preventDefault();
}
render() {
@@ -342,49 +338,48 @@
);
}
- };
+ }
- React.render( , document.getElementById('sr_config'));
- React.render( , document.getElementById('sssd_config'));
+ ReactDOM.render( , document.getElementById('sr_config'));
+ ReactDOM.render( , document.getElementById('sssd_config'));
}());
diff --git a/src/pkg/lib/cockpit-components-listing.jsx b/src/pkg/lib/cockpit-components-listing.jsx
index fe69f07..15cc683 100644
--- a/src/pkg/lib/cockpit-components-listing.jsx
+++ b/src/pkg/lib/cockpit-components-listing.jsx
@@ -17,11 +17,9 @@
* along with Cockpit; If not, see .
*/
-"use strict";
-
-var React = require('react');
-
-require('./listing.less');
+import PropTypes from 'prop-types';
+import React from 'react';
+import './listing.less';
/* 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
@@ -47,55 +45,43 @@ require('./listing.less');
* initiallyExpanded optional: the entry will be initially rendered as expanded, but then behaves normally
* expandChanged optional: callback will be used if the row is either expanded or collapsed passing single `isExpanded` boolean argument
*/
-var ListingRow = React.createClass({
- propTypes: {
- rowId: React.PropTypes.string,
- columns: React.PropTypes.array.isRequired,
- tabRenderers: React.PropTypes.array,
- navigateToItem: React.PropTypes.func,
- listingDetail: React.PropTypes.node,
- listingActions: React.PropTypes.arrayOf(React.PropTypes.node),
- selectChanged: React.PropTypes.func,
- selected: React.PropTypes.bool,
- initiallyExpanded: React.PropTypes.bool,
- expandChanged: React.PropTypes.func,
- initiallyActiveTab: React.PropTypes.bool,
- },
- getDefaultProps: function () {
- return {
- tabRenderers: [],
- navigateToItem: null,
- };
- },
- getInitialState: function() {
- return {
+export class ListingRow extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
expanded: this.props.initiallyExpanded, // show expanded view if true, otherwise one line compact
activeTab: this.props.initiallyActiveTab ? this.props.initiallyActiveTab : 0, // currently active tab in expanded mode, defaults to first tab
loadedTabs: {}, // which tabs were already loaded - this is important for 'loadOnDemand' setting
// contains tab indices
selected: this.props.selected, // whether the current row is selected
};
- },
- handleNavigateClick: function(e) {
+ this.handleNavigateClick = this.handleNavigateClick.bind(this);
+ this.handleExpandClick = this.handleExpandClick.bind(this);
+ this.handleSelectClick = this.handleSelectClick.bind(this);
+ this.handleTabClick = this.handleTabClick.bind(this);
+ }
+
+ handleNavigateClick(e) {
// only consider primary mouse button
if (!e || e.button !== 0)
return;
this.props.navigateToItem();
- },
- handleExpandClick: function(e) {
+ }
+
+ handleExpandClick(e) {
// only consider primary mouse button
if (!e || e.button !== 0)
return;
- var willBeExpanded = !this.state.expanded && this.props.tabRenderers.length > 0;
+ let willBeExpanded = !this.state.expanded && this.props.tabRenderers.length > 0;
this.setState({ expanded: willBeExpanded });
- var loadedTabs = {};
+ let loadedTabs = {};
// unload all tabs if not expanded
if (willBeExpanded) {
// see if we should preload some tabs
- var tabIdx;
- var tabPresence;
+ let tabIdx;
+ let tabPresence;
for (tabIdx = 0; tabIdx < this.props.tabRenderers.length; tabIdx++) {
if ('presence' in this.props.tabRenderers[tabIdx])
tabPresence = this.props.tabRenderers[tabIdx].presence;
@@ -115,13 +101,14 @@ var ListingRow = React.createClass({
e.stopPropagation();
e.preventDefault();
- },
- handleSelectClick: function(e) {
+ }
+
+ handleSelectClick(e) {
// only consider primary mouse button
if (!e || e.button !== 0)
return;
- var selected = !this.state.selected;
+ let selected = !this.state.selected;
this.setState({ selected: selected });
if (this.props.selectChanged)
@@ -129,14 +116,15 @@ var ListingRow = React.createClass({
e.stopPropagation();
e.preventDefault();
- },
- handleTabClick: function(tabIdx, e) {
+ }
+
+ handleTabClick(tabIdx, e) {
// only consider primary mouse button
if (!e || e.button !== 0)
return;
- var prevTab = this.state.activeTab;
- var prevTabPresence = 'default';
- var loadedTabs = this.state.loadedTabs;
+ let prevTab = this.state.activeTab;
+ let prevTabPresence = 'default';
+ let loadedTabs = this.state.loadedTabs;
if (prevTab !== tabIdx) {
// see if we need to unload the previous tab
if ('presence' in this.props.tabRenderers[prevTab])
@@ -151,41 +139,42 @@ var ListingRow = React.createClass({
}
e.stopPropagation();
e.preventDefault();
- },
- render: function() {
- var self = this;
- // only enable navigation if a function is provided and the row isn't expanded (prevent accidental navigation)
- var allowNavigate = !!this.props.navigateToItem && !this.state.expanded;
+ }
- var headerEntries = this.props.columns.map(function(itm) {
+ render() {
+ let self = this;
+ // 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;
+
+ let headerEntries = this.props.columns.map((itm, index) => {
if (typeof itm === 'string' || typeof itm === 'number' || itm === null || itm === undefined || itm instanceof String || React.isValidElement(itm))
- return ({itm} );
+ return ({itm} );
else if ('header' in itm && itm.header)
- return ({itm.name} );
+ return ({itm.name} );
else if ('tight' in itm && itm.tight)
- return ({itm.name || itm.element} );
+ return ({itm.name || itm.element} );
else
- return ({itm.name} );
+ return ({itm.name} );
});
- var allowExpand = (this.props.tabRenderers.length > 0);
- var expandToggle;
+ let allowExpand = (this.props.tabRenderers.length > 0);
+ let expandToggle;
if (allowExpand) {
- expandToggle =
+ expandToggle =
;
} else {
- expandToggle = ;
+ expandToggle = ;
}
- var listingItemClasses = ["listing-ct-item"];
+ let listingItemClasses = ["listing-ct-item"];
if (!allowNavigate)
listingItemClasses.push("listing-ct-nonavigate");
if (!allowExpand)
listingItemClasses.push("listing-ct-noexpand");
- var allowSelect = !(allowNavigate || allowExpand) && (this.state.selected !== undefined);
- var clickHandler;
+ let allowSelect = !(allowNavigate || allowExpand) && (this.state.selected !== undefined);
+ let clickHandler;
if (allowSelect) {
clickHandler = this.handleSelectClick;
if (this.state.selected)
@@ -197,7 +186,7 @@ var ListingRow = React.createClass({
clickHandler = this.handleExpandClick;
}
- var listingItem = (
+ let listingItem = (
@@ -207,18 +196,18 @@ var ListingRow = React.createClass({
);
if (this.state.expanded) {
- var links = this.props.tabRenderers.map(function(itm, idx) {
+ let links = this.props.tabRenderers.map((itm, idx) => {
return (
{itm.name}
);
});
- var tabs = [];
- var tabIdx;
- var Renderer;
- var rendererData;
- var row;
+ let tabs = [];
+ let tabIdx;
+ let Renderer;
+ let rendererData;
+ let row;
for (tabIdx = 0; tabIdx < this.props.tabRenderers.length; tabIdx++) {
Renderer = this.props.tabRenderers[tabIdx].renderer;
rendererData = this.props.tabRenderers[tabIdx].data;
@@ -231,7 +220,7 @@ var ListingRow = React.createClass({
tabs.push({row}
);
}
- var listingDetail;
+ let listingDetail;
if ('listingDetail' in this.props) {
listingDetail = (
@@ -268,8 +257,26 @@ var ListingRow = React.createClass({
);
}
}
-});
+}
+ListingRow.defaultProps = {
+ tabRenderers: [],
+ navigateToItem: null,
+};
+
+ListingRow.propTypes = {
+ rowId: PropTypes.string,
+ columns: PropTypes.array.isRequired,
+ tabRenderers: PropTypes.array,
+ navigateToItem: PropTypes.func,
+ listingDetail: PropTypes.node,
+ listingActions: PropTypes.arrayOf(PropTypes.node),
+ selectChanged: PropTypes.func,
+ selected: PropTypes.bool,
+ initiallyExpanded: PropTypes.bool,
+ expandChanged: PropTypes.func,
+ initiallyActiveTab: PropTypes.bool
+};
/* Implements a PatternFly 'List View' pattern
* https://www.patternfly.org/list-view/
* Properties:
@@ -281,80 +288,77 @@ var ListingRow = React.createClass({
* receives the column index as argument
* - actions: additional listing-wide actions (displayed next to the list's title)
*/
-var Listing = React.createClass({
- propTypes: {
- title: React.PropTypes.string.isRequired,
- fullWidth: React.PropTypes.bool,
- emptyCaption: React.PropTypes.string.isRequired,
- columnTitles: React.PropTypes.arrayOf(React.PropTypes.string),
- columnTitleClick: React.PropTypes.func,
- actions: React.PropTypes.arrayOf(React.PropTypes.node)
- },
- getDefaultProps: function () {
- return {
- fullWidth: true,
- columnTitles: [],
- actions: []
- };
- },
- render: function() {
- var self = this;
- var bodyClasses = ["listing", "listing-ct"];
- if (this.props.fullWidth)
- bodyClasses.push("listing-ct-wide");
- var headerClasses;
- var headerRow;
- var selectableRows;
- if (!this.props.children || this.props.children.length === 0) {
- headerClasses = "listing-ct-empty";
- headerRow = {this.props.emptyCaption} ;
- } else if (this.props.columnTitles.length) {
- // check if any of the children are selectable
- selectableRows = false;
- this.props.children.forEach(function(r) {
- if (r.props.selected !== undefined)
- selectableRows = true;
+export const Listing = (props) => {
+ let bodyClasses = ["listing", "listing-ct"];
+ if (props.fullWidth)
+ bodyClasses.push("listing-ct-wide");
+ let headerClasses;
+ let headerRow;
+ let selectableRows;
+ if (!props.children || props.children.length === 0) {
+ headerClasses = "listing-ct-empty";
+ headerRow = {props.emptyCaption} ;
+ } else if (props.columnTitles.length) {
+ // check if any of the children are selectable
+ selectableRows = false;
+ props.children.forEach(function(r) {
+ if (r.props.selected !== undefined)
+ selectableRows = true;
+ });
+
+ if (selectableRows) {
+ // now make sure that if one is set, it's available on all items
+ props.children.forEach(function(r) {
+ if (r.props.selected === undefined)
+ r.props.selected = false;
});
-
- if (selectableRows) {
- // now make sure that if one is set, it's available on all items
- this.props.children.forEach(function(r) {
- if (r.props.selected === undefined)
- r.props.selected = false;
- });
- }
-
- headerRow = (
-
-
- { this.props.columnTitles.map(function (title, index) {
- var clickHandler = null;
- if (self.props.columnTitleClick)
- clickHandler = function() { self.props.columnTitleClick(index) };
- return {title} ;
- }) }
-
- );
- } else {
- headerRow =
}
- var caption;
- if (this.props.title || (this.props.actions && this.props.actions.length > 0))
- caption = {this.props.title}{this.props.actions} ;
- return (
-
- {caption}
-
- {headerRow}
-
- {this.props.children}
-
+ headerRow = (
+
+
+ { props.columnTitles.map((title, index) => {
+ let clickHandler = null;
+ if (props.columnTitleClick)
+ clickHandler = function() { props.columnTitleClick(index) };
+ return {title} ;
+ }) }
+
);
- },
-});
+ } else {
+ headerRow = ;
+ }
+ let caption;
+ if (props.title || (props.actions && props.actions.length > 0))
+ caption = {props.title}{props.actions} ;
-module.exports = {
- ListingRow: ListingRow,
- Listing: Listing,
+ return (
+
+ {caption}
+
+ {headerRow}
+
+ {props.children}
+
+ );
+};
+
+Listing.defaultProps = {
+ title: '',
+ fullWidth: true,
+ columnTitles: [],
+ actions: []
+};
+
+Listing.propTypes = {
+ title: PropTypes.string,
+ fullWidth: PropTypes.bool,
+ emptyCaption: PropTypes.string.isRequired,
+ columnTitles: PropTypes.arrayOf(
+ PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.element,
+ ])),
+ columnTitleClick: PropTypes.func,
+ actions: PropTypes.arrayOf(PropTypes.node)
};
diff --git a/src/player.jsx b/src/player.jsx
index 53c75c3..d04cb5e 100644
--- a/src/player.jsx
+++ b/src/player.jsx
@@ -1,1132 +1,1121 @@
/*
- * 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 .
+* 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 .
+*/
+"use strict";
+import React from 'react';
+let cockpit = require("cockpit");
+let _ = cockpit.gettext;
+let Term = require("term.js-cockpit");
+let Journal = require("journal");
+let $ = require("jquery");
+require("console.css");
+
+/*
+ * Get an object field, verifying its presence and type.
*/
-
-(function() {
- "use strict";
-
- let cockpit = require("cockpit");
- let _ = cockpit.gettext;
- let React = require("react");
- let Term = require("term.js-cockpit");
- let Journal = require("journal");
- let $ = require("jquery");
-
- require("console.css");
-
- /*
- * Get an object field, verifying its presence and type.
- */
- let getValidField = function (object, field, type) {
- let value;
- if (!(field in object)) {
- throw Error("\"" + field + "\" field is missing");
- }
- value = object[field];
- if (typeof (value) != typeof (type)) {
- throw Error("invalid \"" + field + "\" field type: " + typeof (value));
- }
- return value;
+let getValidField = function (object, field, type) {
+ let value;
+ if (!(field in object)) {
+ throw Error("\"" + field + "\" field is missing");
}
+ value = object[field];
+ if (typeof (value) != typeof (type)) {
+ throw Error("invalid \"" + field + "\" field type: " + typeof (value));
+ }
+ return value;
+};
- let scrollToBottom = function(id) {
- const el = document.getElementById(id);
- if (el) {
- el.scrollTop = el.scrollHeight;
- }
+let scrollToBottom = function(id) {
+ const el = document.getElementById(id);
+ if (el) {
+ el.scrollTop = el.scrollHeight;
+ }
+};
+
+/*
+ * An auto-loading buffer of recording's packets.
+ */
+let PacketBuffer = class {
+ /*
+ * Initialize a buffer.
+ */
+ constructor(matchList) {
+ this.handleError = this.handleError.bind(this);
+ this.handleStream = this.handleStream.bind(this);
+ this.handleDone = this.handleDone.bind(this);
+ /* RegExp used to parse message's timing field */
+ this.timingRE = new RegExp(
+ /* Delay (1) */
+ "\\+(\\d+)|" +
+ /* Text input (2) */
+ "<(\\d+)|" +
+ /* Binary input (3, 4) */
+ "\\[(\\d+)/(\\d+)|" +
+ /* Text output (5) */
+ ">(\\d+)|" +
+ /* Binary output (6, 7) */
+ "\\](\\d+)/(\\d+)|" +
+ /* Window (8, 9) */
+ "=(\\d+)x(\\d+)|" +
+ /* End of string */
+ "$",
+ /* Continue after the last match only */
+ /* FIXME Support likely sparse */
+ "y"
+ );
+ /* List of matches to apply when loading the buffer from Journal */
+ this.matchList = matchList;
+ /*
+ * An array of two-element arrays (tuples) each containing a
+ * packet index and a deferred object. The list is kept sorted to
+ * have tuples with lower packet indices first. Once the buffer
+ * receives a packet at the specified index, the matching tuple is
+ * removed from the list, and its deferred object is resolved.
+ * This is used to keep users informed about packets arriving.
+ */
+ this.idxDfdList = [];
+ /* Last seen message ID */
+ this.id = 0;
+ /* Last seen time position */
+ this.pos = 0;
+ /* Last seen window width */
+ this.width = null;
+ /* Last seen window height */
+ this.height = null;
+ /* List of packets read */
+ this.pktList = [];
+ /* Error which stopped the loading */
+ this.error = null;
+ /* The journalctl reading the recording */
+ this.journalctl = Journal.journalctl(
+ this.matchList,
+ {count: "all", follow: false});
+ this.journalctl.fail(this.handleError);
+ this.journalctl.stream(this.handleStream);
+ this.journalctl.done(this.handleDone);
+ /*
+ * Last seen cursor of the first, non-follow, journalctl run.
+ * Null if no entry was received yet, or the second run has
+ * skipped the entry received last by the first run.
+ */
+ this.cursor = null;
+ /* True if the first, non-follow, journalctl run has completed */
+ this.done = false;
}
/*
- * An auto-loading buffer of recording's packets.
+ * Return a promise which is resolved when a packet at a particular
+ * index is received by the buffer. The promise is rejected with a
+ * non-null argument if an error occurs or has occurred previously.
+ * The promise is rejected with null, when the buffer is stopped. If
+ * the packet index is not specified, assume it's the next packet.
*/
- let PacketBuffer = class {
- /*
- * Initialize a buffer.
- */
- constructor(matchList) {
- this.handleError = this.handleError.bind(this);
- this.handleStream = this.handleStream.bind(this);
- this.handleDone = this.handleDone.bind(this);
- /* RegExp used to parse message's timing field */
- this.timingRE = new RegExp(
- /* Delay (1) */
- "\\+(\\d+)|" +
- /* Text input (2) */
- "<(\\d+)|" +
- /* Binary input (3, 4) */
- "\\[(\\d+)/(\\d+)|" +
- /* Text output (5) */
- ">(\\d+)|" +
- /* Binary output (6, 7) */
- "\\](\\d+)/(\\d+)|" +
- /* Window (8, 9) */
- "=(\\d+)x(\\d+)|" +
- /* End of string */
- "$",
- /* Continue after the last match only */
- /* FIXME Support likely sparse */
- "y"
- );
- /* List of matches to apply when loading the buffer from Journal */
- this.matchList = matchList;
- /*
- * An array of two-element arrays (tuples) each containing a
- * packet index and a deferred object. The list is kept sorted to
- * have tuples with lower packet indices first. Once the buffer
- * receives a packet at the specified index, the matching tuple is
- * removed from the list, and its deferred object is resolved.
- * This is used to keep users informed about packets arriving.
- */
- this.idxDfdList = [];
- /* Last seen message ID */
- this.id = 0;
- /* Last seen time position */
- this.pos = 0;
- /* Last seen window width */
- this.width = null;
- /* Last seen window height */
- this.height = null;
- /* List of packets read */
- this.pktList = [];
- /* Error which stopped the loading */
- this.error = null;
- /* The journalctl reading the recording */
- this.journalctl = Journal.journalctl(
- this.matchList,
- {count: "all", follow: false});
- this.journalctl.fail(this.handleError);
- this.journalctl.stream(this.handleStream);
- this.journalctl.done(this.handleDone);
- /*
- * Last seen cursor of the first, non-follow, journalctl run.
- * Null if no entry was received yet, or the second run has
- * skipped the entry received last by the first run.
- */
- this.cursor = null;
- /* True if the first, non-follow, journalctl run has completed */
- this.done = false;
+ awaitPacket(idx) {
+ let i;
+ let idxDfd;
+
+ /* If an error has occurred previously */
+ if (this.error !== null) {
+ /* Reject immediately */
+ return $.Deferred().reject(this.error)
+ .promise();
}
- /*
- * Return a promise which is resolved when a packet at a particular
- * index is received by the buffer. The promise is rejected with a
- * non-null argument if an error occurs or has occurred previously.
- * The promise is rejected with null, when the buffer is stopped. If
- * the packet index is not specified, assume it's the next packet.
- */
- awaitPacket(idx) {
- let i;
- let idxDfd;
+ /* If the buffer was stopped */
+ if (this.journalctl === null) {
+ return $.Deferred().reject(null)
+ .promise();
+ }
- /* If an error has occurred previously */
- if (this.error !== null) {
- /* Reject immediately */
- return $.Deferred().reject(this.error)
+ /* If packet index is not specified */
+ if (idx === undefined) {
+ /* Assume it's the next one */
+ idx = this.pktList.length;
+ } else {
+ /* If it has already been received */
+ if (idx < this.pktList.length) {
+ /* Return resolved promise */
+ return $.Deferred().resolve()
.promise();
}
+ }
- /* If the buffer was stopped */
- if (this.journalctl === null) {
- return $.Deferred().reject(null)
- .promise();
+ /* Try to find an existing, matching tuple */
+ for (i = 0; i < this.idxDfdList.length; i++) {
+ idxDfd = this.idxDfdList[i];
+ if (idxDfd[0] == idx) {
+ return idxDfd[1].promise();
+ } else if (idxDfd[0] > idx) {
+ break;
}
+ }
- /* If packet index is not specified */
- if (idx === undefined) {
- /* Assume it's the next one */
- idx = this.pktList.length;
+ /* Not found, create and insert a new tuple */
+ idxDfd = [idx, $.Deferred()];
+ this.idxDfdList.splice(i, 0, idxDfd);
+
+ /* Return its promise */
+ return idxDfd[1].promise();
+ }
+
+ /*
+ * Return true if the buffer was done loading everything logged to
+ * journal so far and is now waiting for and loading new entries.
+ * Return false if the buffer is loading existing entries so far.
+ */
+ isDone() {
+ return this.done;
+ }
+
+ /*
+ * Stop receiving the entries
+ */
+ stop() {
+ if (this.journalctl === null) {
+ return;
+ }
+ /* Destroy journalctl */
+ this.journalctl.stop();
+ this.journalctl = null;
+ /* Notify everyone we stopped */
+ for (let i = 0; i < this.idxDfdList.length; i++) {
+ this.idxDfdList[i][1].reject(null);
+ }
+ this.idxDfdList = [];
+ }
+
+ /*
+ * Add a packet to the received packet list.
+ */
+ addPacket(pkt) {
+ /* TODO Validate the packet */
+ /* Add the packet */
+ this.pktList.push(pkt);
+ /* Notify any matching listeners */
+ while (this.idxDfdList.length > 0) {
+ let idxDfd = this.idxDfdList[0];
+ if (idxDfd[0] < this.pktList.length) {
+ this.idxDfdList.shift();
+ idxDfd[1].resolve();
} else {
- /* If it has already been received */
- if (idx < this.pktList.length) {
- /* Return resolved promise */
- return $.Deferred().resolve()
- .promise();
- }
+ break;
}
-
- /* Try to find an existing, matching tuple */
- for (i = 0; i < this.idxDfdList.length; i++) {
- idxDfd = this.idxDfdList[i];
- if (idxDfd[0] == idx) {
- return idxDfd[1].promise();
- } else if (idxDfd[0] > idx) {
- break;
- }
- }
-
- /* Not found, create and insert a new tuple */
- idxDfd = [idx, $.Deferred()];
- this.idxDfdList.splice(i, 0, idxDfd);
-
- /* Return its promise */
- return idxDfd[1].promise();
}
+ }
- /*
- * Return true if the buffer was done loading everything logged to
- * journal so far and is now waiting for and loading new entries.
- * Return false if the buffer is loading existing entries so far.
- */
- isDone() {
- return this.done;
- }
-
- /*
- * Stop receiving the entries
- */
- stop() {
- if (this.journalctl === null) {
- return;
- }
- /* Destroy journalctl */
+ /*
+ * Handle an error.
+ */
+ handleError(error) {
+ /* Remember the error */
+ this.error = error;
+ /* Destroy journalctl, don't try to recover */
+ if (this.journalctl !== null) {
this.journalctl.stop();
this.journalctl = null;
- /* Notify everyone we stopped */
- for (let i = 0; i < this.idxDfdList.length; i++) {
- this.idxDfdList[i][1].reject(null);
- }
- this.idxDfdList = [];
}
+ /* Notify everyone we had an error */
+ for (let i = 0; i < this.idxDfdList.length; i++) {
+ this.idxDfdList[i][1].reject(error);
+ }
+ this.idxDfdList = [];
+ }
- /*
- * Add a packet to the received packet list.
- */
- addPacket(pkt) {
- /* TODO Validate the packet */
- /* Add the packet */
- this.pktList.push(pkt)
- /* Notify any matching listeners */
- while (this.idxDfdList.length > 0) {
- let idxDfd = this.idxDfdList[0];
- if (idxDfd[0] < this.pktList.length) {
- this.idxDfdList.shift();
- idxDfd[1].resolve();
- } else {
+ /*
+ * Parse packets out of a tlog message data and add them to the buffer.
+ */
+ parseMessageData(timing, in_txt, out_txt) {
+ let matches;
+ let in_txt_pos = 0;
+ let out_txt_pos = 0;
+ let t;
+ let x;
+ let y;
+ let s;
+ let io = [];
+ let is_output;
+
+ /* While matching entries in timing */
+ this.timingRE.lastIndex = 0;
+ for (;;) {
+ /* Match next timing entry */
+ matches = this.timingRE.exec(timing);
+ if (matches === null) {
+ throw Error("invalid timing string");
+ } else if (matches[0] == "") {
+ break;
+ }
+
+ /* Switch on entry type character */
+ switch (t = matches[0][0]) {
+ /* Delay */
+ case "+":
+ x = parseInt(matches[1], 10);
+ if (x == 0) {
break;
}
- }
- }
-
- /*
- * Handle an error.
- */
- handleError(error) {
- /* Remember the error */
- this.error = error;
- /* Destroy journalctl, don't try to recover */
- if (this.journalctl !== null) {
- this.journalctl.stop();
- this.journalctl = null;
- }
- /* Notify everyone we had an error */
- for (let i = 0; i < this.idxDfdList.length; i++) {
- this.idxDfdList[i][1].reject(error);
- }
- this.idxDfdList = [];
- }
-
- /*
- * Parse packets out of a tlog message data and add them to the buffer.
- */
- parseMessageData(timing, in_txt, out_txt) {
- let matches;
- let in_txt_pos = 0;
- let out_txt_pos = 0;
- let t;
- let x;
- let y;
- let s;
- let io = [];
- let is_output;
-
- /* While matching entries in timing */
- this.timingRE.lastIndex = 0;
- for (;;) {
- /* Match next timing entry */
- matches = this.timingRE.exec(timing);
- if (matches === null) {
- throw Error("invalid timing string");
- } else if (matches[0] == "") {
- break;
- }
-
- /* Switch on entry type character */
- switch (t = matches[0][0]) {
- /* Delay */
- case "+":
- x = parseInt(matches[1], 10);
- if (x == 0) {
- break;
- }
- if (io.length > 0) {
- this.addPacket({pos: this.pos,
- is_io: true,
- is_output: is_output,
- io: io.join()});
- io = [];
- }
- this.pos += x;
- break;
- /* Text or binary input */
- case "<":
- case "[":
- x = parseInt(matches[(t == "<") ? 2 : 3], 10);
- if (x == 0) {
- break;
- }
- if (io.length > 0 && is_output) {
- this.addPacket({pos: this.pos,
- is_io: true,
- is_output: is_output,
- io: io.join()});
- io = [];
- }
- is_output = false;
- /* Add (replacement) input characters */
- s = in_txt.slice(in_txt_pos, in_txt_pos += x);
- if (s.length != x) {
- throw Error("timing entry out of input bounds");
- }
- io.push(s);
- break;
- /* Text or binary output */
- case ">":
- case "]":
- x = parseInt(matches[(t == ">") ? 5 : 6], 10);
- if (x == 0) {
- break;
- }
- if (io.length > 0 && !is_output) {
- this.addPacket({pos: this.pos,
- is_io: true,
- is_output: is_output,
- io: io.join()});
- io = [];
- }
- is_output = true;
- /* Add (replacement) output characters */
- s = out_txt.slice(out_txt_pos, out_txt_pos += x);
- if (s.length != x) {
- throw Error("timing entry out of output bounds");
- }
- io.push(s);
- break;
- /* Window */
- case "=":
- x = parseInt(matches[8], 10);
- y = parseInt(matches[9], 10);
- if (x == this.width && y == this.height) {
- break;
- }
- if (io.length > 0) {
- this.addPacket({pos: this.pos,
- is_io: true,
- is_output: is_output,
- io: io.join()});
- io = [];
- }
+ if (io.length > 0) {
this.addPacket({pos: this.pos,
- is_io: false,
- width: x,
- height: y});
- this.width = x;
- this.height = y;
+ is_io: true,
+ is_output: is_output,
+ io: io.join()});
+ io = [];
+ }
+ this.pos += x;
+ break;
+ /* Text or binary input */
+ case "<":
+ case "[":
+ x = parseInt(matches[(t == "<") ? 2 : 3], 10);
+ if (x == 0) {
break;
}
- }
-
- if (in_txt_pos < in_txt.length) {
- throw Error("extra input present");
- }
- if (out_txt_pos < out_txt.length) {
- throw Error("extra output present");
- }
-
- if (io.length > 0) {
+ if (io.length > 0 && is_output) {
+ this.addPacket({pos: this.pos,
+ is_io: true,
+ is_output: is_output,
+ io: io.join()});
+ io = [];
+ }
+ is_output = false;
+ /* Add (replacement) input characters */
+ s = in_txt.slice(in_txt_pos, in_txt_pos += x);
+ if (s.length != x) {
+ throw Error("timing entry out of input bounds");
+ }
+ io.push(s);
+ break;
+ /* Text or binary output */
+ case ">":
+ case "]":
+ x = parseInt(matches[(t == ">") ? 5 : 6], 10);
+ if (x == 0) {
+ break;
+ }
+ if (io.length > 0 && !is_output) {
+ this.addPacket({pos: this.pos,
+ is_io: true,
+ is_output: is_output,
+ io: io.join()});
+ io = [];
+ }
+ is_output = true;
+ /* Add (replacement) output characters */
+ s = out_txt.slice(out_txt_pos, out_txt_pos += x);
+ if (s.length != x) {
+ throw Error("timing entry out of output bounds");
+ }
+ io.push(s);
+ break;
+ /* Window */
+ case "=":
+ x = parseInt(matches[8], 10);
+ y = parseInt(matches[9], 10);
+ if (x == this.width && y == this.height) {
+ break;
+ }
+ if (io.length > 0) {
+ this.addPacket({pos: this.pos,
+ is_io: true,
+ is_output: is_output,
+ io: io.join()});
+ io = [];
+ }
this.addPacket({pos: this.pos,
- is_io: true,
- is_output: is_output,
- io: io.join()});
+ is_io: false,
+ width: x,
+ height: y});
+ this.width = x;
+ this.height = y;
+ break;
}
}
- /*
- * Parse packets out of a tlog message and add them to the buffer.
- */
- parseMessage(message) {
- let matches;
- let ver;
- let id;
- let pos;
-
- const number = Number();
- const string = String();
-
- /* Check version */
- ver = getValidField(message, "ver", string);
- matches = ver.match("^(\\d+)\\.(\\d+)$");
- if (matches === null || matches[1] > 2) {
- throw Error("\"ver\" field has invalid value: " + ver);
- }
-
- /* TODO Perhaps check host, rec, user, term, and session fields */
-
- /* Extract message ID */
- id = getValidField(message, "id", number);
- if (id <= this.id) {
- throw Error("out of order \"id\" field value: " + id);
- }
-
- /* Extract message time position */
- pos = getValidField(message, "pos", number);
- if (pos < this.message_pos) {
- throw Error("out of order \"pos\" field value: " + pos);
- }
-
- /* Update last received message ID and time position */
- this.id = id;
- this.pos = pos;
-
- /* Parse message data */
- this.parseMessageData(
- getValidField(message, "timing", string),
- getValidField(message, "in_txt", string),
- getValidField(message, "out_txt", string));
+ if (in_txt_pos < in_txt.length) {
+ throw Error("extra input present");
+ }
+ if (out_txt_pos < out_txt.length) {
+ throw Error("extra output present");
}
- /*
- * Handle journalctl "stream" event.
- */
- handleStream(entryList) {
- let i;
- let e;
- for (i = 0; i < entryList.length; i++) {
- e = entryList[i];
- /* If this is the second, "follow", run */
- if (this.done) {
- /* Skip the last entry we added on the first run */
- if (this.cursor !== null) {
- this.cursor = null;
- continue;
- }
- } else {
- if (!('__CURSOR' in e)) {
- this.handleError("No cursor in a Journal entry");
- }
- this.cursor = e['__CURSOR'];
+ if (io.length > 0) {
+ this.addPacket({pos: this.pos,
+ is_io: true,
+ is_output: is_output,
+ io: io.join()});
+ }
+ }
+
+ /*
+ * Parse packets out of a tlog message and add them to the buffer.
+ */
+ parseMessage(message) {
+ let matches;
+ let ver;
+ let id;
+ let pos;
+
+ const number = Number();
+ const string = String();
+
+ /* Check version */
+ ver = getValidField(message, "ver", string);
+ matches = ver.match("^(\\d+)\\.(\\d+)$");
+ if (matches === null || matches[1] > 2) {
+ throw Error("\"ver\" field has invalid value: " + ver);
+ }
+
+ /* TODO Perhaps check host, rec, user, term, and session fields */
+
+ /* Extract message ID */
+ id = getValidField(message, "id", number);
+ if (id <= this.id) {
+ throw Error("out of order \"id\" field value: " + id);
+ }
+
+ /* Extract message time position */
+ pos = getValidField(message, "pos", number);
+ if (pos < this.message_pos) {
+ throw Error("out of order \"pos\" field value: " + pos);
+ }
+
+ /* Update last received message ID and time position */
+ this.id = id;
+ this.pos = pos;
+
+ /* Parse message data */
+ this.parseMessageData(
+ getValidField(message, "timing", string),
+ getValidField(message, "in_txt", string),
+ getValidField(message, "out_txt", string));
+ }
+
+ /*
+ * Handle journalctl "stream" event.
+ */
+ handleStream(entryList) {
+ let i;
+ let e;
+ for (i = 0; i < entryList.length; i++) {
+ e = entryList[i];
+ /* If this is the second, "follow", run */
+ if (this.done) {
+ /* Skip the last entry we added on the first run */
+ if (this.cursor !== null) {
+ this.cursor = null;
+ continue;
}
- /* TODO Refer to entry number/cursor in errors */
- if (!('MESSAGE' in e)) {
- this.handleError("No message in Journal entry");
- }
- /* Parse the entry message */
- try {
- this.parseMessage(JSON.parse(e['MESSAGE']));
- } catch (error) {
- this.handleError(error);
- return;
+ } else {
+ if (!('__CURSOR' in e)) {
+ this.handleError("No cursor in a Journal entry");
}
+ this.cursor = e['__CURSOR'];
+ }
+ /* TODO Refer to entry number/cursor in errors */
+ if (!('MESSAGE' in e)) {
+ this.handleError("No message in Journal entry");
+ }
+ /* Parse the entry message */
+ try {
+ this.parseMessage(JSON.parse(e['MESSAGE']));
+ } catch (error) {
+ this.handleError(error);
+ return;
}
}
+ }
- /*
- * Handle journalctl "done" event.
- */
- handleDone() {
- this.done = true;
- this.journalctl.stop();
- /* Continue with the "following" run */
- this.journalctl = Journal.journalctl(
- this.matchList,
- {cursor: this.cursor,
- follow: true, count: "all"});
- this.journalctl.fail(this.handleError);
- this.journalctl.stream(this.handleStream);
- /* NOTE: no "done" handler on purpose */
+ /*
+ * Handle journalctl "done" event.
+ */
+ handleDone() {
+ this.done = true;
+ this.journalctl.stop();
+ /* Continue with the "following" run */
+ this.journalctl = Journal.journalctl(
+ this.matchList,
+ {cursor: this.cursor,
+ follow: true, count: "all"});
+ this.journalctl.fail(this.handleError);
+ this.journalctl.stream(this.handleStream);
+ /* NOTE: no "done" handler on purpose */
+ }
+};
+
+let ProgressBar = class extends React.Component {
+ constructor(props) {
+ super(props);
+ this.jumpTo = this.jumpTo.bind(this);
+ }
+
+ jumpTo(e) {
+ if (this.props.fastForwardFunc) {
+ let percent = parseInt((e.nativeEvent.offsetX * 100) / e.currentTarget.clientWidth);
+ let ts = parseInt((this.props.length * percent) / 100);
+ this.props.fastForwardFunc(ts);
}
- };
+ }
- let ProgressBar = class extends React.Component {
- constructor(props) {
- super(props);
- this.jumpTo = this.jumpTo.bind(this);
- }
+ render() {
+ let progress = {
+ "width": parseInt((this.props.mark * 100) / this.props.length) + "%"
+ };
- jumpTo(e) {
- if (this.props.fastForwardFunc) {
- let percent = parseInt((e.offsetX * 100) / e.currentTarget.clientWidth);
- let ts = parseInt((this.props.length * percent) / 100);
- this.props.fastForwardFunc(ts);
- }
- }
+ return (
+
+ );
+ }
+};
- render() {
- let progress = {
- "width": parseInt((this.props.mark * 100) / this.props.length) + "%"
- };
-
- return (
-
-
+let InputPlayer = class extends React.Component {
+ render() {
+ return (
+
+
+ Input
- );
- }
- };
-
- let InputPlayer = class extends React.Component {
- constructor(props) {
- super(props);
- }
-
- render() {
- return(
-
+ );
+ }
+};
+
+export class Player extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.handleTimeout = this.handleTimeout.bind(this);
+ this.handlePacket = this.handlePacket.bind(this);
+ this.handleError = this.handleError.bind(this);
+ this.handleTitleChange = this.handleTitleChange.bind(this);
+ this.rewindToStart = this.rewindToStart.bind(this);
+ this.playPauseToggle = this.playPauseToggle.bind(this);
+ this.speedUp = this.speedUp.bind(this);
+ this.speedDown = this.speedDown.bind(this);
+ this.speedReset = this.speedReset.bind(this);
+ this.fastForwardToEnd = this.fastForwardToEnd.bind(this);
+ this.skipFrame = this.skipFrame.bind(this);
+ this.handleKeyDown = this.handleKeyDown.bind(this);
+ this.sync = this.sync.bind(this);
+ this.zoomIn = this.zoomIn.bind(this);
+ this.zoomOut = this.zoomOut.bind(this);
+ this.fitTo = this.fitTo.bind(this);
+ this.dragPan = this.dragPan.bind(this);
+ this.dragPanEnable = this.dragPanEnable.bind(this);
+ this.dragPanDisable = this.dragPanDisable.bind(this);
+ this.zoom = this.zoom.bind(this);
+ this.fastForwardToTS = this.fastForwardToTS.bind(this);
+ this.sendInput = this.sendInput.bind(this);
+ this.clearInputPlayer = this.clearInputPlayer.bind(this);
+
+ this.state = {
+ cols: 80,
+ rows: 25,
+ title: _("Player"),
+ term: null,
+ paused: true,
+ /* Speed exponent */
+ speedExp: 0,
+ container_width: 630,
+ scale_initial: 1,
+ scale_lock: false,
+ term_top_style: "50%",
+ term_left_style: "50%",
+ term_translate: "-50%, -50%",
+ term_scroll: "hidden",
+ term_zoom_max: false,
+ term_zoom_min: false,
+ drag_pan: false,
+ containerWidth: 630,
+ currentTsPost: 0,
+ scale: 1,
+ error: null,
+ input: "",
+ mark: 0,
+ };
+
+ this.containerHeight = 290;
+
+ /* Auto-loading buffer of recording's packets */
+ this.buf = new PacketBuffer(this.props.matchList);
+
+ /* Current recording time, ms */
+ this.recTS = 0;
+ /* Corresponding local time, ms */
+ this.locTS = 0;
+
+ /* Index of the current packet */
+ this.pktIdx = 0;
+ /* Current packet, or null if not retrieved */
+ this.pkt = null;
+ /* Timeout ID of the current packet, null if none */
+ this.timeout = null;
+
+ /* True if the next packet should be output without delay */
+ this.skip = false;
+ /* Playback speed */
+ this.speed = 1;
+ /*
+ * Timestamp playback should fast-forward to.
+ * Recording time, ms, or null if not fast-forwarding.
+ */
+ this.fastForwardTo = null;
+ }
+
+ reset() {
+ /* Clear any pending timeouts */
+ this.clearTimeout();
+
+ /* Reset the terminal */
+ this.state.term.reset();
+
+ /* Move to beginning of buffer */
+ this.pktIdx = 0;
+ /* No packet loaded */
+ this.pkt = null;
+
+ /* We are not skipping */
+ this.skip = false;
+ /* We are not fast-forwarding */
+ this.fastForwardTo = null;
+
+ /* Move to beginning of recording */
+ this.recTS = 0;
+ this.setState({currentTsPost: parseInt(this.recTS)});
+ /* Start the playback time */
+ this.locTS = performance.now();
+
+ /* Wait for the first packet */
+ this.awaitPacket(0);
+ }
+
+ componentWillMount() {
+ let term = new Term({
+ cols: this.state.cols,
+ rows: this.state.rows,
+ screenKeys: true,
+ useStyle: true
+ });
+
+ term.on('title', this.handleTitleChange);
+
+ this.setState({ term: term });
+
+ window.addEventListener("keydown", this.handleKeyDown, false);
+ }
+
+ componentDidMount() {
+ if (this.refs.wrapper.offsetWidth) {
+ this.setState({containerWidth: this.refs.wrapper.offsetWidth});
}
+ /* Open the terminal */
+ this.state.term.open(this.refs.term);
+ window.setInterval(this.sync, 100);
+ /* Reset playback */
+ this.reset();
+ }
- };
+ /* Subscribe for a packet at specified index */
+ awaitPacket(idx) {
+ this.buf.awaitPacket(idx).done(this.handlePacket)
+ .fail(this.handleError);
+ }
- let Player = class extends React.Component {
- constructor(props) {
- super(props);
+ /* Set next packet timeout, ms */
+ setTimeout(ms) {
+ this.timeout = window.setTimeout(this.handleTimeout, ms);
+ }
- this.handleTimeout = this.handleTimeout.bind(this);
- this.handlePacket = this.handlePacket.bind(this);
- this.handleError = this.handleError.bind(this);
- this.handleTitleChange = this.handleTitleChange.bind(this);
- this.rewindToStart = this.rewindToStart.bind(this);
- this.playPauseToggle = this.playPauseToggle.bind(this);
- this.speedUp = this.speedUp.bind(this);
- this.speedDown = this.speedDown.bind(this);
- this.speedReset = this.speedReset.bind(this);
- this.fastForwardToEnd = this.fastForwardToEnd.bind(this);
- this.skipFrame = this.skipFrame.bind(this);
- this.handleKeyDown = this.handleKeyDown.bind(this);
- this.sync = this.sync.bind(this);
- this.zoomIn = this.zoomIn.bind(this);
- this.zoomOut = this.zoomOut.bind(this);
- this.fitTo = this.fitTo.bind(this);
- this.dragPan = this.dragPan.bind(this);
- this.dragPanEnable = this.dragPanEnable.bind(this);
- this.dragPanDisable = this.dragPanDisable.bind(this);
- this.zoom = this.zoom.bind(this);
- this.fastForwardToTS = this.fastForwardToTS.bind(this);
- this.sendInput = this.sendInput.bind(this);
- this.clearInputPlayer = this.clearInputPlayer.bind(this);
-
- this.state = {
- cols: 80,
- rows: 25,
- title: _("Player"),
- term: null,
- paused: true,
- /* Speed exponent */
- speedExp: 0,
- container_width: 630,
- scale_initial: 1,
- scale_lock: false,
- term_top_style: "50%",
- term_left_style: "50%",
- term_translate: "-50%, -50%",
- term_scroll: "hidden",
- term_zoom_max: false,
- term_zoom_min: false,
- drag_pan: false,
- containerWidth: 630,
- currentTsPost: 0,
- scale: 1,
- error: null,
- input: ""
- };
-
- this.containerHeight = 290;
-
- /* Auto-loading buffer of recording's packets */
- this.buf = new PacketBuffer(this.props.matchList);
-
- /* Current recording time, ms */
- this.recTS = 0;
- /* Corresponding local time, ms */
- this.locTS = 0;
-
- /* Index of the current packet */
- this.pktIdx = 0;
- /* Current packet, or null if not retrieved */
- this.pkt = null;
- /* Timeout ID of the current packet, null if none */
+ /* Clear next packet timeout */
+ clearTimeout() {
+ if (this.timeout !== null) {
+ window.clearTimeout(this.timeout);
this.timeout = null;
-
- /* True if the next packet should be output without delay */
- this.skip = false;
- /* Playback speed */
- this.speed = 1;
- /*
- * Timestamp playback should fast-forward to.
- * Recording time, ms, or null if not fast-forwarding.
- */
- this.fastForwardTo = null;
}
+ }
- reset() {
- /* Clear any pending timeouts */
- this.clearTimeout();
-
- /* Reset the terminal */
- this.state.term.reset();
-
- /* Move to beginning of buffer */
- this.pktIdx = 0;
- /* No packet loaded */
- this.pkt = null;
-
- /* We are not skipping */
- this.skip = false;
- /* We are not fast-forwarding */
- this.fastForwardTo = null;
-
- /* Move to beginning of recording */
- this.recTS = 0;
- /* Start the playback time */
- this.locTS = performance.now();
-
- /* Wait for the first packet */
- this.awaitPacket(0);
+ /* Handle packet retrieval error */
+ handleError(error) {
+ if (error !== null) {
+ this.setState({error: error});
+ console.warn(error);
}
+ }
- componentWillMount() {
- let term = new Term({
- cols: this.state.cols,
- rows: this.state.rows,
- screenKeys: true,
- useStyle: true
- });
+ /* Handle packet retrieval success */
+ handlePacket() {
+ this.sync();
+ }
- term.on('title', this.handleTitleChange);
+ /* Handle arrival of packet output time */
+ handleTimeout() {
+ this.timeout = null;
+ this.sync();
+ }
- this.setState({ term: term });
+ /* Handle terminal title change */
+ handleTitleChange(title) {
+ this.setState({ title: _("Player") + ": " + title });
+ }
- window.addEventListener("keydown", this.handleKeyDown, false);
+ _transform(width, height) {
+ var relation = Math.min(
+ this.state.containerWidth / this.state.term.element.offsetWidth,
+ this.containerHeight / this.state.term.element.offsetHeight
+ );
+ this.setState({
+ term_top_style: "50%",
+ term_left_style: "50%",
+ term_translate: "-50%, -50%",
+ scale: relation,
+ scale_initial: relation,
+ cols: width,
+ rows: height
+ });
+ }
+
+ sendInput(pkt) {
+ if (pkt) {
+ const current_input = this.state.input;
+ this.setState({input: current_input + pkt.io});
}
+ }
- componentDidMount() {
- if (this.refs.wrapper.offsetWidth) {
- this.setState({containerWidth: this.refs.wrapper.offsetWidth});
- }
- /* Open the terminal */
- this.state.term.open(this.refs.term);
- window.setInterval(this.sync, 100);
- /* Reset playback */
- this.reset();
- }
+ /* Synchronize playback */
+ sync() {
+ let locDelay;
- /* Subscribe for a packet at specified index */
- awaitPacket(idx) {
- this.buf.awaitPacket(idx).done(this.handlePacket)
- .fail(this.handleError);
- }
+ /* We are already called, don't call us with timeout */
+ this.clearTimeout();
- /* Set next packet timeout, ms */
- setTimeout(ms) {
- this.timeout = window.setTimeout(this.handleTimeout, ms);
- }
-
- /* Clear next packet timeout */
- clearTimeout() {
- if (this.timeout !== null) {
- window.clearTimeout(this.timeout);
- this.timeout = null;
- }
- }
-
- /* Handle packet retrieval error */
- handleError(error) {
- if (error !== null) {
- this.setState({error: error});
- console.warn(error);
- }
- }
-
- /* Handle packet retrieval success */
- handlePacket() {
- this.sync();
- }
-
- /* Handle arrival of packet output time */
- handleTimeout() {
- this.timeout = null;
- this.sync();
- }
-
- /* Handle terminal title change */
- handleTitleChange(title) {
- this.setState({ title: _("Player") + ": " + title });
- }
-
- _transform(width, height) {
- var relation = Math.min(
- this.state.containerWidth / this.state.term.element.offsetWidth,
- this.containerHeight / this.state.term.element.offsetHeight
- );
- this.setState({
- term_top_style: "50%",
- term_left_style: "50%",
- term_translate: "-50%, -50%",
- scale: relation,
- scale_initial: relation,
- cols: width,
- rows: height
- });
- }
-
- sendInput(pkt) {
- if (pkt) {
- const current_input = this.state.input;
- this.setState({input: current_input + pkt.io});
- }
- }
-
- /* Synchronize playback */
- sync() {
- let locDelay;
-
- /* We are already called, don't call us with timeout */
- this.clearTimeout();
-
- /* Forever */
- for (;;) {
- /* Get another packet to output, if none */
- for (; this.pkt === null; this.pktIdx++) {
- let pkt = this.buf.pktList[this.pktIdx];
- /* If there are no more packets */
- if (pkt === undefined) {
- /*
- * If we're done loading existing packets and we were
- * fast-forwarding.
- */
- if (this.fastForwardTo != null && this.buf.isDone()) {
- /* Stop fast-forwarding */
- this.fastForwardTo = null;
- }
- /* Call us when we get one */
- this.awaitPacket();
- return;
- }
-
- this.pkt = pkt;
- }
-
- /* Get the current local time */
- let nowLocTS = performance.now();
-
- /* Ignore the passed time, if we're paused */
- if (this.state.paused) {
- locDelay = 0;
- } else {
- locDelay = nowLocTS - this.locTS;
- }
-
- /* Sync to the local time */
- this.locTS = nowLocTS;
-
- /* If we are skipping one packet's delay */
- if (this.skip) {
- this.skip = false;
- this.recTS = this.pkt.pos;
- /* Else, if we are fast-forwarding */
- } else if (this.fastForwardTo !== null) {
- /* If we haven't reached fast-forward destination */
- if (this.pkt.pos < this.fastForwardTo) {
- this.recTS = this.pkt.pos;
- } else {
- this.recTS = this.fastForwardTo;
+ /* Forever */
+ for (;;) {
+ /* Get another packet to output, if none */
+ for (; this.pkt === null; this.pktIdx++) {
+ let pkt = this.buf.pktList[this.pktIdx];
+ /* If there are no more packets */
+ if (pkt === undefined) {
+ /*
+ * If we're done loading existing packets and we were
+ * fast-forwarding.
+ */
+ if (this.fastForwardTo != null && this.buf.isDone()) {
+ /* Stop fast-forwarding */
this.fastForwardTo = null;
- continue;
}
- /* Else, if we are paused */
- } else if (this.state.paused) {
+ /* Call us when we get one */
+ this.awaitPacket();
return;
- } else {
- this.recTS += locDelay * this.speed;
- let pktRecDelay = this.pkt.pos - this.recTS;
- let pktLocDelay = pktRecDelay / this.speed;
- this.setState({currentTsPost: parseInt(this.recTS)});
- /* If we're more than 5 ms early for this packet */
- if (pktLocDelay > 5) {
- /* Call us again on time, later */
- this.setTimeout(pktLocDelay);
- return;
- }
}
- /* Send packet ts to the top */
- this.props.onTsChange(this.pkt.pos);
+ this.pkt = pkt;
+ }
- /* Output the packet */
- if (this.pkt.is_io && !this.pkt.is_output) {
- this.sendInput(this.pkt);
- } else if (this.pkt.is_io) {
- this.state.term.write(this.pkt.io);
+ /* Get the current local time */
+ let nowLocTS = performance.now();
+
+ /* Ignore the passed time, if we're paused */
+ if (this.state.paused) {
+ locDelay = 0;
+ } else {
+ locDelay = nowLocTS - this.locTS;
+ }
+
+ /* Sync to the local time */
+ this.locTS = nowLocTS;
+
+ /* If we are skipping one packet's delay */
+ if (this.skip) {
+ this.skip = false;
+ this.recTS = this.pkt.pos;
+ /* Else, if we are fast-forwarding */
+ } else if (this.fastForwardTo !== null) {
+ /* If we haven't reached fast-forward destination */
+ if (this.pkt.pos < this.fastForwardTo) {
+ this.recTS = this.pkt.pos;
} else {
- this.state.term.resize(this.pkt.width, this.pkt.height);
- if (!this.state.scale_lock) {
- this._transform(this.pkt.width, this.pkt.height);
- }
+ this.recTS = this.fastForwardTo;
+ this.fastForwardTo = null;
+ continue;
+ }
+ /* Else, if we are paused */
+ } else if (this.state.paused) {
+ return;
+ } else {
+ this.recTS += locDelay * this.speed;
+ let pktRecDelay = this.pkt.pos - this.recTS;
+ let pktLocDelay = pktRecDelay / this.speed;
+ this.setState({currentTsPost: parseInt(this.recTS)});
+ /* If we're more than 5 ms early for this packet */
+ if (pktLocDelay > 5) {
+ /* Call us again on time, later */
+ this.setTimeout(pktLocDelay);
+ return;
}
-
- /* We no longer have a packet */
- this.pkt = null;
}
- }
- playPauseToggle() {
- this.setState({paused: !this.state.paused});
- }
+ /* Send packet ts to the top */
+ this.props.onTsChange(this.pkt.pos);
+ this.setState({currentTsPost: parseInt(this.pkt.pos)});
- speedUp() {
- let speedExp = this.state.speedExp;
- if (speedExp < 4) {
- this.setState({speedExp: speedExp + 1});
+ /* Output the packet */
+ if (this.pkt.is_io && !this.pkt.is_output) {
+ this.sendInput(this.pkt);
+ } else if (this.pkt.is_io) {
+ this.state.term.write(this.pkt.io);
+ } else {
+ this.state.term.resize(this.pkt.width, this.pkt.height);
+ if (!this.state.scale_lock) {
+ this._transform(this.pkt.width, this.pkt.height);
+ }
}
- }
- speedDown() {
- let speedExp = this.state.speedExp;
- if (speedExp > -4) {
- this.setState({speedExp: speedExp - 1});
- }
+ /* We no longer have a packet */
+ this.pkt = null;
}
+ }
- speedReset() {
- this.setState({speedExp: 0});
+ playPauseToggle() {
+ this.setState({paused: !this.state.paused});
+ }
+
+ speedUp() {
+ let speedExp = this.state.speedExp;
+ if (speedExp < 4) {
+ this.setState({speedExp: speedExp + 1});
}
+ }
- clearInputPlayer() {
- this.setState({input: null});
+ speedDown() {
+ let speedExp = this.state.speedExp;
+ if (speedExp > -4) {
+ this.setState({speedExp: speedExp - 1});
}
+ }
- rewindToStart() {
- this.clearInputPlayer();
+ speedReset() {
+ this.setState({speedExp: 0});
+ }
+
+ clearInputPlayer() {
+ this.setState({input: ""});
+ }
+
+ rewindToStart() {
+ this.clearInputPlayer();
+ this.reset();
+ this.sync();
+ }
+
+ fastForwardToEnd() {
+ this.fastForwardTo = Infinity;
+ this.sync();
+ }
+
+ fastForwardToTS(ts) {
+ if (ts < this.recTS) {
this.reset();
- this.sync();
}
+ this.fastForwardTo = ts;
+ this.sync();
+ }
- fastForwardToEnd() {
- this.fastForwardTo = Infinity;
- this.sync();
+ skipFrame() {
+ this.skip = true;
+ this.sync();
+ }
+
+ handleKeyDown(event) {
+ let keyCodesFuncs = {
+ "p": this.playPauseToggle,
+ "}": this.speedUp,
+ "{": this.speedDown,
+ "Backspace": this.speedReset,
+ ".": this.skipFrame,
+ "G": this.fastForwardToEnd,
+ "R": this.rewindToStart,
+ "+": this.zoomIn,
+ "=": this.zoomIn,
+ "-": this.zoomOut,
+ "Z": this.fitIn,
+ };
+ if (keyCodesFuncs[event.key]) {
+ (keyCodesFuncs[event.key](event));
}
+ }
- fastForwardToTS(ts) {
- if (ts < this.recTS) {
- this.reset();
- }
- this.fastForwardTo = ts;
- this.sync();
- }
-
- skipFrame() {
- this.skip = true;
- this.sync();
- }
-
- handleKeyDown(event) {
- let keyCodesFuncs = {
- "p": this.playPauseToggle,
- "}": this.speedUp,
- "{": this.speedDown,
- "Backspace": this.speedReset,
- ".": this.skipFrame,
- "G": this.fastForwardToEnd,
- "R": this.rewindToStart,
- "+": this.zoomIn,
- "=": this.zoomIn,
- "-": this.zoomOut,
- "Z": this.fitIn,
- };
- if (keyCodesFuncs[event.key]) {
- (keyCodesFuncs[event.key](event));
- }
- }
-
- zoom(scale) {
- if (scale.toFixed(6) === this.state.scale_initial.toFixed(6)) {
- this.fitTo();
- } else {
- this.setState({
- term_top_style: "0",
- term_left_style: "0",
- term_translate: "0, 0",
- scale_lock: true,
- term_scroll: "auto",
- scale: scale,
- term_zoom_max: false,
- term_zoom_min: false,
- });
- }
- }
-
- dragPan() {
- (this.state.drag_pan ? this.dragPanDisable() : this.dragPanEnable());
- }
-
- dragPanEnable() {
- this.setState({drag_pan: true});
-
- let scrollwrap = this.refs.scrollwrap;
-
- let clicked = false;
- let clickX;
- let clickY;
-
- $(this.refs.scrollwrap).on({
- 'mousemove': function(e) {
- clicked && updateScrollPos(e);
- },
- 'mousedown': function(e) {
- clicked = true;
- clickY = e.pageY;
- clickX = e.pageX;
- },
- 'mouseup': function() {
- clicked = false;
- $('html').css('cursor', 'auto');
- }
- });
-
- let updateScrollPos = function(e) {
- $('html').css('cursor', 'move');
- $(scrollwrap).scrollTop($(scrollwrap).scrollTop() + (clickY - e.pageY));
- $(scrollwrap).scrollLeft($(scrollwrap).scrollLeft() + (clickX - e.pageX));
- };
- }
-
- dragPanDisable() {
- this.setState({drag_pan: false});
- let scrollwrap = this.refs.scrollwrap;
- $(scrollwrap).off("mousemove");
- $(scrollwrap).off("mousedown");
- $(scrollwrap).off("mouseup");
- }
-
- zoomIn() {
- let scale = this.state.scale;
- if (scale < 2.1) {
- scale = scale + 0.1;
- this.zoom(scale);
- } else {
- this.setState({term_zoom_max: true});
- }
- }
-
- zoomOut() {
- let scale = this.state.scale;
- if (scale >= 0.2) {
- scale = scale - 0.1;
- this.zoom(scale);
- } else {
- this.setState({term_zoom_min: true});
- }
- }
-
- fitTo() {
+ zoom(scale) {
+ if (scale.toFixed(6) === this.state.scale_initial.toFixed(6)) {
+ this.fitTo();
+ } else {
this.setState({
- term_top_style: "50%",
- term_left_style: "50%",
- term_translate: "-50%, -50%",
- scale_lock: false,
- term_scroll: "hidden",
+ term_top_style: "0",
+ term_left_style: "0",
+ term_translate: "0, 0",
+ scale_lock: true,
+ term_scroll: "auto",
+ scale: scale,
+ term_zoom_max: false,
+ term_zoom_min: false,
});
- this._transform();
+ }
+ }
+
+ dragPan() {
+ (this.state.drag_pan ? this.dragPanDisable() : this.dragPanEnable());
+ }
+
+ dragPanEnable() {
+ this.setState({drag_pan: true});
+
+ let scrollwrap = this.refs.scrollwrap;
+
+ let clicked = false;
+ let clickX;
+ let clickY;
+
+ $(this.refs.scrollwrap).on({
+ 'mousemove': function(e) {
+ clicked && updateScrollPos(e);
+ },
+ 'mousedown': function(e) {
+ clicked = true;
+ clickY = e.pageY;
+ clickX = e.pageX;
+ },
+ 'mouseup': function() {
+ clicked = false;
+ $('html').css('cursor', 'auto');
+ }
+ });
+
+ let updateScrollPos = function(e) {
+ $('html').css('cursor', 'move');
+ $(scrollwrap).scrollTop($(scrollwrap).scrollTop() + (clickY - e.pageY));
+ $(scrollwrap).scrollLeft($(scrollwrap).scrollLeft() + (clickX - e.pageX));
+ };
+ }
+
+ dragPanDisable() {
+ this.setState({drag_pan: false});
+ let scrollwrap = this.refs.scrollwrap;
+ $(scrollwrap).off("mousemove");
+ $(scrollwrap).off("mousedown");
+ $(scrollwrap).off("mouseup");
+ }
+
+ zoomIn() {
+ let scale = this.state.scale;
+ if (scale < 2.1) {
+ scale = scale + 0.1;
+ this.zoom(scale);
+ } else {
+ this.setState({term_zoom_max: true});
+ }
+ }
+
+ zoomOut() {
+ let scale = this.state.scale;
+ if (scale >= 0.2) {
+ scale = scale - 0.1;
+ this.zoom(scale);
+ } else {
+ this.setState({term_zoom_min: true});
+ }
+ }
+
+ fitTo() {
+ this.setState({
+ term_top_style: "50%",
+ term_left_style: "50%",
+ term_translate: "-50%, -50%",
+ scale_lock: false,
+ term_scroll: "hidden",
+ });
+ this._transform();
+ }
+
+ componentWillUpdate(nextProps, nextState) {
+ /* If we changed pause state or speed exponent */
+ if (nextState.paused != this.state.paused ||
+ nextState.speedExp != this.state.speedExp) {
+ this.sync();
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ /* If we changed pause state or speed exponent */
+ if (this.state.paused != prevState.paused ||
+ this.state.speedExp != prevState.speedExp) {
+ this.speed = Math.pow(2, this.state.speedExp);
+ this.sync();
+ }
+ if (this.state.input != prevState.input) {
+ scrollToBottom("input-textarea");
+ }
+ if (prevProps.logsTs != this.props.logsTs) {
+ this.fastForwardToTS(this.props.logsTs);
+ }
+ }
+
+ render() {
+ let speedExp = this.state.speedExp;
+ let speedFactor = Math.pow(2, Math.abs(speedExp));
+ let speedStr;
+
+ if (speedExp > 0) {
+ speedStr = "x" + speedFactor;
+ } else if (speedExp < 0) {
+ speedStr = "/" + speedFactor;
+ } else {
+ speedStr = "";
}
- componentWillUpdate(nextProps, nextState) {
- /* If we changed pause state or speed exponent */
- if (nextState.paused != this.state.paused ||
- nextState.speedExp != this.state.speedExp) {
- this.sync();
+ const style = {
+ "transform": "scale(" + this.state.scale + ") translate(" + this.state.term_translate + ")",
+ "transformOrigin": "top left",
+ "display": "inline-block",
+ "margin": "0 auto",
+ "position": "absolute",
+ "top": this.state.term_top_style,
+ "left": this.state.term_left_style,
+ };
+
+ const scrollwrap = {
+ "minWidth": "630px",
+ "height": this.containerHeight + "px",
+ "backgroundColor": "#f5f5f5",
+ "overflow": this.state.term_scroll,
+ "position": "relative",
+ };
+
+ const to_right = {
+ "float": "right",
+ };
+
+ const progressbar_style = {
+ 'marginTop': '10px',
+ };
+
+ const currentTsPost = function(currentTS, bufLength) {
+ if (currentTS > bufLength) {
+ return bufLength;
}
+ return currentTS;
+ };
+
+ let error = "";
+ if (this.state.error) {
+ error = (
+
+
+
+
+
+ {this.state.error}.
+
);
}
- componentDidUpdate(prevProps, prevState) {
- /* If we changed pause state or speed exponent */
- if (this.state.paused != prevState.paused ||
- this.state.speedExp != prevState.speedExp) {
- this.speed = Math.pow(2, this.state.speedExp);
- this.sync();
- }
- if (this.state.input != prevState.input) {
- scrollToBottom("input-textarea");
- }
- }
-
- render() {
- let speedExp = this.state.speedExp;
- let speedFactor = Math.pow(2, Math.abs(speedExp));
- let speedStr;
-
- if (speedExp > 0) {
- speedStr = "x" + speedFactor;
- } else if (speedExp < 0) {
- speedStr = "/" + speedFactor;
- } else {
- speedStr = "";
- }
-
- const style = {
- "transform": "scale(" + this.state.scale + ") translate(" + this.state.term_translate + ")",
- "transform-origin": "top left",
- "display": "inline-block",
- "margin": "0 auto",
- "position": "absolute",
- "top": this.state.term_top_style,
- "left": this.state.term_left_style,
- };
-
- const scrollwrap = {
- "min-width": "630px",
- "height": this.containerHeight + "px",
- "background-color": "#f5f5f5",
- "overflow": this.state.term_scroll,
- "position": "relative",
- };
-
- const to_right = {
- "float": "right",
- };
-
- const progressbar_style = {
- 'margin-top': '10px',
- };
-
- const currentTsPost = function(currentTS, bufLength) {
- if (currentTS > bufLength) {
- return bufLength;
- }
- return currentTS;
- };
-
- let error = "";
- if (this.state.error) {
- error = (
-
-
-
-
-
- {this.state.error}.
-
);
- }
-
- // ensure react never reuses this div by keying it with the terminal widget
- return (
-
+ // ensure react never reuses this div by keying it with the terminal widget
+ return (
+
-
-
- {this.state.title}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- /2
-
-
- 1:1
-
-
- x2
-
-
{speedStr}
-
-
-
-
-
-
-
-
-
-
-
- {error}
+
+
+ {this.state.title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /2
+
+
+ 1:1
+
+
+ x2
+
+
{speedStr}
+
+
+
+
+
+
+
+
+
+
+
+ {error}
- );
- }
+
+ );
+ }
- componentWillUnmount() {
- this.buf.stop();
- window.removeEventListener("keydown", this.handleKeyDown, false);
- this.state.term.destroy();
- }
- };
-
- Player.propTypes = {
- matchList: React.PropTypes.array,
- // onTitleChanged: React.PropTypes.func
- };
-
- module.exports = { Player: Player };
-}());
+ componentWillUnmount() {
+ this.buf.stop();
+ window.removeEventListener("keydown", this.handleKeyDown, false);
+ this.state.term.destroy();
+ }
+}
diff --git a/src/recordings.jsx b/src/recordings.jsx
index af74e9e..1e93758 100644
--- a/src/recordings.jsx
+++ b/src/recordings.jsx
@@ -16,492 +16,521 @@
* You should have received a copy of the GNU Lesser General Public License
* along with Cockpit; If not, see
.
*/
+"use strict";
-(function() {
- "use strict";
+import React from "react";
+import ReactDOM from "react-dom";
- let $ = require("jquery");
- let cockpit = require("cockpit");
- let _ = cockpit.gettext;
- let moment = require("moment");
- let Journal = require("journal");
- let React = require("react");
- let Listing = require("cockpit-components-listing.jsx");
- let Player = require("./player.jsx");
+let $ = require("jquery");
+let cockpit = require("cockpit");
+let _ = cockpit.gettext;
+let moment = require("moment");
+let Journal = require("journal");
+let Listing = require("cockpit-components-listing.jsx");
+let Player = require("./player.jsx");
- require("bootstrap-datetime-picker/js/bootstrap-datetimepicker.js");
- require("bootstrap-datetime-picker/css/bootstrap-datetimepicker.css");
+require("bootstrap-datetime-picker/js/bootstrap-datetimepicker.js");
+require("bootstrap-datetime-picker/css/bootstrap-datetimepicker.css");
- /*
- * Convert a number to integer number string and pad with zeroes to
- * specified width.
- */
- let padInt = function (n, w) {
- let i = Math.floor(n);
- let a = Math.abs(i);
- let s = a.toString();
- for (w -= s.length; w > 0; w--) {
- s = '0' + s;
- }
- return ((i < 0) ? '-' : '') + s;
+/*
+ * Convert a number to integer number string and pad with zeroes to
+ * specified width.
+ */
+let padInt = function (n, w) {
+ let i = Math.floor(n);
+ let a = Math.abs(i);
+ let s = a.toString();
+ for (w -= s.length; w > 0; w--) {
+ s = '0' + s;
+ }
+ return ((i < 0) ? '-' : '') + s;
+};
+
+/*
+ * Format date and time for a number of milliseconds since Epoch.
+ */
+let formatDateTime = function (ms) {
+ let d = new Date(ms);
+ return (
+ padInt(d.getFullYear(), 4) + '-' +
+ padInt(d.getMonth() + 1, 2) + '-' +
+ padInt(d.getDate(), 2) + ' ' +
+ padInt(d.getHours(), 2) + ':' +
+ padInt(d.getMinutes(), 2) + ':' +
+ padInt(d.getSeconds(), 2)
+ );
+};
+
+/*
+ * Format a time interval from a number of milliseconds.
+ */
+let formatDuration = function (ms) {
+ let v = Math.floor(ms / 1000);
+ let s = Math.floor(v % 60);
+ v = Math.floor(v / 60);
+ let m = Math.floor(v % 60);
+ v = Math.floor(v / 60);
+ let h = Math.floor(v % 24);
+ let d = Math.floor(v / 24);
+ let str = '';
+
+ if (d > 0) {
+ str += d + ' ' + _("days") + ' ';
}
- /*
- * Format date and time for a number of milliseconds since Epoch.
- */
- let formatDateTime = function (ms) {
- let d = new Date(ms);
- return (
- padInt(d.getFullYear(), 4) + '-' +
- padInt(d.getMonth() + 1, 2) + '-' +
- padInt(d.getDate(), 2) + ' ' +
- padInt(d.getHours(), 2) + ':' +
- padInt(d.getMinutes(), 2) + ':' +
- padInt(d.getSeconds(), 2)
- );
- };
-
- /*
- * Format a time interval from a number of milliseconds.
- */
- let formatDuration = function (ms) {
- let v = Math.floor(ms / 1000);
- let s = Math.floor(v % 60);
- v = Math.floor(v / 60);
- let m = Math.floor(v % 60);
- v = Math.floor(v / 60);
- let h = Math.floor(v % 24);
- let d = Math.floor(v / 24);
- let str = '';
-
- if (d > 0) {
- str += d + ' ' + _("days") + ' ';
- }
-
- if (h > 0 || str.length > 0) {
- str += padInt(h, 2) + ':';
- }
-
- str += padInt(m, 2) + ':' + padInt(s, 2);
-
- return (ms < 0 ? '-' : '') + str;
- };
-
- let parseDate = function(date) {
- let regex = new RegExp(/^\s*(\d\d\d\d-\d\d-\d\d)(\s+(\d\d:\d\d(:\d\d)?))?\s*$/);
-
- let captures = regex.exec(date);
-
- if (captures != null) {
- let date = captures[1];
- if (captures[3]) {
- date = date + " " + captures[3];
- }
- if (moment(date, ["YYYY-M-D H:m:s", "YYYY-M-D H:m", "YYYY-M-D"], true).isValid()) {
- return date;
- }
- }
-
- if (date === "" || date === null) {
- return true;
- }
-
- return false;
+ if (h > 0 || str.length > 0) {
+ str += padInt(h, 2) + ':';
}
- /*
- * A component representing a date & time picker based on bootstrap-datetime-picker.
- * Requires jQuery, bootstrap-datetime-picker, moment.js
- * Properties:
- * - onDateChange: function to call on date change event of datepicker.
- * - date: variable to pass which will be used as initial value.
- */
- let Datetimepicker = class extends React.Component {
- constructor(props) {
- super(props);
- this.handleDateChange = this.handleDateChange.bind(this);
- this.clearField = this.clearField.bind(this);
- this.markDateField = this.markDateField.bind(this);
- this.state = {
- invalid: false,
- date: this.props.date,
- dateLastValid: null,
- };
+ str += padInt(m, 2) + ':' + padInt(s, 2);
+
+ return (ms < 0 ? '-' : '') + str;
+};
+
+let parseDate = function(date) {
+ let regex = new RegExp(/^\s*(\d\d\d\d-\d\d-\d\d)(\s+(\d\d:\d\d(:\d\d)?))?\s*$/);
+
+ let captures = regex.exec(date);
+
+ if (captures != null) {
+ let date = captures[1];
+ if (captures[3]) {
+ date = date + " " + captures[3];
}
-
- componentDidMount() {
- let funcDate = this.handleDateChange;
- let datepicker = $(this.refs.datepicker).datetimepicker({
- format: 'yyyy-mm-dd hh:ii:00',
- autoclose: true,
- todayBtn: true,
- });
- datepicker.on('changeDate', function(e) {
- funcDate(e);
- });
- $(this.refs.datepicker_input).datetimepicker('remove');
- this.markDateField();
+ if (moment(date, ["YYYY-M-D H:m:s", "YYYY-M-D H:m", "YYYY-M-D"], true).isValid()) {
+ return date;
}
+ }
- componentWillUnmount() {
- $(this.textInput).datetimepicker('remove');
- }
+ if (date === "" || date === null) {
+ return true;
+ }
- handleDateChange(e) {
- if (e.type === "changeDate") {
- let event = new Event('input', { bubbles: true });
- e.currentTarget.firstChild.dispatchEvent(event);
- }
+ return false;
+};
- if (e.type === "input") {
- this.setState({date: e.target.value});
- if (parseDate(e.target.value)) {
- this.setState({dateLastValid: e.target.value});
- this.setState({invalid: false});
- this.props.onDateChange(e.target.value, e.target.value.trim());
- } else {
- this.setState({invalid: true});
- this.props.onDateChange(e.target.value, this.state.dateLastValid.trim());
- }
- }
- }
+/*
+ * A component representing a date & time picker based on bootstrap-datetime-picker.
+ * Requires jQuery, bootstrap-datetime-picker, moment.js
+ * Properties:
+ * - onDateChange: function to call on date change event of datepicker.
+ * - date: variable to pass which will be used as initial value.
+ */
+class Datetimepicker extends React.Component {
+ constructor(props) {
+ super(props);
+ this.handleDateChange = this.handleDateChange.bind(this);
+ this.clearField = this.clearField.bind(this);
+ this.markDateField = this.markDateField.bind(this);
+ this.state = {
+ invalid: false,
+ date: this.props.date,
+ dateLastValid: null,
+ };
+ }
- clearField() {
- $(this.refs.datepicker_input).val("");
+ componentDidMount() {
+ let funcDate = this.handleDateChange;
+ let datepicker = $(this.refs.datepicker).datetimepicker({
+ format: 'yyyy-mm-dd hh:ii:00',
+ autoclose: true,
+ todayBtn: true,
+ });
+ datepicker.on('changeDate', function(e) {
+ funcDate(e);
+ });
+ $(this.refs.datepicker_input).datetimepicker('remove');
+ this.markDateField();
+ }
+
+ componentWillUnmount() {
+ $(this.textInput).datetimepicker('remove');
+ }
+
+ handleDateChange(e) {
+ if (e.type === "changeDate") {
let event = new Event('input', { bubbles: true });
- this.refs.datepicker_input.dispatchEvent(event);
- this.handleDateChange(event);
+ e.currentTarget.firstChild.dispatchEvent(event);
+ }
+
+ if (e.type === "input") {
+ this.setState({date: e.target.value});
+ if (parseDate(e.target.value)) {
+ this.setState({dateLastValid: e.target.value});
+ this.setState({invalid: false});
+ this.props.onDateChange(e.target.value, e.target.value.trim());
+ } else {
+ this.setState({invalid: true});
+ this.props.onDateChange(e.target.value, this.state.dateLastValid.trim());
+ }
+ }
+ }
+
+ clearField() {
+ $(this.refs.datepicker_input).val("");
+ let event = new Event('input', { bubbles: true });
+ this.refs.datepicker_input.dispatchEvent(event);
+ this.handleDateChange(event);
+ this.setState({invalid: false});
+ }
+
+ markDateField() {
+ let date = $(this.refs.datepicker_input).val()
+ .trim();
+ if (!parseDate(date)) {
+ this.setState({invalid: true});
+ } else {
+ this.setState({dateLastValid: date});
this.setState({invalid: false});
}
-
- markDateField() {
- let date = $(this.refs.datepicker_input).val()
- .trim();
- if (!parseDate(date)) {
- this.setState({invalid: true});
- } else {
- this.setState({dateLastValid: date});
- this.setState({invalid: false});
- }
- }
-
- render() {
- return (
-
-
-
-
-
-
- );
- }
}
- /*
- * A component representing a username input text field.
- * TODO make as a select / drop-down with list of exisiting users.
- */
- let UserPicker = class extends React.Component {
- constructor(props) {
- super(props);
- this.handleUsernameChange = this.handleUsernameChange.bind(this);
- }
-
- handleUsernameChange(e) {
- this.props.onUsernameChange(e.target.value);
- }
-
- render() {
- return (
-
-
-
- );
- }
- }
-
- let HostnamePicker = class extends React.Component {
- constructor(props) {
- super(props);
- this.handleHostnameChange = this.handleHostnameChange.bind(this);
- }
-
- handleHostnameChange(e) {
- this.props.onHostnameChange(e.target.value);
- }
-
- render() {
- return (
-
-
-
- );
- }
- }
-
- function LogElement(props) {
- const entry = props.entry;
- const start = props.start;
- const end = props.end;
- const entry_timestamp = entry.__REALTIME_TIMESTAMP / 1000;
- let className = 'cockpit-logline';
- if (start < entry_timestamp && end > entry_timestamp) {
- className = 'cockpit-logline highlighted';
- }
+ render() {
return (
-
-
-
-
-
{formatDateTime(parseInt(entry.__REALTIME_TIMESTAMP / 1000))}
-
{entry.MESSAGE}
+
+
+
+
+
);
}
+}
- function LogsView(props) {
- const entries = props.entries;
- const start = props.start;
- const end = props.end;
- const rows = entries.map((entry) =>
-
- );
+/*
+ * A component representing a username input text field.
+ * TODO make as a select / drop-down with list of exisiting users.
+*/
+class UserPicker extends React.Component {
+ constructor(props) {
+ super(props);
+ this.handleUsernameChange = this.handleUsernameChange.bind(this);
+ this.state = {
+ username: cockpit.location.options.username || "",
+ };
+ }
+
+ handleUsernameChange(e) {
+ const value = e.target.value;
+ this.setState({username: value});
+ this.props.onUsernameChange(value);
+ }
+
+ render() {
return (
-
- {rows}
+
+
);
}
+}
- let Logs = class extends React.Component {
- constructor(props) {
- super(props);
- this.journalctlError = this.journalctlError.bind(this);
- this.journalctlIngest = this.journalctlIngest.bind(this);
- this.journalctlPrepend = this.journalctlPrepend.bind(this);
- this.getLogs = this.getLogs.bind(this);
- this.loadLater = this.loadLater.bind(this);
- this.loadEarlier = this.loadEarlier.bind(this);
- this.loadForTs = this.loadForTs.bind(this);
- this.journalCtl = null;
- this.entries = [];
- this.start = null;
- this.end = null;
- this.earlier_than = null;
- this.load_earlier = false;
- this.state = {
- cursor: null,
- after: null,
- entries: [],
- };
+let HostnamePicker = class extends React.Component {
+ constructor(props) {
+ super(props);
+ this.handleHostnameChange = this.handleHostnameChange.bind(this);
+ this.state = {
+ hostname: cockpit.location.options.hostname || "",
+ };
+ }
+
+ handleHostnameChange(e) {
+ const value = e.target.value;
+ this.setState({hostname: value});
+ this.props.onHostnameChange(value);
+ }
+
+ render() {
+ return (
+
+
+
+ );
+ }
+};
+
+function LogElement(props) {
+ const entry = props.entry;
+ const start = props.start;
+ const end = props.end;
+ const cursor = entry.__CURSOR;
+ const entry_timestamp = parseInt(entry.__REALTIME_TIMESTAMP / 1000);
+
+ const timeClick = function(e) {
+ const ts = entry_timestamp - start;
+ if (ts > 0) {
+ props.jumpToTs(ts);
+ } else {
+ props.jumpToTs(0);
}
+ };
+ const messageClick = () => {
+ const url = '/system/logs#/' + cursor + '?parent_options={}';
+ const win = window.open(url, '_blank');
+ win.focus();
+ };
- scrollToTop() {
- const logs_view = document.getElementById("logs-view");
- logs_view.scrollTop = 0;
- }
+ let className = 'cockpit-logline';
+ if (start < entry_timestamp && end > entry_timestamp) {
+ className = 'cockpit-logline highlighted';
+ }
- scrollToBottom() {
- const logs_view = document.getElementById("logs-view");
- logs_view.scrollTop = logs_view.scrollHeight;
- }
+ return (
+
+
+
+
+
{formatDateTime(entry_timestamp)}
+
{entry.MESSAGE}
+
+ );
+}
- journalctlError(error) {
- console.warn(cockpit.message(error));
- }
+function LogsView(props) {
+ const entries = props.entries;
+ const start = props.start;
+ const end = props.end;
+ const rows = entries.map((entry) =>
+
+ );
+ return (
+
+ {rows}
+
+ );
+}
- journalctlIngest(entryList) {
- if (this.load_earlier === true) {
- entryList.push(...this.entries);
- this.entries = entryList;
- this.setState({entries: this.entries});
- this.load_earlier = false;
- this.scrollToTop();
- } else {
- if (entryList.length > 0) {
- this.entries.push(...entryList);
- const after = this.entries[this.entries.length - 1].__CURSOR;
- this.setState({entries: this.entries, after: after});
- this.scrollToBottom();
- }
- }
- }
+class Logs extends React.Component {
+ constructor(props) {
+ super(props);
+ this.journalctlError = this.journalctlError.bind(this);
+ this.journalctlIngest = this.journalctlIngest.bind(this);
+ this.journalctlPrepend = this.journalctlPrepend.bind(this);
+ this.getLogs = this.getLogs.bind(this);
+ this.loadLater = this.loadLater.bind(this);
+ this.loadEarlier = this.loadEarlier.bind(this);
+ this.loadForTs = this.loadForTs.bind(this);
+ this.journalCtl = null;
+ this.entries = [];
+ this.start = null;
+ this.end = null;
+ this.earlier_than = null;
+ this.load_earlier = false;
+ this.state = {
+ cursor: null,
+ after: null,
+ entries: [],
+ };
+ }
- journalctlPrepend(entryList) {
+ scrollToTop() {
+ const logs_view = document.getElementById("logs-view");
+ logs_view.scrollTop = 0;
+ }
+
+ scrollToBottom() {
+ const logs_view = document.getElementById("logs-view");
+ logs_view.scrollTop = logs_view.scrollHeight;
+ }
+
+ journalctlError(error) {
+ console.warn(cockpit.message(error));
+ }
+
+ journalctlIngest(entryList) {
+ if (this.load_earlier === true) {
entryList.push(...this.entries);
+ this.entries = entryList;
this.setState({entries: this.entries});
+ this.load_earlier = false;
+ this.scrollToTop();
+ } else {
+ if (entryList.length > 0) {
+ this.entries.push(...entryList);
+ const after = this.entries[this.entries.length - 1].__CURSOR;
+ this.setState({entries: this.entries, after: after});
+ this.scrollToBottom();
+ }
}
+ }
- getLogs() {
- if (this.start != null && this.end != null) {
- if (this.journalCtl != null) {
- this.journalCtl.stop();
- this.journalCtl = null;
- }
+ journalctlPrepend(entryList) {
+ entryList.push(...this.entries);
+ this.setState({entries: this.entries});
+ }
- let matches = [];
+ getLogs() {
+ if (this.start != null && this.end != null) {
+ if (this.journalCtl != null) {
+ this.journalCtl.stop();
+ this.journalCtl = null;
+ }
- let options = {
- since: formatDateTime(this.start),
- until: formatDateTime(this.end),
- follow: false,
- count: "all",
- };
+ let matches = [];
- if (this.load_earlier === true) {
- options["until"] = formatDateTime(this.earlier_than);
- } else if (this.state.after != null) {
- options["after"] = this.state.after;
- delete options.since;
- }
+ let options = {
+ since: formatDateTime(this.start),
+ until: formatDateTime(this.end),
+ follow: false,
+ count: "all",
+ };
- const self = this;
- this.journalCtl = Journal.journalctl(matches, options)
+ if (this.load_earlier === true) {
+ options["until"] = formatDateTime(this.earlier_than);
+ } else if (this.state.after != null) {
+ options["after"] = this.state.after;
+ delete options.since;
+ }
+
+ const self = this;
+ this.journalCtl = Journal.journalctl(matches, options)
.fail(this.journalctlError)
.done(function(data) {
self.journalctlIngest(data);
});
- }
- }
-
- loadEarlier() {
- this.load_earlier = true;
- this.start = this.start - 3600;
- this.getLogs();
- }
-
- loadLater() {
- this.start = this.end;
- this.end = this.end + 3600;
- this.getLogs();
- }
-
- loadForTs(ts) {
- this.end = this.start + ts;
- this.getLogs();
- }
-
- componentDidUpdate() {
- if (this.props.recording) {
- if (this.start === null && this.end === null) {
- this.end = this.props.recording.start + 3600;
- this.start = this.props.recording.start;
- this.earlier_than = this.props.recording.start;
- }
- this.getLogs();
- }
- if (this.props.curTs) {
- const ts = this.props.curTs;
- this.loadForTs(ts);
- }
- }
-
- render() {
- if (this.props.recording) {
- return (
-
-
- Logs
- Load earlier entries
-
-
-
- Load later entries
-
-
- );
- } else {
- return (
Loading...
);
- }
}
}
- /*
- * A component representing a single recording view.
- * Properties:
- * - recording: either null for no recording data available yet, or a
- * recording object, as created by the View below.
- */
- let Recording = class extends React.Component {
- constructor(props) {
- super(props);
- this.goBackToList = this.goBackToList.bind(this);
- this.getHostname = this.getHostname.bind(this);
- this.Hostname = this.Hostname.bind(this);
- this.hostname = null;
- }
+ loadEarlier() {
+ this.load_earlier = true;
+ this.start = this.start - 3600;
+ this.getLogs();
+ }
- goBackToList() {
- if (cockpit.location.path[0]) {
- cockpit.location.go([], cockpit.location.options);
- } else {
- cockpit.location.go('/');
+ loadLater() {
+ this.start = this.end;
+ this.end = this.end + 3600;
+ this.getLogs();
+ }
+
+ loadForTs(ts) {
+ this.end = this.start + ts;
+ this.getLogs();
+ }
+
+ componentDidUpdate() {
+ if (this.props.recording) {
+ if (this.start === null && this.end === null) {
+ this.end = this.props.recording.start + 3600;
+ this.start = this.props.recording.start;
+ this.earlier_than = this.props.recording.start;
}
+ this.getLogs();
}
+ if (this.props.curTs) {
+ const ts = this.props.curTs;
+ this.loadForTs(ts);
+ }
+ }
- getHostname() {
- cockpit.spawn(["hostname"], { err: "ignore" })
+ render() {
+ if (this.props.recording) {
+ return (
+
+
+ Logs
+ Load earlier entries
+
+
+
+ Load later entries
+
+
+ );
+ } else {
+ return (
Loading...
);
+ }
+ }
+}
+
+/*
+ * A component representing a single recording view.
+ * Properties:
+ * - recording: either null for no recording data available yet, or a
+ * recording object, as created by the View below.
+ */
+class Recording extends React.Component {
+ constructor(props) {
+ super(props);
+ this.goBackToList = this.goBackToList.bind(this);
+ this.getHostname = this.getHostname.bind(this);
+ this.Hostname = this.Hostname.bind(this);
+ this.hostname = null;
+ }
+
+ goBackToList() {
+ if (cockpit.location.path[0]) {
+ cockpit.location.go([], cockpit.location.options);
+ } else {
+ cockpit.location.go('/');
+ }
+ }
+
+ getHostname() {
+ cockpit.spawn(["hostname"], { err: "ignore" })
.done(function(output) {
this.hostname = $.trim(output);
})
.fail(function(ex) {
console.log(ex);
});
- }
+ }
+
+ Hostname(props) {
+ let style = {
+ display: "none"
+ };
+ if (this.hostname != null && this.hostname != props.hostname) {
+ style = {};
+ }
+ return (
+
+ {_("Hostname")}
+ {props.hostname}
+
+ );
+ }
+
+ componentWillMount() {
+ this.getHostname();
+ }
+
+ render() {
+ let r = this.props.recording;
+ if (r == null) {
+ return
Loading... ;
+ } else {
+ let player =
+ (
);
- Hostname(props) {
- let style = {
- display: "none"
- };
- if (this.hostname != null && this.hostname != props.hostname) {
- style = {};
- }
return (
-
- {_("Hostname")}
- {props.hostname}
-
- );
- }
-
- componentWillMount() {
- this.getHostname();
- }
-
- render() {
- let r = this.props.recording;
- if (r == null) {
- return
Loading... ;
- } else {
- let player =
- (
);
-
- return (
-
-
-
+
+
+
-
-
-
-
- {_("Recording")}
-
-
-
+
+
+
+
+
+ {_("Recording")}
+
+
+
+
{_("ID")}
{r.id}
@@ -535,133 +564,136 @@
{_("User")}
{r.user}
-
-
+
+
- {player}
+ {player}
- );
- }
+
+ );
}
- };
+ }
+}
- /*
- * A component representing a list of recordings.
- * Properties:
- * - list: an array with recording objects, as created by the View below
- */
- let RecordingList = class extends React.Component {
- constructor(props) {
- super(props);
- this.handleColumnClick = this.handleColumnClick.bind(this);
- this.getSortedList = this.getSortedList.bind(this);
- this.drawSortDir = this.drawSortDir.bind(this);
- this.getColumnTitles = this.getColumnTitles.bind(this);
- this.getColumns = this.getColumns.bind(this);
- this.state = {
- sorting_field: "start",
- sorting_asc: true,
- };
+/*
+ * A component representing a list of recordings.
+ * Properties:
+ * - list: an array with recording objects, as created by the View below
+ */
+class RecordingList extends React.Component {
+ constructor(props) {
+ super(props);
+ this.handleColumnClick = this.handleColumnClick.bind(this);
+ this.getSortedList = this.getSortedList.bind(this);
+ this.drawSortDir = this.drawSortDir.bind(this);
+ this.getColumnTitles = this.getColumnTitles.bind(this);
+ this.getColumns = this.getColumns.bind(this);
+ this.state = {
+ sorting_field: "start",
+ sorting_asc: true,
+ };
+ }
+
+ drawSortDir() {
+ $('#sort_arrow').remove();
+ let type = this.state.sorting_asc ? "asc" : "desc";
+ let arrow = '
';
+ $(this.refs[this.state.sorting_field]).append(arrow);
+ }
+
+ handleColumnClick(event) {
+ if (this.state.sorting_field === event.currentTarget.id) {
+ this.setState({sorting_asc: !this.state.sorting_asc});
+ } else {
+ this.setState({
+ sorting_field: event.currentTarget.id,
+ sorting_asc: 'asc'
+ });
}
+ }
- drawSortDir() {
- $('#sort_arrow').remove();
- let type = this.state.sorting_asc ? "asc" : "desc";
- let arrow = '
';
- $(this.refs[this.state.sorting_field]).append(arrow);
- }
+ getSortedList() {
+ let field = this.state.sorting_field;
+ let asc = this.state.sorting_asc;
+ let list = this.props.list.slice();
- handleColumnClick(event) {
- if (this.state.sorting_field === event.currentTarget.id) {
- this.setState({sorting_asc: !this.state.sorting_asc});
+ if (this.state.sorting_field != null) {
+ if (asc) {
+ list.sort(function(a, b) {
+ return a[field] > b[field];
+ });
} else {
- this.setState({
- sorting_field: event.currentTarget.id,
- sorting_asc: 'asc'
+ list.sort(function(a, b) {
+ return a[field] < b[field];
});
}
}
- getSortedList() {
- let field = this.state.sorting_field;
- let asc = this.state.sorting_asc;
- let list = this.props.list.slice();
+ return list;
+ }
- if (this.state.sorting_field != null) {
- if (asc) {
- list.sort(function(a, b) {
- return a[field] > b[field];
- });
- } else {
- list.sort(function(a, b) {
- return a[field] < b[field];
- });
- }
- }
+ /*
+ * Set the cockpit location to point to the specified recording.
+ */
+ navigateToRecording(recording) {
+ cockpit.location.go([recording.id], cockpit.location.options);
+ }
- return list;
+ componentDidUpdate() {
+ this.drawSortDir();
+ }
+
+ getColumnTitles() {
+ let columnTitles = [
+ (
),
+ (
),
+ (
),
+ (
),
+ ];
+ if (this.props.diff_hosts === true) {
+ columnTitles.push((
));
}
+ return columnTitles;
+ }
- /*
- * Set the cockpit location to point to the specified recording.
- */
- navigateToRecording(recording) {
- cockpit.location.go([recording.id], cockpit.location.options);
+ 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;
+ }
- componentDidUpdate() {
- this.drawSortDir();
+ render() {
+ let columnTitles = this.getColumnTitles();
+ let list = this.getSortedList();
+ let rows = [];
+
+ for (let i = 0; i < list.length; i++) {
+ let r = list[i];
+ let columns = this.getColumns(r);
+ rows.push(
);
}
-
- getColumnTitles() {
- let columnTitles = [
- (
),
- (
),
- (
),
- (
),
- ];
- if (this.props.diff_hosts === true) {
- columnTitles.push((
));
- }
- return columnTitles;
- }
-
- getColumns(r) {
- let columns = [r.user,
- formatDateTime(r.start),
- formatDateTime(r.end),
- formatDuration(r.end - r.start)]
- if (this.props.diff_hosts === true) {
- columns.push(r.hostname);
- }
- return columns;
- }
-
- render() {
- let columnTitles = this.getColumnTitles();
- let list = this.getSortedList();
- let rows = [];
-
- for (let i = 0; i < list.length; i++) {
- let r = list[i];
- let columns = this.getColumns(r);
- rows.push(
);
- }
- return (
-
-
-
-
+ return (
+
+
+
+
+
Since
@@ -680,15 +712,14 @@
Username
-
+
Hostname
+ hostname={this.props.hostname} />
Configuration
@@ -697,348 +728,355 @@
-
-
-
-
- {rows}
-
+
+
+
- );
- }
- };
+
+ {rows}
+
+
+ );
+ }
+}
+
+/*
+ * A component representing the view upon a list of recordings, or a
+ * single recording. Extracts the ID of the recording to display from
+ * cockpit.location.path[0]. If it's zero, displays the list.
+ */
+class View extends React.Component {
+ constructor(props) {
+ super(props);
+ this.onLocationChanged = this.onLocationChanged.bind(this);
+ this.journalctlIngest = this.journalctlIngest.bind(this);
+ this.handleDateSinceChange = this.handleDateSinceChange.bind(this);
+ this.handleDateUntilChange = this.handleDateUntilChange.bind(this);
+ this.handleUsernameChange = this.handleUsernameChange.bind(this);
+ this.handleHostnameChange = this.handleHostnameChange.bind(this);
+ this.handleTsChange = this.handleTsChange.bind(this);
+ this.handleLogTsChange = this.handleLogTsChange.bind(this);
+ /* Journalctl instance */
+ this.journalctl = null;
+ /* Recording ID journalctl instance is invoked with */
+ this.journalctlRecordingID = null;
+ /* Recording ID -> data map */
+ this.recordingMap = {};
+ /* tlog UID in system set in ComponentDidMount */
+ this.uid = null;
+ this.state = {
+ /* List of recordings in start order */
+ recordingList: [],
+ /* ID of the recording to display, or null for all */
+ recordingID: cockpit.location.path[0] || null,
+ dateSince: cockpit.location.options.dateSince || null,
+ dateSinceLastValid: null,
+ dateUntil: cockpit.location.options.dateUntil || null,
+ dateUntilLastValid: null,
+ /* value to filter recordings by username */
+ username: cockpit.location.options.username || "",
+ hostname: cockpit.location.options.hostname || "",
+ error_tlog_uid: false,
+ diff_hosts: false,
+ curTs: null,
+ logsTs: null,
+ };
+ }
/*
- * A component representing the view upon a list of recordings, or a
- * single recording. Extracts the ID of the recording to display from
- * cockpit.location.path[0]. If it's zero, displays the list.
+ * Display a journalctl error
*/
- let View = class extends React.Component {
- constructor(props) {
- super(props);
- this.onLocationChanged = this.onLocationChanged.bind(this);
- this.journalctlIngest = this.journalctlIngest.bind(this);
- this.handleDateSinceChange = this.handleDateSinceChange.bind(this);
- this.handleDateUntilChange = this.handleDateUntilChange.bind(this);
- this.handleUsernameChange = this.handleUsernameChange.bind(this);
- this.handleHostnameChange = this.handleHostnameChange.bind(this);
- this.handleTsChange = this.handleTsChange.bind(this);
- /* Journalctl instance */
- this.journalctl = null;
- /* Recording ID journalctl instance is invoked with */
- this.journalctlRecordingID = null;
- /* Recording ID -> data map */
- this.recordingMap = {};
- /* tlog UID in system set in ComponentDidMount */
- this.uid = null;
- this.state = {
- /* List of recordings in start order */
- recordingList: [],
- /* ID of the recording to display, or null for all */
- recordingID: cockpit.location.path[0] || null,
- dateSince: cockpit.location.options.dateSince || null,
- dateSinceLastValid: null,
- dateUntil: cockpit.location.options.dateUntil || null,
- dateUntilLastValid: null,
- /* value to filter recordings by username */
- username: cockpit.location.options.username || null,
- hostname: cockpit.location.options.hostname || null,
- error_tlog_uid: false,
- diff_hosts: false,
- curTs: null,
+ journalctlError(error) {
+ console.warn(cockpit.message(error));
+ }
+
+ /*
+ * Respond to cockpit location change by extracting and setting the
+ * displayed recording ID.
+ */
+ onLocationChanged() {
+ this.setState({
+ recordingID: cockpit.location.path[0] || null,
+ dateSince: cockpit.location.options.dateSince || null,
+ dateUntil: cockpit.location.options.dateUntil || null,
+ username: cockpit.location.options.username || null,
+ hostname: cockpit.location.options.hostname || null,
+ });
+ }
+
+ /*
+ * Ingest journal entries sent by journalctl.
+ */
+ journalctlIngest(entryList) {
+ let recordingList = this.state.recordingList.slice();
+ let i;
+ let j;
+ let hostname;
+
+ if (entryList[0]) {
+ if (entryList[0]["_HOSTNAME"]) {
+ hostname = entryList[0]["_HOSTNAME"];
}
}
- /*
- * Display a journalctl error
- */
- journalctlError(error) {
- console.warn(cockpit.message(error));
- }
+ for (i = 0; i < entryList.length; i++) {
+ let e = entryList[i];
+ let id = e['TLOG_REC'];
- /*
- * Respond to cockpit location change by extracting and setting the
- * displayed recording ID.
- */
- onLocationChanged() {
- this.setState({
- recordingID: cockpit.location.path[0] || null,
- dateSince: cockpit.location.options.dateSince || null,
- dateUntil: cockpit.location.options.dateUntil || null,
- username: cockpit.location.options.username || null,
- hostname: cockpit.location.options.hostname || null,
- });
- }
-
- /*
- * Ingest journal entries sent by journalctl.
- */
- journalctlIngest(entryList) {
- let recordingList = this.state.recordingList.slice();
- let i;
- let j;
- let hostname;
-
- if (entryList[0]) {
- if (entryList[0]["_HOSTNAME"]) {
- hostname = entryList[0]["_HOSTNAME"];
- }
+ /* Skip entries with missing recording ID */
+ if (id === undefined) {
+ continue;
}
- for (i = 0; i < entryList.length; i++) {
- let e = entryList[i];
- let id = e['TLOG_REC'];
+ let ts = Math.floor(
+ parseInt(e["__REALTIME_TIMESTAMP"], 10) /
+ 1000);
- /* Skip entries with missing recording ID */
- if (id === undefined) {
- continue;
+ let r = this.recordingMap[id];
+ /* If no recording found */
+ if (r === undefined) {
+ /* Create new recording */
+ if (hostname != e["_HOSTNAME"]) {
+ this.setState({diff_hosts: true});
}
- let ts = Math.floor(
- parseInt(e["__REALTIME_TIMESTAMP"], 10) /
- 1000);
-
- let r = this.recordingMap[id];
- /* If no recording found */
- if (r === undefined) {
- /* Create new recording */
- if (hostname != e["_HOSTNAME"]) {
- this.setState({diff_hosts: true});
+ r = {id: id,
+ matchList: ["_UID=" + this.uid,
+ "TLOG_REC=" + id],
+ user: e["TLOG_USER"],
+ boot_id: e["_BOOT_ID"],
+ session_id: parseInt(e["TLOG_SESSION"], 10),
+ pid: parseInt(e["_PID"], 10),
+ start: ts,
+ /* FIXME Should be start + message duration */
+ end: ts,
+ duration: 0};
+ /* Map the recording */
+ this.recordingMap[id] = r;
+ /* Insert the recording in order */
+ for (j = recordingList.length - 1;
+ j >= 0 && r.start < recordingList[j].start;
+ j--);
+ recordingList.splice(j + 1, 0, r);
+ } else {
+ /* Adjust existing recording */
+ if (ts > r.end) {
+ r.end = ts;
+ r.duration = r.end - r.start;
+ }
+ if (ts < r.start) {
+ r.start = ts;
+ r.duration = r.end - r.start;
+ /* Find the recording in the list */
+ for (j = recordingList.length - 1;
+ j >= 0 && recordingList[j] != r;
+ j--);
+ /* If found */
+ if (j >= 0) {
+ /* Remove */
+ recordingList.splice(j, 1);
}
-
- r = {id: id,
- matchList: ["_UID=" + this.uid,
- "TLOG_REC=" + id],
- user: e["TLOG_USER"],
- boot_id: e["_BOOT_ID"],
- session_id: parseInt(e["TLOG_SESSION"], 10),
- pid: parseInt(e["_PID"], 10),
- start: ts,
- /* FIXME Should be start + message duration */
- end: ts,
- duration: 0};
- /* Map the recording */
- this.recordingMap[id] = r;
/* Insert the recording in order */
for (j = recordingList.length - 1;
j >= 0 && r.start < recordingList[j].start;
j--);
recordingList.splice(j + 1, 0, r);
- } else {
- /* Adjust existing recording */
- if (ts > r.end) {
- r.end = ts;
- r.duration = r.end - r.start;
- }
- if (ts < r.start) {
- r.start = ts;
- r.duration = r.end - r.start;
- /* Find the recording in the list */
- for (j = recordingList.length - 1;
- j >= 0 && recordingList[j] != r;
- j--);
- /* If found */
- if (j >= 0) {
- /* Remove */
- recordingList.splice(j, 1);
- }
- /* Insert the recording in order */
- for (j = recordingList.length - 1;
- j >= 0 && r.start < recordingList[j].start;
- j--);
- recordingList.splice(j + 1, 0, r);
- }
}
}
-
- this.setState({recordingList: recordingList});
}
- /*
- * Start journalctl, retrieving entries for the current recording ID.
- * Assumes journalctl is not running.
- */
- journalctlStart() {
- let matches = ["_UID=" + this.uid];
- if (this.state.username) {
- matches.push("TLOG_USER=" + this.state.username);
- }
- if (this.state.hostname && this.state.hostname != null &&
- this.state.hostname != "") {
- matches.push("_HOSTNAME=" + this.state.hostname);
- }
+ this.setState({recordingList: recordingList});
+ }
- let options = {follow: true, count: "all"};
-
- if (this.state.dateSinceLastValid) {
- options['since'] = this.state.dateSinceLastValid;
- }
-
- if (this.state.dateUntil) {
- options['until'] = this.state.dateUntilLastValid;
- }
-
- if (this.state.recordingID !== null) {
- matches.push("TLOG_REC=" + this.state.recordingID);
- }
-
- this.journalctlRecordingID = this.state.recordingID;
- this.journalctl = Journal.journalctl(matches, options)
- .fail(this.journalctlError)
- .stream(this.journalctlIngest);
+ /*
+ * Start journalctl, retrieving entries for the current recording ID.
+ * Assumes journalctl is not running.
+ */
+ journalctlStart() {
+ let matches = ["_UID=" + this.uid];
+ if (this.state.username) {
+ matches.push("TLOG_USER=" + this.state.username);
+ }
+ if (this.state.hostname && this.state.hostname != null &&
+ this.state.hostname != "") {
+ matches.push("_HOSTNAME=" + this.state.hostname);
}
- /*
- * Check if journalctl is running.
- */
- journalctlIsRunning() {
- return this.journalctl != null;
+ let options = {follow: true, count: "all"};
+
+ if (this.state.dateSinceLastValid) {
+ options['since'] = this.state.dateSinceLastValid;
}
- /*
- * Stop current journalctl.
- * Assumes journalctl is running.
- */
- journalctlStop() {
+ if (this.state.dateUntil) {
+ options['until'] = this.state.dateUntilLastValid;
+ }
+
+ if (this.state.recordingID !== null) {
+ matches.push("TLOG_REC=" + this.state.recordingID);
+ }
+
+ this.journalctlRecordingID = this.state.recordingID;
+ this.journalctl = Journal.journalctl(matches, options)
+ .fail(this.journalctlError)
+ .stream(this.journalctlIngest);
+ }
+
+ /*
+ * Check if journalctl is running.
+ */
+ journalctlIsRunning() {
+ return this.journalctl != null;
+ }
+
+ /*
+ * Stop current journalctl.
+ * Assumes journalctl is running.
+ */
+ journalctlStop() {
+ this.journalctl.stop();
+ this.journalctl = null;
+ }
+
+ /*
+ * Restarts journalctl.
+ * Will stop journalctl if it's running.
+ */
+ journalctlRestart() {
+ if (this.journalctlIsRunning()) {
this.journalctl.stop();
- this.journalctl = null;
}
+ this.journalctlStart();
+ }
- /*
- * Restarts journalctl.
- * Will stop journalctl if it's running.
- */
- journalctlRestart() {
- if (this.journalctlIsRunning()) {
- this.journalctl.stop();
- }
+ /*
+ * Clears previous recordings list.
+ * Will clear service obj recordingMap and state.
+ */
+ clearRecordings() {
+ this.recordingMap = {};
+ this.setState({recordingList: []});
+ }
+
+ handleDateSinceChange(date, last_valid) {
+ this.setState({dateSinceLastValid: last_valid});
+ cockpit.location.go([], $.extend(cockpit.location.options, { dateSince: date }));
+ }
+
+ handleDateUntilChange(date, last_valid) {
+ this.setState({dateUntilLastValid: last_valid});
+ cockpit.location.go([], $.extend(cockpit.location.options, { dateUntil: date }));
+ }
+
+ handleUsernameChange(username) {
+ cockpit.location.go([], $.extend(cockpit.location.options, { username: username }));
+ }
+
+ handleHostnameChange(hostname) {
+ cockpit.location.go([], $.extend(cockpit.location.options, { hostname: hostname }));
+ }
+
+ handleTsChange(ts) {
+ this.setState({curTs: ts});
+ }
+
+ handleLogTsChange(ts) {
+ this.setState({logsTs: ts});
+ }
+
+ componentDidMount() {
+ let proc = cockpit.spawn(["getent", "passwd", "tlog"]);
+
+ proc.stream((data) => {
+ this.uid = data.split(":", 3)[2];
this.journalctlStart();
+ proc.close();
+ });
+
+ proc.fail(() => {
+ this.setState({error_tlog_uid: true});
+ });
+
+ let dateSince = parseDate(this.state.dateSince);
+
+ if (dateSince && dateSince != true) {
+ this.setState({dateSinceLastValid: dateSince});
}
+ let dateUntil = parseDate(this.state.dateUntil);
+
+ if (dateUntil && dateUntil != true) {
+ this.setState({dateUntilLastValid: dateUntil});
+ }
+
+ cockpit.addEventListener("locationchanged",
+ this.onLocationChanged);
+ }
+
+ componentWillUnmount() {
+ if (this.journalctlIsRunning()) {
+ this.journalctlStop();
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
/*
- * Clears previous recordings list.
- * Will clear service obj recordingMap and state.
+ * If we're running a specific (non-wildcard) journalctl
+ * and recording ID has changed
*/
- clearRecordings() {
- this.recordingMap = {};
- this.setState({recordingList: []});
- }
-
- handleDateSinceChange(date, last_valid) {
- this.setState({dateSinceLastValid: last_valid});
- cockpit.location.go([], $.extend(cockpit.location.options, { dateSince: date }));
- }
-
- handleDateUntilChange(date, last_valid) {
- this.setState({dateUntilLastValid: last_valid});
- cockpit.location.go([], $.extend(cockpit.location.options, { dateUntil: date }));
- }
-
- handleUsernameChange(username) {
- cockpit.location.go([], $.extend(cockpit.location.options, { username: username }));
- }
-
- handleHostnameChange(hostname) {
- cockpit.location.go([], $.extend(cockpit.location.options, { hostname: hostname }));
- }
-
- handleTsChange(ts) {
- this.setState({curTs: ts});
- }
-
- componentDidMount() {
- let proc = cockpit.spawn(["getent", "passwd", "tlog"]);
-
- proc.stream((data) => {
- this.uid = data.split(":", 3)[2];
- this.journalctlStart();
- proc.close();
- });
-
- proc.fail(() => {
- this.setState({error_tlog_uid: true});
- });
-
- let dateSince = parseDate(this.state.dateSince);
-
- if (dateSince && dateSince != true) {
- this.setState({dateSinceLastValid: dateSince});
- }
-
- let dateUntil = parseDate(this.state.dateUntil);
-
- if (dateUntil && dateUntil != true) {
- this.setState({dateUntilLastValid: dateUntil});
- }
-
- cockpit.addEventListener("locationchanged",
- this.onLocationChanged);
- }
-
- componentWillUnmount() {
+ if (this.journalctlRecordingID !== null &&
+ this.state.recordingID != prevState.recordingID) {
if (this.journalctlIsRunning()) {
this.journalctlStop();
}
+ this.journalctlStart();
}
-
- componentDidUpdate(prevProps, prevState) {
- /*
- * If we're running a specific (non-wildcard) journalctl
- * and recording ID has changed
- */
- if (this.journalctlRecordingID !== null &&
- this.state.recordingID != prevState.recordingID) {
- if (this.journalctlIsRunning()) {
- this.journalctlStop();
- }
- this.journalctlStart();
- }
- if (this.state.dateSinceLastValid != prevState.dateSinceLastValid ||
- this.state.dateUntilLastValid != prevState.dateUntilLastValid ||
- this.state.username != prevState.username ||
- this.state.hostname != prevState.hostname
- ) {
- this.clearRecordings();
- this.journalctlRestart();
- }
+ if (this.state.dateSinceLastValid != prevState.dateSinceLastValid ||
+ this.state.dateUntilLastValid != prevState.dateUntilLastValid ||
+ this.state.username != prevState.username ||
+ this.state.hostname != prevState.hostname
+ ) {
+ this.clearRecordings();
+ this.journalctlRestart();
}
+ }
- render() {
- if (this.state.error_tlog_uid === true) {
- return (
+ render() {
+ if (this.state.error_tlog_uid === true) {
+ return (
+
+ Error getting tlog UID from system.
+
+ );
+ }
+ if (this.state.recordingID === null) {
+ return (
+
+ );
+ } else {
+ return (
+
+
- Error getting tlog UID from system.
-
- );
- }
- if (this.state.recordingID === null) {
- return (
-
- );
- } else {
- return (
-
-
-
+ );
}
- };
+ }
+}
- React.render(
, document.getElementById('view'));
-}());
+ReactDOM.render(
, document.getElementById('view'));
diff --git a/src/terminal.jsx b/src/terminal.jsx
index 5b7c487..b352ead 100644
--- a/src/terminal.jsx
+++ b/src/terminal.jsx
@@ -17,175 +17,177 @@
* along with Cockpit; If not, see
.
*/
-(function() {
- "use strict";
+import Player from "./player";
- var React = require("react");
- var Term = require("term");
- let $ = require("jquery");
- require("console.css");
- require("jquery-resizable");
- require("jquery-resizable/resizable.css");
+"use strict";
- /*
- * A terminal component that communicates over a cockpit channel.
- *
- * The only required property is 'channel', which must point to a cockpit
- * stream channel.
- *
- * The size of the terminal can be set with the 'rows' and 'cols'
- * properties. If those properties are not given, the terminal will fill
- * its container.
- *
- * If the 'onTitleChanged' callback property is set, it will be called whenever
- * the title of the terminal changes.
- *
- * Call focus() to set the input focus on the terminal.
- */
- var Terminal = React.createClass({
- propTypes: {
- cols: React.PropTypes.number,
- rows: React.PropTypes.number,
- channel: React.PropTypes.object.isRequired,
- onTitleChanged: React.PropTypes.func
- },
+var React = require("react");
+var Term = require("term");
+let $ = require("jquery");
- componentWillMount: function () {
- var term = new Term({
- cols: this.state.cols || 80,
- rows: this.state.rows || 25,
- screenKeys: true,
- useStyle: true
+require("console.css");
+require("jquery-resizable");
+require("jquery-resizable/resizable.css");
+
+/*
+ * A terminal component that communicates over a cockpit channel.
+ *
+ * The only required property is 'channel', which must point to a cockpit
+ * stream channel.
+ *
+ * The size of the terminal can be set with the 'rows' and 'cols'
+ * properties. If those properties are not given, the terminal will fill
+ * its container.
+ *
+ * If the 'onTitleChanged' callback property is set, it will be called whenever
+ * the title of the terminal changes.
+ *
+ * Call focus() to set the input focus on the terminal.
+ */
+var Terminal = React.createClass({
+ propTypes: {
+ // cols: React.PropTypes.number,
+ rows: React.PropTypes.number,
+ channel: React.PropTypes.object.isRequired,
+ onTitleChanged: React.PropTypes.func
+ },
+
+ componentWillMount: function () {
+ var term = new Term({
+ cols: this.state.cols || 80,
+ rows: this.state.rows || 25,
+ screenKeys: true,
+ useStyle: true
+ });
+
+ term.on('data', function(data) {
+ if (this.props.channel.valid)
+ this.props.channel.send(data);
+ }.bind(this));
+
+ if (this.props.onTitleChanged)
+ term.on('title', this.props.onTitleChanged);
+
+ this.setState({ terminal: term });
+ },
+
+ componentDidMount: function () {
+ this.state.terminal.open(this.refs.terminal);
+ this.connectChannel();
+
+ let term = this.refs.terminal;
+ let onWindowResize = this.onWindowResize;
+
+ $(function() {
+ $(term).resizable({
+ direction: ['right', 'bottom'],
+ stop: function() {
+ onWindowResize();
+ },
});
+ });
- term.on('data', function(data) {
- if (this.props.channel.valid)
- this.props.channel.send(data);
- }.bind(this));
-
- if (this.props.onTitleChanged)
- term.on('title', this.props.onTitleChanged);
-
- this.setState({ terminal: term });
- },
-
- componentDidMount: function () {
- this.state.terminal.open(this.refs.terminal);
- this.connectChannel();
-
- let term = this.refs.terminal;
- let onWindowResize = this.onWindowResize;
-
- $(function() {
- $(term).resizable({
- direction: ['right', 'bottom'],
- stop: function() {
- onWindowResize();
- },
- });
- });
-
- if (!this.props.rows) {
- window.addEventListener('resize', this.onWindowResize);
- this.onWindowResize();
- }
- },
-
- componentWillUpdate: function (nextProps, nextState) {
- if (nextState.cols !== this.state.cols || nextState.rows !== this.state.rows) {
- this.state.terminal.resize(nextState.cols, nextState.rows);
- this.props.channel.control({
- window: {
- rows: nextState.rows,
- cols: nextState.cols
- }
- });
- }
-
- if (nextProps.channel !== this.props.channel) {
- this.state.terminal.reset();
- this.disconnectChannel();
- }
- },
-
- componentDidUpdate: function (prevProps) {
- if (prevProps.channel !== this.props.channel)
- this.connectChannel();
- },
-
- render: function () {
- let style = {
- 'min-width': '300px',
- 'min-height': '100px',
- }
- // ensure react never reuses this div by keying it with the terminal widget
- return
;
- },
-
- componentWillUnmount: function () {
- this.disconnectChannel();
- this.state.terminal.destroy();
- },
-
- onChannelMessage: function (event, data) {
- if (this.state.terminal) {
- this.state.terminal.write(data);
- }
- },
-
- onChannelClose: function (event, options) {
- var term = this.state.terminal;
- term.write('\x1b[31m' + (options.problem || 'disconnected') + '\x1b[m\r\n');
- term.cursorHidden = true;
- term.refresh(term.y, term.y);
- },
-
- connectChannel: function () {
- var channel = this.props.channel;
- if (channel && channel.valid) {
- channel.addEventListener('message', this.onChannelMessage.bind(this));
- channel.addEventListener('close', this.onChannelClose.bind(this));
- }
- },
-
- disconnectChannel: function () {
- if (this.props.channel) {
- this.props.channel.removeEventListener('message', this.onChannelMessage);
- this.props.channel.removeEventListener('close', this.onChannelClose);
- }
- },
-
- focus: function () {
- if (this.state.terminal)
- this.state.terminal.focus();
- },
-
- onWindowResize: function () {
- if (this.refs) {
- var padding = 2 * 11;
- var node = this.getDOMNode();
- var terminal = this.refs.terminal.querySelector('.terminal');
-
- var ch = document.createElement('div');
- ch.textContent = 'M';
- terminal.appendChild(ch);
- var height = ch.offsetHeight; // offsetHeight is only correct for block elements
- ch.style.display = 'inline';
- var width = ch.offsetWidth;
- terminal.removeChild(ch);
-
- this.setState({
- rows: Math.floor((node.parentElement.clientHeight - padding) / height),
- cols: Math.floor((node.parentElement.clientWidth - padding) / width)
- });
- }
- },
-
- send: function(value) {
- this.state.terminal.send(value);
+ if (!this.props.rows) {
+ window.addEventListener('resize', this.onWindowResize);
+ this.onWindowResize();
}
- });
+ },
- module.exports = { Terminal: Terminal };
-}());
+ componentWillUpdate: function (nextProps, nextState) {
+ if (nextState.cols !== this.state.cols || nextState.rows !== this.state.rows) {
+ this.state.terminal.resize(nextState.cols, nextState.rows);
+ this.props.channel.control({
+ window: {
+ rows: nextState.rows,
+ cols: nextState.cols
+ }
+ });
+ }
+
+ if (nextProps.channel !== this.props.channel) {
+ this.state.terminal.reset();
+ this.disconnectChannel();
+ }
+ },
+
+ componentDidUpdate: function (prevProps) {
+ if (prevProps.channel !== this.props.channel)
+ this.connectChannel();
+ },
+
+ render: function () {
+ let style = {
+ 'min-width': '300px',
+ 'min-height': '100px',
+ };
+ // ensure react never reuses this div by keying it with the terminal widget
+ return
;
+ },
+
+ componentWillUnmount: function () {
+ this.disconnectChannel();
+ this.state.terminal.destroy();
+ },
+
+ onChannelMessage: function (event, data) {
+ if (this.state.terminal) {
+ this.state.terminal.write(data);
+ }
+ },
+
+ onChannelClose: function (event, options) {
+ var term = this.state.terminal;
+ term.write('\x1b[31m' + (options.problem || 'disconnected') + '\x1b[m\r\n');
+ term.cursorHidden = true;
+ term.refresh(term.y, term.y);
+ },
+
+ connectChannel: function () {
+ var channel = this.props.channel;
+ if (channel && channel.valid) {
+ channel.addEventListener('message', this.onChannelMessage.bind(this));
+ channel.addEventListener('close', this.onChannelClose.bind(this));
+ }
+ },
+
+ disconnectChannel: function () {
+ if (this.props.channel) {
+ this.props.channel.removeEventListener('message', this.onChannelMessage);
+ this.props.channel.removeEventListener('close', this.onChannelClose);
+ }
+ },
+
+ focus: function () {
+ if (this.state.terminal)
+ this.state.terminal.focus();
+ },
+
+ onWindowResize: function () {
+ if (this.refs) {
+ var padding = 2 * 11;
+ var node = this.getDOMNode();
+ var terminal = this.refs.terminal.querySelector('.terminal');
+
+ var ch = document.createElement('div');
+ ch.textContent = 'M';
+ terminal.appendChild(ch);
+ var height = ch.offsetHeight; // offsetHeight is only correct for block elements
+ ch.style.display = 'inline';
+ var width = ch.offsetWidth;
+ terminal.removeChild(ch);
+
+ this.setState({
+ rows: Math.floor((node.parentElement.clientHeight - padding) / height),
+ cols: Math.floor((node.parentElement.clientWidth - padding) / width)
+ });
+ }
+ },
+
+ send: function(value) {
+ this.state.terminal.send(value);
+ }
+});
+
+// module.exports = { Terminal: Terminal };
+export class Terminal;
diff --git a/webpack.config.js b/webpack.config.js
index ae2d35c..6cade24 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -30,6 +30,7 @@ var info = {
"config": [
"./config.jsx",
"./recordings.css",
+ "./table.css",
]
},
files: [
@@ -38,6 +39,7 @@ var info = {
"player.jsx",
"recordings.jsx",
"recordings.css",
+ "table.css",
"terminal.jsx",
"manifest.json",
"timer.css",
From 2aa3270d97e10cb62a73425a7f0a00a6fe679c65 Mon Sep 17 00:00:00 2001
From: Kyrylo Gliebov
Date: Tue, 25 Sep 2018 11:19:50 +0200
Subject: [PATCH 011/208] Fix Logs View
---
src/player.jsx | 53 +++++++++++++++++++++++++-------------------------
1 file changed, 27 insertions(+), 26 deletions(-)
diff --git a/src/player.jsx b/src/player.jsx
index d04cb5e..6cc6741 100644
--- a/src/player.jsx
+++ b/src/player.jsx
@@ -608,32 +608,6 @@ export class Player extends React.Component {
this.awaitPacket(0);
}
- componentWillMount() {
- let term = new Term({
- cols: this.state.cols,
- rows: this.state.rows,
- screenKeys: true,
- useStyle: true
- });
-
- term.on('title', this.handleTitleChange);
-
- this.setState({ term: term });
-
- window.addEventListener("keydown", this.handleKeyDown, false);
- }
-
- componentDidMount() {
- if (this.refs.wrapper.offsetWidth) {
- this.setState({containerWidth: this.refs.wrapper.offsetWidth});
- }
- /* Open the terminal */
- this.state.term.open(this.refs.term);
- window.setInterval(this.sync, 100);
- /* Reset playback */
- this.reset();
- }
-
/* Subscribe for a packet at specified index */
awaitPacket(idx) {
this.buf.awaitPacket(idx).done(this.handlePacket)
@@ -954,6 +928,33 @@ export class Player extends React.Component {
this._transform();
}
+ componentWillMount() {
+ let term = new Term({
+ cols: this.state.cols,
+ rows: this.state.rows,
+ screenKeys: true,
+ useStyle: true
+ });
+
+ term.on('title', this.handleTitleChange);
+
+ this.setState({ term: term });
+
+ window.addEventListener("keydown", this.handleKeyDown, false);
+ }
+
+ componentDidMount() {
+ if (this.refs.wrapper.offsetWidth) {
+ this.setState({containerWidth: this.refs.wrapper.offsetWidth});
+ }
+ /* Open the terminal */
+ this.state.term.open(this.refs.term);
+ window.setInterval(this.sync, 100);
+ /* Reset playback */
+ this.reset();
+ this.fastForwardToTS(0);
+ }
+
componentWillUpdate(nextProps, nextState) {
/* If we changed pause state or speed exponent */
if (nextState.paused != this.state.paused ||
From b59140d328949543441ead52e9225b3c14591b21 Mon Sep 17 00:00:00 2001
From: Kyrylo Gliebov
Date: Tue, 25 Sep 2018 17:57:57 +0200
Subject: [PATCH 012/208] Fix Config forms
---
src/config.jsx | 237 +++++++++++++++++++++++++++++++++----------------
1 file changed, 160 insertions(+), 77 deletions(-)
diff --git a/src/config.jsx b/src/config.jsx
index 6367663..e3fbc6a 100644
--- a/src/config.jsx
+++ b/src/config.jsx
@@ -32,36 +32,72 @@
this.handleInputChange = this.handleInputChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.setConfig = this.setConfig.bind(this);
- this.prepareConfig = this.prepareConfig.bind(this);
this.fileReadFailed = this.fileReadFailed.bind(this);
+ this.readConfig = this.readConfig.bind(this);
this.file = null;
+ this.config = null;
this.state = {
- config: null,
- file_error: null,
+ config_loaded: false,
+ file_error: false,
submitting: "none",
+ shell: "",
+ notice: "",
+ latency: "",
+ payload: "",
+ log_input: false,
+ log_output: true,
+ log_window: true,
+ limit_rate: "",
+ limit_burst: "",
+ limit_action: "",
+ file_path: "",
+ syslog_facility: "",
+ syslog_priority: "",
+ journal_augment: "",
+ journal_priority: "",
+ writer: "",
};
}
handleInputChange(e) {
const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
const name = e.target.name;
- const config = this.state.config;
- config[name] = value;
-
- this.forceUpdate();
- }
-
- prepareConfig() {
- this.state.config.latency = parseInt(this.state.config.latency);
- if (this.state.config.input === true) {
- // log.input
- }
+ const state = {};
+ state[name] = value;
+ this.setState(state);
}
handleSubmit(event) {
this.setState({submitting:"block"});
- this.prepareConfig();
- this.file.replace(this.state.config).done(() => {
+ let config = {
+ shell: this.state.shell,
+ notice: this.state.notice,
+ latency: parseInt(this.state.latency),
+ payload: parseInt(this.state.payload),
+ log: {
+ input: this.state.log_input,
+ output: this.state.log_output,
+ window: this.state.log_window,
+ },
+ limit: {
+ rate: parseInt(this.state.limit_rate),
+ burst: parseInt(this.state.limit_burst),
+ action: this.state.limit_action,
+ },
+ file: {
+ path: this.state.file_path,
+ },
+ syslog: {
+ facility: this.state.syslog_facility,
+ priority: this.state.syslog_priority,
+ },
+ journal: {
+ priority: this.state.journal_priority,
+ augment: this.state.journal_augment
+ },
+ writer: this.state.writer
+ };
+ this.file.replace(config).done(() => {
this.setState({submitting:"none"});
})
.fail((error) => {
@@ -71,15 +107,47 @@
}
setConfig(data) {
- this.setState({config: data});
+ delete data.configuration;
+ delete data.args;
+ var flattenObject = function(ob) {
+ var toReturn = {};
+
+ for (var i in ob) {
+ if (!ob.hasOwnProperty(i)) continue;
+
+ if ((typeof ob[i]) == 'object') {
+ var flatObject = flattenObject(ob[i]);
+ for (var x in flatObject) {
+ if (!flatObject.hasOwnProperty(x)) continue;
+
+ toReturn[i + '_' + x] = flatObject[x];
+ }
+ } else {
+ toReturn[i] = ob[i];
+ }
+ }
+ return toReturn;
+ };
+ let state = flattenObject(data);
+ state.config_loaded = true;
+ this.setState(state);
}
- fileReadFailed(reason) {
- console.log(reason);
- this.setState({file_error: reason});
+ getConfig() {
+ let proc = cockpit.spawn(["tlog-rec-session", "--configuration"]);
+
+ proc.stream((data) => {
+ this.setConfig(json.parse(data, null, true));
+ proc.close();
+ });
+
+ proc.fail((fail) => {
+ console.log(fail);
+ this.readConfig();
+ });
}
- componentDidMount() {
+ readConfig() {
let parseFunc = function(data) {
return json.parse(data, null, true);
};
@@ -95,27 +163,37 @@
this.file = cockpit.file("/etc/tlog/tlog-rec-session.conf", {
syntax: syntax_object,
- // binary: boolean,
- // max_read_size: int,
superuser: true,
- // host: string
});
-
+ /*
let promise = this.file.read();
promise.done((data) => {
if (data === null) {
this.fileReadFailed();
- return;
}
- this.setConfig(data);
}).fail((data) => {
this.fileReadFailed(data);
});
+ */
+ }
+
+ fileReadFailed(reason) {
+ console.log(reason);
+ this.setState({file_error: reason});
+ }
+
+ componentWillMount() {
+ this.getConfig();
+ this.readConfig();
}
render() {
- if (this.state.config != null && this.state.file_error === null) {
+ if (this.state.config_loaded === false && this.state.file_error === false) {
+ return (
+ Loading
+ );
+ } else if (this.state.config_loaded === true && this.state.file_error === false) {
return (
+ date_since={this.state.date_since}
+ date_until={this.state.date_until}
+ username={this.state.username}
+ hostname={this.state.hostname}
+ list={this.state.recordingList}
+ diff_hosts={this.state.diff_hosts} />
);
} else {
From adc8159def37cd07e7a01ffa9d32ef86cd019cf1 Mon Sep 17 00:00:00 2001
From: Kyrylo Gliebov
Date: Mon, 1 Oct 2018 17:49:48 +0200
Subject: [PATCH 017/208] Add Hostname filter conditional rendering
---
src/recordings.jsx | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/recordings.jsx b/src/recordings.jsx
index 654758d..3942f74 100644
--- a/src/recordings.jsx
+++ b/src/recordings.jsx
@@ -957,15 +957,19 @@ class View extends React.Component {
onChange={this.handleInputChange} />
+ {this.state.diff_hosts === true &&
Hostname
+ }
+ {this.state.diff_hosts === true &&
+ }
Configuration
From c51610e22e292f1fdb5ebd67ccb6ca68949f76d1 Mon Sep 17 00:00:00 2001
From: Kyrylo Gliebov
Date: Tue, 2 Oct 2018 15:19:36 +0200
Subject: [PATCH 018/208] Datetimepicker refactoring
---
src/recordings.jsx | 79 ++++++++++++++++++----------------------------
1 file changed, 31 insertions(+), 48 deletions(-)
diff --git a/src/recordings.jsx b/src/recordings.jsx
index 3942f74..653bc1a 100644
--- a/src/recordings.jsx
+++ b/src/recordings.jsx
@@ -113,75 +113,51 @@ let parseDate = function(date) {
* A component representing a date & time picker based on bootstrap-datetime-picker.
* Requires jQuery, bootstrap-datetime-picker, moment.js
* Properties:
- * - onDateChange: function to call on date change event of datepicker.
- * - date: variable to pass which will be used as initial value.
+ * - 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.markDateField = this.markDateField.bind(this);
this.state = {
invalid: false,
- date: this.props.date,
- dateLastValid: null,
+ date: this.props.value,
};
}
componentDidMount() {
- let datepicker = $(this.refs.datepicker).datetimepicker({
+ $(this.refs.datepicker).datetimepicker({
format: 'yyyy-mm-dd hh:ii:00',
autoclose: true,
todayBtn: true,
- });
- datepicker.on('changeDate', (e) => {
- this.handleDateChange(e);
- });
+ })
+ .on('changeDate', this.handleDateChange);
+ // remove datepicker from input, so it only works by button press
$(this.refs.datepicker_input).datetimepicker('remove');
- this.markDateField();
}
componentWillUnmount() {
- $(this.textInput).datetimepicker('remove');
+ $(this.refs.datepicker).datetimepicker('remove');
}
- handleDateChange(e) {
- if (e.type === "changeDate") {
- let event = new Event('input', { bubbles: true });
- e.currentTarget.firstChild.dispatchEvent(event);
- }
-
- if (e.type === "input") {
- this.setState({date: e.target.value});
- if (parseDate(e.target.value)) {
- this.setState({dateLastValid: e.target.value});
- this.setState({invalid: false});
- this.props.onDateChange(e.target.value, e.target.value.trim());
- } else {
- this.setState({invalid: true});
- this.props.onDateChange(e.target.value, this.state.dateLastValid.trim());
- }
+ handleDateChange() {
+ const date = $(this.refs.datepicker_input).val()
+ .trim();
+ this.setState({invalid: false, date: date});
+ if (!parseDate(date)) {
+ this.setState({invalid: true});
+ } else {
+ this.props.onChange(date);
}
}
clearField() {
+ const date = "";
+ this.props.onChange(date);
+ this.setState({date: date, invalid: false});
$(this.refs.datepicker_input).val("");
- let event = new Event('input', { bubbles: true });
- this.refs.datepicker_input.dispatchEvent(event);
- this.handleDateChange(event);
- this.setState({invalid: false});
- }
-
- markDateField() {
- let date = $(this.refs.datepicker_input).val()
- .trim();
- if (!parseDate(date)) {
- this.setState({invalid: true});
- } else {
- this.setState({dateLastValid: date});
- this.setState({invalid: false});
- }
}
render() {
@@ -189,7 +165,7 @@ class Datetimepicker extends React.Component {
+ value={this.state.date} onChange={this.handleDateChange} />
@@ -198,8 +174,6 @@ class Datetimepicker extends React.Component {
}
}
-console.log(Datetimepicker);
-
function LogElement(props) {
const entry = props.entry;
const start = props.start;
@@ -659,6 +633,7 @@ class View extends React.Component {
this.handleInputChange = this.handleInputChange.bind(this);
this.handleTsChange = this.handleTsChange.bind(this);
this.handleLogTsChange = this.handleLogTsChange.bind(this);
+ this.handleDateSinceChange = this.handleDateSinceChange.bind(this);
/* Journalctl instance */
this.journalctl = null;
/* Recording ID journalctl instance is invoked with */
@@ -868,6 +843,14 @@ class View extends React.Component {
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}));
+ }
+
handleTsChange(ts) {
this.setState({curTs: ts});
}
@@ -940,13 +923,13 @@ class View extends React.Component {
Since
-
+
Until
-
+
Username
From 460b044720a3ba639019c07c56daa521095d2a11 Mon Sep 17 00:00:00 2001
From: Kyrylo Gliebov
Date: Tue, 2 Oct 2018 18:34:56 +0200
Subject: [PATCH 019/208] Simplify Hostname
---
src/recordings.jsx | 40 ++++++----------------------------------
1 file changed, 6 insertions(+), 34 deletions(-)
diff --git a/src/recordings.jsx b/src/recordings.jsx
index 653bc1a..b995614 100644
--- a/src/recordings.jsx
+++ b/src/recordings.jsx
@@ -379,9 +379,6 @@ class Recording extends React.Component {
constructor(props) {
super(props);
this.goBackToList = this.goBackToList.bind(this);
- this.getHostname = this.getHostname.bind(this);
- this.Hostname = this.Hostname.bind(this);
- this.hostname = null;
}
goBackToList() {
@@ -392,35 +389,6 @@ class Recording extends React.Component {
}
}
- getHostname() {
- cockpit.spawn(["hostname"], { err: "ignore" })
- .done(function(output) {
- this.hostname = $.trim(output);
- })
- .fail(function(ex) {
- console.log(ex);
- });
- }
-
- Hostname(props) {
- let style = {
- display: "none"
- };
- if (this.hostname != null && this.hostname != props.hostname) {
- style = {};
- }
- return (
-
- {_("Hostname")}
- {props.hostname}
-
- );
- }
-
- componentWillMount() {
- this.getHostname();
- }
-
render() {
let r = this.props.recording;
if (r == null) {
@@ -456,7 +424,10 @@ class Recording extends React.Component {
{_("ID")}
{r.id}
-
+
+ {_("Hostname")}
+ {r.hostname}
+
{_("Boot ID")}
{r.boot_id}
@@ -520,7 +491,7 @@ class RecordingList extends React.Component {
drawSortDir() {
$('#sort_arrow').remove();
let type = this.state.sorting_asc ? "asc" : "desc";
- let arrow = ' ';
+ let arrow = ' ';
$(this.refs[this.state.sorting_field]).append(arrow);
}
@@ -727,6 +698,7 @@ class View extends React.Component {
start: ts,
/* FIXME Should be start + message duration */
end: ts,
+ hostname: e["_HOSTNAME"],
duration: 0};
/* Map the recording */
this.recordingMap[id] = r;
From f69b9c18877fa54328c25edf8b49594315cc13f8 Mon Sep 17 00:00:00 2001
From: Kyrylo Gliebov
Date: Tue, 2 Oct 2018 19:05:29 +0200
Subject: [PATCH 020/208] Config page refactoring
---
src/config.html | 30 +-
src/config.jsx | 861 +++++++++++++++++++++++----------------------
src/index.html | 2 +-
src/recordings.jsx | 8 +-
4 files changed, 458 insertions(+), 443 deletions(-)
diff --git a/src/config.html b/src/config.html
index b0c78c0..1184fbd 100644
--- a/src/config.html
+++ b/src/config.html
@@ -30,34 +30,8 @@ along with Cockpit; If not, see .
-
-
-
-
-
-
-
General Configuration
-
-
-
-
-
-
-
+
+