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,