diff --git a/web/locales/en/plugin__monitoring-plugin.json b/web/locales/en/plugin__monitoring-plugin.json index de00f6f9..17b585f5 100644 --- a/web/locales/en/plugin__monitoring-plugin.json +++ b/web/locales/en/plugin__monitoring-plugin.json @@ -166,21 +166,67 @@ "Time range": "Time range", "Refresh interval": "Refresh interval", "Could not parse JSON data for dashboard \"{{dashboard}}\"": "Could not parse JSON data for dashboard \"{{dashboard}}\"", - "Dashboard Variables": "Dashboard Variables", + "Rename Dashboard": "Rename Dashboard", + "Dashboard name": "Dashboard name", + "Renaming...": "Renaming...", + "Rename": "Rename", + "Loading...": "Loading...", + "Failed to load project permissions. Please refresh the page and try again.": "Failed to load project permissions. Please refresh the page and try again.", + "Select namespace": "Select namespace", + "Duplicate": "Duplicate", + "this dashboard": "this dashboard", + "Permanently delete dashboard?": "Permanently delete dashboard?", + "Are you sure you want to delete ": "Are you sure you want to delete ", + "? This action can not be undone.": "? This action can not be undone.", + "Deleting...": "Deleting...", + "Delete": "Delete", + "Must be 75 or fewer characters long": "Must be 75 or fewer characters long", + "Dashboard name '{{dashboardName}}' already exists in '{{projectName}}' project!": "Dashboard name '{{dashboardName}}' already exists in '{{projectName}}' project!", + "Project is required": "Project is required", + "Dashboard name is required": "Dashboard name is required", + "Failed to create dashboard. Please try again.": "Failed to create dashboard. Please try again.", + "Create": "Create", + "You don't have permissions to create dashboards": "You don't have permissions to create dashboards", + "Create Dashboard": "Create Dashboard", + "Select project": "Select project", + "Select a project": "Select a project", + "No project found for \"{{filter}}\"": "No project found for \"{{filter}}\"", + "my-new-dashboard": "my-new-dashboard", + "Creating...": "Creating...", + "View and manage dashboards.": "View and manage dashboards.", + "Rename dashboard": "Rename dashboard", + "Duplicate dashboard": "Duplicate dashboard", + "Delete dashboard": "Delete dashboard", + "You don't have permissions to dashboard actions": "You don't have permissions to dashboard actions", + "Dashboard": "Dashboard", + "Project": "Project", + "Created on": "Created on", + "Last Modified": "Last Modified", + "Filter by name": "Filter by name", + "Filter by project": "Filter by project", + "No dashboards found": "No dashboards found", + "No results match the filter criteria. Clear filters to show results.": "No results match the filter criteria. Clear filters to show results.", + "No Perses dashboards are currently available in this project.": "No Perses dashboards are currently available in this project.", + "Clear all filters": "Clear all filters", + "Dashboard not found": "Dashboard not found", + "The dashboard \"{{name}}\" was not found in project \"{{project}}\".": "The dashboard \"{{name}}\" was not found in project \"{{project}}\".", + "Empty Dashboard": "Empty Dashboard", + "To get started add something to your dashboard": "To get started add something to your dashboard", + "Edit": "Edit", + "You don't have permission to edit this dashboard": "You don't have permission to edit this dashboard", "No matching datasource found": "No matching datasource found", "No Dashboard Available in Selected Project": "No Dashboard Available in Selected Project", "To explore data, create a dashboard for this project": "To explore data, create a dashboard for this project", "No Perses Project Available": "No Perses Project Available", "To explore data, create a Perses Project": "To explore data, create a Perses Project", - "Empty Dashboard": "Empty Dashboard", - "To get started add something to your dashboard": "To get started add something to your dashboard", + "Project is required for fetching project dashboards": "Project is required for fetching project dashboards", "No projects found": "No projects found", "No results match the filter criteria.": "No results match the filter criteria.", "Clear filters": "Clear filters", "Select project...": "Select project...", "Projects": "Projects", - "Project": "Project", - "Dashboard": "Dashboard", + "All Projects": "All Projects", + "useToast must be used within ToastProvider": "useToast must be used within ToastProvider", "Refresh off": "Refresh off", "{{count}} second_one": "{{count}} second", "{{count}} second_other": "{{count}} seconds", @@ -203,7 +249,7 @@ "Component(s)": "Component(s)", "Alert": "Alert", "Incidents": "Incidents", - "Clear all filters": "Clear all filters", + "Incident data is updated every few minutes. What you see may be up to 5 minutes old. Refresh the page to view updated information.": "Incident data is updated every few minutes. What you see may be up to 5 minutes old. Refresh the page to view updated information.", "Filter type selection": "Filter type selection", "Incident ID": "Incident ID", "Severity filters": "Severity filters", @@ -303,6 +349,5 @@ "No metrics targets found": "No metrics targets found", "Error loading latest targets data": "Error loading latest targets data", "Search by endpoint or namespace...": "Search by endpoint or namespace...", - "Text": "Text", - "Incident data is updated every few minutes. What you see may be up to 5 minutes old. Refresh the page to view updated information.":"Incident data is updated every few minutes. What you see may be up to 5 minutes old. Refresh the page to view updated information." + "Text": "Text" } \ No newline at end of file diff --git a/web/src/components/dashboards/perses/dashboard-action-modals.tsx b/web/src/components/dashboards/perses/dashboard-action-modals.tsx index 8d7c5405..d77b5b30 100644 --- a/web/src/components/dashboards/perses/dashboard-action-modals.tsx +++ b/web/src/components/dashboards/perses/dashboard-action-modals.tsx @@ -13,15 +13,11 @@ import { HelperTextItemVariant, ModalVariant, AlertVariant, - Select, - SelectOption, - SelectList, - MenuToggle, - MenuToggleElement, Stack, StackItem, Spinner, } from '@patternfly/react-core'; +import { TypeaheadSelect, TypeaheadSelectOption } from '@patternfly/react-templates'; import { ExclamationCircleIcon } from '@patternfly/react-icons'; import React, { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -46,9 +42,8 @@ import { 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 { useEditableProjects } from './hooks/useEditableProjects'; 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'; @@ -189,19 +184,15 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal const navigate = useNavigate(); const { perspective } = usePerspective(); - const [isProjectSelectOpen, setIsProjectSelectOpen] = useState(false); + const [selectedProject, setSelectedProject] = useState(null); - 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 { + editableProjects, + allProjects, + hasEditableProject, + permissionsLoading, + permissionsError, + } = useEditableProjects(); const defaultProject = useMemo(() => { if (!dashboard) return ''; @@ -210,8 +201,8 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal return dashboard.metadata.project; } - return filteredProjects[0]?.metadata.name || ''; - }, [dashboard, editableProjects, filteredProjects]); + return allProjects[0] || ''; + }, [dashboard, editableProjects, allProjects]); const { schema: validationSchema } = useDashboardValidationSchema(defaultProject, t); @@ -226,24 +217,29 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal }, }); + const selectedProjectName = form.watch('projectName'); + + const projectOptions = useMemo(() => { + if (!editableProjects) { + return []; + } + return editableProjects.map((project) => ({ + content: project, + value: project, + selected: project === selectedProjectName, + })); + }, [editableProjects, selectedProjectName]); + const createDashboardMutation = useCreateDashboardMutation(); React.useEffect(() => { - if (isOpen && dashboard && filteredProjects.length > 0 && defaultProject) { + if (isOpen && dashboard && editableProjects?.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]); + }, [isOpen, dashboard, defaultProject, editableProjects?.length, form]); if (!dashboard) { return null; @@ -295,18 +291,9 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal 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); - } + const onProjectSelect = (_event: any, selection: string) => { + form.setValue('projectName', selection); + setSelectedProject(selection); }; return ( @@ -318,10 +305,15 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal aria-labelledby="duplicate-modal" > - {persesProjectsLoading ? ( + {permissionsLoading ? ( {t('Loading...')} + ) : permissionsError ? ( + + + {t('Failed to load project permissions. Please refresh the page and try again.')} + ) : (
@@ -368,7 +360,7 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal ( + render={({ fieldState }) => ( - + isCreatable={false} + maxMenuHeight="200px" + /> {fieldState.error && ( @@ -430,6 +408,7 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal isDisabled={ !(form.watch('dashboardName') || '')?.trim() || !(form.watch('projectName') || '')?.trim() || + !hasEditableProject || createDashboardMutation.isPending } isLoading={createDashboardMutation.isPending} diff --git a/web/src/components/dashboards/perses/dashboard-api.ts b/web/src/components/dashboards/perses/dashboard-api.ts index 69a3b073..69cf97e8 100644 --- a/web/src/components/dashboards/perses/dashboard-api.ts +++ b/web/src/components/dashboards/perses/dashboard-api.ts @@ -1,9 +1,10 @@ -import { DashboardResource } from '@perses-dev/core'; +import { DashboardResource, ProjectResource } 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'; +import { PERSES_PROXY_BASE_PATH } from './perses-client'; const resource = 'dashboards'; @@ -121,3 +122,36 @@ export function useDashboardList( ...options, }); } + +export const createPersesProject = async (projectName: string): Promise => { + const createProjectURL = '/api/v1/projects'; + const persesURL = `${PERSES_PROXY_BASE_PATH}${createProjectURL}`; + + const newProject: Partial = { + kind: 'Project', + metadata: { + name: projectName, + version: 0, + }, + spec: { + display: { + name: projectName, + }, + }, + }; + + return consoleFetchJSON.post(persesURL, newProject); +}; + +export const useCreateProjectMutation = (): UseMutationResult => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['projects'], + mutationFn: createPersesProject, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['projects'] }); + queryClient.invalidateQueries({ queryKey: [resource] }); + }, + }); +}; diff --git a/web/src/components/dashboards/perses/dashboard-create-dialog.tsx b/web/src/components/dashboards/perses/dashboard-create-dialog.tsx index 803dbec8..6060c079 100644 --- a/web/src/components/dashboards/perses/dashboard-create-dialog.tsx +++ b/web/src/components/dashboards/perses/dashboard-create-dialog.tsx @@ -1,12 +1,7 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { Alert, Button, - Dropdown, - DropdownList, - DropdownItem, - MenuToggle, - MenuToggleElement, Modal, ModalBody, ModalHeader, @@ -20,71 +15,50 @@ import { HelperTextItem, HelperTextItemVariant, ValidatedOptions, + Tooltip, } from '@patternfly/react-core'; +import { TypeaheadSelect, TypeaheadSelectOption } from '@patternfly/react-templates'; import { ExclamationCircleIcon } from '@patternfly/react-icons'; import { usePerses } from './hooks/usePerses'; +import { useEditableProjects } from './hooks/useEditableProjects'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom-v5-compat'; -import { StringParam, useQueryParam } from 'use-query-params'; -import { QueryParams } from '../../query-params'; import { DashboardResource } from '@perses-dev/core'; -import { useCreateDashboardMutation } from './dashboard-api'; +import { useCreateDashboardMutation, useCreateProjectMutation } from './dashboard-api'; import { createNewDashboard } from './dashboard-utils'; import { useToast } from './ToastProvider'; import { usePerspective, getDashboardUrl } from '../../hooks/usePerspective'; -import { usePersesEditPermissions } from './dashboard-toolbar'; import { persesDashboardDataTestIDs } from '../../data-test'; -import { useProjectPermissions } from './dashboard-permissions'; export const DashboardCreateDialog: React.FunctionComponent = () => { const { t } = useTranslation(process.env.I18N_NAMESPACE); const navigate = useNavigate(); const { perspective } = usePerspective(); const { addAlert } = useToast(); - const { persesProjects } = usePerses(); - const [activeProjectFromUrl] = useQueryParam(QueryParams.Project, StringParam); + const { editableProjects, hasEditableProject, permissionsLoading, permissionsError } = + useEditableProjects(); 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(); + const createProjectMutation = useCreateProjectMutation(); + const { persesProjects } = usePerses(); - const { canEdit, loading } = usePersesEditPermissions(activeProjectFromUrl); - - const hookInput = useMemo(() => { - return persesProjects || []; - }, [persesProjects]); - - const { - editableProjects, - hasEditableProject, - loading: globalPermissionsLoading, - } = useProjectPermissions(hookInput); - - const disabled = activeProjectFromUrl ? !canEdit : !hasEditableProject; - - const filteredProjects = useMemo(() => { - return persesProjects.filter((project) => editableProjects.includes(project.metadata.name)); - }, [persesProjects, editableProjects]); - - useEffect(() => { - if ( - isModalOpen && - filteredProjects && - filteredProjects.length > 0 && - selectedProject === null - ) { - const projectToSelect = - activeProjectFromUrl && - filteredProjects.some((p) => p.metadata.name === activeProjectFromUrl) - ? activeProjectFromUrl - : filteredProjects[0].metadata.name; + const disabled = permissionsLoading || !hasEditableProject; - setSelectedProject(projectToSelect); + const projectOptions = useMemo(() => { + if (!editableProjects) { + return []; } - }, [isModalOpen, filteredProjects, selectedProject, activeProjectFromUrl]); + + return editableProjects.map((project) => ({ + content: project, + value: project, + selected: project === selectedProject, + })); + }, [editableProjects, selectedProject]); const { persesProjectDashboards: dashboards } = usePerses( isModalOpen && selectedProject ? selectedProject : undefined, @@ -123,6 +97,24 @@ export const DashboardCreateDialog: React.FunctionComponent = () => { return; } + const projectExists = persesProjects.some( + (project) => project.metadata?.name === selectedProject, + ); + + if (!projectExists) { + try { + await createProjectMutation.mutateAsync(selectedProject as string); + addAlert(`Project "${selectedProject}" created successfully`, 'success'); + } catch (projectError) { + const errorMessage = + projectError?.message || + `Failed to create project "${selectedProject}". Please try again.`; + addAlert(`Error creating project: ${errorMessage}`, 'danger'); + setFormErrors({ general: errorMessage }); + return; + } + } + const newDashboard: DashboardResource = createNewDashboard( dashboardName.trim(), selectedProject as string, @@ -150,50 +142,44 @@ export const DashboardCreateDialog: React.FunctionComponent = () => { 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(); - } + handleModalToggle(); }; - const onSelect = ( - event: React.MouseEvent | undefined, - value: string | number | undefined, - ) => { - setSelectedProject(typeof value === 'string' ? value : null); - setIsDropdownOpen(false); - onFocus(); + const onSelect = (_event: any, selection: string) => { + setSelectedProject(selection); }; - return ( - <> + const CreateBtn = () => { + return ( + ); + }; + + return ( + <> + {disabled ? ( + + + + + + ) : ( + + )} { > + {permissionsError && ( + + )} {formErrors.general && ( { isRequired fieldId="form-group-create-dashboard-dialog-project-selection" > - + t('No project found for "{{filter}}"', { filter }) + } + onClearSelection={() => { + setSelectedProject(null); + }} onSelect={onSelect} - onOpenChange={(isOpen: boolean) => setIsDropdownOpen(isOpen)} - toggle={(toggleRef: React.Ref) => ( - - {selectedProject} - - )} - > - - {filteredProjects.map((project, i) => ( - - {project.metadata.name} - - ))} - - + isCreatable={false} + maxMenuHeight="200px" + /> { variant="primary" onClick={handleAdd} isDisabled={ - !dashboardName?.trim() || !selectedProject || createDashboardMutation.isPending + !dashboardName?.trim() || + !selectedProject || + createDashboardMutation.isPending || + createProjectMutation.isPending } - isLoading={createDashboardMutation.isPending} + isLoading={createDashboardMutation.isPending || createProjectMutation.isPending} > - {createDashboardMutation.isPending ? t('Creating...') : t('Create')} + {createDashboardMutation.isPending || createProjectMutation.isPending + ? t('Creating...') + : t('Create')}