Skip to content
Snippets Groups Projects
Unverified Commit 600fb988 authored by Anton Kulyk's avatar Anton Kulyk Committed by GitHub
Browse files

Handle new model cache states (#22891)

* Add new cache info types

* Add model cache info mock factory

* Add `checkCanRefreshModelCache` utility

* Handle new states on model page caching section

* Handle new states on Tools page

* Fix svg import type error

* Use "queued" for `creating` state

* Fix tests
parent 89927882
No related branches found
No related tags found
No related merge requests found
Showing
with 416 additions and 14 deletions
......@@ -4,6 +4,7 @@ import moment from "moment";
import { connect } from "react-redux";
import PersistedModels from "metabase/entities/persisted-models";
import { checkCanRefreshModelCache } from "metabase/lib/data-modeling/utils";
import Question from "metabase-lib/lib/Question";
import { ModelCacheRefreshStatus } from "metabase-types/api";
......@@ -28,9 +29,15 @@ type LoaderRenderProps = {
};
function getStatusMessage(job: ModelCacheRefreshStatus) {
if (job.state === "off") {
return `Caching is turned off`;
}
if (job.state === "error") {
return t`Failed to update model cache`;
}
if (job.state === "creating") {
return t`Queued`;
}
if (job.state === "refreshing") {
return t`Refreshing model cache`;
}
......@@ -52,7 +59,11 @@ function ModelCacheManagementSection({ model, onRefresh }: Props) {
loadingAndErrorWrapper={false}
>
{({ persistedModel }: LoaderRenderProps) => {
if (!persistedModel) {
if (
!persistedModel ||
persistedModel.state === "off" ||
persistedModel.state === "deletable"
) {
return null;
}
......@@ -60,7 +71,7 @@ function ModelCacheManagementSection({ model, onRefresh }: Props) {
const lastRefreshTime = moment(persistedModel.refresh_end).fromNow();
return (
<Row>
<Row data-testid="model-cache-section">
<div>
<StatusContainer>
<StatusLabel>{getStatusMessage(persistedModel)}</StatusLabel>
......@@ -72,9 +83,15 @@ function ModelCacheManagementSection({ model, onRefresh }: Props) {
</LastRefreshTimeLabel>
)}
</div>
<IconButton onClick={() => onRefresh(persistedModel)}>
<RefreshIcon name="refresh" tooltip={t`Refresh now`} size={14} />
</IconButton>
{checkCanRefreshModelCache(persistedModel) && (
<IconButton onClick={() => onRefresh(persistedModel)}>
<RefreshIcon
name="refresh"
tooltip={t`Refresh now`}
size={14}
/>
</IconButton>
)}
</Row>
);
}}
......
import React from "react";
import moment from "moment";
import xhrMock from "xhr-mock";
import PersistedModels from "metabase/entities/persisted-models";
import { ModelCacheRefreshStatus } from "metabase-types/api";
import { getMockModelCacheInfo } from "metabase-types/api/mocks/models";
import {
fireEvent,
renderWithProviders,
waitFor,
screen,
} from "__support__/ui";
import { ORDERS } from "__support__/sample_database_fixture";
import ModelCacheManagementSection from "./ModelCacheManagementSection";
type SetupOpts = Partial<ModelCacheRefreshStatus> & {
waitForSectionAppearance?: boolean;
};
async function setup({
waitForSectionAppearance = true,
...cacheInfo
}: SetupOpts = {}) {
const question = ORDERS.question();
const model = question.setCard({
...question.card(),
id: 1,
name: "Order model",
dataset: true,
});
const modelCacheInfo = getMockModelCacheInfo({
...cacheInfo,
card_id: model.id(),
card_name: model.displayName(),
});
const onRefreshMock = jest
.spyOn(PersistedModels.objectActions, "refreshCache")
.mockReturnValue({ type: "__MOCK__" });
xhrMock.get(`/api/persist/card/${model.id()}`, {
body: JSON.stringify(modelCacheInfo),
});
if (!waitForSectionAppearance) {
jest.spyOn(PersistedModels, "Loader").mockImplementation(props => {
const { children } = props as any;
return children({ persistedModel: cacheInfo });
});
}
const utils = renderWithProviders(
<ModelCacheManagementSection model={model} />,
);
if (waitForSectionAppearance) {
await waitFor(() => utils.queryByTestId("model-cache-section"));
}
return {
...utils,
modelCacheInfo,
onRefreshMock,
};
}
describe("ModelCacheManagementSection", () => {
beforeEach(() => {
xhrMock.setup();
});
afterEach(() => {
xhrMock.teardown();
jest.resetAllMocks();
});
it("doesn't show up in 'off' state", async () => {
await setup({ state: "off" });
expect(screen.queryByTestId("model-cache-section")).not.toBeInTheDocument();
});
it("doesn't show up in 'deletable' state", async () => {
await setup({ state: "deletable" });
expect(screen.queryByTestId("model-cache-section")).not.toBeInTheDocument();
});
it("displays 'creating' state correctly", async () => {
await setup({ state: "creating" });
expect(screen.getByText("Queued")).toBeInTheDocument();
expect(screen.queryByLabelText("refresh icon")).not.toBeInTheDocument();
});
it("displays 'refreshing' state correctly", async () => {
await setup({ state: "refreshing" });
expect(screen.getByText("Refreshing model cache")).toBeInTheDocument();
expect(screen.queryByLabelText("refresh icon")).not.toBeInTheDocument();
});
it("displays 'persisted' state correctly", async () => {
const { modelCacheInfo } = await setup({ state: "persisted" });
const expectedTimestamp = moment(modelCacheInfo.refresh_end).fromNow();
expect(
screen.getByText(`Model last cached ${expectedTimestamp}`),
).toBeInTheDocument();
expect(screen.getByLabelText("refresh icon")).toBeInTheDocument();
});
it("triggers refresh from 'persisted' state", async () => {
const { modelCacheInfo, onRefreshMock } = await setup({
state: "persisted",
});
fireEvent.click(screen.getByLabelText("refresh icon"));
expect(onRefreshMock).toHaveBeenCalledWith(modelCacheInfo);
});
it("displays 'error' state correctly", async () => {
const { modelCacheInfo } = await setup({ state: "error" });
const expectedTimestamp = moment(modelCacheInfo.refresh_end).fromNow();
expect(
screen.getByText("Failed to update model cache"),
).toBeInTheDocument();
expect(
screen.getByText(`Last attempt ${expectedTimestamp}`),
).toBeInTheDocument();
expect(screen.getByLabelText("refresh icon")).toBeInTheDocument();
});
it("triggers refresh from 'error' state", async () => {
const { modelCacheInfo, onRefreshMock } = await setup({ state: "error" });
fireEvent.click(screen.getByLabelText("refresh icon"));
expect(onRefreshMock).toHaveBeenCalledWith(modelCacheInfo);
});
});
......@@ -4,6 +4,7 @@ export * from "./collection";
export * from "./dashboard";
export * from "./database";
export * from "./dataset";
export * from "./models";
export * from "./timeline";
export * from "./settings";
export * from "./user";
import { ModelCacheRefreshStatus } from "metabase-types/api";
export const getMockModelCacheInfo = (
opts?: Partial<ModelCacheRefreshStatus>,
): ModelCacheRefreshStatus => {
const now = new Date();
const past = new Date();
past.setMinutes(now.getMinutes() - 30);
const future = new Date();
future.setHours(now.getHours() + 1);
return {
id: 1,
state: "persisted",
error: null,
active: true,
card_id: 1,
card_name: "Test Model",
collection_id: "root",
collection_name: "Our analytics",
collection_authority_level: null,
columns: [],
database_id: 1,
database_name: "Sample Database",
schema_name: "PUBLIC",
table_name: "Orders",
refresh_begin: past.toISOString(),
refresh_end: now.toISOString(),
"next-fire-time": future.toISOString(),
...opts,
};
};
......@@ -7,9 +7,17 @@ import {
UserId,
} from "metabase-types/api";
export type ModelCacheState =
| "creating"
| "refreshing"
| "persisted"
| "error"
| "deletable"
| "off";
export interface ModelCacheRefreshStatus {
id: number;
state: "refreshing" | "persisted" | "error";
state: ModelCacheState;
error: string | null;
active: boolean;
......
......@@ -5,11 +5,13 @@ import { connect } from "react-redux";
import Link from "metabase/core/components/Link";
import DateTime from "metabase/components/DateTime";
import EmptyState from "metabase/components/EmptyState";
import Icon from "metabase/components/Icon";
import Tooltip from "metabase/components/Tooltip";
import PaginationControls from "metabase/components/PaginationControls";
import PersistedModels from "metabase/entities/persisted-models";
import { checkCanRefreshModelCache } from "metabase/lib/data-modeling/utils";
import { capitalize } from "metabase/lib/formatting";
import * as Urls from "metabase/lib/urls";
......@@ -17,6 +19,8 @@ import { usePagination } from "metabase/hooks/use-pagination";
import { ModelCacheRefreshStatus } from "metabase-types/api";
import NoResults from "assets/img/no_results.svg";
import {
ErrorBox,
IconButtonContainer,
......@@ -39,6 +43,12 @@ function JobTableItem({ job, onRefresh }: JobTableItemProps) {
const lastRunAtLabel = capitalize(moment(job.refresh_begin).fromNow());
const renderStatus = useCallback(() => {
if (job.state === "off") {
return t`Off`;
}
if (job.state === "creating") {
return t`Queued`;
}
if (job.state === "refreshing") {
return t`Refreshing`;
}
......@@ -73,11 +83,13 @@ function JobTableItem({ job, onRefresh }: JobTableItemProps) {
</th>
<th>{job.creator?.common_name || t`Automatic`}</th>
<th>
<Tooltip tooltip={t`Refresh`}>
<IconButtonContainer onClick={onRefresh}>
<Icon name="refresh" />
</IconButtonContainer>
</Tooltip>
{checkCanRefreshModelCache(job) && (
<Tooltip tooltip={t`Refresh`}>
<IconButtonContainer onClick={onRefresh}>
<Icon name="refresh" />
</IconButtonContainer>
</Tooltip>
)}
</th>
</tr>
);
......@@ -118,8 +130,23 @@ function ModelCacheRefreshJobs({ children, onRefresh }: Props) {
{({ persistedModels, metadata }: PersistedModelsListLoaderProps) => {
const hasPagination = metadata.total > PAGE_SIZE;
const modelCacheInfo = persistedModels.filter(
cacheInfo => cacheInfo.state !== "deletable",
);
if (modelCacheInfo.length === 0) {
return (
<div data-testid="model-cache-logs">
<EmptyState
title={t`No results`}
illustrationElement={<img src={NoResults} />}
/>
</div>
);
}
return (
<>
<div data-testid="model-cache-logs">
<table className="ContentTable border-bottom">
<colgroup>
<col style={{ width: "30%" }} />
......@@ -138,7 +165,7 @@ function ModelCacheRefreshJobs({ children, onRefresh }: Props) {
</tr>
</thead>
<tbody>
{persistedModels.map(job => (
{modelCacheInfo.map(job => (
<JobTableItem
key={job.id}
job={job}
......@@ -160,7 +187,7 @@ function ModelCacheRefreshJobs({ children, onRefresh }: Props) {
/>
</PaginationControlsContainer>
)}
</>
</div>
);
}}
</PersistedModels.ListLoader>
......
import React from "react";
import PersistedModels from "metabase/entities/persisted-models";
import { ModelCacheRefreshStatus } from "metabase-types/api";
import { getMockModelCacheInfo } from "metabase-types/api/mocks/models";
import { renderWithProviders, waitFor, screen } from "__support__/ui";
import ModelCacheRefreshJobs from "./ModelCacheRefreshJobs";
async function setup({ logs = [] }: { logs?: ModelCacheRefreshStatus[] } = {}) {
const onRefreshMock = jest
.spyOn(PersistedModels.objectActions, "refreshCache")
.mockReturnValue({ type: "__MOCK__" });
jest.spyOn(PersistedModels, "ListLoader").mockImplementation(props => {
const { children } = props as any;
return children({
persistedModels: logs,
metadata: {
limit: 20,
offset: 0,
total: logs.length,
},
});
});
const utils = renderWithProviders(
<ModelCacheRefreshJobs>
<></>
</ModelCacheRefreshJobs>,
);
await waitFor(() => utils.queryByTestId("model-cache-logs"));
return {
...utils,
onRefreshMock,
};
}
describe("ModelCacheRefreshJobs", () => {
afterEach(() => {
jest.resetAllMocks();
});
it("shows empty state when there are no cache logs", async () => {
await setup({ logs: [] });
expect(screen.getByText("No results")).toBeInTheDocument();
expect(document.querySelector("table")).not.toBeInTheDocument();
});
it("shows empty state when all logs are in 'deletable' state", async () => {
await setup({
logs: [
getMockModelCacheInfo({ id: 1, card_id: 1, state: "deletable" }),
getMockModelCacheInfo({ id: 2, card_id: 2, state: "deletable" }),
],
});
expect(screen.getByText("No results")).toBeInTheDocument();
expect(document.querySelector("table")).not.toBeInTheDocument();
});
it("shows model and collection names", async () => {
const info = getMockModelCacheInfo({
collection_name: "Growth",
card_name: "Customer",
});
await setup({ logs: [info] });
expect(screen.getByText("Customer")).toBeInTheDocument();
expect(screen.getByText("Growth")).toBeInTheDocument();
});
it("handles models in root collections", async () => {
const info = getMockModelCacheInfo({
collection_id: "root",
collection_name: undefined,
});
await setup({ logs: [info] });
expect(screen.getByText("Our analytics")).toBeInTheDocument();
});
it("doesn't show records in 'deletable' state", async () => {
await setup({
logs: [
getMockModelCacheInfo({
id: 1,
card_id: 1,
card_name: "DELETABLE",
state: "deletable",
}),
getMockModelCacheInfo({ id: 2, card_id: 2, state: "persisted" }),
],
});
expect(screen.queryByText("DELETABLE")).not.toBeInTheDocument();
});
it("displays 'off' state correctly", async () => {
await setup({ logs: [getMockModelCacheInfo({ state: "off" })] });
expect(screen.getByText("Off")).toBeInTheDocument();
expect(screen.queryByLabelText("refresh icon")).not.toBeInTheDocument();
});
it("displays 'creating' state correctly", async () => {
await setup({ logs: [getMockModelCacheInfo({ state: "creating" })] });
expect(screen.getByText("Queued")).toBeInTheDocument();
expect(screen.queryByLabelText("refresh icon")).not.toBeInTheDocument();
});
it("displays 'refreshing' state correctly", async () => {
await setup({ logs: [getMockModelCacheInfo({ state: "refreshing" })] });
expect(screen.getByText("Refreshing")).toBeInTheDocument();
expect(screen.queryByLabelText("refresh icon")).not.toBeInTheDocument();
});
it("displays 'persisted' state correctly", async () => {
await setup({ logs: [getMockModelCacheInfo({ state: "persisted" })] });
expect(screen.getByText("Completed")).toBeInTheDocument();
expect(screen.getByLabelText("refresh icon")).toBeInTheDocument();
});
it("displays 'error' state correctly", async () => {
await setup({
logs: [getMockModelCacheInfo({ state: "error", error: "FOO BAR ERROR" })],
});
expect(screen.getByText("FOO BAR ERROR")).toBeInTheDocument();
expect(screen.getByLabelText("refresh icon")).toBeInTheDocument();
});
});
import Question from "metabase-lib/lib/Question";
import NativeQuery from "metabase-lib/lib/queries/NativeQuery";
import Database from "metabase-lib/lib/metadata/Database";
import { isStructured } from "metabase/lib/query";
import { getQuestionVirtualTableId } from "metabase/lib/saved-questions";
import { ModelCacheRefreshStatus } from "metabase-types/api";
import { TemplateTag } from "metabase-types/types/Query";
import {
Card as CardObject,
......@@ -66,3 +69,9 @@ export function isAdHocModelQuestion(
}
return isAdHocModelQuestionCard(question.card(), originalQuestion.card());
}
export function checkCanRefreshModelCache(
refreshInfo: ModelCacheRefreshStatus,
) {
return refreshInfo.state === "persisted" || refreshInfo.state === "error";
}
import Question from "metabase-lib/lib/Question";
import Database from "metabase-lib/lib/metadata/Database";
import { ModelCacheState } from "metabase-types/api";
import {
TemplateTag,
TemplateTagType,
......@@ -7,12 +9,16 @@ import {
SourceTableId,
} from "metabase-types/types/Query";
import { CardId } from "metabase-types/types/Card";
import { createMockDatabase } from "metabase-types/api/mocks/database";
import { getMockModelCacheInfo } from "metabase-types/api/mocks/models";
import { ORDERS, metadata } from "__support__/sample_database_fixture";
import {
checkCanBeModel,
isAdHocModelQuestion,
isAdHocModelQuestionCard,
checkCanRefreshModelCache,
} from "./utils";
type NativeQuestionFactoryOpts = {
......@@ -249,4 +255,24 @@ describe("data model utils", () => {
).toBe(false);
});
});
describe("checkCanRefreshModelCache", () => {
const testCases: Record<ModelCacheState, boolean> = {
creating: false,
refreshing: false,
persisted: true,
error: true,
deletable: false,
off: false,
};
const states = Object.keys(testCases) as ModelCacheState[];
states.forEach(state => {
const canRefresh = testCases[state];
it(`returns '${canRefresh}' for '${state}' caching state`, () => {
const info = getMockModelCacheInfo({ state });
expect(checkCanRefreshModelCache(info)).toBe(canRefresh);
});
});
});
});
interface Window {
MetabaseBootstrap: any;
}
// This allows importing static SVGs from TypeScript files
declare module "*.svg" {
const content: any;
export default content;
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment