POC/Provisioning: Unify tags (#101218)

* Unify tags

* add both tags

* add tag types

* Check for the redirect only once

* Add fetch settings with delay hook

* Refetch settings

* Split hooks into separate files

* Cleanup

* Prettier

* Prettier

* Remove lint code

* Betterer

---------

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
Alex KhomenkoandRyan McKinley authored and GitHub committed 2025-02-25 19:06:47 +02:00
1 parent caf4b3d346
commit ff87355aba
18 files changed
+248 -232

No files matched your search

+10 -4
View File
@@ -5756,8 +5756,7 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"]
],
"public/app/features/provisioning/JobStatus.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
@@ -5870,8 +5869,7 @@ exports[`better eslint`] = {
],
"public/app/features/provisioning/SyncRepository.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/provisioning/TokenPermissionsInfo.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
@@ -5942,6 +5940,14 @@ exports[`better eslint`] = {
"public/app/features/provisioning/api/types.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/provisioning/hooks/index.ts:5381": [
[0, 0, 0, "Do not re-export imported variable (\`./useCreateOrUpdateRepositoryFile\`)", "0"],
[0, 0, 0, "Do not re-export imported variable (\`./useCreateOrUpdateRepository\`)", "1"],
[0, 0, 0, "Do not re-export imported variable (\`./useGetResourceRepository\`)", "2"],
[0, 0, 0, "Do not re-export imported variable (\`./usePullRequestParam\`)", "3"],
[0, 0, 0, "Do not re-export imported variable (\`./useRepositoryJobs\`)", "4"],
[0, 0, 0, "Do not re-export imported variable (\`./useRepositoryList\`)", "5"]
],
"public/app/features/query/components/QueryEditorRow.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
+5 -3
View File
@@ -25,8 +25,8 @@ func (b *APIBuilder) GetAPIRoutes() *builder.APIRoutes {
Spec: &spec3.PathProps{
Get: &spec3.Operation{
OperationProps: spec3.OperationProps{
OperationId: "getResourceStats", // used for RTK client
Tags: []string{"Provisioning"},
OperationId: "getResourceStats", // used for RTK client
Tags: []string{"Provisioning", "Repository"}, //includes stats for repositores and provisiong in general
Description: "Get resource stats for this namespace",
Parameters: []*spec3.Parameter{
{
@@ -72,7 +72,9 @@ func (b *APIBuilder) GetAPIRoutes() *builder.APIRoutes {
Get: &spec3.Operation{
OperationProps: spec3.OperationProps{
OperationId: "getFrontendSettings", // used for RTK client
Tags: []string{"Provisioning"},
//includes stats for repositores and provisiong in general
// This must include "Repository" so that the RTK client will invalidate when things are deleted
Tags: []string{"Provisioning", "Repository"},
Description: "Get the frontend settings for this namespace",
Parameters: []*spec3.Parameter{
{
@@ -2538,7 +2538,8 @@
"/apis/provisioning.grafana.app/v0alpha1/namespaces/{namespace}/settings": {
"get": {
"tags": [
"Provisioning"
"Provisioning",
"Repository"
],
"description": "Get the frontend settings for this namespace",
"operationId": "getFrontendSettings",
@@ -2570,7 +2571,8 @@
"/apis/provisioning.grafana.app/v0alpha1/namespaces/{namespace}/stats": {
"get": {
"tags": [
"Provisioning"
"Provisioning",
"Repository"
],
"description": "Get resource stats for this namespace",
"operationId": "getResourceStats",
@@ -64,7 +64,6 @@ export default function FileStatusPage() {
}
enum TabSelection {
Lint = 'lint',
File = 'file',
Existing = 'existing',
DryRun = 'dryRun',
@@ -146,65 +145,44 @@ function ResourceView({ wrap, repo, repoRef, tab }: Props) {
))}
</TabsBar>
<TabContent>
{tab === TabSelection.Lint && (
<div>
{wrap.lint ? (
wrap.lint.map((r, idx) => {
return (
//@ts-expect-error TODO fix lint response types
<Alert title={r.rule} severity={r.severity} key={`${r.rule}/${idx}`}>
{r.message}
</Alert>
);
})
) : (
<div>
<h3>No lint issues</h3>
</div>
)}
<div>
<div style={{ height: 800 }}>
<AutoSizer disableWidth>
{({ height }) => (
<CodeEditor
width="100%"
height={height}
language={'json'}
showLineNumbers={true}
showMiniMap={true}
value={jsonView}
onBlur={setJsonView}
onSave={setJsonView}
/>
)}
</AutoSizer>
</div>
)}
{tab !== TabSelection.Lint && (
<div>
<div style={{ height: 800 }}>
<AutoSizer disableWidth>
{({ height }) => (
<CodeEditor
width="100%"
height={height}
language={'json'}
showLineNumbers={true}
showMiniMap={true}
value={jsonView}
onBlur={setJsonView}
onSave={setJsonView}
/>
)}
</AutoSizer>
</div>
<Stack direction={'row'}>
<Button
disabled={replaceFileStatus.isLoading}
onClick={() => {
replaceFile({
name: repo,
path: wrap.path!,
body: JSON.parse(jsonView),
message: 'updated from repo test UI',
});
}}
>
{replaceFileStatus.isLoading ? 'Saving' : 'Save'}
</Button>
</Stack>
{replaceFileStatus.isError && (
<Alert title="Error saving file">
<pre>{JSON.stringify(replaceFileStatus.error)}</pre>
</Alert>
)}
</div>
)}
<Stack direction={'row'}>
<Button
disabled={replaceFileStatus.isLoading}
onClick={() => {
replaceFile({
name: repo,
path: wrap.path!,
body: JSON.parse(jsonView),
message: 'updated from repo test UI',
});
}}
>
{replaceFileStatus.isLoading ? 'Saving' : 'Save'}
</Button>
</Stack>
{replaceFileStatus.isError && (
<Alert title="Error saving file">
<pre>{JSON.stringify(replaceFileStatus.error)}</pre>
</Alert>
)}
</div>
</TabContent>
</div>
);
@@ -29,7 +29,7 @@ export function JobStatus({ name, onStatusChange }: JobStatusProps) {
return (
<Stack direction="column" alignItems="center" gap={2}>
<Spinner />
<Text>Loading migration status...</Text>
<Text>Starting migration...</Text>
</Stack>
);
}
@@ -1,12 +1,16 @@
import SVG from 'react-inlinesvg';
import { useNavigate } from 'react-router-dom-v5-compat';
import { EmptyState, LinkButton, Alert, Stack, Text } from '@grafana/ui';
import { EmptyState, LinkButton, Alert, Stack, Text, Button } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { SetupWarnings } from './SetupWarnings';
import { useGetFrontendSettingsQuery } from './api';
import { NEW_URL, MIGRATE_URL } from './constants';
export default function OnboardingPage({ legacyStorage }: { legacyStorage?: boolean }) {
const settingsQuery = useGetFrontendSettingsQuery();
const navigate = useNavigate();
return (
<Page
navId="provisioning"
@@ -25,9 +29,16 @@ export default function OnboardingPage({ legacyStorage }: { legacyStorage?: bool
message="Set up your provisioning connection!"
image={<SVG src="public/img/provisioning-empty.svg" width={300} />}
button={
<LinkButton size="lg" icon="plus" href={MIGRATE_URL}>
<Button
size="lg"
icon="plus"
onClick={async () => {
await settingsQuery.refetch();
navigate(MIGRATE_URL);
}}
>
Connect Grafana to repository
</LinkButton>
</Button>
}
>
<Stack direction="column" alignItems="center">
@@ -62,7 +62,9 @@ export default function RepositoryListPage() {
title="Legacy storage detected"
severity="error"
buttonContent={<>Remove all configured repositories</>}
onRemove={() => setShowDeleteModal(true)}
onRemove={() => {
setShowDeleteModal(true);
}}
>
Configured repositories will not work while running legacy storage.
</Alert>
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom-v5-compat';
import { Page } from 'app/core/components/Page/Page';
@@ -10,20 +10,26 @@ import { PROVISIONING_URL } from '../constants';
import { ProvisioningWizard } from './ProvisioningWizard';
export default function MigrateToProvisioningPage() {
const settings = useGetFrontendSettingsQuery();
const settingsQuery = useGetFrontendSettingsQuery();
const navigate = useNavigate();
const configChecked = useRef(false);
useEffect(() => {
if (settings.data) {
// Do not run the migration wizard if you are already using unified storage
if (!Boolean(settings.data.legacyStorage)) {
navigate(PROVISIONING_URL);
}
// Do not run the migration wizard if something is already targeting the instance
if (settings.data.items.find((v) => v.target === 'instance')) {
navigate(PROVISIONING_URL);
// Only run the redirect logic on the initial success load, not on subsequent data updates
if (settingsQuery.isSuccess && !configChecked.current) {
configChecked.current = true;
if (settingsQuery.data) {
// Do not run the migration wizard if you are already using unified storage
if (!Boolean(settingsQuery.data.legacyStorage)) {
navigate(PROVISIONING_URL);
}
// Do not run the migration wizard if something is already targeting the instance
if (settingsQuery.data.items.find((v) => v.target === 'instance')) {
navigate(PROVISIONING_URL);
}
}
}
}, [settings.data, navigate]);
}, [settingsQuery.data, navigate, settingsQuery.isSuccess]);
return (
<Page
@@ -7,6 +7,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { Button, Stack, useStyles2 } from '@grafana/ui';
import { getDefaultValues } from '../ConfigForm';
import { useGetFrontendSettingsQuery } from '../api';
import { ConnectionStep } from './ConnectionStep';
import { MigrateStep } from './MigrateStep';
@@ -27,10 +28,11 @@ const nextButtonText = {
} as const;
export function ProvisioningWizard() {
const navigate = useNavigate();
const [activeStep, setActiveStep] = useState<WizardStep>('connection');
const [completedSteps, setCompletedSteps] = useState<WizardStep[]>([]);
const [stepSuccess, setStepSuccess] = useState(false);
const settingsQuery = useGetFrontendSettingsQuery();
const navigate = useNavigate();
const methods = useForm<WizardFormData>({
defaultValues: {
repository: getDefaultValues(),
@@ -60,6 +62,7 @@ export function ProvisioningWizard() {
setActiveStep(steps[currentStepIndex + 1].id);
setStepSuccess(false);
} else if (activeStep === 'migrate') {
settingsQuery.refetch();
navigate('/dashboards');
}
};
@@ -263,11 +263,11 @@ const injectedRtkApi = api
}),
getFrontendSettings: build.query<GetFrontendSettingsResponse, GetFrontendSettingsArg>({
query: () => ({ url: `/settings` }),
providesTags: ['Provisioning'],
providesTags: ['Provisioning', 'Repository'],
}),
getResourceStats: build.query<GetResourceStatsResponse, GetResourceStatsArg>({
query: () => ({ url: `/stats` }),
providesTags: ['Provisioning'],
providesTags: ['Provisioning', 'Repository'],
}),
}),
overrideExisting: false,
-145
View File
@@ -1,145 +0,0 @@
import { skipToken } from '@reduxjs/toolkit/query/react';
import { useCallback } from 'react';
import { useUrlParams } from 'app/core/navigation/hooks';
import { useGetFolderQuery } from '../browse-dashboards/api/browseDashboardsAPI';
import {
ListRepositoryArg,
ReplaceRepositoryFilesWithPathArg,
Repository,
RepositorySpec,
useCreateRepositoryFilesWithPathMutation,
useCreateRepositoryMutation,
useListRepositoryQuery,
useReplaceRepositoryFilesWithPathMutation,
useReplaceRepositoryMutation,
useListJobQuery,
Job,
} from './api';
export function useCreateOrUpdateRepository(name?: string) {
const [create, createRequest] = useCreateRepositoryMutation();
const [update, updateRequest] = useReplaceRepositoryMutation();
const generateRepositoryMetadata = (data: RepositorySpec) => {
// We don't know for sure that we can use a normalised name. If we can't, we'll ask the server to generate one for us.
const normalisedName = data.title.toLowerCase().replaceAll(/[^a-z0-9\-_]+/g, '');
if (
crypto.randomUUID && // we might not be in a secure context
normalisedName && // we need a non-empty string before we check the first character
normalisedName.charAt(0) >= 'a' && // required to start with a letter to be a valid k8s name
normalisedName.charAt(0) <= 'z' &&
normalisedName.replaceAll(/[^a-z]/g, '').length >= 3 // must look sensible to a human
) {
// We still want a suffix, to avoid name collisions.
const randomBit = crypto.randomUUID().substring(0, 7);
const shortenedName = normalisedName.substring(0, 63 - 1 - randomBit.length);
return { name: `${shortenedName}-${randomBit}` };
} else {
return { generateName: 'r' };
}
};
const updateOrCreate = useCallback(
(data: RepositorySpec) => {
if (name) {
return update({ name, repository: { metadata: { name }, spec: data } });
}
return create({ repository: { metadata: generateRepositoryMetadata(data), spec: data } });
},
[create, name, update]
);
return [updateOrCreate, name ? updateRequest : createRequest] as const;
}
// Sort repositories alphabetically by title
export function useRepositoryList(options: ListRepositoryArg = {}): [Repository[] | undefined, boolean] {
const query = useListRepositoryQuery(options);
const collator = new Intl.Collator(undefined, { numeric: true });
const sortedItems = query.data?.items?.slice().sort((a, b) => {
const titleA = a.spec?.title ?? '';
const titleB = b.spec?.title ?? '';
return collator.compare(titleA, titleB);
});
return [sortedItems, query.isLoading];
}
export function useCreateOrUpdateRepositoryFile(name?: string) {
const [create, createRequest] = useCreateRepositoryFilesWithPathMutation();
const [update, updateRequest] = useReplaceRepositoryFilesWithPathMutation();
const updateOrCreate = useCallback(
(data: ReplaceRepositoryFilesWithPathArg) => {
const actions = name ? update : create;
return actions(data);
},
[create, name, update]
);
return [updateOrCreate, name ? updateRequest : createRequest] as const;
}
export const usePullRequestParam = () => {
const [params] = useUrlParams();
const prParam = params.get('pull_request_url');
if (!prParam) {
return undefined;
}
return decodeURIComponent(prParam);
};
interface GetResourceRepositoryArgs {
name?: string;
folderUid?: string;
}
export const useGetResourceRepository = ({ name, folderUid }: GetResourceRepositoryArgs) => {
const [items, isLoading] = useRepositoryList();
// Get the folder data from API to get repository data for nested folders
const folderQuery = useGetFolderQuery(name || !folderUid ? skipToken : folderUid);
const repoName = name || folderQuery.data?.repository?.name;
if (!items?.length || isLoading || !repoName) {
return undefined;
}
return items.find((repo) => repo.metadata?.name === repoName);
};
interface RepositoryJobsArgs {
name?: string;
watch?: boolean;
}
export function useRepositoryJobs({ name, watch = true }: RepositoryJobsArgs = {}): [
Job[] | undefined,
ReturnType<typeof useListJobQuery>,
] {
const query = useListJobQuery(
name
? {
labelSelector: `repository=${name}`,
watch,
}
: skipToken
);
const collator = new Intl.Collator(undefined, { numeric: true });
const sortedItems = query.data?.items?.slice().sort((a, b) => {
const aTime = a.metadata?.creationTimestamp ?? '';
const bTime = b.metadata?.creationTimestamp ?? '';
return collator.compare(bTime, aTime); // Reverse order for newest first
});
return [sortedItems, query];
}
@@ -0,0 +1,6 @@
export { useCreateOrUpdateRepository } from './useCreateOrUpdateRepository';
export { useCreateOrUpdateRepositoryFile } from './useCreateOrUpdateRepositoryFile';
export { useGetResourceRepository } from './useGetResourceRepository';
export { usePullRequestParam } from './usePullRequestParam';
export { useRepositoryJobs } from './useRepositoryJobs';
export { useRepositoryList } from './useRepositoryList';
@@ -0,0 +1,40 @@
import { useCallback } from 'react';
import { RepositorySpec, useCreateRepositoryMutation, useReplaceRepositoryMutation } from '../api';
export function useCreateOrUpdateRepository(name?: string) {
const [create, createRequest] = useCreateRepositoryMutation();
const [update, updateRequest] = useReplaceRepositoryMutation();
const generateRepositoryMetadata = (data: RepositorySpec) => {
// We don't know for sure that we can use a normalised name. If we can't, we'll ask the server to generate one for us.
const normalisedName = data.title.toLowerCase().replaceAll(/[^a-z0-9\-_]+/g, '');
if (
crypto.randomUUID && // we might not be in a secure context
normalisedName && // we need a non-empty string before we check the first character
normalisedName.charAt(0) >= 'a' && // required to start with a letter to be a valid k8s name
normalisedName.charAt(0) <= 'z' &&
normalisedName.replaceAll(/[^a-z]/g, '').length >= 3 // must look sensible to a human
) {
// We still want a suffix, to avoid name collisions.
const randomBit = crypto.randomUUID().substring(0, 7);
const shortenedName = normalisedName.substring(0, 63 - 1 - randomBit.length);
return { name: `${shortenedName}-${randomBit}` };
} else {
return { generateName: 'r' };
}
};
const updateOrCreate = useCallback(
(data: RepositorySpec) => {
if (name) {
return update({ name, repository: { metadata: { name }, spec: data } });
}
return create({ repository: { metadata: generateRepositoryMetadata(data), spec: data } });
},
[create, name, update]
);
return [updateOrCreate, name ? updateRequest : createRequest] as const;
}
@@ -0,0 +1,22 @@
import { useCallback } from 'react';
import {
ReplaceRepositoryFilesWithPathArg,
useCreateRepositoryFilesWithPathMutation,
useReplaceRepositoryFilesWithPathMutation,
} from '../api';
export function useCreateOrUpdateRepositoryFile(name?: string) {
const [create, createRequest] = useCreateRepositoryFilesWithPathMutation();
const [update, updateRequest] = useReplaceRepositoryFilesWithPathMutation();
const updateOrCreate = useCallback(
(data: ReplaceRepositoryFilesWithPathArg) => {
const actions = name ? update : create;
return actions(data);
},
[create, name, update]
);
return [updateOrCreate, name ? updateRequest : createRequest] as const;
}
@@ -0,0 +1,24 @@
import { skipToken } from '@reduxjs/toolkit/query/react';
import { useGetFolderQuery } from '../../browse-dashboards/api/browseDashboardsAPI';
import { useRepositoryList } from './useRepositoryList';
interface GetResourceRepositoryArgs {
name?: string;
folderUid?: string;
}
export const useGetResourceRepository = ({ name, folderUid }: GetResourceRepositoryArgs) => {
const [items, isLoading] = useRepositoryList();
// Get the folder data from API to get repository data for nested folders
const folderQuery = useGetFolderQuery(name || !folderUid ? skipToken : folderUid);
const repoName = name || folderQuery.data?.repository?.name;
if (!items?.length || isLoading || !repoName) {
return undefined;
}
return items.find((repo) => repo.metadata?.name === repoName);
};
@@ -0,0 +1,12 @@
import { useUrlParams } from 'app/core/navigation/hooks';
export const usePullRequestParam = () => {
const [params] = useUrlParams();
const prParam = params.get('pull_request_url');
if (!prParam) {
return undefined;
}
return decodeURIComponent(prParam);
};
@@ -0,0 +1,32 @@
import { skipToken } from '@reduxjs/toolkit/query/react';
import { Job, useListJobQuery } from '../api';
interface RepositoryJobsArgs {
name?: string;
watch?: boolean;
}
export function useRepositoryJobs({ name, watch = true }: RepositoryJobsArgs = {}): [
Job[] | undefined,
ReturnType<typeof useListJobQuery>,
] {
const query = useListJobQuery(
name
? {
labelSelector: `repository=${name}`,
watch,
}
: skipToken
);
const collator = new Intl.Collator(undefined, { numeric: true });
const sortedItems = query.data?.items?.slice().sort((a, b) => {
const aTime = a.metadata?.creationTimestamp ?? '';
const bTime = b.metadata?.creationTimestamp ?? '';
return collator.compare(bTime, aTime); // Reverse order for newest first
});
return [sortedItems, query];
}
@@ -0,0 +1,15 @@
import { ListRepositoryArg, Repository, useListRepositoryQuery } from '../api';
// Sort repositories alphabetically by title
export function useRepositoryList(options: ListRepositoryArg = {}): [Repository[] | undefined, boolean] {
const query = useListRepositoryQuery(options);
const collator = new Intl.Collator(undefined, { numeric: true });
const sortedItems = query.data?.items?.slice().sort((a, b) => {
const titleA = a.spec?.title ?? '';
const titleB = b.spec?.title ?? '';
return collator.compare(titleA, titleB);
});
return [sortedItems, query.isLoading];
}