diff --git a/web/package-lock.json b/web/package-lock.json
index fb1a8839..88c266f2 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -53,6 +53,7 @@
"murmurhash-js": "1.0.x",
"react": "^17.0.1",
"react-dom": "^17.0.1",
+ "react-hook-form": "^7.66.0",
"react-i18next": "^11.8.11",
"react-linkify": "^0.2.2",
"react-modal": "^3.12.1",
diff --git a/web/package.json b/web/package.json
index c1e65cc0..a6a6a736 100644
--- a/web/package.json
+++ b/web/package.json
@@ -89,6 +89,7 @@
"murmurhash-js": "1.0.x",
"react": "^17.0.1",
"react-dom": "^17.0.1",
+ "react-hook-form": "^7.66.0",
"react-i18next": "^11.8.11",
"react-linkify": "^0.2.2",
"react-modal": "^3.12.1",
diff --git a/web/src/components/dashboards/perses/dashboard-action-modals.tsx b/web/src/components/dashboards/perses/dashboard-action-modals.tsx
new file mode 100644
index 00000000..8d7c5405
--- /dev/null
+++ b/web/src/components/dashboards/perses/dashboard-action-modals.tsx
@@ -0,0 +1,511 @@
+import {
+ Button,
+ Modal,
+ ModalBody,
+ ModalFooter,
+ ModalHeader,
+ FormGroup,
+ TextInput,
+ FormHelperText,
+ HelperText,
+ HelperTextItem,
+ ValidatedOptions,
+ HelperTextItemVariant,
+ ModalVariant,
+ AlertVariant,
+ Select,
+ SelectOption,
+ SelectList,
+ MenuToggle,
+ MenuToggleElement,
+ Stack,
+ StackItem,
+ Spinner,
+} from '@patternfly/react-core';
+import { ExclamationCircleIcon } from '@patternfly/react-icons';
+import React, { useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ useUpdateDashboardMutation,
+ useCreateDashboardMutation,
+ useDeleteDashboardMutation,
+} from './dashboard-api';
+import {
+ renameDashboardDialogValidationSchema,
+ RenameDashboardValidationType,
+ createDashboardDialogValidationSchema,
+ CreateDashboardValidationType,
+ useDashboardValidationSchema,
+} from './dashboard-action-validations';
+
+import { Controller, FormProvider, SubmitHandler, useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import {
+ DashboardResource,
+ getResourceDisplayName,
+ getResourceExtendedDisplayName,
+} from '@perses-dev/core';
+import { useToast } from './ToastProvider';
+import { usePerses } from './hooks/usePerses';
+import { generateMetadataName } from './dashboard-utils';
+import { useProjectPermissions } from './dashboard-permissions';
+import { t_global_spacer_200, t_global_font_weight_200 } from '@patternfly/react-tokens';
+import { useNavigate } from 'react-router-dom-v5-compat';
+import { usePerspective, getDashboardUrl } from '../../hooks/usePerspective';
+
+const formGroupStyle = {
+ fontWeight: t_global_font_weight_200.value,
+} as React.CSSProperties;
+
+const LabelSpacer = () => {
+ return
;
+};
+
+interface ActionModalProps {
+ dashboard: DashboardResource;
+ isOpen: boolean;
+ onClose: () => void;
+ handleModalClose: () => void;
+}
+
+export const RenameActionModal = ({ dashboard, isOpen, onClose }: ActionModalProps) => {
+ const { t } = useTranslation(process.env.I18N_NAMESPACE);
+ const { addAlert } = useToast();
+
+ const form = useForm({
+ resolver: zodResolver(renameDashboardDialogValidationSchema(t)),
+ mode: 'onBlur',
+ defaultValues: { dashboardName: dashboard ? getResourceDisplayName(dashboard) : '' },
+ });
+
+ const updateDashboardMutation = useUpdateDashboardMutation();
+
+ if (!dashboard) {
+ return null;
+ }
+
+ const processForm: SubmitHandler = (data) => {
+ if (dashboard.spec?.display) {
+ dashboard.spec.display.name = data.dashboardName;
+ } else {
+ dashboard.spec.display = { name: data.dashboardName };
+ }
+
+ updateDashboardMutation.mutate(dashboard, {
+ onSuccess: (updatedDashboard: DashboardResource) => {
+ const msg = t(
+ `Dashboard ${getResourceExtendedDisplayName(
+ updatedDashboard,
+ )} has been successfully updated`,
+ );
+ addAlert(msg, AlertVariant.success);
+ handleClose();
+ },
+ onError: (err) => {
+ const msg = t(`Could not rename dashboard. ${err}`);
+ addAlert(msg, AlertVariant.danger);
+ throw err;
+ },
+ });
+ };
+
+ const handleClose = () => {
+ onClose();
+ form.reset();
+ };
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModalProps) => {
+ const { t } = useTranslation(process.env.I18N_NAMESPACE);
+ const { addAlert } = useToast();
+
+ const navigate = useNavigate();
+ const { perspective } = usePerspective();
+ const [isProjectSelectOpen, setIsProjectSelectOpen] = useState(false);
+
+ const { persesProjects, persesProjectsLoading } = usePerses();
+
+ const hookInput = useMemo(() => {
+ return persesProjects || [];
+ }, [persesProjects]);
+
+ const { editableProjects } = useProjectPermissions(hookInput);
+
+ const filteredProjects = useMemo(() => {
+ return persesProjects.filter((project) => editableProjects.includes(project.metadata.name));
+ }, [persesProjects, editableProjects]);
+
+ const defaultProject = useMemo(() => {
+ if (!dashboard) return '';
+
+ if (dashboard.metadata.project && editableProjects.includes(dashboard.metadata.project)) {
+ return dashboard.metadata.project;
+ }
+
+ return filteredProjects[0]?.metadata.name || '';
+ }, [dashboard, editableProjects, filteredProjects]);
+
+ const { schema: validationSchema } = useDashboardValidationSchema(defaultProject, t);
+
+ const form = useForm({
+ resolver: validationSchema
+ ? zodResolver(validationSchema)
+ : zodResolver(createDashboardDialogValidationSchema(t)),
+ mode: 'onBlur',
+ defaultValues: {
+ projectName: defaultProject,
+ dashboardName: '',
+ },
+ });
+
+ const createDashboardMutation = useCreateDashboardMutation();
+
+ React.useEffect(() => {
+ if (isOpen && dashboard && filteredProjects.length > 0 && defaultProject) {
+ form.reset({
+ projectName: defaultProject,
+ dashboardName: '',
+ });
+ }
+ }, [isOpen, dashboard, defaultProject, filteredProjects.length, form]);
+
+ const selectedProjectName = form.watch('projectName');
+ const selectedProjectDisplay = useMemo(() => {
+ const selectedProject = filteredProjects.find((p) => p.metadata.name === selectedProjectName);
+ return selectedProject
+ ? getResourceDisplayName(selectedProject)
+ : selectedProjectName || t('Select project');
+ }, [filteredProjects, selectedProjectName, t]);
+
+ if (!dashboard) {
+ return null;
+ }
+
+ const processForm: SubmitHandler = (data) => {
+ const newDashboard: DashboardResource = {
+ ...dashboard,
+ metadata: {
+ ...dashboard.metadata,
+ name: generateMetadataName(data.dashboardName),
+ project: data.projectName,
+ },
+ spec: {
+ ...dashboard.spec,
+ display: {
+ ...dashboard.spec.display,
+ name: data.dashboardName,
+ },
+ },
+ };
+
+ createDashboardMutation.mutate(newDashboard, {
+ onSuccess: (createdDashboard: DashboardResource) => {
+ const msg = t(
+ `Dashboard ${getResourceExtendedDisplayName(
+ createdDashboard,
+ )} has been successfully created`,
+ );
+ addAlert(msg, AlertVariant.success);
+
+ handleClose();
+
+ const dashboardUrl = getDashboardUrl(perspective);
+ const dashboardParam = `dashboard=${createdDashboard.metadata.name}`;
+ const projectParam = `project=${createdDashboard.metadata.project}`;
+ const editModeParam = `edit=true`;
+ navigate(`${dashboardUrl}?${dashboardParam}&${projectParam}&${editModeParam}`);
+ },
+ onError: (err) => {
+ const msg = t(`Could not duplicate dashboard. ${err}`);
+ addAlert(msg, AlertVariant.danger);
+ },
+ });
+ };
+
+ const handleClose = () => {
+ onClose();
+ form.reset();
+ };
+
+ const onProjectToggle = () => {
+ setIsProjectSelectOpen(!isProjectSelectOpen);
+ };
+
+ const onProjectSelect = (
+ _event: React.MouseEvent | undefined,
+ value: string | number | undefined,
+ ) => {
+ if (typeof value === 'string') {
+ form.setValue('projectName', value);
+ setIsProjectSelectOpen(false);
+ }
+ };
+
+ return (
+
+
+ {persesProjectsLoading ? (
+
+ {t('Loading...')}
+
+ ) : (
+
+
+
+ )}
+
+ );
+};
+
+export const DeleteActionModal = ({ dashboard, isOpen, onClose }: ActionModalProps) => {
+ const { t } = useTranslation(process.env.I18N_NAMESPACE);
+ const { addAlert } = useToast();
+
+ const deleteDashboardMutation = useDeleteDashboardMutation();
+ const dashboardName = dashboard?.spec?.display?.name ?? t('this dashboard');
+
+ const handleDeleteConfirm = async () => {
+ if (!dashboard) return;
+
+ deleteDashboardMutation.mutate(dashboard, {
+ onSuccess: (deletedDashboard: DashboardResource) => {
+ const msg = t(
+ `Dashboard ${getResourceExtendedDisplayName(
+ deletedDashboard,
+ )} has been successfully deleted`,
+ );
+ addAlert(msg, AlertVariant.success);
+ onClose();
+ },
+ onError: (err) => {
+ const msg = t(`Could not delete dashboard. ${err}`);
+ addAlert(msg, AlertVariant.danger);
+ throw err;
+ },
+ });
+ };
+
+ return (
+
+
+
+ {t('Are you sure you want to delete ')}
+ {dashboardName}
+ {t('? This action can not be undone.')}
+
+
+
+
+
+
+ );
+};
diff --git a/web/src/components/dashboards/perses/dashboard-action-validations.ts b/web/src/components/dashboards/perses/dashboard-action-validations.ts
new file mode 100644
index 00000000..907362c6
--- /dev/null
+++ b/web/src/components/dashboards/perses/dashboard-action-validations.ts
@@ -0,0 +1,92 @@
+import { z } from 'zod';
+import { useMemo } from 'react';
+import { nameSchema } from '@perses-dev/core';
+import { useDashboardList } from './dashboard-api';
+import { generateMetadataName } from './dashboard-utils';
+
+export const createDashboardDisplayNameValidationSchema = (t: (key: string) => string) =>
+ z.string().min(1, t('Required')).max(75, t('Must be 75 or fewer characters long'));
+
+export const createDashboardDialogValidationSchema = (t?: (key: string) => string) =>
+ z.object({
+ projectName: nameSchema,
+ dashboardName: createDashboardDisplayNameValidationSchema(t),
+ });
+
+export const renameDashboardDialogValidationSchema = (t?: (key: string) => string) =>
+ z.object({
+ dashboardName: createDashboardDisplayNameValidationSchema(t),
+ });
+
+export type CreateDashboardValidationType = z.infer<
+ ReturnType
+>;
+export type RenameDashboardValidationType = z.infer<
+ ReturnType
+>;
+
+export interface DashboardValidationSchema {
+ schema?: z.ZodSchema;
+ isSchemaLoading: boolean;
+ hasSchemaError: boolean;
+}
+
+// Validate dashboard name and check if it doesn't already exist
+export function useDashboardValidationSchema(
+ projectName?: string,
+ t?: (key: string, options?: any) => string,
+): DashboardValidationSchema {
+ const {
+ data: dashboards,
+ isLoading: isDashboardsLoading,
+ isError,
+ } = useDashboardList({ project: projectName });
+ return useMemo((): DashboardValidationSchema => {
+ if (isDashboardsLoading)
+ return {
+ schema: undefined,
+ isSchemaLoading: true,
+ hasSchemaError: false,
+ };
+
+ if (isError) {
+ return {
+ hasSchemaError: true,
+ isSchemaLoading: false,
+ schema: undefined,
+ };
+ }
+
+ if (!dashboards?.length)
+ return {
+ schema: createDashboardDialogValidationSchema(t),
+ isSchemaLoading: true,
+ hasSchemaError: false,
+ };
+
+ const refinedSchema = createDashboardDialogValidationSchema(t).refine(
+ (schema) => {
+ return !(dashboards ?? []).some((dashboard) => {
+ return (
+ dashboard.metadata.project.toLowerCase() === schema.projectName.toLowerCase() &&
+ dashboard.metadata.name.toLowerCase() ===
+ generateMetadataName(schema.dashboardName).toLowerCase()
+ );
+ });
+ },
+ (schema) => ({
+ // eslint-disable-next-line max-len
+ message: t
+ ? t(`Dashboard name '{{dashboardName}}' already exists in '{{projectName}}' project!`, {
+ dashboardName: schema.dashboardName,
+ projectName: schema.projectName,
+ })
+ : // eslint-disable-next-line max-len
+ `Dashboard name '${schema.dashboardName}' already exists in '${schema.projectName}' project!`,
+ path: ['dashboardName'],
+ }),
+ );
+
+ return { schema: refinedSchema, isSchemaLoading: true, hasSchemaError: false };
+ }, [dashboards, isDashboardsLoading, isError, t]);
+}
diff --git a/web/src/components/dashboards/perses/dashboard-api.ts b/web/src/components/dashboards/perses/dashboard-api.ts
index bee69eaf..69a3b073 100644
--- a/web/src/components/dashboards/perses/dashboard-api.ts
+++ b/web/src/components/dashboards/perses/dashboard-api.ts
@@ -2,6 +2,8 @@ import { DashboardResource } from '@perses-dev/core';
import buildURL from './perses/url-builder';
import { useMutation, UseMutationResult, useQueryClient } from '@tanstack/react-query';
import { consoleFetchJSON } from '@openshift-console/dynamic-plugin-sdk';
+import { useQuery, UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
+import { StatusError } from '@perses-dev/core';
const resource = 'dashboards';
@@ -54,3 +56,68 @@ export const useCreateDashboardMutation = (
},
});
};
+
+const deleteDashboard = async (entity: DashboardResource): Promise => {
+ const url = buildURL({
+ resource: resource,
+ project: entity.metadata.project,
+ name: entity.metadata.name,
+ });
+
+ await consoleFetchJSON.delete(url);
+};
+
+export function useDeleteDashboardMutation(): UseMutationResult<
+ DashboardResource,
+ Error,
+ DashboardResource
+> {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationKey: [resource],
+ mutationFn: (entity: DashboardResource) => {
+ return deleteDashboard(entity).then(() => {
+ return entity;
+ });
+ },
+ onSuccess: (dashboard) => {
+ queryClient.removeQueries({
+ queryKey: [resource, dashboard.metadata.project, dashboard.metadata.name],
+ });
+ return queryClient.invalidateQueries({ queryKey: [resource] });
+ },
+ });
+}
+
+export const getDashboards = async (
+ project?: string,
+ metadataOnly: boolean = false,
+): Promise => {
+ const queryParams = new URLSearchParams();
+ if (metadataOnly) {
+ queryParams.set('metadata_only', 'true');
+ }
+ const url = buildURL({ resource: resource, project: project, queryParams: queryParams });
+
+ return consoleFetchJSON(url);
+};
+
+type DashboardListOptions = Omit<
+ UseQueryOptions,
+ 'queryKey' | 'queryFn'
+> & {
+ project?: string;
+ metadataOnly?: boolean;
+};
+
+export function useDashboardList(
+ options: DashboardListOptions,
+): UseQueryResult {
+ return useQuery({
+ queryKey: [resource, options.project, options.metadataOnly],
+ queryFn: () => {
+ return getDashboards(options.project, options.metadataOnly);
+ },
+ ...options,
+ });
+}
diff --git a/web/src/components/dashboards/perses/dashboard-create-dialog.tsx b/web/src/components/dashboards/perses/dashboard-create-dialog.tsx
index a64a4a50..803dbec8 100644
--- a/web/src/components/dashboards/perses/dashboard-create-dialog.tsx
+++ b/web/src/components/dashboards/perses/dashboard-create-dialog.tsx
@@ -21,7 +21,6 @@ import {
HelperTextItemVariant,
ValidatedOptions,
} from '@patternfly/react-core';
-import { useQuery } from '@tanstack/react-query';
import { ExclamationCircleIcon } from '@patternfly/react-icons';
import { usePerses } from './hooks/usePerses';
import { useTranslation } from 'react-i18next';
@@ -36,92 +35,7 @@ import { useToast } from './ToastProvider';
import { usePerspective, getDashboardUrl } from '../../hooks/usePerspective';
import { usePersesEditPermissions } from './dashboard-toolbar';
import { persesDashboardDataTestIDs } from '../../data-test';
-import { checkAccess } from '@openshift-console/dynamic-plugin-sdk';
-
-const checkProjectPermissions = async (projects: any[]): Promise => {
- if (!projects || projects.length === 0) {
- return [];
- }
-
- const editableProjectNames: string[] = [];
-
- for (const project of projects) {
- const projectName = project?.metadata?.name;
- if (!projectName) continue;
-
- try {
- const [createResult, updateResult, deleteResult] = await Promise.all([
- checkAccess({
- group: 'perses.dev',
- resource: 'persesdashboards',
- verb: 'create',
- namespace: projectName,
- }),
- checkAccess({
- group: 'perses.dev',
- resource: 'persesdashboards',
- verb: 'update',
- namespace: projectName,
- }),
- checkAccess({
- group: 'perses.dev',
- resource: 'persesdashboards',
- verb: 'delete',
- namespace: projectName,
- }),
- ]);
-
- const canEdit =
- createResult.status.allowed && updateResult.status.allowed && deleteResult.status.allowed;
-
- if (canEdit) {
- editableProjectNames.push(projectName);
- }
- } catch (error) {
- // eslint-disable-next-line no-console
- console.warn(`Failed to check permissions for project ${projectName}:`, error);
- }
- }
-
- return editableProjectNames;
-};
-
-const useProjectPermissions = (projects: any[]) => {
- const queryKey = useMemo(() => {
- if (!projects || projects.length === 0) {
- return ['project-permissions', 'empty'];
- }
-
- const projectFingerprint = projects.map((p) => ({
- name: p?.metadata?.name,
- version: p?.metadata?.version,
- updatedAt: p?.metadata?.updatedAt,
- }));
-
- return ['project-permissions', JSON.stringify(projectFingerprint)];
- }, [projects]);
-
- const {
- data: editableProjects = [],
- isLoading: loading,
- error,
- } = useQuery({
- queryKey,
- queryFn: () => checkProjectPermissions(projects),
- enabled: !!projects && projects.length > 0,
- staleTime: 5 * 60 * 1000,
- refetchOnWindowFocus: true,
- retry: 2,
- onError: (error) => {
- // eslint-disable-next-line no-console
- console.warn('Failed to check project permissions:', error);
- },
- });
-
- const hasEditableProject = editableProjects.length > 0;
-
- return { editableProjects, hasEditableProject, loading, error };
-};
+import { useProjectPermissions } from './dashboard-permissions';
export const DashboardCreateDialog: React.FunctionComponent = () => {
const { t } = useTranslation(process.env.I18N_NAMESPACE);
diff --git a/web/src/components/dashboards/perses/dashboard-frame.tsx b/web/src/components/dashboards/perses/dashboard-frame.tsx
index df33d350..f454345c 100644
--- a/web/src/components/dashboards/perses/dashboard-frame.tsx
+++ b/web/src/components/dashboards/perses/dashboard-frame.tsx
@@ -12,7 +12,7 @@ interface DashboardFrameProps {
setActiveProject: (project: string | null) => void;
activeProjectDashboardsMetadata: CombinedDashboardMetadata[];
changeBoard: (boardName: string) => void;
- dashboardName: string;
+ dashboardDisplayName: string;
children: ReactNode;
}
@@ -21,7 +21,7 @@ export const DashboardFrame: React.FC = ({
setActiveProject,
activeProjectDashboardsMetadata,
changeBoard,
- dashboardName,
+ dashboardDisplayName,
children,
}) => {
return (
@@ -36,7 +36,7 @@ export const DashboardFrame: React.FC = ({
{children}
diff --git a/web/src/components/dashboards/perses/dashboard-header.tsx b/web/src/components/dashboards/perses/dashboard-header.tsx
index dfe9e1e3..fc28a992 100644
--- a/web/src/components/dashboards/perses/dashboard-header.tsx
+++ b/web/src/components/dashboards/perses/dashboard-header.tsx
@@ -9,9 +9,7 @@ import { CombinedDashboardMetadata } from './hooks/useDashboardsData';
import { Breadcrumb, BreadcrumbItem } from '@patternfly/react-core';
import { useNavigate } from 'react-router-dom-v5-compat';
-import { StringParam, useQueryParam } from 'use-query-params';
import { getDashboardsListUrl, usePerspective } from '../../hooks/usePerspective';
-import { QueryParams } from '../../query-params';
import {
chart_color_blue_100,
@@ -31,11 +29,12 @@ const shouldHideFavoriteButton = (): boolean => {
return currentUrl.includes(DASHBOARD_VIEW_PATH);
};
-const DashboardBreadCrumb: React.FunctionComponent = () => {
+const DashboardBreadCrumb: React.FunctionComponent<{ dashboardDisplayName?: string }> = ({
+ dashboardDisplayName,
+}) => {
const { t } = useTranslation(process.env.I18N_NAMESPACE);
const { perspective } = usePerspective();
- const [dashboardName] = useQueryParam(QueryParams.Dashboard, StringParam);
const { theme } = usePatternFlyTheme();
const navigate = useNavigate();
@@ -63,26 +62,28 @@ const DashboardBreadCrumb: React.FunctionComponent = () => {
>
{t('Dashboards')}
- {dashboardName && (
+ {dashboardDisplayName && (
- {dashboardName}
+ {dashboardDisplayName}
)}
);
};
-const DashboardPageHeader: React.FunctionComponent = () => {
+const DashboardPageHeader: React.FunctionComponent<{ dashboardDisplayName?: string }> = ({
+ dashboardDisplayName,
+}) => {
const { t } = useTranslation(process.env.I18N_NAMESPACE);
const hideFavBtn = shouldHideFavoriteButton();
return (
-
+
{
type MonitoringDashboardsPageProps = PropsWithChildren<{
boardItems: CombinedDashboardMetadata[];
changeBoard: (dashboardName: string) => void;
- dashboardName: string;
+ dashboardDisplayName: string;
activeProject?: string;
}>;
-export const DashboardHeader: FC = memo(({ children }) => {
- const { t } = useTranslation(process.env.I18N_NAMESPACE);
-
- return (
- <>
- {t('Metrics dashboards')}
-
-
-
- {children}
- >
- );
-});
+export const DashboardHeader: FC = memo(
+ ({ children, dashboardDisplayName }) => {
+ const { t } = useTranslation(process.env.I18N_NAMESPACE);
+
+ return (
+ <>
+ {t('Metrics dashboards')}
+
+
+
+ {children}
+ >
+ );
+ },
+);
export const DashboardListHeader: FC = memo(({ children }) => {
const { t } = useTranslation(process.env.I18N_NAMESPACE);
diff --git a/web/src/components/dashboards/perses/dashboard-list-frame.tsx b/web/src/components/dashboards/perses/dashboard-list-frame.tsx
index 298bbb71..6bce52cc 100644
--- a/web/src/components/dashboards/perses/dashboard-list-frame.tsx
+++ b/web/src/components/dashboards/perses/dashboard-list-frame.tsx
@@ -26,7 +26,7 @@ export const DashboardListFrame: React.FC = ({
{children}
diff --git a/web/src/components/dashboards/perses/dashboard-list.tsx b/web/src/components/dashboards/perses/dashboard-list.tsx
index 3ce0ddbd..2154ab3e 100644
--- a/web/src/components/dashboards/perses/dashboard-list.tsx
+++ b/web/src/components/dashboards/perses/dashboard-list.tsx
@@ -1,4 +1,4 @@
-import React, { ReactNode, useMemo, type FC } from 'react';
+import React, { ReactNode, useCallback, useMemo, useState, type FC } from 'react';
import { useTranslation } from 'react-i18next';
import { useDashboardsData } from './hooks/useDashboardsData';
@@ -9,6 +9,7 @@ import {
EmptyStateVariant,
Pagination,
Title,
+ Tooltip,
} from '@patternfly/react-core';
import { DataView } from '@patternfly/react-data-view/dist/dynamic/DataView';
import { DataViewFilters } from '@patternfly/react-data-view/dist/dynamic/DataViewFilters';
@@ -21,18 +22,77 @@ import { DataViewTextFilter } from '@patternfly/react-data-view/dist/dynamic/Dat
import { DataViewToolbar } from '@patternfly/react-data-view/dist/dynamic/DataViewToolbar';
import { useDataViewFilters, useDataViewSort } from '@patternfly/react-data-view';
import { useDataViewPagination } from '@patternfly/react-data-view/dist/dynamic/Hooks';
-import { ThProps } from '@patternfly/react-table';
+import { ActionsColumn, ThProps } from '@patternfly/react-table';
import { Link, useSearchParams } from 'react-router-dom-v5-compat';
import { getDashboardUrl, usePerspective } from '../../hooks/usePerspective';
import { Timestamp } from '@openshift-console/dynamic-plugin-sdk';
import { listPersesDashboardsDataTestIDs } from '../../../components/data-test';
import { DashboardListFrame } from './dashboard-list-frame';
+import { usePersesEditPermissions } from './dashboard-toolbar';
+import { DashboardResource } from '@perses-dev/core';
+import {
+ DeleteActionModal,
+ DuplicateActionModal,
+ RenameActionModal,
+} from './dashboard-action-modals';
const perPageOptions = [
{ title: '10', value: 10 },
{ title: '20', value: 20 },
];
+const DashboardActionsCell = React.memo(
+ ({
+ project,
+ dashboard,
+ onRename,
+ onDuplicate,
+ onDelete,
+ emptyActions,
+ }: {
+ project: string;
+ dashboard: DashboardResource;
+ onRename: (dashboard: DashboardResource) => void;
+ onDuplicate: (dashboard: DashboardResource) => void;
+ onDelete: (dashboard: DashboardResource) => void;
+ emptyActions: any[];
+ }) => {
+ const { t } = useTranslation(process.env.I18N_NAMESPACE);
+ const { canEdit, loading } = usePersesEditPermissions(project);
+ const disabled = !canEdit;
+
+ const rowSpecificActions = useMemo(
+ () => [
+ {
+ title: t('Rename dashboard'),
+ onClick: () => onRename(dashboard),
+ },
+ {
+ title: t('Duplicate dashboard'),
+ onClick: () => onDuplicate(dashboard),
+ },
+ {
+ title: t('Delete dashboard'),
+ onClick: () => onDelete(dashboard),
+ },
+ ],
+ [dashboard, onRename, onDuplicate, onDelete, t],
+ );
+
+ if (disabled || loading) {
+ return (
+
+
+
+ );
+ }
+
+ return ;
+ },
+);
+
interface DashboardRowNameLink {
link: ReactNode;
label: string;
@@ -46,6 +106,8 @@ interface DashboardRow {
// Raw values for sorting
createdAt?: string;
updatedAt?: string;
+ // Reference to original dashboard data
+ dashboard: DashboardResource;
}
interface DashboardRowFilters {
@@ -87,14 +149,7 @@ const sortDashboardData = (
};
interface DashboardsTableProps {
- persesDashboards: Array<{
- metadata?: {
- name?: string;
- project?: string;
- createdAt?: string;
- updatedAt?: string;
- };
- }>;
+ persesDashboards: DashboardResource[];
persesDashboardsLoading: boolean;
activeProject: string | null;
}
@@ -154,6 +209,7 @@ const DashboardsTable: React.FunctionComponent = ({
}
return persesDashboards.map((board) => {
const metadata = board?.metadata;
+ const displayName = board?.spec?.display?.name;
const dashboardsParams = `?dashboard=${metadata?.name}&project=${metadata?.project}`;
const dashboardName: DashboardRowNameLink = {
link: (
@@ -161,10 +217,10 @@ const DashboardsTable: React.FunctionComponent = ({
to={`${dashboardBaseURL}${dashboardsParams}`}
data-test={`perseslistpage-${board?.metadata?.name}`}
>
- {metadata?.name}
+ {displayName}
),
- label: metadata?.name || '',
+ label: displayName || '',
};
return {
@@ -174,6 +230,7 @@ const DashboardsTable: React.FunctionComponent = ({
modified: ,
createdAt: metadata?.createdAt,
updatedAt: metadata?.updatedAt,
+ dashboard: board,
};
});
}, [dashboardBaseURL, persesDashboards, persesDashboardsLoading]);
@@ -198,14 +255,83 @@ const DashboardsTable: React.FunctionComponent = ({
[filteredData, sortBy, direction],
);
- const pageRows: DataViewTr[] = useMemo(
- () =>
- sortedAndFilteredData
- .slice((page - 1) * perPage, (page - 1) * perPage + perPage)
- .map(({ name, project, created, modified }) => [name.link, project, created, modified]),
- [page, perPage, sortedAndFilteredData],
+ const [targetedDashboard, setTargetedDashboard] = useState();
+ const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
+ const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false);
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+
+ const handleRenameModalOpen = useCallback((dashboard: DashboardResource) => {
+ setTargetedDashboard(dashboard);
+ setIsRenameModalOpen(true);
+ }, []);
+
+ const handleRenameModalClose = useCallback(() => {
+ setIsRenameModalOpen(false);
+ setTargetedDashboard(undefined);
+ }, []);
+
+ const handleDuplicateModalOpen = useCallback((dashboard: DashboardResource) => {
+ setTargetedDashboard(dashboard);
+ setIsDuplicateModalOpen(true);
+ }, []);
+
+ const handleDuplicateModalClose = useCallback(() => {
+ setIsDuplicateModalOpen(false);
+ setTargetedDashboard(undefined);
+ }, []);
+
+ const handleDeleteModalOpen = useCallback((dashboard: DashboardResource) => {
+ setTargetedDashboard(dashboard);
+ setIsDeleteModalOpen(true);
+ }, []);
+
+ const handleDeleteModalClose = useCallback(() => {
+ setIsDeleteModalOpen(false);
+ setTargetedDashboard(undefined);
+ }, []);
+
+ const emptyRowActions = useMemo(
+ () => [
+ {
+ title: t("You don't have permissions to dashboard actions"),
+ onClick: () => {},
+ },
+ ],
+ [t],
);
+ const pageRows: DataViewTr[] = useMemo(() => {
+ return sortedAndFilteredData
+ .slice((page - 1) * perPage, (page - 1) * perPage + perPage)
+ .map(({ name, project, created, modified, dashboard }) => [
+ name.link,
+ project,
+ created,
+ modified,
+ {
+ cell: (
+
+ ),
+ props: { isActionCell: true },
+ },
+ ]);
+ }, [
+ sortedAndFilteredData,
+ page,
+ perPage,
+ emptyRowActions,
+ handleRenameModalOpen,
+ handleDuplicateModalOpen,
+ handleDeleteModalOpen,
+ ]);
+
const PaginationTool = () => {
return (
= ({
}
/>
{hasData ? (
-
+ <>
+
+
+
+
+ >
) : (
{
setActiveProject(urlProject);
}
- // Change dashboard if provided in URL
- if (urlDashboard && urlDashboard !== dashboardName) {
- changeBoard(urlDashboard);
- }
+ useEffect(() => {
+ if (urlDashboard && urlDashboard !== dashboardName) {
+ changeBoard(urlDashboard);
+ }
+ }, [urlDashboard, dashboardName, changeBoard]);
if (combinedInitialLoad) {
return ;
@@ -81,7 +82,7 @@ const DashboardPage_: FC = () => {
setActiveProject={setActiveProject}
activeProjectDashboardsMetadata={activeProjectDashboardsMetadata}
changeBoard={changeBoard}
- dashboardName={currentDashboard.name}
+ dashboardDisplayName={currentDashboard.title}
>
=> {
+ if (!projects || projects.length === 0) {
+ return [];
+ }
+
+ const editableProjectNames: string[] = [];
+
+ for (const project of projects) {
+ const projectName = project?.metadata?.name;
+ if (!projectName) continue;
+
+ try {
+ const [createResult, updateResult, deleteResult] = await Promise.all([
+ checkAccess({
+ group: 'perses.dev',
+ resource: 'persesdashboards',
+ verb: 'create',
+ namespace: projectName,
+ }),
+ checkAccess({
+ group: 'perses.dev',
+ resource: 'persesdashboards',
+ verb: 'update',
+ namespace: projectName,
+ }),
+ checkAccess({
+ group: 'perses.dev',
+ resource: 'persesdashboards',
+ verb: 'delete',
+ namespace: projectName,
+ }),
+ ]);
+
+ const canEdit =
+ createResult.status.allowed && updateResult.status.allowed && deleteResult.status.allowed;
+
+ if (canEdit) {
+ editableProjectNames.push(projectName);
+ }
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.warn(`Failed to check permissions for project ${projectName}:`, error);
+ }
+ }
+
+ return editableProjectNames;
+};
+
+export const useProjectPermissions = (projects: any[]) => {
+ const queryKey = useMemo(() => {
+ if (!projects || projects.length === 0) {
+ return ['project-permissions', 'empty'];
+ }
+
+ const projectFingerprint = projects.map((p) => ({
+ name: p?.metadata?.name,
+ version: p?.metadata?.version,
+ updatedAt: p?.metadata?.updatedAt,
+ }));
+
+ return ['project-permissions', JSON.stringify(projectFingerprint)];
+ }, [projects]);
+
+ const {
+ data: editableProjects = [],
+ isLoading: loading,
+ error,
+ } = useQuery({
+ queryKey,
+ queryFn: async () => {
+ try {
+ return await checkProjectPermissions(projects);
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.warn('Failed to check project permissions:', error);
+ return [];
+ }
+ },
+ enabled: !!projects && projects.length > 0,
+ staleTime: 5 * 60 * 1000,
+ refetchOnWindowFocus: true,
+ retry: 2,
+ });
+
+ const hasEditableProject = editableProjects.length > 0;
+
+ return { editableProjects, hasEditableProject, loading, error };
+};
diff --git a/web/src/components/dashboards/perses/dashboard-utils.ts b/web/src/components/dashboards/perses/dashboard-utils.ts
index b7727cb0..2a8101ae 100644
--- a/web/src/components/dashboards/perses/dashboard-utils.ts
+++ b/web/src/components/dashboards/perses/dashboard-utils.ts
@@ -1,5 +1,9 @@
import { DashboardResource } from '@perses-dev/core';
+/**
+ * Generated a resource name valid for the API.
+ * By removing accents from alpha characters and replace specials character by underscores.
+ */
export const generateMetadataName = (name: string): string => {
return name
.normalize('NFD')
diff --git a/web/src/components/dashboards/perses/hooks/useDashboardsData.ts b/web/src/components/dashboards/perses/hooks/useDashboardsData.ts
index 7fc780e7..614093bb 100644
--- a/web/src/components/dashboards/perses/hooks/useDashboardsData.ts
+++ b/web/src/components/dashboards/perses/hooks/useDashboardsData.ts
@@ -1,4 +1,4 @@
-import { useMemo, useCallback } from 'react';
+import { useMemo, useCallback, useRef } from 'react';
import { DashboardResource } from '@perses-dev/core';
import { useNavigate } from 'react-router-dom-v5-compat';
@@ -39,13 +39,33 @@ export const useDashboardsData = () => {
return true;
}, [persesProjectsLoading, persesDashboardsLoading, initialPageLoad, setInitialPageLoadFalse]);
+ const prevDashboardsRef = useRef([]);
+ const prevMetadataRef = useRef([]);
+
// Homogenize data needed for dashboards dropdown between legacy and perses dashboards
// to enable both to use the same component
const combinedDashboardsMetadata = useMemo(() => {
if (combinedInitialLoad) {
return [];
}
- return persesDashboards.map((persesDashboard) => {
+
+ // Check if dashboards data has actually changed to avoid recreation
+ const dashboardsChanged =
+ persesDashboards.length !== prevDashboardsRef.current.length ||
+ persesDashboards.some((dashboard, i) => {
+ const prevDashboard = prevDashboardsRef.current[i];
+ return (
+ dashboard?.metadata?.name !== prevDashboard?.metadata?.name ||
+ dashboard?.spec?.display?.name !== prevDashboard?.spec?.display?.name ||
+ dashboard?.metadata?.project !== prevDashboard?.metadata?.project
+ );
+ });
+
+ if (!dashboardsChanged && prevMetadataRef.current.length > 0) {
+ return prevMetadataRef.current;
+ }
+
+ const newMetadata = persesDashboards.map((persesDashboard) => {
const name = persesDashboard?.metadata?.name;
const displayName = persesDashboard?.spec?.display?.name || name;
@@ -57,6 +77,10 @@ export const useDashboardsData = () => {
persesDashboard,
};
});
+
+ prevDashboardsRef.current = persesDashboards;
+ prevMetadataRef.current = newMetadata;
+ return newMetadata;
}, [persesDashboards, combinedInitialLoad]);
// Retrieve dashboard metadata for the currently selected project
diff --git a/web/src/components/dashboards/perses/hooks/usePerses.ts b/web/src/components/dashboards/perses/hooks/usePerses.ts
index 728e13e3..cc3f0f10 100644
--- a/web/src/components/dashboards/perses/hooks/usePerses.ts
+++ b/web/src/components/dashboards/perses/hooks/usePerses.ts
@@ -29,7 +29,7 @@ export const usePerses = (project?: string | number) => {
} = useQuery({
queryKey: ['dashboards'],
queryFn: fetchPersesDashboardsMetadata,
- enabled: true,
+ enabled: !project, // Only fetch all dashboards when no specific project is requested
refetchInterval: refreshInterval,
});
@@ -50,10 +50,11 @@ export const usePerses = (project?: string | number) => {
});
return {
- // All Dashboards
- persesDashboards: persesDashboards ?? [],
- persesDashboardsError,
- persesDashboardsLoading,
+ // All Dashboards - fallback to project dashboards when all dashboards query is disabled
+ persesDashboards: persesDashboards ?? persesProjectDashboards ?? [],
+ persesDashboardsError: persesDashboardsError ?? persesProjectDashboardsError,
+ persesDashboardsLoading:
+ persesDashboardsLoading || (!!project && persesProjectDashboardsLoading),
// All Projects
persesProjectsLoading,
persesProjects: persesProjects ?? [],