Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/metabase query history #26

Merged
merged 6 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export interface QueryHistory {
hash: string;
database_id: number;
result_rows: number;
started_at: string;
native: boolean;
running_time: number;
query: string;
name: string;
}

export interface Query {
type: string;
native?: Native;
database: number;
middleware: Middleware;
}
export interface Native {
query?: string;
}

export interface Middleware {
"js-int-to-string?": boolean;
"add-default-userland-constraints?": boolean;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React, { useCallback, useMemo, useState } from "react";
import { Dropdown, Space, Modal as AntdModal } from "antd";
import { MenuProps } from "antd/lib";
import { DownOutlined } from "@ant-design/icons";
import compact from "lodash/compact";
import noop from "lodash/noop";
import { StyledLinkButton } from "metabase/query_builder/components/view/QueryHistoryButton/StyledLinkButton";
import { QueryHistory } from "metabase/query_builder/components/view/QueryHistoryButton/components/QueryHistory";
import Modal from "metabase/components/Modal";
import ModalContent from "metabase/components/ModalContent";
import {
ParsedQueryHistory,
useQueryHistory,
} from "metabase/query_builder/components/view/QueryHistoryButton/hooks/useQueryHistory";
import { QueryHistoryMenuItem } from "metabase/query_builder/components/view/QueryHistoryButton/components/QueryHistoryMenuItem";

interface QueryHistoryButtonProps {
onSelectQuery: (query: ParsedQueryHistory) => void;
}

/**
* Button to show query history
*/
export const QueryHistoryButton = ({
onSelectQuery,
}: QueryHistoryButtonProps) => {
const [isOpen, setIsOpen] = useState(false);
const { isLoading, queryHistory, refresh } = useQueryHistory();

const onClickQuery = useCallback(
(query: ParsedQueryHistory) => {
AntdModal.confirm({
title: "Warning",
closable: true,
okText: "Apply",
cancelText: "Cancel",
content:
"This will replace your current query with the selected query. Are you sure you want to continue?",
onOk: () => {
onSelectQuery(query);
setIsOpen(false);
},
onCancel: noop,
});
},
[onSelectQuery],
);

const records = useMemo<MenuProps["items"]>(() => {
const queries = queryHistory.slice(0, 10).map(record => {
if (!record.query.native?.query) {
return;
}

return {
key: record.hash + record.started_at,
label: <QueryHistoryMenuItem record={record} />,
onClick: () => onClickQuery(record),
};
});

return compact([
...queries,
{
key: "more",
label: <div>View more</div>,
onClick: () => setIsOpen(true),
},
]);
}, [queryHistory, onClickQuery]);

if (isLoading || queryHistory.length === 0) {
return null;
}

return (
<>
<Dropdown
menu={{ items: records }}
onOpenChange={isOpen => isOpen && refresh()}
>
<StyledLinkButton to="/" onClick={e => e.preventDefault()}>
<Space size={2}>
History
<DownOutlined rev />
</Space>
</StyledLinkButton>
</Dropdown>
<Modal isOpen={isOpen} wide>
<ModalContent title="Query History" onClose={() => setIsOpen(false)}>
<QueryHistory
dataSource={queryHistory}
onSelectQuery={onClickQuery}
/>
</ModalContent>
</Modal>
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import styled from "@emotion/styled";
import Link from "metabase/core/components/Link/Link";
import { color } from "metabase/lib/colors";

export const StyledLinkButton = styled(Link)`
color: ${color("brand")};
font-weight: bold;
padding: 0.5rem 1rem;
border-radius: 8px;
background-color: ${color("bg-white")};

:hover {
background-color: ${color("bg-light")};
}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React, { useMemo } from "react";
import { Table, Button } from "antd";
import moment from "moment";
import styled from "@emotion/styled";
import {
ParsedQueryHistory,
useQueryHistory,
} from "metabase/query_builder/components/view/QueryHistoryButton/hooks/useQueryHistory";
import { formatSql } from "metabase/query_builder/components/view/QueryHistoryButton/utils/format";

const StyledQueryHistory = styled.div`
overflow: auto;
width: 100%;
.query-history-table {
min-width: 800px;
}
`;

const columns = [
{
title: "Database",
dataIndex: "name",
key: "name",
},
{
title: "Result Rows",
dataIndex: "result_rows",
key: "result_rows",
},
{
title: "Started At",
dataIndex: "started_at",
key: "started_at",
render: (text: string) => moment(text).format("MM/DD HH:mm:ss"),
},
{
title: "Running Time",
dataIndex: "running_time",
key: "running_time",
render: (time: number) => time + "ms",
},
{
title: "Sql",
dataIndex: "query.native.query",
key: "sql",
width: 200,
render: function render(_: undefined, record: ParsedQueryHistory) {
return (
<div style={{ fontFamily: "monospace" }}>
{formatSql(record.query.native?.query, 50)}
</div>
);
},
},
];

interface QueryHistoryProps {
dataSource: ParsedQueryHistory[];
onSelectQuery: (query: ParsedQueryHistory) => void;
}

export const QueryHistory = ({
dataSource,
onSelectQuery,
}: QueryHistoryProps): JSX.Element => {
const { isLoading, queryHistory } = useQueryHistory();

const columnsWithAction = useMemo(() => {
return [
...columns,
{
title: "Action",
key: "action",
render: function render(_: undefined, record: ParsedQueryHistory) {
return (
<Button type="link" onClick={() => onSelectQuery(record)}>
Apply
</Button>
);
},
},
];
}, [onSelectQuery]);

if (!isLoading && !queryHistory.length) {
return <>No query history</>;
}

return (
<StyledQueryHistory>
<Table<ParsedQueryHistory>
className="query-history-table"
columns={columnsWithAction}
dataSource={dataSource}
pagination={{ pageSize: 50 }}
/>
</StyledQueryHistory>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React, { useMemo } from "react";
import moment from "moment/moment";
import styled from "@emotion/styled";
import { Space } from "antd";
import { ClockCircleOutlined, DatabaseOutlined } from "@ant-design/icons";
import { ParsedQueryHistory } from "metabase/query_builder/components/view/QueryHistoryButton/hooks/useQueryHistory";
import { formatSql } from "metabase/query_builder/components/view/QueryHistoryButton/utils/format";

const StyledQueryHistoryMenuItem = styled.div`
border-bottom: 1px solid #f0f0f0;
min-width: 500px;
padding: 5px 0;

.timestamp {
font-size: 12px;
color: #999;
}

.sql {
margin-top: 5px;
font-family: monospace;
}
`;

export const QueryHistoryMenuItem = ({
record,
}: {
record: ParsedQueryHistory;
}) => {
const query = useMemo(() => {
return formatSql(record.query.native?.query);
}, [record.query.native?.query]);

return (
<StyledQueryHistoryMenuItem>
<div className="timestamp">
<Space>
<Space size={2}>
<ClockCircleOutlined rev />
{moment(record.started_at).format("MM/DD HH:mm")}
</Space>
<Space size={2}>
<DatabaseOutlined rev />
{record.name}
</Space>
</Space>
</div>
<div className="sql">{query}</div>
</StyledQueryHistoryMenuItem>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { GET } from "metabase/lib/api";
import {
QueryHistory,
Query,
} from "metabase/query_builder/components/view/QueryHistoryButton/@types/types";

const query = GET("/api/query-history/current");

export interface ParsedQueryHistory extends Omit<QueryHistory, "query"> {
query: Query;
}

export const useQueryHistory = (): {
isLoading: boolean;
queryHistory: ParsedQueryHistory[];
refresh: () => void;
} => {
const [isLoading, setIsLoading] = useState(false);
const [queryHistory, setQueryHistory] = useState<ParsedQueryHistory[]>([]);

const fetch = useCallback(async (shouldFlush: boolean = true) => {
try {
shouldFlush && setIsLoading(true);
const data = (await query()) as QueryHistory[];
setQueryHistory(
data.map(item => {
return {
...item,
query: JSON.parse(item.query) as Query,
};
}),
);
} catch (error) {
} finally {
setIsLoading(false);
}
}, []);

const refresh = useCallback(() => {
void fetch(false);
}, [fetch]);

useEffect(() => {
void fetch();
}, [fetch]);

return useMemo(() => {
return {
isLoading,
queryHistory,
refresh,
};
}, [isLoading, queryHistory, refresh]);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Format SQL to be displayed in the UI
* @param sql
* @param visibleLength
*/
export const formatSql = (sql?: string, visibleLength: number = 50) => {
if (!sql) {
return "";
}

if (sql.length <= 50) {
return sql;
}

return sql.substring(0, 50) + "...";
};
Loading
Loading