Files
object-browser/portal-ui/src/screens/Console/Logs/LogSearch/LogsSearchMain.tsx
Alex 684f089f61 Added loading validation to avoid flashing empty components (#1177)
Signed-off-by: Benjamin Perez <benjamin@bexsoft.net>

Co-authored-by: Benjamin Perez <benjamin@bexsoft.net>
2021-11-01 20:46:45 -07:00

486 lines
15 KiB
TypeScript

// This file is part of MinIO Console Server
// Copyright (c) 2021 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, { Fragment, useState, useEffect, useCallback } from "react";
import { connect } from "react-redux";
import get from "lodash/get";
import { Theme } from "@mui/material/styles";
import createStyles from "@mui/styles/createStyles";
import withStyles from "@mui/styles/withStyles";
import { Button, Grid } from "@mui/material";
import { ArrowDropUp } from "@mui/icons-material";
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
import {
actionsTray,
containerForHeader,
logsCommon,
searchField,
} from "../../Common/FormComponents/common/styleLibrary";
import { IReqInfoSearchResults, ISearchResponse } from "./types";
import { niceBytes, nsToSeconds } from "../../../../common/utils";
import { setErrorSnackMessage } from "../../../../actions";
import { AppState } from "../../../../store";
import { ErrorResponseHandler } from "../../../../common/types";
import api from "../../../../common/api";
import TableWrapper from "../../Common/TableWrapper/TableWrapper";
import FilterInputWrapper from "../../Common/FormComponents/FilterInputWrapper/FilterInputWrapper";
import LogSearchFullModal from "./LogSearchFullModal";
import { LogSearchColumnLabels } from "./utils";
import DateRangeSelector from "../../Common/FormComponents/DateRangeSelector/DateRangeSelector";
interface ILogSearchProps {
classes: any;
features: string[] | null;
setErrorSnackMessage: typeof setErrorSnackMessage;
}
const styles = (theme: Theme) =>
createStyles({
inputBar: {
flexGrow: 1,
marginLeft: 15,
},
advancedLabel: {
display: "flex",
alignItems: "center",
color: "#091C42",
border: 0,
backgroundColor: "transparent",
cursor: "pointer",
"&:focus, &:active": {
outline: "none",
},
},
advancedLabelContainer: {
marginTop: 10,
},
getInformationContainer: {
textAlign: "right",
},
orderButton: {
width: 93,
},
recordsLabel: {
alignSelf: "center",
marginLeft: 15,
},
blockCollapsed: {
height: 0,
overflowY: "hidden",
transitionDuration: "0.3s",
},
filterOpen: {
height: 200,
marginBottom: 12,
},
endLineAction: {
marginBottom: 15,
},
filtersContainer: {
display: "flex",
justifyContent: "space-between",
marginBottom: 12,
},
innerContainer: {
backgroundColor: "#fff",
border: "#EAEDEE 1px solid",
borderRadius: 3,
padding: 10,
marginBottom: 15,
},
noticeLabel: {
marginLeft: 15,
marginBottom: 15,
fontSize: 12,
color: "#9C9C9C",
},
tableFOpen: {
height: "calc(100vh - 561px)",
},
tableFClosed: {
height: "calc(100vh - 349px)",
},
"@global": {
".overrideMargin": {
marginLeft: 0,
},
},
...searchField,
...actionsTray,
...logsCommon,
...containerForHeader(theme.spacing(4)),
});
const LogsSearchMain = ({
classes,
features,
setErrorSnackMessage,
}: ILogSearchProps) => {
const [loading, setLoading] = useState<boolean>(true);
const [timeStart, setTimeStart] = useState<any>(null);
const [timeEnd, setTimeEnd] = useState<any>(null);
const [filterOpen, setFilterOpen] = useState<boolean>(false);
const [records, setRecords] = useState<IReqInfoSearchResults[]>([]);
const [bucket, setBucket] = useState<string>("");
const [apiName, setApiName] = useState<string>("");
const [userAgent, setUserAgent] = useState<string>("");
const [object, setObject] = useState<string>("");
const [requestID, setRequestID] = useState<string>("");
const [responseStatus, setResponseStatus] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"ASC" | "DESC" | undefined>(
"DESC"
);
const [columnsShown, setColumnsShown] = useState<string[]>([
"time",
"api_name",
"bucket",
"object",
"remote_host",
"request_id",
"user_agent",
"response_status",
]);
const [nextPage, setNextPage] = useState<number>(0);
const [alreadyFetching, setAlreadyFetching] = useState<boolean>(false);
const [logSearchExtrasOpen, setLogSearchExtrasOpen] =
useState<boolean>(false);
const [selectedItem, setSelectedItem] =
useState<IReqInfoSearchResults | null>(null);
let recordsResp: any = null;
const logSearchEnabled = features && features.includes("log-search");
const fetchRecords = useCallback(() => {
if (!alreadyFetching && logSearchEnabled) {
setAlreadyFetching(true);
let queryParams = `${bucket !== "" ? `&fp=bucket:${bucket}` : ""}${
object !== "" ? `&fp=object:${object}` : ""
}${apiName !== "" ? `&fp=api_name:${apiName}` : ""}${
requestID !== "" ? `&fp=request_id:${requestID}` : ""
}${userAgent !== "" ? `&fp=user_agent:${userAgent}` : ""}${
responseStatus !== "" ? `&fp=response_status:${responseStatus}` : ""
}`;
queryParams = queryParams.trim();
if (queryParams.endsWith(",")) {
queryParams = queryParams.slice(0, -1);
}
api
.invoke(
"GET",
`/api/v1/logs/search?q=reqinfo${
queryParams !== "" ? `${queryParams}` : ""
}&pageSize=100&pageNo=${nextPage}&order=${
sortOrder === "DESC" ? "timeDesc" : "timeAsc"
}${
timeStart !== null ? `&timeStart=${timeStart.toISOString()}` : ""
}${timeEnd !== null ? `&timeEnd=${timeEnd.toISOString()}` : ""}`
)
.then((res: ISearchResponse) => {
const fetchedResults = res.results || [];
setLoading(false);
setAlreadyFetching(false);
setRecords(fetchedResults);
setNextPage(nextPage + 1);
if (recordsResp !== null) {
recordsResp();
}
})
.catch((err: ErrorResponseHandler) => {
setLoading(false);
setAlreadyFetching(false);
setErrorSnackMessage(err);
});
}
}, [
alreadyFetching,
logSearchEnabled,
bucket,
object,
apiName,
requestID,
userAgent,
responseStatus,
nextPage,
sortOrder,
timeStart,
timeEnd,
recordsResp,
setErrorSnackMessage,
]);
useEffect(() => {
if (loading) {
setRecords([]);
fetchRecords();
}
}, [loading, sortOrder, fetchRecords]);
const triggerLoad = () => {
setNextPage(0);
setLoading(true);
};
const selectColumn = (colName: string, active: boolean) => {
let newArray = [...columnsShown];
if (!active) {
newArray = columnsShown.filter((element) => element !== colName);
} else {
if (!newArray.includes(colName)) {
newArray.push(colName);
}
}
setColumnsShown(newArray);
};
const sortChange = (sortData: any) => {
const newSortDirection = get(sortData, "sortDirection", "DESC");
setSortOrder(newSortDirection);
setNextPage(0);
setLoading(true);
};
const loadMoreRecords = (_: { startIndex: number; stopIndex: number }) => {
fetchRecords();
return new Promise((resolve) => {
recordsResp = resolve;
});
};
const openExtraInformation = (item: IReqInfoSearchResults) => {
setSelectedItem(item);
setLogSearchExtrasOpen(true);
};
const closeViewExtraInformation = () => {
setSelectedItem(null);
setLogSearchExtrasOpen(false);
};
return (
<Fragment>
{logSearchExtrasOpen && selectedItem !== null && (
<LogSearchFullModal
logSearchElement={selectedItem}
modalOpen={logSearchExtrasOpen}
onClose={closeViewExtraInformation}
/>
)}
<Grid container className={classes.logsSubContainer}>
<Grid
item
xs={12}
className={`${classes.actionsTray} ${classes.timeContainers}`}
>
<DateRangeSelector
setTimeEnd={setTimeEnd}
setTimeStart={setTimeStart}
timeEnd={timeEnd}
timeStart={timeStart}
/>
</Grid>
<Grid item xs={12} className={`${classes.advancedLabelContainer}`}>
<div
className={`${classes.blockCollapsed} ${
filterOpen ? classes.filterOpen : ""
}`}
>
<div className={classes.innerContainer}>
<div className={classes.noticeLabel}>
Enable your preferred options to get filtered records.
<br />
You can use '*' to match any character, '.' to signify a single
character or '\' to scape an special character (E.g. mybucket-*)
</div>
<div className={classes.filtersContainer}>
<FilterInputWrapper
onChange={setBucket}
value={bucket}
label={"Bucket"}
id="bucket"
name="bucket"
/>
<FilterInputWrapper
onChange={setApiName}
value={apiName}
label={"API Name"}
id="api_name"
name="api_name"
/>
<FilterInputWrapper
onChange={setUserAgent}
value={userAgent}
label={"User Agent"}
id="user_agent"
name="user_agent"
/>
</div>
<div className={classes.filtersContainer}>
<FilterInputWrapper
onChange={setObject}
value={object}
label={"Object"}
id="object"
name="object"
/>
<FilterInputWrapper
onChange={setRequestID}
value={requestID}
label={"Request ID"}
id="request_id"
name="request_id"
/>
<FilterInputWrapper
onChange={setResponseStatus}
value={responseStatus}
label={"Response Status"}
id="response_status"
name="response_status"
/>
</div>
</div>
</div>
</Grid>
<Grid
item
xs={12}
className={`${classes.actionsTray} ${classes.endLineAction}`}
>
<div>
<button
type="button"
className={`${classes.advancedLabel} overrideMargin`}
onClick={() => {
setFilterOpen(!filterOpen);
}}
>
Advanced Filters{" "}
{filterOpen ? <ArrowDropUp /> : <ArrowDropDownIcon />}
</button>
</div>
<Button
type="button"
variant="contained"
color="primary"
onClick={triggerLoad}
>
Get Information
</Button>
</Grid>
<Grid item xs={12}>
<TableWrapper
columns={[
{
label: LogSearchColumnLabels.time,
elementKey: "time",
enableSort: true,
},
{ label: LogSearchColumnLabels.api_name, elementKey: "api_name" },
{ label: LogSearchColumnLabels.bucket, elementKey: "bucket" },
{ label: LogSearchColumnLabels.object, elementKey: "object" },
{
label: LogSearchColumnLabels.remote_host,
elementKey: "remote_host",
},
{
label: LogSearchColumnLabels.request_id,
elementKey: "request_id",
},
{
label: LogSearchColumnLabels.user_agent,
elementKey: "user_agent",
},
{
label: LogSearchColumnLabels.response_status,
elementKey: "response_status",
renderFunction: (element) => (
<Fragment>
<span>
{element.response_status_code} ({element.response_status})
</span>
</Fragment>
),
renderFullObject: true,
},
{
label: LogSearchColumnLabels.request_content_length,
elementKey: "request_content_length",
renderFunction: niceBytes,
},
{
label: LogSearchColumnLabels.response_content_length,
elementKey: "response_content_length",
renderFunction: niceBytes,
},
{
label: LogSearchColumnLabels.time_to_response_ns,
elementKey: "time_to_response_ns",
renderFunction: nsToSeconds,
contentTextAlign: "right",
},
]}
isLoading={loading}
records={records}
entityName="Logs"
customEmptyMessage={"There is no information with this criteria"}
idField="request_id"
columnsSelector
columnsShown={columnsShown}
onColumnChange={selectColumn}
customPaperHeight={
filterOpen ? classes.tableFOpen : classes.tableFClosed
}
sortConfig={{
currentSort: "time",
currentDirection: sortOrder,
triggerSort: sortChange,
}}
infiniteScrollConfig={{
recordsCount: 1000000,
loadMoreRecords: loadMoreRecords,
}}
itemActions={[
{
type: "view",
onClick: openExtraInformation,
},
]}
textSelectable
/>
</Grid>
</Grid>
</Fragment>
);
};
const mapState = (state: AppState) => ({
features: state.console.session.features,
});
const mapDispatchToProps = {
setErrorSnackMessage,
};
const connector = connect(mapState, mapDispatchToProps);
export default withStyles(styles)(connector(LogsSearchMain));