Skip to content
Snippets Groups Projects
Unverified Commit 0b06c1da authored by Nick Fitzpatrick's avatar Nick Fitzpatrick Committed by GitHub
Browse files

Usage insight links on questions and dashboards (#38573)


* adding links with hardcoded IDs

* add audit-info endpoint

* fix perms

* Fetching Dashboard IDs from the API

* e2e tests

* add BE tests

* kinda busted

* InstanceAnalyticsButton

* removing unused file

* Update enterprise/backend/src/metabase_enterprise/audit_app/api/user.clj

Co-authored-by: default avatarNoah Moss <32746338+noahmoss@users.noreply.github.com>

---------

Co-authored-by: default avatarJerry Huang <jhuang37050@gmail.com>
Co-authored-by: default avatarJerry Huang <34140255+qwef@users.noreply.github.com>
Co-authored-by: default avatarNoah Moss <32746338+noahmoss@users.noreply.github.com>
parent 0492a88b
No related branches found
No related tags found
No related merge requests found
Showing
with 364 additions and 12 deletions
import {
ORDERS_DASHBOARD_ID,
ORDERS_QUESTION_ID,
} from "e2e/support/cypress_sample_instance_data";
import {
restore,
setTokenFeatures,
......@@ -6,6 +10,8 @@ import {
modal,
visitDashboard,
visitModel,
visitQuestion,
describeOSS,
} from "e2e/support/helpers";
const ANALYTICS_COLLECTION_NAME = "Metabase analytics";
......@@ -251,6 +257,108 @@ describeEE("scenarios > Metabase Analytics Collection (AuditV2) ", () => {
});
});
describe("question and dashboard links", () => {
describeEE("ee", () => {
beforeEach(() => {
restore();
cy.signInAsAdmin();
setTokenFeatures("all");
});
it("should show a analytics link for questions", () => {
visitQuestion(ORDERS_QUESTION_ID);
cy.intercept("GET", "/api/collection/**").as("collection");
cy.findByTestId("qb-header-action-panel")
.button(/\.\.\./)
.click();
popover().findByText("Usage insights").click();
cy.wait("@collection");
cy.findByDisplayValue("Question overview").should("exist");
cy.findByRole("button", { name: /Question ID/ }).should(
"contain.text",
ORDERS_QUESTION_ID,
);
cy.findAllByTestId("dashcard")
.contains("[data-testid=dashcard]", "Question metadata")
.within(() => {
cy.findByText("Entity ID");
cy.findByText(ORDERS_QUESTION_ID);
cy.findByText("Name");
cy.findByText("Orders");
cy.findByText("Entity Type");
cy.findByText("question");
});
});
it("should show a analytics link for dashboards", () => {
visitDashboard(ORDERS_DASHBOARD_ID);
cy.intercept("GET", "/api/collection/**").as("collection");
cy.button("dashboard-menu-button").click();
popover().findByText("Usage insights").click();
cy.wait("@collection");
cy.findByDisplayValue("Dashboard overview").should("exist");
cy.findByRole("button", { name: /Dashboard ID/ }).should(
"contain.text",
ORDERS_DASHBOARD_ID,
);
cy.findAllByTestId("dashcard")
.contains("[data-testid=dashcard]", "Dashboard metadata")
.within(() => {
cy.findByText("Entity ID");
cy.findByText(ORDERS_DASHBOARD_ID);
cy.findByText("Name");
cy.findByText("Orders in a dashboard");
cy.findByText("Entity Type");
cy.findByText("dashboard");
});
});
it("should not show option for users with no access to Metabase Analytics", () => {
cy.signInAsNormalUser();
visitQuestion(ORDERS_QUESTION_ID);
cy.findByTestId("qb-header-action-panel")
.button(/\.\.\./)
.click();
popover().findByText("Usage insights").should("not.exist");
visitDashboard(ORDERS_DASHBOARD_ID);
cy.button("dashboard-menu-button").click();
popover().findByText("Usage insights").should("not.exist");
});
});
describeOSS("oss", { tags: "@OSS" }, () => {
beforeEach(() => {
restore();
cy.signInAsAdmin();
});
it("should never appear in OSS", () => {
visitQuestion(ORDERS_QUESTION_ID);
cy.findByTestId("qb-header-action-panel")
.button(/\.\.\./)
.click();
popover().findByText("Usage insights").should("not.exist");
visitDashboard(ORDERS_DASHBOARD_ID);
cy.button("dashboard-menu-button").click();
popover().findByText("Usage insights").should("not.exist");
});
});
});
function getCollectionId(collectionName) {
return cy.request("GET", "/api/collection").then(({ body }) => {
const collection = body.find(({ name }) => name === collectionName);
......
(ns metabase-enterprise.audit-app.api.user
"`/api/ee/audit-app/user` endpoints. These only work if you have a premium token with the `:audit-app` feature."
(:require
[compojure.core :refer [DELETE]]
[compojure.core :refer [DELETE GET]]
[metabase-enterprise.audit-db :as audit-db]
[metabase.api.common :as api]
[metabase.api.user :as api.user]
[metabase.models.interface :as mi]
[metabase.models.pulse :refer [Pulse]]
[metabase.models.pulse-channel-recipient :refer [PulseChannelRecipient]]
[metabase.util :as u]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
(api/defendpoint GET "/audit-info"
"Gets audit info for the current user if he has permissions to access the audit collection.
Otherwise return an empty map."
[]
(let [custom-reports (audit-db/default-custom-reports-collection)
question-overview (audit-db/entity-id->object :model/Dashboard audit-db/default-question-overview-entity-id)
dashboard-overview (audit-db/entity-id->object :model/Dashboard audit-db/default-dashboard-overview-entity-id)]
(merge
{}
(when (mi/can-read? (audit-db/default-custom-reports-collection))
{(:slug custom-reports) (:id custom-reports)})
(when (mi/can-read? (audit-db/default-audit-collection))
{(u/slugify (:name question-overview)) (:id question-overview)
(u/slugify (:name dashboard-overview)) (:id dashboard-overview)}))))
(api/defendpoint DELETE "/:id/subscriptions"
"Delete all Alert and DashboardSubscription subscriptions for a User (i.e., so they will no longer receive them).
Archive all Alerts and DashboardSubscriptions created by the User. Only allowed for admins or for the current user."
......
......@@ -76,24 +76,33 @@
"Default custom reports entity id."
"okNLSZKdSxaoG58JSQY54")
(defn collection-entity-id->collection
"Returns the collection from entity id for collections. Memoizes from entity id."
[entity-id]
(def default-question-overview-entity-id
"Default Question Overview (this is a dashboard) entity id."
"jm7KgY6IuS6pQjkBZ7WUI")
(def default-dashboard-overview-entity-id
"Default Dashboard Overview (this is a dashboard) entity id."
"bJEYb0o5CXlfWFcIztDwJ")
(defn entity-id->object
"Returns the object from entity id and model. Memoizes from entity id.
Should only be used for audit/pre-loaded objects."
[model entity-id]
((mdb.connection/memoize-for-application-db
(fn [entity-id]
(t2/select-one :model/Collection :entity_id entity-id))) entity-id))
(t2/select-one model :entity_id entity-id))) entity-id))
(defenterprise default-custom-reports-collection
"Default custom reports collection."
:feature :none
[]
(collection-entity-id->collection default-custom-reports-entity-id))
(entity-id->object :model/Collection default-custom-reports-entity-id))
(defenterprise default-audit-collection
"Default audit collection (instance analytics) collection."
:feature :none
[]
(collection-entity-id->collection default-audit-collection-entity-id))
(entity-id->object :model/Collection default-audit-collection-entity-id))
(defn- install-database!
"Creates the audit db, a clone of the app db used for auditing purposes.
......
(ns ^:mb/once metabase-enterprise.audit-app.api.user-test
(:require
[clojure.test :refer :all]
[metabase.models :refer [Card Dashboard DashboardCard Pulse PulseCard PulseChannel PulseChannelRecipient User]]
[metabase-enterprise.audit-app.permissions-test :as ee-perms-test]
[metabase-enterprise.audit-db :as audit-db]
[metabase.models :refer [Card Dashboard DashboardCard Pulse PulseCard
PulseChannel PulseChannelRecipient User]]
[metabase.models.permissions :as perms]
[metabase.models.permissions-group :as perms-group]
[metabase.test :as mt]
[toucan2.core :as t2]
[toucan2.tools.with-temp :as t2.with-temp]))
......@@ -83,3 +88,30 @@
:alert-archived? true
:dashboard-subscription-archived? true}
(describe-objects))))))))))))
(deftest get-audit-info-test
(testing "GET /api/ee/audit-app/user/audit-info"
(mt/with-premium-features #{:audit-app}
(ee-perms-test/install-audit-db-if-needed!)
(testing "None of the ids show up when perms aren't given"
(perms/revoke-collection-permissions! (perms-group/all-users) (audit-db/default-custom-reports-collection))
(perms/revoke-collection-permissions! (perms-group/all-users) (audit-db/default-audit-collection))
(is (= #{}
(->>
(mt/user-http-request :rasta :get 200 "/ee/audit-app/user/audit-info")
keys
(into #{})))))
(testing "Custom reports collection shows up when perms are given"
(perms/grant-collection-read-permissions! (perms-group/all-users) (audit-db/default-custom-reports-collection))
(is (= #{:custom_reports}
(->>
(mt/user-http-request :rasta :get 200 "/ee/audit-app/user/audit-info")
keys
(into #{})))))
(testing "Everything shows up when all perms are given"
(perms/grant-collection-read-permissions! (perms-group/all-users) (audit-db/default-audit-collection))
(is (= #{:question_overview :dashboard_overview :custom_reports}
(->>
(mt/user-http-request :rasta :get 200 "/ee/audit-app/user/audit-info")
keys
(into #{}))))))))
......@@ -116,7 +116,7 @@
(update-graph! (assoc-in (graph :clear-revisions? true) [:groups group-id (:id collection)] :write)))))))))
;; TODO: re-enable these tests once they're no longer flaky
(defn- install-audit-db-if-needed!
(defn install-audit-db-if-needed!
"Checks if there's an audit-db. if not, it will create it and serialize audit content, including the
`default-audit-collection`. If the audit-db is there, this does nothing."
[]
......
import { t } from "ttag";
import { useEffect } from "react";
import { push } from "react-router-redux";
import { loadInfo } from "metabase-enterprise/audit_app/reducer";
import EntityMenuItem from "metabase/components/EntityMenuItem";
import { useDispatch, useSelector } from "metabase/lib/redux";
import type { AuditInfoState } from "metabase-enterprise/audit_app/types/state";
import type { DashboardId } from "metabase-types/api";
interface InstanceAnalyticsButtonProps {
entitySelector: (state: AuditInfoState) => number;
linkQueryParams: { dashboard_id: DashboardId } | { question_id: number };
}
export const InstanceAnalyticsButton = ({
entitySelector,
linkQueryParams,
}: InstanceAnalyticsButtonProps) => {
const dispatch = useDispatch();
const entityId = useSelector(state =>
entitySelector(state as AuditInfoState),
);
useEffect(() => {
dispatch(loadInfo());
}, [dispatch]);
if (entityId !== undefined) {
return (
<EntityMenuItem
icon="audit"
title={t`Usage insights`}
action={() => {
dispatch(
push({
pathname: `/dashboard/${entityId}`,
query: linkQueryParams,
}),
);
}}
/>
);
} else {
return null;
}
};
......@@ -4,9 +4,16 @@ import {
PLUGIN_ADMIN_ROUTES,
PLUGIN_ADMIN_USER_MENU_ITEMS,
PLUGIN_ADMIN_USER_MENU_ROUTES,
PLUGIN_REDUCERS,
PLUGIN_DASHBOARD_HEADER,
PLUGIN_QUERY_BUILDER_HEADER,
} from "metabase/plugins";
import { hasPremiumFeature } from "metabase-enterprise/settings";
import getAuditRoutes, { getUserMenuRotes } from "./routes";
import { auditInfo } from "./reducer";
import { getDashboardOverviewId, getQuestionOverviewId } from "./selectors";
import { InstanceAnalyticsButton } from "./components/InstanceAnalyticsButton/InstanceAnalyticsButton";
if (hasPremiumFeature("audit_app")) {
PLUGIN_ADMIN_NAV_ITEMS.push({
......@@ -24,4 +31,34 @@ if (hasPremiumFeature("audit_app")) {
]);
PLUGIN_ADMIN_USER_MENU_ROUTES.push(getUserMenuRotes);
PLUGIN_REDUCERS.auditInfo = auditInfo;
PLUGIN_DASHBOARD_HEADER.extraButtons = dashboard => {
return [
{
key: "Usage insights",
component: (
<InstanceAnalyticsButton
entitySelector={getDashboardOverviewId}
linkQueryParams={{ dashboard_id: dashboard.id }}
/>
),
},
];
};
PLUGIN_QUERY_BUILDER_HEADER.extraButtons = question => {
return [
{
key: "Usage insights",
component: (
<InstanceAnalyticsButton
entitySelector={getQuestionOverviewId}
linkQueryParams={{ question_id: question.id() }}
/>
),
},
];
};
}
import { createReducer } from "@reduxjs/toolkit";
import { createAsyncThunk } from "metabase/lib/redux";
import { GET } from "metabase/lib/api";
import { isAuditInfoComplete } from "./selectors";
import type { AuditInfoState } from "./types/state";
const getAuditInfo = GET("/api/ee/audit-app/user/audit-info");
const LOAD_AUDIT_INFO = "metabase-enterprise/audit/FETCH_AUDIT_INFO";
export const loadInfo = createAsyncThunk(
LOAD_AUDIT_INFO,
async (_, { getState }) => {
const state = getState() as AuditInfoState;
const isComplete = isAuditInfoComplete(state);
if (!isComplete) {
const data = await getAuditInfo();
return data;
}
},
);
const initialState = {
isLoading: false,
isComplete: false,
data: undefined,
};
export const auditInfo = createReducer(initialState, builder => {
builder.addCase(loadInfo.pending, state => {
state.isLoading = true;
});
builder.addCase(loadInfo.fulfilled, (state, { payload }) => {
state.isLoading = false;
state.isComplete = true;
state.data = payload || state.data;
});
});
import type { AuditInfoState } from "./types/state";
export const isAuditInfoLoading = (state: AuditInfoState) => {
const {
plugins: { auditInfo },
} = state;
return auditInfo.isLoading;
};
export const isAuditInfoComplete = (state: AuditInfoState) => {
const {
plugins: { auditInfo },
} = state;
return auditInfo.isComplete;
};
export const getDashboardOverviewId = (state: AuditInfoState) =>
state.plugins.auditInfo.data?.dashboard_overview ?? undefined;
export const getQuestionOverviewId = (state: AuditInfoState) =>
state.plugins.auditInfo.data?.question_overview ?? undefined;
import type { DashboardId } from "metabase-types/api";
import type { State } from "metabase-types/store";
export interface AuditInfoState extends State {
plugins: {
auditInfo: {
isLoading: boolean;
isComplete: boolean;
data: {
dashboard_overview: DashboardId;
question_overview: number;
};
};
};
}
......@@ -107,6 +107,12 @@ class EntityMenu extends Component {
/>
</li>
);
} else if (item.component) {
return (
<li key={item.title} data-testid={item.testId}>
{item.component}
</li>
);
} else {
return (
<li key={item.title} data-testid={item.testId}>
......
......@@ -4,7 +4,7 @@ import { connect } from "react-redux";
import { push } from "react-router-redux";
import { msgid, ngettext, t } from "ttag";
import _ from "underscore";
import type { Location } from "history";
import type { Location, LocationDescriptor } from "history";
import { trackExportDashboardToPDF } from "metabase/dashboard/analytics";
......@@ -73,6 +73,7 @@ import type {
State,
} from "metabase-types/store";
import { PLUGIN_DASHBOARD_HEADER } from "metabase/plugins";
import type { UiParameter } from "metabase-lib/parameters/types";
import { ExtraEditButtonsMenu } from "../ExtraEditButtonsMenu/ExtraEditButtonsMenu";
import { DashboardButtonTooltip } from "../DashboardButtonTooltip";
......@@ -162,7 +163,7 @@ interface DispatchProps {
deleteBookmark: (args: { id: DashboardId }) => void;
fetchPulseFormInput: () => void;
toggleSidebar: (sidebarName: DashboardSidebarName) => void;
onChangeLocation: (location: Location) => void;
onChangeLocation: (location: LocationDescriptor) => void;
addActionToDashboard: (
opts: NewDashCardOpts & {
action: Partial<WritebackAction>;
......@@ -591,6 +592,8 @@ class DashboardHeaderContainer extends Component<DashboardHeaderProps> {
link: `${location.pathname}/archive`,
event: "Dashboard;Archive",
});
extraButtons.push(...PLUGIN_DASHBOARD_HEADER.extraButtons(dashboard));
}
}
......
......@@ -15,6 +15,7 @@ import type {
Collection,
CollectionAuthorityLevelConfig,
CollectionInstanceAnaltyicsConfig,
Dashboard,
Dataset,
Group,
GroupPermissions,
......@@ -130,6 +131,7 @@ export const PLUGIN_SELECTORS = {
getIsWhiteLabeling: (_state: State) => false,
getApplicationName: (_state: State) => "Metabase",
getShowMetabaseLinks: (_state: State) => true,
getDashboardOverviewId: (_state: State) => undefined,
};
export const PLUGIN_FORM_WIDGETS: Record<string, ComponentType<any>> = {};
......@@ -251,10 +253,12 @@ export const PLUGIN_REDUCERS: {
applicationPermissionsPlugin: any;
sandboxingPlugin: any;
shared: any;
auditInfo: any;
} = {
applicationPermissionsPlugin: () => null,
sandboxingPlugin: () => null,
shared: () => null,
auditInfo: () => null,
};
export const PLUGIN_ADVANCED_PERMISSIONS = {
......@@ -323,3 +327,11 @@ export const PLUGIN_EMBEDDING = {
export const PLUGIN_CONTENT_VERIFICATION = {
VerifiedFilter: {} as SearchFilterComponent<"verified">,
};
export const PLUGIN_DASHBOARD_HEADER = {
extraButtons: (_dashboard: Dashboard) => [],
};
export const PLUGIN_QUERY_BUILDER_HEADER = {
extraButtons: (_question: Question) => [],
};
......@@ -7,7 +7,11 @@ import Button from "metabase/core/components/Button";
import Tooltip from "metabase/core/components/Tooltip";
import EntityMenu from "metabase/components/EntityMenu";
import { PLUGIN_MODERATION, PLUGIN_MODEL_PERSISTENCE } from "metabase/plugins";
import {
PLUGIN_MODERATION,
PLUGIN_MODEL_PERSISTENCE,
PLUGIN_QUERY_BUILDER_HEADER,
} from "metabase/plugins";
import { MODAL_TYPES } from "metabase/query_builder/constants";
......@@ -224,6 +228,8 @@ export const QuestionActions = ({
});
}
extraButtons.push(...PLUGIN_QUERY_BUILDER_HEADER.extraButtons(question));
const fileInputRef = useRef<HTMLInputElement>(null);
const handleUploadClick = () => {
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment