diff --git a/frontend/src/metabase-types/api/index.ts b/frontend/src/metabase-types/api/index.ts index 01cb581aa9697d52456be4d1bf539c19b73bdf55..a163fb3d4f208c7b334621d96a03c9d336f5c75a 100644 --- a/frontend/src/metabase-types/api/index.ts +++ b/frontend/src/metabase-types/api/index.ts @@ -31,6 +31,7 @@ export * from "./slack"; export * from "./snippets"; export * from "./store"; export * from "./table"; +export * from "./task"; export * from "./timeline"; export * from "./user"; export * from "./util"; diff --git a/frontend/src/metabase-types/api/task.ts b/frontend/src/metabase-types/api/task.ts new file mode 100644 index 0000000000000000000000000000000000000000..81f6b0d2be5aa9bc8ebdf222a801b38809af4b2a --- /dev/null +++ b/frontend/src/metabase-types/api/task.ts @@ -0,0 +1,15 @@ +import type { DatabaseId } from "./database"; +import type { PaginationRequest, PaginationResponse } from "./pagination"; + +export interface Task { + id: number; + db_id: DatabaseId | null; + duration: number; + started_at: string; + ended_at: string; + task: string; + task_details: Record<string, unknown> | null; +} +export type ListTasksRequest = PaginationRequest; + +export type ListTasksResponse = { data: Task[] } & PaginationResponse; diff --git a/frontend/src/metabase/admin/routes.jsx b/frontend/src/metabase/admin/routes.jsx index 78fcb70a2d14731be2992d3df4ba6828de509c10..045fe50f88bba654c50043d841c6a8b0a5e273dc 100644 --- a/frontend/src/metabase/admin/routes.jsx +++ b/frontend/src/metabase/admin/routes.jsx @@ -34,8 +34,8 @@ import { ModelCacheRefreshJobs, ModelCacheRefreshJobModal, } from "metabase/admin/tasks/containers/ModelCacheRefreshJobs"; -import TaskModal from "metabase/admin/tasks/containers/TaskModal"; -import TasksApp from "metabase/admin/tasks/containers/TasksApp"; +import { TaskModal } from "metabase/admin/tasks/containers/TaskModal"; +import { TasksApp } from "metabase/admin/tasks/containers/TasksApp"; import TroubleshootingApp from "metabase/admin/tasks/containers/TroubleshootingApp"; import Tools from "metabase/admin/tools/containers/Tools"; import { diff --git a/frontend/src/metabase/admin/tasks/containers/TaskModal.jsx b/frontend/src/metabase/admin/tasks/containers/TaskModal.jsx deleted file mode 100644 index b2d9eda599b6d389697b5d5aca078dc4b0a52381..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/admin/tasks/containers/TaskModal.jsx +++ /dev/null @@ -1,30 +0,0 @@ -/* eslint-disable react/prop-types */ -import { Component } from "react"; -import { connect } from "react-redux"; -import { goBack } from "react-router-redux"; -import { t } from "ttag"; -import _ from "underscore"; - -import Code from "metabase/components/Code"; -import ModalContent from "metabase/components/ModalContent"; -import Task from "metabase/entities/tasks"; - -class TaskModalInner extends Component { - render() { - const { object } = this.props; - return ( - <ModalContent title={t`Task details`} onClose={() => this.props.goBack()}> - <Code>{JSON.stringify(object.task_details)}</Code> - </ModalContent> - ); - } -} - -const TaskModal = _.compose( - Task.load({ - id: (state, props) => props.params.taskId, - }), - connect(null, { goBack }), -)(TaskModalInner); - -export default TaskModal; diff --git a/frontend/src/metabase/admin/tasks/containers/TaskModal.tsx b/frontend/src/metabase/admin/tasks/containers/TaskModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..17482fdda3aca1ea4696a72da60a6661e534aa7f --- /dev/null +++ b/frontend/src/metabase/admin/tasks/containers/TaskModal.tsx @@ -0,0 +1,31 @@ +import { goBack } from "react-router-redux"; +import { t } from "ttag"; + +import { useGetTaskQuery } from "metabase/api"; +import Code from "metabase/components/Code"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper/LoadingAndErrorWrapper"; +import ModalContent from "metabase/components/ModalContent"; +import { useDispatch } from "metabase/lib/redux"; + +type TaskModalProps = { + params: { taskId: number }; +}; + +export const TaskModal = ({ params }: TaskModalProps) => { + const dispatch = useDispatch(); + const { data, isLoading, error } = useGetTaskQuery(params.taskId); + + if (isLoading || error) { + return <LoadingAndErrorWrapper loading={isLoading} error={error} />; + } + + if (!data) { + return null; + } + + return ( + <ModalContent title={t`Task details`} onClose={() => dispatch(goBack())}> + <Code>{JSON.stringify(data.task_details)}</Code> + </ModalContent> + ); +}; diff --git a/frontend/src/metabase/admin/tasks/containers/TasksApp.jsx b/frontend/src/metabase/admin/tasks/containers/TasksApp.jsx deleted file mode 100644 index 3f1b9911fee5586a1e4b74619472b0cfb4558265..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/admin/tasks/containers/TasksApp.jsx +++ /dev/null @@ -1,119 +0,0 @@ -/* eslint-disable react/prop-types */ -import cx from "classnames"; -import { Component } from "react"; -import { t } from "ttag"; -import _ from "underscore"; - -import AdminHeader from "metabase/components/AdminHeader"; -import PaginationControls from "metabase/components/PaginationControls"; -import Link from "metabase/core/components/Link"; -import Tooltip from "metabase/core/components/Tooltip"; -import AdminS from "metabase/css/admin.module.css"; -import CS from "metabase/css/core/index.css"; -import Database from "metabase/entities/databases"; -import Task from "metabase/entities/tasks"; - -import { - InfoIcon, - SectionControls, - SectionHeader, - SectionRoot, - SectionTitle, -} from "./TasksApp.styled"; - -// Please preserve the following 2 @ calls in this order. -// Otherwise @Database.loadList overrides pagination props -// that come from @Task.LoadList - -class TasksAppInner extends Component { - render() { - const { - tasks, - databases, - page, - pageSize, - onNextPage, - onPreviousPage, - children, - } = this.props; - const databaseByID = {}; - // index databases by id for lookup - for (const db of databases) { - databaseByID[db.id] = db; - } - return ( - <SectionRoot> - <SectionHeader> - <SectionTitle> - <AdminHeader title={t`Troubleshooting logs`} /> - <Tooltip - tooltip={t`Trying to get to the bottom of something? This section shows logs of Metabase's background tasks, which can help shed light on what's going on.`} - > - <InfoIcon name="info" /> - </Tooltip> - </SectionTitle> - <SectionControls> - <PaginationControls - onPreviousPage={onPreviousPage} - onNextPage={onNextPage} - page={page} - pageSize={pageSize} - itemsLength={tasks.length} - /> - </SectionControls> - </SectionHeader> - - <table className={cx(AdminS.ContentTable, CS.mt2)}> - <thead> - <tr> - <th>{t`Task`}</th> - <th>{t`DB Name`}</th> - <th>{t`DB Engine`}</th> - <th>{t`Started at`}</th> - <th>{t`Ended at`}</th> - <th>{t`Duration (ms)`}</th> - <th>{t`Details`}</th> - </tr> - </thead> - <tbody> - {tasks.map(task => { - const db = task.db_id ? databaseByID[task.db_id] : null; - const name = db ? db.name : null; - const engine = db ? db.engine : null; - // only want unknown if there is a db on the task and we don't have info - return ( - <tr key={task.id}> - <td className={CS.textBold}>{task.task}</td> - <td>{task.db_id ? name || t`Unknown name` : null}</td> - <td>{task.db_id ? engine || t`Unknown engine` : null}</td> - <td>{task.started_at}</td> - <td>{task.ended_at}</td> - <td>{task.duration}</td> - <td> - <Link - className={cx(CS.link, CS.textBold)} - to={`/admin/troubleshooting/tasks/${task.id}`} - >{t`View`}</Link> - </td> - </tr> - ); - })} - </tbody> - </table> - { - // render 'children' so that the invididual task modals show up - children - } - </SectionRoot> - ); - } -} - -const TasksApp = _.compose( - Database.loadList(), - Task.loadList({ - pageSize: 50, - }), -)(TasksAppInner); - -export default TasksApp; diff --git a/frontend/src/metabase/admin/tasks/containers/TasksApp.tsx b/frontend/src/metabase/admin/tasks/containers/TasksApp.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3e0bd501249aa40478ddd4424b8fb5f722d63395 --- /dev/null +++ b/frontend/src/metabase/admin/tasks/containers/TasksApp.tsx @@ -0,0 +1,133 @@ +import cx from "classnames"; +import type { ReactNode } from "react"; +import { useState } from "react"; +import { t } from "ttag"; +import _ from "underscore"; + +import { useListTasksQuery, useListDatabasesQuery } from "metabase/api"; +import AdminHeader from "metabase/components/AdminHeader"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper/LoadingAndErrorWrapper"; +import PaginationControls from "metabase/components/PaginationControls"; +import Link from "metabase/core/components/Link"; +import AdminS from "metabase/css/admin.module.css"; +import CS from "metabase/css/core/index.css"; +import { Tooltip } from "metabase/ui"; +import type { Database, Task } from "metabase-types/api"; + +import { + InfoIcon, + SectionControls, + SectionHeader, + SectionRoot, + SectionTitle, +} from "./TasksApp.styled"; + +type TasksAppProps = { + children: ReactNode; +}; + +export const TasksApp = ({ children }: TasksAppProps) => { + const [page, setPage] = useState(0); + const pageSize = 50; + + const { + data: tasksData, + isFetching: isLoadingTasks, + error: tasksError, + } = useListTasksQuery({ + limit: pageSize, + offset: page * pageSize, + }); + + const { + data: databasesData, + isFetching: isLoadingDatabases, + error: databasesError, + } = useListDatabasesQuery(); + + const tasks = tasksData?.data; + const databases = databasesData?.data; + + if (isLoadingTasks || isLoadingDatabases || tasksError || databasesError) { + return ( + <LoadingAndErrorWrapper + loading={isLoadingTasks || isLoadingDatabases} + error={tasksError || databasesError} + /> + ); + } + + if (!tasks || !databases) { + return null; + } + + // index databases by id for lookup + const databaseByID: Record<number, Database> = _.indexBy(databases, "id"); + + return ( + <SectionRoot> + <SectionHeader> + <SectionTitle> + <AdminHeader title={t`Troubleshooting logs`} /> + <Tooltip + label={t`Trying to get to the bottom of something? This section shows logs of Metabase's background tasks, which can help shed light on what's going on.`} + > + <InfoIcon name="info" /> + </Tooltip> + </SectionTitle> + <SectionControls> + <PaginationControls + onPreviousPage={() => setPage(page - 1)} + onNextPage={() => setPage(page + 1)} + page={page} + pageSize={50} + itemsLength={tasks.length} + total={tasksData.total} + /> + </SectionControls> + </SectionHeader> + + <table className={cx(AdminS.ContentTable, CS.mt2)}> + <thead> + <tr> + <th>{t`Task`}</th> + <th>{t`DB Name`}</th> + <th>{t`DB Engine`}</th> + <th>{t`Started at`}</th> + <th>{t`Ended at`}</th> + <th>{t`Duration (ms)`}</th> + <th>{t`Details`}</th> + </tr> + </thead> + <tbody> + {tasks.map((task: Task) => { + const db = task.db_id ? databaseByID[task.db_id] : null; + const name = db ? db.name : null; + const engine = db ? db.engine : null; + // only want unknown if there is a db on the task and we don't have info + return ( + <tr key={task.id}> + <td className={CS.textBold}>{task.task}</td> + <td>{task.db_id ? name || t`Unknown name` : null}</td> + <td>{task.db_id ? engine || t`Unknown engine` : null}</td> + <td>{task.started_at}</td> + <td>{task.ended_at}</td> + <td>{task.duration}</td> + <td> + <Link + className={cx(CS.link, CS.textBold)} + to={`/admin/troubleshooting/tasks/${task.id}`} + >{t`View`}</Link> + </td> + </tr> + ); + })} + </tbody> + </table> + { + // render 'children' so that the invididual task modals show up + children + } + </SectionRoot> + ); +}; diff --git a/frontend/src/metabase/api/index.ts b/frontend/src/metabase/api/index.ts index 3afdb03479716b584ca23da737b3472a28c7bfad..c9933accc5612022ab89549c71c97dd5127c6b71 100644 --- a/frontend/src/metabase/api/index.ts +++ b/frontend/src/metabase/api/index.ts @@ -16,6 +16,7 @@ export * from "./search"; export * from "./segment"; export * from "./session"; export * from "./table"; +export * from "./task"; export * from "./timeline"; export * from "./timeline-event"; export * from "./user"; diff --git a/frontend/src/metabase/api/tags/constants.ts b/frontend/src/metabase/api/tags/constants.ts index 5eaf8c7d9a258719b9ee5b22b8b2d883a4a2cff5..700ded618785b1bd280fbf6912f0a0aa0ac3692e 100644 --- a/frontend/src/metabase/api/tags/constants.ts +++ b/frontend/src/metabase/api/tags/constants.ts @@ -17,6 +17,7 @@ export const TAG_TYPES = [ "snippet", "segment", "table", + "task", "timeline", "timeline-event", "user", diff --git a/frontend/src/metabase/api/tags/utils.ts b/frontend/src/metabase/api/tags/utils.ts index 4dfd787865980d0d9cf923f523e0e1f42cb99d8c..379c3e20a58801a7d21c033859531ed022377f4b 100644 --- a/frontend/src/metabase/api/tags/utils.ts +++ b/frontend/src/metabase/api/tags/utils.ts @@ -23,6 +23,7 @@ import type { SearchResult, Segment, Table, + Task, Timeline, TimelineEvent, UserInfo, @@ -317,6 +318,14 @@ export function provideTableTags(table: Table): TagDescription<TagType>[] { ]; } +export function provideTaskListTags(tasks: Task[]): TagDescription<TagType>[] { + return [listTag("task"), ...tasks.flatMap(provideTaskTags)]; +} + +export function provideTaskTags(task: Task): TagDescription<TagType>[] { + return [idTag("task", task.id)]; +} + export function provideTimelineEventListTags( events: TimelineEvent[], ): TagDescription<TagType>[] { diff --git a/frontend/src/metabase/api/task.ts b/frontend/src/metabase/api/task.ts new file mode 100644 index 0000000000000000000000000000000000000000..41e9bbb61848232c0eb4e30cc8a2d414e675adf1 --- /dev/null +++ b/frontend/src/metabase/api/task.ts @@ -0,0 +1,38 @@ +import type { + ListTasksRequest, + ListTasksResponse, + Task, +} from "metabase-types/api"; + +import { Api } from "./api"; +import { provideTaskTags, provideTaskListTags } from "./tags"; + +export const taskApi = Api.injectEndpoints({ + endpoints: builder => ({ + listTasks: builder.query<ListTasksResponse, ListTasksRequest | void>({ + query: params => ({ + method: "GET", + url: "/api/task", + params, + }), + providesTags: response => + response ? provideTaskListTags(response.data) : [], + }), + getTask: builder.query<Task, number>({ + query: id => ({ + method: "GET", + url: `/api/task/${id}`, + }), + providesTags: task => (task ? provideTaskTags(task) : []), + }), + getTasksInfo: builder.query<unknown, void>({ + query: () => ({ + method: "GET", + url: "/api/task/info", + }), + }), + }), +}); + +export const { useListTasksQuery, useGetTaskQuery, useGetTasksInfoQuery } = + taskApi; diff --git a/frontend/src/metabase/components/PaginationControls/PaginationControls.jsx b/frontend/src/metabase/components/PaginationControls/PaginationControls.jsx index 507de8b69c2bc3adb4cbb713df9285243c729309..91ac1054c453d183a8e6585a1eb485d4e85c9a1f 100644 --- a/frontend/src/metabase/components/PaginationControls/PaginationControls.jsx +++ b/frontend/src/metabase/components/PaginationControls/PaginationControls.jsx @@ -21,6 +21,9 @@ export default function PaginationControls({ return null; } + const isLastPage = (pageIndex, pageSize, total) => + pageIndex === Math.ceil(total / pageSize) - 1; + const isPreviousDisabled = page === 0; const isNextDisabled = total != null ? isLastPage(page, pageSize, total) : !onNextPage; @@ -73,6 +76,3 @@ PaginationControls.propTypes = { PaginationControls.defaultProps = { showTotal: false, }; - -export const isLastPage = (pageIndex, pageSize, total) => - pageIndex === Math.ceil(total / pageSize) - 1; diff --git a/frontend/src/metabase/entities/index.js b/frontend/src/metabase/entities/index.js index 0d6e1dd3fef6f615ed052f8e8746fc0304d94402..d60172da2f83d4d9c5f1ea51bd72fd16f7be9df3 100644 --- a/frontend/src/metabase/entities/index.js +++ b/frontend/src/metabase/entities/index.js @@ -20,7 +20,6 @@ export { default as tables } from "./tables"; export { default as fields } from "./fields"; export { default as metrics } from "./metrics"; export { default as segments } from "./segments"; -export { default as tasks } from "./tasks"; export { default as users } from "./users"; export { default as groups } from "./groups"; diff --git a/frontend/src/metabase/entities/tasks.js b/frontend/src/metabase/entities/tasks.js deleted file mode 100644 index 4fac2c88c6b818ba6ed2aa0610093164590c6c05..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/entities/tasks.js +++ /dev/null @@ -1,9 +0,0 @@ -import { createEntity } from "metabase/lib/entities"; - -/** - * @deprecated use "metabase/api" instead - */ -export default createEntity({ - name: "tasks", - path: "/api/task", -}); diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js index f4a72317a48e939dbe45e067b28074e825ab6eed..1f75317e95e99623c9a9d43c8b8e3e3472d75c06 100644 --- a/frontend/src/metabase/services.js +++ b/frontend/src/metabase/services.js @@ -470,7 +470,6 @@ export const I18NApi = { }; export const TaskApi = { - get: GET("/api/task"), getJobsInfo: GET("/api/task/info"), };