Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 53 additions & 8 deletions web/locales/en/plugin__monitoring-plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Grammatically awkward translation string.

"You don't have permissions to dashboard actions" reads unnaturally. Consider revising to something like "You don't have permissions to perform dashboard actions" or "You don't have permissions for dashboard actions".

🤖 Prompt for AI Agents
In `@web/locales/en/plugin__monitoring-plugin.json` at line 200, Update the
translation value for the key "You don't have permissions to dashboard actions"
to a grammatically correct phrase; locate the JSON entry with that exact key and
replace the value string with a clearer alternative such as "You don't have
permission to perform dashboard actions" (or "You don't have permission for
dashboard actions") so the key/value reads naturally in the locales file.

"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",
Comment on lines +169 to +229
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing translation key used in dashboard-action-modals.tsx.

In dashboard-action-modals.tsx line 376, the code uses t(`No namespace found for "${filter}"`) with string interpolation, but this key does not appear in the localization file. Only "No project found for \"{{filter}}\"" (line 193) exists. The modals file should either reuse the existing key with {{filter}} interpolation syntax or a new namespace-specific key should be added here.

🤖 Prompt for AI Agents
In `@web/locales/en/plugin__monitoring-plugin.json` around lines 169 - 229, The
code in dashboard-action-modals.tsx calls t(`No namespace found for
"${filter}"`) but that exact key is missing from plugin__monitoring-plugin.json;
either change the call to reuse the existing interpolated key t('No project
found for "{{filter}}"') (replace string interpolation with the i18n {{filter}}
syntax) or add a new JSON entry "No namespace found for \"{{filter}}\"": "No
namespace found for \"{{filter}}\"" to the locales file so t('No namespace found
for \"{{filter}}\"') will resolve; update the call site to use the same
{{filter}} placeholder rather than JS string interpolation.

"Refresh off": "Refresh off",
"{{count}} second_one": "{{count}} second",
"{{count}} second_other": "{{count}} seconds",
Expand All @@ -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",
Expand Down Expand Up @@ -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"
}
123 changes: 51 additions & 72 deletions web/src/components/dashboards/perses/dashboard-action-modals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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<string | null>(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 '';
Expand All @@ -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);

Expand All @@ -226,24 +217,29 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal
},
});

const selectedProjectName = form.watch('projectName');

const projectOptions = useMemo<TypeaheadSelectOption[]>(() => {
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;
Expand Down Expand Up @@ -295,18 +291,9 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal
form.reset();
};

const onProjectToggle = () => {
setIsProjectSelectOpen(!isProjectSelectOpen);
};

const onProjectSelect = (
_event: React.MouseEvent<Element, 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 (
Expand All @@ -318,10 +305,15 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal
aria-labelledby="duplicate-modal"
>
<ModalHeader title="Duplicate Dashboard" labelId="duplicate-modal-title" />
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing t() wrapper for modal title.

"Duplicate Dashboard" is a hardcoded string and won't be translated, unlike the RenameActionModal which uses t('Rename Dashboard') on line 120.

Suggested fix
-      <ModalHeader title="Duplicate Dashboard" labelId="duplicate-modal-title" />
+      <ModalHeader title={t('Duplicate Dashboard')} labelId="duplicate-modal-title" />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<ModalHeader title="Duplicate Dashboard" labelId="duplicate-modal-title" />
<ModalHeader title={t('Duplicate Dashboard')} labelId="duplicate-modal-title" />
🤖 Prompt for AI Agents
In `@web/src/components/dashboards/perses/dashboard-action-modals.tsx` at line
307, The modal title is hardcoded in the Duplicate Dashboard modal—replace the
literal "Duplicate Dashboard" passed to ModalHeader with a translated string
(e.g. title={t('Duplicate Dashboard')}) and ensure the translation function is
available in this component (import/use the existing useTranslation hook or the
component's t prop as used in RenameActionModal and other modals); update the
ModalHeader call in this duplicate-dashboard modal to use t('Duplicate
Dashboard') instead of the hardcoded string.

{persesProjectsLoading ? (
{permissionsLoading ? (
<ModalBody style={{ textAlign: 'center', padding: '2rem' }}>
{t('Loading...')} <Spinner aria-label="Duplicate Dashboard Modal Loading" />
</ModalBody>
) : permissionsError ? (
<ModalBody style={{ textAlign: 'center', padding: '2rem' }}>
<ExclamationCircleIcon />
{t('Failed to load project permissions. Please refresh the page and try again.')}
</ModalBody>
) : (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(processForm)}>
Expand Down Expand Up @@ -368,42 +360,28 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal
<Controller
control={form.control}
name="projectName"
render={({ field, fieldState }) => (
render={({ fieldState }) => (
<FormGroup
label={t('Select namespace')}
isRequired
fieldId="duplicate-modal-select-namespace-form-group"
style={formGroupStyle}
>
<LabelSpacer />
<Select
id="duplicate-modal-select-namespace-form-group-select"
isOpen={isProjectSelectOpen}
selected={field.value}
<TypeaheadSelect
key={selectedProject || 'no-selection'}
initialOptions={projectOptions}
placeholder={t('Select namespace')}
noOptionsFoundMessage={(filter) =>
t(`No namespace found for "${filter}"`)
}
onClearSelection={() => {
setSelectedProject(null);
}}
onSelect={onProjectSelect}
onOpenChange={setIsProjectSelectOpen}
toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
ref={toggleRef}
onClick={onProjectToggle}
isExpanded={isProjectSelectOpen}
isFullWidth
>
{selectedProjectDisplay}
</MenuToggle>
)}
>
<SelectList>
{filteredProjects.map((project) => (
<SelectOption
key={project.metadata.name}
value={project.metadata.name}
>
{getResourceDisplayName(project)}
</SelectOption>
))}
</SelectList>
</Select>
isCreatable={false}
maxMenuHeight="200px"
/>
Comment on lines +371 to +384
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Translation string uses JS template literal instead of i18next interpolation — translation will never match.

Line 376 uses t(`No namespace found for "${filter}"`) which embeds the runtime filter value into the translation key. This means i18next will look up a key like No namespace found for "myInput" which won't exist. Use i18next interpolation syntax instead:

Suggested fix
-                          noOptionsFoundMessage={(filter) =>
-                            t(`No namespace found for "${filter}"`)
-                          }
+                          noOptionsFoundMessage={(filter) =>
+                            t('No project found for "{{filter}}"', { filter })
+                          }

This also aligns the key with the existing localization entry "No project found for \"{{filter}}\"" in the JSON file. If a namespace-specific key is desired, add it to the locale file with {{filter}} interpolation syntax.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<TypeaheadSelect
key={selectedProject || 'no-selection'}
initialOptions={projectOptions}
placeholder={t('Select namespace')}
noOptionsFoundMessage={(filter) =>
t(`No namespace found for "${filter}"`)
}
onClearSelection={() => {
setSelectedProject(null);
}}
onSelect={onProjectSelect}
onOpenChange={setIsProjectSelectOpen}
toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
ref={toggleRef}
onClick={onProjectToggle}
isExpanded={isProjectSelectOpen}
isFullWidth
>
{selectedProjectDisplay}
</MenuToggle>
)}
>
<SelectList>
{filteredProjects.map((project) => (
<SelectOption
key={project.metadata.name}
value={project.metadata.name}
>
{getResourceDisplayName(project)}
</SelectOption>
))}
</SelectList>
</Select>
isCreatable={false}
maxMenuHeight="200px"
/>
<TypeaheadSelect
key={selectedProject || 'no-selection'}
initialOptions={projectOptions}
placeholder={t('Select namespace')}
noOptionsFoundMessage={(filter) =>
t('No project found for "{{filter}}"', { filter })
}
onClearSelection={() => {
setSelectedProject(null);
}}
onSelect={onProjectSelect}
isCreatable={false}
maxMenuHeight="200px"
/>
🤖 Prompt for AI Agents
In `@web/src/components/dashboards/perses/dashboard-action-modals.tsx` around
lines 371 - 384, The translation call inside the TypeaheadSelect's
noOptionsFoundMessage currently uses a JS template literal (t(`No namespace
found for "${filter}"`)) which prevents i18next interpolation; change it to use
i18next interpolation syntax by passing a translation key string and an
interpolation object (e.g., t('No namespace found for "{{filter}}"', { filter
})) in the noOptionsFoundMessage prop so i18next can match the locale entry and
substitute the runtime filter value; update the TypeaheadSelect usage where
noOptionsFoundMessage is defined to use t('No namespace found for "{{filter}}"',
{ filter }) instead of the template literal.

Comment on lines +375 to +384
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

onClearSelection does not reset the form value for projectName.

When the user clears the TypeaheadSelect, selectedProject is set to null but the form's projectName field retains its old value. This leaves the form in an inconsistent state — validation may pass with a stale project, or the submit button may remain enabled when no project is visually selected.

Suggested fix
                          onClearSelection={() => {
                            setSelectedProject(null);
+                           form.setValue('projectName', '');
                          }}
🤖 Prompt for AI Agents
In `@web/src/components/dashboards/perses/dashboard-action-modals.tsx` around
lines 375 - 384, When handling onClearSelection for the TypeaheadSelect, after
calling setSelectedProject(null) also clear the form's projectName so the form
state and UI stay consistent; update the onClearSelection handler in
dashboard-action-modals.tsx to call the form API (e.g.
setValue('projectName','') or resetField('projectName')) and clear validation
(clearErrors('projectName')) in addition to setSelectedProject(null) so the form
value, errors, and selectedProject are all cleared together.

{fieldState.error && (
<FormHelperText>
<HelperText>
Expand All @@ -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}
Expand Down
36 changes: 35 additions & 1 deletion web/src/components/dashboards/perses/dashboard-api.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -121,3 +122,36 @@ export function useDashboardList(
...options,
});
}

export const createPersesProject = async (projectName: string): Promise<ProjectResource> => {
const createProjectURL = '/api/v1/projects';
const persesURL = `${PERSES_PROXY_BASE_PATH}${createProjectURL}`;

const newProject: Partial<ProjectResource> = {
kind: 'Project',
metadata: {
name: projectName,
version: 0,
},
spec: {
display: {
name: projectName,
},
},
};

return consoleFetchJSON.post(persesURL, newProject);
};

export const useCreateProjectMutation = (): UseMutationResult<ProjectResource, Error, string> => {
const queryClient = useQueryClient();

return useMutation<ProjectResource, Error, string>({
mutationKey: ['projects'],
mutationFn: createPersesProject,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
queryClient.invalidateQueries({ queryKey: [resource] });
},
});
};
Loading