+
+
+
+
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 (
+
+
+ );
+ }
+ }
+});
+
+/* 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 = (
+
+ );
+ },
+});
+
+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 @@
+