diff --git a/frontend/src/metabase-types/api/admin.ts b/frontend/src/metabase-types/api/admin.ts index 251b4fc35720bc1a23e33289e7e7b0f53f07b858..42e674fe776a093826b52e681ba43b0cf29a110e 100644 --- a/frontend/src/metabase-types/api/admin.ts +++ b/frontend/src/metabase-types/api/admin.ts @@ -16,7 +16,7 @@ export type ApiKey = { }; export interface Log { - timestamp: number; + timestamp: string; process_uuid: string; fqns: string; msg: string; diff --git a/frontend/src/metabase/admin/tasks/components/Logs/Logs.styled.tsx b/frontend/src/metabase/admin/tasks/components/Logs/Logs.styled.tsx index 38472ce1b3793a5282bb6f8af72886b31dbe33dd..e0a4ae14b3f255aea69dfef5b5e7af581c85d152 100644 --- a/frontend/src/metabase/admin/tasks/components/Logs/Logs.styled.tsx +++ b/frontend/src/metabase/admin/tasks/components/Logs/Logs.styled.tsx @@ -21,6 +21,8 @@ const LOG_COLORS = { export const LogsContainer = styled(LoadingAndErrorWrapper)` height: 100%; padding-left: 1rem; + display: flex; + flex-direction: column; `; export const LogsContent = styled.div` diff --git a/frontend/src/metabase/admin/tasks/components/Logs/Logs.tsx b/frontend/src/metabase/admin/tasks/components/Logs/Logs.tsx index eb2a5a18a65f0a4667e8b72f3dcc7347c49387f0..a9c009bcc4cb815509782c6ca9fac25a2a048e90 100644 --- a/frontend/src/metabase/admin/tasks/components/Logs/Logs.tsx +++ b/frontend/src/metabase/admin/tasks/components/Logs/Logs.tsx @@ -1,165 +1,68 @@ -import moment from "moment-timezone"; // eslint-disable-line no-restricted-imports -- deprecated usage -import { Component } from "react"; +import { useState, useMemo } from "react"; import * as React from "react"; import reactAnsiStyle from "react-ansi-style"; import { t } from "ttag"; import _ from "underscore"; import Select, { Option } from "metabase/core/components/Select"; -import { UtilApi } from "metabase/services"; -import type { Log } from "metabase-types/api"; import { LogsContainer, LogsContent } from "./Logs.styled"; - -const MAX_LOGS = 50000; - -function logEventKey(ev: Log) { - return `${ev.timestamp}, ${ev.process_uuid}, ${ev.fqns}, ${ev.msg}`; +import { usePollingLogsQuery, useTailLogs } from "./hooks"; +import { filterLogs, formatLog, getAllProcessUUIDs } from "./utils"; + +interface LogsProps { + // NOTE: fetching logs could come back from any machine if there's multiple machines backing a MB isntance + // make this frequent enough that you will most likely get every log from every machine in some reasonable + // amount of time + pollingDurationMs?: number; } -function mergeLogs(...logArrays: Log[] | Log[][]) { - return _.chain(logArrays) - .flatten(true) - .sortBy(ev => ev.msg) - .sortBy(ev => ev.process_uuid) - .sortBy(ev => ev.timestamp) - .uniq(true, logEventKey) - .last(MAX_LOGS) - .value(); -} - -type LogsProps = Record<string, never>; - -interface LogsState { - loaded: boolean; - error: string | null; - logs: Log[]; - selectedProcessUUID: string; -} - -export class Logs extends Component<LogsProps, LogsState> { - constructor(props: LogsProps) { - super(props); - this.state = { - loaded: false, - error: null, - logs: [], - selectedProcessUUID: "ALL", - }; - } - - timer: NodeJS.Timeout | null = null; - scrollRef: React.RefObject<HTMLDivElement> = React.createRef(); - unmounted = false; - shouldTail = true; - - componentDidMount() { - this.fetchLogs(); - this.timer = setInterval(this.fetchLogs, 1000); - } - - componentWillUnmount() { - this.unmounted = true; - if (this.timer) { - clearInterval(this.timer); - } - } - - fetchLogs = async () => { - try { - const logs = await UtilApi.logs(); - if (!this.unmounted) { - this.setState({ - loaded: true, - logs: mergeLogs(this.state.logs, logs.reverse()), - }); - this.maybeTail(); - } - } catch (err: any) { - console.error(err); - const msg = err?.data?.message ?? err.messsage ?? t`An error occurred.`; - !this.unmounted && this.setState({ error: msg }); - } - }; - - _onScroll = () => { - const elem = this.scrollRef.current; - if (elem) { - this.shouldTail = elem.scrollTop >= elem.scrollHeight - elem.offsetHeight; - } - }; - - maybeTail = () => { - const elem = this.scrollRef.current; - if (this.shouldTail && elem) { - elem.scrollTop = elem.scrollHeight; - } - }; - - render() { - const { logs, selectedProcessUUID, error, loaded } = this.state; - const filteredLogs = logs.filter( - ev => - !selectedProcessUUID || - selectedProcessUUID === "ALL" || - ev.process_uuid === selectedProcessUUID, - ); - const processUUIDs = _.uniq( - logs.map(ev => ev.process_uuid).filter(Boolean), - ).sort(); - const renderedLogs = filteredLogs.flatMap(ev => { - const timestamp = moment(ev.timestamp).format(); - const uuid = ev.process_uuid || "---"; - return [ - `[${uuid}] ${timestamp} ${ev.level} ${ev.fqns} ${ev.msg}`, - ...(ev.exception || []), - ]; - }); - - const noResults = !filteredLogs || filteredLogs.length === 0; - const resultText = noResults +export const Logs = ({ pollingDurationMs = 1000 }: LogsProps) => { + const [selectedProcessUUID, setSelectedProcessUUID] = useState("ALL"); + const { loaded, error, logs } = usePollingLogsQuery(pollingDurationMs); + const processUUIDs = useMemo(() => getAllProcessUUIDs(logs), [logs]); + const filteredLogs = useMemo( + () => filterLogs(logs, selectedProcessUUID), + [logs, selectedProcessUUID], + ); + const { scrollRef, onScroll, refollow } = useTailLogs(filteredLogs); + + const displayLogs = useMemo(() => { + const noResults = filteredLogs.length === 0; + const logText = noResults ? t`There's nothing here, yet.` - : renderedLogs.join("\n"); - - return ( - <LogsContainer loading={!loaded} error={error}> - {() => ( - <> - {processUUIDs.length > 1 && ( - <div className="pb1"> - <label>{t`Select Metabase process:`}</label> - <Select - defaultValue="ALL" - value={this.state.selectedProcessUUID} - onChange={(e: { target: { value: string } }) => - this.setState({ selectedProcessUUID: e.target.value }) - } - className="inline-block ml1" - width={400} - > - <Option - value="ALL" - key="ALL" - >{t`All Metabase processes`}</Option> - {processUUIDs.map(uuid => ( - <Option key={uuid} value={uuid}> - <code>{uuid}</code> - </Option> - ))} - </Select> - </div> - )} - - <LogsContent - id="logs-content" - ref={this.scrollRef} - onScroll={this._onScroll} - > - {reactAnsiStyle(React, resultText)} - </LogsContent> - </> - )} - </LogsContainer> - ); - } -} + : filteredLogs.map(formatLog).join("\n"); + return reactAnsiStyle(React, logText); + }, [filteredLogs]); + + return ( + <LogsContainer loading={!loaded} error={error}> + {processUUIDs.length > 1 && ( + <div className="pb1"> + <label>{t`Select Metabase process:`}</label> + <Select + defaultValue="ALL" + value={selectedProcessUUID} + onChange={(e: { target: { value: string } }) => { + refollow(); + setSelectedProcessUUID(e.target.value); + }} + className="inline-block ml1" + width={400} + > + <Option value="ALL" key="ALL">{t`All Metabase processes`}</Option> + {processUUIDs.map(uuid => ( + <Option key={uuid} value={uuid}> + <code>{uuid}</code> + </Option> + ))} + </Select> + </div> + )} + + <LogsContent id="logs-content" ref={scrollRef} onScroll={onScroll}> + {displayLogs} + </LogsContent> + </LogsContainer> + ); +}; diff --git a/frontend/src/metabase/admin/tasks/components/Logs/Logs.unit.spec.tsx b/frontend/src/metabase/admin/tasks/components/Logs/Logs.unit.spec.tsx index b805b23e3873998d8832a17b82f6e2825b76f2d6..5dc45a5026ca146941a8d0165936f8ea992656da 100644 --- a/frontend/src/metabase/admin/tasks/components/Logs/Logs.unit.spec.tsx +++ b/frontend/src/metabase/admin/tasks/components/Logs/Logs.unit.spec.tsx @@ -1,15 +1,37 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; import fetchMock from "fetch-mock"; import { UtilApi } from "metabase/services"; import { Logs } from "./Logs"; +import { maybeMergeLogs } from "./utils"; + +const log = { + timestamp: "2024-01-10T21:21:58.597Z", + level: "DEBUG", + fqns: "metabase.server.middleware.log", + msg: "\u001B[32mGET /api/collection/root 200 17.6 ms (2 DB calls) App DB connections: 0/7 Jetty threads: 7/50 (1 idle, 0 queued) (42 total active threads) Queries in flight: 0 (0 queued)\u001B[0m", + exception: null, + process_uuid: "e7774ef2-42ab-43de-89f7-d6de9fdc624f", +}; + +let utilSpy: any; describe("Logs", () => { describe("log fetching", () => { + beforeEach(() => { + utilSpy = jest.spyOn(UtilApi, "logs"); + jest.useFakeTimers({ advanceTimers: true }); + }); + + afterEach(() => { + utilSpy.mockClear(); + fetchMock.restore(); + jest.useRealTimers(); + }); + it("should call UtilApi.logs every 1 second", async () => { fetchMock.get("path:/api/util/logs", []); - const utilSpy = jest.spyOn(UtilApi, "logs"); render(<Logs />); await waitFor(() => [ expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(), @@ -17,6 +39,33 @@ describe("Logs", () => { ]); }); + it("should skip calls to UtilsApi.logs if last request is still in-flight", async () => { + fetchMock.get("path:/api/util/logs", []); + let resolve: any; + utilSpy.mockReturnValueOnce(new Promise(res => (resolve = res))); + render(<Logs />); + await waitFor(() => [ + expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(), + expect(utilSpy).toHaveBeenCalledTimes(1), + ]); + act(() => { + jest.advanceTimersByTime(1100); // wait longer than polling period + }); + expect(utilSpy).toHaveBeenCalledTimes(1); // should not have been called + act(() => { + resolve([log]); + }); + await waitFor(() => { + expect( + screen.getByText(new RegExp(log.process_uuid)), + ).toBeInTheDocument(); + }); + act(() => { + jest.advanceTimersByTime(1100); + }); + expect(utilSpy).toHaveBeenCalledTimes(2); // should have issued new request + }); + it("should display no results if there are no logs", async () => { fetchMock.get("path:/api/util/logs", []); render(<Logs />); @@ -28,14 +77,6 @@ describe("Logs", () => { }); it("should display results if server responds with logs", async () => { - const log = { - timestamp: "2024-01-10T21:21:58.597Z", - level: "DEBUG", - fqns: "metabase.server.middleware.log", - msg: "\u001B[32mGET /api/collection/root 200 17.6 ms (2 DB calls) App DB connections: 0/7 Jetty threads: 7/50 (1 idle, 0 queued) (42 total active threads) Queries in flight: 0 (0 queued)\u001B[0m", - exception: null, - process_uuid: "e7774ef2-42ab-43de-89f7-d6de9fdc624f", - }; fetchMock.get("path:/api/util/logs", [log]); render(<Logs />); await waitFor(() => { @@ -57,4 +98,16 @@ describe("Logs", () => { }); }); }); + + describe("log processing", () => { + it("should skip updates if fetched logs match match previously requested logs", async () => { + const originalLogs = [log]; + const shouldNotBeMerged = maybeMergeLogs(originalLogs, [log]); + expect(shouldNotBeMerged).toBe(originalLogs); + const shoudlBeMerged = maybeMergeLogs(originalLogs, [ + { ...log, msg: "different" }, + ]); + expect(shoudlBeMerged).not.toBe(originalLogs); + }); + }); }); diff --git a/frontend/src/metabase/admin/tasks/components/Logs/hooks.ts b/frontend/src/metabase/admin/tasks/components/Logs/hooks.ts new file mode 100644 index 0000000000000000000000000000000000000000..f811a35cd2b572e9c4af86fb1e8481ca1e94aeba --- /dev/null +++ b/frontend/src/metabase/admin/tasks/components/Logs/hooks.ts @@ -0,0 +1,100 @@ +import { useInterval } from "@mantine/hooks"; +import { useState, useEffect, useRef, useCallback } from "react"; +import { useMount } from "react-use"; +import { t } from "ttag"; +import _ from "underscore"; + +import { UtilApi } from "metabase/services"; +import type { Log } from "metabase-types/api"; + +import { maybeMergeLogs } from "./utils"; + +export function usePollingLogsQuery(pollingDurationMs: number) { + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState<any>(null); + const [logs, setLogs] = useState<Log[]>([]); + + const isMountedRef = useRef(false); + const isFetchingRef = useRef(false); + + const fetchLogs = async () => { + if (isFetchingRef.current) { + console.warn("skipping logs request as a request is currently in flight"); + return; + } + + try { + isFetchingRef.current = true; + const newLogs: Log[] = await UtilApi.logs(); + if (isMountedRef.current) { + setLoaded(true); + setError(null); + setLogs(logs => maybeMergeLogs(logs, newLogs)); + isFetchingRef.current = false; + } + } catch (err: any) { + console.error(err); + const msg = err?.data?.message ?? err.messsage ?? t`An error occurred.`; + if (isMountedRef.current) { + setError(msg); + isFetchingRef.current = false; + } + } + }; + + const pollingInterval = useInterval(fetchLogs, pollingDurationMs); + + // keep track of mounted state to avoid settings state after unmount + // clear timeout that is polling for logs + useMount(() => { + isMountedRef.current = true; + fetchLogs(); + pollingInterval.start(); + return () => { + isMountedRef.current = false; + pollingInterval.stop(); + }; + }); + + return { loaded, error, logs }; +} + +export function useTailLogs(logs: Log[]) { + const scrollRef = useRef<any>(); + const shouldAutoFollow = useRef(true); + + function autoFollow() { + const elem = scrollRef.current; + if (elem && shouldAutoFollow.current) { + elem.scrollTop = elem.scrollHeight; + } + } + + // auto-follow logs on update + useEffect(() => { + if (logs.length) { + autoFollow(); + } + }, [logs]); + + // recalculate if we should be auto-following based on if the + // user is currently scrolled to the bottom of the container + const onScroll = useCallback(() => { + const elem = scrollRef.current; + if (elem) { + const isAtBottom = + elem.scrollTop >= elem.scrollHeight - elem.offsetHeight; + shouldAutoFollow.current = isAtBottom; + } + }, []); + + const refollow = () => { + shouldAutoFollow.current = true; + }; + + return { + scrollRef, + onScroll, + refollow, + }; +} diff --git a/frontend/src/metabase/admin/tasks/components/Logs/utils.ts b/frontend/src/metabase/admin/tasks/components/Logs/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..9923570acc48a8bcf08ad53b91267c9ed7a00d50 --- /dev/null +++ b/frontend/src/metabase/admin/tasks/components/Logs/utils.ts @@ -0,0 +1,69 @@ +import dayjs from "dayjs"; +import orderBy from "lodash.orderby"; +import _ from "underscore"; + +import type { Log } from "metabase-types/api"; + +const MAX_LOGS = 50000; + +function logEventKey(ev: Log) { + return `${ev.timestamp}, ${ev.process_uuid}, ${ev.fqns}, ${ev.msg}`; +} + +export function mergeLogs(logArrays: Log[][]) { + let prevLogKey = ""; + return orderBy( + logArrays.flat(), + ["timestamp", "process_uuid", "msg"], + ["asc", "asc", "asc"], + ) + .filter(log => { + const key = logEventKey(log); + const keep = prevLogKey !== key; + if (keep) { + prevLogKey = key; + } + return keep; + }) + .slice(-1 * MAX_LOGS); +} + +export function maybeMergeLogs(logs: Log[], newLogs: Log[]) { + const newestLog = _.first(newLogs); + const hasFetchedNewLogs = newestLog && !hasLog(logs, newestLog); + if (hasFetchedNewLogs) { + return mergeLogs([logs, newLogs.reverse()]); + } + return logs; +} + +export function hasLog(logs: Log[], targetLog: Log): boolean { + // search from back as newer logs are last + return _.findLastIndex(logs, targetLog) > -1; +} + +export function filterLogs(logs: Log[], processUUID: string) { + return logs.filter( + ev => + !processUUID || processUUID === "ALL" || ev.process_uuid === processUUID, + ); +} + +export function getAllProcessUUIDs(logs: Log[]) { + const uuids = new Set<string>(); + logs.forEach(log => uuids.add(log.process_uuid)); + return [...uuids].filter(Boolean).sort(); +} + +// date formatting is expensive for megabytes of logs +const formatTs = (ts: string) => dayjs(ts).format(); +const memoedFormatTs = _.memoize(formatTs); + +export function formatLog(log: Log) { + const timestamp = memoedFormatTs(log.timestamp); + const uuid = log.process_uuid || "---"; + return [ + `[${uuid}] ${timestamp} ${log.level} ${log.fqns} ${log.msg}`, + ...(log.exception || []), + ]; +} diff --git a/package.json b/package.json index 9e592952818b4c7f271512dfbb75f92248157461..1422e0fe1aeed91db0a1d80c12162335ad25b5fe 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "leaflet-draw": "^0.4.9", "leaflet.heat": "^0.2.0", "lodash.debounce": "^4.0.8", + "lodash.orderby": "^4.6.0", "moment-timezone": "^0.5.38", "mustache": "^2.3.2", "normalizr": "^3.0.2", @@ -183,6 +184,7 @@ "@types/leaflet.heat": "^0.2.1", "@types/lodash.clonedeep": "^4.5.9", "@types/lodash.debounce": "^4.0.9", + "@types/lodash.orderby": "^4.6.9", "@types/mustache": "^4.1.2", "@types/postcss-import": "^12.0.1", "@types/postcss-url": "^8.0.2", diff --git a/yarn.lock b/yarn.lock index 6bd06c20ddff4e99843d36823f9d5b0e64c64efd..7f1565ab9204d02987792b93e3cdc63cedd2be78 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4792,6 +4792,13 @@ dependencies: "@types/lodash" "*" +"@types/lodash.orderby@^4.6.9": + version "4.6.9" + resolved "https://registry.yarnpkg.com/@types/lodash.orderby/-/lodash.orderby-4.6.9.tgz#3b1968ad257016690b1b9479ba4079208a97ee5f" + integrity sha512-T9o2wkIJOmxXwVTPTmwJ59W6eTi2FseiLR369fxszG649Po/xe9vqFNhf/MtnvT5jrbDiyWKxPFPZbpSVK0SVQ== + dependencies: + "@types/lodash" "*" + "@types/lodash@*": version "4.14.202" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.202.tgz#f09dbd2fb082d507178b2f2a5c7e74bd72ff98f8" @@ -14832,6 +14839,11 @@ lodash.once@^4.1.1: resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== +lodash.orderby@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.orderby/-/lodash.orderby-4.6.0.tgz#e697f04ce5d78522f54d9338b32b81a3393e4eb3" + integrity sha512-T0rZxKmghOOf5YPnn8EY5iLYeWCpZq8G41FfqoVHH5QDTAFaghJRmAdLiadEDq+ztgM2q5PjA+Z1fOwGrLgmtg== + lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"