Export: move export page to a full page (not view on storage) (#60263)

Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
This commit is contained in:
Ryan McKinley 2022-12-13 19:40:20 -08:00 committed by GitHub
parent 821614fb43
commit 4064fa51c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 295 additions and 271 deletions

View File

@ -161,6 +161,16 @@ func (s *ServiceImpl) getServerAdminNode(c *models.ReqContext) *navtree.NavLink
}
adminNavLinks = append(adminNavLinks, storage)
if s.features.IsEnabled(featuremgmt.FlagExport) {
storage.Children = append(storage.Children, &navtree.NavLink{
Text: "Export",
Id: "export",
SubTitle: "Export grafana settings",
Icon: "cube",
Url: s.cfg.AppSubURL + "/admin/storage/export",
})
}
if s.features.IsEnabled(featuremgmt.FlagK8s) {
storage.Children = append(storage.Children, &navtree.NavLink{
Text: "Kubernetes",

View File

@ -0,0 +1,278 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useAsync, useLocalStorage } from 'react-use';
import { isLiveChannelMessageEvent, isLiveChannelStatusEvent, LiveChannelScope, SelectableValue } from '@grafana/data';
import { getBackendSrv, getGrafanaLiveSrv, config } from '@grafana/runtime';
import {
Button,
CodeEditor,
Collapse,
Field,
HorizontalGroup,
InlineField,
InlineFieldRow,
InlineSwitch,
Input,
LinkButton,
Select,
Switch,
Alert,
} from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { useNavModel } from 'app/core/hooks/useNavModel';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
export const EXPORT_LOCAL_STORAGE_KEY = 'grafana.export.config';
interface ExportStatusMessage {
running: boolean;
target: string;
started: number;
finished: number;
update: number;
count: number;
current: number;
last: string;
status: string;
}
interface ExportJob {
format: string; // 'git';
generalFolderPath: string;
history: boolean;
exclude: Record<string, boolean>;
git?: {};
}
const defaultJob: ExportJob = {
format: 'git',
generalFolderPath: 'general',
history: true,
exclude: {},
git: {},
};
interface ExporterInfo {
key: string;
name: string;
description: string;
children?: ExporterInfo[];
}
enum StorageFormat {
Git = 'git',
EntityStore = 'entityStore',
}
const formats: Array<SelectableValue<string>> = [
{ label: 'GIT', value: StorageFormat.Git, description: 'Exports a fresh git repository' },
{ label: 'Entity store', value: StorageFormat.EntityStore, description: 'Export to the SQL based entity store' },
];
interface Props extends GrafanaRouteComponentProps {}
const labelWith = 18;
export default function ExportPage(props: Props) {
const navModel = useNavModel('export');
const [status, setStatus] = useState<ExportStatusMessage>();
const [body, setBody] = useLocalStorage<ExportJob>(EXPORT_LOCAL_STORAGE_KEY, defaultJob);
const [details, setDetails] = useState(false);
const serverOptions = useAsync(() => {
return getBackendSrv().get<{ exporters: ExporterInfo[] }>('/api/admin/export/options');
}, []);
const doStart = () => {
getBackendSrv()
.post('/api/admin/export', body)
.then((v) => {
if (v.cfg && v.status.running) {
setBody(v.cfg); // saves the valid parsed body
}
});
};
const doStop = () => {
getBackendSrv().post('/api/admin/export/stop');
};
const setInclude = useCallback(
(k: string, v: boolean) => {
if (!serverOptions.value || !body) {
return;
}
const exclude: Record<string, boolean> = {};
if (k === '*') {
if (!v) {
for (let exp of serverOptions.value.exporters) {
exclude[exp.key] = true;
}
}
setBody({ ...body, exclude });
return;
}
for (let exp of serverOptions.value.exporters) {
let val = body.exclude?.[exp.key];
if (k === exp.key) {
val = !v;
}
if (val) {
exclude[exp.key] = val;
}
}
setBody({ ...body, exclude });
},
[body, setBody, serverOptions]
);
useEffect(() => {
const subscription = getGrafanaLiveSrv()
.getStream<ExportStatusMessage>({
scope: LiveChannelScope.Grafana,
namespace: 'broadcast',
path: 'export',
})
.subscribe({
next: (evt) => {
if (isLiveChannelMessageEvent(evt)) {
setStatus(evt.message);
} else if (isLiveChannelStatusEvent(evt)) {
setStatus(evt.message);
}
},
});
return () => {
subscription.unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const renderView = () => {
const isEntityStoreEnabled = body?.format === StorageFormat.EntityStore && config.featureToggles.entityStore;
const shouldDisplayContent = isEntityStoreEnabled || body?.format === StorageFormat.Git;
const statusFragment = status && (
<div>
<h3>Status</h3>
<pre>{JSON.stringify(status, null, 2)}</pre>
{status.running && (
<div>
<Button variant="secondary" onClick={doStop}>
Stop
</Button>
</div>
)}
</div>
);
const formFragment = !Boolean(status?.running) && (
<div>
<Field label="Format">
<Select
options={formats}
width={40}
value={formats.find((v) => v.value === body?.format)}
onChange={(v) => setBody({ ...body!, format: v.value! })}
/>
</Field>
{!isEntityStoreEnabled && body?.format !== StorageFormat.Git && (
<div>
<Alert title="Missing feature flag">Enable the `entityStore` feature flag</Alert>
</div>
)}
{body?.format === StorageFormat.Git && (
<>
<Field label="Keep history">
<Switch value={body?.history} onChange={(v) => setBody({ ...body!, history: v.currentTarget.checked })} />
</Field>
<Field label="Include">
<>
<InlineFieldRow>
<InlineField label="Toggle all" labelWidth={labelWith}>
<InlineSwitch
value={Object.keys(body?.exclude ?? {}).length === 0}
onChange={(v) => setInclude('*', v.currentTarget.checked)}
/>
</InlineField>
</InlineFieldRow>
{serverOptions.value && (
<div>
{serverOptions.value.exporters.map((ex) => (
<InlineFieldRow key={ex.key}>
<InlineField label={ex.name} labelWidth={labelWith} tooltip={ex.description}>
<InlineSwitch
value={body?.exclude?.[ex.key] !== true}
onChange={(v) => setInclude(ex.key, v.currentTarget.checked)}
/>
</InlineField>
</InlineFieldRow>
))}
</div>
)}
</>
</Field>
</>
)}
{shouldDisplayContent && (
<>
<Field label="General folder" description="Set the folder name for items without a real folder">
<Input
width={40}
value={body?.generalFolderPath ?? ''}
onChange={(v) => setBody({ ...body!, generalFolderPath: v.currentTarget.value })}
placeholder="root folder path"
/>
</Field>
<HorizontalGroup>
<Button onClick={doStart} variant="primary">
Export
</Button>
<LinkButton href="admin/storage/" variant="secondary">
Cancel
</LinkButton>
</HorizontalGroup>
</>
)}
</div>
);
const requestDetailsFragment = (isEntityStoreEnabled || body?.format === StorageFormat.Git) && (
<Collapse label="Request details" isOpen={details} onToggle={setDetails} collapsible={true}>
<CodeEditor
height={275}
value={JSON.stringify(body, null, 2) ?? ''}
showLineNumbers={false}
readOnly={false}
language="json"
showMiniMap={false}
onBlur={(text: string) => {
setBody(JSON.parse(text)); // force JSON?
}}
/>
</Collapse>
);
return (
<div>
{statusFragment}
{formFragment}
<br />
<br />
{requestDetailsFragment}
</div>
);
};
return (
<Page navModel={navModel}>
<Page.Contents>{renderView()}</Page.Contents>
</Page>
);
}

View File

@ -1,256 +0,0 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useAsync, useLocalStorage } from 'react-use';
import { isLiveChannelMessageEvent, isLiveChannelStatusEvent, LiveChannelScope, SelectableValue } from '@grafana/data';
import { getBackendSrv, getGrafanaLiveSrv, config } from '@grafana/runtime';
import {
Button,
CodeEditor,
Collapse,
Field,
HorizontalGroup,
InlineField,
InlineFieldRow,
InlineSwitch,
Input,
LinkButton,
Select,
Switch,
Alert,
} from '@grafana/ui';
import { StorageView } from './types';
export const EXPORT_LOCAL_STORAGE_KEY = 'grafana.export.config';
interface ExportStatusMessage {
running: boolean;
target: string;
started: number;
finished: number;
update: number;
count: number;
current: number;
last: string;
status: string;
}
interface ExportJob {
format: string; // 'git';
generalFolderPath: string;
history: boolean;
exclude: Record<string, boolean>;
git?: {};
}
const defaultJob: ExportJob = {
format: 'git',
generalFolderPath: 'general',
history: true,
exclude: {},
git: {},
};
interface ExporterInfo {
key: string;
name: string;
description: string;
children?: ExporterInfo[];
}
const formats: Array<SelectableValue<string>> = [
{ label: 'GIT', value: 'git', description: 'Exports a fresh git repository' },
{ label: 'Entity store', value: 'entityStore', description: 'Export to the SQL based entity store' },
];
interface Props {
onPathChange: (p: string, v?: StorageView) => void;
}
const labelWith = 18;
export const ExportView = ({ onPathChange }: Props) => {
const [status, setStatus] = useState<ExportStatusMessage>();
const [body, setBody] = useLocalStorage<ExportJob>(EXPORT_LOCAL_STORAGE_KEY, defaultJob);
const [details, setDetails] = useState(false);
const serverOptions = useAsync(() => {
return getBackendSrv().get<{ exporters: ExporterInfo[] }>('/api/admin/export/options');
}, []);
const doStart = () => {
getBackendSrv()
.post('/api/admin/export', body)
.then((v) => {
if (v.cfg && v.status.running) {
setBody(v.cfg); // saves the valid parsed body
}
});
};
const doStop = () => {
getBackendSrv().post('/api/admin/export/stop');
};
const setInclude = useCallback(
(k: string, v: boolean) => {
if (!serverOptions.value || !body) {
return;
}
const exclude: Record<string, boolean> = {};
if (k === '*') {
if (!v) {
for (let exp of serverOptions.value.exporters) {
exclude[exp.key] = true;
}
}
setBody({ ...body, exclude });
return;
}
for (let exp of serverOptions.value.exporters) {
let val = body.exclude?.[exp.key];
if (k === exp.key) {
val = !v;
}
if (val) {
exclude[exp.key] = val;
}
}
setBody({ ...body, exclude });
},
[body, setBody, serverOptions]
);
useEffect(() => {
const subscription = getGrafanaLiveSrv()
.getStream<ExportStatusMessage>({
scope: LiveChannelScope.Grafana,
namespace: 'broadcast',
path: 'export',
})
.subscribe({
next: (evt) => {
if (isLiveChannelMessageEvent(evt)) {
setStatus(evt.message);
} else if (isLiveChannelStatusEvent(evt)) {
setStatus(evt.message);
}
},
});
return () => {
subscription.unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div>
{status && (
<div>
<h3>Status</h3>
<pre>{JSON.stringify(status, null, 2)}</pre>
{status.running && (
<div>
<Button variant="secondary" onClick={doStop}>
Stop
</Button>
</div>
)}
</div>
)}
{!Boolean(status?.running) && (
<div>
<h3>Export grafana instance</h3>
<Field label="Format">
<Select
options={formats}
width={40}
value={formats.find((v) => v.value === body?.format)}
onChange={(v) => setBody({ ...body!, format: v.value! })}
/>
</Field>
{body?.format === 'entityStore' && !config.featureToggles.entityStore && (
<div>
<Alert title="Missing feature flag">Enable the `entityStore` feature flag</Alert>
</div>
)}
{body?.format === 'git' && (
<>
<Field label="Keep history">
<Switch
value={body?.history}
onChange={(v) => setBody({ ...body!, history: v.currentTarget.checked })}
/>
</Field>
<Field label="Include">
<>
<InlineFieldRow>
<InlineField label="Toggle all" labelWidth={labelWith}>
<InlineSwitch
value={Object.keys(body?.exclude ?? {}).length === 0}
onChange={(v) => setInclude('*', v.currentTarget.checked)}
/>
</InlineField>
</InlineFieldRow>
{serverOptions.value && (
<div>
{serverOptions.value.exporters.map((ex) => (
<InlineFieldRow key={ex.key}>
<InlineField label={ex.name} labelWidth={labelWith} tooltip={ex.description}>
<InlineSwitch
value={body?.exclude?.[ex.key] !== true}
onChange={(v) => setInclude(ex.key, v.currentTarget.checked)}
/>
</InlineField>
</InlineFieldRow>
))}
</div>
)}
</>
</Field>
</>
)}
<Field label="General folder" description="Set the folder name for items without a real folder">
<Input
width={40}
value={body?.generalFolderPath ?? ''}
onChange={(v) => setBody({ ...body!, generalFolderPath: v.currentTarget.value })}
placeholder="root folder path"
/>
</Field>
<HorizontalGroup>
<Button onClick={doStart} variant="primary">
Export
</Button>
<LinkButton href="admin/storage/" variant="secondary">
Cancel
</LinkButton>
</HorizontalGroup>
</div>
)}
<br />
<br />
<Collapse label="Request details" isOpen={details} onToggle={setDetails} collapsible={true}>
<CodeEditor
height={275}
value={JSON.stringify(body, null, 2) ?? ''}
showLineNumbers={false}
readOnly={false}
language="json"
showMiniMap={false}
onBlur={(text: string) => {
setBody(JSON.parse(text)); // force JSON?
}}
/>
</Collapse>
</div>
);
};

View File

@ -3,7 +3,6 @@ import React, { useMemo, useState } from 'react';
import { useAsync } from 'react-use';
import { DataFrame, GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import {
Alert,
Button,
@ -100,11 +99,6 @@ export function RootView({ root, onPathChange }: Props) {
<Button className="pull-right" onClick={() => onPathChange('', StorageView.AddRoot)}>
Add Root
</Button>
{config.featureToggles.export && (
<Button className="pull-right" onClick={() => onPathChange('', StorageView.Export)}>
Export
</Button>
)}
</div>
<div>{renderRoots('', roots.base)}</div>

View File

@ -14,7 +14,6 @@ import { ShowConfirmModalEvent } from 'app/types/events';
import { AddRootView } from './AddRootView';
import { Breadcrumb } from './Breadcrumb';
import { CreateNewFolderModal } from './CreateNewFolderModal';
import { ExportView } from './ExportView';
import { FileView } from './FileView';
import { FolderView } from './FolderView';
import { RootView } from './RootView';
@ -116,13 +115,6 @@ export default function StoragePage(props: Props) {
const renderView = () => {
const isRoot = !path?.length || path === '/';
switch (view) {
case StorageView.Export:
if (!isRoot) {
setPath('');
return <Spinner />;
}
return <ExportView onPathChange={setPath} />;
case StorageView.AddRoot:
if (!isRoot) {
setPath('');

View File

@ -4,7 +4,6 @@ export enum StorageView {
Data = 'data',
Config = 'config',
Perms = 'perms',
Export = 'export',
History = 'history',
AddRoot = 'add',
}

View File

@ -366,6 +366,13 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "K8SStoragePage" */ 'app/features/storage/k8s/K8SPage')
),
},
{
path: '/admin/storage/export',
roles: () => ['Admin'],
component: SafeDynamicImport(
() => import(/* webpackChunkName: "ExportPage" */ 'app/features/storage/ExportPage')
),
},
{
path: '/admin/storage/:path*',
roles: () => ['Admin'],