From af24dd91105f3f025aa547c6ccfadc7e9bddc24e Mon Sep 17 00:00:00 2001
From: Raphael Krut-Landau <raphael.kl@gmail.com>
Date: Fri, 23 Aug 2024 14:57:54 -0400
Subject: [PATCH] feat(admin/performance): In Admin / Performance, add a tab
 where you can manage the caching policies of dashboards and questions
 (#42990)

Closes #42567
---
 docs/configuring-metabase/caching.md          |   4 +-
 .../helpers/e2e-performance-helpers.ts        |  15 +-
 .../helpers/e2e-strategy-form-helpers.ts      |  18 +-
 .../admin/performance/performance.cy.spec.ts  | 705 +++++++++++-------
 .../admin/performance/schedule.cy.spec.ts     |   4 +-
 .../scenarios/dashboard/dashboard.cy.spec.js  |   2 +-
 .../scenarios/question/caching.cy.spec.js     |   2 +-
 .../DashboardAndQuestionCachingTab.tsx        |  16 +
 ...EditorForQuestionsAndDashboards.module.css |  60 ++
 ...trategyEditorForQuestionsAndDashboards.tsx | 374 ++++++++++
 .../TableRowForCacheableItem.styled.tsx       |   7 +
 .../TableRowForCacheableItem.tsx              | 101 +++
 .../constants.tsx                             |  15 +
 .../utils.tsx                                 |  30 +
 .../utils.unit.spec.tsx                       | 174 +++++
 .../caching/components/types.tsx              |  26 +
 .../caching/components/utils.tsx              |  11 +
 .../metabase-enterprise/caching/constants.ts  |  27 +-
 .../src/metabase-enterprise/caching/index.tsx |  12 +-
 .../UploadManagementTable.tsx                 |   3 +
 frontend/src/metabase-types/api/search.ts     |   1 +
 frontend/src/metabase-types/store/admin.ts    |   5 +-
 .../ModelPersistenceConfiguration.module.css  |   4 +
 .../ModelPersistenceConfiguration.tsx         |  12 +-
 ...p.styled.tsx => PerformanceApp.module.css} |  38 +-
 .../performance/components/PerformanceApp.tsx |  62 +-
 .../components/StrategyEditorForDatabases.tsx |   2 +-
 .../admin/performance/constants/complex.ts    |  22 +-
 .../performance/hooks/useSaveStrategy.tsx     |   4 +-
 .../src/metabase/admin/performance/types.ts   |   1 +
 .../src/metabase/admin/performance/utils.tsx  |   7 +-
 frontend/src/metabase/admin/routes.jsx        |  23 +-
 .../components/ApiKeys/ManageApiKeys.tsx      |   3 +
 .../browse/components/ModelsTable.tsx         |  16 +-
 .../metabase/browse/components/constants.tsx  |   1 -
 .../src/metabase/browse/components/utils.tsx  |  18 +-
 .../browse/components/utils.unit.spec.tsx     |  32 +-
 frontend/src/metabase/browse/types.tsx        |   6 -
 frontend/src/metabase/collections/utils.ts    |  17 +
 .../metabase/collections/utils.unit.spec.ts   |  27 +
 .../EllipsifiedCollectionPath.tsx             |  21 +
 .../components/Table/ClientSortableTable.tsx  |  69 +-
 .../common/components/Table/Table.tsx         |  61 +-
 .../components/Table/Table.unit.spec.tsx      |  13 +-
 .../metabase/common/components/Table/types.ts |   7 +
 .../components/Table/useTableSorting.tsx      |  65 ++
 .../src/metabase/common/components/types.ts   |   5 +
 .../ResponsiveContainer.tsx                   |   4 +-
 .../metabase/components/Schedule/constants.ts |   4 +-
 .../palette/hooks/useCommandPalette.tsx       |  10 +-
 frontend/src/metabase/plugins/index.ts        |   8 +-
 51 files changed, 1676 insertions(+), 498 deletions(-)
 create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/DashboardAndQuestionCachingTab.tsx
 create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/StrategyEditorForQuestionsAndDashboards.module.css
 create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/StrategyEditorForQuestionsAndDashboards.tsx
 create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/TableRowForCacheableItem.styled.tsx
 create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/TableRowForCacheableItem.tsx
 create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/constants.tsx
 create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/utils.tsx
 create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/utils.unit.spec.tsx
 create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/types.tsx
 create mode 100644 frontend/src/metabase/admin/performance/components/ModelPersistenceConfiguration.module.css
 rename frontend/src/metabase/admin/performance/components/{PerformanceApp.styled.tsx => PerformanceApp.module.css} (57%)
 delete mode 100644 frontend/src/metabase/browse/components/constants.tsx
 create mode 100644 frontend/src/metabase/common/components/EllipsifiedPath/EllipsifiedCollectionPath.tsx
 create mode 100644 frontend/src/metabase/common/components/Table/types.ts
 create mode 100644 frontend/src/metabase/common/components/Table/useTableSorting.tsx
 create mode 100644 frontend/src/metabase/common/components/types.ts

diff --git a/docs/configuring-metabase/caching.md b/docs/configuring-metabase/caching.md
index 36edaa0a1a5..d9e24f00f20 100644
--- a/docs/configuring-metabase/caching.md
+++ b/docs/configuring-metabase/caching.md
@@ -83,7 +83,7 @@ _* Denotes [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://
 
 ### Default caching policy
 
-To set a default caching policy for your Metabase: Hit Cmd/Ctrl + k to bring up the command palette and search for **Performance**. Or, click through **Gear** settings icon > **Admin settings** > **Performance** > **Database caching settings**.
+To set a default caching policy for your Metabase: Hit Cmd/Ctrl + k to bring up the command palette and search for **Performance**. Or, click through **Gear** settings icon > **Admin settings** > **Performance** > **Database caching**.
 
 Click on the button next to **Default policy**, and select a [cache invalidation policy](#cache-invalidation-policies).
 
@@ -137,7 +137,7 @@ A question policy overrides a dashboard policy, which overrides a database polic
 To clear the cache and refresh the results:
 
 - **Questions and dashboards**: Visit the item and click through the **Info > Caching policy > Clear cache** (the "Clear cache" button is at the bottom of the sidebar).
-- **Database**: Click the **Gear** icon and click through **Admin settings** > **Performance** > **Database caching settings**. Select your database and click the **Clear cache** button (at the bottom of the page).
+- **Database**: Click the **Gear** icon and click through **Admin settings** > **Performance** > **Database caching**. Select your database and click the **Clear cache** button (at the bottom of the page).
 
 ## Caching location
 
diff --git a/e2e/test/scenarios/admin/performance/helpers/e2e-performance-helpers.ts b/e2e/test/scenarios/admin/performance/helpers/e2e-performance-helpers.ts
index aedde7d6368..210f2b8bb9b 100644
--- a/e2e/test/scenarios/admin/performance/helpers/e2e-performance-helpers.ts
+++ b/e2e/test/scenarios/admin/performance/helpers/e2e-performance-helpers.ts
@@ -6,7 +6,7 @@ dayjs.extend(duration);
 dayjs.extend(relativeTime);
 
 /** Intercept routes for caching tests */
-export const interceptRoutes = () => {
+export const interceptPerformanceRoutes = () => {
   cy.intercept("POST", "/api/dataset").as("dataset");
   cy.intercept("POST", "/api/card/*/query").as("cardQuery");
   cy.intercept("PUT", "/api/cache").as("putCacheConfig");
@@ -28,5 +28,14 @@ export const log = (message: string) => {
   console.log(message);
 };
 
-export const databaseCachingSettingsPage = () =>
-  cy.findByRole("main", { name: "Database caching settings" });
+export const databaseCachingPage = () =>
+  cy.findByRole("tabpanel", { name: "Database caching" });
+
+export const visitDashboardAndQuestionCachingTab = () => {
+  cy.visit("/admin/performance");
+  cy.findByRole("tablist")
+    .get("[aria-selected]")
+    .contains("Database caching")
+    .should("be.visible");
+  cy.findByRole("tab", { name: "Dashboard and question caching" }).click();
+};
diff --git a/e2e/test/scenarios/admin/performance/helpers/e2e-strategy-form-helpers.ts b/e2e/test/scenarios/admin/performance/helpers/e2e-strategy-form-helpers.ts
index 34fbd3e6ad0..f85d672c5a3 100644
--- a/e2e/test/scenarios/admin/performance/helpers/e2e-strategy-form-helpers.ts
+++ b/e2e/test/scenarios/admin/performance/helpers/e2e-strategy-form-helpers.ts
@@ -1,10 +1,11 @@
+import { modal } from "e2e/support/helpers";
 import {
   type ScheduleComponentType,
   getScheduleComponentLabel,
 } from "metabase/components/Schedule/constants";
 import type { CacheStrategyType, CacheableModel } from "metabase-types/api";
 
-import { databaseCachingSettingsPage } from "./e2e-performance-helpers";
+import { databaseCachingPage } from "./e2e-performance-helpers";
 
 /** Save the cache strategy form and wait for a response from the relevant endpoint */
 export const saveCacheStrategyForm = (options?: {
@@ -29,7 +30,7 @@ export const saveCacheStrategyForm = (options?: {
 };
 
 export const cacheStrategyForm = () =>
-  cy.findByLabelText("Select the cache invalidation policy");
+  cy.findByRole("form", { name: "Select the cache invalidation policy" });
 
 export const cacheStrategyRadioButton = (name: RegExp) =>
   cacheStrategyForm().findByRole("radio", { name });
@@ -50,21 +51,21 @@ export const formLauncher = (
     | "currently inheriting the default policy",
   strategyLabel = "",
 ) => {
-  databaseCachingSettingsPage().should("exist");
   const regExp = new RegExp(`Edit.*${itemName}.*${preface}.*${strategyLabel}`);
   cy.log(`Finding strategy for launcher for regular expression: ${regExp}`);
-  const launcher = databaseCachingSettingsPage().findByLabelText(regExp);
+  const launcher = databaseCachingPage().findByLabelText(regExp);
   launcher.should("exist");
   return launcher;
 };
 
+/** Opens the strategy form on 'Database caching' tab */
 export const openStrategyFormForDatabaseOrDefaultPolicy = (
   /** To open the form for the default policy, set this parameter to "default policy" */
   databaseNameOrDefaultPolicy: string,
   currentStrategyLabel?: string,
 ) => {
   cy.visit("/admin/performance");
-  cy.findByRole("tab", { name: "Database caching settings" }).click();
+  cy.findByRole("tablist").get("[aria-selected]").contains("Database caching");
   cy.log(`Open strategy form for ${databaseNameOrDefaultPolicy}`);
   formLauncher(
     databaseNameOrDefaultPolicy,
@@ -91,3 +92,10 @@ export const openSidebarCacheStrategyForm = () => {
   cy.wait("@getCacheConfig");
   cy.findByLabelText("Caching policy").click();
 };
+
+export const cancelConfirmationModal = () => {
+  modal().within(() => {
+    cy.findByText("Discard your changes?");
+    cy.button("Cancel").click();
+  });
+};
diff --git a/e2e/test/scenarios/admin/performance/performance.cy.spec.ts b/e2e/test/scenarios/admin/performance/performance.cy.spec.ts
index 6a25467e6d2..5a4c231da48 100644
--- a/e2e/test/scenarios/admin/performance/performance.cy.spec.ts
+++ b/e2e/test/scenarios/admin/performance/performance.cy.spec.ts
@@ -1,317 +1,406 @@
-import { describeEE, restore, setTokenFeatures } from "e2e/support/helpers";
+import {
+  ORDERS_COUNT_QUESTION_ID,
+  ORDERS_QUESTION_ID,
+} from "e2e/support/cypress_sample_instance_data";
+import {
+  describeEE,
+  restore,
+  setTokenFeatures,
+  visitQuestion,
+} from "e2e/support/helpers";
 
+import {
+  interceptPerformanceRoutes,
+  visitDashboardAndQuestionCachingTab,
+} from "./helpers/e2e-performance-helpers";
 import {
   adaptiveRadioButton,
   cacheStrategyForm,
   cacheStrategyRadioButton,
+  cancelConfirmationModal,
   dontCacheResultsRadioButton,
   durationRadioButton,
   formLauncher,
+  openSidebarCacheStrategyForm,
   openStrategyFormForDatabaseOrDefaultPolicy,
   saveCacheStrategyForm,
   scheduleRadioButton,
   useDefaultRadioButton,
 } from "./helpers/e2e-strategy-form-helpers";
 
-// NOTE: These tests just check that the form can be saved. They do not test
-// whether the cache is actually invalidated at the specified times.
-
-describe("scenarios > admin > performance", { tags: "@OSS" }, () => {
-  beforeEach(() => {
-    restore();
-    cy.intercept("PUT", "/api/cache").as("putCacheConfig");
-    cy.intercept("DELETE", "/api/cache").as("deleteCacheConfig");
-    cy.intercept("POST", "/api/persist/enable").as("enablePersistence");
-    cy.intercept("POST", "/api/persist/disable").as("disablePersistence");
-    cy.signInAsAdmin();
-
-    cy.visit("/admin");
-    cy.findByRole("link", { name: "Performance" }).click();
-  });
-
-  it("can enable and disable model persistence", () => {
-    cy.findByRole("tab", { name: "Model persistence" }).click();
-    cy.findByRole("checkbox", { name: "Disabled" }).next("label").click();
-    cy.wait("@enablePersistence");
-    cy.findByTestId("toast-undo").contains("Saved");
-    cy.findByTestId("toast-undo")
-      .findByRole("img", { name: /close icon/ })
-      .click();
-
-    cy.findByRole("checkbox", { name: "Enabled" }).next("label").click();
-    cy.wait("@disablePersistence");
-    cy.findByTestId("toast-undo").contains("Saved");
-  });
-
-  it("can change when models are refreshed", () => {
-    cy.findByRole("tab", { name: "Model persistence" }).click();
-    cy.findByRole("checkbox", { name: "Disabled" }).next("label").click();
-    cy.wait("@enablePersistence");
-    cy.findByTestId("toast-undo").contains("Saved");
-    cy.findByTestId("toast-undo")
-      .findByRole("img", { name: /close icon/ })
-      .click();
-    cy.findByRole("combobox").click();
-    cy.findByRole("listbox").findByText("2 hours").click();
-    cy.findByTestId("toast-undo").contains("Saved");
-  });
-
-  it("there are two policy options for the default policy, Adaptive and Don't cache results", () => {
-    cacheStrategyForm().findAllByRole("radio").should("have.length", 2);
-    adaptiveRadioButton().should("exist");
-    dontCacheResultsRadioButton().should("exist");
-  });
-
-  it("can set default policy to Don't cache results", () => {
-    const model = "root";
-    cy.log("Set default policy to Adaptive first");
-    adaptiveRadioButton().click();
-    saveCacheStrategyForm({ strategyType: "ttl", model });
-    adaptiveRadioButton().should("be.checked");
-
-    cy.log("Then set default policy to Don't cache results");
-    dontCacheResultsRadioButton().click();
-    saveCacheStrategyForm({ strategyType: "nocache", model });
-    dontCacheResultsRadioButton().should("be.checked");
-  });
+/** NOTE: These do not test whether caches are actually invalidated at the specified times. */
+describe("scenarios > admin > performance", () => {
+  describe("oss", { tags: "@OSS" }, () => {
+    beforeEach(() => {
+      restore();
+      interceptPerformanceRoutes();
+      cy.signInAsAdmin();
 
-  describe("adaptive strategy", () => {
-    it("can set default policy to adaptive", () => {
-      adaptiveRadioButton().click();
-      saveCacheStrategyForm({ strategyType: "ttl", model: "root" });
-      adaptiveRadioButton().should("be.checked");
+      cy.visit("/admin");
+      cy.findByRole("link", { name: "Performance" }).click();
     });
-
-    it("can configure a minimum query duration for the default adaptive policy", () => {
-      adaptiveRadioButton().click();
-      cy.findByLabelText(/Minimum query duration/).type("1000");
-      saveCacheStrategyForm({ strategyType: "ttl", model: "root" });
-      adaptiveRadioButton().should("be.checked");
-      cy.findByLabelText(/Minimum query duration/).should("have.value", "1000");
+    it("has the right tabs", () => {
+      cy.findByRole("main")
+        .findByRole("tablist")
+        .findAllByRole("tab")
+        .should("have.length", 2);
+      cy.findByRole("tab", { name: "Database caching" }).should("be.visible");
+      cy.findByRole("tab", { name: "Model persistence" }).should("be.visible");
+      cy.findByRole("tab", { name: "Dashboard and question caching" }).should(
+        "not.exist",
+      );
     });
 
-    it("can configure a multiplier for the default adaptive policy", () => {
-      adaptiveRadioButton().click();
-      cy.findByLabelText(/Multiplier/).type("3");
-      saveCacheStrategyForm({ strategyType: "ttl", model: "root" });
-      adaptiveRadioButton().should("be.checked");
-      cy.findByLabelText(/Multiplier/).should("have.value", "3");
-    });
+    describe("model persistence tab", () => {
+      it("can enable and disable model persistence", () => {
+        cy.findByRole("tab", { name: "Model persistence" }).click();
+        cy.findByRole("checkbox", { name: "Disabled" }).next("label").click();
+        cy.wait("@enablePersistence");
+        cy.findByTestId("toast-undo").contains("Saved");
+        cy.findByTestId("toast-undo")
+          .findByRole("img", { name: /close icon/ })
+          .click();
+
+        cy.findByRole("checkbox", { name: "Enabled" }).next("label").click();
+        cy.wait("@disablePersistence");
+        cy.findByTestId("toast-undo").contains("Saved");
+      });
 
-    it("can configure both a minimum query duration and a multiplier for the default adaptive policy", () => {
-      adaptiveRadioButton().click();
-      cy.findByLabelText(/Minimum query duration/).type("1234");
-      cy.findByLabelText(/Multiplier/).type("4");
-      saveCacheStrategyForm({ strategyType: "ttl", model: "root" });
-      adaptiveRadioButton().should("be.checked");
-      cy.findByLabelText(/Minimum query duration/).should("have.value", "1234");
-      cy.findByLabelText(/Multiplier/).should("have.value", "4");
+      it("can change when models are refreshed", () => {
+        cy.findByRole("tab", { name: "Model persistence" }).click();
+        cy.findByRole("checkbox", { name: "Disabled" }).next("label").click();
+        cy.wait("@enablePersistence");
+        cy.findByTestId("toast-undo").contains("Saved");
+        cy.findByTestId("toast-undo")
+          .findByRole("img", { name: /close icon/ })
+          .click();
+        cy.findByRole("combobox").click();
+        cy.findByRole("listbox").findByText("2 hours").click();
+        cy.findByTestId("toast-undo").contains("Saved");
+      });
     });
-  });
-});
 
-describeEE("EE", () => {
-  beforeEach(() => {
-    restore();
-    cy.intercept("PUT", "/api/cache").as("putCacheConfig");
-    cy.intercept("DELETE", "/api/cache").as("deleteCacheConfig");
-    cy.intercept(
-      "POST",
-      "/api/cache/invalidate?include=overrides&database=1",
-    ).as("invalidateCacheForSampleDatabase");
-    cy.intercept("POST", "/api/persist/enable").as("enablePersistence");
-    cy.intercept("POST", "/api/persist/disable").as("disablePersistence");
-    cy.signInAsAdmin();
-    setTokenFeatures("all");
-  });
-
-  const checkInheritanceIfNeeded = (itemName: string, strategyName: string) => {
-    if (itemName === "default policy") {
-      cy.log(
-        `Sample Database is now inheriting a default policy of ${strategyName}`,
-      );
-      formLauncher(
-        "Sample Database",
-        "currently inheriting the default policy",
-        strategyName,
-      );
-    }
-  };
+    describe("database caching tab", () => {
+      it("there are two policy options for the default policy, Adaptive and Don't cache results", () => {
+        cacheStrategyForm().findAllByRole("radio").should("have.length", 2);
+        adaptiveRadioButton().should("exist");
+        dontCacheResultsRadioButton().should("exist");
+      });
 
-  it("can call cache invalidation endpoint for Sample Database", () => {
-    openStrategyFormForDatabaseOrDefaultPolicy("default policy", "No caching");
-    cy.log('A "Clear cache" button is not present for the default policy');
-    cy.button(/Clear cache/).should("not.exist");
+      it("can set default policy to Don't cache results", () => {
+        const model = "root";
+        cy.log("Set default policy to Adaptive first");
+        adaptiveRadioButton().click();
+        saveCacheStrategyForm({ strategyType: "ttl", model });
+        adaptiveRadioButton().should("be.checked");
 
-    openStrategyFormForDatabaseOrDefaultPolicy("Sample Database", "No caching");
-    cy.log(
-      'A "Clear cache" button is not yet present because the database does not use a cache',
-    );
-    cy.button(/Clear cache/).should("not.exist");
+        cy.log("Then set default policy to Don't cache results");
+        dontCacheResultsRadioButton().click();
+        saveCacheStrategyForm({ strategyType: "nocache", model });
+        dontCacheResultsRadioButton().should("be.checked");
+      });
 
-    cy.log("Set Sample Database's caching policy to Duration");
-    durationRadioButton().click();
+      describe("adaptive strategy", () => {
+        it("can set default policy to adaptive", () => {
+          adaptiveRadioButton().click();
+          saveCacheStrategyForm({ strategyType: "ttl", model: "root" });
+          adaptiveRadioButton().should("be.checked");
+        });
 
-    cy.log("Save the caching strategy form");
-    saveCacheStrategyForm({ strategyType: "duration", model: "database" });
+        it("can configure a minimum query duration for the default adaptive policy", () => {
+          adaptiveRadioButton().click();
+          cy.findByLabelText(/Minimum query duration/).type("1000");
+          saveCacheStrategyForm({ strategyType: "ttl", model: "root" });
+          adaptiveRadioButton().should("be.checked");
+          cy.findByLabelText(/Minimum query duration/).should(
+            "have.value",
+            "1000",
+          );
+        });
 
-    cy.log("Now there's a 'Clear cache' button. Click it");
-    cy.button(/Clear cache/).click();
+        it("can configure a multiplier for the default adaptive policy", () => {
+          adaptiveRadioButton().click();
+          cy.findByLabelText(/Multiplier/).type("3");
+          saveCacheStrategyForm({ strategyType: "ttl", model: "root" });
+          adaptiveRadioButton().should("be.checked");
+          cy.findByLabelText(/Multiplier/).should("have.value", "3");
+        });
 
-    cy.log('Confirm via the "Clear cache" dialog');
-    cy.findByRole("dialog")
-      .button(/Clear cache/)
-      .click();
+        it("can configure both a minimum query duration and a multiplier for the default adaptive policy", () => {
+          adaptiveRadioButton().click();
+          cy.findByLabelText(/Minimum query duration/).type("1234");
+          cy.findByLabelText(/Multiplier/).type("4");
+          saveCacheStrategyForm({ strategyType: "ttl", model: "root" });
+          adaptiveRadioButton().should("be.checked");
+          cy.findByLabelText(/Minimum query duration/).should(
+            "have.value",
+            "1234",
+          );
+          cy.findByLabelText(/Multiplier/).should("have.value", "4");
+        });
+      });
+    });
+  });
 
-    cy.wait("@invalidateCacheForSampleDatabase");
+  describeEE("ee", () => {
+    beforeEach(() => {
+      restore();
+      interceptPerformanceRoutes();
+      cy.signInAsAdmin();
+      setTokenFeatures("all");
+    });
 
-    cy.log("The cache has been cleared");
-    cy.button(/Cache cleared/);
-  });
+    const checkInheritanceIfNeeded = (
+      itemName: string,
+      strategyName: string,
+    ) => {
+      if (itemName === "default policy") {
+        cy.log(
+          `Sample Database is now inheriting a default policy of ${strategyName}`,
+        );
+        formLauncher(
+          "Sample Database",
+          "currently inheriting the default policy",
+          strategyName,
+        );
+      }
+    };
+
+    it("has the right tabs", () => {
+      cy.visit("/admin");
+      cy.findByRole("link", { name: "Performance" }).click();
+      cy.findByRole("main")
+        .findByRole("tablist")
+        .findAllByRole("tab")
+        .should("have.length", 3);
+      cy.findByRole("tab", { name: "Database caching" }).should("be.visible");
+      cy.findByRole("tab", { name: "Dashboard and question caching" }).should(
+        "be.visible",
+      );
+      cy.findByRole("tab", { name: "Model persistence" }).should("be.visible");
+    });
 
-  [/Duration/, /Schedule/, /Adaptive/].forEach(strategy => {
-    const strategyAsString = strategy.toString().replace(/\//g, "");
-    it(`can configure Sample Database to use a default policy of ${strategyAsString}`, () => {
-      cy.log(`Set default policy to ${strategy}`);
+    it("can call cache invalidation endpoint for Sample Database", () => {
       openStrategyFormForDatabaseOrDefaultPolicy(
         "default policy",
         "No caching",
       );
-      cacheStrategyRadioButton(strategy).click();
-      saveCacheStrategyForm();
+      cy.log('A "Clear cache" button is not present for the default policy');
+      cy.button(/Clear cache/).should("not.exist");
 
-      cy.log("Open strategy form for Sample Database");
       openStrategyFormForDatabaseOrDefaultPolicy(
         "Sample Database",
-        strategyAsString,
+        "No caching",
       );
+      cy.log(
+        'A "Clear cache" button is not yet present because the database does not use a cache',
+      );
+      cy.button(/Clear cache/).should("not.exist");
 
-      cy.log("Set Sample Database to Duration first");
+      cy.log("Set Sample Database's caching policy to Duration");
       durationRadioButton().click();
+
+      cy.log("Save the caching strategy form");
       saveCacheStrategyForm({ strategyType: "duration", model: "database" });
-      formLauncher("Sample Database", "currently", "Duration");
 
-      cy.log("Then configure Sample Database to use the default policy");
-      useDefaultRadioButton().click();
-      saveCacheStrategyForm({ strategyType: "inherit", model: "database" });
-      formLauncher("Sample Database", "currently inheriting", strategyAsString);
-    });
-  });
+      cy.log("Now there's a 'Clear cache' button. Click it");
+      cy.button(/Clear cache/).click();
 
-  ["default policy", "Sample Database"].forEach(itemName => {
-    const model = itemName === "default policy" ? "root" : "database";
-    const expectedNumberOfOptions = itemName === "default policy" ? 4 : 5;
-    it(`there are ${expectedNumberOfOptions} policy options for ${itemName}`, () => {
-      openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching");
-      cacheStrategyForm()
-        .findAllByRole("radio")
-        .should("have.length", expectedNumberOfOptions);
-    });
+      cy.log('Confirm via the "Clear cache" dialog');
+      cy.findByRole("dialog")
+        .button(/Clear cache/)
+        .click();
 
-    it(`can set ${itemName} to Don't cache results`, () => {
-      openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching");
-      cy.log(`Set ${itemName} to Duration first`);
-      durationRadioButton().click();
-      saveCacheStrategyForm({ strategyType: "duration", model });
-      formLauncher(itemName, "currently", "Duration");
-
-      cy.log(`Then set ${itemName} to Don't cache results`);
-      dontCacheResultsRadioButton().click();
-      saveCacheStrategyForm({ strategyType: "nocache", model });
-      formLauncher(itemName, "currently", "No caching");
-      checkInheritanceIfNeeded(itemName, "No caching");
-    });
+      cy.wait("@invalidateCacheForSampleDatabase");
 
-    it(`can set ${itemName} to a duration-based cache invalidation policy`, () => {
-      openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching");
-      cy.log(`Set ${itemName} to Duration`);
-      durationRadioButton().click();
-      saveCacheStrategyForm({ strategyType: "duration", model });
-      cy.log(`${itemName} is now set to Duration`);
-      formLauncher(itemName, "currently", "Duration");
-      checkInheritanceIfNeeded(itemName, "Duration");
+      cy.log("The cache has been cleared");
+      cy.button(/Cache cleared/);
     });
 
-    describe("adaptive strategy", () => {
-      beforeEach(() => {
-        openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching");
-        adaptiveRadioButton().click();
-      });
+    [/Duration/, /Schedule/, /Adaptive/].forEach(strategy => {
+      const strategyAsString = strategy.toString().replace(/\//g, "");
+      it(`can configure Sample Database to use a default policy of ${strategyAsString}`, () => {
+        cy.log(`Set default policy to ${strategy}`);
+        openStrategyFormForDatabaseOrDefaultPolicy(
+          "default policy",
+          "No caching",
+        );
+        cacheStrategyRadioButton(strategy).click();
+        saveCacheStrategyForm();
 
-      it(`can set ${itemName} to adaptive`, () => {
-        saveCacheStrategyForm({ strategyType: "ttl", model });
-        formLauncher(itemName, "currently", "Adaptive");
-        checkInheritanceIfNeeded(itemName, "Adaptive");
-      });
+        cy.log("Open strategy form for Sample Database");
+        openStrategyFormForDatabaseOrDefaultPolicy(
+          "Sample Database",
+          strategyAsString,
+        );
 
-      it(`can configure a minimum query duration for ${itemName}'s adaptive policy`, () => {
-        cy.findByLabelText(/Minimum query duration/).type("1000");
-        saveCacheStrategyForm({ strategyType: "ttl", model });
-        formLauncher(itemName, "currently", "Adaptive");
-        cy.findByLabelText(/Minimum query duration/).should(
-          "have.value",
-          "1000",
+        cy.log("Set Sample Database to Duration first");
+        durationRadioButton().click();
+        saveCacheStrategyForm({ strategyType: "duration", model: "database" });
+        formLauncher("Sample Database", "currently", "Duration");
+
+        cy.log("Then configure Sample Database to use the default policy");
+        useDefaultRadioButton().click();
+        saveCacheStrategyForm({ strategyType: "inherit", model: "database" });
+        formLauncher(
+          "Sample Database",
+          "currently inheriting",
+          strategyAsString,
         );
       });
+    });
 
-      it(`can configure a multiplier for ${itemName}'s adaptive policy`, () => {
-        cy.findByLabelText(/Multiplier/).type("3");
-        saveCacheStrategyForm({ strategyType: "ttl", model });
-        formLauncher(itemName, "currently", "Adaptive");
-        cy.findByLabelText(/Multiplier/).should("have.value", "3");
+    ["default policy", "Sample Database"].forEach(itemName => {
+      const model = itemName === "default policy" ? "root" : "database";
+      const expectedNumberOfOptions = itemName === "default policy" ? 4 : 5;
+      it(`there are ${expectedNumberOfOptions} policy options for ${itemName}`, () => {
+        openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching");
+        cacheStrategyForm()
+          .findAllByRole("radio")
+          .should("have.length", expectedNumberOfOptions);
       });
 
-      it(`can configure both a minimum query duration and a multiplier for ${itemName}'s adaptive policy`, () => {
-        cy.findByLabelText(/Minimum query duration/).type("1234");
-        cy.findByLabelText(/Multiplier/).type("4");
-        saveCacheStrategyForm({ strategyType: "ttl", model });
-        formLauncher(itemName, "currently", "Adaptive");
-        cy.findByLabelText(/Minimum query duration/).should(
-          "have.value",
-          "1234",
-        );
-        cy.findByLabelText(/Multiplier/).should("have.value", "4");
+      it(`can set ${itemName} to Don't cache results`, () => {
+        openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching");
+        cy.log(`Set ${itemName} to Duration first`);
+        durationRadioButton().click();
+        saveCacheStrategyForm({ strategyType: "duration", model });
+        formLauncher(itemName, "currently", "Duration");
+
+        cy.log(`Then set ${itemName} to Don't cache results`);
+        dontCacheResultsRadioButton().click();
+        saveCacheStrategyForm({ strategyType: "nocache", model });
+        formLauncher(itemName, "currently", "No caching");
+        checkInheritanceIfNeeded(itemName, "No caching");
       });
-    });
 
-    describe(`can set ${itemName} to a schedule-based cache invalidation policy`, () => {
-      beforeEach(() => {
-        cy.visit("/admin");
-        cy.findByRole("link", { name: "Performance" }).click();
-        cy.log(`Open caching strategy form for ${itemName}`);
-        formLauncher(
-          itemName,
-          itemName === "default policy"
-            ? "currently"
-            : "currently inheriting the default policy",
-          "No caching",
-        ).click();
-        cy.log("View schedule options");
-        scheduleRadioButton().click();
+      it(`can set ${itemName} to a duration-based cache invalidation policy`, () => {
+        openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching");
+        cy.log(`Set ${itemName} to Duration`);
+        durationRadioButton().click();
+        saveCacheStrategyForm({ strategyType: "duration", model });
+        cy.log(`${itemName} is now set to Duration`);
+        formLauncher(itemName, "currently", "Duration");
+        checkInheritanceIfNeeded(itemName, "Duration");
       });
 
-      const selectScheduleType = (type: string) => {
-        cy.log(`Set schedule to "${type}"`);
-        cy.findByRole("searchbox").click();
-        cy.findByRole("listbox").findByText(type).click();
-      };
+      describe("adaptive strategy", () => {
+        beforeEach(() => {
+          openStrategyFormForDatabaseOrDefaultPolicy(itemName, "No caching");
+          adaptiveRadioButton().click();
+        });
+
+        it(`can set ${itemName} to adaptive`, () => {
+          saveCacheStrategyForm({ strategyType: "ttl", model });
+          formLauncher(itemName, "currently", "Adaptive");
+          checkInheritanceIfNeeded(itemName, "Adaptive");
+        });
+
+        it(`can configure a minimum query duration for ${itemName}'s adaptive policy`, () => {
+          cy.findByLabelText(/Minimum query duration/).type("1000");
+          saveCacheStrategyForm({ strategyType: "ttl", model });
+          formLauncher(itemName, "currently", "Adaptive");
+          cy.findByLabelText(/Minimum query duration/).should(
+            "have.value",
+            "1000",
+          );
+        });
+
+        it(`can configure a multiplier for ${itemName}'s adaptive policy`, () => {
+          cy.findByLabelText(/Multiplier/).type("3");
+          saveCacheStrategyForm({ strategyType: "ttl", model });
+          formLauncher(itemName, "currently", "Adaptive");
+          cy.findByLabelText(/Multiplier/).should("have.value", "3");
+        });
 
-      it(`can save a new hourly schedule policy for ${itemName}`, () => {
-        selectScheduleType("hourly");
-        saveCacheStrategyForm({ strategyType: "schedule", model });
-        formLauncher(itemName, "currently", "Scheduled: hourly");
+        it(`can configure both a minimum query duration and a multiplier for ${itemName}'s adaptive policy`, () => {
+          cy.findByLabelText(/Minimum query duration/).type("1234");
+          cy.findByLabelText(/Multiplier/).type("4");
+          saveCacheStrategyForm({ strategyType: "ttl", model });
+          formLauncher(itemName, "currently", "Adaptive");
+          cy.findByLabelText(/Minimum query duration/).should(
+            "have.value",
+            "1234",
+          );
+          cy.findByLabelText(/Multiplier/).should("have.value", "4");
+        });
       });
 
-      it(`can save a new daily schedule policy - for ${itemName}`, () => {
-        [12, 1, 11].forEach(time => {
-          ["AM", "PM"].forEach(amPm => {
-            cy.log(`Test daily at ${time} ${amPm}`);
-            selectScheduleType("daily");
+      describe(`can set ${itemName} to a schedule-based cache invalidation policy`, () => {
+        beforeEach(() => {
+          cy.visit("/admin");
+          cy.findByRole("link", { name: "Performance" }).click();
+          cy.log(`Open caching strategy form for ${itemName}`);
+          formLauncher(
+            itemName,
+            itemName === "default policy"
+              ? "currently"
+              : "currently inheriting the default policy",
+            "No caching",
+          ).click();
+          cy.log("View schedule options");
+          scheduleRadioButton().click();
+        });
+
+        const selectScheduleType = (type: string) => {
+          cy.log(`Set schedule to "${type}"`);
+          cy.findByRole("searchbox").click();
+          cy.findByRole("listbox").findByText(type).click();
+        };
+
+        it(`can save a new hourly schedule policy for ${itemName}`, () => {
+          selectScheduleType("hourly");
+          saveCacheStrategyForm({ strategyType: "schedule", model });
+          formLauncher(itemName, "currently", "Scheduled: hourly");
+        });
+
+        it(`can save a new daily schedule policy - for ${itemName}`, () => {
+          [12, 1, 11].forEach(time => {
+            ["AM", "PM"].forEach(amPm => {
+              cy.log(`Test daily at ${time} ${amPm}`);
+              selectScheduleType("daily");
+              cy.findAllByRole("searchbox").eq(1).click();
+              cy.findByRole("listbox").findByText(`${time}:00`).click();
+              cy.findByLabelText(amPm).next().click();
+              saveCacheStrategyForm({ strategyType: "schedule", model });
+              formLauncher("Sample Database", "currently", "Scheduled: daily");
+
+              // reset for next iteration of loop
+              dontCacheResultsRadioButton().click();
+              saveCacheStrategyForm({ strategyType: "nocache", model });
+              scheduleRadioButton().click();
+            });
+          });
+        });
+
+        it(`can save a new weekly schedule policy - for ${itemName}`, () => {
+          [
+            ["Sunday", "12:00 AM"],
+            ["Monday", "1:00 AM"],
+            ["Tuesday", "11:00 AM"],
+            ["Wednesday", "12:00 PM"],
+            ["Thursday", "1:00 PM"],
+            ["Friday", "7:00 PM"],
+            ["Saturday", "11:00 PM"],
+          ].forEach(([day, time]) => {
+            cy.log(`testing on ${day} at ${time}`);
+            selectScheduleType("weekly");
             cy.findAllByRole("searchbox").eq(1).click();
-            cy.findByRole("listbox").findByText(`${time}:00`).click();
+            cy.findByRole("listbox").findByText(day).click();
+            cy.findAllByRole("searchbox").eq(2).click();
+            const [hour, amPm] = time.split(" ");
+            cy.findByRole("listbox").findByText(hour).click();
             cy.findByLabelText(amPm).next().click();
             saveCacheStrategyForm({ strategyType: "schedule", model });
-            formLauncher("Sample Database", "currently", "Scheduled: daily");
+            formLauncher(itemName, "currently", "Scheduled: weekly");
+            cy.findAllByRole("searchbox").then(searchBoxes => {
+              const values = Cypress._.map(
+                searchBoxes,
+                box => (box as HTMLInputElement).value,
+              );
+              expect(values).to.deep.equal(["weekly", day, hour]);
+            });
+            cy.findByRole("radio", { name: amPm }).should("be.checked");
 
             // reset for next iteration of loop
             dontCacheResultsRadioButton().click();
@@ -320,41 +409,95 @@ describeEE("EE", () => {
           });
         });
       });
+    });
 
-      it(`can save a new weekly schedule policy - for ${itemName}`, () => {
-        [
-          ["Sunday", "12:00 AM"],
-          ["Monday", "1:00 AM"],
-          ["Tuesday", "11:00 AM"],
-          ["Wednesday", "12:00 PM"],
-          ["Thursday", "1:00 PM"],
-          ["Friday", "7:00 PM"],
-          ["Saturday", "11:00 PM"],
-        ].forEach(([day, time]) => {
-          cy.log(`testing on ${day} at ${time}`);
-          selectScheduleType("weekly");
-          cy.findAllByRole("searchbox").eq(1).click();
-          cy.findByRole("listbox").findByText(day).click();
-          cy.findAllByRole("searchbox").eq(2).click();
-          const [hour, amPm] = time.split(" ");
-          cy.findByRole("listbox").findByText(hour).click();
-          cy.findByLabelText(amPm).next().click();
-          saveCacheStrategyForm({ strategyType: "schedule", model });
-          formLauncher(itemName, "currently", "Scheduled: weekly");
-          cy.findAllByRole("searchbox").then(searchBoxes => {
-            const values = Cypress._.map(
-              searchBoxes,
-              box => (box as HTMLInputElement).value,
-            );
-            expect(values).to.deep.equal(["weekly", day, hour]);
+    describe("Dashboard and question caching tab", () => {
+      it("can configure Sample Database on the 'Dashboard and question caching' tab", () => {
+        interceptPerformanceRoutes();
+        visitDashboardAndQuestionCachingTab();
+        const table = () =>
+          cy.findByRole("table", {
+            name: /Here are the dashboards and questions/,
+          });
+        table()
+          .should("be.visible")
+          .contains(
+            "No dashboards or questions have their own caching policies yet.",
+          );
+        visitQuestion(ORDERS_QUESTION_ID);
+        openSidebarCacheStrategyForm();
+        durationRadioButton().click();
+        cy.findByLabelText(/Cache results for this many hours/).type("99");
+        saveCacheStrategyForm({ strategyType: "duration", model: "database" });
+        visitDashboardAndQuestionCachingTab();
+        table()
+          .contains("Duration: 99h")
+          .within(() => {
+            cy.findByText("Duration: 99h").click();
           });
-          cy.findByRole("radio", { name: amPm }).should("be.checked");
+        adaptiveRadioButton().click();
+        saveCacheStrategyForm({ strategyType: "ttl", model: "database" });
+      });
 
-          // reset for next iteration of loop
-          dontCacheResultsRadioButton().click();
-          saveCacheStrategyForm({ strategyType: "nocache", model });
-          scheduleRadioButton().click();
+      it("confirmation modal appears before dirty form is abandoned", () => {
+        interceptPerformanceRoutes();
+        visitDashboardAndQuestionCachingTab();
+        const table = () =>
+          cy.findByRole("table", {
+            name: /Here are the dashboards and questions/,
+          });
+        table()
+          .should("be.visible")
+          .contains(
+            "No dashboards or questions have their own caching policies yet.",
+          );
+        visitQuestion(ORDERS_QUESTION_ID);
+        openSidebarCacheStrategyForm();
+        durationRadioButton().click();
+        cy.findByLabelText(/Cache results for this many hours/).type("99");
+        saveCacheStrategyForm({ strategyType: "duration", model: "database" });
+        visitDashboardAndQuestionCachingTab();
+        table()
+          .contains("Duration: 99h")
+          .within(() => {
+            cy.findByText("Duration: 99h").click();
+          });
+        adaptiveRadioButton().click();
+        saveCacheStrategyForm({ strategyType: "ttl", model: "database" });
+
+        visitQuestion(ORDERS_COUNT_QUESTION_ID);
+        openSidebarCacheStrategyForm();
+        durationRadioButton().click();
+        cy.findByLabelText(/Cache results for this many hours/).type("24");
+        saveCacheStrategyForm({ strategyType: "duration", model: "database" });
+        visitDashboardAndQuestionCachingTab();
+        table().contains("Adaptive");
+        table()
+          .contains("Duration: 24h")
+          .within(() => {
+            cy.findByText("Duration: 24h").click();
+          });
+
+        cy.log("Make form dirty");
+        scheduleRadioButton().click();
+
+        cy.log("Modal appears when another row's form launcher is clicked");
+        table()
+          .contains("Adaptive")
+          .within(() => {
+            cy.findByText("Adaptive").click();
+          });
+        cancelConfirmationModal();
+
+        cy.log("Modal appears when another admin nav item is clicked");
+        cy.findByLabelText("Navigation bar").within(() => {
+          cy.findByText("Settings").click();
         });
+        cancelConfirmationModal();
+
+        cy.log("Modal appears when another Performance tab is clicked");
+        cy.findByRole("tab", { name: "Database caching" }).click();
+        cancelConfirmationModal();
       });
     });
   });
diff --git a/e2e/test/scenarios/admin/performance/schedule.cy.spec.ts b/e2e/test/scenarios/admin/performance/schedule.cy.spec.ts
index 93d35645efd..88492b13899 100644
--- a/e2e/test/scenarios/admin/performance/schedule.cy.spec.ts
+++ b/e2e/test/scenarios/admin/performance/schedule.cy.spec.ts
@@ -9,7 +9,7 @@ import {
 import type { ScheduleComponentType } from "metabase/components/Schedule/constants";
 import type { CacheableModel } from "metabase-types/api";
 
-import { interceptRoutes } from "./helpers/e2e-performance-helpers";
+import { interceptPerformanceRoutes } from "./helpers/e2e-performance-helpers";
 import {
   cacheStrategyForm,
   getScheduleComponent,
@@ -26,7 +26,7 @@ import {
 describeEE("scenarios > admin > performance > schedule strategy", () => {
   beforeEach(() => {
     restore();
-    interceptRoutes();
+    interceptPerformanceRoutes();
     cy.signInAsAdmin();
     setTokenFeatures("all");
   });
diff --git a/e2e/test/scenarios/dashboard/dashboard.cy.spec.js b/e2e/test/scenarios/dashboard/dashboard.cy.spec.js
index 5592e038b5a..332c592a7b8 100644
--- a/e2e/test/scenarios/dashboard/dashboard.cy.spec.js
+++ b/e2e/test/scenarios/dashboard/dashboard.cy.spec.js
@@ -58,7 +58,7 @@ import {
   createMockVirtualDashCard,
 } from "metabase-types/api/mocks";
 
-import { interceptRoutes as interceptPerformanceRoutes } from "../admin/performance/helpers/e2e-performance-helpers";
+import { interceptPerformanceRoutes } from "../admin/performance/helpers/e2e-performance-helpers";
 import {
   adaptiveRadioButton,
   durationRadioButton,
diff --git a/e2e/test/scenarios/question/caching.cy.spec.js b/e2e/test/scenarios/question/caching.cy.spec.js
index 0d37c4f4e77..21615cb3673 100644
--- a/e2e/test/scenarios/question/caching.cy.spec.js
+++ b/e2e/test/scenarios/question/caching.cy.spec.js
@@ -7,7 +7,7 @@ import {
   visitQuestion,
 } from "e2e/support/helpers";
 
-import { interceptRoutes as interceptPerformanceRoutes } from "../admin/performance/helpers/e2e-performance-helpers";
+import { interceptPerformanceRoutes } from "../admin/performance/helpers/e2e-performance-helpers";
 import {
   adaptiveRadioButton,
   durationRadioButton,
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/DashboardAndQuestionCachingTab.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/DashboardAndQuestionCachingTab.tsx
new file mode 100644
index 00000000000..3a44c5b0810
--- /dev/null
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/DashboardAndQuestionCachingTab.tsx
@@ -0,0 +1,16 @@
+import P from "metabase/admin/performance/components/PerformanceApp.module.css";
+import { PerformanceTabId } from "metabase/admin/performance/types";
+import { getPerformanceTabName } from "metabase/admin/performance/utils";
+import { Tabs } from "metabase/ui";
+
+export const DashboardAndQuestionCachingTab = () => {
+  return (
+    <Tabs.Tab
+      className={P.Tab}
+      key="DashboardAndQuestionCaching"
+      value={PerformanceTabId.DashboardsAndQuestions}
+    >
+      {getPerformanceTabName(PerformanceTabId.DashboardsAndQuestions)}
+    </Tabs.Tab>
+  );
+};
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/StrategyEditorForQuestionsAndDashboards.module.css b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/StrategyEditorForQuestionsAndDashboards.module.css
new file mode 100644
index 00000000000..aecea51ef6a
--- /dev/null
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/StrategyEditorForQuestionsAndDashboards.module.css
@@ -0,0 +1,60 @@
+.NoResultsTableRow,
+.SkeletonTableRow {
+  &:hover {
+    background-color: transparent !important;
+  }
+}
+
+.SkeletonTableRow {
+  height: 3rem;
+
+  td {
+    width: 20rem;
+  }
+}
+
+.CacheableItemTable {
+  table-layout: fixed;
+  background-color: var(--mb-color-text-white);
+
+  col {
+    width: 30%;
+  }
+
+  td {
+    padding: 0.75rem !important;
+  }
+
+  tbody > tr:hover {
+    background-color: var(--mb-color-brand-alpha-04) !important;
+  }
+
+  tbody > tr.currentTarget {
+    background-color: var(--mb-color-brand-lighter) !important;
+  }
+}
+
+.StrategyFormPanel {
+  border-left: 1px solid var(--mb-color-border);
+  position: relative;
+  max-width: 30rem;
+
+  @media screen and (--breakpoint-max-lg) {
+    max-width: 25rem;
+  }
+}
+
+.ItemLink {
+  font-size: inherit !important;
+
+  &:hover {
+    color: var(--mb-color-brand);
+  }
+}
+
+.ItemLink,
+.CollectionLink {
+  :hover {
+    color: var(--mb-color-brand);
+  }
+}
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/StrategyEditorForQuestionsAndDashboards.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/StrategyEditorForQuestionsAndDashboards.tsx
new file mode 100644
index 00000000000..b0117d264ae
--- /dev/null
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/StrategyEditorForQuestionsAndDashboards.tsx
@@ -0,0 +1,374 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import type { InjectedRouter, Route } from "react-router";
+import { withRouter } from "react-router";
+import { t } from "ttag";
+import _ from "underscore";
+
+import { Panel } from "metabase/admin/performance/components/StrategyEditorForDatabases.styled";
+import { StrategyForm } from "metabase/admin/performance/components/StrategyForm";
+import { rootId } from "metabase/admin/performance/constants/simple";
+import { useCacheConfigs } from "metabase/admin/performance/hooks/useCacheConfigs";
+import { useConfirmIfFormIsDirty } from "metabase/admin/performance/hooks/useConfirmIfFormIsDirty";
+import { useSaveStrategy } from "metabase/admin/performance/hooks/useSaveStrategy";
+import { skipToken, useSearchQuery } from "metabase/api";
+import { ClientSortableTable } from "metabase/common/components/Table/ClientSortableTable";
+import type { ColumnItem } from "metabase/common/components/Table/types";
+import { useLocale } from "metabase/common/hooks/use-locale/use-locale";
+import { DelayedLoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper/DelayedLoadingAndErrorWrapper";
+import {
+  Box,
+  Button,
+  Center,
+  Flex,
+  Icon,
+  Skeleton,
+  Stack,
+  Text,
+} from "metabase/ui";
+import { Repeat } from "metabase/ui/components/feedback/Skeleton/Repeat";
+import type { CacheableModel } from "metabase-types/api";
+import { CacheDurationUnit } from "metabase-types/api";
+import { SortDirection } from "metabase-types/api/sorting";
+
+import type {
+  CacheableItem,
+  DashboardResult,
+  QuestionResult,
+  UpdateTarget,
+} from "../types";
+
+import Styles from "./StrategyEditorForQuestionsAndDashboards.module.css";
+import { TableRowForCacheableItem } from "./TableRowForCacheableItem";
+import { getConstants } from "./constants";
+import { formatValueForSorting } from "./utils";
+
+type CacheableItemResult = DashboardResult | QuestionResult;
+
+const _StrategyEditorForQuestionsAndDashboards = ({
+  router,
+  route,
+}: {
+  router: InjectedRouter;
+  route?: Route;
+}) => {
+  const [
+    // The targetId is the id of the object that is currently being edited
+    targetId,
+    setTargetId,
+  ] = useState<number | null>(null);
+
+  const { tableColumns } = useMemo(() => getConstants(), []);
+
+  const [targetModel, setTargetModel] = useState<CacheableModel | null>(null);
+
+  const configurableModels: CacheableModel[] = useMemo(
+    () => ["dashboard", "question"],
+    [],
+  );
+
+  const {
+    configs,
+    setConfigs,
+    error: configsError,
+    loading: configsAreLoading,
+  } = useCacheConfigs({ configurableModels });
+
+  const dashboardIds = useMemo(
+    () =>
+      configs
+        .filter(config => config.model === "dashboard")
+        .map(c => c.model_id),
+    [configs],
+  );
+
+  const questionIds = useMemo(
+    () =>
+      configs
+        .filter(config => config.model === "question")
+        .map(c => c.model_id),
+    [configs],
+  );
+
+  const dashboardsResult = useSearchQuery(
+    dashboardIds.length
+      ? {
+          models: ["dashboard"],
+          ids: dashboardIds,
+          //FIXME: Add `ancestors: true` once jds/ancestors-for-all-the-things is merged
+        }
+      : skipToken,
+  );
+  const questionsResult = useSearchQuery(
+    questionIds.length
+      ? {
+          models: ["card"],
+          ids: questionIds,
+          //FIXME: Add `ancestors: true` once jds/ancestors-for-all-the-things is merged
+        }
+      : skipToken,
+  );
+
+  const dashboardsAndQuestions = useMemo(
+    () =>
+      (dashboardsResult.data?.data || []).concat(
+        questionsResult.data?.data || [],
+      ) as CacheableItemResult[],
+    [dashboardsResult.data, questionsResult.data],
+  );
+
+  const cacheableItems = useMemo(() => {
+    const items = new Map<string, CacheableItem>();
+    for (const config of configs) {
+      items.set(`${config.model}${config.model_id}`, {
+        ..._.omit(config, "model_id"),
+        id: config.model_id,
+      });
+    }
+
+    // Hydrate data from the search results into the cacheable items
+    for (const result of dashboardsAndQuestions ?? []) {
+      const normalizedModel =
+        result.model === "card" ? "question" : result.model;
+      const item = items.get(`${normalizedModel}${result.id}`);
+      if (item) {
+        item.name = result.name;
+        item.collection = result.collection;
+        item.iconModel = result.model;
+      }
+    }
+    // Filter out items that have no match in the dashboard and question list
+    const hydratedCacheableItems: CacheableItem[] = [...items.values()].filter(
+      item => item.name !== undefined,
+    );
+
+    return hydratedCacheableItems;
+  }, [configs, dashboardsAndQuestions]);
+
+  useEffect(
+    /** When the user configures an item to 'Use default' and that item
+     * disappears from the table, it should no longer be the target */
+    function removeTargetIfNoLongerInTable() {
+      const isTargetIdInTable = cacheableItems.some(
+        item => item.id === targetId,
+      );
+      if (targetId !== null && !isTargetIdInTable) {
+        setTargetId(null);
+        setTargetModel(null);
+      }
+    },
+    [targetId, cacheableItems],
+  );
+
+  /** The config for the object currently being edited */
+  const targetConfig = targetModel
+    ? _.findWhere(configs, {
+        model_id: targetId ?? undefined,
+        model: targetModel,
+      })
+    : undefined;
+  const savedStrategy = targetConfig?.strategy;
+
+  const targetName = useMemo(() => {
+    if (targetId === null || targetModel === null) {
+      return;
+    }
+    const item = _.findWhere(cacheableItems, {
+      id: targetId,
+      model: targetModel,
+    });
+    return item?.name;
+  }, [targetId, targetModel, cacheableItems]);
+
+  if (savedStrategy?.type === "duration") {
+    savedStrategy.unit = CacheDurationUnit.Hours;
+  }
+
+  const {
+    askBeforeDiscardingChanges,
+    confirmationModal,
+    isStrategyFormDirty,
+    setIsStrategyFormDirty,
+  } = useConfirmIfFormIsDirty(router, route);
+
+  /** Change the target, but first confirm if the form is unsaved */
+  const updateTarget: UpdateTarget = useCallback(
+    ({ id: newTargetId, model: newTargetModel }, isFormDirty) => {
+      if (targetId !== newTargetId || targetModel !== newTargetModel) {
+        const update = () => {
+          setTargetId(newTargetId);
+          setTargetModel(newTargetModel);
+          setIsStrategyFormDirty(false);
+        };
+        isFormDirty ? askBeforeDiscardingChanges(update) : update();
+      }
+    },
+    [
+      targetId,
+      targetModel,
+      setTargetId,
+      setTargetModel,
+      setIsStrategyFormDirty,
+      askBeforeDiscardingChanges,
+    ],
+  );
+
+  const saveStrategy = useSaveStrategy(
+    targetId,
+    configs,
+    setConfigs,
+    targetModel,
+  );
+
+  const cacheableItemsAreLoading = configs.length > 0 && !cacheableItems.length;
+
+  const error = configsError || dashboardsResult.error || questionsResult.error;
+  const loading =
+    configsAreLoading ||
+    dashboardsResult.isLoading ||
+    questionsResult.isLoading ||
+    cacheableItemsAreLoading;
+
+  const rowRenderer = useCallback(
+    (item: CacheableItem) => (
+      <TableRowForCacheableItem
+        updateTarget={updateTarget}
+        currentTargetId={targetId}
+        currentTargetModel={targetModel}
+        forId={item.id}
+        item={item}
+        isFormDirty={isStrategyFormDirty}
+      />
+    ),
+    [updateTarget, targetId, targetModel, isStrategyFormDirty],
+  );
+
+  const explanatoryAsideId = "mb-explanatory-aside";
+
+  const closeForm = useCallback(() => {
+    updateTarget({ id: null, model: null }, isStrategyFormDirty);
+  }, [updateTarget, isStrategyFormDirty]);
+
+  const locale = useLocale();
+
+  return (
+    <Flex
+      role="region"
+      aria-label={t`Dashboard and question caching`}
+      w="100%"
+      direction="row"
+      justify="space-between"
+      onKeyDown={e => {
+        if (e.key === "Escape" && !e.ctrlKey && !e.metaKey) {
+          closeForm();
+        }
+      }}
+    >
+      <Stack
+        spacing="sm"
+        lh="1.5rem"
+        pt="md"
+        pb="md"
+        px="2.5rem"
+        style={{
+          flex: 1,
+          overflowY: "auto",
+        }}
+      >
+        <Box component="aside" maw="32rem" id={explanatoryAsideId}>
+          {t`Here are the dashboards and questions that have their own caching policies, which override any default or database policies you’ve set.`}
+        </Box>
+        {confirmationModal}
+        <Flex maw="60rem">
+          <DelayedLoadingAndErrorWrapper
+            error={error}
+            loading={loading}
+            loader={<TableSkeleton columns={tableColumns} />}
+          >
+            <Flex align="flex-start">
+              <ClientSortableTable<CacheableItem>
+                className={Styles.CacheableItemTable}
+                columns={tableColumns}
+                rows={cacheableItems}
+                rowRenderer={rowRenderer}
+                defaultSortColumn="name"
+                defaultSortDirection={SortDirection.Asc}
+                locale={locale}
+                formatValueForSorting={formatValueForSorting}
+                emptyBody={<NoResultsTableRow />}
+                aria-labelledby={explanatoryAsideId}
+                cols={
+                  <>
+                    <col />
+                    <col />
+                    <col />
+                  </>
+                }
+              />
+            </Flex>
+          </DelayedLoadingAndErrorWrapper>
+        </Flex>
+      </Stack>
+
+      {targetId !== null && targetModel !== null && (
+        <Panel className={Styles.StrategyFormPanel}>
+          <Button
+            variant="subtle"
+            pos="absolute"
+            p="1rem"
+            top="1rem"
+            style={{ insetInlineEnd: "1rem" }}
+            onClick={() => {
+              closeForm();
+            }}
+          >
+            <Text color="var(--mb-color-text-dark)">
+              <Icon name="close" />
+            </Text>
+          </Button>
+          <StrategyForm
+            targetId={targetId}
+            targetModel={targetModel}
+            targetName={targetName ?? `Untitled ${targetModel}`}
+            setIsDirty={setIsStrategyFormDirty}
+            saveStrategy={saveStrategy}
+            savedStrategy={savedStrategy}
+            shouldAllowInvalidation={true}
+            shouldShowName={targetId !== rootId}
+          />
+        </Panel>
+      )}
+    </Flex>
+  );
+};
+
+export const StrategyEditorForQuestionsAndDashboards = withRouter(
+  _StrategyEditorForQuestionsAndDashboards,
+);
+
+const TableSkeleton = ({ columns }: { columns: ColumnItem[] }) => (
+  <ClientSortableTable<{ id: number }>
+    columns={columns}
+    rows={[{ id: 0 }, { id: 1 }, { id: 2 }]}
+    rowRenderer={() => (
+      <tr className={Styles.SkeletonTableRow}>
+        <Repeat times={3}>
+          <td>
+            <Skeleton h="1rem" natural />
+          </td>
+        </Repeat>
+      </tr>
+    )}
+    className={Styles.CacheableItemTable}
+    locale="en-US"
+  />
+);
+
+const NoResultsTableRow = () => (
+  <tr className={Styles.NoResultsTableRow}>
+    <td colSpan={3}>
+      <Center fw="bold" c="text-light">
+        {t`No dashboards or questions have their own caching policies yet.`}
+      </Center>
+    </td>
+  </tr>
+);
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/TableRowForCacheableItem.styled.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/TableRowForCacheableItem.styled.tsx
new file mode 100644
index 00000000000..a1b56da776a
--- /dev/null
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/TableRowForCacheableItem.styled.tsx
@@ -0,0 +1,7 @@
+import styled from "@emotion/styled";
+
+import { Ellipsified } from "metabase/core/components/Ellipsified";
+
+export const CacheableItemName = styled(Ellipsified)`
+  font-weight: bold;
+`;
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/TableRowForCacheableItem.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/TableRowForCacheableItem.tsx
new file mode 100644
index 00000000000..dc9d0f3808a
--- /dev/null
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/TableRowForCacheableItem.tsx
@@ -0,0 +1,101 @@
+import { useMemo } from "react";
+
+import { getShortStrategyLabel } from "metabase/admin/performance/utils";
+import { EllipsifiedCollectionPath } from "metabase/common/components/EllipsifiedPath/EllipsifiedCollectionPath";
+import { MaybeLink } from "metabase/components/Badge/Badge.styled";
+import { Ellipsified } from "metabase/core/components/Ellipsified";
+import Link from "metabase/core/components/Link";
+import { getIcon } from "metabase/lib/icon";
+import * as Urls from "metabase/lib/urls";
+import { Box, Button, FixedSizeIcon, Flex } from "metabase/ui";
+import type { CacheableModel } from "metabase-types/api";
+
+import type { CacheableItem, UpdateTarget } from "../types";
+import { getItemUrl } from "../utils";
+
+import StrategyEditorForQuestionsAndDashboardsS from "./StrategyEditorForQuestionsAndDashboards.module.css";
+
+export const TableRowForCacheableItem = ({
+  item,
+  forId,
+  currentTargetId,
+  currentTargetModel,
+  updateTarget,
+  isFormDirty,
+}: {
+  item: CacheableItem;
+  forId: number;
+  currentTargetId: number | null;
+  currentTargetModel: CacheableModel | null;
+  updateTarget: UpdateTarget;
+  isFormDirty: boolean;
+}) => {
+  const { name, id, collection, model, strategy, iconModel } = item;
+
+  const iconName = iconModel
+    ? getIcon({ model: iconModel || "card" }).name
+    : null;
+
+  const url = useMemo(
+    () => getItemUrl(model, item as { id: number; name: string }) || undefined,
+    [model, item],
+  );
+
+  const launchForm = () => {
+    if (currentTargetId !== item.id || currentTargetModel !== item.model) {
+      updateTarget({ id, model }, isFormDirty);
+    }
+  };
+
+  return (
+    <tr
+      className={
+        currentTargetId !== null && currentTargetId === forId
+          ? StrategyEditorForQuestionsAndDashboardsS.currentTarget
+          : undefined
+      }
+    >
+      <td>
+        <MaybeLink
+          className={StrategyEditorForQuestionsAndDashboardsS.ItemLink}
+          to={url}
+        >
+          <Flex
+            align="center"
+            wrap="nowrap"
+            gap="sm"
+            style={{ overflow: "hidden" }}
+          >
+            {iconName ? (
+              <FixedSizeIcon name={iconName} />
+            ) : (
+              <Box h="sm" w="md" />
+            )}
+            <Ellipsified style={{ fontWeight: "bold" }}>{name}</Ellipsified>
+          </Flex>
+        </MaybeLink>
+      </td>
+      <td>
+        {collection && (
+          <Link
+            className={StrategyEditorForQuestionsAndDashboardsS.CollectionLink}
+            to={Urls.collection(collection)}
+          >
+            <EllipsifiedCollectionPath collection={collection} />
+          </Link>
+        )}
+      </td>
+      <td>
+        <Button
+          variant="subtle"
+          onClick={() => launchForm()}
+          p={0}
+          fw="bold"
+          c="var(--mb-color-brand)"
+        >
+          {getShortStrategyLabel(strategy)}
+        </Button>
+      </td>
+    </tr>
+  );
+};
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/constants.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/constants.tsx
new file mode 100644
index 00000000000..9a0d3be9144
--- /dev/null
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/constants.tsx
@@ -0,0 +1,15 @@
+import { t } from "ttag";
+
+import type { ColumnItem } from "metabase/common/components/Table/types";
+
+/** Retrieve constants for the dashboard and question caching table
+ *
+ * Some constants need to be defined within the component's scope so that ttag.t knows the current locale */
+export const getConstants = () => {
+  const tableColumns: ColumnItem[] = [
+    { key: "name", name: t`Name`, sortable: true },
+    { key: "collection", name: t`Collection`, sortable: true },
+    { key: "policy", name: t`Policy`, sortable: true },
+  ];
+  return { tableColumns };
+};
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/utils.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/utils.tsx
new file mode 100644
index 00000000000..8e0cc213cf9
--- /dev/null
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/utils.tsx
@@ -0,0 +1,30 @@
+import { t } from "ttag";
+import _ from "underscore";
+
+import { getShortStrategyLabel } from "metabase/admin/performance/utils";
+import { getCollectionPathAsString } from "metabase/collections/utils";
+
+import type { CacheableItem } from "../types";
+
+export const formatValueForSorting = (
+  row: CacheableItem,
+  columnName: string,
+) => {
+  if (columnName === "policy") {
+    const label = getShortStrategyLabel(row.strategy, row.model);
+    if (row.strategy.type === "duration") {
+      // Sort durations in ascending order of length
+      return label?.replace(/(\d+)h/, (_, num) => {
+        const paddedNumber = num.padStart(5, "0");
+        return `${t`Duration`} ${paddedNumber}`;
+      });
+    } else {
+      return label;
+    }
+  }
+  if (columnName === "collection") {
+    return row.collection ? getCollectionPathAsString(row.collection) : "";
+  } else {
+    return _.get(row, columnName);
+  }
+};
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/utils.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/utils.unit.spec.tsx
new file mode 100644
index 00000000000..c2504c7e65a
--- /dev/null
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyEditorForQuestionsAndDashboards/utils.unit.spec.tsx
@@ -0,0 +1,174 @@
+import { strategies } from "metabase/admin/performance/constants/complex";
+import { getShortStrategyLabel } from "metabase/admin/performance/utils";
+import { getCollectionPathAsString } from "metabase/collections/utils";
+import { PLUGIN_CACHING } from "metabase/plugins";
+import { enterpriseOnlyCachingStrategies } from "metabase-enterprise/caching/constants";
+import {
+  type AdaptiveStrategy,
+  CacheDurationUnit,
+  type ScheduleStrategy,
+} from "metabase-types/api";
+import { createMockCollection } from "metabase-types/api/mocks";
+
+import type { CacheableItem } from "../types";
+
+import { formatValueForSorting } from "./utils";
+
+const hourlyScheduleStrategy: ScheduleStrategy = {
+  type: "schedule",
+  schedule: "0 0 * * * ?",
+};
+const dailyScheduleStrategy: ScheduleStrategy = {
+  type: "schedule",
+  schedule: "0 0 8 * * ?",
+};
+const weeklyScheduleStrategy: ScheduleStrategy = {
+  type: "schedule",
+  schedule: "0 0 8 ? * 2",
+};
+const monthlyScheduleStrategy: ScheduleStrategy = {
+  type: "schedule",
+  schedule: "0 0 8 ? * 2#1",
+};
+const adaptiveStrategy: AdaptiveStrategy = {
+  type: "ttl",
+  multiplier: 10,
+  min_duration_ms: 1000,
+};
+
+const a = createMockCollection({ id: 2, name: "A" });
+const b = createMockCollection({ id: 3, name: "B" });
+const c = createMockCollection({ id: 4, name: "C" });
+const d = createMockCollection({ id: 5, name: "D" });
+const e = createMockCollection({ id: 6, name: "E" });
+const f = createMockCollection({ id: 7, name: "F" });
+const g = createMockCollection({ id: 8, name: "G" });
+const h = createMockCollection({ id: 9, name: "H" });
+
+const unsortedRows: CacheableItem[] = [
+  {
+    model: "question",
+    id: 0,
+    strategy: monthlyScheduleStrategy,
+    collection: createMockCollection({
+      ...c,
+      effective_ancestors: [a, b],
+    }),
+  },
+  {
+    model: "question",
+    id: 1,
+    strategy: {
+      type: "duration",
+      duration: 100,
+      unit: CacheDurationUnit.Hours,
+    },
+    collection: createMockCollection({
+      ...b,
+      effective_ancestors: [h, a],
+    }),
+  },
+  {
+    model: "question",
+    id: 2,
+    strategy: hourlyScheduleStrategy,
+    collection: createMockCollection({
+      ...a,
+      effective_ancestors: [g, h],
+    }),
+  },
+  {
+    model: "question",
+    id: 3,
+    strategy: { type: "duration", duration: 10, unit: CacheDurationUnit.Hours },
+    collection: createMockCollection({
+      ...d,
+      effective_ancestors: [b, c],
+    }),
+  },
+  {
+    model: "question",
+    id: 4,
+    strategy: weeklyScheduleStrategy,
+    collection: createMockCollection({
+      ...h,
+      effective_ancestors: [f, g],
+    }),
+  },
+  {
+    model: "question",
+    id: 5,
+    strategy: { type: "duration", duration: 1, unit: CacheDurationUnit.Hours },
+    collection: createMockCollection({
+      ...f,
+      effective_ancestors: [d, e],
+    }),
+  },
+  {
+    model: "question",
+    id: 6,
+    strategy: dailyScheduleStrategy,
+    collection: createMockCollection({
+      ...e,
+      effective_ancestors: [c, d],
+    }),
+  },
+  {
+    model: "question",
+    id: 7,
+    strategy: adaptiveStrategy,
+    collection: createMockCollection({
+      ...g,
+      effective_ancestors: [e, f],
+    }),
+  },
+];
+
+describe("StrategyEditorForQuestionsAndDashboards utilities", () => {
+  describe("formatValueForSorting", () => {
+    beforeAll(() => {
+      PLUGIN_CACHING.strategies = {
+        ...strategies,
+        ...enterpriseOnlyCachingStrategies,
+      };
+    });
+    it("sorts by policy correctly", () => {
+      const sorted = unsortedRows.sort((rowA, rowB) => {
+        const a = formatValueForSorting(rowA, "policy") as string;
+        const b = formatValueForSorting(rowB, "policy") as string;
+        return a.localeCompare(b);
+      });
+      const strategies = sorted.map(row => getShortStrategyLabel(row.strategy));
+      expect(strategies).toEqual([
+        "Adaptive",
+        "Duration: 1h",
+        "Duration: 10h",
+        "Duration: 100h",
+        "Scheduled: daily",
+        "Scheduled: hourly",
+        "Scheduled: monthly",
+        "Scheduled: weekly",
+      ]);
+    });
+    it("sorts by collection correctly", () => {
+      const sorted = unsortedRows.sort((rowA, rowB) => {
+        const a = formatValueForSorting(rowA, "collection") as string;
+        const b = formatValueForSorting(rowB, "collection") as string;
+        return a.localeCompare(b);
+      });
+      const collections = sorted.map(row =>
+        row.collection ? getCollectionPathAsString(row.collection) : null,
+      );
+      expect(collections).toEqual([
+        "A / B / C",
+        "B / C / D",
+        "C / D / E",
+        "D / E / F",
+        "E / F / G",
+        "F / G / H",
+        "G / H / A",
+        "H / A / B",
+      ]);
+    });
+  });
+});
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/types.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/types.tsx
new file mode 100644
index 00000000000..6778c5c3ccb
--- /dev/null
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/types.tsx
@@ -0,0 +1,26 @@
+import type {
+  CacheConfig,
+  CacheStrategy,
+  CacheableModel,
+  CollectionEssentials,
+  SearchModel,
+  SearchResult,
+} from "metabase-types/api";
+
+/** Something with a configurable cache strategy */
+export type CacheableItem = Omit<CacheConfig, "model_id"> & {
+  id: number;
+  name?: string;
+  collection?: CollectionEssentials;
+  strategy?: CacheStrategy;
+  /** In the sense of 'type of object' */
+  iconModel?: SearchModel;
+};
+
+export type DashboardResult = SearchResult<number, "dashboard">;
+export type QuestionResult = SearchResult<number, "card">;
+
+export type UpdateTarget = (
+  newValues: { id: number | null; model: CacheableModel | null },
+  isFormDirty: boolean,
+) => void;
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/utils.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/utils.tsx
index 35bdb3b8103..743d6d4c3a2 100644
--- a/enterprise/frontend/src/metabase-enterprise/caching/components/utils.tsx
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/utils.tsx
@@ -1,5 +1,7 @@
+import { match } from "ts-pattern";
 import { t } from "ttag";
 
+import * as Urls from "metabase/lib/urls";
 import type Question from "metabase-lib/v1/Question";
 import type { CacheableDashboard, CacheableModel } from "metabase-types/api";
 
@@ -18,3 +20,12 @@ export const getItemName = (
   model === "dashboard"
     ? (item as CacheableDashboard).name
     : (item as Question).displayName() ?? t`Untitled question`;
+
+export const getItemUrl = (
+  model: CacheableModel,
+  item: { id: number; name: string },
+) =>
+  match(model)
+    .with("dashboard", () => Urls.dashboard(item))
+    .with("question", () => Urls.question(item))
+    .otherwise(() => null);
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/constants.ts b/enterprise/frontend/src/metabase-enterprise/caching/constants.ts
index 53878dbcb63..ada84ede168 100644
--- a/enterprise/frontend/src/metabase-enterprise/caching/constants.ts
+++ b/enterprise/frontend/src/metabase-enterprise/caching/constants.ts
@@ -1,10 +1,17 @@
 import { t } from "ttag";
 import * as Yup from "yup";
 
-import { getPositiveIntegerSchema } from "metabase/admin/performance/constants/complex";
-import type { StrategyData } from "metabase/admin/performance/types";
+import {
+  getPerformanceTabMetadata,
+  getPositiveIntegerSchema,
+} from "metabase/admin/performance/constants/complex";
+import {
+  PerformanceTabId,
+  type StrategyData,
+} from "metabase/admin/performance/types";
 import { defaultCron } from "metabase/admin/performance/utils";
 import { CacheDurationUnit } from "metabase-types/api";
+import type { AdminPath } from "metabase-types/store";
 
 export const durationUnits = new Set(
   Object.values(CacheDurationUnit).map(String),
@@ -40,3 +47,19 @@ export const enterpriseOnlyCachingStrategies: Record<string, StrategyData> = {
     shortLabel: t`Duration`,
   },
 };
+
+export const getEnterprisePerformanceTabMetadata = () => {
+  const metadata = getPerformanceTabMetadata();
+  // On EE there is an additional tab in between the "Database caching" and
+  // "Model persistence" tabs
+  return [
+    metadata.find(({ key }) => key === "performance-databases"),
+    {
+      name: t`Dashboard and question caching`,
+      path: "/admin/performance/dashboards-and-questions",
+      key: "performance-dashboards-and-questions",
+      tabId: PerformanceTabId.DashboardsAndQuestions,
+    },
+    metadata.find(({ key }) => key === "performance-models"),
+  ] as (AdminPath & { tabId: string })[];
+};
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/index.tsx b/enterprise/frontend/src/metabase-enterprise/caching/index.tsx
index f1b8fc016a8..30de2d38fd1 100644
--- a/enterprise/frontend/src/metabase-enterprise/caching/index.tsx
+++ b/enterprise/frontend/src/metabase-enterprise/caching/index.tsx
@@ -1,13 +1,18 @@
 import { PLUGIN_CACHING } from "metabase/plugins";
 import { hasPremiumFeature } from "metabase-enterprise/settings";
 
+import { DashboardAndQuestionCachingTab } from "./components/DashboardAndQuestionCachingTab";
 import { DashboardStrategySidebar } from "./components/DashboardStrategySidebar";
 import { GranularControlsExplanation } from "./components/GranularControlsExplanation";
 import { InvalidateNowButton } from "./components/InvalidateNowButton";
 import { SidebarCacheForm } from "./components/SidebarCacheForm";
 import { SidebarCacheSection } from "./components/SidebarCacheSection";
+import { StrategyEditorForQuestionsAndDashboards } from "./components/StrategyEditorForQuestionsAndDashboards/StrategyEditorForQuestionsAndDashboards";
 import { StrategyFormLauncherPanel } from "./components/StrategyFormLauncherPanel";
-import { enterpriseOnlyCachingStrategies } from "./constants";
+import {
+  enterpriseOnlyCachingStrategies,
+  getEnterprisePerformanceTabMetadata,
+} from "./constants";
 import { hasQuestionCacheSection } from "./utils";
 
 if (hasPremiumFeature("cache_granular_controls")) {
@@ -27,4 +32,9 @@ if (hasPremiumFeature("cache_granular_controls")) {
     ttl: PLUGIN_CACHING.strategies.ttl,
     nocache: PLUGIN_CACHING.strategies.nocache,
   };
+  PLUGIN_CACHING.DashboardAndQuestionCachingTab =
+    DashboardAndQuestionCachingTab;
+  PLUGIN_CACHING.StrategyEditorForQuestionsAndDashboards =
+    StrategyEditorForQuestionsAndDashboards;
+  PLUGIN_CACHING.getTabMetadata = getEnterprisePerformanceTabMetadata;
 }
diff --git a/enterprise/frontend/src/metabase-enterprise/upload_management/UploadManagementTable.tsx b/enterprise/frontend/src/metabase-enterprise/upload_management/UploadManagementTable.tsx
index eb8057636a7..8f4feea32ea 100644
--- a/enterprise/frontend/src/metabase-enterprise/upload_management/UploadManagementTable.tsx
+++ b/enterprise/frontend/src/metabase-enterprise/upload_management/UploadManagementTable.tsx
@@ -3,6 +3,7 @@ import { msgid, ngettext, t } from "ttag";
 
 import SettingHeader from "metabase/admin/settings/components/SettingHeader";
 import { ClientSortableTable } from "metabase/common/components/Table";
+import { useLocale } from "metabase/common/hooks";
 import {
   BulkActionBar,
   BulkActionButton,
@@ -35,6 +36,7 @@ export function UploadManagementTable() {
   const [deleteTableRequest] = useDeleteUploadTableMutation();
   const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
   const dispatch = useDispatch();
+  const locale = useLocale();
 
   // TODO: once we have uploads running through RTK Query, we can remove the force update
   // because we can properly invalidate the tables tag
@@ -154,6 +156,7 @@ export function UploadManagementTable() {
         columns={columns}
         rows={uploadTables}
         rowRenderer={row => renderRow(row)}
+        locale={locale}
       />
     </Box>
   );
diff --git a/frontend/src/metabase-types/api/search.ts b/frontend/src/metabase-types/api/search.ts
index f787f07fc94..c7ed408f7cd 100644
--- a/frontend/src/metabase-types/api/search.ts
+++ b/frontend/src/metabase-types/api/search.ts
@@ -128,6 +128,7 @@ export type SearchRequest = {
   archived?: boolean;
   table_db_id?: DatabaseId;
   models?: SearchModel[];
+  ids?: SearchResultId[];
   filter_items_in_personal_collection?: "only" | "exclude";
   context?: SearchContext;
   created_at?: string | null;
diff --git a/frontend/src/metabase-types/store/admin.ts b/frontend/src/metabase-types/store/admin.ts
index f18f5ddc6e0..9cd856c8e46 100644
--- a/frontend/src/metabase-types/store/admin.ts
+++ b/frontend/src/metabase-types/store/admin.ts
@@ -14,7 +14,10 @@ export type AdminPathKey =
   | "troubleshooting"
   | "audit"
   | "tools"
-  | "performance";
+  | "performance"
+  | "performance-models"
+  | "performance-dashboards-and-questions"
+  | "performance-databases";
 
 export type AdminPath = {
   key: AdminPathKey;
diff --git a/frontend/src/metabase/admin/performance/components/ModelPersistenceConfiguration.module.css b/frontend/src/metabase/admin/performance/components/ModelPersistenceConfiguration.module.css
new file mode 100644
index 00000000000..a0bc5e7e618
--- /dev/null
+++ b/frontend/src/metabase/admin/performance/components/ModelPersistenceConfiguration.module.css
@@ -0,0 +1,4 @@
+.Explanation p {
+  margin-top: 0;
+  margin-bottom: 1rem;
+}
diff --git a/frontend/src/metabase/admin/performance/components/ModelPersistenceConfiguration.tsx b/frontend/src/metabase/admin/performance/components/ModelPersistenceConfiguration.tsx
index 5d1176278be..be6a71857f3 100644
--- a/frontend/src/metabase/admin/performance/components/ModelPersistenceConfiguration.tsx
+++ b/frontend/src/metabase/admin/performance/components/ModelPersistenceConfiguration.tsx
@@ -15,7 +15,9 @@ import {
   getShowMetabaseLinks,
 } from "metabase/selectors/whitelabel";
 import { PersistedModelsApi } from "metabase/services";
-import { Stack, Switch, Text } from "metabase/ui";
+import { Box, Stack, Switch, Text } from "metabase/ui";
+
+import ModelPersistenceConfigurationS from "./ModelPersistenceConfiguration.module.css";
 
 const modelCachingOptions = [
   {
@@ -134,7 +136,11 @@ export const ModelPersistenceConfiguration = () => {
 
   return (
     <Stack spacing="xl" maw="40rem">
-      <div>
+      <Box
+        mb="sm"
+        lh="1.5rem"
+        className={ModelPersistenceConfigurationS.Explanation}
+      >
         <p>
           {t`Enable model persistence to make your models (and the queries that use them) load faster.`}
         </p>
@@ -170,7 +176,7 @@ export const ModelPersistenceConfiguration = () => {
             checked={modelPersistenceEnabled}
           />
         </DelayedLoadingAndErrorWrapper>
-      </div>
+      </Box>
       {modelPersistenceEnabled && (
         <div>
           <ModelCachingScheduleWidget
diff --git a/frontend/src/metabase/admin/performance/components/PerformanceApp.styled.tsx b/frontend/src/metabase/admin/performance/components/PerformanceApp.module.css
similarity index 57%
rename from frontend/src/metabase/admin/performance/components/PerformanceApp.styled.tsx
rename to frontend/src/metabase/admin/performance/components/PerformanceApp.module.css
index 129927ab1fc..762ce7c8253 100644
--- a/frontend/src/metabase/admin/performance/components/PerformanceApp.styled.tsx
+++ b/frontend/src/metabase/admin/performance/components/PerformanceApp.module.css
@@ -1,17 +1,12 @@
-import styled from "@emotion/styled";
-
-import { Tabs } from "metabase/ui";
-
-import { PerformanceTabId } from "../types";
-
-export const TabsList = styled(Tabs.List)`
+.TabsList {
   padding: 0 2.5rem;
   background-color: var(--mb-color-bg-light);
   border-bottom-width: 1px;
   margin-top: 1.5rem;
-`;
+  border-bottom-color: var(--mb-color-border);
+}
 
-export const Tab = styled(Tabs.Tab)`
+.Tab {
   top: 1px;
   margin-bottom: 1px;
   border-bottom-width: 3px !important;
@@ -23,13 +18,30 @@ export const Tab = styled(Tabs.Tab)`
     background-color: inherit;
     border-color: transparent;
   }
-`;
+}
 
-export const TabsPanel = styled(Tabs.Panel)<{ value: string }>`
+.TabsPanel {
   display: flex;
   flex-flow: column nowrap;
   flex: 1;
   justify-content: stretch;
+}
+
+.hideOverflow {
+  overflow: hidden;
+}
+
+.TabBody {
+  flex: 1;
+  background-color: var(--mb-color-bg-light);
+  height: 100%;
+}
+
+.DatabasesTabBody {
+  padding: 1rem 2.5rem;
+  overflow: hidden;
+}
+
+.ModelPersistenceConfigurationTabBody {
   padding: 1rem 2.5rem;
-  ${props => props.value === PerformanceTabId.Databases && `overflow: hidden;`}
-`;
+}
diff --git a/frontend/src/metabase/admin/performance/components/PerformanceApp.tsx b/frontend/src/metabase/admin/performance/components/PerformanceApp.tsx
index ccaab07f227..e6e98335895 100644
--- a/frontend/src/metabase/admin/performance/components/PerformanceApp.tsx
+++ b/frontend/src/metabase/admin/performance/components/PerformanceApp.tsx
@@ -1,16 +1,18 @@
+import classNames from "classnames";
 import { useLayoutEffect, useRef, useState } from "react";
 import type { Route } from "react-router";
 import { push } from "react-router-redux";
-import { t } from "ttag";
 
 import { useDispatch } from "metabase/lib/redux";
+import { PLUGIN_CACHING } from "metabase/plugins";
 import type { TabsValue } from "metabase/ui";
 import { Flex, Tabs } from "metabase/ui";
 
 import { PerformanceTabId } from "../types";
+import { getPerformanceTabName } from "../utils";
 
 import { ModelPersistenceConfiguration } from "./ModelPersistenceConfiguration";
-import { Tab, TabsList, TabsPanel } from "./PerformanceApp.styled";
+import P from "./PerformanceApp.module.css";
 import { StrategyEditorForDatabases } from "./StrategyEditorForDatabases";
 
 const validTabIds = new Set(Object.values(PerformanceTabId).map(String));
@@ -62,34 +64,60 @@ export const PerformanceApp = ({
           console.error("Invalid tab value", value);
         }
       }}
-      style={{ display: "flex", flexDirection: "column" }}
+      style={{ height: tabsHeight, display: "flex", flexDirection: "column" }}
       ref={tabsRef}
-      bg="bg-light"
-      h={tabsHeight}
+      bg="var(--mb-color-bg-light)"
     >
-      <TabsList>
-        <Tab
+      <Tabs.List className={P.TabsList}>
+        <Tabs.Tab
+          className={P.Tab}
           key={PerformanceTabId.Databases}
           value={PerformanceTabId.Databases}
         >
-          {t`Database caching settings`}
-        </Tab>
-        <Tab key={PerformanceTabId.Models} value={PerformanceTabId.Models}>
-          {t`Model persistence`}
-        </Tab>
-      </TabsList>
-      <TabsPanel key={tabId} value={tabId}>
+          {getPerformanceTabName(PerformanceTabId.Databases)}
+        </Tabs.Tab>
+        <PLUGIN_CACHING.DashboardAndQuestionCachingTab />
+        <Tabs.Tab
+          className={P.Tab}
+          key={PerformanceTabId.Models}
+          value={PerformanceTabId.Models}
+        >
+          {getPerformanceTabName(PerformanceTabId.Models)}
+        </Tabs.Tab>
+      </Tabs.List>
+      <Tabs.Panel
+        key={tabId}
+        value={tabId}
+        className={classNames(P.TabsPanel, {
+          [P.hideOverflow]: [
+            PerformanceTabId.Databases,
+            PerformanceTabId.DashboardsAndQuestions,
+          ].includes(tabId),
+        })}
+      >
         {tabId === PerformanceTabId.Databases && (
-          <Flex style={{ flex: 1, overflow: "hidden" }} bg="bg-light" h="100%">
+          <Flex className={classNames(P.TabBody, P.DatabasesTabBody)}>
             <StrategyEditorForDatabases route={route} />
           </Flex>
         )}
+        {tabId === PerformanceTabId.DashboardsAndQuestions && (
+          <Flex className={classNames(P.TabBody, P.hideOverflow)}>
+            <PLUGIN_CACHING.StrategyEditorForQuestionsAndDashboards
+              route={route}
+            />
+          </Flex>
+        )}
         {tabId === PerformanceTabId.Models && (
-          <Flex style={{ flex: 1 }} bg="bg-light" h="100%">
+          <Flex
+            className={classNames(
+              P.TabBody,
+              P.ModelPersistenceConfigurationTabBody,
+            )}
+          >
             <ModelPersistenceConfiguration />
           </Flex>
         )}
-      </TabsPanel>
+      </Tabs.Panel>
     </Tabs>
   );
 };
diff --git a/frontend/src/metabase/admin/performance/components/StrategyEditorForDatabases.tsx b/frontend/src/metabase/admin/performance/components/StrategyEditorForDatabases.tsx
index 4d27b40517d..46f28d1ba72 100644
--- a/frontend/src/metabase/admin/performance/components/StrategyEditorForDatabases.tsx
+++ b/frontend/src/metabase/admin/performance/components/StrategyEditorForDatabases.tsx
@@ -127,7 +127,7 @@ const StrategyEditorForDatabases_Base = ({
   }
 
   return (
-    <TabWrapper aria-label={t`Database caching settings`}>
+    <TabWrapper role="region" aria-label={t`Data caching settings`}>
       <Stack spacing="xl" lh="1.5rem" maw="32rem" mb="1.5rem">
         <aside>
           {t`Speed up queries by caching their results.`}
diff --git a/frontend/src/metabase/admin/performance/constants/complex.ts b/frontend/src/metabase/admin/performance/constants/complex.ts
index 8ccfacae27a..e1434f21faa 100644
--- a/frontend/src/metabase/admin/performance/constants/complex.ts
+++ b/frontend/src/metabase/admin/performance/constants/complex.ts
@@ -2,8 +2,9 @@ import { t } from "ttag";
 import * as Yup from "yup";
 
 import type { CacheableModel } from "metabase-types/api";
+import type { AdminPath } from "metabase-types/store";
 
-import type { StrategyData } from "../types";
+import { PerformanceTabId, type StrategyData } from "../types";
 import { getStrategyValidationSchema, isValidStrategyName } from "../utils";
 
 import { defaultMinDurationMs } from "./simple";
@@ -98,3 +99,22 @@ export const strategies = {
     validationSchema: doNotCacheStrategyValidationSchema,
   },
 } as Record<string, StrategyData>;
+
+export const getPerformanceTabMetadata = () =>
+  [
+    {
+      name: t`Database caching`,
+      path: "/admin/performance/databases",
+      key: "performance-databases",
+      tabId: PerformanceTabId.Databases,
+    },
+    {
+      name: t`Model persistence`,
+      path: "/admin/performance/models",
+      key: "performance-models",
+      tabId: PerformanceTabId.Models,
+    },
+  ] as (AdminPath & { tabId: string })[];
+
+export const getPerformanceAdminPaths = (metadata: AdminPath[]) =>
+  metadata.map(tab => ({ ...tab, name: `${t`Performance`} - ${tab.name}` }));
diff --git a/frontend/src/metabase/admin/performance/hooks/useSaveStrategy.tsx b/frontend/src/metabase/admin/performance/hooks/useSaveStrategy.tsx
index 6feab91b94f..16b7e1629de 100644
--- a/frontend/src/metabase/admin/performance/hooks/useSaveStrategy.tsx
+++ b/frontend/src/metabase/admin/performance/hooks/useSaveStrategy.tsx
@@ -22,11 +22,11 @@ export const useSaveStrategy = (
   targetId: number | null,
   configs: CacheConfig[],
   setConfigs: Dispatch<SetStateAction<CacheConfig[]>>,
-  model: CacheableModel,
+  model: CacheableModel | null,
 ) => {
   const saveStrategy = useCallback(
     async (values: CacheStrategy) => {
-      if (targetId === null) {
+      if (targetId === null || model === null) {
         return;
       }
       const { strategies } = PLUGIN_CACHING;
diff --git a/frontend/src/metabase/admin/performance/types.ts b/frontend/src/metabase/admin/performance/types.ts
index 310c4b3bb18..d8209db03d4 100644
--- a/frontend/src/metabase/admin/performance/types.ts
+++ b/frontend/src/metabase/admin/performance/types.ts
@@ -21,4 +21,5 @@ export type StrategyData = {
 export enum PerformanceTabId {
   Databases = "databases",
   Models = "models",
+  DashboardsAndQuestions = "dashboards-and-questions",
 }
diff --git a/frontend/src/metabase/admin/performance/utils.tsx b/frontend/src/metabase/admin/performance/utils.tsx
index 3e087472c7e..5936ebb597e 100644
--- a/frontend/src/metabase/admin/performance/utils.tsx
+++ b/frontend/src/metabase/admin/performance/utils.tsx
@@ -22,7 +22,7 @@ import type {
 } from "metabase-types/api";
 
 import { defaultMinDurationMs, rootId } from "./constants/simple";
-import type { StrategyData, StrategyLabel } from "./types";
+import type { PerformanceTabId, StrategyData, StrategyLabel } from "./types";
 
 const AM = 0;
 const PM = 1;
@@ -315,3 +315,8 @@ export const translateConfigFromAPI = (config: CacheConfig): CacheConfig =>
 /** Translate a config from the frontend's format into the API's preferred format */
 export const translateConfigToAPI = (config: CacheConfig): CacheConfig =>
   translateConfig(config, "toAPI");
+
+export const getPerformanceTabName = (tabId: PerformanceTabId) =>
+  PLUGIN_CACHING.getTabMetadata().find(
+    ({ key }) => key === `performance-${tabId}`,
+  )?.name;
diff --git a/frontend/src/metabase/admin/routes.jsx b/frontend/src/metabase/admin/routes.jsx
index 5810fbd910c..73c08649326 100644
--- a/frontend/src/metabase/admin/routes.jsx
+++ b/frontend/src/metabase/admin/routes.jsx
@@ -48,6 +48,7 @@ import {
   PLUGIN_ADMIN_ROUTES,
   PLUGIN_ADMIN_TOOLS,
   PLUGIN_ADMIN_USER_MENU_ROUTES,
+  PLUGIN_CACHING,
 } from "metabase/plugins";
 import { getSetting } from "metabase/selectors/settings";
 
@@ -164,18 +165,16 @@ const getRoutes = (store, CanAccessSettings, IsAdmin) => (
       >
         <Route title={t`Performance`}>
           <IndexRedirect to={PerformanceTabId.Databases} />
-          <Route
-            title={t`Database caching`}
-            path={PerformanceTabId.Databases}
-            component={() => (
-              <PerformanceApp tabId={PerformanceTabId.Databases} />
-            )}
-          />
-          <Route
-            title={t`Model persistence`}
-            path={PerformanceTabId.Models}
-            component={() => <PerformanceApp tabId={PerformanceTabId.Models} />}
-          />
+          {PLUGIN_CACHING.getTabMetadata().map(({ name, key, tabId }) => (
+            <Route
+              component={routeProps => (
+                <PerformanceApp {...routeProps} tabId={tabId} />
+              )}
+              title={name}
+              path={tabId}
+              key={key}
+            />
+          ))}
         </Route>
       </Route>
       <Route
diff --git a/frontend/src/metabase/admin/settings/components/ApiKeys/ManageApiKeys.tsx b/frontend/src/metabase/admin/settings/components/ApiKeys/ManageApiKeys.tsx
index eabc596d18d..debae137f30 100644
--- a/frontend/src/metabase/admin/settings/components/ApiKeys/ManageApiKeys.tsx
+++ b/frontend/src/metabase/admin/settings/components/ApiKeys/ManageApiKeys.tsx
@@ -3,6 +3,7 @@ import { t } from "ttag";
 
 import { useListApiKeysQuery } from "metabase/api";
 import { ClientSortableTable } from "metabase/common/components/Table";
+import { useLocale } from "metabase/common/hooks/use-locale/use-locale";
 import { DelayedLoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper/DelayedLoadingAndErrorWrapper";
 import { Ellipsified } from "metabase/core/components/Ellipsified";
 import CS from "metabase/css/core/index.css";
@@ -66,6 +67,7 @@ function ApiKeysTable({
   error?: unknown;
 }) {
   const flatApiKeys = useMemo(() => apiKeys?.map(flattenApiKey), [apiKeys]);
+  const locale = useLocale();
 
   if (loading || error) {
     return <DelayedLoadingAndErrorWrapper loading={loading} error={error} />;
@@ -80,6 +82,7 @@ function ApiKeysTable({
       data-testid="api-keys-table"
       columns={columns}
       rows={flatApiKeys}
+      locale={locale}
       rowRenderer={row => (
         <ApiKeyRow
           apiKey={row}
diff --git a/frontend/src/metabase/browse/components/ModelsTable.tsx b/frontend/src/metabase/browse/components/ModelsTable.tsx
index ad6054a95de..5ce8f905ca1 100644
--- a/frontend/src/metabase/browse/components/ModelsTable.tsx
+++ b/frontend/src/metabase/browse/components/ModelsTable.tsx
@@ -3,7 +3,7 @@ import { push } from "react-router-redux";
 import { t } from "ttag";
 
 import { getCollectionName } from "metabase/collections/utils";
-import { EllipsifiedPath } from "metabase/common/components/EllipsifiedPath";
+import { EllipsifiedCollectionPath } from "metabase/common/components/EllipsifiedPath/EllipsifiedCollectionPath";
 import { useLocale } from "metabase/common/hooks/use-locale/use-locale";
 import EntityItem from "metabase/components/EntityItem";
 import { SortableColumnHeader } from "metabase/components/ItemsTable/BaseItemsTable";
@@ -43,11 +43,7 @@ import {
   ModelNameColumn,
   ModelTableRow,
 } from "./ModelsTable.styled";
-import {
-  getCollectionPathString,
-  getModelDescription,
-  sortModels,
-} from "./utils";
+import { getModelDescription, sortModels } from "./utils";
 
 export interface ModelsTableProps {
   models?: ModelResult[];
@@ -220,13 +216,7 @@ const TBodyRow = ({
           <Flex gap="sm">
             <FixedSizeIcon name="folder" />
             <Box w="calc(100% - 1.5rem)">
-              <EllipsifiedPath
-                tooltip={getCollectionPathString(model.collection)}
-                items={[
-                  ...(model.collection?.effective_ancestors || []),
-                  model.collection,
-                ].map(c => getCollectionName(c))}
-              />
+              <EllipsifiedCollectionPath collection={model.collection} />
             </Box>
           </Flex>
         </Link>
diff --git a/frontend/src/metabase/browse/components/constants.tsx b/frontend/src/metabase/browse/components/constants.tsx
deleted file mode 100644
index 7146a49bfee..00000000000
--- a/frontend/src/metabase/browse/components/constants.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export const pathSeparatorChar = "/";
diff --git a/frontend/src/metabase/browse/components/utils.tsx b/frontend/src/metabase/browse/components/utils.tsx
index 3f83dae39a9..2aa67029d5b 100644
--- a/frontend/src/metabase/browse/components/utils.tsx
+++ b/frontend/src/metabase/browse/components/utils.tsx
@@ -1,12 +1,10 @@
 import { t } from "ttag";
 
-import type { CollectionEssentials, SearchResult } from "metabase-types/api";
+import { getCollectionPathAsString } from "metabase/collections/utils";
+import type { SearchResult } from "metabase-types/api";
 import { SortDirection, type SortingOptions } from "metabase-types/api/sorting";
 
 import type { ModelResult } from "../types";
-import { getCollectionName } from "../utils";
-
-import { pathSeparatorChar } from "./constants";
 
 export const isModel = (item: SearchResult) => item.model === "dataset";
 
@@ -18,22 +16,12 @@ export const getModelDescription = (item: SearchResult) => {
   }
 };
 
-export const getCollectionPathString = (collection: CollectionEssentials) => {
-  const ancestors: CollectionEssentials[] =
-    collection.effective_ancestors || [];
-  const collections = ancestors.concat(collection);
-  const pathString = collections
-    .map(coll => getCollectionName(coll))
-    .join(` ${pathSeparatorChar} `);
-  return pathString;
-};
-
 const getValueForSorting = (
   model: ModelResult,
   sort_column: keyof ModelResult,
 ): string => {
   if (sort_column === "collection") {
-    return getCollectionPathString(model.collection);
+    return getCollectionPathAsString(model.collection);
   } else {
     return model[sort_column];
   }
diff --git a/frontend/src/metabase/browse/components/utils.unit.spec.tsx b/frontend/src/metabase/browse/components/utils.unit.spec.tsx
index e7244066ef1..80cd31e2972 100644
--- a/frontend/src/metabase/browse/components/utils.unit.spec.tsx
+++ b/frontend/src/metabase/browse/components/utils.unit.spec.tsx
@@ -4,37 +4,7 @@ import { SortDirection } from "metabase-types/api/sorting";
 import { createMockModelResult } from "../test-utils";
 import type { ModelResult } from "../types";
 
-import {
-  getCollectionPathString,
-  getMaxRecentModelCount,
-  sortModels,
-} from "./utils";
-
-describe("getCollectionPathString", () => {
-  it("should return path for collection without ancestors", () => {
-    const collection = createMockCollection({
-      id: 0,
-      name: "Documents",
-      effective_ancestors: [],
-    });
-    const pathString = getCollectionPathString(collection);
-    expect(pathString).toBe("Documents");
-  });
-
-  it("should return path for collection with multiple ancestors", () => {
-    const ancestors = [
-      createMockCollection({ name: "Home" }),
-      createMockCollection({ name: "User" }),
-      createMockCollection({ name: "Files" }),
-    ];
-    const collection = createMockCollection({
-      name: "Documents",
-      effective_ancestors: ancestors,
-    });
-    const pathString = getCollectionPathString(collection);
-    expect(pathString).toBe("Home / User / Files / Documents");
-  });
-});
+import { getMaxRecentModelCount, sortModels } from "./utils";
 
 describe("sortModels", () => {
   let id = 0;
diff --git a/frontend/src/metabase/browse/types.tsx b/frontend/src/metabase/browse/types.tsx
index 88efd19b541..d689a551985 100644
--- a/frontend/src/metabase/browse/types.tsx
+++ b/frontend/src/metabase/browse/types.tsx
@@ -1,15 +1,9 @@
-import type { MutableRefObject } from "react";
-
 import type {
   RecentCollectionItem,
   RecentItem,
   SearchResult,
 } from "metabase-types/api";
 
-export type RefProp<RefValue> = {
-  ref: MutableRefObject<RefValue> | ((value: RefValue) => void);
-};
-
 /** Model retrieved through the search endpoint */
 export type ModelResult = SearchResult<number, "dataset">;
 
diff --git a/frontend/src/metabase/collections/utils.ts b/frontend/src/metabase/collections/utils.ts
index 0d30d91855b..cb0693964c6 100644
--- a/frontend/src/metabase/collections/utils.ts
+++ b/frontend/src/metabase/collections/utils.ts
@@ -3,6 +3,7 @@ import { t } from "ttag";
 import { PLUGIN_COLLECTIONS } from "metabase/plugins";
 import type {
   Collection,
+  CollectionEssentials,
   CollectionId,
   CollectionItem,
 } from "metabase-types/api";
@@ -246,3 +247,19 @@ export const getCollectionName = (
   }
   return collection?.name || t`Untitled collection`;
 };
+
+export const getCollectionPath = (collection: CollectionEssentials) => {
+  const ancestors: CollectionEssentials[] =
+    collection.effective_ancestors || [];
+  const collections = ancestors.concat(collection);
+  return collections;
+};
+
+export const getCollectionPathAsString = (collection: CollectionEssentials) => {
+  const collections = getCollectionPath(collection);
+  return collections
+    .map(coll => getCollectionName(coll))
+    .join(` ${collectionPathSeparator} `);
+};
+
+export const collectionPathSeparator = "/";
diff --git a/frontend/src/metabase/collections/utils.unit.spec.ts b/frontend/src/metabase/collections/utils.unit.spec.ts
index fa43c0b963f..65abf83a39d 100644
--- a/frontend/src/metabase/collections/utils.unit.spec.ts
+++ b/frontend/src/metabase/collections/utils.unit.spec.ts
@@ -1,5 +1,6 @@
 import {
   canonicalCollectionId,
+  getCollectionPathAsString,
   isItemCollection,
   isReadOnlyCollection,
   isRootCollection,
@@ -125,4 +126,30 @@ describe("Collections > utils", () => {
       ).toBe(false);
     });
   });
+
+  describe("getCollectionPathAsString", () => {
+    it("should return path for collection without ancestors", () => {
+      const collection = createMockCollection({
+        id: 0,
+        name: "Documents",
+        effective_ancestors: [],
+      });
+      const pathString = getCollectionPathAsString(collection);
+      expect(pathString).toBe("Documents");
+    });
+
+    it("should return path for collection with multiple ancestors", () => {
+      const ancestors = [
+        createMockCollection({ name: "Home" }),
+        createMockCollection({ name: "User" }),
+        createMockCollection({ name: "Files" }),
+      ];
+      const collection = createMockCollection({
+        name: "Documents",
+        effective_ancestors: ancestors,
+      });
+      const pathString = getCollectionPathAsString(collection);
+      expect(pathString).toBe("Home / User / Files / Documents");
+    });
+  });
 });
diff --git a/frontend/src/metabase/common/components/EllipsifiedPath/EllipsifiedCollectionPath.tsx b/frontend/src/metabase/common/components/EllipsifiedPath/EllipsifiedCollectionPath.tsx
new file mode 100644
index 00000000000..6b152d0f23f
--- /dev/null
+++ b/frontend/src/metabase/common/components/EllipsifiedPath/EllipsifiedCollectionPath.tsx
@@ -0,0 +1,21 @@
+import {
+  getCollectionName,
+  getCollectionPath,
+  getCollectionPathAsString,
+} from "metabase/collections/utils";
+import type { CollectionEssentials } from "metabase-types/api";
+
+import { EllipsifiedPath } from "./EllipsifiedPath";
+
+export const EllipsifiedCollectionPath = ({
+  collection,
+}: {
+  collection: CollectionEssentials;
+}) => {
+  return (
+    <EllipsifiedPath
+      tooltip={getCollectionPathAsString(collection)}
+      items={getCollectionPath(collection).map(c => getCollectionName(c))}
+    />
+  );
+};
diff --git a/frontend/src/metabase/common/components/Table/ClientSortableTable.tsx b/frontend/src/metabase/common/components/Table/ClientSortableTable.tsx
index 2ffe7ba30fa..092a4e80530 100644
--- a/frontend/src/metabase/common/components/Table/ClientSortableTable.tsx
+++ b/frontend/src/metabase/common/components/Table/ClientSortableTable.tsx
@@ -1,62 +1,49 @@
-import React, { useMemo } from "react";
+import cx from "classnames";
 
-import { SortDirection } from "metabase-types/api/sorting";
+import type { SortDirection } from "metabase-types/api/sorting";
 
 import { type BaseRow, Table, type TableProps } from "./Table";
-
-const compareNumbers = (a: number, b: number) => a - b;
+import TableS from "./Table.module.css";
+import { useTableSorting } from "./useTableSorting";
 
 export type ClientSortableTableProps<T extends BaseRow> = TableProps<T> & {
-  locale?: string;
+  locale: string;
   formatValueForSorting?: (row: T, columnName: string) => any;
+  defaultSortColumn?: string;
+  defaultSortDirection?: SortDirection;
+  className?: string;
 };
 
 /**
  * A basic reusable table component that supports client-side sorting by a column
- *
- * @param props.columns               - an array of objects with name and key properties
- * @param props.rows                  - an array of objects with keys that match the column keys
- * @param props.rowRenderer           - a function that takes a row object and returns a <tr> element
- * @param props.formatValueForSorting - a function that is passed the row and column and returns a value to be used for sorting. Defaults to row[column]
- * @param props.locale                - a locale used for string comparisons
- * @param props.emptyBody             - content to be displayed when the row count is 0
- * @param props.cols                  - a ReactNode that is inserted in the table element before <thead>. Useful for defining <colgroups> and <cols>
  */
 export function ClientSortableTable<Row extends BaseRow>({
+  className,
   columns,
   rows,
   rowRenderer,
   formatValueForSorting = (row: Row, columnName: string) => row[columnName],
+  defaultSortColumn,
+  defaultSortDirection,
   locale,
   ...rest
 }: ClientSortableTableProps<Row>) {
-  const [sortColumn, setSortColumn] = React.useState<string | null>(null);
-  const [sortDirection, setSortDirection] = React.useState<SortDirection>(
-    SortDirection.Asc,
-  );
-
-  const sortedRows = useMemo(() => {
-    if (sortColumn) {
-      return [...rows].sort((rowA, rowB) => {
-        const a = formatValueForSorting(rowA, sortColumn);
-        const b = formatValueForSorting(rowB, sortColumn);
-
-        if (!isSortableValue(a) || !isSortableValue(b)) {
-          return 0;
-        }
-
-        const result =
-          typeof a === "string"
-            ? compareStrings(a, b as string, locale)
-            : compareNumbers(a, b as number);
-        return sortDirection === SortDirection.Asc ? result : -result;
-      });
-    }
-    return rows;
-  }, [rows, sortColumn, sortDirection, locale, formatValueForSorting]);
+  const {
+    sortColumn,
+    sortDirection,
+    setSortColumn,
+    setSortDirection,
+    sortedRows,
+  } = useTableSorting({
+    rows,
+    defaultSortColumn,
+    formatValueForSorting,
+    locale,
+  });
 
   return (
     <Table
+      className={cx(className, TableS.Table)}
       rows={sortedRows}
       columns={columns}
       rowRenderer={rowRenderer}
@@ -70,11 +57,3 @@ export function ClientSortableTable<Row extends BaseRow>({
     />
   );
 }
-
-function isSortableValue(value: unknown): value is string | number {
-  return typeof value === "string" || typeof value === "number";
-}
-
-function compareStrings(a: string, b: string, locale?: string) {
-  return a.localeCompare(b, locale, { sensitivity: "base" });
-}
diff --git a/frontend/src/metabase/common/components/Table/Table.tsx b/frontend/src/metabase/common/components/Table/Table.tsx
index 245477bf9b7..2d339363c95 100644
--- a/frontend/src/metabase/common/components/Table/Table.tsx
+++ b/frontend/src/metabase/common/components/Table/Table.tsx
@@ -1,22 +1,18 @@
+import cx from "classnames";
 import React from "react";
 
 import {
   PaginationControls,
   type PaginationControlsProps,
 } from "metabase/components/PaginationControls";
-import { Box, Flex, Icon } from "metabase/ui";
+import { Box, Flex, type FlexProps, Icon } from "metabase/ui";
 import { SortDirection } from "metabase-types/api/sorting";
 
 import CS from "./Table.module.css";
+import type { ColumnItem } from "./types";
 
 export type BaseRow = Record<string, unknown> & { id: number | string };
 
-type ColumnItem = {
-  name: string;
-  key: string;
-  sortable?: boolean;
-};
-
 export type TableProps<Row extends BaseRow> = {
   columns: ColumnItem[];
   rows: Row[];
@@ -30,7 +26,7 @@ export type TableProps<Row extends BaseRow> = {
   > & { onPageChange: (page: number) => void };
   emptyBody?: React.ReactNode;
   cols?: React.ReactNode;
-};
+} & Omit<React.HTMLProps<HTMLTableElement>, "rows" | "cols">;
 
 /**
  * A basic reusable table component
@@ -38,14 +34,15 @@ export type TableProps<Row extends BaseRow> = {
  * @param props.columns         - an array of objects with name and key properties
  * @param props.rows            - an array of objects with keys that match the column keys
  * @param props.rowRenderer     - a function that takes a row object and returns a <tr> element
- * @param props.emptyBody       - content to be displayed when the row count is 0
- * @param props.cols            - a ReactNode that is inserted in the table element before <thead>. Useful for defining <colgroups> and <cols>
  * @param props.sortColumnName  - ID of the column currently used in row sorting
  * @param props.sortDirection   - The direction of the sort. Can be "asc" or "desc"
  * @param props.onSort          - a callback containing updated sort info for when a header is clicked
  * @param props.paginationProps - a map of information used to render pagination controls.
+ * @param props.emptyBody       - content to be displayed when the row count is 0
+ * @param props.cols            - a ReactNode that is inserted in the table element before <thead>. Useful for defining <colgroups> and <cols>
+ * @param props.className       - this will be added to the <table> element along with the default classname
+ * @note All other props are passed to the <table> element
  */
-
 export function Table<Row extends BaseRow>({
   columns,
   rows,
@@ -56,30 +53,34 @@ export function Table<Row extends BaseRow>({
   paginationProps,
   emptyBody,
   cols,
+  className,
   ...rest
 }: TableProps<Row>) {
   return (
     <>
-      <table {...rest} className={CS.Table}>
+      <table className={cx(CS.Table, className)} {...rest}>
         {cols && <colgroup>{cols}</colgroup>}
         <thead>
           <tr>
-            {columns.map(column => (
-              <th key={String(column.key)}>
-                {onSort && column.sortable !== false ? (
-                  <ColumnHeader
-                    column={column}
-                    sortColumn={sortColumnName}
-                    sortDirection={sortDirection}
-                    onSort={(columnKey: string, direction: SortDirection) => {
-                      onSort(columnKey, direction);
-                    }}
-                  />
-                ) : (
-                  column.name
-                )}
-              </th>
-            ))}
+            {columns.map(column => {
+              const { sortable = true } = column;
+              return (
+                <th key={String(column.key)}>
+                  {onSort && sortable ? (
+                    <ColumnHeader
+                      column={column}
+                      sortColumn={sortColumnName}
+                      sortDirection={sortDirection}
+                      onSort={(columnKey: string, direction: SortDirection) => {
+                        onSort(columnKey, direction);
+                      }}
+                    />
+                  ) : (
+                    column.name
+                  )}
+                </th>
+              );
+            })}
           </tr>
         </thead>
         <tbody>
@@ -118,12 +119,13 @@ function ColumnHeader({
   sortColumn,
   sortDirection,
   onSort,
+  ...rest
 }: {
   column: ColumnItem;
   sortColumn?: string | null;
   sortDirection?: SortDirection;
   onSort: (column: string, direction: SortDirection) => void;
-}) {
+} & FlexProps) {
   return (
     <Flex
       gap="sm"
@@ -137,6 +139,7 @@ function ColumnHeader({
             : SortDirection.Asc,
         )
       }
+      {...rest}
     >
       {column.name}
       {
diff --git a/frontend/src/metabase/common/components/Table/Table.unit.spec.tsx b/frontend/src/metabase/common/components/Table/Table.unit.spec.tsx
index 5f28e726d86..bacad083a13 100644
--- a/frontend/src/metabase/common/components/Table/Table.unit.spec.tsx
+++ b/frontend/src/metabase/common/components/Table/Table.unit.spec.tsx
@@ -1,7 +1,6 @@
-import { render, screen, within } from "@testing-library/react";
 import userEvent from "@testing-library/user-event";
 
-import { getIcon, queryIcon } from "__support__/ui";
+import { getIcon, queryIcon, render, screen, within } from "__support__/ui";
 
 import { ClientSortableTable } from "./ClientSortableTable";
 import { Table } from "./Table";
@@ -94,6 +93,7 @@ describe("common > components > ClientSortableTable", () => {
         columns={sampleColumns}
         rows={sampleData}
         rowRenderer={renderRow}
+        locale="en-US"
       />,
     );
     expect(screen.getByText("Name")).toBeInTheDocument();
@@ -108,6 +108,7 @@ describe("common > components > ClientSortableTable", () => {
         columns={sampleColumns}
         rows={sampleData}
         rowRenderer={renderRow}
+        locale="en-US"
       />,
     );
     expect(screen.getByText("Bulbasaur")).toBeInTheDocument();
@@ -125,6 +126,7 @@ describe("common > components > ClientSortableTable", () => {
         columns={sampleColumns}
         rows={sampleData}
         rowRenderer={renderRow}
+        locale="en-US"
       />,
     );
     const sortButton = screen.getByText("Name");
@@ -188,6 +190,7 @@ describe("common > components > ClientSortableTable", () => {
         columns={sampleColumns}
         rows={sampleData}
         rowRenderer={renderRow}
+        locale="en-US"
       />,
     );
     const sortNameButton = screen.getByText("Name");
@@ -221,6 +224,7 @@ describe("common > components > ClientSortableTable", () => {
             <td colSpan={3}>No Results</td>
           </tr>
         }
+        locale="en-US"
       />,
     );
     expect(screen.getByText("Name")).toBeInTheDocument();
@@ -229,12 +233,13 @@ describe("common > components > ClientSortableTable", () => {
     expect(screen.getByText("No Results")).toBeInTheDocument();
   });
 
-  it("should allow you provide a format values when sorting", async () => {
+  it("should let you provide format values when sorting", async () => {
     render(
       <ClientSortableTable
         columns={sampleColumns}
         rows={sampleData}
         rowRenderer={renderRow}
+        locale="en-US"
         formatValueForSorting={(row, colName) => {
           if (colName === "type") {
             if (row.type === "Water") {
@@ -280,7 +285,7 @@ describe("common > components > Table", () => {
     expect(onSort).toHaveBeenCalledWith("type", "asc");
   });
 
-  it("if pageination props are passed, it should render the pagination controller.", async () => {
+  it("should render the pagination controller if pagination props are passed", async () => {
     const onPageChange = jest.fn();
 
     render(
diff --git a/frontend/src/metabase/common/components/Table/types.ts b/frontend/src/metabase/common/components/Table/types.ts
new file mode 100644
index 00000000000..87d4d5822bf
--- /dev/null
+++ b/frontend/src/metabase/common/components/Table/types.ts
@@ -0,0 +1,7 @@
+export type BaseRow = Record<string, any> & { id: number | string };
+
+export type ColumnItem = {
+  name: string;
+  key: string;
+  sortable?: boolean;
+};
diff --git a/frontend/src/metabase/common/components/Table/useTableSorting.tsx b/frontend/src/metabase/common/components/Table/useTableSorting.tsx
new file mode 100644
index 00000000000..ac607d486a9
--- /dev/null
+++ b/frontend/src/metabase/common/components/Table/useTableSorting.tsx
@@ -0,0 +1,65 @@
+import { useCallback, useMemo, useState } from "react";
+
+import { SortDirection } from "metabase-types/api/sorting";
+
+import type { BaseRow } from "./types";
+
+const compareNumbers = (a: number, b: number) => a - b;
+
+export const useTableSorting = <Row extends BaseRow>({
+  rows,
+  defaultSortColumn,
+  defaultSortDirection = SortDirection.Asc,
+  formatValueForSorting,
+  locale,
+}: {
+  rows: Row[];
+  defaultSortColumn?: string;
+  defaultSortDirection?: SortDirection;
+  formatValueForSorting: (row: Row, columnName: string) => any;
+  locale: string;
+}) => {
+  const [sortColumn, setSortColumn] = useState<string | undefined>(
+    defaultSortColumn,
+  );
+  const [sortDirection, setSortDirection] =
+    useState<SortDirection>(defaultSortDirection);
+
+  const compareStrings = useCallback(
+    (a: string, b: string) =>
+      a.localeCompare(b, locale, { sensitivity: "base" }),
+    [locale],
+  );
+
+  const sortedRows = useMemo(() => {
+    if (sortColumn) {
+      return [...rows].sort((rowA, rowB) => {
+        const a = formatValueForSorting(rowA, sortColumn);
+        const b = formatValueForSorting(rowB, sortColumn);
+
+        if (!isSortableValue(a) || !isSortableValue(b)) {
+          return 0;
+        }
+
+        const result =
+          typeof a === "string"
+            ? compareStrings(a, b as string)
+            : compareNumbers(a, b as number);
+        return sortDirection === SortDirection.Asc ? result : -result;
+      });
+    }
+    return rows;
+  }, [rows, sortColumn, sortDirection, formatValueForSorting, compareStrings]);
+
+  return {
+    sortColumn,
+    sortDirection,
+    setSortColumn,
+    setSortDirection,
+    sortedRows,
+  };
+};
+
+function isSortableValue(value: unknown): value is string | number {
+  return typeof value === "string" || typeof value === "number";
+}
diff --git a/frontend/src/metabase/common/components/types.ts b/frontend/src/metabase/common/components/types.ts
new file mode 100644
index 00000000000..2f21f864662
--- /dev/null
+++ b/frontend/src/metabase/common/components/types.ts
@@ -0,0 +1,5 @@
+import type { Ref } from "react";
+
+export type RefProp<RefValue> = {
+  ref: Ref<RefValue>;
+};
diff --git a/frontend/src/metabase/components/ResponsiveContainer/ResponsiveContainer.tsx b/frontend/src/metabase/components/ResponsiveContainer/ResponsiveContainer.tsx
index 48f141a13de..d386f50af4f 100644
--- a/frontend/src/metabase/components/ResponsiveContainer/ResponsiveContainer.tsx
+++ b/frontend/src/metabase/components/ResponsiveContainer/ResponsiveContainer.tsx
@@ -1,6 +1,6 @@
 import styled from "@emotion/styled";
+import type { Ref } from "react";
 
-import type { RefProp } from "metabase/browse/types";
 import { doNotForwardProps } from "metabase/common/utils/doNotForwardProps";
 import { Box, type BoxProps } from "metabase/ui";
 
@@ -22,5 +22,5 @@ ResponsiveContainer.defaultProps = { type: "inline-size" };
 export const ResponsiveChild = styled(Box, doNotForwardProps("containerName"))<
   BoxProps & {
     containerName: string;
-  } & Partial<RefProp<HTMLDivElement | null>>
+  } & { ref?: Ref<HTMLDivElement | null> }
 >``;
diff --git a/frontend/src/metabase/components/Schedule/constants.ts b/frontend/src/metabase/components/Schedule/constants.ts
index 4c01d48a95b..7c4346cb8d2 100644
--- a/frontend/src/metabase/components/Schedule/constants.ts
+++ b/frontend/src/metabase/components/Schedule/constants.ts
@@ -54,7 +54,9 @@ export type Weekday = {
 /** These strings are created in a function, rather than in module scope, so that ttag is not called until the locale is set */
 export const getScheduleStrings = () => {
   const scheduleOptionNames = {
-    // The context is needed because 'hourly' can be an adjective ('hourly rate') or adverb ('update hourly'). Same with 'daily', 'weekly', and 'monthly'.
+    // The context is needed because 'hourly' can be an adjective ('hourly
+    // rate') or adverb ('update hourly'). Same with 'daily', 'weekly', and
+    // 'monthly'.
     hourly: c("adverb").t`hourly`,
     daily: c("adverb").t`daily`,
     weekly: c("adverb").t`weekly`,
diff --git a/frontend/src/metabase/palette/hooks/useCommandPalette.tsx b/frontend/src/metabase/palette/hooks/useCommandPalette.tsx
index 0cfff14237d..20c6ee67997 100644
--- a/frontend/src/metabase/palette/hooks/useCommandPalette.tsx
+++ b/frontend/src/metabase/palette/hooks/useCommandPalette.tsx
@@ -6,6 +6,7 @@ import { useDebounce } from "react-use";
 import { jt, t } from "ttag";
 
 import { getAdminPaths } from "metabase/admin/app/selectors";
+import { getPerformanceAdminPaths } from "metabase/admin/performance/constants/complex";
 import { getSectionsWithPlugins } from "metabase/admin/settings/selectors";
 import { useListRecentsQuery, useSearchQuery } from "metabase/api";
 import { useSetting } from "metabase/common/hooks";
@@ -16,6 +17,7 @@ import { getIcon } from "metabase/lib/icon";
 import { getName } from "metabase/lib/name";
 import { useDispatch, useSelector } from "metabase/lib/redux";
 import * as Urls from "metabase/lib/urls";
+import { PLUGIN_CACHING } from "metabase/plugins";
 import { trackSearchClick } from "metabase/search/analytics";
 import {
   getDocsSearchUrl,
@@ -263,7 +265,13 @@ export const useCommandPalette = ({
   ]);
 
   const adminActions = useMemo<PaletteAction[]>(() => {
-    return adminPaths.map(adminPath => ({
+    // Subpaths - i.e. paths to items within the main Admin tabs - are needed
+    // in the command palette but are not part of the main list of admin paths
+    const adminSubpaths = getPerformanceAdminPaths(
+      PLUGIN_CACHING.getTabMetadata(),
+    );
+    const paths = [...adminPaths, ...adminSubpaths];
+    return paths.map(adminPath => ({
       id: `admin-page-${adminPath.key}`,
       name: `${adminPath.name}`,
       icon: "gear",
diff --git a/frontend/src/metabase/plugins/index.ts b/frontend/src/metabase/plugins/index.ts
index e1e770448da..bb2a981a2cc 100644
--- a/frontend/src/metabase/plugins/index.ts
+++ b/frontend/src/metabase/plugins/index.ts
@@ -10,7 +10,10 @@ import _ from "underscore";
 import type { AnySchema } from "yup";
 
 import noResultsSource from "assets/img/no_results.svg";
-import { strategies } from "metabase/admin/performance/constants/complex";
+import {
+  getPerformanceTabMetadata,
+  strategies,
+} from "metabase/admin/performance/constants/complex";
 import { UNABLE_TO_CHANGE_ADMIN_PERMISSIONS } from "metabase/admin/permissions/constants/messages";
 import {
   type DataPermission,
@@ -403,6 +406,9 @@ export const PLUGIN_CACHING = {
   canOverrideRootStrategy: false,
   /** Metadata describing the different kinds of strategies */
   strategies: strategies,
+  DashboardAndQuestionCachingTab: PluginPlaceholder as any,
+  StrategyEditorForQuestionsAndDashboards: PluginPlaceholder as any,
+  getTabMetadata: getPerformanceTabMetadata,
 };
 
 export const PLUGIN_REDUCERS: {
-- 
GitLab