/* * 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"; import ReactDOM from "react-dom"; let $ = require("jquery"); let cockpit = require("cockpit"); let _ = cockpit.gettext; let moment = require("moment"); let Journal = require("journal"); let Listing = require("cockpit-components-listing.jsx"); let Player = require("./player.jsx"); require("bootstrap-datetime-picker/js/bootstrap-datetimepicker.js"); require("bootstrap-datetime-picker/css/bootstrap-datetimepicker.css"); /* * Convert a number to integer number string and pad with zeroes to * specified width. */ let padInt = function (n, w) { let i = Math.floor(n); let a = Math.abs(i); let s = a.toString(); for (w -= s.length; w > 0; w--) { s = '0' + s; } return ((i < 0) ? '-' : '') + s; }; /* * Format date and time for a number of milliseconds since Epoch. */ let formatDateTime = function (ms) { return moment(ms).format("YYYY-MM-DD HH:mm:ss"); }; let formatDateTimeOffset = function (ms, offset) { return moment(ms).utcOffset(offset) .format("YYYY-MM-DD HH:mm:ss"); }; let formatUTC = function(date) { return moment(date).utc() .format("YYYY-MM-DD HH:mm:ss") + " UTC"; }; /* * Format a time interval from a number of milliseconds. */ let formatDuration = function (ms) { let v = Math.floor(ms / 1000); let s = Math.floor(v % 60); v = Math.floor(v / 60); let m = Math.floor(v % 60); v = Math.floor(v / 60); let h = Math.floor(v % 24); let d = Math.floor(v / 24); let str = ''; if (d > 0) { str += d + ' ' + _("days") + ' '; } if (h > 0 || str.length > 0) { str += padInt(h, 2) + ':'; } str += padInt(m, 2) + ':' + padInt(s, 2); return (ms < 0 ? '-' : '') + str; }; let parseDate = function(date) { let regex = new RegExp(/^\s*(\d\d\d\d-\d\d-\d\d)(\s+(\d\d:\d\d(:\d\d)?))?\s*$/); let captures = regex.exec(date); if (captures != null) { let date = captures[1]; if (captures[3]) { date = date + " " + captures[3]; } if (moment(date, ["YYYY-M-D H:m:s", "YYYY-M-D H:m", "YYYY-M-D"], true).isValid()) { return date; } } if (date === "" || date === null) { return true; } return false; }; /* * A component representing a date & time picker based on bootstrap-datetime-picker. * Requires jQuery, bootstrap-datetime-picker, moment.js * Properties: * - onChange: function to call on date change event of datepicker. * - value: variable to pass which will be used as initial value. */ class Datetimepicker extends React.Component { constructor(props) { super(props); this.handleDateChange = this.handleDateChange.bind(this); this.clearField = this.clearField.bind(this); this.state = { invalid: false, date: this.props.value, }; } componentDidMount() { $(this.refs.datepicker).datetimepicker({ format: 'yyyy-mm-dd hh:ii:00', autoclose: true, todayBtn: true, }) .on('changeDate', this.handleDateChange); // remove datepicker from input, so it only works by button press $(this.refs.datepicker_input).datetimepicker('remove'); } componentWillUnmount() { $(this.refs.datepicker).datetimepicker('remove'); } handleDateChange() { const date = $(this.refs.datepicker_input).val() .trim(); this.setState({invalid: false, date: date}); if (!parseDate(date)) { this.setState({invalid: true}); } else { this.props.onChange(date); } } clearField() { const date = ""; this.props.onChange(date); this.setState({date: date, invalid: false}); $(this.refs.datepicker_input).val(""); } render() { return (
); } } function LogElement(props) { const entry = props.entry; const start = props.start; const end = props.end; const cursor = entry.__CURSOR; const entry_timestamp = parseInt(entry.__REALTIME_TIMESTAMP / 1000); const timeClick = function(e) { const ts = entry_timestamp - start; if (ts > 0) { props.jumpToTs(ts); } else { props.jumpToTs(0); } }; const messageClick = () => { const url = '/system/logs#/' + cursor + '?parent_options={}'; const win = window.open(url, '_blank'); win.focus(); }; let className = 'cockpit-logline'; if (start < entry_timestamp && end > entry_timestamp) { className = 'cockpit-logline highlighted'; } return (
{formatDateTime(entry_timestamp)}
{entry.MESSAGE}
); } function LogsView(props) { const entries = props.entries; const start = props.start; const end = props.end; const rows = entries.map((entry) => ); return (
{rows}
); } class Logs extends React.Component { constructor(props) { super(props); this.journalctlError = this.journalctlError.bind(this); this.journalctlIngest = this.journalctlIngest.bind(this); this.journalctlPrepend = this.journalctlPrepend.bind(this); this.getLogs = this.getLogs.bind(this); this.loadLater = this.loadLater.bind(this); this.loadForTs = this.loadForTs.bind(this); this.getServerTimeOffset = this.getServerTimeOffset.bind(this); this.journalCtl = null; this.entries = []; this.start = null; this.end = null; this.hostname = null; this.state = { serverTimeOffset: null, cursor: null, after: null, entries: [], }; } getServerTimeOffset() { cockpit.spawn(["date", "+%s:%:z"], { err: "message" }) .done((data) => { this.setState({serverTimeOffset: data.slice(data.indexOf(":") + 1)}); }) .fail((ex) => { console.log("Couldn't calculate server time offset: " + cockpit.message(ex)); }); } scrollToTop() { const logs_view = document.getElementById("logs-view"); logs_view.scrollTop = 0; } scrollToBottom() { const logs_view = document.getElementById("logs-view"); logs_view.scrollTop = logs_view.scrollHeight; } journalctlError(error) { console.warn(cockpit.message(error)); } journalctlIngest(entryList) { if (entryList.length > 0) { this.entries.push(...entryList); const after = this.entries[this.entries.length - 1].__CURSOR; this.setState({entries: this.entries, after: after}); this.scrollToBottom(); } } journalctlPrepend(entryList) { entryList.push(...this.entries); this.setState({entries: this.entries}); } getLogs() { if (this.start != null && this.end != null) { if (this.journalCtl != null) { this.journalCtl.stop(); this.journalCtl = null; } let matches = []; if (this.hostname) { matches.push("_HOSTNAME=" + this.hostname); } let start = null; let end = null; if (this.state.serverTimeOffset != null) { start = formatDateTimeOffset(this.start, this.state.serverTimeOffset); end = formatDateTimeOffset(this.end, this.state.serverTimeOffset); } else { start = formatDateTime(this.start); end = formatDateTime(this.end); } let options = { since: start, until: end, follow: false, count: "all", merge: true, }; if (this.state.after != null) { options["after"] = this.state.after; delete options.since; } const self = this; this.journalCtl = Journal.journalctl(matches, options) .fail(this.journalctlError) .done(function(data) { self.journalctlIngest(data); }); } } loadLater() { this.start = this.end; this.end = this.end + 3600; this.getLogs(); } loadForTs(ts) { this.end = this.start + ts; this.getLogs(); } componentDidMount() { this.getServerTimeOffset(); } componentDidUpdate() { if (this.props.recording) { if (this.start === null && this.end === null) { this.end = this.props.recording.start + 3600; this.start = this.props.recording.start; } if (this.props.recording.hostname) { this.hostname = this.props.recording.hostname; } this.getLogs(); } if (this.props.curTs) { const ts = this.props.curTs; this.loadForTs(ts); } } componentWillUnmount() { this.journalCtl.stop(); this.setState({ serverTimeOffset: null, cursor: null, after: null, entries: [], }); } render() { let r = this.props.recording; if (r == null) { return Loading...; } else { return (
{_("Logs")}
); } } } /* * A component representing a single recording view. * Properties: * - recording: either null for no recording data available yet, or a * recording object, as created by the View below. */ class Recording extends React.Component { constructor(props) { super(props); this.goBackToList = this.goBackToList.bind(this); this.handleTsChange = this.handleTsChange.bind(this); this.handleLogTsChange = this.handleLogTsChange.bind(this); this.handleLogsClick = this.handleLogsClick.bind(this); this.handleLogsReset = this.handleLogsReset.bind(this); this.state = { curTs: null, logsTs: null, logsEnabled: false, }; } handleTsChange(ts) { this.setState({curTs: ts}); } handleLogTsChange(ts) { this.setState({logsTs: ts}); } handleLogsClick() { this.setState({logsEnabled: !this.state.logsEnabled}); } handleLogsReset() { this.setState({logsEnabled: false}, () => { this.setState({logsEnabled: true}); }); } goBackToList() { if (cockpit.location.path[0]) { if ("search_rec" in cockpit.location.options) { delete cockpit.location.options.search_rec; } cockpit.location.go([], cockpit.location.options); } else { cockpit.location.go('/'); } } render() { let r = this.props.recording; if (r == null) { return Loading...; } else { let player = (); return (
  1. {_("Session Recording")}
  2. {_("Session")}
{player}
{this.state.logsEnabled === 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 = '