mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
72
public/app/features/support-bundles/state/actions.ts
Normal file
72
public/app/features/support-bundles/state/actions.ts
Normal 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'));
|
||||
}
|
||||
};
|
||||
}
|
||||
60
public/app/features/support-bundles/state/reducers.ts
Normal file
60
public/app/features/support-bundles/state/reducers.ts
Normal 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,
|
||||
};
|
||||
@@ -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')
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 };
|
||||
|
||||
30
public/app/types/supportBundles.ts
Normal file
30
public/app/types/supportBundles.ts
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user