Add stack list, new stack, edit stack
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 */}
|
||||
<Route caseSensitive path={ROUTES.DataSources.replace(ROUTES.Base, '')} element={<DataSourcesListPage />} />
|
||||
<Route caseSensitive path={ROUTES.DataSourcesNew.replace(ROUTES.Base, '')} element={<NewDataSourcePage />} />
|
||||
<Route caseSensitive path={ROUTES.Stacks.replace(ROUTES.Base, '')} element={<DataSourceStacksPage />} />
|
||||
<Route caseSensitive path={ROUTES.StacksNew.replace(ROUTES.Base, '')} element={<NewStackPage />} />
|
||||
<Route caseSensitive path={ROUTES.StacksEdit.replace(ROUTES.Base, '')} element={<EditStackPage />} />
|
||||
<Route
|
||||
caseSensitive
|
||||
path={ROUTES.DataSourcesDetails.replace(ROUTES.Base, '')}
|
||||
|
||||
@@ -75,5 +75,11 @@ export function getOssCardData(): CardData[] {
|
||||
url: '/connections/datasources',
|
||||
icon: 'database',
|
||||
},
|
||||
{
|
||||
text: 'Stacks',
|
||||
subTitle: 'Manage your data source stacks',
|
||||
url: '/connections/stacks',
|
||||
icon: 'layers',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ export const ROUTES = {
|
||||
DataSourcesNew: `/${ROUTE_BASE_ID}/datasources/new`,
|
||||
DataSourcesEdit: `/${ROUTE_BASE_ID}/datasources/edit/:uid`,
|
||||
DataSourcesDashboards: `/${ROUTE_BASE_ID}/datasources/edit/:uid/dashboards`,
|
||||
// Stacks
|
||||
Stacks: `/${ROUTE_BASE_ID}/stacks`,
|
||||
StacksNew: `/${ROUTE_BASE_ID}/stacks/new`,
|
||||
StacksEdit: `/${ROUTE_BASE_ID}/stacks/edit/:uid`,
|
||||
|
||||
// Add new connection
|
||||
AddNewConnection: `/${ROUTE_BASE_ID}/add-new-connection`,
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import {
|
||||
Card,
|
||||
EmptyState,
|
||||
FilterInput,
|
||||
IconButton,
|
||||
LinkButton,
|
||||
Spinner,
|
||||
Stack,
|
||||
TagList,
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { ScopedResourceClient } from 'app/features/apiserver/client';
|
||||
import { Resource, ResourceList, GroupVersionResource } from 'app/features/apiserver/types';
|
||||
|
||||
// Define the DataSourceStack spec type based on the backend Go types
|
||||
export interface DataSourceStackTemplateItem {
|
||||
group: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DataSourceStackModeItem {
|
||||
dataSourceRef: string;
|
||||
}
|
||||
|
||||
export interface DataSourceStackModeSpec {
|
||||
name: string;
|
||||
uid: string;
|
||||
definition: Record<string, DataSourceStackModeItem>;
|
||||
}
|
||||
|
||||
export interface DataSourceStackSpec {
|
||||
template: Record<string, DataSourceStackTemplateItem>;
|
||||
modes: DataSourceStackModeSpec[];
|
||||
}
|
||||
|
||||
// GroupVersionResource for datasourcestacks
|
||||
const datasourceStacksGVR: GroupVersionResource = {
|
||||
group: 'collections.grafana.app',
|
||||
version: 'v1alpha1',
|
||||
resource: 'datasourcestacks',
|
||||
};
|
||||
|
||||
const datasourceStacksClient = new ScopedResourceClient<DataSourceStackSpec>(datasourceStacksGVR);
|
||||
|
||||
export function DataSourceStacksPage() {
|
||||
const [stacks, setStacks] = useState<Array<Resource<DataSourceStackSpec>>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const fetchStacks = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response: ResourceList<DataSourceStackSpec> = 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 ? (
|
||||
<LinkButton variant="primary" icon="plus" href="/connections/stacks/new">
|
||||
<Trans i18nKey="connections.stacks-list-view.add-stack">Add stack</Trans>
|
||||
</LinkButton>
|
||||
) : 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 (
|
||||
<Page navId="connections-datasources" pageNav={pageNav} actions={actions}>
|
||||
<Page.Contents>
|
||||
<DataSourceStacksListContent
|
||||
stacks={filteredStacks}
|
||||
loading={loading}
|
||||
error={error}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
onDeleteStack={onDeleteStack}
|
||||
styles={styles}
|
||||
/>
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataSourceStacksListContentProps {
|
||||
stacks: Array<Resource<DataSourceStackSpec>>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
styles: ReturnType<typeof getStyles>;
|
||||
onDeleteStack: (stackName: string) => () => Promise<void>;
|
||||
}
|
||||
|
||||
function DataSourceStacksListContent({
|
||||
stacks,
|
||||
loading,
|
||||
error,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
styles,
|
||||
onDeleteStack,
|
||||
}: DataSourceStacksListContentProps) {
|
||||
if (loading) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyState
|
||||
variant="not-found"
|
||||
message={t('connections.stacks-list-view.error', 'Failed to load data source stacks')}
|
||||
>
|
||||
<div>{error}</div>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
if (stacks.length === 0 && !searchQuery) {
|
||||
return (
|
||||
<EmptyState
|
||||
message={t(
|
||||
'connections.stacks-list-view.empty.no-rules-created',
|
||||
"You haven't created any data source stacks yet"
|
||||
)}
|
||||
variant="call-to-action"
|
||||
>
|
||||
<div>
|
||||
<Trans i18nKey="connections.stacks-list-view.empty.description">
|
||||
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.
|
||||
</Trans>
|
||||
</div>
|
||||
|
||||
<LinkButton variant="primary" icon="plus" size="lg" href="/connections/stacks/new">
|
||||
<Trans i18nKey="connections.stacks-list-view.empty.new-stack">New stack</Trans>
|
||||
</LinkButton>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={2}>
|
||||
<div className={styles.searchContainer}>
|
||||
<FilterInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder={t('connections.stacks-list-view.search-placeholder', 'Search by name or type')}
|
||||
/>
|
||||
</div>
|
||||
{stacks.length === 0 && searchQuery ? (
|
||||
<EmptyState
|
||||
variant="not-found"
|
||||
message={t('connections.stacks-list-view.no-results', 'No data source stacks found')}
|
||||
/>
|
||||
) : (
|
||||
<ul className={styles.list}>
|
||||
{stacks.map((stack) => (
|
||||
<li key={stack.metadata.name}>
|
||||
<Card noMargin href={`/connections/stacks/edit/${stack.metadata.name}`}>
|
||||
<Card.Heading>{stack.metadata.name}</Card.Heading>
|
||||
<Card.Tags>
|
||||
<Stack direction="row" gap={2} alignItems="center">
|
||||
<TagList tags={getDatasourceList(stack.spec)} />
|
||||
<IconButton
|
||||
name="trash-alt"
|
||||
variant="destructive"
|
||||
aria-label={t('connections.stacks-list-view.delete-stack', 'Delete stack')}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDeleteStack(stack.metadata.name)();
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Card.Tags>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -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<DataSourceStackSpec>(datasourceStacksGVR);
|
||||
|
||||
export function EditStackPage() {
|
||||
const { uid } = useParams<{ uid: string }>();
|
||||
const [stack, setStack] = useState<Resource<DataSourceStackSpec> | null>(null);
|
||||
const [formValues, setFormValues] = useState<StackFormValues | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Page navId="connections-datasources" pageNav={pageNav}>
|
||||
<Page.Contents>
|
||||
<EditStackContent loading={loading} error={error} formValues={formValues} />
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditStackContentProps {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
formValues: StackFormValues | null;
|
||||
}
|
||||
|
||||
function EditStackContent({ loading, error, formValues }: EditStackContentProps) {
|
||||
if (loading) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyState
|
||||
variant="not-found"
|
||||
message={t('connections.edit-stack-page.error', 'Failed to load data source stack')}
|
||||
>
|
||||
<div>{error}</div>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
if (!formValues) {
|
||||
return (
|
||||
<EmptyState
|
||||
variant="not-found"
|
||||
message={t('connections.edit-stack-page.not-found', 'Data source stack not found')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <StackForm existing={formValues} />;
|
||||
}
|
||||
@@ -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 (
|
||||
<Page
|
||||
navId={'connections-datasources'}
|
||||
pageNav={{
|
||||
text: 'New Data Source Stack',
|
||||
subTitle: 'Add a new data source stack',
|
||||
active: true,
|
||||
}}
|
||||
>
|
||||
<Page.Contents>
|
||||
<StackForm />
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -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<StackFormValues>({
|
||||
mode: 'onSubmit',
|
||||
defaultValues: initialValues,
|
||||
shouldFocusError: true,
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = formAPI;
|
||||
|
||||
const submit = async (values: StackFormValues): Promise<void> => {
|
||||
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<StackFormValues> = () => {
|
||||
notifyApp.error('There are errors in the form. Please correct them and try again!');
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...formAPI}>
|
||||
<form onSubmit={(e) => e.preventDefault()} className={styles.form}>
|
||||
<div className={styles.contentOuter}>
|
||||
<Stack direction="column" gap={3}>
|
||||
{/* Step 1 - name */}
|
||||
<StackName />
|
||||
|
||||
{/* Step 2 - Templates */}
|
||||
<StackTemplate />
|
||||
|
||||
{/* Step 3 - Modes */}
|
||||
<StackModes />
|
||||
|
||||
{/* Actions */}
|
||||
<Stack direction="row" alignItems="center">
|
||||
<Button
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={handleSubmit((values) => submit(values), onInvalid)}
|
||||
disabled={isSubmitting}
|
||||
icon={isSubmitting ? 'spinner' : undefined}
|
||||
>
|
||||
<Trans i18nKey="datasources.stack-form.save">Save</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
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<string, string> = {};
|
||||
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<string, { dataSourceRef: string }> = {};
|
||||
|
||||
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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -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<StackFormSectionProps>) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.parent}>
|
||||
<FieldSet
|
||||
className={cx(fullWidth && styles.fullWidth)}
|
||||
label={
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Text variant="h3">
|
||||
{stepNo}. {title}
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<Stack direction="column">
|
||||
{description && <div className={styles.description}>{description}</div>}
|
||||
{children}
|
||||
</Stack>
|
||||
</FieldSet>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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),
|
||||
}),
|
||||
});
|
||||
@@ -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<StackFormValues>();
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'modes',
|
||||
});
|
||||
|
||||
const templates = watch('templates');
|
||||
|
||||
const hasTemplates = templates && templates.length > 0;
|
||||
|
||||
return (
|
||||
<StackFormSection
|
||||
stepNo={3}
|
||||
title={t('datasources.stack-modes.title', 'Add modes')}
|
||||
description={
|
||||
<Text variant="bodySmall" color="secondary">
|
||||
<Trans i18nKey="datasources.stack-modes.description">
|
||||
Define modes (e.g., dev, staging, prod) and select the actual datasources for each template entry.
|
||||
</Trans>
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Stack direction="column" gap={3}>
|
||||
{!hasTemplates && (
|
||||
<Text color="secondary" italic>
|
||||
<Trans i18nKey="datasources.stack-modes.no-templates">
|
||||
Add template sections first to define modes.
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{hasTemplates &&
|
||||
fields.map((field, index) => (
|
||||
<ModeSectionRow
|
||||
key={field.id}
|
||||
index={index}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
templates={templates}
|
||||
onRemove={() => remove(index)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasTemplates && (
|
||||
<Button type="button" variant="secondary" icon="plus" onClick={() => append(createEmptyMode())}>
|
||||
<Trans i18nKey="datasources.stack-modes.add-mode">Add mode</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</StackFormSection>
|
||||
);
|
||||
};
|
||||
|
||||
interface ModeSectionRowProps {
|
||||
index: number;
|
||||
register: ReturnType<typeof useFormContext<StackFormValues>>['register'];
|
||||
control: ReturnType<typeof useFormContext<StackFormValues>>['control'];
|
||||
errors: ReturnType<typeof useFormContext<StackFormValues>>['formState']['errors'];
|
||||
templates: StackFormValues['templates'];
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const ModeSectionRow = ({ index, register, control, errors, templates, onRemove }: ModeSectionRowProps) => {
|
||||
return (
|
||||
<Stack direction="column" gap={2}>
|
||||
<Stack direction="row" gap={2} alignItems="center">
|
||||
<Field
|
||||
noMargin
|
||||
label={t('datasources.stack-modes.mode-name-label', 'Mode name')}
|
||||
error={errors?.modes?.[index]?.name?.message}
|
||||
invalid={!!errors?.modes?.[index]?.name?.message}
|
||||
>
|
||||
<Input
|
||||
id={`modes.${index}.name`}
|
||||
width={30}
|
||||
{...register(`modes.${index}.name`, {
|
||||
required: {
|
||||
value: true,
|
||||
message: t('datasources.stack-modes.mode-name-required', 'Mode name is required'),
|
||||
},
|
||||
})}
|
||||
placeholder={t('datasources.stack-modes.mode-name-placeholder', 'e.g. production')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<IconButton
|
||||
name="trash-alt"
|
||||
variant="destructive"
|
||||
tooltip={t('datasources.stack-modes.remove-mode', 'Remove mode')}
|
||||
onClick={onRemove}
|
||||
aria-label={t('datasources.stack-modes.remove-mode', 'Remove mode')}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" gap={2} wrap="wrap">
|
||||
{templates.map((template) => (
|
||||
<Field
|
||||
noMargin
|
||||
key={template.name}
|
||||
label={template.name || t('datasources.stack-modes.unnamed-template', 'Unnamed template')}
|
||||
>
|
||||
<Controller
|
||||
name={`modes.${index}.datasources.${template.name}`}
|
||||
control={control}
|
||||
render={({ field: { ref, onChange, value, ...field } }) => (
|
||||
<DataSourcePicker
|
||||
{...field}
|
||||
current={value}
|
||||
onChange={(ds) => onChange(ds.uid)}
|
||||
noDefault={true}
|
||||
pluginId={template.type}
|
||||
placeholder={t('datasources.stack-modes.select-datasource', 'Select datasource')}
|
||||
width={30}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<StackFormValues>();
|
||||
|
||||
return (
|
||||
<StackFormSection
|
||||
stepNo={1}
|
||||
title={t('datasources.stack-name.title', 'Enter stack name')}
|
||||
description={
|
||||
<Text variant="bodySmall" color="secondary">
|
||||
<Trans i18nKey="datasources.stack-name.description">Enter a name to identify your stack.</Trans>
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Stack direction="column">
|
||||
<Field
|
||||
label={t('datasources.stack-name.label', 'Name')}
|
||||
error={errors?.name?.message}
|
||||
invalid={!!errors.name?.message}
|
||||
>
|
||||
<Input
|
||||
data-testid={selectors.components.AlertRules.ruleNameField}
|
||||
id="name"
|
||||
width={38}
|
||||
{...register('name', {
|
||||
required: {
|
||||
value: true,
|
||||
message: t('datasources.stack-name.required', 'Must enter a name'),
|
||||
},
|
||||
})}
|
||||
aria-label={t('datasources.stack-name.aria-label', 'name')}
|
||||
placeholder="example: LGTM"
|
||||
/>
|
||||
</Field>
|
||||
</Stack>
|
||||
</StackFormSection>
|
||||
);
|
||||
};
|
||||
@@ -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<StackFormValues>();
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'templates',
|
||||
});
|
||||
|
||||
return (
|
||||
<StackFormSection
|
||||
stepNo={2}
|
||||
title={t('datasources.stack-template.title', 'Add template sections')}
|
||||
description={
|
||||
<Text variant="bodySmall" color="secondary">
|
||||
<Trans i18nKey="datasources.stack-template.description">
|
||||
Add which datasource types comprise your stack and add names to reference them in the query editor.
|
||||
</Trans>
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Stack direction="column" gap={2}>
|
||||
{fields.map((field, index) => (
|
||||
<TemplateSectionRow
|
||||
key={field.id}
|
||||
index={index}
|
||||
register={register}
|
||||
control={control}
|
||||
errors={errors}
|
||||
onRemove={() => remove(index)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button type="button" variant="secondary" icon="plus" onClick={() => append(emptyTemplateSection)}>
|
||||
<Trans i18nKey="datasources.stack-template.add-section">Add datasource</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</StackFormSection>
|
||||
);
|
||||
};
|
||||
|
||||
interface TemplateSectionRowProps {
|
||||
index: number;
|
||||
register: ReturnType<typeof useFormContext<StackFormValues>>['register'];
|
||||
control: ReturnType<typeof useFormContext<StackFormValues>>['control'];
|
||||
errors: ReturnType<typeof useFormContext<StackFormValues>>['formState']['errors'];
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const TemplateSectionRow = ({ index, register, control, errors, onRemove }: TemplateSectionRowProps) => {
|
||||
const dataSourceOptions = getOptionDataSourceTypes();
|
||||
|
||||
return (
|
||||
<Stack direction="row" gap={2} alignItems="flex-start">
|
||||
<Field
|
||||
noMargin
|
||||
label={t('datasources.stack-template.name-label', 'Name')}
|
||||
error={errors?.templates?.[index]?.name?.message}
|
||||
invalid={!!errors?.templates?.[index]?.name?.message}
|
||||
>
|
||||
<Input
|
||||
id={`templates.${index}.name`}
|
||||
width={30}
|
||||
{...register(`templates.${index}.name`, {
|
||||
required: {
|
||||
value: true,
|
||||
message: t('datasources.stack-template.name-required', 'Name is required'),
|
||||
},
|
||||
})}
|
||||
placeholder={t('datasources.stack-template.name-placeholder', 'e.g. logs-datasource')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
noMargin
|
||||
label={t('datasources.stack-template.type-label', 'Data source type')}
|
||||
error={errors?.templates?.[index]?.type?.message}
|
||||
invalid={!!errors?.templates?.[index]?.type?.message}
|
||||
>
|
||||
<Controller
|
||||
name={`templates.${index}.type`}
|
||||
control={control}
|
||||
rules={{
|
||||
required: {
|
||||
value: true,
|
||||
message: t('datasources.stack-template.type-required', 'Type is required'),
|
||||
},
|
||||
}}
|
||||
render={({ field: { ref, onChange, ...field } }) => (
|
||||
<Combobox
|
||||
id={`templates.${index}.type`}
|
||||
width={30}
|
||||
options={dataSourceOptions}
|
||||
onChange={(option) => onChange(option?.value || '')}
|
||||
placeholder={t('datasources.stack-template.type-placeholder', 'Select type')}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<IconButton
|
||||
name="trash-alt"
|
||||
variant="destructive"
|
||||
tooltip={t('datasources.stack-template.remove-section', 'Remove section')}
|
||||
onClick={onRemove}
|
||||
aria-label={t('datasources.stack-template.remove-section', 'Remove section')}
|
||||
style={{ marginTop: '28px' }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface TemplateSection {
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ModeSection {
|
||||
name: string;
|
||||
/** template name to selected datasource UID */
|
||||
datasources: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface StackFormValues {
|
||||
name: string;
|
||||
templates: TemplateSection[];
|
||||
modes: ModeSection[];
|
||||
}
|
||||
Reference in New Issue
Block a user