diff --git a/web/src/components/dashboards/perses/ToastProvider.tsx b/web/src/components/dashboards/perses/ToastProvider.tsx index 1f4c2642..7e4ac0a1 100644 --- a/web/src/components/dashboards/perses/ToastProvider.tsx +++ b/web/src/components/dashboards/perses/ToastProvider.tsx @@ -24,7 +24,7 @@ const ToastContext = createContext(undefined); export const useToast = () => { const context = useContext(ToastContext); if (!context) { - throw new Error('useAlerts must be used within AlertProvider'); + throw new Error('useToast must be used within ToastProvider'); } return context; }; diff --git a/web/src/components/dashboards/perses/dashboard-api.ts b/web/src/components/dashboards/perses/dashboard-api.ts index f167093b..bee69eaf 100644 --- a/web/src/components/dashboards/perses/dashboard-api.ts +++ b/web/src/components/dashboards/perses/dashboard-api.ts @@ -30,3 +30,27 @@ export const useUpdateDashboardMutation = (): UseMutationResult< }, }); }; + +const createDashboard = async (entity: DashboardResource): Promise => { + const url = buildURL({ + resource: resource, + project: entity.metadata.project, + }); + + return consoleFetchJSON.post(url, entity); +}; + +export const useCreateDashboardMutation = ( + onSuccess?: (data: DashboardResource, variables: DashboardResource) => Promise | unknown, +): UseMutationResult => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: [resource], + mutationFn: (dashboard) => createDashboard(dashboard), + onSuccess: onSuccess, + onSettled: () => { + return queryClient.invalidateQueries({ queryKey: [resource] }); + }, + }); +}; diff --git a/web/src/components/dashboards/perses/dashboard-app.tsx b/web/src/components/dashboards/perses/dashboard-app.tsx index 5adb0411..62b9e461 100644 --- a/web/src/components/dashboards/perses/dashboard-app.tsx +++ b/web/src/components/dashboards/perses/dashboard-app.tsx @@ -28,6 +28,7 @@ import { OCPDashboardToolbar } from './dashboard-toolbar'; import { useUpdateDashboardMutation } from './dashboard-api'; import { useTranslation } from 'react-i18next'; import { useToast } from './ToastProvider'; +import { useSearchParams } from 'react-router-dom-v5-compat'; export interface DashboardAppProps { dashboardResource: DashboardResource | EphemeralDashboardResource; @@ -81,6 +82,14 @@ export const OCPDashboardApp = (props: DashboardAppProps): ReactElement => { const { openDiscardChangesConfirmationDialog, closeDiscardChangesConfirmationDialog } = useDiscardChangesConfirmationDialog(); + const [searchParams] = useSearchParams(); + const isEdit = searchParams.get('edit'); + useEffect(() => { + if (isEdit === 'true') { + setEditMode(true); + } + }, [isEdit, setEditMode]); + const handleDiscardChanges = (): void => { // Reset to the original spec and exit edit mode if (originalDashboard) { diff --git a/web/src/components/dashboards/perses/dashboard-create-dialog.tsx b/web/src/components/dashboards/perses/dashboard-create-dialog.tsx new file mode 100644 index 00000000..0841bc90 --- /dev/null +++ b/web/src/components/dashboards/perses/dashboard-create-dialog.tsx @@ -0,0 +1,261 @@ +import { useEffect, useState } from 'react'; +import { + Button, + Dropdown, + DropdownList, + DropdownItem, + MenuToggle, + MenuToggleElement, + Modal, + ModalBody, + ModalHeader, + ModalFooter, + ModalVariant, + FormGroup, + Form, + TextInput, +} from '@patternfly/react-core'; +import { usePerses } from './hooks/usePerses'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom-v5-compat'; + +import { DashboardResource } from '@perses-dev/core'; +import { useCreateDashboardMutation } from './dashboard-api'; +import { createNewDashboard } from './dashboard-utils'; +import { useToast } from './ToastProvider'; +import { usePerspective, getDashboardUrl } from '../../hooks/usePerspective'; +import { t_color_red_50 } from '@patternfly/react-tokens'; +import { usePersesEditPermissions } from './dashboard-toolbar'; +import { persesDashboardDataTestIDs } from '../../data-test'; + +export const DashboardCreateDialog: React.FunctionComponent = () => { + const { t } = useTranslation(process.env.I18N_NAMESPACE); + const navigate = useNavigate(); + const { perspective } = usePerspective(); + const { addAlert } = useToast(); + const { canEdit, loading } = usePersesEditPermissions(); + const disabled = !canEdit; + + const { persesProjects } = usePerses(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [selectedProject, setSelectedProject] = useState(null); + const [dashboardName, setDashboardName] = useState(''); + const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({}); + + const createDashboardMutation = useCreateDashboardMutation(); + + useEffect(() => { + if (persesProjects && persesProjects.length > 0 && selectedProject === null) { + setSelectedProject(persesProjects[0].metadata.name); + } + }, [persesProjects, selectedProject]); + + const { persesProjectDashboards: dashboards } = usePerses(selectedProject || undefined); + + const warningColor = t_color_red_50.value; + + const handleSetDashboardName = (_event, dashboardName: string) => { + setDashboardName(dashboardName); + if (formErrors.dashboardName) { + setFormErrors((prev) => ({ ...prev, dashboardName: '' })); + } + }; + + const handleAdd = async () => { + setFormErrors({}); + + if (!selectedProject || !dashboardName.trim()) { + const errors: { [key: string]: string } = {}; + if (!selectedProject) errors.project = 'Project is required'; + if (!dashboardName.trim()) errors.dashboardName = 'Dashboard name is required'; + setFormErrors(errors); + return; + } + + try { + if ( + dashboards && + dashboards.some( + (d) => + d.metadata.project === selectedProject && + d.metadata.name.toLowerCase() === dashboardName.trim().toLowerCase(), + ) + ) { + setFormErrors({ + dashboardName: `Dashboard name "${dashboardName}" already exists in this project`, + }); + return; + } + + const newDashboard: DashboardResource = createNewDashboard( + dashboardName.trim(), + selectedProject as string, + ); + + const createdDashboard = await createDashboardMutation.mutateAsync(newDashboard); + + addAlert(`Dashboard "${dashboardName}" created successfully`, 'success'); + + 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}`); + + setIsModalOpen(false); + setDashboardName(''); + setFormErrors({}); + } catch (error) { + const errorMessage = error?.message || t('Failed to create dashboard. Please try again.'); + addAlert(`Error creating dashboard: ${errorMessage}`, 'danger'); + setFormErrors({ general: errorMessage }); + } + }; + + const handleModalToggle = () => { + setIsModalOpen(!isModalOpen); + setIsDropdownOpen(false); + if (isModalOpen) { + setDashboardName(''); + setFormErrors({}); + } + }; + + const handleDropdownToggle = () => { + setIsDropdownOpen(!isDropdownOpen); + }; + + const onFocus = () => { + const element = document.getElementById('modal-dropdown-toggle'); + (element as HTMLElement)?.focus(); + }; + + const onEscapePress = () => { + if (isDropdownOpen) { + setIsDropdownOpen(!isDropdownOpen); + onFocus(); + } else { + handleModalToggle(); + } + }; + + const onSelect = ( + event: React.MouseEvent | undefined, + value: string | number | undefined, + ) => { + setSelectedProject(typeof value === 'string' ? value : null); + setIsDropdownOpen(false); + onFocus(); + }; + + return ( + <> + + + + + {formErrors.general && ( +
+ {formErrors.general} +
+ )} +
{ + e.preventDefault(); + handleAdd(); + }} + > + + setIsDropdownOpen(isOpen)} + toggle={(toggleRef: React.Ref) => ( + + {selectedProject} + + )} + > + + {persesProjects.map((project, i) => ( + + {project.metadata.name} + + ))} + + + + + + {formErrors.dashboardName && ( +
+ {formErrors.dashboardName} +
+ )} +
+
+
+ + + + +
+ + ); +}; diff --git a/web/src/components/dashboards/perses/dashboard-frame.tsx b/web/src/components/dashboards/perses/dashboard-frame.tsx index 2d539e26..4cac6e07 100644 --- a/web/src/components/dashboards/perses/dashboard-frame.tsx +++ b/web/src/components/dashboards/perses/dashboard-frame.tsx @@ -1,11 +1,12 @@ import React, { ReactNode } from 'react'; import { DashboardEmptyState } from './emptystates/DashboardEmptyState'; -import { DashboardHeader } from './dashboard-skeleton'; +import { DashboardHeader } from './dashboard-header'; import { CombinedDashboardMetadata } from './hooks/useDashboardsData'; import { ProjectBar } from './project/ProjectBar'; import { PersesWrapper } from './PersesWrapper'; -import { Overview } from '@openshift-console/dynamic-plugin-sdk'; import { ToastProvider } from './ToastProvider'; +import { PageSection } from '@patternfly/react-core'; +import { t_global_spacer_sm } from '@patternfly/react-tokens'; interface DashboardFrameProps { activeProject: string | null; @@ -32,14 +33,24 @@ export const DashboardFrame: React.FC = ({ {activeProjectDashboardsMetadata?.length === 0 ? ( ) : ( - - {children} - + <> + + + {children} + + + )} diff --git a/web/src/components/dashboards/perses/dashboard-skeleton.tsx b/web/src/components/dashboards/perses/dashboard-header.tsx similarity index 53% rename from web/src/components/dashboards/perses/dashboard-skeleton.tsx rename to web/src/components/dashboards/perses/dashboard-header.tsx index 8b5bde05..ce51d8ff 100644 --- a/web/src/components/dashboards/perses/dashboard-skeleton.tsx +++ b/web/src/components/dashboards/perses/dashboard-header.tsx @@ -2,7 +2,7 @@ import type { FC, PropsWithChildren } from 'react'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; -import { PageSection, Stack, StackItem } from '@patternfly/react-core'; +import { Divider, Grid, GridItem, PageSection, Stack, StackItem } from '@patternfly/react-core'; import { DocumentTitle, ListPageHeader } from '@openshift-console/dynamic-plugin-sdk'; import { CombinedDashboardMetadata } from './hooks/useDashboardsData'; @@ -13,9 +13,22 @@ import { StringParam, useQueryParam } from 'use-query-params'; import { getDashboardsListUrl, usePerspective } from '../../hooks/usePerspective'; import { QueryParams } from '../../query-params'; -import { chart_color_blue_100, chart_color_blue_300 } from '@patternfly/react-tokens'; +import { + chart_color_blue_100, + chart_color_blue_300, + t_global_spacer_md, + t_global_spacer_sm, +} from '@patternfly/react-tokens'; import { listPersesDashboardsDataTestIDs } from '../../data-test'; import { usePatternFlyTheme } from '../../hooks/usePatternflyTheme'; +import { DashboardCreateDialog } from './dashboard-create-dialog'; + +const DASHBOARD_VIEW_PATH = 'v2/dashboards/view'; + +const shouldHideFavoriteButton = (): boolean => { + const currentUrl = window.location.href; + return currentUrl.includes(DASHBOARD_VIEW_PATH); +}; const DashboardBreadCrumb: React.FunctionComponent = () => { const { t } = useTranslation(process.env.I18N_NAMESPACE); @@ -29,11 +42,11 @@ const DashboardBreadCrumb: React.FunctionComponent = () => { navigate(getDashboardsListUrl(perspective)); }; - const patternflyBlue100 = chart_color_blue_100.value; + const lightThemeColor = chart_color_blue_100.value; - const patternflyBlue300 = chart_color_blue_300.value; + const darkThemeColor = chart_color_blue_300.value; - const linkColor = theme == 'dark' ? patternflyBlue100 : patternflyBlue300; + const linkColor = theme == 'dark' ? lightThemeColor : darkThemeColor; return ( @@ -43,6 +56,7 @@ const DashboardBreadCrumb: React.FunctionComponent = () => { cursor: 'pointer', color: linkColor, textDecoration: 'underline', + paddingLeft: t_global_spacer_md.value, }} data-test={listPersesDashboardsDataTestIDs.PersesBreadcrumbDashboardItem} > @@ -60,27 +74,53 @@ const DashboardBreadCrumb: React.FunctionComponent = () => { ); }; -const HeaderTop: FC = memo(() => { +const DashboardPageHeader: React.FunctionComponent = () => { const { t } = useTranslation(process.env.I18N_NAMESPACE); - - const currentUrl = window.location.href; - const hideFavBtn = currentUrl.includes('v2/dashboards/view'); + const hideFavBtn = shouldHideFavoriteButton(); return ( - + - - + + + ); -}); +}; + +const DashboardListPageHeader: React.FunctionComponent = () => { + const { t } = useTranslation(process.env.I18N_NAMESPACE); + const hideFavBtn = shouldHideFavoriteButton(); + + return ( + + + + + + + + + ); +}; type MonitoringDashboardsPageProps = PropsWithChildren<{ boardItems: CombinedDashboardMetadata[]; @@ -95,9 +135,38 @@ export const DashboardHeader: FC = memo(({ childr return ( <> {t('Metrics dashboards')} - - + + + + {children} + + ); +}); + +export const DashboardListHeader: FC = memo(({ children }) => { + const { t } = useTranslation(process.env.I18N_NAMESPACE); + + return ( + <> + {t('Metrics dashboards')} + + + {children} ); diff --git a/web/src/components/dashboards/perses/dashboard-list-frame.tsx b/web/src/components/dashboards/perses/dashboard-list-frame.tsx index e4999c56..298bbb71 100644 --- a/web/src/components/dashboards/perses/dashboard-list-frame.tsx +++ b/web/src/components/dashboards/perses/dashboard-list-frame.tsx @@ -1,8 +1,7 @@ import React, { ReactNode } from 'react'; -import { DashboardHeader } from './dashboard-skeleton'; +import { DashboardListHeader } from './dashboard-header'; import { CombinedDashboardMetadata } from './hooks/useDashboardsData'; import { ProjectBar } from './project/ProjectBar'; -import { Overview } from '@openshift-console/dynamic-plugin-sdk'; interface DashboardListFrameProps { activeProject: string | null; @@ -24,14 +23,14 @@ export const DashboardListFrame: React.FC = ({ return ( <> - - {children} - + {children} + ); }; diff --git a/web/src/components/dashboards/perses/dashboard-list-page.tsx b/web/src/components/dashboards/perses/dashboard-list-page.tsx index 542ea821..92c3391e 100644 --- a/web/src/components/dashboards/perses/dashboard-list-page.tsx +++ b/web/src/components/dashboards/perses/dashboard-list-page.tsx @@ -3,6 +3,7 @@ import { type FC } from 'react'; import { QueryParamProvider } from 'use-query-params'; import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5'; import { DashboardList } from './dashboard-list'; +import { ToastProvider } from './ToastProvider'; const queryClient = new QueryClient({ defaultOptions: { @@ -17,7 +18,9 @@ const DashboardListPage: FC = () => { return ( - + + + ); diff --git a/web/src/components/dashboards/perses/dashboard-page.tsx b/web/src/components/dashboards/perses/dashboard-page.tsx index 46fa88d1..00d5dee2 100644 --- a/web/src/components/dashboards/perses/dashboard-page.tsx +++ b/web/src/components/dashboards/perses/dashboard-page.tsx @@ -9,6 +9,7 @@ import { OCPDashboardApp } from './dashboard-app'; import { DashboardFrame } from './dashboard-frame'; import { ProjectEmptyState } from './emptystates/ProjectEmptyState'; import { useDashboardsData } from './hooks/useDashboardsData'; +import { ToastProvider } from './ToastProvider'; const queryClient = new QueryClient({ defaultOptions: { @@ -100,7 +101,9 @@ const DashboardPage: React.FC = () => { return ( - + + + ); diff --git a/web/src/components/dashboards/perses/dashboard-toolbar.tsx b/web/src/components/dashboards/perses/dashboard-toolbar.tsx index e7d691e4..47a28ef2 100644 --- a/web/src/components/dashboards/perses/dashboard-toolbar.tsx +++ b/web/src/components/dashboards/perses/dashboard-toolbar.tsx @@ -92,7 +92,7 @@ export const EditButton = ({ onClick }: EditButtonProps): ReactElement => { return button; }; -const usePersesEditPermissions = () => { +export const usePersesEditPermissions = () => { const { activeProject: namespace } = useActiveProject(); const [canCreate, createLoading] = useAccessReview({ group: 'perses.dev', diff --git a/web/src/components/dashboards/perses/dashboard-utils.ts b/web/src/components/dashboards/perses/dashboard-utils.ts new file mode 100644 index 00000000..b7727cb0 --- /dev/null +++ b/web/src/components/dashboards/perses/dashboard-utils.ts @@ -0,0 +1,33 @@ +import { DashboardResource } from '@perses-dev/core'; + +export const generateMetadataName = (name: string): string => { + return name + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .replace(/[^a-zA-Z0-9_.-]/g, '_'); +}; + +export const createNewDashboard = ( + dashboardName: string, + projectName: string, +): DashboardResource => { + return { + kind: 'Dashboard', + metadata: { + name: generateMetadataName(dashboardName), + project: projectName, + version: 0, + }, + spec: { + display: { + name: dashboardName, + }, + datasources: {}, + panels: {}, + layouts: [], + variables: [], + duration: '1h', + refreshInterval: '30s', + }, + }; +}; diff --git a/web/src/components/dashboards/perses/hooks/usePerses.ts b/web/src/components/dashboards/perses/hooks/usePerses.ts index 64343241..728e13e3 100644 --- a/web/src/components/dashboards/perses/hooks/usePerses.ts +++ b/web/src/components/dashboards/perses/hooks/usePerses.ts @@ -1,9 +1,14 @@ -import { fetchPersesProjects, fetchPersesDashboardsMetadata } from '../perses-client'; +import { + fetchPersesProjects, + fetchPersesDashboardsMetadata, + fetchPersesDashboardsByProject, +} from '../perses-client'; import { useQuery } from '@tanstack/react-query'; import { NumberParam, useQueryParam } from 'use-query-params'; import { QueryParams } from '../../../query-params'; +import { t } from 'i18next'; -export const usePerses = () => { +export const usePerses = (project?: string | number) => { const [refreshInterval] = useQueryParam(QueryParams.RefreshInterval, NumberParam); const { @@ -28,12 +33,34 @@ export const usePerses = () => { refetchInterval: refreshInterval, }); + const { + isLoading: persesProjectDashboardsLoading, + error: persesProjectDashboardsError, + data: persesProjectDashboards, + } = useQuery({ + queryKey: ['dashboards', 'project', project], + queryFn: () => { + if (project === undefined || project === null) { + throw new Error(t('Project is required for fetching project dashboards')); + } + return fetchPersesDashboardsByProject(String(project)); + }, + enabled: !!project, + refetchInterval: refreshInterval, + }); + return { + // All Dashboards persesDashboards: persesDashboards ?? [], persesDashboardsError, persesDashboardsLoading, + // All Projects persesProjectsLoading, persesProjects: persesProjects ?? [], persesProjectsError, + // Dashboards of a given project + persesProjectDashboards: persesProjectDashboards ?? [], + persesProjectDashboardsError, + persesProjectDashboardsLoading, }; }; diff --git a/web/src/components/dashboards/perses/perses-client.ts b/web/src/components/dashboards/perses/perses-client.ts index f1fae0b4..63bf526c 100644 --- a/web/src/components/dashboards/perses/perses-client.ts +++ b/web/src/components/dashboards/perses/perses-client.ts @@ -13,6 +13,13 @@ export const fetchPersesDashboardsMetadata = (): Promise => return ocpPersesFetchJson(persesURL); }; +export const fetchPersesDashboardsByProject = (project: string): Promise => { + const dashboardsEndpoint = `${PERSES_PROXY_BASE_PATH}/api/v1/dashboards`; + const persesURL = `${dashboardsEndpoint}?project=${encodeURIComponent(project)}`; + + return ocpPersesFetchJson(persesURL); +}; + export const fetchPersesProjects = (): Promise => { const listProjectURL = '/api/v1/projects'; const persesURL = `${PERSES_PROXY_BASE_PATH}${listProjectURL}`; diff --git a/web/src/components/data-test.ts b/web/src/components/data-test.ts index ec4f74f6..65ba3fe7 100644 --- a/web/src/components/data-test.ts +++ b/web/src/components/data-test.ts @@ -236,6 +236,7 @@ export const persesMUIDataTestIDs = { }; export const persesDashboardDataTestIDs = { + createDashboardButtonToolbar: 'create-dashboard-button-list-page', editDashboardButtonToolbar: 'edit-dashboard-button-toolbar', cancelButtonToolbar: 'cancel-button-toolbar', };