diff --git a/superset-frontend/src/components/IconTooltip/IconTooltip.stories.tsx b/superset-frontend/src/components/IconTooltip/IconTooltip.stories.tsx
index 44b193e5535..d4ba20d3421 100644
--- a/superset-frontend/src/components/IconTooltip/IconTooltip.stories.tsx
+++ b/superset-frontend/src/components/IconTooltip/IconTooltip.stories.tsx
@@ -39,19 +39,21 @@ const PLACEMENTS = [
'topRight',
];
-const theme = useTheme();
+export const InteractiveIconTooltip = (args: Props) => {
+ const theme = useTheme();
-export const InteractiveIconTooltip = (args: Props) => (
-
-
-
-
-
-);
+ return (
+
+
+
+
+
+ );
+};
InteractiveIconTooltip.args = {
tooltip: 'Tooltip',
diff --git a/superset-frontend/src/features/groups/types.ts b/superset-frontend/src/features/groups/types.ts
new file mode 100644
index 00000000000..4bfab858bc6
--- /dev/null
+++ b/superset-frontend/src/features/groups/types.ts
@@ -0,0 +1,31 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+export interface BaseGroupListModalProps {
+ show: boolean;
+ onHide: () => void;
+ onSave: () => void;
+}
+
+export interface FormValues {
+ name: string;
+ label?: string;
+ description?: string;
+ roles: number[];
+ users: { value: number; label: string }[];
+}
diff --git a/superset-frontend/src/features/groups/utils.ts b/superset-frontend/src/features/groups/utils.ts
new file mode 100644
index 00000000000..89e3dc05ccd
--- /dev/null
+++ b/superset-frontend/src/features/groups/utils.ts
@@ -0,0 +1,74 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { SupersetClient, t } from '@superset-ui/core';
+import rison from 'rison';
+import { FormValues } from './types';
+
+export const createGroup = async (values: FormValues) => {
+ await SupersetClient.post({
+ endpoint: '/api/v1/security/groups/',
+ jsonPayload: { ...values, users: values.users.map(user => user.value) },
+ });
+};
+
+export const updateGroup = async (groupId: number, values: FormValues) => {
+ await SupersetClient.put({
+ endpoint: `/api/v1/security/groups/${groupId}`,
+ jsonPayload: { ...values, users: values.users.map(user => user.value) },
+ });
+};
+
+export const deleteGroup = async (groupId: number) =>
+ SupersetClient.delete({
+ endpoint: `/api/v1/security/groups/${groupId}`,
+ });
+
+export const fetchUserOptions = async (
+ filterValue: string,
+ page: number,
+ pageSize: number,
+ addDangerToast: (msg: string) => void,
+) => {
+ const query = rison.encode({
+ filter: filterValue,
+ page,
+ page_size: pageSize,
+ order_column: 'username',
+ order_direction: 'asc',
+ });
+
+ try {
+ const response = await SupersetClient.get({
+ endpoint: `/api/v1/security/users/?q=${query}`,
+ });
+
+ const results = response.json?.result || [];
+
+ return {
+ data: results.map((user: any) => ({
+ value: user.id,
+ label: user.username,
+ })),
+ totalCount: response.json?.count ?? 0,
+ };
+ } catch (error) {
+ addDangerToast(t('There was an error while fetching users'));
+ return { data: [], totalCount: 0 };
+ }
+};
diff --git a/superset-frontend/src/pages/ActionLog/index.tsx b/superset-frontend/src/pages/ActionLog/index.tsx
new file mode 100644
index 00000000000..7c2f708ddb7
--- /dev/null
+++ b/superset-frontend/src/pages/ActionLog/index.tsx
@@ -0,0 +1,282 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useMemo } from 'react';
+import { t, css } from '@superset-ui/core';
+import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
+import { useListViewResource } from 'src/views/CRUD/hooks';
+import { useToasts } from 'src/components/MessageToasts/withToasts';
+import ListView, { Filters, FilterOperator } from 'src/components/ListView';
+// eslint-disable-next-line no-restricted-imports
+import { Typography } from 'antd-v5';
+import { fetchUserOptions } from 'src/features/groups/utils';
+
+export type ActionLogObject = {
+ user: {
+ username: string;
+ };
+ action: string;
+ dttm: string | null;
+ dashboard_id?: number;
+ slice_id?: number;
+ json?: string;
+ duration_ms?: number;
+ referrer?: string;
+};
+
+const PAGE_SIZE = 25;
+
+function ActionLogList() {
+ const { addDangerToast, addSuccessToast } = useToasts();
+ const initialSort = [{ id: 'dttm', desc: true }];
+ const subMenuButtons: SubMenuProps['buttons'] = [];
+
+ const {
+ state: {
+ loading,
+ resourceCount: LogsCount,
+ resourceCollection: Logs,
+ bulkSelectEnabled,
+ },
+ fetchData,
+ refreshData,
+ toggleBulkSelect,
+ } = useListViewResource(
+ 'log',
+ t('Log'),
+ addDangerToast,
+ false,
+ );
+ const filters: Filters = useMemo(
+ () => [
+ {
+ Header: t('Users'),
+ key: 'user',
+ id: 'user',
+ input: 'select',
+ operator: FilterOperator.RelationOneMany,
+ unfilteredLabel: t('All'),
+ fetchSelects: async (filterValue, page, pageSize) =>
+ fetchUserOptions(filterValue, page, pageSize, addDangerToast),
+ },
+ {
+ Header: t('Dashboard Id'),
+ key: 'dashboard_id',
+ id: 'dashboard_id',
+ input: 'search',
+ operator: FilterOperator.Equals,
+ },
+ {
+ Header: t('Slice Id'),
+ key: 'slice_id',
+ id: 'slice_id',
+ input: 'search',
+ operator: FilterOperator.Equals,
+ },
+ {
+ Header: t('Action'),
+ key: 'action',
+ id: 'action',
+ input: 'search',
+ operator: FilterOperator.Contains,
+ },
+ {
+ Header: t('JSON'),
+ key: 'json',
+ id: 'json',
+ input: 'search',
+ operator: FilterOperator.Contains,
+ },
+ {
+ Header: t('dttm'),
+ key: 'dttm',
+ id: 'dttm',
+ input: 'datetime_range',
+ operator: FilterOperator.Between,
+ dateFilterValueType: 'iso',
+ },
+ {
+ Header: t('Referrer'),
+ key: 'referrer',
+ id: 'referrer',
+ input: 'search',
+ operator: FilterOperator.Equals,
+ },
+ {
+ Header: t('Duration Ms'),
+ key: 'duration_ms',
+ id: 'duration_ms',
+ input: 'search',
+ operator: FilterOperator.Equals,
+ },
+ ],
+ [],
+ );
+
+ const columns = useMemo(
+ () => [
+ {
+ accessor: 'action',
+ Header: t('Action'),
+ Cell: ({
+ row: {
+ original: { action },
+ },
+ }: any) => {action},
+ },
+ {
+ accessor: 'user',
+ Header: t('User'),
+ Cell: ({
+ row: {
+ original: { user },
+ },
+ }: any) => {user?.username},
+ },
+
+ {
+ accessor: 'duration_ms',
+ Header: t('Duration Ms'),
+
+ Cell: ({
+ row: {
+ original: { duration_ms },
+ },
+ }: any) => {duration_ms},
+ },
+ {
+ accessor: 'dashboard_id',
+ Header: t('Dashboard Id'),
+ hidden: false,
+ Cell: ({
+ row: {
+ original: { dashboard_id },
+ },
+ }: any) => {dashboard_id},
+ },
+ {
+ accessor: 'slice_id',
+ Header: t('Slice Id'),
+ hidden: false,
+ Cell: ({
+ row: {
+ original: { slice_id },
+ },
+ }: any) => {slice_id},
+ },
+ {
+ accessor: 'json',
+ Header: t('JSON'),
+
+ Cell: ({
+ row: {
+ original: { json },
+ },
+ }: any) => (
+
+ {json}
+
+ ),
+ },
+
+ {
+ accessor: 'referrer',
+ Header: t('Referrer'),
+
+ Cell: ({
+ row: {
+ original: { referrer },
+ },
+ }: any) => (
+
+ {referrer}
+
+ ),
+ },
+ {
+ accessor: 'dttm',
+ Header: t('Dttm'),
+ Cell: ({
+ row: {
+ original: { dttm },
+ },
+ }: any) => {dttm},
+ },
+ ],
+ [],
+ );
+
+ const emptyState = {
+ title: t('No Logs yet'),
+ image: 'filter-results.svg',
+ };
+
+ return (
+ <>
+
+
+ className="action-log-view"
+ columns={columns}
+ count={LogsCount}
+ data={Logs}
+ fetchData={fetchData}
+ filters={filters}
+ initialSort={initialSort}
+ loading={loading}
+ pageSize={PAGE_SIZE}
+ bulkSelectEnabled={bulkSelectEnabled}
+ disableBulkSelect={toggleBulkSelect}
+ addDangerToast={addDangerToast}
+ addSuccessToast={addSuccessToast}
+ emptyState={emptyState}
+ refreshData={refreshData}
+ />
+ >
+ );
+}
+
+export default ActionLogList;
diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx
index 521bbee0ddf..8380ac58f0b 100644
--- a/superset-frontend/src/views/routes.tsx
+++ b/superset-frontend/src/views/routes.tsx
@@ -137,6 +137,9 @@ const RolesList = lazy(
const UsersList: LazyExoticComponent = lazy(
() => import(/* webpackChunkName: "UsersList" */ 'src/pages/UsersList'),
);
+const ActionLogList: LazyExoticComponent = lazy(
+ () => import(/* webpackChunkName: "ActionLogList" */ 'src/pages/ActionLog'),
+);
type Routes = {
path: string;
@@ -240,6 +243,10 @@ export const routes: Routes = [
path: '/sqllab/',
Component: SqlLab,
},
+ {
+ path: '/actionlog/list',
+ Component: ActionLogList,
+ },
];
if (isFeatureEnabled(FeatureFlag.TaggingSystem)) {
diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py
index 4022c653ff0..427fd699254 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -175,7 +175,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
from superset.views.error_handling import set_app_error_handlers
from superset.views.explore import ExplorePermalinkView, ExploreView
from superset.views.log.api import LogRestApi
- from superset.views.log.views import LogModelView
+ from superset.views.logs import ActionLogView
from superset.views.roles import RolesListView
from superset.views.sql_lab.views import (
SavedQueryView,
@@ -224,6 +224,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_api(TagRestApi)
appbuilder.add_api(SqlLabRestApi)
appbuilder.add_api(SqlLabPermalinkRestApi)
+ appbuilder.add_api(LogRestApi)
#
# Setup regular views
#
@@ -289,6 +290,14 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
category_label=__("Security"),
)
+ appbuilder.add_view(
+ ActionLogView,
+ "Action Logs",
+ label=__("Action Logs"),
+ category="Security",
+ category_label=__("Security"),
+ )
+
appbuilder.add_view(
DynamicPluginsView,
"Plugins",
@@ -369,19 +378,6 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
category="Manage",
menu_cond=lambda: feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"),
)
- appbuilder.add_api(LogRestApi)
- appbuilder.add_view(
- LogModelView,
- "Action Log",
- label=__("Action Log"),
- category="Security",
- category_label=__("Security"),
- icon="fa-list-ol",
- menu_cond=lambda: (
- self.config["FAB_ADD_SECURITY_VIEWS"]
- and self.config["SUPERSET_LOG_VIEW"]
- ),
- )
appbuilder.add_api(SecurityRestApi)
#
# Conditionally setup email views
diff --git a/superset/security/manager.py b/superset/security/manager.py
index 962e0d9c5c9..3eba380929b 100644
--- a/superset/security/manager.py
+++ b/superset/security/manager.py
@@ -263,7 +263,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
ADMIN_ONLY_VIEW_MENUS = {
"Access Requests",
- "Action Log",
+ "Action Logs",
"Log",
"List Users",
"UsersListView",
diff --git a/superset/views/log/api.py b/superset/views/log/api.py
index ffa3a860060..53a7e08d7f7 100644
--- a/superset/views/log/api.py
+++ b/superset/views/log/api.py
@@ -19,6 +19,7 @@ from typing import Any, Optional
from flask import current_app as app
from flask_appbuilder.api import expose, protect, rison, safe
from flask_appbuilder.hooks import before_request
+from flask_appbuilder.models.sqla.filters import FilterRelationOneToManyEqual
from flask_appbuilder.models.sqla.interface import SQLAInterface
import superset.models.core as models
@@ -45,7 +46,8 @@ class LogRestApi(LogMixin, BaseSupersetModelRestApi):
resource_name = "log"
allow_browser_login = True
list_columns = [
- "user.username",
+ "user",
+ "user_id",
"action",
"dttm",
"json",
@@ -55,6 +57,21 @@ class LogRestApi(LogMixin, BaseSupersetModelRestApi):
"duration_ms",
"referrer",
]
+ search_columns = [
+ "user",
+ "user_id",
+ "action",
+ "dttm",
+ "json",
+ "slice_id",
+ "dashboard_id",
+ "user_id",
+ "duration_ms",
+ "referrer",
+ ]
+ search_filters = {
+ "user": [FilterRelationOneToManyEqual],
+ }
show_columns = list_columns
page_size = 20
apispec_parameter_schemas = {
diff --git a/tests/integration_tests/log_model_view_tests.py b/superset/views/logs.py
similarity index 51%
rename from tests/integration_tests/log_model_view_tests.py
rename to superset/views/logs.py
index e347f39e9a4..39fe10d0a3e 100644
--- a/tests/integration_tests/log_model_view_tests.py
+++ b/superset/views/logs.py
@@ -14,24 +14,21 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from unittest.mock import patch
+from flask_appbuilder import permission_name
+from flask_appbuilder.api import expose
+from flask_appbuilder.security.decorators import has_access
-from superset.views.log.views import LogModelView
-from tests.integration_tests.base_tests import SupersetTestCase
-from tests.integration_tests.constants import ADMIN_USERNAME
+from superset.superset_typing import FlaskResponse
+
+from .base import BaseSupersetView
-class TestLogModelView(SupersetTestCase):
- def test_disabled(self):
- with patch.object(LogModelView, "is_enabled", return_value=False):
- self.login(ADMIN_USERNAME)
- uri = "/logmodelview/list/"
- rv = self.client.get(uri)
- self.assert404(rv)
+class ActionLogView(BaseSupersetView):
+ route_base = "/"
+ class_permission_name = "security"
- def test_enabled(self):
- with patch.object(LogModelView, "is_enabled", return_value=True):
- self.login(ADMIN_USERNAME)
- uri = "/logmodelview/list/"
- rv = self.client.get(uri)
- self.assert200(rv)
+ @expose("/actionlog/list")
+ @has_access
+ @permission_name("read")
+ def list(self) -> FlaskResponse:
+ return super().render_app_template()
diff --git a/tests/integration_tests/log_api_tests.py b/tests/integration_tests/log_api_tests.py
index ddec917cb4a..a08068defab 100644
--- a/tests/integration_tests/log_api_tests.py
+++ b/tests/integration_tests/log_api_tests.py
@@ -98,7 +98,7 @@ class TestLogApi(SupersetTestCase):
response = json.loads(rv.data.decode("utf-8"))
assert list(response["result"][0].keys()) == EXPECTED_COLUMNS
assert response["result"][0]["action"] == "some_action"
- assert response["result"][0]["user"] == {"username": "admin"}
+ assert response["result"][0]["user"]["username"] == "admin"
db.session.delete(log)
db.session.commit()
@@ -132,7 +132,7 @@ class TestLogApi(SupersetTestCase):
assert list(response["result"].keys()) == EXPECTED_COLUMNS
assert response["result"]["action"] == "some_action"
- assert response["result"]["user"] == {"username": "admin"}
+ assert response["result"]["user"]["username"] == "admin"
db.session.delete(log)
db.session.commit()