diff --git a/client/package-lock.json b/client/package-lock.json index 145cf5045..84fc4c3a6 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -26,6 +26,7 @@ "joi": "17.13.3", "lucide-react": "^0.562.0", "mui-color-input": "^7.0.0", + "pretty-bytes": "^7.1.0", "pretty-ms": "^9.3.0", "react": "18.3.1", "react-dom": "^18.2.0", @@ -5265,6 +5266,18 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-bytes": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-7.1.0.tgz", + "integrity": "sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pretty-ms": { "version": "9.3.0", "license": "MIT", @@ -6737,7 +6750,6 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "devOptional": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/client/package.json b/client/package.json index caf4d0856..f0b34dbbc 100644 --- a/client/package.json +++ b/client/package.json @@ -34,6 +34,7 @@ "joi": "17.13.3", "lucide-react": "^0.562.0", "mui-color-input": "^7.0.0", + "pretty-bytes": "^7.1.0", "pretty-ms": "^9.3.0", "react": "18.3.1", "react-dom": "^18.2.0", diff --git a/client/src/Components/v2/design-elements/Gauge.tsx b/client/src/Components/v2/design-elements/Gauge.tsx index 34c57487d..4fee5386c 100644 --- a/client/src/Components/v2/design-elements/Gauge.tsx +++ b/client/src/Components/v2/design-elements/Gauge.tsx @@ -1,3 +1,5 @@ +import { BaseChart } from "@/Components/v2/design-elements"; +import Stack from "@mui/material/Stack"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; @@ -104,3 +106,50 @@ export const Gauge = ({ ); }; + +export const DetailGauge = ({ + title, + progress, + upperLabel, + upperValue, + lowerLabel, + lowerValue, +}: { + title: string; + progress: number; + upperLabel?: string; + upperValue?: string | number; + lowerLabel?: string; + lowerValue?: string | number; +}) => { + const theme = useTheme(); + return ( + + + + + + {upperLabel} + {upperValue} + + + {lowerLabel} + {lowerValue} + + + ); +}; diff --git a/client/src/Components/v2/design-elements/Table.tsx b/client/src/Components/v2/design-elements/Table.tsx index aea8b40a0..35cd4512f 100644 --- a/client/src/Components/v2/design-elements/Table.tsx +++ b/client/src/Components/v2/design-elements/Table.tsx @@ -29,6 +29,7 @@ import type { TablePaginationOwnProps } from "@mui/material/TablePagination"; import { useTranslation } from "react-i18next"; import { useState, Fragment, useEffect } from "react"; import { useTheme } from "@mui/material/styles"; +import type { SxProps, Theme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; export type Header = { @@ -47,6 +48,7 @@ type DataTableProps = renderExpandedContent?: (row: T) => React.ReactNode; emptyViewText?: string; emptyViewPositive?: boolean; + getRowSx?: (row: T) => SxProps; }; export function DataTable< @@ -64,6 +66,7 @@ export function DataTable< renderExpandedContent, emptyViewText, emptyViewPositive, + getRowSx, }: DataTableProps) { const theme = useTheme(); const [expanded, setExpanded] = useState<(string | number) | null>(null); @@ -204,6 +207,7 @@ export function DataTable< { if (expandableRows) handleExpand(row); diff --git a/client/src/Features/UI/uiSlice.js b/client/src/Features/UI/uiSlice.js index 65aff99a8..014940a9a 100644 --- a/client/src/Features/UI/uiSlice.js +++ b/client/src/Features/UI/uiSlice.js @@ -19,6 +19,9 @@ const initialState = { infrastructure: { rowsPerPage: 5, }, + logs: { + rowsPerPage: 15, + }, sidebar: { collapsed: false, }, @@ -41,9 +44,10 @@ const uiSlice = createSlice({ }, setRowsPerPage: (state, action) => { const { table, value } = action.payload; - if (state[table]) { - state[table].rowsPerPage = value; + if (!state[table]) { + state[table] = {}; } + state[table].rowsPerPage = value; }, toggleSidebar: (state) => { state.sidebar.collapsed = !state.sidebar.collapsed; diff --git a/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx b/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx index a72530db4..7e508a545 100644 --- a/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx +++ b/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx @@ -1,6 +1,5 @@ import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import { BaseChart, Gauge } from "@/Components/v2/design-elements"; +import { DetailGauge } from "@/Components/v2/design-elements"; import { useTranslation } from "react-i18next"; import { getGbs, getFrequency } from "@/Utils/InfraUtils"; @@ -8,53 +7,6 @@ import { useTheme } from "@mui/material"; import useMediaQuery from "@mui/material/useMediaQuery"; import type { CheckSnapshot } from "@/Types/Check"; -const InfraDetailGauge = ({ - title, - progress, - upperLabel, - upperValue, - lowerLabel, - lowerValue, -}: { - title: string; - progress: number; - upperLabel?: string; - upperValue?: string | number; - lowerLabel?: string; - lowerValue?: string | number; -}) => { - const theme = useTheme(); - return ( - - - - - - {upperLabel} - {upperValue} - - - {lowerLabel} - {lowerValue} - - - ); -}; - export const InfraDetailsGauges = ({ snapshot, }: { @@ -74,7 +26,7 @@ export const InfraDetailsGauges = ({ spacing={theme.spacing(8)} alignItems={"center"} > - - {snapshot?.disk?.map((disk, idx) => { return ( - { - const theme = useTheme() - return( - - {children} - - ); -}; - -const InfrastructureStyleGauge = ({ value, heading, metricOne, valueOne, metricTwo, valueTwo }) => { - const theme = useTheme(); - - const MetricRow = ({ label, value }) => ( - - {label} - - {value} - - - ); - - return( - - - - - - {heading} - - - - - {metricTwo && valueTwo && ( - - )} - - - - ); -}; - -const Gauges = ({ diagnostics, isLoading }) => { - const heapTotalSize = getPercentage( - diagnostics?.v8HeapStats?.totalHeapSizeBytes, - diagnostics?.v8HeapStats?.heapSizeLimitBytes - ); - - const heapUsedSize = getPercentage( - diagnostics?.v8HeapStats?.usedHeapSizeBytes, - diagnostics?.v8HeapStats?.heapSizeLimitBytes - ); - - const actualHeapUsed = getPercentage( - diagnostics?.v8HeapStats?.usedHeapSizeBytes, - diagnostics?.v8HeapStats?.totalHeapSizeBytes - ); - - const theme = useTheme(); - const { t } = useTranslation(); - - return ( - - - - - - - ); -}; - -Gauges.propTypes = { - diagnostics: PropTypes.object, - isLoading: PropTypes.bool, -}; - -InfrastructureStyleGauge.propTypes = { - value: PropTypes.number, - heading: PropTypes.string, - metricOne: PropTypes.string, - valueOne: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), - metricTwo: PropTypes.string, - valueTwo: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), -}; - -BaseContainer.propTypes = { - children: PropTypes.node.isRequired, -}; - -export default Gauges; diff --git a/client/src/Pages/Logs/Diagnostics/components/stats/index.jsx b/client/src/Pages/Logs/Diagnostics/components/stats/index.jsx deleted file mode 100644 index 13ed63618..000000000 --- a/client/src/Pages/Logs/Diagnostics/components/stats/index.jsx +++ /dev/null @@ -1,97 +0,0 @@ -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import Card from "@mui/material/Card"; -import CardContent from "@mui/material/CardContent"; -import CircularProgress from "@mui/material/CircularProgress"; - -import { useTheme } from "@emotion/react"; -import PropTypes from "prop-types"; - -import { getHumanReadableDuration } from "../../../../../Utils/timeUtilsLegacy.js"; -import { formatBytes } from "../../utils/utils.js"; -import { useTranslation } from "react-i18next"; - -const StatsCard = ({ title, value, unit = "", isLoading }) => { - const theme = useTheme(); - return ( - - {isLoading ? ( - - - - ) : ( - - - {title} - - - {value} {unit} - - - )} - - ); -}; - -StatsCard.propTypes = { - title: PropTypes.string, - value: PropTypes.string, - unit: PropTypes.string, - isLoading: PropTypes.bool, -}; - -const Stats = ({ diagnostics, isLoading }) => { - const theme = useTheme(); - const { t } = useTranslation(); - return ( - - - - - - - - - - - ); -}; - -Stats.propTypes = { - diagnostics: PropTypes.object, - isLoading: PropTypes.bool, -}; - -export default Stats; diff --git a/client/src/Pages/Logs/Diagnostics/index.jsx b/client/src/Pages/Logs/Diagnostics/index.jsx deleted file mode 100644 index 194e13821..000000000 --- a/client/src/Pages/Logs/Diagnostics/index.jsx +++ /dev/null @@ -1,77 +0,0 @@ -import Stack from "@mui/material/Stack"; -import Box from "@mui/material/Box"; -import Gauges from "./components/gauges/index.jsx"; -import Button from "@mui/material/Button"; -import StatBox from "@/Components/v1/StatBox/index.jsx"; -import StatusBoxes from "@/Components/v1/StatusBoxes/index.jsx"; -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import { useFetchDiagnostics } from "../../../Hooks/logHooks.js"; -import { getHumanReadableDuration } from "../../../Utils/timeUtilsLegacy.js"; -import { formatBytes, getPercentage } from "./utils/utils.js"; - -const Diagnostics = () => { - // Local state - - // Hooks - const theme = useTheme(); - const { t } = useTranslation(); - const [diagnostics, fetchDiagnostics, isLoading, error] = useFetchDiagnostics(); - // Setup - return ( - - - - - - - - - - - - - - - ); -}; - -export default Diagnostics; diff --git a/client/src/Pages/Logs/Diagnostics/utils/utils.js b/client/src/Pages/Logs/Diagnostics/utils/utils.js deleted file mode 100644 index a3b75631f..000000000 --- a/client/src/Pages/Logs/Diagnostics/utils/utils.js +++ /dev/null @@ -1,16 +0,0 @@ -export const getPercentage = (value, total) => { - if (!value || !total) return 0; - return (value / total) * 100; -}; - -export const formatBytes = (bytes) => { - if (!bytes) return "N/A"; - - if (bytes === 0) return "0 Bytes"; - - if (bytes >= 1024 * 1024 * 1024) { - return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; - } - - return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; -}; diff --git a/client/src/Pages/Logs/Logs/index.jsx b/client/src/Pages/Logs/Logs/index.jsx deleted file mode 100644 index d0319a387..000000000 --- a/client/src/Pages/Logs/Logs/index.jsx +++ /dev/null @@ -1,176 +0,0 @@ -import Stack from "@mui/material/Stack"; -import Box from "@mui/material/Box"; -import Select from "@/Components/v1/Inputs/Select/index.jsx"; -import Typography from "@mui/material/Typography"; -import DataTable from "@/Components/v1/Table/index.jsx"; -import Pagination from "@/Components/v1/Table/TablePagination/index.jsx"; - -import { useFetchLogs } from "../../../Hooks/logHooks.js"; -import { useTheme } from "@emotion/react"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; - -const LevelBadge = ({ level }) => { - const theme = useTheme(); - - const levelColors = { - info: theme.palette.success.main, - warn: theme.palette.warning.main, - error: theme.palette.error.main, - debug: theme.palette.accent.main, - }; - - const color = levelColors[level] || theme.palette.primary.contrastText; - - return ( - - {level} - - ); -}; - -const formatTimestamp = (timestamp) => { - if (!timestamp) return "-"; - const date = new Date(timestamp); - return date.toLocaleString(); -}; - -const Logs = () => { - const [logLevel, setLogLevel] = useState("all"); - const [page, setPage] = useState(0); - const [rowsPerPage, setRowsPerPage] = useState(15); - - const theme = useTheme(); - const { t } = useTranslation(); - const [logs, isLoading, error] = useFetchLogs(); - - const LOG_LEVELS = [ - { _id: "all", name: t("logsPage.logLevelSelect.values.all") }, - { _id: "info", name: t("logsPage.logLevelSelect.values.info") }, - { _id: "warn", name: t("logsPage.logLevelSelect.values.warn") }, - { _id: "error", name: t("logsPage.logLevelSelect.values.error") }, - { _id: "debug", name: t("logsPage.logLevelSelect.values.debug") }, - ]; - - const headers = [ - { - id: "timestamp", - content: t("logsPage.table.timestamp"), - render: (row) => ( - - {formatTimestamp(row.timestamp)} - - ), - }, - { - id: "level", - content: t("logsPage.table.level"), - render: (row) => , - }, - { - id: "service", - content: t("logsPage.table.service"), - render: (row) => ( - {row.service || "-"} - ), - }, - { - id: "method", - content: t("logsPage.table.method"), - render: (row) => ( - {row.method || "-"} - ), - }, - { - id: "message", - content: t("logsPage.table.message"), - render: (row) => ( - - {row.message || "-"} - - ), - }, - ]; - - const filteredLogs = logs - ?.filter((log) => { - if (logLevel === "all") return true; - return log.level === logLevel; - }) - .reverse() - .map((log, idx) => ({ ...log, id: idx })); - - const paginatedLogs = filteredLogs?.slice( - page * rowsPerPage, - page * rowsPerPage + rowsPerPage - ); - - const handleChangePage = (event, newPage) => { - setPage(newPage); - }; - - const handleChangeRowsPerPage = (event) => { - setRowsPerPage(parseInt(event.target.value, 10)); - setPage(0); - }; - - const handleLogLevelChange = (e) => { - setLogLevel(e.target.value); - setPage(0); - }; - - return ( - - - {t("logsPage.logLevelSelect.title")} - setSelectedLogLevel(e.target.value)} + > + {LOG_LEVEL_OPTIONS.map((level) => { + return ( + + {level} + + ); + })} + + + + ); +}; diff --git a/client/src/Pages/Logs/TabQueue.tsx b/client/src/Pages/Logs/TabQueue.tsx new file mode 100644 index 000000000..be0013368 --- /dev/null +++ b/client/src/Pages/Logs/TabQueue.tsx @@ -0,0 +1,60 @@ +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { TableJobs, TableFailedJobs } from "./components/TableJobs"; + +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useGet, usePost } from "@/Hooks/UseApi"; +import type { QueueData } from "@/Types/Queue"; +import { Metrics } from "@/Pages/Logs/components/Metrics"; +import { Button } from "@/Components/v2/inputs"; + +export const TabQueue = () => { + const theme = useTheme(); + const { t } = useTranslation(); + const { + data: queueData, + // isLoading, + // error, + refetch, + } = useGet("/queue/all-metrics", {}, { refreshInterval: 5000 }); + + const { post, loading: isFlushing } = usePost(); + + const jobs = queueData?.jobs ?? []; + const metrics = queueData?.metrics ?? null; + + const handleFlushQueue = async () => { + await post("/queue/flush", {}); + refetch(); + }; + + return ( + + + + {t("pages.logs.jobQueue")} + + + + {t("pages.logs.failedJobs")} + + + + + + + ); +}; diff --git a/client/src/Pages/Logs/components/Metrics.tsx b/client/src/Pages/Logs/components/Metrics.tsx new file mode 100644 index 000000000..8f96ec5ac --- /dev/null +++ b/client/src/Pages/Logs/components/Metrics.tsx @@ -0,0 +1,50 @@ +import Stack from "@mui/material/Stack"; +import { StatBox } from "@/Components/v2/design-elements"; + +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import type { QueueMetrics } from "@/Types/Queue"; + +interface MetricsProps { + metrics: QueueMetrics | null; +} + +export const Metrics = ({ metrics }: MetricsProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + + if (!metrics) { + return null; + } + + return ( + + + 0 ? "success" : undefined} + title={t("pages.logs.metrics.activeJobs")} + subtitle={String(metrics.activeJobs)} + /> + 0 ? "error" : undefined} + title={t("pages.logs.metrics.failingJobs")} + subtitle={String(metrics.failingJobs)} + /> + + 0 ? "error" : undefined} + title={t("pages.logs.metrics.totalFailures")} + subtitle={String(metrics.totalFailures)} + /> + + ); +}; diff --git a/client/src/Pages/Logs/components/StatGauges.tsx b/client/src/Pages/Logs/components/StatGauges.tsx new file mode 100644 index 000000000..17137f114 --- /dev/null +++ b/client/src/Pages/Logs/components/StatGauges.tsx @@ -0,0 +1,84 @@ +import Stack from "@mui/material/Stack"; +import { DetailGauge } from "@/Components/v2/design-elements"; + +import prettyBytes from "pretty-bytes"; +import { useTranslation } from "react-i18next"; +import { useTheme } from "@mui/material"; +import type { Diagnostics } from "@/Types/Diagnostics"; + +interface StatGaugesProps { + diagnostics: Diagnostics | null; +} + +const getPercentage = (value: number, total: number) => { + if (!value || !total) return 0; + return (value / total) * 100; +}; + +const formatPercentage = new Intl.NumberFormat("en-US", { + style: "percent", + minimumFractionDigits: 1, + maximumFractionDigits: 1, +}); + +export const StatGauges = ({ diagnostics }: StatGaugesProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + if (!diagnostics) { + return null; + } + + const heapTotalSize = getPercentage( + diagnostics?.v8HeapStats?.totalHeapSizeBytes, + diagnostics?.v8HeapStats?.heapSizeLimitBytes + ); + + const heapUsedSize = getPercentage( + diagnostics?.v8HeapStats?.usedHeapSizeBytes, + diagnostics?.v8HeapStats?.heapSizeLimitBytes + ); + + const actualHeapUsed = getPercentage( + diagnostics?.v8HeapStats?.usedHeapSizeBytes, + diagnostics?.v8HeapStats?.totalHeapSizeBytes + ); + + return ( + + + + + + + ); +}; diff --git a/client/src/Pages/Logs/components/Stats.tsx b/client/src/Pages/Logs/components/Stats.tsx new file mode 100644 index 000000000..429330873 --- /dev/null +++ b/client/src/Pages/Logs/components/Stats.tsx @@ -0,0 +1,51 @@ +import Stack from "@mui/material/Stack"; +import { StatBox } from "@/Components/v2/design-elements"; + +import prettyBytes from "pretty-bytes"; +import prettyMilliseconds from "pretty-ms"; +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import type { Diagnostics } from "@/Types/Diagnostics"; + +interface StatsProps { + diagnostics: Diagnostics | null; +} + +export const Stats = ({ diagnostics }: StatsProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + + if (!diagnostics) { + return null; + } + + return ( + + + + + + + + ); +}; diff --git a/client/src/Pages/Logs/components/TableJobs.tsx b/client/src/Pages/Logs/components/TableJobs.tsx new file mode 100644 index 000000000..e9608df9a --- /dev/null +++ b/client/src/Pages/Logs/components/TableJobs.tsx @@ -0,0 +1,164 @@ +import { Table } from "@/Components/v2/design-elements"; +import { Typography, useTheme } from "@mui/material"; +import prettyMilliseconds from "pretty-ms"; +import { formatTimestamp } from "@/Utils/TimeUtils"; + +import { useTranslation } from "react-i18next"; +import type { QueueJobFailure, QueueJobSummary, QueueMetrics } from "@/Types/Queue"; +import type { Header } from "@/Components/v2/design-elements"; + +type QueueJobWithId = QueueJobSummary & { id: string | number }; + +interface TableJobsProps { + jobs: QueueJobSummary[]; +} + +export const TableJobs = ({ jobs }: TableJobsProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + + const jobsWithId: QueueJobWithId[] = jobs.map((job) => ({ + ...job, + id: job.monitorId, + })); + + const headers: Header[] = [ + { + id: "id", + content: t("common.table.headers.monitorId"), + render: (row) => {row.monitorId}, + }, + { + id: "url", + content: t("common.table.headers.url"), + render: (row) => {row.monitorUrl}, + }, + { + id: "interval", + content: t("common.table.headers.interval"), + render: (row) => ( + {prettyMilliseconds(row.monitorInterval ?? 0)} + ), + }, + { + id: "type", + content: t("common.table.headers.type"), + render: (row) => {row.monitorType}, + }, + { + id: "active", + content: t("common.table.headers.active"), + render: (row) => {row.active.toString()}, + }, + { + id: "runCount", + content: t("pages.logs.table.headers.runCount"), + render: (row) => {row.runCount}, + }, + { + id: "failCount", + content: t("pages.logs.table.headers.failCount"), + render: (row) => {row.failCount}, + }, + { + id: "lastRun", + content: t("pages.logs.table.headers.lastRunAt"), + render: (row) => {formatTimestamp(row.lastRunAt)}, + }, + { + id: "lockedAt", + content: t("pages.logs.table.headers.lockedAt"), + render: (row) => {formatTimestamp(row.lockedAt)}, + }, + + { + id: "lastFinish", + content: t("pages.logs.table.headers.lastFinishedAt"), + render: (row) => {formatTimestamp(row.lastFinishedAt)}, + }, + { + id: "lastRunTook", + content: t("pages.logs.table.headers.lastRunTook"), + render: (row) => { + const value = row.lastRunTook ? prettyMilliseconds(row.lastRunTook) : "-"; + return {value}; + }, + }, + ]; + + return ( + ({ + ...(row.lockedAt && { + "& td": { backgroundColor: theme.palette.success.light }, + }), + })} + /> + ); +}; + +type QueueJobFailureWithId = QueueJobFailure & { id: string | number }; + +interface TableFailedJobsProps { + metrics: QueueMetrics | null; +} +export const TableFailedJobs = ({ metrics }: TableFailedJobsProps) => { + const { t } = useTranslation(); + if (!metrics) { + return null; + } + + const jobFailuresWithId: QueueJobFailureWithId[] = metrics.jobsWithFailures.map( + (job) => ({ + ...job, + id: job.monitorId, + }) + ); + + const headers: Header[] = [ + { + id: "monitorId", + content: t("common.table.headers.monitorId"), + render: (row) => { + return {row.monitorId}; + }, + }, + { + id: "monitorUrl", + content: t("common.table.headers.url"), + render: (row) => { + return {row.monitorUrl}; + }, + }, + { + id: "failCount", + content: t("pages.logs.table.headers.failCount"), + render: (row) => { + return {row.failCount}; + }, + }, + { + id: "failedAt", + content: t("pages.logs.table.headers.lastFailedAt"), + render: (row) => { + return {formatTimestamp(row.failedAt)}; + }, + }, + { + id: "failReason", + content: t("pages.logs.table.headers.failReason"), + render: (row) => { + return {row.failReason}; + }, + }, + ]; + + return ( +
+ ); +}; diff --git a/client/src/Pages/Logs/components/TableLogs.tsx b/client/src/Pages/Logs/components/TableLogs.tsx new file mode 100644 index 000000000..dace0db43 --- /dev/null +++ b/client/src/Pages/Logs/components/TableLogs.tsx @@ -0,0 +1,128 @@ +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { Table } from "@/Components/v2/design-elements"; +import { Pagination } from "@/Components/v2/design-elements/Table"; +import { useTheme } from "@mui/material"; +import type { Header } from "@/Components/v2/design-elements/Table"; +import type { Log, LogLevel } from "@/Types/Log"; +import { useTranslation } from "react-i18next"; +import { useSelector, useDispatch } from "react-redux"; +import type { RootState } from "@/Types/state"; +import { setRowsPerPage } from "@/Features/UI/uiSlice"; +import { formatTimestamp } from "@/Utils/TimeUtils"; + +type LogWithId = Log & { id: number }; + +interface TableLogsProps { + logs: LogWithId[]; + logCount: number; + page: number; + setPage: (page: number) => void; +} + +const LevelBadge = ({ level }: { level: LogLevel }) => { + const theme = useTheme(); + + const levelColors: Record = { + info: theme.palette.success.main, + warn: theme.palette.warning.main, + error: theme.palette.error.main, + debug: theme.palette.info.main, + }; + + const color = levelColors[level] || theme.palette.text.primary; + + return ( + + {level} + + ); +}; + +export const TableLogs = ({ logs, logCount, page, setPage }: TableLogsProps) => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const rowsPerPage = useSelector( + (state: RootState) => state?.ui?.logs?.rowsPerPage ?? 15 + ); + + const headers: Header[] = [ + { + id: "timestamp", + content: t("pages.logs.table.headers.timestamp"), + render: (row) => ( + + {formatTimestamp(row.timestamp)} + + ), + }, + { + id: "level", + content: t("pages.logs.table.headers.level"), + render: (row) => , + }, + { + id: "service", + content: t("pages.logs.table.headers.service"), + render: (row) => {row.service || "-"}, + }, + { + id: "method", + content: t("pages.logs.table.headers.method"), + render: (row) => ( + {row.method || "-"} + ), + }, + { + id: "message", + content: t("common.table.headers.message"), + render: (row) => ( + + {row.message || "-"} + + ), + }, + ]; + + const handlePageChange = ( + _e: React.MouseEvent | null, + newPage: number + ) => { + setPage(newPage); + }; + + const handleRowsPerPageChange = ( + e: React.ChangeEvent + ) => { + dispatch(setRowsPerPage({ value: Number(e.target.value), table: "logs" })); + setPage(0); + }; + + return ( + +
+ + + ); +}; diff --git a/client/src/Pages/Logs/index.jsx b/client/src/Pages/Logs/index.jsx deleted file mode 100644 index e5bdf623d..000000000 --- a/client/src/Pages/Logs/index.jsx +++ /dev/null @@ -1,50 +0,0 @@ -import Stack from "@mui/material/Stack"; -import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx"; -import Tabs from "@mui/material/Tabs"; -import Tab from "@mui/material/Tab"; -import Queue from "./Queue/index.jsx"; -import LogsComponent from "./Logs/index.jsx"; -import Diagnostics from "./Diagnostics/index.jsx"; - -import { useTheme } from "@emotion/react"; -import { useTranslation } from "react-i18next"; -import { useState } from "react"; - -const Logs = () => { - const { t } = useTranslation(); - const theme = useTheme(); - - // Local state - const [value, setValue] = useState(0); - - // Handlers - const handleChange = (event, newValue) => { - setValue(newValue); - }; - - const BREADCRUMBS = [{ name: t("logsPage.title"), path: "/logs" }]; - return ( - - - - - - - - {value === 0 && } - {value === 1 && } - {value === 2 && } - - ); -}; - -export default Logs; diff --git a/client/src/Pages/Logs/index.tsx b/client/src/Pages/Logs/index.tsx new file mode 100644 index 000000000..ab30b84eb --- /dev/null +++ b/client/src/Pages/Logs/index.tsx @@ -0,0 +1,29 @@ +import { BasePage, Tabs, Tab } from "@/Components/v2/design-elements"; +import { TabLogs } from "@/Pages/Logs/TabLogs"; +import { TabQueue } from "@/Pages/Logs/TabQueue"; +import { TabDiagnostics } from "@/Pages/Logs/TabDiagnostics"; + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +const LogsPage = () => { + const { t } = useTranslation(); + const [activeTab, setActiveTab] = useState(2); + return ( + + setActiveTab(newValue)} + > + + + + + {activeTab === 0 && } + {activeTab === 1 && } + {activeTab === 2 && } + + ); +}; + +export default LogsPage; diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index d4a5619d6..22b5b446a 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -55,7 +55,7 @@ import ProtectedRoute from "../Components/v1/ProtectedRoute"; import RoleProtectedRoute from "../Components/v1/RoleProtectedRoute"; import withAdminCheck from "@/Components/v1/HOC/withAdminCheck"; import BulkImport from "../Pages/Uptime/BulkImport/index.jsx"; -import Logs from "../Pages/Logs/index.jsx"; +import Logs from "../Pages/Logs"; import CreateMonitor from "@/Pages/CreateMonitor"; @@ -350,7 +350,9 @@ const Routes = () => { path="logs" element={ - + + + } /> diff --git a/client/src/Types/Diagnostics.ts b/client/src/Types/Diagnostics.ts new file mode 100644 index 000000000..fe2ea6f16 --- /dev/null +++ b/client/src/Types/Diagnostics.ts @@ -0,0 +1,33 @@ +export interface Diagnostics { + osStats: OsStats; + memoryUsage: MemoryUsage; + cpuUsage: CpuUsage; + v8HeapStats: V8HeapStats; + eventLoopDelayMs: number; + uptimeMs: number; +} + +export interface OsStats { + freeMemoryBytes: number; + totalMemoryBytes: number; +} + +export interface MemoryUsage { + rss: number; + heapTotal: number; + heapUsed: number; + external: number; + arrayBuffers: number; +} + +export interface CpuUsage { + userUsageMs: number; + systemUsageMs: number; + usagePercentage: number; +} + +export interface V8HeapStats { + totalHeapSizeBytes: number; + usedHeapSizeBytes: number; + heapSizeLimitBytes: number; +} diff --git a/client/src/Types/Log.ts b/client/src/Types/Log.ts new file mode 100644 index 000000000..b2038e31f --- /dev/null +++ b/client/src/Types/Log.ts @@ -0,0 +1,13 @@ +export const LOG_LEVELS = ["debug", "info", "warn", "error"] as const; +export type LogLevel = (typeof LOG_LEVELS)[number]; +export const LOG_LEVEL_OPTIONS = ["all", ...LOG_LEVELS] as const; +export type LogLevelOption = (typeof LOG_LEVEL_OPTIONS)[number]; +export interface Log { + message: string; + service?: string; + method?: string; + details?: Record; + stack?: string; + level: LogLevel; + timestamp: string; +} diff --git a/client/src/Types/Queue.ts b/client/src/Types/Queue.ts new file mode 100644 index 000000000..77a770aeb --- /dev/null +++ b/client/src/Types/Queue.ts @@ -0,0 +1,40 @@ +import type { MonitorType } from "./Monitor"; + +export interface QueueJobFailure { + monitorId: string | number; + monitorUrl: string | null; + monitorType: MonitorType | null; + failedAt: number | null; + failCount: number; + failReason: string | null; +} + +export interface QueueMetrics { + jobs: number; + activeJobs: number; + failingJobs: number; + jobsWithFailures: QueueJobFailure[]; + totalRuns: number; + totalFailures: number; +} + +export interface QueueJobSummary { + monitorId: string | number; + monitorUrl: string | null; + monitorType: MonitorType | null; + monitorInterval: number | null; + active: boolean; + lockedAt: number | null; + runCount: number; + failCount: number; + failReason: string | null; + lastRunAt: number | null; + lastFinishedAt: number | null; + lastRunTook: number | null; + lastFailedAt: number | null; +} + +export interface QueueData { + jobs: QueueJobSummary[]; + metrics: QueueMetrics; +} diff --git a/client/src/Types/state.ts b/client/src/Types/state.ts index 137d67b66..1575fffb8 100644 --- a/client/src/Types/state.ts +++ b/client/src/Types/state.ts @@ -19,6 +19,9 @@ export interface UIState { infrastructure: { rowsPerPage: number; }; + logs: { + rowsPerPage: number; + }; sidebar: { collapsed: boolean; }; diff --git a/client/src/Utils/TimeUtils.ts b/client/src/Utils/TimeUtils.ts index a19a0bf8b..abd56ce44 100644 --- a/client/src/Utils/TimeUtils.ts +++ b/client/src/Utils/TimeUtils.ts @@ -50,3 +50,9 @@ export const tooltipDateFormatLookup = (range: string) => { } return format; }; + +export const formatTimestamp = (timestamp: string | number | null): string => { + if (!timestamp) return "-"; + const date = new Date(timestamp); + return date.toLocaleString(); +}; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 644057425..4bd280dcf 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -199,7 +199,8 @@ "save": "Save", "test": "Test", "testNotifications": "Test notifications", - "toggleTheme": "Toggles light & dark" + "toggleTheme": "Toggles light & dark", + "flushQueue": "Flush queue" }, "charts": { "labels": { @@ -230,9 +231,13 @@ "dateTime": "Date & time", "message": "Message", "monitor": "Monitor", + "monitorId": "Monitor ID", "name": "Name", "status": "Status", - "type": "Type" + "type": "Type", + "url": "Url", + "interval": "Interval", + "active": "Active" } }, "toasts": { @@ -777,6 +782,63 @@ "title": "An infrastructure monitor is used to:" } }, + "logs": { + "tabs": { + "diagnostics": "Diagnostics", + "logs": "Logs", + "queue": "Job queue" + }, + "logLevelSelect": { + "label": "Log level" + }, + "jobQueue": "Job queue", + "failedJobs": "Failed jobs", + "noLogs": "No logs found", + "metrics": { + "jobs": "Jobs", + "activeJobs": "Active jobs", + "failingJobs": "Failing jobs", + "totalRuns": "Total runs", + "totalFailures": "Total failures" + }, + "diagnostics": { + "stats": { + "eventLoopDelay": "Event loop delay", + "uptime": "Uptime", + "usedHeapSize": "Used heap size", + "totalHeapSize": "Total heap size", + "osMemoryLimit": "OS memory limit" + }, + "gauges": { + "heapAllocation": "Heap allocaton", + "heapUsage": "Heap usage", + "heapUtilization": "Heap utilization", + "instantCpuUsage": "Instant CPU usage", + "availableMemoryPercentage": "% of available memory", + "allocatedPercentage": "% allocated", + "usedSPercentage": "% of 1s used by CPU", + "total": "Total", + "used": "Used" + } + }, + "table": { + "headers": { + "timestamp": "Timestamp", + "level": "Level", + "service": "Service", + "method": "Method", + "monitorId": "Monitor ID", + "runCount": "Run count", + "failCount": "Fail count", + "lastRunAt": "Last run at", + "lockedAt": "Locked at", + "lastFinishedAt": "Last finished at", + "lastRunTook": "Last run took", + "lastFailedAt": "Last failed at", + "failReason": "Fail reason" + } + } + }, "maintenanceWindow": { "fallback": { "actionButton": "Let's create your first maintenance window!", diff --git a/client/vite.config.ts b/client/vite.config.ts index 3d8d66335..f381eb5ed 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -7,7 +7,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); export default defineConfig(({}) => { - let version = "3.2.1"; + let version = "3.3"; return { base: "/",