Add console logs api and integrate it with UI (#90)
Uses same behavior as the Trace feature using websockets. For displaying it on the UI it needed to handle colors since the log message comes with unicode colors embbeded on the message. Also a special case when an error log comes needed to be handled to show all sources of the error.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -20,6 +20,7 @@
|
||||
"@types/superagent": "^4.1.4",
|
||||
"@types/webpack-env": "^1.14.1",
|
||||
"@types/websocket": "^1.0.0",
|
||||
"ansi-to-react": "^6.0.5",
|
||||
"codemirror": "^5.52.2",
|
||||
"history": "^4.10.1",
|
||||
"local-storage-fallback": "^4.1.1",
|
||||
|
||||
@@ -62,6 +62,7 @@ import ConfigurationsList from "./Configurations/ConfigurationPanels/Configurati
|
||||
import { Button, LinearProgress } from "@material-ui/core";
|
||||
import WebhookPanel from "./Configurations/ConfigurationPanels/WebhookPanel";
|
||||
import Trace from "./Trace/Trace";
|
||||
import Logs from "./Logs/Logs";
|
||||
|
||||
function Copyright() {
|
||||
return (
|
||||
@@ -304,6 +305,7 @@ class Console extends React.Component<
|
||||
<Route exact path="/webhook/logger" component={WebhookPanel} />
|
||||
<Route exact path="/webhook/audit" component={WebhookPanel} />
|
||||
<Route exct path="/trace" component={Trace} />
|
||||
<Route exct path="/logs" component={Logs} />
|
||||
<Route exact path="/">
|
||||
<Redirect to="/dashboard" />
|
||||
</Route>
|
||||
|
||||
315
portal-ui/src/screens/Console/Logs/Logs.tsx
Normal file
315
portal-ui/src/screens/Console/Logs/Logs.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
// This file is part of MinIO Console Server
|
||||
// Copyright (c) 2020 MinIO, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program 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 Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import React, { useEffect } from "react";
|
||||
import { IMessageEvent, w3cwebsocket as W3CWebSocket } from "websocket";
|
||||
import storage from "local-storage-fallback";
|
||||
import { AppState } from "../../../store";
|
||||
import { connect } from "react-redux";
|
||||
import { logMessageReceived, logResetMessages } from "./actions";
|
||||
import { LogMessage } from "./types";
|
||||
import { createStyles, Theme, withStyles } from "@material-ui/core/styles";
|
||||
import { niceBytes } from "../../../common/utils";
|
||||
import Ansi from "ansi-to-react";
|
||||
import { isNull, isNullOrUndefined } from "util";
|
||||
|
||||
const styles = (theme: Theme) =>
|
||||
createStyles({
|
||||
logList: {
|
||||
background: "white",
|
||||
maxHeight: "400px",
|
||||
overflow: "auto",
|
||||
"& ul": {
|
||||
margin: "4px",
|
||||
padding: "0px"
|
||||
},
|
||||
"& ul li": {
|
||||
listStyle: "none",
|
||||
margin: "0px",
|
||||
padding: "0px",
|
||||
borderBottom: "1px solid #dedede"
|
||||
}
|
||||
},
|
||||
tab: {
|
||||
padding: "25px"
|
||||
},
|
||||
ansiblue: {
|
||||
color: "blue"
|
||||
},
|
||||
logerror: {
|
||||
color: "red"
|
||||
},
|
||||
logerror_tab: {
|
||||
color: "red",
|
||||
padding: "25px"
|
||||
},
|
||||
ansidefault: {
|
||||
color: "black"
|
||||
},
|
||||
});
|
||||
|
||||
interface ILogs {
|
||||
classes: any;
|
||||
logMessageReceived: typeof logMessageReceived;
|
||||
logResetMessages: typeof logResetMessages;
|
||||
messages: LogMessage[];
|
||||
}
|
||||
|
||||
const Logs = ({
|
||||
classes,
|
||||
logMessageReceived,
|
||||
logResetMessages,
|
||||
messages
|
||||
}: ILogs) => {
|
||||
useEffect(() => {
|
||||
logResetMessages();
|
||||
const url = new URL(window.location.toString());
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const port = isDev ? "9090" : url.port;
|
||||
|
||||
const c = new W3CWebSocket(`ws://${url.hostname}:${port}/ws/console`);
|
||||
|
||||
let interval: any | null = null;
|
||||
if (c !== null) {
|
||||
c.onopen = () => {
|
||||
console.log("WebSocket Client Connected");
|
||||
c.send("ok");
|
||||
interval = setInterval(() => {
|
||||
c.send("ok");
|
||||
}, 10 * 1000);
|
||||
};
|
||||
c.onmessage = (message: IMessageEvent) => {
|
||||
// console.log(message.data.toString())
|
||||
let m: LogMessage = JSON.parse(message.data.toString());
|
||||
m.time = new Date(m.time.toString());
|
||||
m.key = Math.random();
|
||||
logMessageReceived(m);
|
||||
};
|
||||
c.onclose = () => {
|
||||
clearInterval(interval);
|
||||
console.log("connection closed by server");
|
||||
};
|
||||
return () => {
|
||||
c.close(1000);
|
||||
clearInterval(interval);
|
||||
console.log("closing websockets");
|
||||
};
|
||||
}
|
||||
}, [logMessageReceived]);
|
||||
|
||||
const timeFromdate = (d: Date) => {
|
||||
let h = d.getHours() < 10 ? `0${d.getHours()}` : `${d.getHours()}`;
|
||||
let m = d.getMinutes() < 10 ? `0${d.getMinutes()}` : `${d.getMinutes()}`;
|
||||
let s = d.getSeconds() < 10 ? `0${d.getSeconds()}` : `${d.getSeconds()}`;
|
||||
|
||||
return `${h}:${m}:${s}:${d.getMilliseconds()}`;
|
||||
};
|
||||
|
||||
// replaces a character of a string with other at a given index
|
||||
const replaceWeirdChar = (origString: string, replaceChar: string, index: number) => {
|
||||
let firstPart = origString.substr(0, index);
|
||||
let lastPart = origString.substr(index + 1);
|
||||
|
||||
let newString = firstPart + replaceChar + lastPart;
|
||||
return newString;
|
||||
};
|
||||
|
||||
|
||||
const colorify = (str: string) => {
|
||||
// matches strings starting like: `[34mEndpoint: [0m`
|
||||
const colorRegex = /(\[[0-9]+m)(.*?)(\[0+m)/g;
|
||||
let matches = colorRegex.exec(str)
|
||||
if (!isNullOrUndefined(matches)) {
|
||||
let start_color = matches[1];
|
||||
let text = matches[2];
|
||||
|
||||
if (start_color === "[34m") {
|
||||
return <span className={classes.ansiblue}>
|
||||
{text}
|
||||
</span>
|
||||
}
|
||||
if (start_color === "[1m") {
|
||||
return <span className={classes.ansidarkblue}>
|
||||
{text}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderError = (logElement: LogMessage) => {
|
||||
let errorElems = [];
|
||||
if (!isNullOrUndefined(logElement.error)) {
|
||||
if (logElement.api && logElement.api.name) {
|
||||
errorElems.push(
|
||||
<li key={`api-${logElement.key}`}>
|
||||
<span className={classes.logerror}>
|
||||
API: {logElement.api.name}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
if (logElement.time) {
|
||||
errorElems.push(
|
||||
<li key={`time-${logElement.key}`}>
|
||||
<span className={classes.logerror}>
|
||||
Time: {timeFromdate(logElement.time)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
if (logElement.deploymentid) {
|
||||
errorElems.push(
|
||||
<li key={`deploytmentid-${logElement.key}`}>
|
||||
<span className={classes.logerror}>
|
||||
DeploymentID: {logElement.deploymentid}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
if (logElement.requestID) {
|
||||
errorElems.push(
|
||||
<li key={`requestid-${logElement.key}`}>
|
||||
<span className={classes.logerror}>
|
||||
RequestID: {logElement.requestID}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
if (logElement.remotehost) {
|
||||
errorElems.push(
|
||||
<li key={`remotehost-${logElement.key}`}>
|
||||
<span className={classes.logerror}>
|
||||
RemoteHost: {logElement.remotehost}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
if (logElement.host) {
|
||||
errorElems.push(
|
||||
<li key={`host-${logElement.key}`}>
|
||||
<span className={classes.logerror}>
|
||||
Host: {logElement.host}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
if (logElement.userAgent) {
|
||||
errorElems.push(
|
||||
<li key={`useragent-${logElement.key}`}>
|
||||
<span className={classes.logerror}>
|
||||
UserAgent: {logElement.userAgent}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
if (logElement.error.message) {
|
||||
errorElems.push(
|
||||
<li key={`message-${logElement.key}`}>
|
||||
<span className={classes.logerror}>
|
||||
Error: {logElement.error.message}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
if (logElement.error.source) {
|
||||
// for all sources add padding
|
||||
for (let s in logElement.error.source) {
|
||||
errorElems.push(
|
||||
<li key={`source-${logElement.key}-${s}`}>
|
||||
<span className={classes.logerror_tab}>
|
||||
{logElement.error.source[s]}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errorElems
|
||||
};
|
||||
|
||||
const renderLog = (logElement: LogMessage) => {
|
||||
let logMessage = logElement.ConsoleMsg;
|
||||
// if somehow after the color code starts with unicode 10 = Line feed
|
||||
// delete it
|
||||
const regexInit = /(\[[0-9]+m)/g;
|
||||
let match = regexInit.exec(logMessage);
|
||||
if (match) {
|
||||
if (logMessage.slice(match[0].length).codePointAt(1) == 10) {
|
||||
logMessage = replaceWeirdChar(logMessage, "", match[0].length + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Select what to add color and what not to.
|
||||
const colorRegex = /(\[[0-9]+m)(.*?)(\[0+m)/g;
|
||||
let m = colorRegex.exec(logMessage);
|
||||
|
||||
// get substring if there was a match for to split what
|
||||
// is going to be colored and what not, here we add color
|
||||
// only to the first match.
|
||||
let substr = logMessage.slice(colorRegex.lastIndex);
|
||||
substr = substr.replace(regexInit, "");
|
||||
|
||||
// strClean used for corner case when string has unicode 32 for
|
||||
// space instead of normal space.
|
||||
let strClean = logMessage.replace(regexInit, "");
|
||||
// if starts with multiple spaces add padding
|
||||
if (strClean.startsWith(" ") || strClean.codePointAt(1) === 32) {
|
||||
return <li key={logElement.key}>
|
||||
<span className={classes.tab}>
|
||||
{colorify(logMessage)} {substr}
|
||||
</span>
|
||||
</li>
|
||||
} else if (!isNullOrUndefined(logElement.error)) {
|
||||
// list error message and all sources and error elems
|
||||
return (
|
||||
renderError(logElement)
|
||||
)
|
||||
} else {
|
||||
// for all remaining set default class
|
||||
return <li key={logElement.key}>
|
||||
<span className={classes.ansidefault}>
|
||||
{colorify(logMessage)} {substr}
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Logs</h1>
|
||||
<div className={classes.logList}>
|
||||
<ul>
|
||||
{messages.map(m => {
|
||||
return renderLog(m)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mapState = (state: AppState) => ({
|
||||
messages: state.logs.messages
|
||||
});
|
||||
|
||||
const connector = connect(mapState, {
|
||||
logMessageReceived: logMessageReceived,
|
||||
logResetMessages: logResetMessages
|
||||
});
|
||||
|
||||
export default connector(withStyles(styles)(Logs));
|
||||
|
||||
|
||||
|
||||
46
portal-ui/src/screens/Console/Logs/actions.ts
Normal file
46
portal-ui/src/screens/Console/Logs/actions.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// This file is part of MinIO Console Server
|
||||
// Copyright (c) 2020 MinIO, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program 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 Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { LogMessage } from "./types";
|
||||
|
||||
export const LOG_MESSAGE_RECEIVED = "LOG_MESSAGE_RECEIVED";
|
||||
export const LOG_RESET_MESSAGES = "LOG_RESET_MESSAGES";
|
||||
|
||||
interface LogMessageReceivedAction {
|
||||
type: typeof LOG_MESSAGE_RECEIVED;
|
||||
message: LogMessage;
|
||||
}
|
||||
|
||||
interface LogResetMessagesAction {
|
||||
type: typeof LOG_RESET_MESSAGES;
|
||||
}
|
||||
|
||||
export type LogActionTypes =
|
||||
| LogMessageReceivedAction
|
||||
| LogResetMessagesAction;
|
||||
|
||||
export function logMessageReceived(message: LogMessage) {
|
||||
return {
|
||||
type: LOG_MESSAGE_RECEIVED,
|
||||
message: message
|
||||
};
|
||||
}
|
||||
|
||||
export function logResetMessages() {
|
||||
return {
|
||||
type: LOG_RESET_MESSAGES
|
||||
};
|
||||
}
|
||||
50
portal-ui/src/screens/Console/Logs/reducers.ts
Normal file
50
portal-ui/src/screens/Console/Logs/reducers.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// This file is part of MinIO Console Server
|
||||
// Copyright (c) 2020 MinIO, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program 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 Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import {
|
||||
LOG_MESSAGE_RECEIVED,
|
||||
LOG_RESET_MESSAGES,
|
||||
LogActionTypes
|
||||
} from "./actions";
|
||||
import { LogMessage } from "./types";
|
||||
|
||||
export interface LogState {
|
||||
messages: LogMessage[];
|
||||
}
|
||||
|
||||
const initialState: LogState = {
|
||||
messages: []
|
||||
};
|
||||
|
||||
export function logReducer(
|
||||
state = initialState,
|
||||
action: LogActionTypes
|
||||
): LogState {
|
||||
switch (action.type) {
|
||||
case LOG_MESSAGE_RECEIVED:
|
||||
return {
|
||||
...state,
|
||||
messages: [...state.messages, action.message]
|
||||
};
|
||||
case LOG_RESET_MESSAGES:
|
||||
return {
|
||||
...state,
|
||||
messages: []
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
44
portal-ui/src/screens/Console/Logs/types.ts
Normal file
44
portal-ui/src/screens/Console/Logs/types.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// This file is part of MinIO Console Server
|
||||
// Copyright (c) 2020 MinIO, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program 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 Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
export interface logError {
|
||||
message: string;
|
||||
source: string[];
|
||||
}
|
||||
|
||||
export interface logErrorApiArgs {
|
||||
bucket: string;
|
||||
object: string;
|
||||
}
|
||||
|
||||
export interface logErrorApi {
|
||||
name: string;
|
||||
args: logErrorApiArgs;
|
||||
}
|
||||
|
||||
export interface LogMessage {
|
||||
remotehost: string;
|
||||
host: string;
|
||||
requestID: string;
|
||||
userAgent: string;
|
||||
message: string;
|
||||
api: logErrorApi;
|
||||
deploymentid: string;
|
||||
time: Date;
|
||||
error: logError;
|
||||
ConsoleMsg: string;
|
||||
key: number;
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
import React from "react";
|
||||
import ListItem from "@material-ui/core/ListItem";
|
||||
import ListItemIcon from "@material-ui/core/ListItemIcon";
|
||||
import WebAssetIcon from '@material-ui/icons/WebAsset';
|
||||
import ListItemText from "@material-ui/core/ListItemText";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { Divider, Typography, withStyles } from "@material-ui/core";
|
||||
@@ -157,6 +158,12 @@ class Menu extends React.Component<MenuProps> {
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Trace" />
|
||||
</ListItem>
|
||||
<ListItem button component={NavLink} to="/logs">
|
||||
<ListItemIcon>
|
||||
<WebAssetIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Console Logs" />
|
||||
</ListItem>
|
||||
<ListItem component={Typography}>Configuration</ListItem>
|
||||
<ListItem button component={NavLink} to="/notification-endpoints">
|
||||
<ListItemIcon>
|
||||
|
||||
@@ -21,7 +21,7 @@ import { connect } from "react-redux";
|
||||
import { traceMessageReceived, traceResetMessages } from "./actions";
|
||||
import { TraceMessage } from "./types";
|
||||
import { createStyles, Theme, withStyles } from "@material-ui/core/styles";
|
||||
import { niceBytes, setCookie } from "../../../common/utils";
|
||||
import { niceBytes } from "../../../common/utils";
|
||||
|
||||
const styles = (theme: Theme) =>
|
||||
createStyles({
|
||||
@@ -57,13 +57,10 @@ const Trace = ({
|
||||
}: ITrace) => {
|
||||
useEffect(() => {
|
||||
traceResetMessages();
|
||||
const token: string = storage.getItem("token")!;
|
||||
const url = new URL(window.location.toString());
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const port = isDev ? "9090" : url.port;
|
||||
|
||||
setCookie("token", token);
|
||||
|
||||
const c = new W3CWebSocket(`ws://${url.hostname}:${port}/ws/trace`);
|
||||
|
||||
let interval: any | null = null;
|
||||
|
||||
@@ -29,6 +29,7 @@ import { userLoggedIn } from "../../actions";
|
||||
import history from "../../history";
|
||||
import api from "../../common/api";
|
||||
import { ILoginDetails } from "./types";
|
||||
import { setCookie } from "../../common/utils";
|
||||
|
||||
const styles = (theme: Theme) =>
|
||||
createStyles({
|
||||
@@ -153,6 +154,7 @@ class Login extends React.Component<ILoginProps, ILoginState> {
|
||||
|
||||
if (bodyResponse.sessionId) {
|
||||
// store the jwt token
|
||||
setCookie("token", bodyResponse.sessionId);
|
||||
storage.setItem("token", bodyResponse.sessionId);
|
||||
//return res.body.sessionId;
|
||||
} else if (bodyResponse.error) {
|
||||
|
||||
@@ -18,10 +18,12 @@ import { applyMiddleware, combineReducers, compose, createStore } from "redux";
|
||||
import thunk from "redux-thunk";
|
||||
import { systemReducer } from "./reducer";
|
||||
import { traceReducer } from "./screens/Console/Trace/reducers";
|
||||
import { logReducer } from "./screens/Console/Logs/reducers";
|
||||
|
||||
const globalReducer = combineReducers({
|
||||
system: systemReducer,
|
||||
trace: traceReducer
|
||||
trace: traceReducer,
|
||||
logs: logReducer
|
||||
});
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -17,9 +17,10 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react"
|
||||
"jsx": "react",
|
||||
"downlevelIteration": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2027,6 +2027,11 @@ alphanum-sort@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3"
|
||||
integrity sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=
|
||||
|
||||
anser@^1.4.1:
|
||||
version "1.4.9"
|
||||
resolved "https://registry.yarnpkg.com/anser/-/anser-1.4.9.tgz#1f85423a5dcf8da4631a341665ff675b96845760"
|
||||
integrity sha512-AI+BjTeGt2+WFk4eWcqbQ7snZpDBt8SaLlj0RT2h5xfdWaiy51OjYvqwMrNzJLGy8iOAL6nKDITWO+rd4MkYEA==
|
||||
|
||||
ansi-align@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-2.0.0.tgz#c36aeccba563b89ceb556f3690f0b1d9e3547f7f"
|
||||
@@ -2096,6 +2101,14 @@ ansi-styles@^4.1.0:
|
||||
"@types/color-name" "^1.1.1"
|
||||
color-convert "^2.0.1"
|
||||
|
||||
ansi-to-react@^6.0.5:
|
||||
version "6.0.5"
|
||||
resolved "https://registry.yarnpkg.com/ansi-to-react/-/ansi-to-react-6.0.5.tgz#b41d4e91dbbf847a373368cfa517f337399aef9b"
|
||||
integrity sha512-kVKsflDGTJP6CnKOrgt7YQGnCurcf4q0rapWTNmx5KcIvgz+wHqjUIkfNmLQcJM6ZeHD0epbSOz0QOLg7fbVmg==
|
||||
dependencies:
|
||||
anser "^1.4.1"
|
||||
escape-carriage "^1.3.0"
|
||||
|
||||
ansicolors@~0.3.2:
|
||||
version "0.3.2"
|
||||
resolved "https://registry.yarnpkg.com/ansicolors/-/ansicolors-0.3.2.tgz#665597de86a9ffe3aa9bfbe6cae5c6ea426b4979"
|
||||
@@ -4496,6 +4509,11 @@ es6-symbol@^3.1.1, es6-symbol@~3.1.3:
|
||||
d "^1.0.1"
|
||||
ext "^1.1.2"
|
||||
|
||||
escape-carriage@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/escape-carriage/-/escape-carriage-1.3.0.tgz#71006b2d4da8cb6828686addafcb094239c742f3"
|
||||
integrity sha512-ATWi5MD8QlAGQOeMgI8zTp671BG8aKvAC0M7yenlxU4CRLGO/sKthxVUyjiOFKjHdIo+6dZZUNFgHFeVEaKfGQ==
|
||||
|
||||
escape-html@~1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
|
||||
|
||||
147
restapi/admin_console.go
Normal file
147
restapi/admin_console.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// This file is part of MinIO Console Server
|
||||
// Copyright (c) 2020 MinIO, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program 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 Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package restapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/minio/minio/pkg/madmin"
|
||||
)
|
||||
|
||||
const logTimeFormat string = "15:04:05 MST 01/02/2006"
|
||||
|
||||
// startConsoleLog starts log of the servers
|
||||
// by first setting a websocket reader that will
|
||||
// check for a heartbeat.
|
||||
//
|
||||
// A WaitGroup is used to handle goroutines and to ensure
|
||||
// all finish in the proper order. If any, sendConsoleLogInfo()
|
||||
// or wsReadCheck() returns, trace should end.
|
||||
func startConsoleLog(conn WSConn, client MinioAdmin) (mError error) {
|
||||
// a WaitGroup waits for a collection of goroutines to finish
|
||||
wg := sync.WaitGroup{}
|
||||
// a cancel context is needed to end all goroutines used
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Set number of goroutines to wait. wg.Wait()
|
||||
// waitsuntil counter is zero (all are done)
|
||||
wg.Add(3)
|
||||
// start go routine for reading websocket heartbeat
|
||||
readErr := wsReadCheck(ctx, &wg, conn)
|
||||
// send Stream of Console Log Info to the ws c.connection
|
||||
logCh := sendConsoleLogInfo(ctx, &wg, conn, client)
|
||||
// If wsReadCheck returns it means that it is not possible to check
|
||||
// ws heartbeat anymore so we stop from doing Console Log, cancel context
|
||||
// for all goroutines.
|
||||
go func(wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
if err := <-readErr; err != nil {
|
||||
log.Println("error on wsReadCheck:", err)
|
||||
mError = err
|
||||
}
|
||||
// cancel context for all goroutines.
|
||||
cancel()
|
||||
}(&wg)
|
||||
|
||||
// get logCh err on finish
|
||||
if err := <-logCh; err != nil {
|
||||
mError = err
|
||||
}
|
||||
|
||||
// if logCh closes for any reason,
|
||||
// cancel context for all goroutines
|
||||
cancel()
|
||||
// wait all goroutines to finish
|
||||
wg.Wait()
|
||||
return mError
|
||||
}
|
||||
|
||||
// sendlogInfo sends stream of Console Log Info to the ws connection
|
||||
func sendConsoleLogInfo(ctx context.Context, wg *sync.WaitGroup, conn WSConn, client MinioAdmin) <-chan error {
|
||||
// decrements the WaitGroup counter
|
||||
// by one when the function returns
|
||||
defer wg.Done()
|
||||
ch := make(chan error)
|
||||
go func(ch chan<- error) {
|
||||
defer close(ch)
|
||||
|
||||
// TODO: accept parameters as variables
|
||||
// name of node, default = "" (all)
|
||||
node := ""
|
||||
// number of log lines
|
||||
lineCount := 100
|
||||
// type of logs "minio"|"application"|"all" default = "all"
|
||||
logKind := "all"
|
||||
// Start listening on all Console Log activity.
|
||||
logCh := client.getLogs(ctx, node, lineCount, logKind)
|
||||
|
||||
for logInfo := range logCh {
|
||||
if logInfo.Err != nil {
|
||||
log.Println("error on console logs:", logInfo.Err)
|
||||
ch <- logInfo.Err
|
||||
return
|
||||
}
|
||||
|
||||
// Serialize message to be sent
|
||||
bytes, err := json.Marshal(serializeConsoleLogInfo(&logInfo))
|
||||
if err != nil {
|
||||
fmt.Println("error on json.Marshal:", err)
|
||||
ch <- err
|
||||
return
|
||||
}
|
||||
|
||||
// Send Message through websocket connection
|
||||
err = conn.writeMessage(websocket.TextMessage, bytes)
|
||||
if err != nil {
|
||||
log.Println("error writeMessage:", err)
|
||||
ch <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}(ch)
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
func serializeConsoleLogInfo(l *madmin.LogInfo) (logInfo madmin.LogInfo) {
|
||||
logInfo = *l
|
||||
if logInfo.ConsoleMsg != "" {
|
||||
if strings.HasPrefix(logInfo.ConsoleMsg, "\n") {
|
||||
logInfo.ConsoleMsg = strings.TrimPrefix(logInfo.ConsoleMsg, "\n")
|
||||
}
|
||||
}
|
||||
if logInfo.Time != "" {
|
||||
logInfo.Time = getLogTime(logInfo.Time)
|
||||
}
|
||||
return logInfo
|
||||
}
|
||||
|
||||
func getLogTime(lt string) string {
|
||||
tm, err := time.Parse(time.RFC3339Nano, lt)
|
||||
if err != nil {
|
||||
return lt
|
||||
}
|
||||
return tm.Format(logTimeFormat)
|
||||
}
|
||||
168
restapi/admin_console_test.go
Normal file
168
restapi/admin_console_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
// This file is part of MinIO Console Server
|
||||
// Copyright (c) 2020 MinIO, Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program 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 Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package restapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/minio/minio/pkg/madmin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// assigning mock at runtime instead of compile time
|
||||
var minioGetLogsMock func(ctx context.Context, node string, lineCnt int, logKind string) <-chan madmin.LogInfo
|
||||
|
||||
// mock function of listPolicies()
|
||||
func (ac adminClientMock) getLogs(ctx context.Context, node string, lineCnt int, logKind string) <-chan madmin.LogInfo {
|
||||
return minioGetLogsMock(ctx, node, lineCnt, logKind)
|
||||
}
|
||||
|
||||
func TestAdminConsoleLog(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
adminClient := adminClientMock{}
|
||||
mockWSConn := mockConn{}
|
||||
wsClientMock := wsClientMock{madmin: adminClient}
|
||||
function := "startConsoleLog()"
|
||||
|
||||
testReceiver := make(chan madmin.LogInfo, 5)
|
||||
textToReceive := "test message"
|
||||
testStreamSize := 5
|
||||
|
||||
// Test-1: Serve Console with no errors until Console finishes sending
|
||||
// define mock function behavior for minio server Console
|
||||
minioGetLogsMock = func(ctx context.Context, node string, lineCnt int, logKind string) <-chan madmin.LogInfo {
|
||||
ch := make(chan madmin.LogInfo)
|
||||
// Only success, start a routine to start reading line by line.
|
||||
go func(ch chan<- madmin.LogInfo) {
|
||||
defer close(ch)
|
||||
lines := make([]int, testStreamSize)
|
||||
// mocking sending 5 lines of info
|
||||
for range lines {
|
||||
info := madmin.LogInfo{
|
||||
ConsoleMsg: textToReceive,
|
||||
}
|
||||
ch <- info
|
||||
}
|
||||
}(ch)
|
||||
return ch
|
||||
}
|
||||
// mock function of conn.ReadMessage(), no error on read
|
||||
connReadMessageMock = func() (messageType int, p []byte, err error) {
|
||||
return 0, []byte{}, nil
|
||||
}
|
||||
writesCount := 1
|
||||
// mock connection WriteMessage() no error
|
||||
connWriteMessageMock = func(messageType int, data []byte) error {
|
||||
// emulate that receiver gets the message written
|
||||
var t madmin.LogInfo
|
||||
_ = json.Unmarshal(data, &t)
|
||||
if writesCount == testStreamSize {
|
||||
// for testing we need to close the receiver channel
|
||||
close(testReceiver)
|
||||
return nil
|
||||
}
|
||||
testReceiver <- t
|
||||
writesCount++
|
||||
return nil
|
||||
}
|
||||
if err := startConsoleLog(mockWSConn, wsClientMock.madmin); err != nil {
|
||||
t.Errorf("Failed on %s:, error occurred: %s", function, err.Error())
|
||||
}
|
||||
// check that the TestReceiver got the same number of data from Console.
|
||||
for i := range testReceiver {
|
||||
assert.Equal(textToReceive, i.ConsoleMsg)
|
||||
}
|
||||
|
||||
// Test-2: if error happens while writing, return error
|
||||
connWriteMessageMock = func(messageType int, data []byte) error {
|
||||
return fmt.Errorf("error on write")
|
||||
}
|
||||
if err := startConsoleLog(mockWSConn, wsClientMock.madmin); assert.Error(err) {
|
||||
assert.Equal("error on write", err.Error())
|
||||
}
|
||||
|
||||
// Test-3: error happens while reading, unexpected Close Error should return error.
|
||||
connWriteMessageMock = func(messageType int, data []byte) error {
|
||||
return nil
|
||||
}
|
||||
// mock function of conn.ReadMessage(), returns unexpected Close Error CloseAbnormalClosure
|
||||
connReadMessageMock = func() (messageType int, p []byte, err error) {
|
||||
return 0, []byte{}, &websocket.CloseError{Code: websocket.CloseAbnormalClosure, Text: ""}
|
||||
}
|
||||
if err := startConsoleLog(mockWSConn, wsClientMock.madmin); assert.Error(err) {
|
||||
assert.Equal("websocket: close 1006 (abnormal closure)", err.Error())
|
||||
}
|
||||
|
||||
// Test-4: error happens while reading, expected Close Error NormalClosure
|
||||
// expected Close Error should not return an error, just end Console.
|
||||
connReadMessageMock = func() (messageType int, p []byte, err error) {
|
||||
return 0, []byte{}, &websocket.CloseError{Code: websocket.CloseNormalClosure, Text: ""}
|
||||
}
|
||||
if err := startConsoleLog(mockWSConn, wsClientMock.madmin); err != nil {
|
||||
t.Errorf("Failed on %s:, error occurred: %s", function, err.Error())
|
||||
}
|
||||
|
||||
// Test-5: error happens while reading, expected Close Error CloseGoingAway
|
||||
// expected Close Error should not return an error, just return.
|
||||
connReadMessageMock = func() (messageType int, p []byte, err error) {
|
||||
return 0, []byte{}, &websocket.CloseError{Code: websocket.CloseGoingAway, Text: ""}
|
||||
}
|
||||
if err := startConsoleLog(mockWSConn, wsClientMock.madmin); err != nil {
|
||||
t.Errorf("Failed on %s:, error occurred: %s", function, err.Error())
|
||||
}
|
||||
|
||||
// Test-6: error happens while reading, non Close Error Type should be returned as
|
||||
// error
|
||||
connReadMessageMock = func() (messageType int, p []byte, err error) {
|
||||
return 0, []byte{}, fmt.Errorf("error on read")
|
||||
}
|
||||
if err := startConsoleLog(mockWSConn, wsClientMock.madmin); assert.Error(err) {
|
||||
assert.Equal("error on read", err.Error())
|
||||
}
|
||||
|
||||
// Test-7: error happens on GetLogs Minio, Console should stop
|
||||
// and error shall be returned.
|
||||
minioGetLogsMock = func(ctx context.Context, node string, lineCnt int, logKind string) <-chan madmin.LogInfo {
|
||||
ch := make(chan madmin.LogInfo)
|
||||
// Only success, start a routine to start reading line by line.
|
||||
go func(ch chan<- madmin.LogInfo) {
|
||||
defer close(ch)
|
||||
lines := make([]int, 2)
|
||||
// mocking sending 5 lines of info
|
||||
for range lines {
|
||||
info := madmin.LogInfo{
|
||||
ConsoleMsg: textToReceive,
|
||||
}
|
||||
ch <- info
|
||||
}
|
||||
ch <- madmin.LogInfo{Err: fmt.Errorf("error on Console")}
|
||||
}(ch)
|
||||
return ch
|
||||
}
|
||||
// mock function of conn.ReadMessage(), no error on read, should stay unless
|
||||
// context is done.
|
||||
connReadMessageMock = func() (messageType int, p []byte, err error) {
|
||||
return 0, []byte{}, nil
|
||||
}
|
||||
if err := startConsoleLog(mockWSConn, wsClientMock.madmin); assert.Error(err) {
|
||||
assert.Equal("error on Console", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -50,37 +49,6 @@ type callStats struct {
|
||||
Ttfb string `json:"timeToFirstByte"`
|
||||
}
|
||||
|
||||
// trace serves madmin.ServiceTraceInfo
|
||||
// on a Websocket connection.
|
||||
func (wsc *wsClient) trace() {
|
||||
defer func() {
|
||||
log.Println("trace stopped")
|
||||
// close connection after return
|
||||
wsc.conn.close()
|
||||
}()
|
||||
log.Println("trace started")
|
||||
|
||||
err := startTraceInfo(wsc.conn, wsc.madmin)
|
||||
// Send Connection Close Message indicating the Status Code
|
||||
// see https://tools.ietf.org/html/rfc6455#page-45
|
||||
if err != nil {
|
||||
// If connection exceeded read deadline send Close
|
||||
// Message Policy Violation code since we don't want
|
||||
// to let the receiver figure out the read deadline.
|
||||
// This is a generic code designed if there is a
|
||||
// need to hide specific details about the policy.
|
||||
if nErr, ok := err.(net.Error); ok && nErr.Timeout() {
|
||||
wsc.conn.writeMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.ClosePolicyViolation, ""))
|
||||
return
|
||||
}
|
||||
// else, internal server error
|
||||
wsc.conn.writeMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseInternalServerErr, err.Error()))
|
||||
return
|
||||
}
|
||||
// normal closure
|
||||
wsc.conn.writeMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
}
|
||||
|
||||
// startTraceInfo starts trace of the servers
|
||||
// by first setting a websocket reader that will
|
||||
// check for a heartbeat.
|
||||
@@ -115,7 +83,7 @@ func startTraceInfo(conn WSConn, client MinioAdmin) (mError error) {
|
||||
cancel()
|
||||
}(&wg)
|
||||
|
||||
// wait for traceCh to finish
|
||||
// get traceCh error on finish
|
||||
if err := <-traceCh; err != nil {
|
||||
mError = err
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ type MinioAdmin interface {
|
||||
startProfiling(ctx context.Context, profiler madmin.ProfilerType) ([]madmin.StartProfilingResult, error)
|
||||
stopProfiling(ctx context.Context) (io.ReadCloser, error)
|
||||
serviceTrace(ctx context.Context, allTrace, errTrace bool) <-chan madmin.ServiceTraceInfo
|
||||
getLogs(ctx context.Context, node string, lineCnt int, logKind string) <-chan madmin.LogInfo
|
||||
// Service Accounts
|
||||
addServiceAccount(ctx context.Context, policy *iampolicy.Policy) (mauth.Credentials, error)
|
||||
listServiceAccounts(ctx context.Context) (madmin.ListServiceAccountsResp, error)
|
||||
@@ -205,6 +206,11 @@ func (ac adminClient) serviceTrace(ctx context.Context, allTrace, errTrace bool)
|
||||
return ac.client.ServiceTrace(ctx, allTrace, errTrace)
|
||||
}
|
||||
|
||||
// implements madmin.GetLogs()
|
||||
func (ac adminClient) getLogs(ctx context.Context, node string, lineCnt int, logKind string) <-chan madmin.LogInfo {
|
||||
return ac.client.GetLogs(ctx, node, lineCnt, logKind)
|
||||
}
|
||||
|
||||
// implements madmin.AddServiceAccount()
|
||||
func (ac adminClient) addServiceAccount(ctx context.Context, policy *iampolicy.Policy) (mauth.Credentials, error) {
|
||||
return ac.client.AddServiceAccount(ctx, policy)
|
||||
|
||||
@@ -19,6 +19,7 @@ package restapi
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -149,6 +150,8 @@ func serveWS(w http.ResponseWriter, req *http.Request) {
|
||||
switch strings.TrimPrefix(req.URL.Path, wsBasePath) {
|
||||
case "/trace":
|
||||
go wsClient.trace()
|
||||
case "/console":
|
||||
go wsClient.console()
|
||||
default:
|
||||
// path not found
|
||||
conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
@@ -205,3 +208,65 @@ func wsReadCheck(ctx context.Context, wg *sync.WaitGroup, conn WSConn) chan erro
|
||||
}(ch)
|
||||
return ch
|
||||
}
|
||||
|
||||
// trace serves madmin.ServiceTraceInfo
|
||||
// on a Websocket connection.
|
||||
func (wsc *wsClient) trace() {
|
||||
defer func() {
|
||||
log.Println("trace stopped")
|
||||
// close connection after return
|
||||
wsc.conn.close()
|
||||
}()
|
||||
log.Println("trace started")
|
||||
|
||||
err := startTraceInfo(wsc.conn, wsc.madmin)
|
||||
// Send Connection Close Message indicating the Status Code
|
||||
// see https://tools.ietf.org/html/rfc6455#page-45
|
||||
if err != nil {
|
||||
// If connection exceeded read deadline send Close
|
||||
// Message Policy Violation code since we don't want
|
||||
// to let the receiver figure out the read deadline.
|
||||
// This is a generic code designed if there is a
|
||||
// need to hide specific details about the policy.
|
||||
if nErr, ok := err.(net.Error); ok && nErr.Timeout() {
|
||||
wsc.conn.writeMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.ClosePolicyViolation, ""))
|
||||
return
|
||||
}
|
||||
// else, internal server error
|
||||
wsc.conn.writeMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseInternalServerErr, err.Error()))
|
||||
return
|
||||
}
|
||||
// normal closure
|
||||
wsc.conn.writeMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
}
|
||||
|
||||
// console serves madmin.GetLogs
|
||||
// on a Websocket connection.
|
||||
func (wsc *wsClient) console() {
|
||||
defer func() {
|
||||
log.Println("console logs stopped")
|
||||
// close connection after return
|
||||
wsc.conn.close()
|
||||
}()
|
||||
log.Println("console logs started")
|
||||
|
||||
err := startConsoleLog(wsc.conn, wsc.madmin)
|
||||
// Send Connection Close Message indicating the Status Code
|
||||
// see https://tools.ietf.org/html/rfc6455#page-45
|
||||
if err != nil {
|
||||
// If connection exceeded read deadline send Close
|
||||
// Message Policy Violation code since we don't want
|
||||
// to let the receiver figure out the read deadline.
|
||||
// This is a generic code designed if there is a
|
||||
// need to hide specific details about the policy.
|
||||
if nErr, ok := err.(net.Error); ok && nErr.Timeout() {
|
||||
wsc.conn.writeMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.ClosePolicyViolation, ""))
|
||||
return
|
||||
}
|
||||
// else, internal server error
|
||||
wsc.conn.writeMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseInternalServerErr, err.Error()))
|
||||
return
|
||||
}
|
||||
// normal closure
|
||||
wsc.conn.writeMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user