diff --git a/src/app.scss b/src/app.scss index 00327507f..d7cbb705f 100644 --- a/src/app.scss +++ b/src/app.scss @@ -24,6 +24,7 @@ @import 'assets/styles/pages/organizations'; @import 'assets/styles/pages/organization'; @import 'assets/styles/pages/auth'; +@import 'assets/styles/pages/logs'; @import 'assets/styles/pages/config'; @import 'assets/styles/pages/clustering'; @import 'assets/styles/pages/resources'; diff --git a/src/assets/styles/components/_modal.scss b/src/assets/styles/components/_modal.scss index 12f6b4f49..0977ffb9a 100644 --- a/src/assets/styles/components/_modal.scss +++ b/src/assets/styles/components/_modal.scss @@ -66,3 +66,22 @@ } } } + +#log-info-modal { + .error, + .fatal { + color: $color-danger; + } + + .warn { + color: $color-warning; + } + + .complete, + .notify { + color: $color-success; + } + code { + color: initial; + } +} diff --git a/src/assets/styles/overrides/_akamai.scss b/src/assets/styles/overrides/_akamai.scss index 99c871f86..9934cf959 100644 --- a/src/assets/styles/overrides/_akamai.scss +++ b/src/assets/styles/overrides/_akamai.scss @@ -234,6 +234,10 @@ $partner-brand-color: #017ac6; color: $color-grey !important; } + .filters-header { + color: $color-black !important; + } + .modal-content { background: $color-white !important; color: $color-grey !important; diff --git a/src/assets/styles/overrides/_dark.scss b/src/assets/styles/overrides/_dark.scss index 2b8c1a21b..1fa5a7415 100644 --- a/src/assets/styles/overrides/_dark.scss +++ b/src/assets/styles/overrides/_dark.scss @@ -58,6 +58,19 @@ background-color: $color-pureblack !important; border: 1px solid $faint-white-overlay !important; } + #logs { + .react-select__control { + background-color: $color-pureblack !important; + } + + .react-select__single-value { + color: $color-white !important; + } + + .react-select__placeholder { + color: $color-white !important; + } + } input, select, @@ -189,6 +202,10 @@ color: $color-lightgrey !important; } + .filters-header { + color: $color-white !important; + } + #replaceFile { color: $color-white; } @@ -200,6 +217,10 @@ .modal-header { background: $dark-grey-overlay !important; } + + code { + color: $color-white !important; + } } .auth-form-container { diff --git a/src/assets/styles/overrides/_light.scss b/src/assets/styles/overrides/_light.scss index f3a993e14..cc1738282 100644 --- a/src/assets/styles/overrides/_light.scss +++ b/src/assets/styles/overrides/_light.scss @@ -141,6 +141,15 @@ } } + #logs { + .react-select__single-value { + color: $color-darkgrey !important; + } + + .react-select__placeholder { + color: $color-darkgrey !important; + } + } input, select, textarea, @@ -225,6 +234,10 @@ color: $color-grey !important; } + .filters-header { + color: $color-black !important; + } + .modal-content { background: $color-white !important; color: $color-grey !important; diff --git a/src/assets/styles/pages/_logs.scss b/src/assets/styles/pages/_logs.scss new file mode 100644 index 000000000..8d126a39d --- /dev/null +++ b/src/assets/styles/pages/_logs.scss @@ -0,0 +1,98 @@ +#logs { + height: 100%; + .filters-header { + color: $color-white; + font-weight: $font-weight-bold; + white-space: nowrap; + font-size: 13px !important; + } + + .instance-details { + .card-body { + padding-bottom: 0; + } + + .nowrap-scroll { + overflow: hidden; + overflow-x: auto; + white-space: nowrap; + } + } + + .react-select__control { + background-color: $color-white !important; + } + + .react-select__single-value { + color: $color-darkgrey !important; + } + + .react-select__placeholder { + color: $color-darkgrey !important; + } + + .input-label { + color: $color-white; + font-weight: $font-weight-bold; + white-space: nowrap; + font-size: 13px !important; + display: block; + } + + .item-list { + font-size: 0.65rem; + padding: 0; + + .header { + padding: 16px 16px 8px; + } + + hr { + background-color: $color-grey !important; + } + + .item-scroller { + height: calc(100vh - 275px); + overflow: hidden auto; + + &::-webkit-scrollbar { + width: 10px; + } + + .item-row { + border-bottom: 1px solid $lightest-grey-overlay; + padding: 4px; + + .text-nowrap { + overflow: hidden; + text-overflow: ellipsis; + } + + .error, + .fatal { + color: $color-danger; + } + + .warn { + color: $color-warning; + } + + .complete, + .notify { + color: $color-success; + } + pre { + margin-bottom: 0; + font-size: 12px; + } + } + } + } + + .configLoader { + height: 38px; + padding-top: 15px; + text-align: center; + width: 100%; + } +} diff --git a/src/components/instance/logs/LogRow.js b/src/components/instance/logs/LogRow.js new file mode 100644 index 000000000..2f37c66ba --- /dev/null +++ b/src/components/instance/logs/LogRow.js @@ -0,0 +1,37 @@ +import React from 'react'; +import { Row, Col } from 'reactstrap'; + +import isObject from '../../../functions/util/isObject'; + +function LogsRow({ onRowClick, level, timestamp, message, tags, thread }) { + return ( + + + {level?.toUpperCase() || 'UNKNOWN'} + + + {new Date(timestamp || null).toLocaleDateString()} + + + {new Date(timestamp || null).toLocaleTimeString()} + + + {thread} + + + {tags?.join(', ')} + + + {isObject(message) && message.error ? ( + message.error + ) : ( +
+						{message}
+					
+ )} + +
+ ); +} + +export default LogsRow; diff --git a/src/components/instance/logs/Logs.js b/src/components/instance/logs/Logs.js new file mode 100644 index 000000000..0571b41be --- /dev/null +++ b/src/components/instance/logs/Logs.js @@ -0,0 +1,171 @@ +import React, { useState, useEffect } from 'react'; +import { useStoreState } from 'pullstate'; +import { Card, CardBody, Row, Col, Button, Modal, ModalHeader, ModalBody } from 'reactstrap'; +import useInterval from 'use-interval'; +import { ErrorBoundary } from 'react-error-boundary'; +import { useParams } from 'react-router-dom'; + +import instanceState from '../../../functions/state/instanceState'; +import appState from '../../../functions/state/appState'; +import config from '../../../config'; + +import readLog from '../../../functions/api/instance/readLog'; +import LogRow from './LogRow'; +import ErrorFallback from '../../shared/ErrorFallback'; +import addError from '../../../functions/api/lms/addError'; + +let controller; + +const fetchData = async ({ auth, url, logsFilter }) => { + controller = new AbortController(); + await readLog({ + auth, + url, + signal: controller.signal, + // currentLogCount: logs?.length || 0, + logsFilter, // default log limit is 1000 + }); +}; + +function Logs({ logsFilter }) { + const { compute_stack_id } = useParams(); + const auth = useStoreState(instanceState, (s) => s.auth); + const url = useStoreState(instanceState, (s) => s.url); + const logs = useStoreState(instanceState, (s) => s.logs, [compute_stack_id]); + const logsError = useStoreState(instanceState, (s) => s.logsError); + const theme = useStoreState(appState, (s) => s.theme); + const [autoRefresh, setAutoRefresh] = useState(false); + const [loading, setLoading] = useState(true); + const [lastUpdate, setLastUpdate] = useState(true); + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedLogInfo, setSelectedLogInfo] = useState({}); + + const toggleModal = () => setIsModalOpen(!isModalOpen); + + useEffect(() => { + if (auth) { + setLoading(true); + fetchData({ auth, url, logsFilter }); + setLoading(false); + } + + return () => { + controller?.abort(); + }; + // eslint-disable-next-line + }, [auth, lastUpdate, logsFilter]); + + useInterval(() => auth && autoRefresh && setLastUpdate(Date.now()), config.refresh_content_interval); + + return ( + addError({ error: { message: error.message, componentStack } })} + FallbackComponent={ErrorFallback} + > + + logs + + + + + + + + + + Status + + + Date + + + {!loading && logsError ? ( + log fetch error: {new Date().toLocaleTimeString().toLowerCase()} + ) : ( + Time + )} + + + Thread + + + Tags + + + Message + + +
+
+ {loading && !logs && !autoRefresh ? ( +
+ +
+ ) : logs && logs.length ? ( + logs.map((log, index) => ( + { + setSelectedLogInfo(log); + toggleModal(); + }} + /> + )) + ) : logs && !logs.length ? ( +
no logs found
+ ) : ( +
no logs found in this view
+ )} +
+
+
+
+ + View Log Info + + {selectedLogInfo && ( +
+

+ Level:{' '} + + {selectedLogInfo?.level?.toUpperCase()} + +

+

+ Timestamp(UTC): +

+ {selectedLogInfo?.timestamp} +

+ Thread: {selectedLogInfo?.thread} +

+

+ Tags: {selectedLogInfo?.tags?.join(', ')} +

+

+ Message: +

+
+								{/*  */}
+								{selectedLogInfo?.message?.error ? selectedLogInfo?.message?.error : selectedLogInfo?.message}
+								{/*  */}
+							
+
+ )} +
+
+
+ ); +} + +export default Logs; diff --git a/src/components/instance/logs/index.js b/src/components/instance/logs/index.js new file mode 100644 index 000000000..d633201f6 --- /dev/null +++ b/src/components/instance/logs/index.js @@ -0,0 +1,133 @@ +import React, { useState, useRef } from 'react'; +import { Button, Col, Input, Label, Row } from 'reactstrap'; +import Select from 'react-select'; +import { useAlert } from 'react-alert'; +import Logs from './Logs'; + +const isValidDateRange = (startDate, endDate) => { + if (!startDate && !endDate) return true; + if (!startDate || !endDate) return true; + + const start = new Date(startDate); + const end = new Date(endDate); + return start <= end; +}; +/* +logFilter object: + limit: 1000 | 500 | 250 | 100 | 10 || 1000 + level: 'notify' | 'error', | 'warn' | 'info' | 'debug' | 'trace' || undefined + from: DATE (UTC) timestamp || undefined + until: DATE (UTC) timestamp || undefined +*/ +const defaultFormState = { + limit: 1000, + level: undefined, + from: undefined, + until: undefined, +}; +function LogsIndex() { + const [logsFilter, setLogsFilter] = useState(defaultFormState); + const logsFilterFormRef = useRef(); + const logLimitSelectRef = useRef(); + const logLevelSelectRef = useRef(); + const alert = useAlert(); + const applyFilters = (event) => { + event.preventDefault(); + + if (!isValidDateRange(event.target.elements.logFromInput.value, event.target.elements.logUntilInput.value)) { + alert.error('Please provide a valid date range.'); + return; + } + + setLogsFilter({ + limit: parseInt(event.target.elements.logLimitSelect.value, 10) || 1000, + level: event.target.elements.logLevelSelect.value || undefined, + from: event.target.elements.logFromInput.value || undefined, + until: event.target.elements.logUntilInput.value || undefined, + }); + }; + + const resetForm = () => { + logsFilterFormRef.current.reset(); + logLimitSelectRef.current.clearValue(); + logLevelSelectRef.current.clearValue(); + setLogsFilter(defaultFormState); + }; + return ( + + +

Log Filters

+
+ + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ ); +} + +export default LogsIndex; diff --git a/src/components/instance/routes.js b/src/components/instance/routes.js index c2f97bcfc..9dced4a0b 100644 --- a/src/components/instance/routes.js +++ b/src/components/instance/routes.js @@ -10,6 +10,7 @@ import supportsApplications from '../../functions/instance/functions/supportsApp const Cluster = lazy(() => import(/* webpackChunkName: "instance-cluster" */ './replication')); const Config = lazy(() => import(/* webpackChunkName: "instance-config" */ './config')); const Metrics = lazy(() => import(/* webpackChunkName: "instance-status" */ './status')); +const LogsIndex = lazy(() => import(/* webpackChunkName: "instance-logs" */ './logs')); const Users = lazy(() => import(/* webpackChunkName: "instance-users" */ './users')); const Roles = lazy(() => import(/* webpackChunkName: "instance-roles" */ './roles')); const Functions = lazy(() => import(/* webpackChunkName: "custom-functions" */ './functions')); @@ -90,6 +91,13 @@ const routes = ({ super_user, version = null }) => { label: 'status', icon: 'tachometer-alt', }; + const logs = { + element: , + path: `logs`, + link: 'logs', + label: 'logs', + icon: 'tachometer-alt', + }; const configure = { element: , @@ -100,11 +108,11 @@ const routes = ({ super_user, version = null }) => { }; if (config.is_local_studio && super_user) { - return [browse, users, roles, cluster, useApplications ? applications : functions, metrics, configure]; + return [browse, users, roles, cluster, useApplications ? applications : functions, metrics, configure, logs]; } if (super_user) { - return [browse, users, roles, cluster, useApplications ? applications : functions, metrics, configure]; + return [browse, users, roles, cluster, useApplications ? applications : functions, metrics, configure, logs]; } if (config.is_local_studio) { diff --git a/src/components/instance/status/LogRow.js b/src/components/instance/status/LogRow.js deleted file mode 100644 index d81c3a784..000000000 --- a/src/components/instance/status/LogRow.js +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import { Row, Col } from 'reactstrap'; - -import isObject from '../../../functions/util/isObject'; - -function LogsRow({ level, timestamp, message, tags, thread }) { - return ( -
- - {level?.toUpperCase() || 'UNKNOWN'} - {new Date(timestamp || null).toLocaleDateString()} - {new Date(timestamp || null).toLocaleTimeString()} - {thread} - {tags?.join(', ')} - - {isObject(message) && message.error ? message.error : JSON.stringify(message).slice(1, -1)} - - -
- ); -} - -export default LogsRow; diff --git a/src/components/instance/status/Logs.js b/src/components/instance/status/Logs.js deleted file mode 100644 index 9d59527eb..000000000 --- a/src/components/instance/status/Logs.js +++ /dev/null @@ -1,105 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useStoreState } from 'pullstate'; -import { Card, CardBody, Row, Col, Button } from 'reactstrap'; -import useInterval from 'use-interval'; -import { ErrorBoundary } from 'react-error-boundary'; -import { useParams } from 'react-router-dom'; - -import instanceState from '../../../functions/state/instanceState'; -import config from '../../../config'; - -import readLog from '../../../functions/api/instance/readLog'; -import LogRow from './LogRow'; -import ErrorFallback from '../../shared/ErrorFallback'; -import addError from '../../../functions/api/lms/addError'; - -let controller; - -function Logs() { - const { compute_stack_id } = useParams(); - const auth = useStoreState(instanceState, (s) => s.auth); - const url = useStoreState(instanceState, (s) => s.url); - const logs = useStoreState(instanceState, (s) => s.logs, [compute_stack_id]); - const logsError = useStoreState(instanceState, (s) => s.logsError); - const [autoRefresh, setAutoRefresh] = useState(false); - const [loading, setLoading] = useState(true); - const [lastUpdate, setLastUpdate] = useState(true); - - useEffect(() => { - let isMounted = true; - - const fetchData = async () => { - setLoading(true); - controller = new AbortController(); - await readLog({ auth, url, signal: controller.signal, currentLogCount: logs?.length || 0 }); - if (isMounted) setLoading(false); - }; - - if (auth) fetchData(); - - return () => { - controller?.abort(); - isMounted = false; - }; - // eslint-disable-next-line - }, [auth, lastUpdate]); - - useInterval(() => auth && autoRefresh && setLastUpdate(Date.now()), config.refresh_content_interval); - - return ( - addError({ error: { message: error.message, componentStack } })} - FallbackComponent={ErrorFallback} - > - - logs - - - - - - - - - status - date - - {!loading && logsError ? ( - log fetch error: {new Date().toLocaleTimeString().toLowerCase()} - ) : ( - time - )} - - thread - tags - -
-
- {loading && !logs && !autoRefresh ? ( -
- -
- ) : logs && logs.length ? ( - logs.map((l, i) => ( - // eslint-disable-next-line - - )) - ) : logs && !logs.length ? ( -
no logs found
- ) : ( -
no logs found in this view
- )} -
-
-
-
-
- ); -} - -export default Logs; diff --git a/src/components/instance/status/index.js b/src/components/instance/status/index.js index c04c0f2d9..7ff017c65 100644 --- a/src/components/instance/status/index.js +++ b/src/components/instance/status/index.js @@ -2,7 +2,6 @@ import React from 'react'; import { Col, Row } from 'reactstrap'; import SystemInfo from './SystemInfo'; -import Logs from './Logs'; import Jobs from './Jobs'; import Alarms from './Alarms'; import config from '../../../config'; @@ -13,14 +12,11 @@ function MetricsIndex() { - - - - + {!config.is_local_studio && ( - + )} diff --git a/src/functions/api/instance/readLog.js b/src/functions/api/instance/readLog.js index 93955afee..d949cf09b 100644 --- a/src/functions/api/instance/readLog.js +++ b/src/functions/api/instance/readLog.js @@ -1,11 +1,12 @@ import queryInstance from '../queryInstance'; import instanceState from '../../state/instanceState'; -export default async ({ auth, url, signal }) => { +export default async ({ auth, url, signal, logsFilter }) => { const logs = await queryInstance({ operation: { operation: 'read_log', - limit: 1000, + start: 0, + ...logsFilter, order: 'desc', }, auth,