diff --git a/components/log-viewer-webui/client/src/pages/IngestPage/Details/Files.tsx b/components/log-viewer-webui/client/src/pages/IngestPage/Details/Files.tsx index a40662014e..43cb5ea78e 100644 --- a/components/log-viewer-webui/client/src/pages/IngestPage/Details/Files.tsx +++ b/components/log-viewer-webui/client/src/pages/IngestPage/Details/Files.tsx @@ -1,19 +1,21 @@ import DetailsCard from "./DetailsCard"; -// eslint-disable-next-line no-warning-comments -// TODO: Replace with values from database once api implemented. -const DUMMY_FILES = 124; +interface FilesProps { + numFiles: number; +} /** * Renders the files statistic. * + * @param props + * @param props.numFiles * @return */ -const Files = () => { +const Files = ({numFiles}: FilesProps) => { return ( ); }; diff --git a/components/log-viewer-webui/client/src/pages/IngestPage/Details/Messages.tsx b/components/log-viewer-webui/client/src/pages/IngestPage/Details/Messages.tsx index ebf4885807..6d020df46f 100644 --- a/components/log-viewer-webui/client/src/pages/IngestPage/Details/Messages.tsx +++ b/components/log-viewer-webui/client/src/pages/IngestPage/Details/Messages.tsx @@ -1,19 +1,21 @@ import DetailsCard from "./DetailsCard"; -// eslint-disable-next-line no-warning-comments -// TODO: Replace with values from database once api implemented. -const DUMMY_MESSAGES = 1235844; +interface MessagesProps { + numMessages: number; +} /** * Renders the messages statistic. * + * @param props + * @param props.numMessages * @return */ -const Messages = () => { +const Messages = ({numMessages}: MessagesProps) => { return ( ); }; diff --git a/components/log-viewer-webui/client/src/pages/IngestPage/Details/TimeRange.tsx b/components/log-viewer-webui/client/src/pages/IngestPage/Details/TimeRange.tsx index 7609d27b56..50230fedba 100644 --- a/components/log-viewer-webui/client/src/pages/IngestPage/Details/TimeRange.tsx +++ b/components/log-viewer-webui/client/src/pages/IngestPage/Details/TimeRange.tsx @@ -1,23 +1,27 @@ -import dayjs from "dayjs"; +import {Dayjs} from "dayjs"; +import {Nullable} from "src/typings/common"; import DetailsCard from "./DetailsCard"; -// eslint-disable-next-line no-warning-comments -// TODO: Replace with values from database once api implemented. -const DUMMY_START_DATE = "2021-12-14"; -const DUMMY_END_DATE = "2025-04-16"; - const DATE_FORMAT = "MMMM D, YYYY"; +interface TimeRangeProps { + beginDate: Nullable; + endDate: Nullable; +} + /** * Renders the time range statistic. * + * @param props + * @param props.beginDate + * @param props.endDate * @return */ -const TimeRange = () => { - const formattedStat = `${dayjs(DUMMY_START_DATE).format(DATE_FORMAT)} - - ${dayjs(DUMMY_END_DATE).format(DATE_FORMAT)}`; +const TimeRange = ({beginDate, endDate}: TimeRangeProps) => { + const formattedStat = `${beginDate?.format(DATE_FORMAT) ?? "Unknown Begin Date"} - + ${endDate?.format(DATE_FORMAT) ?? "Unknown End Date"}`; return ( { + const {refreshInterval} = useIngestStatsStore(); + const [beginDate, setBeginDate] = useState>(DETAILS_DEFAULT.beginDate); + const [endDate, setEndDate] = useState>(DETAILS_DEFAULT.endDate); + const [numFiles, setNumFiles] = useState(DETAILS_DEFAULT.numFiles); + const [numMessages, setNumMessages] = useState(DETAILS_DEFAULT.numMessages); + const intervalIdRef = useRef>(SET_INTERVAL_INVALID_ID); + + /** + * Fetches details stats from the server. + * + * @throws {Error} If the response is undefined. + */ + const fetchDetailsStats = useCallback(async () => { + const {data: [resp]} = await querySql(getDetailsSql()); + if ("undefined" === typeof resp) { + throw new Error("Details response is undefined"); + } + setBeginDate(dayjs(resp.begin_timestamp)); + setEndDate(dayjs(resp.end_timestamp)); + setNumFiles(resp.num_files); + setNumMessages(resp.num_messages); + }, []); + + useEffect(() => { + // eslint-disable-next-line no-void + void fetchDetailsStats(); + intervalIdRef.current = setInterval(fetchDetailsStats, refreshInterval); + + return () => { + clearInterval(intervalIdRef.current); + }; + }, [ + refreshInterval, + fetchDetailsStats, + ]); + + return (
- +
- - + +
); }; diff --git a/components/log-viewer-webui/client/src/pages/IngestPage/Details/sql.ts b/components/log-viewer-webui/client/src/pages/IngestPage/Details/sql.ts new file mode 100644 index 0000000000..f1f04c909c --- /dev/null +++ b/components/log-viewer-webui/client/src/pages/IngestPage/Details/sql.ts @@ -0,0 +1,47 @@ +import { + CLP_ARCHIVES_TABLE_COLUMN_NAMES, + CLP_FILES_TABLE_COLUMN_NAMES, + SQL_CONFIG, +} from "../sqlConfig"; + + +/** + * Builds the query string to query stats. + * + * @return + */ +const getDetailsSql = () => ` +SELECT + a.begin_timestamp AS begin_timestamp, + a.end_timestamp AS end_timestamp, + b.num_files AS num_files, + b.num_messages AS num_messages +FROM +( + SELECT + MIN(${CLP_ARCHIVES_TABLE_COLUMN_NAMES.BEGIN_TIMESTAMP}) AS begin_timestamp, + MAX(${CLP_ARCHIVES_TABLE_COLUMN_NAMES.END_TIMESTAMP}) AS end_timestamp + FROM ${SQL_CONFIG.SqlDbClpArchivesTableName} +) a, +( + SELECT + NULLIF(COUNT(DISTINCT ${CLP_FILES_TABLE_COLUMN_NAMES.ORIG_FILE_ID}), 0) AS num_files, + SUM(${CLP_FILES_TABLE_COLUMN_NAMES.NUM_MESSAGES}) AS num_messages + FROM ${SQL_CONFIG.SqlDbClpFilesTableName} +) b; +`; + +interface DetailsItem { + begin_timestamp: number; + end_timestamp: number; + num_files: number; + num_messages: number; +} + +type DetailsResp = DetailsItem[]; + +export type { + DetailsItem, + DetailsResp, +}; +export {getDetailsSql}; diff --git a/components/log-viewer-webui/client/src/pages/IngestPage/Jobs/index.tsx b/components/log-viewer-webui/client/src/pages/IngestPage/Jobs/index.tsx index 80671e5d92..08925325d5 100644 --- a/components/log-viewer-webui/client/src/pages/IngestPage/Jobs/index.tsx +++ b/components/log-viewer-webui/client/src/pages/IngestPage/Jobs/index.tsx @@ -1,76 +1,40 @@ +import { + useCallback, + useEffect, + useRef, + useState, +} from "react"; + import {Table} from "antd"; +import dayjs from "dayjs"; import {DashboardCard} from "../../../components/DashboardCard"; +import {SET_INTERVAL_INVALID_ID} from "../../../typings/time"; +import useIngestStatsStore from "../ingestStatsStore"; +import {querySql} from "../sqlConfig"; import styles from "./index.module.css"; +import { + getQueryJobsSql, + QueryJobsResp, +} from "./sql"; import { jobColumns, JobData, } from "./typings"; +import {convertQueryJobsItemToJobData} from "./utils"; -// eslint-disable-next-line no-warning-comments -// TODO: Replace with values from database once api implemented. -const DUMMY_DATA: JobData[] = [ - { - compressedSize: "460 B", - dataIngested: "267 B", - jobId: "1", - key: "1", - speed: "66 B/s", - status: "success", - }, - { - compressedSize: "5 KB", - dataIngested: "50 KB", - jobId: "3", - key: "3", - speed: "10 KB/s", - status: "success", - }, - { - compressedSize: "800 B", - dataIngested: "1 KB", - jobId: "5", - key: "5", - speed: "500 B/s", - status: "success", - }, - { - compressedSize: "1 KB", - dataIngested: "17 KB", - jobId: "2", - key: "2", - speed: "5 KB/s", - status: "processing", - }, - { - compressedSize: "8 MB", - dataIngested: "10 MB", - jobId: "4", - key: "4", - speed: "1 MB/s", - status: "processing", - }, - { - compressedSize: "0 B", - dataIngested: "0 B", - jobId: "6", - key: "6", - speed: "0 B/s", - status: "error", - }, - { - compressedSize: "450 B", - dataIngested: "500 B", - jobId: "7", - key: "7", - speed: "100 B/s", - status: "warning", - }, -]; +const DAYS_TO_SHOW: number = 30; + +/** + * Default state for jobs. + */ +const JOBS_DEFAULT = Object.freeze({ + jobs: [], +}); interface JobsProps { - className?: string; + className: string; } /** @@ -81,13 +45,47 @@ interface JobsProps { * @return */ const Jobs = ({className}: JobsProps) => { + const {refreshInterval} = useIngestStatsStore(); + const [jobs, setJobs] = useState(JOBS_DEFAULT.jobs); + const intervalIdRef = useRef>(SET_INTERVAL_INVALID_ID); + + /** + * Fetches jobs stats from the server. + * + * @throws {Error} If the response is undefined. + */ + const fetchJobsStats = useCallback(async () => { + const beginTimestamp = dayjs().subtract(DAYS_TO_SHOW, "days") + .unix(); + const {data: resp} = await querySql(getQueryJobsSql(beginTimestamp)); + const newJobs = resp + .map((item): JobData => convertQueryJobsItemToJobData(item)); + + setJobs(newJobs); + }, []); + + + useEffect(() => { + // eslint-disable-next-line no-void + void fetchJobsStats(); + intervalIdRef.current = setInterval(fetchJobsStats, refreshInterval); + + return () => { + clearInterval(intervalIdRef.current); + }; + }, [ + refreshInterval, + fetchJobsStats, + ]); + + return (
className={styles["jobs"] || ""} columns={jobColumns} - dataSource={DUMMY_DATA} + dataSource={jobs} pagination={false}/>
diff --git a/components/log-viewer-webui/client/src/pages/IngestPage/Jobs/sql.ts b/components/log-viewer-webui/client/src/pages/IngestPage/Jobs/sql.ts new file mode 100644 index 0000000000..e0256667c1 --- /dev/null +++ b/components/log-viewer-webui/client/src/pages/IngestPage/Jobs/sql.ts @@ -0,0 +1,48 @@ +import {Nullable} from "src/typings/common"; + +import { + COMPRESSION_JOBS_TABLE_COLUMN_NAMES, + SQL_CONFIG, +} from "../sqlConfig"; + + +/** + * Builds the query string to query jobs. + * + * @param lastUpdateTimestampSeconds + * @return + */ +const getQueryJobsSql = (lastUpdateTimestampSeconds: number) => ` +SELECT + UNIX_TIMESTAMP() as retrieval_time, + id as _id, + ${COMPRESSION_JOBS_TABLE_COLUMN_NAMES.STATUS}, + ${COMPRESSION_JOBS_TABLE_COLUMN_NAMES.STATUS_MSG}, + ${COMPRESSION_JOBS_TABLE_COLUMN_NAMES.START_TIME}, + ${COMPRESSION_JOBS_TABLE_COLUMN_NAMES.UPDATE_TIME}, + ${COMPRESSION_JOBS_TABLE_COLUMN_NAMES.DURATION}, + ${COMPRESSION_JOBS_TABLE_COLUMN_NAMES.UNCOMPRESSED_SIZE}, + ${COMPRESSION_JOBS_TABLE_COLUMN_NAMES.COMPRESSED_SIZE} +FROM ${SQL_CONFIG.SqlDbCompressionJobsTableName} +WHERE ${COMPRESSION_JOBS_TABLE_COLUMN_NAMES.UPDATE_TIME} >= + FROM_UNIXTIME(${lastUpdateTimestampSeconds}) - 1 +ORDER BY _id DESC;`; + +interface QueryJobsItem { + compressed_size: number; + duration: Nullable; + retrieval_time: number; + start_time: Nullable; + status: number; + status_msg: string; + uncompressed_size: number; + update_time: string; + _id: number; +} + +type QueryJobsResp = QueryJobsItem[]; + +export type { + QueryJobsItem, QueryJobsResp, +}; +export {getQueryJobsSql}; diff --git a/components/log-viewer-webui/client/src/pages/IngestPage/Jobs/units.ts b/components/log-viewer-webui/client/src/pages/IngestPage/Jobs/units.ts new file mode 100644 index 0000000000..0ca72cb1d5 --- /dev/null +++ b/components/log-viewer-webui/client/src/pages/IngestPage/Jobs/units.ts @@ -0,0 +1,40 @@ +/* eslint-disable @stylistic/array-element-newline */ +const SI_UNITS = ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB", "RB", "QB"]; +const IEC_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB", "RiB", "QiB"]; +/* eslint-enable @stylistic/array-element-newline */ + +/** + * Formats the given size value using either SI (kB, MB, etc.) or IEC (KiB, MiB, etc.) units. + * + * @param value The input value in bytes. + * @param useSiUnits Whether to use SI units. + * @param numFractionalDigits Number of digits to keep after the decimal point. + * @return The value formatted as " ". + */ +const formatSizeInBytes = ( + value: number, + useSiUnits: boolean = true, + numFractionalDigits: number = 1 +): string => { + const units = useSiUnits ? + SI_UNITS : + IEC_UNITS; + const divisor = useSiUnits ? + 10 ** 3 : + 2 ** 10; + + const multiplier = 10 ** numFractionalDigits; + let unitIdx = 0; + while (unitIdx < units.length - 1) { + const roundedValue = Math.round(Math.abs(value) * multiplier) / multiplier; + if (roundedValue < divisor) { + break; + } + value /= divisor; + ++unitIdx; + } + + return `${value.toFixed(numFractionalDigits)} ${units[unitIdx]}`; +}; + +export {formatSizeInBytes}; diff --git a/components/log-viewer-webui/client/src/pages/IngestPage/Jobs/utils.ts b/components/log-viewer-webui/client/src/pages/IngestPage/Jobs/utils.ts new file mode 100644 index 0000000000..e106c9f3cb --- /dev/null +++ b/components/log-viewer-webui/client/src/pages/IngestPage/Jobs/utils.ts @@ -0,0 +1,80 @@ +import {PresetStatusColorType} from "antd/es/_util/colors"; +import dayjs from "dayjs"; + +import {JobData} from "../Jobs/typings"; +import {QueryJobsItem} from "./sql"; +import {formatSizeInBytes} from "./units"; + + +/** + * Compression job statuses, matching the `CompressionJobStatus` class in + * `job_orchestration.scheduler.constants`. + */ +enum CompressionJobStatus { + PENDING = 0, + RUNNING = 1, + SUCCEEDED = 2, + FAILED = 3, +} + +/** + * Map from Job Status to Antd status color name + */ +const JOB_STATUS_TO_DISPLAY_NAME: Record< + CompressionJobStatus, + PresetStatusColorType +> = Object.freeze({ + [CompressionJobStatus.PENDING]: "warning", + [CompressionJobStatus.RUNNING]: "processing", + [CompressionJobStatus.SUCCEEDED]: "success", + [CompressionJobStatus.FAILED]: "error", +}); + +/** + * Convert a QueryJobsItem to JobData + * + * @param job + * @return + */ +const convertQueryJobsItemToJobData = (job: QueryJobsItem): JobData => { + let uncompressedSizeText = ""; + let compressedSizeText = ""; + let speedText = ""; + + if (null === job.duration) { + if (null !== job.start_time) { + job.duration = dayjs().unix() - dayjs(job.start_time).unix(); + } else { + speedText = "N/A"; + } + } + + const uncompressedSize = Number(job.uncompressed_size); + if (false === isNaN(uncompressedSize) && 0 !== uncompressedSize) { + uncompressedSizeText = formatSizeInBytes(uncompressedSize); + } + + const compressedSize = Number(job.compressed_size); + if (false === isNaN(compressedSize) && 0 !== compressedSize) { + compressedSizeText = formatSizeInBytes(compressedSize); + } + + if (false === isNaN(uncompressedSize) && + 0 !== uncompressedSize && + null !== job.duration && + 0 < job.duration + ) { + speedText = `${formatSizeInBytes(uncompressedSize / job.duration)}/s`; + } + + return { + compressedSize: compressedSizeText, + dataIngested: uncompressedSizeText, + jobId: String(job._id), + key: String(job._id), + speed: speedText, + status: JOB_STATUS_TO_DISPLAY_NAME[job.status as CompressionJobStatus], + }; +}; + +export {convertQueryJobsItemToJobData}; diff --git a/components/log-viewer-webui/client/src/pages/IngestPage/SpaceSavings/index.tsx b/components/log-viewer-webui/client/src/pages/IngestPage/SpaceSavings/index.tsx index 9d959820a4..0954cc2671 100644 --- a/components/log-viewer-webui/client/src/pages/IngestPage/SpaceSavings/index.tsx +++ b/components/log-viewer-webui/client/src/pages/IngestPage/SpaceSavings/index.tsx @@ -1,12 +1,30 @@ +import { + useCallback, + useEffect, + useRef, + useState, +} from "react"; + import {theme} from "antd"; import StatCard from "../../../components/StatCard"; +import {SET_INTERVAL_INVALID_ID} from "../../../typings/time"; +import useIngestStatsStore from "../ingestStatsStore"; +import {querySql} from "../sqlConfig"; +import { + getSpaceSavingsSql, + SpaceSavingsResp, +} from "./sql"; + +/** + * Default state for space savings. + */ +const SPACE_SAVINGS_DEFAULT = Object.freeze({ + compressedSize: 0, + uncompressedSize: 0, +}); -// eslint-disable-next-line no-warning-comments -// TODO: Replace with values from database once api implemented. -const DUMMY_COMPRESSED_SIZE = 1004023; -const DUMMY_UNCOMPRESSED_SIZE = 110300010; /** * Renders space savings card. @@ -14,9 +32,36 @@ const DUMMY_UNCOMPRESSED_SIZE = 110300010; * @return */ const SpaceSavings = () => { + const {refreshInterval} = useIngestStatsStore(); + const [compressedSize, setCompressedSize] = + useState(SPACE_SAVINGS_DEFAULT.compressedSize); + const [uncompressedSize, setUncompressedSize] = + useState(SPACE_SAVINGS_DEFAULT.uncompressedSize); const {token} = theme.useToken(); - const compressedSize = DUMMY_COMPRESSED_SIZE as number; - const uncompressedSize = DUMMY_UNCOMPRESSED_SIZE as number; + const intervalIdRef = useRef>(SET_INTERVAL_INVALID_ID); + + const fetchSpaceSavingsStats = useCallback(async () => { + const {data: [resp]} = await querySql(getSpaceSavingsSql()); + if ("undefined" === typeof resp) { + throw new Error("Space savings response is undefined"); + } + setCompressedSize(resp.total_compressed_size); + setUncompressedSize(resp.total_uncompressed_size); + }, []); + + useEffect(() => { + // eslint-disable-next-line no-void + void fetchSpaceSavingsStats(); + intervalIdRef.current = setInterval(fetchSpaceSavingsStats, refreshInterval); + + return () => { + clearInterval(intervalIdRef.current); + }; + }, [ + refreshInterval, + fetchSpaceSavingsStats, + ]); + const spaceSavingsPercent = (0 !== uncompressedSize) ? 100 * (1 - (compressedSize / uncompressedSize)) : diff --git a/components/log-viewer-webui/client/src/pages/IngestPage/SpaceSavings/sql.ts b/components/log-viewer-webui/client/src/pages/IngestPage/SpaceSavings/sql.ts new file mode 100644 index 0000000000..37504f2cb5 --- /dev/null +++ b/components/log-viewer-webui/client/src/pages/IngestPage/SpaceSavings/sql.ts @@ -0,0 +1,30 @@ +import { + CLP_ARCHIVES_TABLE_COLUMN_NAMES, + SQL_CONFIG, +} from "../sqlConfig"; + + +/** + * Builds the query string to query stats. + * + * @return + */ +const getSpaceSavingsSql = () => ` +SELECT + SUM(${CLP_ARCHIVES_TABLE_COLUMN_NAMES.UNCOMPRESSED_SIZE}) AS total_uncompressed_size, + SUM(${CLP_ARCHIVES_TABLE_COLUMN_NAMES.SIZE}) AS total_compressed_size +FROM ${SQL_CONFIG.SqlDbClpArchivesTableName} +`; + +interface SpaceSavingsItem { + total_uncompressed_size: number; + total_compressed_size: number; +} + +type SpaceSavingsResp = SpaceSavingsItem[]; + +export type { + SpaceSavingsItem, + SpaceSavingsResp, +}; +export {getSpaceSavingsSql}; diff --git a/components/log-viewer-webui/client/src/pages/IngestPage/ingestStatsStore.ts b/components/log-viewer-webui/client/src/pages/IngestPage/ingestStatsStore.ts new file mode 100644 index 0000000000..3aea09deb9 --- /dev/null +++ b/components/log-viewer-webui/client/src/pages/IngestPage/ingestStatsStore.ts @@ -0,0 +1,29 @@ +import {create} from "zustand"; + + +interface IngestStatsValues { + refreshInterval: number; +} + +interface IngestStatsActions { + setRefreshInterval: (newRefreshInterval: number)=> void; +} + +/** + * Default values for the ingest stats store. + */ +const INGEST_STATS_DEFAULT: IngestStatsValues = Object.freeze({ + refreshInterval: 10_000, +}); + +type IngestStatsState = IngestStatsValues & IngestStatsActions; + +const useIngestStatsStore = create((set) => ({ + ...INGEST_STATS_DEFAULT, + setRefreshInterval: (newRefreshInterval: number) => { + set(() => ({refreshInterval: newRefreshInterval})); + }, +})); + + +export default useIngestStatsStore; diff --git a/components/log-viewer-webui/client/src/pages/IngestPage/sqlConfig.ts b/components/log-viewer-webui/client/src/pages/IngestPage/sqlConfig.ts new file mode 100644 index 0000000000..57c1e339d9 --- /dev/null +++ b/components/log-viewer-webui/client/src/pages/IngestPage/sqlConfig.ts @@ -0,0 +1,66 @@ +import axios from "axios"; + + +// eslint-disable-next-line no-warning-comments +// TODO: replace with a settings file shared between client and server +enum SQL_CONFIG { + SqlDbClpArchivesTableName = "clp_archives", + SqlDbClpFilesTableName = "clp_files", + SqlDbCompressionJobsTableName = "compression_jobs", +} + +/** + * Column names for the `clp_archives` table. + */ +enum CLP_ARCHIVES_TABLE_COLUMN_NAMES { + BEGIN_TIMESTAMP = "begin_timestamp", + END_TIMESTAMP = "end_timestamp", + UNCOMPRESSED_SIZE = "uncompressed_size", + SIZE = "size", +} + +/** + * Column names for the `clp_files` table. + */ +enum CLP_FILES_TABLE_COLUMN_NAMES { + ORIG_FILE_ID = "orig_file_id", + NUM_MESSAGES = "num_messages", +} + +/** + * Column names for the `compression_jobs` table. + */ +enum COMPRESSION_JOBS_TABLE_COLUMN_NAMES { + CLP_BINARY_VERSION = "clp_binary_version", + CLP_CONFIG = "clp_config", + COMPRESSED_SIZE = "compressed_size", + CREATION_TIME = "creation_time", + DURATION = "duration", + ID = "id", + NUM_TASKS = "num_tasks", + NUM_TASKS_COMPLETED = "num_tasks_completed", + ORIGINAL_SIZE = "original_size", + START_TIME = "start_time", + STATUS = "status", + STATUS_MSG = "status_msg", + UNCOMPRESSED_SIZE = "uncompressed_size", + UPDATE_TIME = "update_time", +} + +/** + * Query the SQL server with the queryString. + * + * @param queryString + * @return + */ +const querySql = async (queryString: string) => { + return axios.post("/query/sql", {queryString}); +}; + +export { + CLP_ARCHIVES_TABLE_COLUMN_NAMES, + CLP_FILES_TABLE_COLUMN_NAMES, + COMPRESSION_JOBS_TABLE_COLUMN_NAMES, + querySql, + SQL_CONFIG, +}; diff --git a/components/log-viewer-webui/client/src/typings/time.ts b/components/log-viewer-webui/client/src/typings/time.ts new file mode 100644 index 0000000000..674755a50b --- /dev/null +++ b/components/log-viewer-webui/client/src/typings/time.ts @@ -0,0 +1,8 @@ +/** + * Invalid ID for setInterval return value storage. + * When clearInterval is called with this ID, it will not cause any effect. + */ +const SET_INTERVAL_INVALID_ID = 0; + + +export {SET_INTERVAL_INVALID_ID}; diff --git a/components/log-viewer-webui/server/src/plugins/DbManager.ts b/components/log-viewer-webui/server/src/plugins/DbManager.ts index a1f863c21e..ca2ed88f18 100644 --- a/components/log-viewer-webui/server/src/plugins/DbManager.ts +++ b/components/log-viewer-webui/server/src/plugins/DbManager.ts @@ -82,6 +82,18 @@ class DbManager { }); } + /** + * Submits a query to MySQL. + * + * @param queryString + * @return The result from MySQL. + */ + async queryMySql (queryString: string) { + const [result] = await this.#mysqlConnectionPool.query(queryString); + return result; + } + + /** * Submits a stream extraction job to the scheduler and waits for it to finish. * diff --git a/components/log-viewer-webui/server/src/routes/query.ts b/components/log-viewer-webui/server/src/routes/query.ts index a8500e53c5..e3713a252c 100644 --- a/components/log-viewer-webui/server/src/routes/query.ts +++ b/components/log-viewer-webui/server/src/routes/query.ts @@ -16,9 +16,25 @@ import { * @param app * @return */ +// eslint-disable-next-line max-lines-per-function const routes: FastifyPluginAsync = async (app) => { const fastify = app.withTypeProvider(); + fastify.post( + "/query/sql", + { + schema: { + body: Type.Object({ + queryString: Type.String({minLength: 1}), + }), + }, + }, + async (req) => { + const {queryString} = req.body; + return await fastify.dbManager.queryMySql(queryString); + }, + ); + fastify.post("/query/extract-stream", { schema: { body: Type.Object({