Support Bundles: Improve creating bundle UX (#61611)

* Support Bundles: Improve creating bundle UX

* Refactor create bundle page

* Fix typo

* Don't show loading indicaror after deleting bundle
This commit is contained in:
Alexander Zobnin
2023-01-17 17:50:14 +01:00
committed by GitHub
parent 4f1bdc0607
commit 354342ab26
8 changed files with 263 additions and 84 deletions

View File

@@ -17,6 +17,7 @@ import panelsReducers from 'app/features/panel/state/reducers';
import { reducer as pluginsReducer } from 'app/features/plugins/admin/state/reducer';
import userReducers from 'app/features/profile/state/reducers';
import serviceAccountsReducer from 'app/features/serviceaccounts/state/reducers';
import supportBundlesReducer from 'app/features/support-bundles/state/reducers';
import teamsReducers from 'app/features/teams/state/reducers';
import usersReducers from 'app/features/users/state/reducers';
import templatingReducers from 'app/features/variables/state/keyedVariablesReducer';
@@ -43,6 +44,7 @@ const rootReducers = {
...panelEditorReducers,
...panelsReducers,
...templatingReducers,
...supportBundlesReducer,
plugins: pluginsReducer,
[alertingApi.reducerPath]: alertingApi.reducer,
[publicDashboardApi.reducerPath]: publicDashboardApi.reducer,

View File

@@ -1,10 +1,13 @@
import React, { useEffect } from 'react';
import { useAsyncFn } from 'react-use';
import { connect, ConnectedProps } from 'react-redux';
import { dateTimeFormat } from '@grafana/data';
import { config, getBackendSrv } from '@grafana/runtime';
import { LinkButton } from '@grafana/ui';
import { config } from '@grafana/runtime';
import { LinkButton, Spinner, IconButton } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { StoreState } from 'app/types';
import { loadBundles, removeBundle, checkBundles } from './state/actions';
const subTitle = (
<span>
@@ -13,39 +16,48 @@ const subTitle = (
</span>
);
const newButton = (
const NewBundleButton = (
<LinkButton icon="plus" href="admin/support-bundles/create" variant="primary">
New support bundle
</LinkButton>
);
type SupportBundleState = 'complete' | 'error' | 'timeout' | 'pending';
interface SupportBundle {
uid: string;
state: SupportBundleState;
creator: string;
createdAt: number;
expiresAt: number;
}
const getBundles = () => {
return getBackendSrv().get<SupportBundle[]>('/api/support-bundles');
const mapStateToProps = (state: StoreState) => {
return {
supportBundles: state.supportBundles.supportBundles,
isLoading: state.supportBundles.isLoading,
};
};
function SupportBundles() {
const [bundlesState, fetchBundles] = useAsyncFn(getBundles, []);
const mapDispatchToProps = {
loadBundles,
removeBundle,
checkBundles,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
type Props = ConnectedProps<typeof connector>;
const SupportBundlesUnconnected = ({ supportBundles, isLoading, loadBundles, removeBundle, checkBundles }: Props) => {
const isPending = supportBundles.some((b) => b.state === 'pending');
useEffect(() => {
fetchBundles();
}, [fetchBundles]);
loadBundles();
}, [loadBundles]);
const actions = config.featureToggles.topnav ? newButton : undefined;
useEffect(() => {
if (isPending) {
checkBundles();
}
});
const actions = config.featureToggles.topnav ? NewBundleButton : undefined;
return (
<Page navId="support-bundles" subTitle={subTitle} actions={actions}>
<Page.Contents isLoading={bundlesState.loading}>
{!config.featureToggles.topnav && newButton}
<Page.Contents isLoading={isLoading}>
{!config.featureToggles.topnav && NewBundleButton}
<table className="filter-table form-inline">
<thead>
@@ -53,25 +65,31 @@ function SupportBundles() {
<th>Created on</th>
<th>Requested by</th>
<th>Expires</th>
<th style={{ width: '32px' }} />
<th style={{ width: '1%' }} />
<th style={{ width: '1%' }} />
</tr>
</thead>
<tbody>
{bundlesState?.value?.map((b) => (
<tr key={b.uid}>
<th>{dateTimeFormat(b.createdAt * 1000)}</th>
<th>{b.creator}</th>
<th>{dateTimeFormat(b.expiresAt * 1000)}</th>
{supportBundles?.map((bundle) => (
<tr key={bundle.uid}>
<th>{dateTimeFormat(bundle.createdAt * 1000)}</th>
<th>{bundle.creator}</th>
<th>{dateTimeFormat(bundle.expiresAt * 1000)}</th>
<th>{bundle.state === 'pending' && <Spinner />}</th>
<th>
<LinkButton
fill="outline"
disabled={b.state !== 'complete'}
disabled={bundle.state !== 'complete'}
target={'_self'}
href={'/api/support-bundles/' + b.uid}
href={`/api/support-bundles/${bundle.uid}`}
>
Download
</LinkButton>
</th>
<th>
<IconButton onClick={() => removeBundle(bundle.uid)} name="trash-alt" variant="destructive" />
</th>
</tr>
))}
</tbody>
@@ -79,6 +97,6 @@ function SupportBundles() {
</Page.Contents>
</Page>
);
}
};
export default SupportBundles;
export default connector(SupportBundlesUnconnected);

View File

@@ -1,29 +1,11 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useAsyncFn } from 'react-use';
import React, { useEffect } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { getBackendSrv, locationService } from '@grafana/runtime';
import { Form, Button, Field, Checkbox } from '@grafana/ui';
import { Form, Button, Field, Checkbox, LinkButton, HorizontalGroup, Alert } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { StoreState } from 'app/types';
// move to types
export interface SupportBundleCreateRequest {
collectors: string[];
}
export interface SupportBundleCollector {
uid: string;
displayName: string;
description: string;
includedByDefault: boolean;
default: boolean;
}
export interface Props {}
const createSupportBundle = async (data: SupportBundleCreateRequest) => {
const result = await getBackendSrv().post('/api/support-bundles', data);
return result;
};
import { loadSupportBundleCollectors, createSupportBundle } from './state/actions';
const subTitle = (
<span>
@@ -31,50 +13,60 @@ const subTitle = (
</span>
);
export const SupportBundlesCreate = ({}: Props): JSX.Element => {
const onSubmit = useCallback(async (data) => {
try {
const selectedLabelsArray = Object.keys(data).filter((key) => data[key]);
const response = await createSupportBundle({ collectors: selectedLabelsArray });
console.info(response);
} catch (e) {
console.error(e);
}
const mapStateToProps = (state: StoreState) => {
return {
collectors: state.supportBundles.supportBundleCollectors,
isLoading: state.supportBundles.createBundlePageLoading,
loadCollectorsError: state.supportBundles.loadBundlesError,
createBundleError: state.supportBundles.createBundleError,
};
};
locationService.push('/admin/support-bundles');
}, []);
const mapDispatchToProps = {
loadSupportBundleCollectors,
createSupportBundle,
};
const [components, setComponents] = useState<SupportBundleCollector[]>([]);
// populate components from the backend
const populateComponents = async () => {
return await getBackendSrv().get('/api/support-bundles/collectors');
const connector = connect(mapStateToProps, mapDispatchToProps);
type Props = ConnectedProps<typeof connector>;
export const SupportBundlesCreateUnconnected = ({
collectors,
isLoading,
loadCollectorsError,
createBundleError,
loadSupportBundleCollectors,
createSupportBundle,
}: Props): JSX.Element => {
const onSubmit = (data: Record<string, boolean>) => {
const selectedLabelsArray = Object.keys(data).filter((key) => data[key]);
createSupportBundle({ collectors: selectedLabelsArray });
};
const [state, fetchComponents] = useAsyncFn(populateComponents);
useEffect(() => {
fetchComponents().then((res) => {
setComponents(res);
});
}, [fetchComponents]);
loadSupportBundleCollectors();
}, [loadSupportBundleCollectors]);
// turn components into a uuid -> enabled map
const values: Record<string, boolean> = components.reduce((acc, curr) => {
const values: Record<string, boolean> = collectors.reduce((acc, curr) => {
return { ...acc, [curr.uid]: curr.default };
}, {});
return (
<Page navId="support-bundles" pageNav={{ text: 'Create support bundle' }} subTitle={subTitle}>
<Page.Contents>
<Page.Contents isLoading={isLoading}>
<Page.OldNavOnly>
<h3 className="page-sub-heading">Create support bundle</h3>
</Page.OldNavOnly>
{state.error && <p>{state.error}</p>}
{!!components.length && (
{loadCollectorsError && <Alert title={loadCollectorsError} severity="error" />}
{createBundleError && <Alert title={createBundleError} severity="error" />}
{!!collectors.length && (
<Form defaultValues={values} onSubmit={onSubmit} validateOn="onSubmit">
{({ register, errors }) => {
return (
<>
{components.map((component) => {
{collectors.map((component) => {
return (
<Field key={component.uid}>
<Checkbox
@@ -88,7 +80,12 @@ export const SupportBundlesCreate = ({}: Props): JSX.Element => {
</Field>
);
})}
<Button type="submit">Create</Button>
<HorizontalGroup>
<Button type="submit">Create</Button>
<LinkButton href="/admin/support-bundles" variant="secondary">
Cancel
</LinkButton>
</HorizontalGroup>
</>
);
}}
@@ -99,4 +96,4 @@ export const SupportBundlesCreate = ({}: Props): JSX.Element => {
);
};
export default SupportBundlesCreate;
export default connector(SupportBundlesCreateUnconnected);

View File

@@ -0,0 +1,72 @@
import { throttle } from 'lodash';
import { getBackendSrv, locationService } from '@grafana/runtime';
import { SupportBundle, SupportBundleCollector, SupportBundleCreateRequest, ThunkResult } from 'app/types';
import {
collectorsFetchBegin,
collectorsFetchEnd,
fetchBegin,
fetchEnd,
setCreateBundleError,
setLoadBundleError,
supportBundleCollectorsLoaded,
supportBundlesLoaded,
} from './reducers';
export function loadBundles(skipPageRefresh = false): ThunkResult<void> {
return async (dispatch) => {
try {
if (!skipPageRefresh) {
dispatch(fetchBegin());
}
const result = await getBackendSrv().get<SupportBundle[]>('/api/support-bundles');
dispatch(supportBundlesLoaded(result));
} finally {
dispatch(fetchEnd());
}
};
}
const checkBundlesStatusThrottled = throttle(async (dispatch) => {
const result = await getBackendSrv().get<SupportBundle[]>('/api/support-bundles');
dispatch(supportBundlesLoaded(result));
}, 1000);
export function checkBundles(): ThunkResult<void> {
return async (dispatch) => {
dispatch(checkBundlesStatusThrottled);
};
}
export function removeBundle(uid: string): ThunkResult<void> {
return async (dispatch) => {
await getBackendSrv().delete(`/api/support-bundles/${uid}`);
dispatch(loadBundles(true));
};
}
export function loadSupportBundleCollectors(): ThunkResult<void> {
return async (dispatch) => {
try {
dispatch(collectorsFetchBegin());
const result = await getBackendSrv().get<SupportBundleCollector[]>('/api/support-bundles/collectors');
dispatch(supportBundleCollectorsLoaded(result));
} catch (err) {
dispatch(setLoadBundleError('Error loading support bundles data collectors'));
} finally {
dispatch(collectorsFetchEnd());
}
};
}
export function createSupportBundle(data: SupportBundleCreateRequest): ThunkResult<void> {
return async (dispatch) => {
try {
await getBackendSrv().post('/api/support-bundles', data);
locationService.push('/admin/support-bundles');
} catch (err) {
dispatch(setCreateBundleError('Error creating support bundle'));
}
};
}

View File

@@ -0,0 +1,60 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { SupportBundle, SupportBundleCollector, SupportBundlesState } from 'app/types';
export const initialState: SupportBundlesState = {
supportBundles: [],
isLoading: false,
supportBundleCollectors: [],
createBundlePageLoading: false,
loadBundlesError: '',
createBundleError: '',
};
const supportBundlesSlice = createSlice({
name: 'supportBundles',
initialState,
reducers: {
supportBundlesLoaded: (state, action: PayloadAction<SupportBundle[]>): SupportBundlesState => {
return { ...state, supportBundles: action.payload, isLoading: false };
},
fetchBegin: (state): SupportBundlesState => {
return { ...state, isLoading: true };
},
fetchEnd: (state): SupportBundlesState => {
return { ...state, isLoading: false };
},
collectorsFetchBegin: (state): SupportBundlesState => {
return { ...state, createBundlePageLoading: true };
},
collectorsFetchEnd: (state): SupportBundlesState => {
return { ...state, createBundlePageLoading: false };
},
supportBundleCollectorsLoaded: (state, action: PayloadAction<SupportBundleCollector[]>): SupportBundlesState => {
return { ...state, supportBundleCollectors: action.payload, createBundlePageLoading: false };
},
setLoadBundleError: (state, action: PayloadAction<string>): SupportBundlesState => {
return { ...state, loadBundlesError: action.payload, supportBundleCollectors: [] };
},
setCreateBundleError: (state, action: PayloadAction<string>): SupportBundlesState => {
return { ...state, createBundleError: action.payload };
},
},
});
export const {
supportBundlesLoaded,
fetchBegin,
fetchEnd,
supportBundleCollectorsLoaded,
collectorsFetchBegin,
collectorsFetchEnd,
setLoadBundleError,
setCreateBundleError,
} = supportBundlesSlice.actions;
export const supportBundlesReducer = supportBundlesSlice.reducer;
export default {
supportBundles: supportBundlesReducer,
};

View File

@@ -575,8 +575,7 @@ export function getSupportBundleRoutes(cfg = config): RouteDescriptor[] {
{
path: '/admin/support-bundles/create',
component: SafeDynamicImport(
() =>
import(/* webpackChunkName: "ServiceAccountCreatePage" */ 'app/features/support-bundles/SupportBundlesCreate')
() => import(/* webpackChunkName: "SupportBundlesCreate" */ 'app/features/support-bundles/SupportBundlesCreate')
),
},
];

View File

@@ -18,6 +18,7 @@ export * from './appEvent';
export * from './query';
export * from './preferences';
export * from './accessControl';
export * from './supportBundles';
import * as CoreEvents from './events';
export { CoreEvents };

View File

@@ -0,0 +1,30 @@
type SupportBundleState = 'complete' | 'error' | 'timeout' | 'pending';
export interface SupportBundle {
uid: string;
state: SupportBundleState;
creator: string;
createdAt: number;
expiresAt: number;
}
export interface SupportBundlesState {
supportBundles: SupportBundle[];
isLoading: boolean;
createBundlePageLoading: boolean;
supportBundleCollectors: SupportBundleCollector[];
loadBundlesError: string;
createBundleError: string;
}
export interface SupportBundleCollector {
uid: string;
displayName: string;
description: string;
includedByDefault: boolean;
default: boolean;
}
export interface SupportBundleCreateRequest {
collectors: string[];
}