diff --git a/pkg/api/api.go b/pkg/api/api.go index 88f432c206c..5d72ef642f8 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -150,6 +150,10 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/connections/datasources/edit/*", authorize(datasources.EditPageAccess), hs.Index) r.Get("/connections", authorize(datasources.ConfigurationPageAccess), hs.Index) r.Get("/connections/add-new-connection", authorize(datasources.ConfigurationPageAccess), hs.Index) + r.Get("/connections/stacks", authorize(datasources.ConfigurationPageAccess), hs.Index) + r.Get("/connections/stacks/new", authorize(datasources.ConfigurationPageAccess), hs.Index) + r.Get("/connections/stacks/edit/*", authorize(datasources.ConfigurationPageAccess), hs.Index) + // Plugin details pages r.Get("/connections/datasources/:id", middleware.CanAdminPlugins(hs.Cfg, hs.AccessControl), hs.Index) r.Get("/connections/datasources/:id/page/:page", middleware.CanAdminPlugins(hs.Cfg, hs.AccessControl), hs.Index) diff --git a/public/app/core/utils/navBarItem-translations.ts b/public/app/core/utils/navBarItem-translations.ts index 0ac5619e893..655cf87ce88 100644 --- a/public/app/core/utils/navBarItem-translations.ts +++ b/public/app/core/utils/navBarItem-translations.ts @@ -182,6 +182,8 @@ export function getNavTitle(navId: string | undefined) { return t('nav.connections.title', 'Connections'); case 'connections-add-new-connection': return t('nav.add-new-connections.title', 'Add new connection'); + case 'connections-stacks': + return t('nav.stacks.title', 'Stacks'); case 'standalone-plugin-page-/connections/collector': return t('nav.collector.title', 'Collector'); case 'connections-datasources': diff --git a/public/app/features/connections/Connections.tsx b/public/app/features/connections/Connections.tsx index 4d40c5684b7..7f1c4ec7e94 100644 --- a/public/app/features/connections/Connections.tsx +++ b/public/app/features/connections/Connections.tsx @@ -10,10 +10,13 @@ import { CacheFeatureHighlightPage } from './pages/CacheFeatureHighlightPage'; import ConnectionsHomePage from './pages/ConnectionsHomePage'; import { DataSourceDashboardsPage } from './pages/DataSourceDashboardsPage'; import { DataSourceDetailsPage } from './pages/DataSourceDetailsPage'; +import { DataSourceStacksPage } from './pages/DataSourceStacksPage'; import { DataSourcesListPage } from './pages/DataSourcesListPage'; import { EditDataSourcePage } from './pages/EditDataSourcePage'; +import { EditStackPage } from './pages/EditStackPage'; import { InsightsFeatureHighlightPage } from './pages/InsightsFeatureHighlightPage'; import { NewDataSourcePage } from './pages/NewDataSourcePage'; +import { NewStackPage } from './pages/NewStackPage'; import { PermissionsFeatureHighlightPage } from './pages/PermissionsFeatureHighlightPage'; function RedirectToAddNewConnection() { @@ -41,6 +44,9 @@ export default function Connections() { {/* The route paths need to be relative to the parent path (ROUTES.Base), so we need to remove that part */} } /> } /> + } /> + } /> + } /> ; +} + +export interface DataSourceStackSpec { + template: Record; + modes: DataSourceStackModeSpec[]; +} + +// GroupVersionResource for datasourcestacks +const datasourceStacksGVR: GroupVersionResource = { + group: 'collections.grafana.app', + version: 'v1alpha1', + resource: 'datasourcestacks', +}; + +const datasourceStacksClient = new ScopedResourceClient(datasourceStacksGVR); + +export function DataSourceStacksPage() { + const [stacks, setStacks] = useState>>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const styles = useStyles2(getStyles); + + const fetchStacks = useCallback(async () => { + try { + setLoading(true); + const response: ResourceList = await datasourceStacksClient.list(); + setStacks(response.items); + } catch (err) { + console.error('Failed to fetch datasource stacks:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch datasource stacks'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchStacks(); + }, [fetchStacks]); + + const onDeleteStack = (stackName: string) => async () => { + await datasourceStacksClient.delete(stackName, false); + fetchStacks(); + }; + + // Filter stacks based on search query + const filteredStacks = useMemo(() => { + if (!searchQuery) { + return stacks; + } + const query = searchQuery.toLowerCase(); + return stacks.filter((stack) => { + const nameMatch = stack.metadata.name?.toLowerCase().includes(query); + const templateMatch = Object.values(stack.spec.template).some( + (template) => template.name.toLowerCase().includes(query) || template.group.toLowerCase().includes(query) + ); + return nameMatch || templateMatch; + }); + }, [stacks, searchQuery]); + + const actions = + stacks.length > 0 ? ( + + Add stack + + ) : undefined; + + const pageNav = { + text: t('connections.stacks-list-view.title', 'Data source stacks'), + subTitle: t( + 'connections.stacks-list-view.subtitle', + 'Manage your data source stacks to group environments like dev, staging, and production' + ), + }; + + return ( + + + + + + ); +} + +interface DataSourceStacksListContentProps { + stacks: Array>; + loading: boolean; + error: string | null; + searchQuery: string; + setSearchQuery: (query: string) => void; + styles: ReturnType; + onDeleteStack: (stackName: string) => () => Promise; +} + +function DataSourceStacksListContent({ + stacks, + loading, + error, + searchQuery, + setSearchQuery, + styles, + onDeleteStack, +}: DataSourceStacksListContentProps) { + if (loading) { + return ; + } + + if (error) { + return ( + + {error} + + ); + } + if (stacks.length === 0 && !searchQuery) { + return ( + + + + Use data source stacks to group environments like dev, stg, and prod. Reference the stack in your query, and + Grafana automatically selects the right data source for that environment. + + + + + New stack + + + ); + } + + return ( + + + + + {stacks.length === 0 && searchQuery ? ( + + ) : ( + + {stacks.map((stack) => ( + + + {stack.metadata.name} + + + + { + e.preventDefault(); + e.stopPropagation(); + onDeleteStack(stack.metadata.name)(); + }} + /> + + + + + ))} + + )} + + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + searchContainer: css({ + marginBottom: theme.spacing(2), + maxWidth: '500px', + }), + list: css({ + listStyle: 'none', + display: 'grid', + gap: theme.spacing(1), + }), +}); + +const getDatasourceList = (stack: DataSourceStackSpec): string[] => { + return Array.from( + // remove duplicates + new Set( + Object.values(stack.template).map((template) => { + const match = template.group.match(/^grafana-(.+)-datasource$/); + if (match && match[1]) { + return match[1].charAt(0).toUpperCase() + match[1].slice(1); + } + return template.name.charAt(0).toUpperCase() + template.name.slice(1); + }) + ) + ); +}; diff --git a/public/app/features/connections/pages/EditStackPage.tsx b/public/app/features/connections/pages/EditStackPage.tsx new file mode 100644 index 00000000000..04fa8aae48b --- /dev/null +++ b/public/app/features/connections/pages/EditStackPage.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom-v5-compat'; + +import { t } from '@grafana/i18n'; +import { EmptyState, Spinner } from '@grafana/ui'; +import { Page } from 'app/core/components/Page/Page'; +import { ScopedResourceClient } from 'app/features/apiserver/client'; +import { Resource, GroupVersionResource } from 'app/features/apiserver/types'; +import { + StackForm, + transformStackSpecToFormValues, +} from 'app/features/datasources/components/new-stack-form/StackForm'; +import { StackFormValues } from 'app/features/datasources/components/new-stack-form/types'; + +import { DataSourceStackSpec } from './DataSourceStacksPage'; + +const datasourceStacksGVR: GroupVersionResource = { + group: 'collections.grafana.app', + version: 'v1alpha1', + resource: 'datasourcestacks', +}; + +const datasourceStacksClient = new ScopedResourceClient(datasourceStacksGVR); + +export function EditStackPage() { + const { uid } = useParams<{ uid: string }>(); + const [stack, setStack] = useState | null>(null); + const [formValues, setFormValues] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchStack = async () => { + if (!uid) { + setError('No stack UID provided'); + setLoading(false); + return; + } + + try { + setLoading(true); + const response = await datasourceStacksClient.get(uid); + setStack(response); + + const values = transformStackSpecToFormValues(response.metadata.name || '', response.spec); + setFormValues(values); + } catch (err) { + console.error('Failed to fetch datasource stack:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch datasource stack'); + } finally { + setLoading(false); + } + }; + + fetchStack(); + }, [uid]); + + const pageNav = { + text: stack?.metadata.name + ? t('connections.edit-stack-page.title-with-name', 'Edit {{name}}', { name: stack.metadata.name }) + : t('connections.edit-stack-page.title', 'Edit Data Source Stack'), + subTitle: t('connections.edit-stack-page.subtitle', 'Modify your data source stack configuration'), + }; + + return ( + + + + + + ); +} + +interface EditStackContentProps { + loading: boolean; + error: string | null; + formValues: StackFormValues | null; +} + +function EditStackContent({ loading, error, formValues }: EditStackContentProps) { + if (loading) { + return ; + } + + if (error) { + return ( + + {error} + + ); + } + + if (!formValues) { + return ( + + ); + } + + return ; +} diff --git a/public/app/features/connections/pages/NewStackPage.tsx b/public/app/features/connections/pages/NewStackPage.tsx new file mode 100644 index 00000000000..d264a450b23 --- /dev/null +++ b/public/app/features/connections/pages/NewStackPage.tsx @@ -0,0 +1,19 @@ +import { Page } from 'app/core/components/Page/Page'; +import { StackForm } from 'app/features/datasources/components/new-stack-form/StackForm'; + +export function NewStackPage() { + return ( + + + + + + ); +} diff --git a/public/app/features/datasources/components/new-stack-form/StackForm.tsx b/public/app/features/datasources/components/new-stack-form/StackForm.tsx new file mode 100644 index 00000000000..39f5e0172cf --- /dev/null +++ b/public/app/features/datasources/components/new-stack-form/StackForm.tsx @@ -0,0 +1,178 @@ +import { css } from '@emotion/css'; +import { useMemo } from 'react'; +import { FormProvider, SubmitErrorHandler, useForm } from 'react-hook-form'; +import { v4 as uuidv4 } from 'uuid'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { Button, Stack, useStyles2 } from '@grafana/ui'; +import { useAppNotification } from 'app/core/copy/appNotification'; +import { DataSourceStackSpec } from 'app/features/connections/pages/DataSourceStacksPage'; + +import { StackModes } from './StackModes'; +import { StackName } from './StackName'; +import { StackTemplate } from './StackTemplate'; +import { StackFormValues } from './types'; + +type Props = { + existing?: StackFormValues; +}; + +const defaultValues: StackFormValues = { + name: '', + templates: [], + modes: [], +}; + +export const StackForm = ({ existing }: Props) => { + const styles = useStyles2(getStyles); + const notifyApp = useAppNotification(); + + const initialValues: StackFormValues = useMemo(() => { + if (existing) { + return existing; + } + return defaultValues; + }, [existing]); + + const formAPI = useForm({ + mode: 'onSubmit', + defaultValues: initialValues, + shouldFocusError: true, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = formAPI; + + const submit = async (values: StackFormValues): Promise => { + const payload = prepareCreateStackPayload(values); + console.log('Form submitted with payload:', payload); + // TODO: Call API to save the stack using payload + notifyApp.success('Stack saved successfully!'); + }; + + const onInvalid: SubmitErrorHandler = () => { + notifyApp.error('There are errors in the form. Please correct them and try again!'); + }; + + return ( + + e.preventDefault()} className={styles.form}> + + + {/* Step 1 - name */} + + + {/* Step 2 - Templates */} + + + {/* Step 3 - Modes */} + + + {/* Actions */} + + submit(values), onInvalid)} + disabled={isSubmitting} + icon={isSubmitting ? 'spinner' : undefined} + > + Save + + + + + + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + form: css({ + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + }), + contentOuter: css({ + background: theme.colors.background.primary, + overflow: 'hidden', + maxWidth: theme.breakpoints.values.xl, + flex: 1, + }), +}); + +export const prepareCreateStackPayload = (formValues: StackFormValues): DataSourceStackSpec => { + // creates a mapping from template name to UUID + const templateNameToUuid: Record = {}; + formValues.templates.forEach((template) => { + templateNameToUuid[template.name] = uuidv4(); + }); + + // builds the template record with UUIDs as keys + const template: DataSourceStackSpec['template'] = {}; + formValues.templates.forEach((t) => { + const uuid = templateNameToUuid[t.name]; + template[uuid] = { + group: t.type, + name: t.name, + }; + }); + // uses template ids to build modes + const modes: DataSourceStackSpec['modes'] = formValues.modes.map((mode) => { + const definition: Record = {}; + + Object.entries(mode.datasources).forEach(([templateName, dataSourceUid]) => { + const uuid = templateNameToUuid[templateName]; + if (uuid) { + definition[uuid] = { dataSourceRef: dataSourceUid }; + } + }); + + return { + name: mode.name, + uid: uuidv4(), + definition, + }; + }); + + return { template, modes }; +}; + +//used when loading an existing stack for editing. +export const transformStackSpecToFormValues = (stackName: string, spec: DataSourceStackSpec): StackFormValues => { + const uuidToTemplateName: Record = {}; + Object.entries(spec.template).forEach(([uuid, templateItem]) => { + uuidToTemplateName[uuid] = templateItem.name; + }); + + const templates = Object.values(spec.template).map((templateItem) => ({ + name: templateItem.name, + type: templateItem.group, + })); + + const modes = spec.modes.map((mode) => { + const datasources: Record = {}; + + Object.entries(mode.definition).forEach(([uuid, modeItem]) => { + const templateName = uuidToTemplateName[uuid]; + if (templateName) { + datasources[templateName] = modeItem.dataSourceRef; + } + }); + + return { + name: mode.name, + datasources, + }; + }); + + return { + name: stackName, + templates, + modes, + }; +}; diff --git a/public/app/features/datasources/components/new-stack-form/StackFormSection.tsx b/public/app/features/datasources/components/new-stack-form/StackFormSection.tsx new file mode 100644 index 00000000000..f66ae732481 --- /dev/null +++ b/public/app/features/datasources/components/new-stack-form/StackFormSection.tsx @@ -0,0 +1,63 @@ +import { css, cx } from '@emotion/css'; +import * as React from 'react'; +import { ReactElement } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { FieldSet, Stack, Text, useStyles2 } from '@grafana/ui'; + +export interface StackFormSectionProps { + title: string; + stepNo: number; + description?: string | ReactElement; + fullWidth?: boolean; +} + +export const StackFormSection = ({ + title, + stepNo, + children, + fullWidth = false, + description, +}: React.PropsWithChildren) => { + const styles = useStyles2(getStyles); + + return ( + + + + {stepNo}. {title} + + + } + > + + {description && {description}} + {children} + + + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + parent: css({ + display: 'flex', + flexDirection: 'row', + border: `solid 1px ${theme.colors.border.weak}`, + borderRadius: theme.shape.radius.lg, + padding: `${theme.spacing(2)} ${theme.spacing(3)}`, + }), + description: css({ + marginTop: `-${theme.spacing(2)}`, + }), + fullWidth: css({ + width: '100%', + }), + reverse: css({ + flexDirection: 'row-reverse', + gap: theme.spacing(1), + }), +}); diff --git a/public/app/features/datasources/components/new-stack-form/StackModes.tsx b/public/app/features/datasources/components/new-stack-form/StackModes.tsx new file mode 100644 index 00000000000..fc01197b1e1 --- /dev/null +++ b/public/app/features/datasources/components/new-stack-form/StackModes.tsx @@ -0,0 +1,145 @@ +import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; + +import { Trans, t } from '@grafana/i18n'; +import { Button, Field, IconButton, Input, Stack, Text } from '@grafana/ui'; +import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; + +import { StackFormSection } from './StackFormSection'; +import { ModeSection, StackFormValues } from './types'; + +const createEmptyMode = (): ModeSection => ({ + name: '', + datasources: {}, +}); + +export const StackModes = () => { + const { + control, + register, + watch, + formState: { errors }, + } = useFormContext(); + + const { fields, append, remove } = useFieldArray({ + control, + name: 'modes', + }); + + const templates = watch('templates'); + + const hasTemplates = templates && templates.length > 0; + + return ( + + + Define modes (e.g., dev, staging, prod) and select the actual datasources for each template entry. + + + } + > + + {!hasTemplates && ( + + + Add template sections first to define modes. + + + )} + + {hasTemplates && + fields.map((field, index) => ( + remove(index)} + /> + ))} + + {hasTemplates && ( + append(createEmptyMode())}> + Add mode + + )} + + + ); +}; + +interface ModeSectionRowProps { + index: number; + register: ReturnType>['register']; + control: ReturnType>['control']; + errors: ReturnType>['formState']['errors']; + templates: StackFormValues['templates']; + onRemove: () => void; +} + +const ModeSectionRow = ({ index, register, control, errors, templates, onRemove }: ModeSectionRowProps) => { + return ( + + + + + + + + + + + {templates.map((template) => ( + + ( + onChange(ds.uid)} + noDefault={true} + pluginId={template.type} + placeholder={t('datasources.stack-modes.select-datasource', 'Select datasource')} + width={30} + /> + )} + /> + + ))} + + + ); +}; + diff --git a/public/app/features/datasources/components/new-stack-form/StackName.tsx b/public/app/features/datasources/components/new-stack-form/StackName.tsx new file mode 100644 index 00000000000..b9ee6571c52 --- /dev/null +++ b/public/app/features/datasources/components/new-stack-form/StackName.tsx @@ -0,0 +1,49 @@ +import { useFormContext } from 'react-hook-form'; + +import { selectors } from '@grafana/e2e-selectors'; +import { Trans, t } from '@grafana/i18n'; +import { Field, Input, Stack, Text } from '@grafana/ui'; + +import { StackFormSection } from './StackFormSection'; +import { StackFormValues } from './types'; + +export const StackName = () => { + const { + register, + formState: { errors }, + } = useFormContext(); + + return ( + + Enter a name to identify your stack. + + } + > + + + + + + + ); +}; diff --git a/public/app/features/datasources/components/new-stack-form/StackTemplate.tsx b/public/app/features/datasources/components/new-stack-form/StackTemplate.tsx new file mode 100644 index 00000000000..1dffcd5ed9c --- /dev/null +++ b/public/app/features/datasources/components/new-stack-form/StackTemplate.tsx @@ -0,0 +1,129 @@ +import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; + +import { Trans, t } from '@grafana/i18n'; +import { Button, Combobox, Field, IconButton, Input, Stack, Text } from '@grafana/ui'; +import { getOptionDataSourceTypes } from 'app/features/dashboard-scene/settings/variables/utils'; + +import { StackFormSection } from './StackFormSection'; +import { StackFormValues, TemplateSection } from './types'; + +const emptyTemplateSection: TemplateSection = { + name: '', + type: '', +}; + +export const StackTemplate = () => { + const { + control, + register, + formState: { errors }, + } = useFormContext(); + + const { fields, append, remove } = useFieldArray({ + control, + name: 'templates', + }); + + return ( + + + Add which datasource types comprise your stack and add names to reference them in the query editor. + + + } + > + + {fields.map((field, index) => ( + remove(index)} + /> + ))} + + append(emptyTemplateSection)}> + Add datasource + + + + ); +}; + +interface TemplateSectionRowProps { + index: number; + register: ReturnType>['register']; + control: ReturnType>['control']; + errors: ReturnType>['formState']['errors']; + onRemove: () => void; +} + +const TemplateSectionRow = ({ index, register, control, errors, onRemove }: TemplateSectionRowProps) => { + const dataSourceOptions = getOptionDataSourceTypes(); + + return ( + + + + + + + ( + onChange(option?.value || '')} + placeholder={t('datasources.stack-template.type-placeholder', 'Select type')} + {...field} + /> + )} + /> + + + + + ); +}; diff --git a/public/app/features/datasources/components/new-stack-form/types.ts b/public/app/features/datasources/components/new-stack-form/types.ts new file mode 100644 index 00000000000..f53737f73cf --- /dev/null +++ b/public/app/features/datasources/components/new-stack-form/types.ts @@ -0,0 +1,16 @@ +export interface TemplateSection { + name: string; + type: string; +} + +export interface ModeSection { + name: string; + /** template name to selected datasource UID */ + datasources: Record; +} + +export interface StackFormValues { + name: string; + templates: TemplateSection[]; + modes: ModeSection[]; +}