github.com/minio/console@v1.4.1/web-app/src/screens/Console/Logs/LogSearch/LogsSearchMain.tsx (about) 1 // This file is part of MinIO Console Server 2 // Copyright (c) 2021 MinIO, Inc. 3 // 4 // This program is free software: you can redistribute it and/or modify 5 // it under the terms of the GNU Affero General Public License as published by 6 // the Free Software Foundation, either version 3 of the License, or 7 // (at your option) any later version. 8 // 9 // This program is distributed in the hope that it will be useful, 10 // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 // GNU Affero General Public License for more details. 13 // 14 // You should have received a copy of the GNU Affero General Public License 15 // along with this program. If not, see <http://www.gnu.org/licenses/>. 16 17 import React, { Fragment, useCallback, useEffect, useState } from "react"; 18 import get from "lodash/get"; 19 import { useSelector } from "react-redux"; 20 import { CSSObject } from "styled-components"; 21 import { 22 Box, 23 breakPoints, 24 Button, 25 DataTable, 26 ExpandOptionsButton, 27 Grid, 28 PageLayout, 29 SearchIcon, 30 } from "mds"; 31 import { DateTime } from "luxon"; 32 import { IReqInfoSearchResults, ISearchResponse } from "./types"; 33 import { niceBytes, nsToSeconds } from "../../../../common/utils"; 34 import { ErrorResponseHandler } from "../../../../common/types"; 35 import { LogSearchColumnLabels } from "./utils"; 36 import { 37 CONSOLE_UI_RESOURCE, 38 IAM_SCOPES, 39 } from "../../../../common/SecureComponent/permissions"; 40 import { setErrorSnackMessage, setHelpName } from "../../../../systemSlice"; 41 import { selFeatures } from "../../consoleSlice"; 42 import { useAppDispatch } from "../../../../store"; 43 import { SecureComponent } from "../../../../common/SecureComponent"; 44 import api from "../../../../common/api"; 45 import FilterInputWrapper from "../../Common/FormComponents/FilterInputWrapper/FilterInputWrapper"; 46 import LogSearchFullModal from "./LogSearchFullModal"; 47 import DateRangeSelector from "../../Common/FormComponents/DateRangeSelector/DateRangeSelector"; 48 import MissingIntegration from "../../Common/MissingIntegration/MissingIntegration"; 49 import PageHeaderWrapper from "../../Common/PageHeaderWrapper/PageHeaderWrapper"; 50 import HelpMenu from "../../HelpMenu"; 51 52 const filtersContainer: CSSObject = { 53 display: "flex", 54 justifyContent: "space-between", 55 marginBottom: 12, 56 }; 57 58 const LogsSearchMain = () => { 59 const dispatch = useAppDispatch(); 60 const features = useSelector(selFeatures); 61 62 const [loading, setLoading] = useState<boolean>(true); 63 const [timeStart, setTimeStart] = useState<DateTime | null>(null); 64 const [timeEnd, setTimeEnd] = useState<DateTime | null>(null); 65 const [filterOpen, setFilterOpen] = useState<boolean>(false); 66 const [records, setRecords] = useState<IReqInfoSearchResults[]>([]); 67 const [bucket, setBucket] = useState<string>(""); 68 const [apiName, setApiName] = useState<string>(""); 69 const [accessKey, setAccessKey] = useState<string>(""); 70 const [userAgent, setUserAgent] = useState<string>(""); 71 const [object, setObject] = useState<string>(""); 72 const [requestID, setRequestID] = useState<string>(""); 73 const [responseStatus, setResponseStatus] = useState<string>(""); 74 const [sortOrder, setSortOrder] = useState<"ASC" | "DESC" | undefined>( 75 "DESC", 76 ); 77 const [columnsShown, setColumnsShown] = useState<string[]>([ 78 "time", 79 "api_name", 80 "access_key", 81 "bucket", 82 "object", 83 "remote_host", 84 "request_id", 85 "user_agent", 86 "response_status", 87 ]); 88 const [nextPage, setNextPage] = useState<number>(0); 89 const [alreadyFetching, setAlreadyFetching] = useState<boolean>(false); 90 const [logSearchExtrasOpen, setLogSearchExtrasOpen] = 91 useState<boolean>(false); 92 const [selectedItem, setSelectedItem] = 93 useState<IReqInfoSearchResults | null>(null); 94 95 let recordsResp: any = null; 96 const logSearchEnabled = features && features.includes("log-search"); 97 98 const fetchRecords = useCallback(() => { 99 if (!alreadyFetching && logSearchEnabled) { 100 setAlreadyFetching(true); 101 let queryParams = `${bucket !== "" ? `&fp=bucket:${bucket}` : ""}${ 102 object !== "" ? `&fp=object:${object}` : "" 103 }${apiName !== "" ? `&fp=api_name:${apiName}` : ""}${ 104 accessKey !== "" ? `&fp=access_key:${accessKey}` : "" 105 }${requestID !== "" ? `&fp=request_id:${requestID}` : ""}${ 106 userAgent !== "" ? `&fp=user_agent:${userAgent}` : "" 107 }${responseStatus !== "" ? `&fp=response_status:${responseStatus}` : ""}`; 108 109 queryParams = queryParams.trim(); 110 111 if (queryParams.endsWith(",")) { 112 queryParams = queryParams.slice(0, -1); 113 } 114 115 api 116 .invoke( 117 "GET", 118 `/api/v1/logs/search?q=reqinfo${ 119 queryParams !== "" ? `${queryParams}` : "" 120 }&pageSize=100&pageNo=${nextPage}&order=${ 121 sortOrder === "DESC" ? "timeDesc" : "timeAsc" 122 }${ 123 timeStart !== null ? `&timeStart=${timeStart.toUTC().toISO()}` : "" 124 }${timeEnd !== null ? `&timeEnd=${timeEnd.toUTC().toISO()}` : ""}`, 125 ) 126 .then((res: ISearchResponse) => { 127 const fetchedResults = res.results || []; 128 129 setLoading(false); 130 setAlreadyFetching(false); 131 setRecords(fetchedResults); 132 setNextPage(nextPage + 1); 133 134 if (recordsResp !== null) { 135 recordsResp(); 136 } 137 }) 138 .catch((err: ErrorResponseHandler) => { 139 setLoading(false); 140 setAlreadyFetching(false); 141 dispatch(setErrorSnackMessage(err)); 142 }); 143 } else { 144 setLoading(false); 145 setAlreadyFetching(false); 146 } 147 }, [ 148 alreadyFetching, 149 logSearchEnabled, 150 bucket, 151 object, 152 apiName, 153 accessKey, 154 requestID, 155 userAgent, 156 responseStatus, 157 nextPage, 158 sortOrder, 159 timeStart, 160 timeEnd, 161 recordsResp, 162 dispatch, 163 ]); 164 165 useEffect(() => { 166 if (loading) { 167 setRecords([]); 168 fetchRecords(); 169 } 170 }, [loading, sortOrder, fetchRecords]); 171 172 const triggerLoad = () => { 173 setNextPage(0); 174 setLoading(true); 175 }; 176 177 const selectColumn = (colID: string) => { 178 let newArray: string[]; 179 180 const columnShown = columnsShown.findIndex((item) => item === colID); 181 182 // Column Exist, We remove from Array 183 if (columnShown >= 0) { 184 newArray = columnsShown.filter((element) => element !== colID); 185 } else { 186 // Column not visible, we include it in the array 187 newArray = [...columnsShown, colID]; 188 } 189 190 setColumnsShown(newArray); 191 }; 192 193 const sortChange = (sortData: any) => { 194 const newSortDirection = get(sortData, "sortDirection", "DESC"); 195 setSortOrder(newSortDirection); 196 setNextPage(0); 197 setLoading(true); 198 }; 199 200 const loadMoreRecords = (_: { startIndex: number; stopIndex: number }) => { 201 fetchRecords(); 202 return new Promise((resolve) => { 203 recordsResp = resolve; 204 }); 205 }; 206 207 const openExtraInformation = (item: IReqInfoSearchResults) => { 208 setSelectedItem(item); 209 setLogSearchExtrasOpen(true); 210 }; 211 212 const closeViewExtraInformation = () => { 213 setSelectedItem(null); 214 setLogSearchExtrasOpen(false); 215 }; 216 217 useEffect(() => { 218 dispatch(setHelpName("audit_logs")); 219 // eslint-disable-next-line react-hooks/exhaustive-deps 220 }, []); 221 222 return ( 223 <Fragment> 224 {logSearchExtrasOpen && selectedItem !== null && ( 225 <LogSearchFullModal 226 logSearchElement={selectedItem} 227 modalOpen={logSearchExtrasOpen} 228 onClose={closeViewExtraInformation} 229 /> 230 )} 231 232 <PageHeaderWrapper label="Audit Logs" actions={<HelpMenu />} /> 233 234 <PageLayout> 235 {!logSearchEnabled ? ( 236 <MissingIntegration 237 entity={"Audit Logs"} 238 iconComponent={<SearchIcon />} 239 documentationLink="https://min.io/docs/minio/windows/operations/monitoring/minio-logging.html?ref=con" 240 /> 241 ) : ( 242 <Fragment> 243 {" "} 244 <Box withBorders sx={{ marginBottom: 15 }}> 245 <Grid 246 item 247 xs={12} 248 sx={{ 249 display: "flex", 250 padding: 15, 251 [`@media (max-width: ${breakPoints.lg}px)`]: { 252 flexFlow: "column", 253 }, 254 }} 255 > 256 <Box> 257 <DateRangeSelector 258 setTimeEnd={(time) => setTimeEnd(time)} 259 setTimeStart={(time) => setTimeStart(time)} 260 timeEnd={timeEnd} 261 timeStart={timeStart} 262 /> 263 </Box> 264 <Box sx={{ display: "flex", alignItems: "center" }}> 265 <ExpandOptionsButton 266 label={`${filterOpen ? "Hide" : "Show"} advanced Filters`} 267 open={filterOpen} 268 onClick={() => { 269 setFilterOpen(!filterOpen); 270 }} 271 /> 272 </Box> 273 </Grid> 274 <Grid 275 item 276 xs={12} 277 sx={{ 278 display: filterOpen ? "block" : "none", 279 overflowY: "hidden", 280 marginBottom: filterOpen ? 12 : 0, 281 }} 282 > 283 <Box 284 sx={{ 285 marginLeft: 15, 286 marginBottom: 15, 287 fontSize: 12, 288 color: "#9C9C9C", 289 }} 290 > 291 Enable your preferred options to get filtered records. 292 <br /> 293 You can use '*' to match any character, '.' to signify a 294 single character or '\' to scape an special character (E.g. 295 mybucket-*) 296 </Box> 297 <Box sx={filtersContainer}> 298 <FilterInputWrapper 299 onChange={setBucket} 300 value={bucket} 301 label={"Bucket"} 302 id="bucket" 303 name="bucket" 304 /> 305 <FilterInputWrapper 306 onChange={setApiName} 307 value={apiName} 308 label={"API Name"} 309 id="api_name" 310 name="api_name" 311 /> 312 <FilterInputWrapper 313 onChange={setAccessKey} 314 value={accessKey} 315 label={"Access Key"} 316 id="access_key" 317 name="access_key" 318 /> 319 <FilterInputWrapper 320 onChange={setUserAgent} 321 value={userAgent} 322 label={"User Agent"} 323 id="user_agent" 324 name="user_agent" 325 /> 326 </Box> 327 <Box sx={filtersContainer}> 328 <FilterInputWrapper 329 onChange={setObject} 330 value={object} 331 label={"Object"} 332 id="object" 333 name="object" 334 /> 335 <FilterInputWrapper 336 onChange={setRequestID} 337 value={requestID} 338 label={"Request ID"} 339 id="request_id" 340 name="request_id" 341 /> 342 <FilterInputWrapper 343 onChange={setResponseStatus} 344 value={responseStatus} 345 label={"Response Status"} 346 id="response_status" 347 name="response_status" 348 /> 349 </Box> 350 </Grid> 351 <Grid 352 item 353 xs={12} 354 sx={{ 355 marginBottom: 15, 356 padding: "0 15px 0 15px", 357 display: "flex", 358 alignItems: "center", 359 justifyContent: "flex-end", 360 }} 361 > 362 <Button 363 id={"get-information"} 364 type="button" 365 variant="callAction" 366 onClick={triggerLoad} 367 label={"Get Information"} 368 /> 369 </Grid> 370 </Box> 371 <Grid item xs={12}> 372 <SecureComponent 373 scopes={[IAM_SCOPES.ADMIN_HEALTH_INFO]} 374 resource={CONSOLE_UI_RESOURCE} 375 errorProps={{ disabled: true }} 376 > 377 <DataTable 378 columns={[ 379 { 380 label: LogSearchColumnLabels.time, 381 elementKey: "time", 382 enableSort: true, 383 }, 384 { 385 label: LogSearchColumnLabels.api_name, 386 elementKey: "api_name", 387 }, 388 { 389 label: LogSearchColumnLabels.access_key, 390 elementKey: "access_key", 391 }, 392 { 393 label: LogSearchColumnLabels.bucket, 394 elementKey: "bucket", 395 }, 396 { 397 label: LogSearchColumnLabels.object, 398 elementKey: "object", 399 }, 400 { 401 label: LogSearchColumnLabels.remote_host, 402 elementKey: "remote_host", 403 }, 404 { 405 label: LogSearchColumnLabels.request_id, 406 elementKey: "request_id", 407 }, 408 { 409 label: LogSearchColumnLabels.user_agent, 410 elementKey: "user_agent", 411 }, 412 { 413 label: LogSearchColumnLabels.response_status, 414 elementKey: "response_status", 415 renderFunction: (element) => ( 416 <Fragment> 417 <span> 418 {element.response_status_code} ( 419 {element.response_status}) 420 </span> 421 </Fragment> 422 ), 423 renderFullObject: true, 424 }, 425 { 426 label: LogSearchColumnLabels.request_content_length, 427 elementKey: "request_content_length", 428 renderFunction: niceBytes, 429 }, 430 { 431 label: LogSearchColumnLabels.response_content_length, 432 elementKey: "response_content_length", 433 renderFunction: niceBytes, 434 }, 435 { 436 label: LogSearchColumnLabels.time_to_response_ns, 437 elementKey: "time_to_response_ns", 438 renderFunction: nsToSeconds, 439 contentTextAlign: "right", 440 }, 441 ]} 442 isLoading={loading} 443 records={records} 444 entityName="Logs" 445 customEmptyMessage={ 446 "There is no information with this criteria" 447 } 448 idField="request_id" 449 columnsSelector 450 columnsShown={columnsShown} 451 onColumnChange={selectColumn} 452 customPaperHeight={ 453 filterOpen ? "calc(100vh - 520px)" : "calc(100vh - 320px)" 454 } 455 sortEnabled={{ 456 currentSort: "time", 457 currentDirection: sortOrder, 458 onSortClick: sortChange, 459 }} 460 infiniteScrollConfig={{ 461 recordsCount: 1000000, 462 loadMoreRecords: loadMoreRecords, 463 }} 464 itemActions={[ 465 { 466 type: "view", 467 onClick: openExtraInformation, 468 }, 469 ]} 470 textSelectable 471 /> 472 </SecureComponent> 473 </Grid> 474 </Fragment> 475 )} 476 </PageLayout> 477 </Fragment> 478 ); 479 }; 480 481 export default LogsSearchMain;