E2C: Improvements to workflow (#91045)

* E2C: Improvements to workflow

* no eslint comment

* i18n

* i18n:

* lint
This commit is contained in:
Josh Hunt 2024-07-26 16:56:03 +01:00 committed by GitHub
parent 8e006fedda
commit d7e85354d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 224 additions and 61 deletions

View File

@ -4744,7 +4744,9 @@ exports[`better eslint`] = {
], ],
"public/app/features/migrate-to-cloud/onprem/NameCell.tsx:5381": [ "public/app/features/migrate-to-cloud/onprem/NameCell.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"] [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
], ],
"public/app/features/notifications/StoredNotifications.tsx:5381": [ "public/app/features/notifications/StoredNotifications.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"] [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]

View File

@ -26,7 +26,7 @@ export const CallToAction = () => {
<ConnectModal <ConnectModal
isOpen={modalOpen} isOpen={modalOpen}
isLoading={createMigrationResponse.isLoading} isLoading={createMigrationResponse.isLoading}
isError={createMigrationResponse.isError} error={createMigrationResponse.error}
onConfirm={createMigration} onConfirm={createMigration}
hideModal={() => setModalOpen(false)} hideModal={() => setModalOpen(false)}
/> />

View File

@ -3,15 +3,16 @@ import { useId } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form'; import { SubmitHandler, useForm } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { Modal, Button, Stack, TextLink, Field, Input, Text, useStyles2, Alert } from '@grafana/ui'; import { Modal, Button, Stack, TextLink, Field, Input, Text, useStyles2 } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization'; import { Trans, t } from 'app/core/internationalization';
import { AlertWithTraceID } from 'app/features/migrate-to-cloud/shared/AlertWithTraceID';
import { CreateSessionApiArg } from '../../../api'; import { CreateSessionApiArg } from '../../../api';
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
isLoading: boolean; isLoading: boolean;
isError: boolean; error: unknown;
hideModal: () => void; hideModal: () => void;
onConfirm: (connectStackData: CreateSessionApiArg) => Promise<unknown>; onConfirm: (connectStackData: CreateSessionApiArg) => Promise<unknown>;
} }
@ -20,7 +21,7 @@ interface FormData {
token: string; token: string;
} }
export const ConnectModal = ({ isOpen, isLoading, isError, hideModal, onConfirm }: Props) => { export const ConnectModal = ({ isOpen, isLoading, error, hideModal, onConfirm }: Props) => {
const tokenId = useId(); const tokenId = useId();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
@ -94,16 +95,17 @@ export const ConnectModal = ({ isOpen, isLoading, isError, hideModal, onConfirm
</Trans> </Trans>
</div> </div>
{isError && ( {error ? (
<Alert <AlertWithTraceID
error={error}
severity="error" severity="error"
title={t('migrate-to-cloud.connect-modal.token-error-title', 'Error saving token')} title={t('migrate-to-cloud.connect-modal.token-error-title', 'Error saving token')}
> >
<Trans i18nKey="migrate-to-cloud.connect-modal.token-error-description"> <Trans i18nKey="migrate-to-cloud.connect-modal.token-error-description">
There was an error saving the token. See the Grafana server logs for more details. There was an error saving the token. See the Grafana server logs for more details.
</Trans> </Trans>
</Alert> </AlertWithTraceID>
)} ) : undefined}
<Field <Field
className={styles.field} className={styles.field}

View File

@ -7,10 +7,13 @@ import { config } from '@grafana/runtime';
import { CellProps, Stack, Text, Icon, useStyles2 } from '@grafana/ui'; import { CellProps, Stack, Text, Icon, useStyles2 } from '@grafana/ui';
import { getSvgSize } from '@grafana/ui/src/components/Icon/utils'; import { getSvgSize } from '@grafana/ui/src/components/Icon/utils';
import { Trans } from 'app/core/internationalization'; import { Trans } from 'app/core/internationalization';
import { useGetFolderQuery } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
import { useGetDashboardByUidQuery, MigrateDataResponseItemDto } from '../api'; import { useGetDashboardByUidQuery } from '../api';
export function NameCell(props: CellProps<MigrateDataResponseItemDto>) { import { ResourceTableItem } from './types';
export function NameCell(props: CellProps<ResourceTableItem>) {
const data = props.row.original; const data = props.row.original;
return ( return (
@ -18,12 +21,23 @@ export function NameCell(props: CellProps<MigrateDataResponseItemDto>) {
<ResourceIcon resource={data} /> <ResourceIcon resource={data} />
<Stack direction="column" gap={0}> <Stack direction="column" gap={0}>
{data.type === 'DATASOURCE' ? <DatasourceInfo data={data} /> : <DashboardInfo data={data} />} <ResourceInfo data={data} />
</Stack> </Stack>
</Stack> </Stack>
); );
} }
function ResourceInfo({ data }: { data: ResourceTableItem }) {
switch (data.type) {
case 'DASHBOARD':
return <DashboardInfo data={data} />;
case 'DATASOURCE':
return <DatasourceInfo data={data} />;
case 'FOLDER':
return <FolderInfo data={data} />;
}
}
function getDashboardTitle(dashboardData: object) { function getDashboardTitle(dashboardData: object) {
if ('title' in dashboardData && typeof dashboardData.title === 'string') { if ('title' in dashboardData && typeof dashboardData.title === 'string') {
return dashboardData.title; return dashboardData.title;
@ -32,7 +46,7 @@ function getDashboardTitle(dashboardData: object) {
return undefined; return undefined;
} }
function DatasourceInfo({ data }: { data: MigrateDataResponseItemDto }) { function DatasourceInfo({ data }: { data: ResourceTableItem }) {
const datasourceUID = data.refId; const datasourceUID = data.refId;
const datasource = useDatasource(datasourceUID); const datasource = useDatasource(datasourceUID);
@ -59,7 +73,7 @@ function DatasourceInfo({ data }: { data: MigrateDataResponseItemDto }) {
); );
} }
function DashboardInfo({ data }: { data: MigrateDataResponseItemDto }) { function DashboardInfo({ data }: { data: ResourceTableItem }) {
const dashboardUID = data.refId; const dashboardUID = data.refId;
// TODO: really, the API should return this directly // TODO: really, the API should return this directly
const { data: dashboardData, isError } = useGetDashboardByUidQuery({ const { data: dashboardData, isError } = useGetDashboardByUidQuery({
@ -92,6 +106,32 @@ function DashboardInfo({ data }: { data: MigrateDataResponseItemDto }) {
); );
} }
function FolderInfo({ data }: { data: ResourceTableItem }) {
const { data: folderData, isLoading, isError } = useGetFolderQuery(data.refId);
if (isLoading || !folderData) {
return <InfoSkeleton />;
}
if (isError) {
return (
<>
<Text italic>Unable to load dashboard</Text>
<Text color="secondary">Dashboard {data.refId}</Text>
</>
);
}
const parentFolderName = folderData.parents?.[folderData.parents.length - 1]?.title;
return (
<>
<span>{folderData.title}</span>
<Text color="secondary">{parentFolderName ?? 'Dashboards'}</Text>
</>
);
}
function InfoSkeleton() { function InfoSkeleton() {
return ( return (
<> <>
@ -101,15 +141,15 @@ function InfoSkeleton() {
); );
} }
function ResourceIcon({ resource }: { resource: MigrateDataResponseItemDto }) { function ResourceIcon({ resource }: { resource: ResourceTableItem }) {
const styles = useStyles2(getIconStyles); const styles = useStyles2(getIconStyles);
const datasource = useDatasource(resource.type === 'DATASOURCE' ? resource.refId : undefined); const datasource = useDatasource(resource.type === 'DATASOURCE' ? resource.refId : undefined);
if (resource.type === 'DASHBOARD') { if (resource.type === 'DASHBOARD') {
return <Icon size="xl" name="dashboard" />; return <Icon size="xl" name="dashboard" />;
} } else if (resource.type === 'FOLDER') {
return <Icon size="xl" name="folder" />;
if (resource.type === 'DATASOURCE' && datasource?.meta?.info?.logos?.small) { } else if (resource.type === 'DATASOURCE' && datasource?.meta?.info?.logos?.small) {
return <img className={styles.icon} src={datasource.meta.info.logos.small} alt="" />; return <img className={styles.icon} src={datasource.meta.info.logos.small} alt="" />;
} else if (resource.type === 'DATASOURCE') { } else if (resource.type === 'DATASOURCE') {
return <Icon size="xl" name="database" />; return <Icon size="xl" name="database" />;

View File

@ -1,7 +1,6 @@
import { skipToken } from '@reduxjs/toolkit/query/react'; import { skipToken } from '@reduxjs/toolkit/query/react';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { isFetchError } from '@grafana/runtime';
import { Alert, Box, Stack, Text } from '@grafana/ui'; import { Alert, Box, Stack, Text } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization'; import { Trans, t } from 'app/core/internationalization';
@ -15,6 +14,7 @@ import {
useGetSnapshotQuery, useGetSnapshotQuery,
useUploadSnapshotMutation, useUploadSnapshotMutation,
} from '../api'; } from '../api';
import { AlertWithTraceID } from '../shared/AlertWithTraceID';
import { DisconnectModal } from './DisconnectModal'; import { DisconnectModal } from './DisconnectModal';
import { EmptyState } from './EmptyState/EmptyState'; import { EmptyState } from './EmptyState/EmptyState';
@ -57,7 +57,7 @@ const SHOULD_POLL_STATUSES: Array<SnapshotDto['status']> = [
'PROCESSING', 'PROCESSING',
]; ];
const SNAPSHOT_REBUILD_STATUSES: Array<SnapshotDto['status']> = ['FINISHED', 'ERROR', 'UNKNOWN']; const SNAPSHOT_REBUILD_STATUSES: Array<SnapshotDto['status']> = ['PENDING_PROCESSING', 'FINISHED', 'ERROR', 'UNKNOWN'];
const SNAPSHOT_BUILDING_STATUSES: Array<SnapshotDto['status']> = ['INITIALIZING', 'CREATING']; const SNAPSHOT_BUILDING_STATUSES: Array<SnapshotDto['status']> = ['INITIALIZING', 'CREATING'];
@ -166,7 +166,8 @@ export const Page = () => {
{/* TODO: show errors from all mutation's in a... modal? */} {/* TODO: show errors from all mutation's in a... modal? */}
{createSnapshotResult.isError && ( {createSnapshotResult.isError && (
<Alert <AlertWithTraceID
error={createSnapshotResult.error}
severity="error" severity="error"
title={t('migrate-to-cloud.summary.run-migration-error-title', 'Error creating snapshot')} title={t('migrate-to-cloud.summary.run-migration-error-title', 'Error creating snapshot')}
> >
@ -175,13 +176,7 @@ export const Page = () => {
See the Grafana server logs for more details See the Grafana server logs for more details
</Trans> </Trans>
</Text> </Text>
</AlertWithTraceID>
{maybeGetTraceID(createSnapshotResult.error) && (
// Deliberately don't want to translate 'Trace ID'
// eslint-disable-next-line @grafana/no-untranslated-strings
<Text element="p">Trace ID: {maybeGetTraceID(createSnapshotResult.error)}</Text>
)}
</Alert>
)} )}
{disconnectResult.isError && ( {disconnectResult.isError && (
@ -247,13 +242,3 @@ export const Page = () => {
</> </>
); );
}; };
function maybeGetTraceID(err: unknown) {
const data = isFetchError<unknown>(err) ? err.data : undefined;
if (typeof data === 'object' && data && 'traceID' in data && typeof data.traceID === 'string') {
return data.traceID;
}
return undefined;
}

View File

@ -0,0 +1,55 @@
import { Button, Modal, Stack, Text } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
import { prettyTypeName } from './TypeCell';
import { ResourceTableItem } from './types';
interface ResourceErrorModalProps {
resource: ResourceTableItem | undefined;
onClose: () => void;
}
export function ResourceErrorModal(props: ResourceErrorModalProps) {
const { resource, onClose } = props;
const refId = resource?.refId;
const typeName = resource && prettyTypeName(resource.type);
return (
<Modal
title={t('migrate-to-cloud.resource-error.title', 'Unable to migrate this resource')}
isOpen={Boolean(resource)}
onDismiss={onClose}
>
{resource && (
<Stack direction="column" gap={2} alignItems="flex-start">
<Text element="p" weight="bold">
<Trans i18nKey="migrate-to-cloud.resource-error.resource-summary">
{{ refId }} ({{ typeName }})
</Trans>
</Text>
{resource.error ? (
<>
<Text element="p">
<Trans i18nKey="migrate-to-cloud.resource-error.specific-error">The specific error was:</Trans>
</Text>
<Text element="p" weight="bold">
{resource.error}
</Text>
</>
) : (
<Text element="p">
<Trans i18nKey="migrate-to-cloud.resource-error.unknown-error">An unknown error occurred.</Trans>
</Text>
)}
<Button onClick={onClose}>
<Trans i18nKey="migrate-to-cloud.resource-error.dismiss-button">OK</Trans>
</Button>
</Stack>
)}
</Modal>
);
}

View File

@ -1,10 +1,14 @@
import { useCallback, useMemo, useState } from 'react';
import { InteractiveTable } from '@grafana/ui'; import { InteractiveTable } from '@grafana/ui';
import { MigrateDataResponseItemDto } from '../api'; import { MigrateDataResponseItemDto } from '../api';
import { NameCell } from './NameCell'; import { NameCell } from './NameCell';
import { ResourceErrorModal } from './ResourceErrorModal';
import { StatusCell } from './StatusCell'; import { StatusCell } from './StatusCell';
import { TypeCell } from './TypeCell'; import { TypeCell } from './TypeCell';
import { ResourceTableItem } from './types';
interface ResourcesTableProps { interface ResourcesTableProps {
resources: MigrateDataResponseItemDto[]; resources: MigrateDataResponseItemDto[];
@ -17,5 +21,21 @@ const columns = [
]; ];
export function ResourcesTable({ resources }: ResourcesTableProps) { export function ResourcesTable({ resources }: ResourcesTableProps) {
return <InteractiveTable columns={columns} data={resources} getRowId={(r) => r.refId} pageSize={15} />; const [erroredResource, setErroredResource] = useState<ResourceTableItem | undefined>();
const handleShowErrorModal = useCallback((resource: ResourceTableItem) => {
setErroredResource(resource);
}, []);
const data = useMemo(() => {
return resources.map((r) => ({ ...r, showError: handleShowErrorModal }));
}, [resources, handleShowErrorModal]);
return (
<>
<InteractiveTable columns={columns} data={data} getRowId={(r) => r.refId} pageSize={15} />
<ResourceErrorModal resource={erroredResource} onClose={() => setErroredResource(undefined)} />
</>
);
} }

View File

@ -1,32 +1,35 @@
import { CellProps, Text, Stack, Button } from '@grafana/ui'; import { CellProps, Text, Stack, Button } from '@grafana/ui';
import { t } from 'app/core/internationalization'; import { t } from 'app/core/internationalization';
import { MigrateDataResponseItemDto } from '../api'; import { ResourceTableItem } from './types';
export function StatusCell(props: CellProps<MigrateDataResponseItemDto>) { export function StatusCell(props: CellProps<ResourceTableItem>) {
const { status, error } = props.row.original; const item = props.row.original;
// Keep these here to preserve the translations // Keep these here to preserve the translations
// t('migrate-to-cloud.resource-status.migrating', 'Uploading...') // t('migrate-to-cloud.resource-status.migrating', 'Uploading...')
if (status === 'PENDING') { if (item.status === 'PENDING') {
return <Text color="secondary">{t('migrate-to-cloud.resource-status.not-migrated', 'Not yet uploaded')}</Text>; return <Text color="secondary">{t('migrate-to-cloud.resource-status.not-migrated', 'Not yet uploaded')}</Text>;
} else if (status === 'OK') { } else if (item.status === 'OK') {
return <Text color="success">{t('migrate-to-cloud.resource-status.migrated', 'Uploaded to cloud')}</Text>; return <Text color="success">{t('migrate-to-cloud.resource-status.migrated', 'Uploaded to cloud')}</Text>;
} else if (status === 'ERROR') { } else if (item.status === 'ERROR') {
return ( return <ErrorCell item={item} />;
<Stack alignItems="center">
<Text color="error">{t('migrate-to-cloud.resource-status.failed', 'Error')}</Text>
{error && (
// TODO: trigger a proper modal, probably from the parent, on click
<Button size="sm" variant="secondary" onClick={() => window.alert(error)}>
{t('migrate-to-cloud.resource-status.error-details-button', 'Details')}
</Button>
)}
</Stack>
);
} }
return <Text color="secondary">{t('migrate-to-cloud.resource-status.unknown', 'Unknown')}</Text>; return <Text color="secondary">{t('migrate-to-cloud.resource-status.unknown', 'Unknown')}</Text>;
} }
function ErrorCell({ item }: { item: ResourceTableItem }) {
return (
<Stack alignItems="center">
<Text color="error">{t('migrate-to-cloud.resource-status.failed', 'Error')}</Text>
{item.error && (
<Button size="sm" variant="secondary" onClick={() => item.showError(item)}>
{t('migrate-to-cloud.resource-status.error-details-button', 'Details')}
</Button>
)}
</Stack>
);
}

View File

@ -1,11 +1,9 @@
import { CellProps } from '@grafana/ui'; import { CellProps } from '@grafana/ui';
import { t } from 'app/core/internationalization'; import { t } from 'app/core/internationalization';
import { MigrateDataResponseItemDto } from '../api'; import { ResourceTableItem } from './types';
export function TypeCell(props: CellProps<MigrateDataResponseItemDto>) {
const { type } = props.row.original;
export function prettyTypeName(type: ResourceTableItem['type']) {
switch (type) { switch (type) {
case 'DATASOURCE': case 'DATASOURCE':
return t('migrate-to-cloud.resource-type.datasource', 'Data source'); return t('migrate-to-cloud.resource-type.datasource', 'Data source');
@ -17,3 +15,8 @@ export function TypeCell(props: CellProps<MigrateDataResponseItemDto>) {
return t('migrate-to-cloud.resource-type.unknown', 'Unknown'); return t('migrate-to-cloud.resource-type.unknown', 'Unknown');
} }
} }
export function TypeCell(props: CellProps<ResourceTableItem>) {
const { type } = props.row.original;
return <>{prettyTypeName(type)}</>;
}

View File

@ -0,0 +1,5 @@
import { MigrateDataResponseItemDto } from '../api';
export interface ResourceTableItem extends MigrateDataResponseItemDto {
showError: (resource: ResourceTableItem) => void;
}

View File

@ -0,0 +1,34 @@
import { isFetchError } from '@grafana/runtime';
import { Alert, Stack, Text } from '@grafana/ui';
import { Props as AlertProps } from '@grafana/ui/src/components/Alert/Alert';
interface AlertWithTraceIDProps extends AlertProps {
error?: unknown;
}
export function AlertWithTraceID(props: AlertWithTraceIDProps) {
const { error, children, ...rest } = props;
const traceID = maybeGetTraceID(error);
return (
<Alert {...rest}>
<Stack direction="column" gap={1}>
{children}
{/* Deliberately don't want to translate 'Trace ID' */}
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
{traceID && <Text element="p">Trace ID: {traceID}</Text>}
</Stack>
</Alert>
);
}
function maybeGetTraceID(err: unknown) {
const data = isFetchError<unknown>(err) ? err.data : err;
if (typeof data === 'object' && data && 'traceID' in data && typeof data.traceID === 'string') {
return data.traceID;
}
return undefined;
}

View File

@ -1087,6 +1087,13 @@
"message": "Help us improve this feature by providing feedback and reporting any issues.", "message": "Help us improve this feature by providing feedback and reporting any issues.",
"title": "Migrate to Grafana Cloud is in public preview" "title": "Migrate to Grafana Cloud is in public preview"
}, },
"resource-error": {
"dismiss-button": "OK",
"resource-summary": "{{refId}} ({{typeName}})",
"specific-error": "The specific error was:",
"title": "Unable to migrate this resource",
"unknown-error": "An unknown error occurred."
},
"resource-status": { "resource-status": {
"error-details-button": "Details", "error-details-button": "Details",
"failed": "Error", "failed": "Error",

View File

@ -1087,6 +1087,13 @@
"message": "Ħęľp ūş įmpřővę ŧĥįş ƒęäŧūřę þy přővįđįʼnģ ƒęęđþäčĸ äʼnđ řępőřŧįʼnģ äʼny įşşūęş.", "message": "Ħęľp ūş įmpřővę ŧĥįş ƒęäŧūřę þy přővįđįʼnģ ƒęęđþäčĸ äʼnđ řępőřŧįʼnģ äʼny įşşūęş.",
"title": "Mįģřäŧę ŧő Ğřäƒäʼnä Cľőūđ įş įʼn pūþľįč přęvįęŵ" "title": "Mįģřäŧę ŧő Ğřäƒäʼnä Cľőūđ įş įʼn pūþľįč přęvįęŵ"
}, },
"resource-error": {
"dismiss-button": "ØĶ",
"resource-summary": "{{refId}} ({{typeName}})",
"specific-error": "Ŧĥę şpęčįƒįč ęřřőř ŵäş:",
"title": "Ůʼnäþľę ŧő mįģřäŧę ŧĥįş řęşőūřčę",
"unknown-error": "Åʼn ūʼnĸʼnőŵʼn ęřřőř őččūřřęđ."
},
"resource-status": { "resource-status": {
"error-details-button": "Đęŧäįľş", "error-details-button": "Đęŧäįľş",
"failed": "Ēřřőř", "failed": "Ēřřőř",