Chore: Remove experimental Storage UI (#96887)

This commit is contained in:
Ryan McKinley 2024-11-22 13:38:02 +03:00 committed by GitHub
parent 6a0fd05a9e
commit 68c61514b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 4 additions and 1257 deletions

View File

@ -3883,43 +3883,6 @@ exports[`better eslint`] = {
"public/app/features/serviceaccounts/state/reducers.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/storage/AddRootView.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/storage/CreateNewFolderModal.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/storage/FileView.tsx:5381": [
[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 />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
],
"public/app/features/storage/FolderView.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/storage/RootView.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/storage/StorageFolderPage.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/storage/StoragePage.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/storage/UploadButton.tsx:5381": [
[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 />", "2"]
],
"public/app/features/storage/storage.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/features/support-bundles/SupportBundles.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],

1
.github/CODEOWNERS vendored
View File

@ -465,7 +465,6 @@ playwright.config.ts @grafana/plugins-platform-frontend
/public/app/features/browse-dashboards/ @grafana/grafana-frontend-platform
/public/app/features/search/ @grafana/grafana-frontend-platform
/public/app/features/serviceaccounts/ @grafana/identity-squad
/public/app/features/storage/ @grafana/grafana-app-platform-squad
/public/app/features/teams/ @grafana/access-squad
/public/app/features/templating/ @grafana/dashboards-squad
/public/app/features/trails/ @grafana/observability-metrics

View File

@ -33,6 +33,8 @@ import (
"errors"
"net/http"
"go.opentelemetry.io/otel"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/middleware"
"github.com/grafana/grafana/pkg/middleware/requestmeta"
@ -51,7 +53,6 @@ import (
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/web"
"go.opentelemetry.io/otel"
)
var tracer = otel.Tracer("github.com/grafana/grafana/pkg/api")
@ -113,10 +114,6 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/admin/orgs", authorizeInOrg(ac.UseGlobalOrg, ac.OrgsAccessEvaluator), hs.Index)
r.Get("/admin/orgs/edit/:id", authorizeInOrg(ac.UseGlobalOrg, ac.OrgsAccessEvaluator), hs.Index)
r.Get("/admin/stats", authorize(ac.EvalPermission(ac.ActionServerStatsRead)), hs.Index)
if hs.Features.IsEnabledGlobally(featuremgmt.FlagStorage) {
r.Get("/admin/storage", reqSignedIn, hs.Index)
r.Get("/admin/storage/*", reqSignedIn, hs.Index)
}
if hs.Features.IsEnabledGlobally(featuremgmt.FlagOnPremToCloudMigrations) {
r.Get("/admin/migrate-to-cloud", reqOrgAdmin, hs.Index)

View File

@ -20,8 +20,7 @@ import { PanelModel } from 'app/features/dashboard/state';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { AngularDeprecationNotice } from 'app/features/plugins/angularDeprecation/AngularDeprecationNotice';
import { AngularMigrationNotice } from 'app/features/plugins/angularDeprecation/AngularMigrationNotice';
import { getPageNavFromSlug, getRootContentNavModel } from 'app/features/storage/StorageFolderPage';
import { DashboardRoutes, KioskMode, StoreState } from 'app/types';
import { KioskMode, StoreState } from 'app/types';
import { PanelEditEnteredEvent, PanelEditExitedEvent } from 'app/types/events';
import { cancelVariables, templateVarsChangedInUrl } from '../../variables/state/actions';
@ -519,19 +518,7 @@ function updateStatePageNavFromProps(props: Props, state: State): State {
};
}
if (props.route.routeName === DashboardRoutes.Path) {
sectionNav = getRootContentNavModel();
const pageNav = getPageNavFromSlug(props.params.slug!);
if (pageNav?.parentItem) {
pageNav.parentItem = pageNav.parentItem;
}
} else {
sectionNav = getNavModel(
props.navIndex,
ID_PREFIX + dashboard.uid,
getNavModel(props.navIndex, 'dashboards/browse')
);
}
sectionNav = getNavModel(props.navIndex, ID_PREFIX + dashboard.uid, getNavModel(props.navIndex, 'dashboards/browse'));
const { folderUid } = dashboard.meta;
if (folderUid && pageNav && sectionNav.main.id !== 'starred') {

View File

@ -126,10 +126,6 @@ async function fetchDashboard(
}
return await buildNewDashboardSaveModel(args.urlFolderUid);
}
case DashboardRoutes.Path: {
const path = args.urlSlug ?? '';
return await dashboardLoaderSrv.loadDashboard(DashboardRoutes.Path, path, path);
}
default:
throw { message: 'Unknown route ' + args.routeName };
}

View File

@ -1,18 +0,0 @@
import { Button } from '@grafana/ui';
import { StorageView } from './types';
interface Props {
onPathChange: (p: string, v?: StorageView) => void;
}
export function AddRootView({ onPathChange }: Props) {
return (
<div>
<div>TODO... Add ROOT</div>
<Button variant="secondary" onClick={() => onPathChange('/')}>
Cancel
</Button>
</div>
);
}

View File

@ -1,61 +0,0 @@
import { css } from '@emotion/css';
import { uniqueId } from 'lodash';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, IconName, useStyles2 } from '@grafana/ui';
interface Props {
rootIcon?: IconName;
pathName: string;
onPathChange: (path: string) => void;
}
export function Breadcrumb({ pathName, onPathChange, rootIcon }: Props) {
const styles = useStyles2(getStyles);
const paths = pathName.split('/').filter(Boolean);
return (
<ul className={styles.breadCrumb}>
{rootIcon && (
<li>
<Icon name={rootIcon} onClick={() => onPathChange('')} />
</li>
)}
{paths.map((path, index) => {
let url = '/' + paths.slice(0, index + 1).join('/');
const onClickBreadcrumb = () => onPathChange(url);
const isLastBreadcrumb = index === paths.length - 1;
return (
// TODO: fix keyboard a11y
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<li key={uniqueId(path)} onClick={isLastBreadcrumb ? undefined : onClickBreadcrumb}>
{path}
</li>
);
})}
</ul>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
breadCrumb: css({
listStyle: 'none',
padding: theme.spacing(2, 1),
li: {
display: 'inline',
':not(:last-child)': {
color: theme.colors.text.link,
cursor: 'pointer',
},
'+ li:before': {
content: "'>'",
padding: theme.spacing(1),
color: theme.colors.text.secondary,
},
},
}),
};
}

View File

@ -1,44 +0,0 @@
import { SubmitHandler, Validate } from 'react-hook-form';
import { Button, Field, Input, Modal } from '@grafana/ui';
import { Form } from 'app/core/components/Form/Form';
type FormModel = { folderName: string };
interface Props {
onSubmit: SubmitHandler<FormModel>;
onDismiss: () => void;
validate: Validate<string, FormModel>;
}
const initialFormModel = { folderName: '' };
export function CreateNewFolderModal({ validate, onDismiss, onSubmit }: Props) {
return (
<Modal onDismiss={onDismiss} isOpen={true} title="New Folder">
<Form defaultValues={initialFormModel} onSubmit={onSubmit} maxWidth={'none'}>
{({ register, errors }) => (
<>
<Field
label="Folder name"
invalid={!!errors.folderName}
error={errors.folderName && errors.folderName.message}
>
<Input
id="folder-name-input"
{...register('folderName', {
required: 'Folder name is required.',
validate: { validate },
})}
/>
</Field>
<Modal.ButtonRow>
<Button type="submit">Create</Button>
</Modal.ButtonRow>
</>
)}
</Form>
</Modal>
);
}

View File

@ -1,155 +0,0 @@
import { css } from '@emotion/css';
import { isString } from 'lodash';
import { useMemo } from 'react';
import { useAsync } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer';
import { DataFrame, GrafanaTheme2 } from '@grafana/data';
import { CodeEditor, useStyles2 } from '@grafana/ui';
import { SanitizedSVG } from 'app/core/components/SVG/SanitizedSVG';
import { getGrafanaStorage } from './storage';
import { StorageView } from './types';
interface FileDisplayInfo {
category?: 'svg' | 'image' | 'text';
language?: string; // match code editor
}
interface Props {
listing: DataFrame;
path: string;
onPathChange: (p: string, view?: StorageView) => void;
view: StorageView;
}
export function FileView({ listing, path, onPathChange, view }: Props) {
const styles = useStyles2(getStyles);
const info = useMemo(() => getFileDisplayInfo(path), [path]);
const body = useAsync(async () => {
if (info.category === 'text') {
const rsp = await getGrafanaStorage().get(path);
if (isString(rsp)) {
return rsp;
}
return JSON.stringify(rsp, null, 2);
}
return null;
}, [info, path]);
switch (view) {
case StorageView.Config:
return <div>CONFIGURE?</div>;
case StorageView.Perms:
return <div>Permissions</div>;
case StorageView.History:
return <div>TODO... history</div>;
}
let src = `api/storage/read/${path}`;
if (src.endsWith('/')) {
src = src.substring(0, src.length - 1);
}
switch (info.category) {
case 'svg':
return (
<div>
<SanitizedSVG src={src} className={styles.icon} />
</div>
);
case 'image':
return (
<div>
<a target={'_self'} href={src}>
<img src={src} alt="File preview" className={styles.img} />
</a>
</div>
);
case 'text':
return (
<div className={styles.tableWrapper}>
<AutoSizer>
{({ width, height }) => (
<CodeEditor
width={width}
height={height}
value={body.value ?? ''}
showLineNumbers={false}
readOnly={true}
language={info.language ?? 'text'}
showMiniMap={false}
onBlur={(text: string) => {
console.log('CHANGED!', text);
}}
/>
)}
</AutoSizer>
</div>
);
}
return (
<div>
FILE: <a href={src}>{path}</a>
</div>
);
}
function getFileDisplayInfo(path: string): FileDisplayInfo {
const idx = path.lastIndexOf('.');
if (idx < 0) {
return {};
}
const suffix = path.substring(idx + 1).toLowerCase();
switch (suffix) {
case 'svg':
return { category: 'svg' };
case 'jpg':
case 'jpeg':
case 'png':
case 'webp':
case 'gif':
return { category: 'image' };
case 'geojson':
case 'json':
return { category: 'text', language: 'json' };
case 'text':
case 'go':
case 'md':
return { category: 'text' };
}
return {};
}
const getStyles = (theme: GrafanaTheme2) => ({
// TODO: remove `height: 90%`
wrapper: css({
display: 'flex',
flexDirection: 'column',
height: '100%',
}),
tableControlRowWrapper: css({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
marginBottom: theme.spacing(2),
}),
// TODO: remove `height: 100%`
tableWrapper: css({
border: `1px solid ${theme.colors.border.medium}`,
height: '100%',
}),
uploadSpot: css({
marginLeft: theme.spacing(2),
}),
border: css({
border: `1px solid ${theme.colors.border.medium}`,
padding: theme.spacing(2),
}),
img: css({
maxWidth: '100%',
}),
icon: css({}),
});

View File

@ -1,69 +0,0 @@
import { css } from '@emotion/css';
import AutoSizer from 'react-virtualized-auto-sizer';
import { DataFrame, GrafanaTheme2 } from '@grafana/data';
import { Table, useStyles2 } from '@grafana/ui';
import { StorageView } from './types';
interface Props {
listing: DataFrame;
view: StorageView;
}
export function FolderView({ listing, view }: Props) {
const styles = useStyles2(getStyles);
switch (view) {
case StorageView.Config:
return <div>CONFIGURE?</div>;
case StorageView.Perms:
return <div>Permissions</div>;
}
return (
<div className={styles.tableWrapper}>
<AutoSizer>
{({ width, height }) => (
<div style={{ width: `${width}px`, height: `${height}px` }}>
<Table
height={height}
width={width}
data={listing}
noHeader={false}
showTypeIcons={false}
resizable={false}
/>
</div>
)}
</AutoSizer>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
// TODO: remove `height: 90%`
wrapper: css({
display: 'flex',
flexDirection: 'column',
height: '100%',
}),
tableControlRowWrapper: css({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
marginBottom: theme.spacing(2),
}),
// TODO: remove `height: 100%`
tableWrapper: css({
border: `1px solid ${theme.colors.border.medium}`,
height: '100%',
}),
uploadSpot: css({
marginLeft: theme.spacing(2),
}),
border: css({
border: `1px solid ${theme.colors.border.medium}`,
padding: theme.spacing(2),
}),
});

View File

@ -1,135 +0,0 @@
import { css } from '@emotion/css';
import { useMemo, useState } from 'react';
import { useAsync } from 'react-use';
import { DataFrame, GrafanaTheme2 } from '@grafana/data';
import { Alert, Button, Card, FilterInput, Icon, IconName, TagList, useStyles2, Stack, InlineField } from '@grafana/ui';
import { getGrafanaStorage } from './storage';
import { StorageInfo, StorageView } from './types';
interface Props {
root: DataFrame;
onPathChange: (p: string, v?: StorageView) => void;
}
export function RootView({ root, onPathChange }: Props) {
const styles = useStyles2(getStyles);
const storage = useAsync(getGrafanaStorage().getConfig);
const [searchQuery, setSearchQuery] = useState<string>('');
let base = location.pathname;
if (!base.endsWith('/')) {
base += '/';
}
const roots = useMemo(() => {
let show = storage.value ?? [];
if (searchQuery?.length) {
const lower = searchQuery.toLowerCase();
show = show.filter((r) => {
const v = r.config;
const isMatch = v.name.toLowerCase().indexOf(lower) >= 0 || v.description.toLowerCase().indexOf(lower) >= 0;
if (isMatch) {
return true;
}
return false;
});
}
const base: StorageInfo[] = [];
const content: StorageInfo[] = [];
for (const r of show ?? []) {
if (r.config.underContentRoot) {
content.push(r);
} else if (r.config.prefix !== 'content') {
base.push(r);
}
}
return { base, content };
}, [searchQuery, storage]);
const renderRoots = (pfix: string, roots: StorageInfo[]) => {
return (
<Stack direction="column">
{roots.map((s) => {
const ok = s.ready;
return (
<Card key={s.config.prefix} href={ok ? `admin/storage/${pfix}${s.config.prefix}/` : undefined}>
<Card.Heading>{s.config.name}</Card.Heading>
<Card.Meta className={styles.clickable}>
{s.config.description}
{s.config.git?.remote && <a href={s.config.git?.remote}>{s.config.git?.remote}</a>}
</Card.Meta>
{s.notice?.map((notice) => <Alert key={notice.text} severity={notice.severity} title={notice.text} />)}
<Card.Tags className={styles.clickable}>
<Stack>
<TagList tags={getTags(s)} />
</Stack>
</Card.Tags>
<Card.Figure className={styles.clickable}>
<Icon name={getIconName(s.config.type)} size="xxxl" className={styles.secondaryTextColor} />
</Card.Figure>
</Card>
);
})}
</Stack>
);
};
return (
<div>
<div className="page-action-bar">
<InlineField grow>
<FilterInput placeholder="Search Storage" value={searchQuery} onChange={setSearchQuery} />
</InlineField>
<div className="page-action-bar__spacer" />
<Button onClick={() => onPathChange('', StorageView.AddRoot)}>Add Root</Button>
</div>
<div>{renderRoots('', roots.base)}</div>
<div>
<h3>Content</h3>
{renderRoots('content/', roots.content)}
</div>
</div>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
secondaryTextColor: css({
color: theme.colors.text.secondary,
}),
clickable: css({
pointerEvents: 'none',
}),
};
}
function getTags(v: StorageInfo) {
const tags: string[] = [];
if (v.builtin) {
tags.push('Builtin');
}
// Error
if (!v.ready) {
tags.push('Not ready');
}
return tags;
}
export function getIconName(type: string): IconName {
switch (type) {
case 'git':
return 'code-branch';
case 'disk':
return 'folder-open';
case 'sql':
return 'database';
default:
return 'folder-open';
}
}

View File

@ -1,72 +0,0 @@
import { useParams } from 'react-router-dom-v5-compat';
import { useAsync } from 'react-use';
import { DataFrame, NavModel, NavModelItem } from '@grafana/data';
import { Card, Icon, Spinner } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { getGrafanaStorage } from './storage';
export function StorageFolderPage() {
const { slug = '' } = useParams();
const listing = useAsync((): Promise<DataFrame | undefined> => {
return getGrafanaStorage().list('content/' + slug);
}, [slug]);
const childRoot = slug.length > 0 ? `g/${slug}/` : 'g/';
const pageNav = getPageNavFromSlug(slug);
const renderListing = () => {
if (listing.value) {
const names = listing.value.fields[0].values;
return names.map((item: string) => {
let name = item;
const isFolder = name.indexOf('.') < 0;
const isDash = !isFolder && name.endsWith('.json');
const url = `${childRoot}${name}`;
return (
<Card key={name} href={isFolder || isDash ? url : undefined}>
<Card.Heading>{name}</Card.Heading>
<Card.Figure>
<Icon name={isFolder ? 'folder' : isDash ? 'gf-grid' : 'file-alt'} size="sm" />
</Card.Figure>
</Card>
);
});
}
if (listing.loading) {
return <Spinner />;
}
return <div>?</div>;
};
const navModel = getRootContentNavModel();
return (
<Page navModel={navModel} pageNav={pageNav}>
{renderListing()}
</Page>
);
}
export function getPageNavFromSlug(slug: string) {
const parts = slug.split('/');
let pageNavs: NavModelItem[] = [];
let url = 'g';
let lastPageNav: NavModelItem | undefined;
for (let i = 0; i < parts.length; i++) {
url += `/${parts[i]}`;
pageNavs.push({ text: parts[i], url, parentItem: lastPageNav });
lastPageNav = pageNavs[pageNavs.length - 1];
}
return lastPageNav;
}
export function getRootContentNavModel(): NavModel {
return { main: { text: 'C:' }, node: { text: 'Content', url: '/g' } };
}
export default StorageFolderPage;

View File

@ -1,296 +0,0 @@
import { css } from '@emotion/css';
import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom-v5-compat';
import { useAsync } from 'react-use';
import { DataFrame, GrafanaTheme2, isDataFrame, ValueLinkConfig } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { useStyles2, Spinner, TabsBar, Tab, Button, Stack, Box, Alert, toIconName } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { Page } from 'app/core/components/Page/Page';
import { useNavModel } from 'app/core/hooks/useNavModel';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { ShowConfirmModalEvent } from 'app/types/events';
import { AddRootView } from './AddRootView';
import { Breadcrumb } from './Breadcrumb';
import { CreateNewFolderModal } from './CreateNewFolderModal';
import { FileView } from './FileView';
import { FolderView } from './FolderView';
import { RootView } from './RootView';
import { UploadButton } from './UploadButton';
import { getGrafanaStorage, filenameAlreadyExists } from './storage';
import { StorageView } from './types';
interface RouteParams {
path: string;
}
interface QueryParams {
view: StorageView;
}
const folderNameRegex = /^[a-z\d!\-_.*'() ]+$/;
const folderNameMaxLength = 256;
interface Props extends GrafanaRouteComponentProps<RouteParams, QueryParams> {}
const getParentPath = (path: string) => {
const lastSlashIdx = path.lastIndexOf('/');
if (lastSlashIdx < 1) {
return '';
}
return path.substring(0, lastSlashIdx);
};
export default function StoragePage(props: Props) {
const styles = useStyles2(getStyles);
const navModel = useNavModel('storage');
const { path = '' } = useParams();
const view = props.queryParams.view ?? StorageView.Data;
const setPath = (p: string, view?: StorageView) => {
let url = ('/admin/storage/' + p).replace('//', '/');
if (view && view !== StorageView.Data) {
url += '?view=' + view;
}
locationService.push(url);
};
const [isAddingNewFolder, setIsAddingNewFolder] = useState(false);
const [errorMessages, setErrorMessages] = useState<string[]>([]);
const listing = useAsync((): Promise<DataFrame | undefined> => {
return getGrafanaStorage()
.list(path)
.then((frame) => {
if (frame) {
const name = frame.fields[0];
frame.fields[0] = {
...name,
getLinks: (cfg: ValueLinkConfig) => {
const n = name.values[cfg.valueRowIndex ?? 0];
const p = path + '/' + n;
return [
{
title: `Open ${n}`,
href: `/admin/storage/${p}`,
target: '_self',
origin: name,
onClick: () => {
setPath(p);
},
},
];
},
};
}
return frame;
});
}, [path]);
const isFolder = useMemo(() => {
let isFolder = path?.indexOf('/') < 0;
if (listing.value) {
const length = listing.value.length;
if (length === 1) {
const first: string = listing.value.fields[0].values[0];
isFolder = !path.endsWith(first);
} else {
// TODO: handle files/folders which do not exist
isFolder = true;
}
}
return isFolder;
}, [path, listing]);
const fileNames = useMemo(() => {
return listing.value?.fields?.find((f) => f.name === 'name')?.values.filter((v) => typeof v === 'string') ?? [];
}, [listing]);
const renderView = () => {
const isRoot = !path?.length || path === '/';
switch (view) {
case StorageView.AddRoot:
if (!isRoot) {
setPath('');
return <Spinner />;
}
return <AddRootView onPathChange={setPath} />;
}
const frame = listing.value;
if (!isDataFrame(frame)) {
return <></>;
}
if (isRoot) {
return <RootView root={frame} onPathChange={setPath} />;
}
const opts = [{ what: StorageView.Data, text: 'Data' }];
// Root folders have a config page
if (path.indexOf('/') < 0) {
opts.push({ what: StorageView.Config, text: 'Configure' });
}
// Lets only apply permissions to folders (for now)
if (isFolder) {
// opts.push({ what: StorageView.Perms, text: 'Permissions' });
} else {
// TODO: only if the file exists in a storage engine with
opts.push({ what: StorageView.History, text: 'History' });
}
const canAddFolder = isFolder && (path.startsWith('resources') || path.startsWith('content'));
const canDelete = path.startsWith('resources/') || path.startsWith('content/');
const getErrorMessages = () => {
return (
<div className={styles.errorAlert}>
<Alert title="Upload failed" severity="error" onRemove={clearAlert}>
{errorMessages.map((error) => {
return <div key={error}>{error}</div>;
})}
</Alert>
</div>
);
};
const clearAlert = () => {
setErrorMessages([]);
};
return (
<div className={styles.wrapper}>
<Box display="flex" justifyContent="space-between" width="100%" height={3}>
<Breadcrumb pathName={path} onPathChange={setPath} rootIcon={toIconName(navModel.node.icon ?? '')} />
<Stack>
{canAddFolder && (
<>
<UploadButton path={path} setErrorMessages={setErrorMessages} fileNames={fileNames} setPath={setPath} />
<Button onClick={() => setIsAddingNewFolder(true)}>New Folder</Button>
</>
)}
{canDelete && (
<Button
variant="destructive"
onClick={() => {
const text = isFolder
? 'Are you sure you want to delete this folder and all its contents?'
: 'Are you sure you want to delete this file?';
const parentPath = getParentPath(path);
appEvents.publish(
new ShowConfirmModalEvent({
title: `Delete ${isFolder ? 'folder' : 'file'}`,
text,
icon: 'trash-alt',
yesText: 'Delete',
onConfirm: () =>
getGrafanaStorage()
.delete({ path, isFolder })
.then(() => {
setPath(parentPath);
}),
})
);
}}
>
Delete
</Button>
)}
</Stack>
</Box>
{errorMessages.length > 0 && getErrorMessages()}
<TabsBar>
{opts.map((opt) => (
<Tab
key={opt.what}
label={opt.text}
active={opt.what === view}
onChangeTab={() => setPath(path, opt.what)}
/>
))}
</TabsBar>
{isFolder ? (
<FolderView listing={frame} view={view} />
) : (
<FileView path={path} listing={frame} onPathChange={setPath} view={view} />
)}
{isAddingNewFolder && (
<CreateNewFolderModal
onSubmit={async ({ folderName }) => {
const folderPath = `${path}/${folderName}`;
const res = await getGrafanaStorage().createFolder(folderPath);
if (typeof res?.error !== 'string') {
setPath(folderPath);
setIsAddingNewFolder(false);
}
}}
onDismiss={() => {
setIsAddingNewFolder(false);
}}
validate={(folderName) => {
const lowerCase = folderName.toLowerCase();
if (filenameAlreadyExists(folderName, fileNames)) {
return 'A file or a folder with the same name already exists';
}
if (!folderNameRegex.test(lowerCase)) {
return 'Name contains illegal characters';
}
if (folderName.length > folderNameMaxLength) {
return `Name is too long, maximum length: ${folderNameMaxLength} characters`;
}
return true;
}}
/>
)}
</div>
);
};
return (
<Page navModel={navModel}>
<Page.Contents isLoading={listing.loading}>{renderView()}</Page.Contents>
</Page>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
// TODO: remove `height: 90%`
wrapper: css({
display: 'flex',
flexDirection: 'column',
height: '100%',
}),
tableControlRowWrapper: css({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
marginBottom: theme.spacing(2),
}),
// TODO: remove `height: 100%`
tableWrapper: css({
border: `1px solid ${theme.colors.border.medium}`,
height: '100%',
}),
border: css({
border: `1px solid ${theme.colors.border.medium}`,
padding: theme.spacing(2),
}),
errorAlert: css({
paddingTop: '20px',
}),
uploadButton: css({
marginRight: theme.spacing(2),
}),
});

View File

@ -1,118 +0,0 @@
import { css } from '@emotion/css';
import { FormEvent, useEffect, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { ConfirmModal, FileUpload, useStyles2 } from '@grafana/ui';
import { filenameAlreadyExists, getGrafanaStorage } from './storage';
import { StorageView, UploadResponse } from './types';
interface Props {
setErrorMessages: (errors: string[]) => void;
setPath: (p: string, view?: StorageView) => void;
path: string;
fileNames: string[];
}
const fileFormats = 'image/jpg, image/jpeg, image/png, image/gif, image/webp';
export function UploadButton({ setErrorMessages, setPath, path, fileNames }: Props) {
const styles = useStyles2(getStyles);
const [file, setFile] = useState<File | undefined>(undefined);
const [filenameExists, setFilenameExists] = useState(false);
const [fileUploadKey, setFileUploadKey] = useState(1);
const [isConfirmOpen, setIsConfirmOpen] = useState(true);
useEffect(() => {
setFileUploadKey((prev) => prev + 1);
}, [file]);
const onUpload = (rsp: UploadResponse) => {
console.log('Uploaded: ' + path);
if (rsp.path) {
setPath(rsp.path);
} else {
setPath(path); // back to data
}
};
const doUpload = async (fileToUpload: File, overwriteExistingFile: boolean) => {
if (!fileToUpload) {
setErrorMessages(['Please select a file.']);
return;
}
const rsp = await getGrafanaStorage().upload(path, fileToUpload, overwriteExistingFile);
if (rsp.status !== 200) {
setErrorMessages([rsp.message]);
} else {
onUpload(rsp);
}
};
const onFileUpload = (event: FormEvent<HTMLInputElement>) => {
setErrorMessages([]);
const fileToUpload =
event.currentTarget.files && event.currentTarget.files.length > 0 && event.currentTarget.files[0]
? event.currentTarget.files[0]
: undefined;
if (fileToUpload) {
setFile(fileToUpload);
const fileExists = filenameAlreadyExists(fileToUpload.name, fileNames);
if (!fileExists) {
setFilenameExists(false);
doUpload(fileToUpload, false).then((r) => {});
} else {
setFilenameExists(true);
setIsConfirmOpen(true);
}
}
};
const onOverwriteConfirm = () => {
if (file) {
doUpload(file, true).then((r) => {});
setIsConfirmOpen(false);
}
};
const onOverwriteDismiss = () => {
setFile(undefined);
setFilenameExists(false);
setIsConfirmOpen(false);
};
return (
<>
<FileUpload accept={fileFormats} onFileUpload={onFileUpload} key={fileUploadKey} className={styles.uploadButton}>
Upload
</FileUpload>
{file && filenameExists && (
<ConfirmModal
isOpen={isConfirmOpen}
body={
<div>
<p>{file?.name}</p>
<p>A file with this name already exists.</p>
<p>What would you like to do?</p>
</div>
}
title={'This file already exists'}
confirmText={'Replace'}
onConfirm={onOverwriteConfirm}
onDismiss={onOverwriteDismiss}
/>
)}
</>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
uploadButton: css({
marginRight: theme.spacing(2),
}),
});

View File

@ -1,145 +0,0 @@
import { DataFrame, dataFrameFromJSON, DataFrameJSON, getDisplayProcessor } from '@grafana/data';
import { config, getBackendSrv } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import { UploadResponse, StorageInfo, ItemOptions, WriteValueRequest, WriteValueResponse } from './types';
// Likely should be built into the search interface!
export interface GrafanaStorage {
get: <T = any>(path: string) => Promise<T>;
list: (path: string) => Promise<DataFrame | undefined>;
upload: (folder: string, file: File, overwriteExistingFile: boolean) => Promise<UploadResponse>;
createFolder: (path: string) => Promise<{ error?: string }>;
delete: (path: { isFolder: boolean; path: string }) => Promise<{ error?: string }>;
/** Admin only */
getConfig: () => Promise<StorageInfo[]>;
/** Called before save */
getOptions: (path: string) => Promise<ItemOptions>;
/** Saves dashboards */
write: (path: string, options: WriteValueRequest) => Promise<WriteValueResponse>;
}
class SimpleStorage implements GrafanaStorage {
constructor() {}
async get<T = any>(path: string): Promise<T> {
const storagePath = `api/storage/read/${path}`.replace('//', '/');
return getBackendSrv().get<T>(storagePath);
}
async list(path: string): Promise<DataFrame | undefined> {
let url = 'api/storage/list/';
if (path) {
url += path + '/';
}
const rsp = await getBackendSrv().get<DataFrameJSON>(url);
if (rsp?.data) {
const f = dataFrameFromJSON(rsp);
for (const field of f.fields) {
field.display = getDisplayProcessor({ field, theme: config.theme2 });
}
return f;
}
return undefined;
}
async createFolder(path: string): Promise<{ error?: string }> {
const res = await getBackendSrv().post<{ success: boolean; message: string }>(
'/api/storage/createFolder',
JSON.stringify({ path })
);
if (!res.success) {
return {
error: res.message ?? 'unknown error',
};
}
return {};
}
async deleteFolder(req: { path: string; force: boolean }): Promise<{ error?: string }> {
const res = await getBackendSrv().post<{ success: boolean; message: string }>(
`/api/storage/deleteFolder`,
JSON.stringify(req)
);
if (!res.success) {
return {
error: res.message ?? 'unknown error',
};
}
return {};
}
async deleteFile(req: { path: string }): Promise<{ error?: string }> {
const res = await getBackendSrv().post<{ success: boolean; message: string }>(`/api/storage/delete/${req.path}`);
if (!res.success) {
return {
error: res.message ?? 'unknown error',
};
}
return {};
}
async delete(req: { isFolder: boolean; path: string }): Promise<{ error?: string }> {
return req.isFolder ? this.deleteFolder({ path: req.path, force: true }) : this.deleteFile({ path: req.path });
}
async upload(folder: string, file: File, overwriteExistingFile: boolean): Promise<UploadResponse> {
const formData = new FormData();
formData.append('folder', folder);
formData.append('file', file);
formData.append('overwriteExistingFile', String(overwriteExistingFile));
const res = await fetch('/api/storage/upload', {
method: 'POST',
body: formData,
});
let body = await res.json();
if (!body) {
body = {};
}
body.status = res.status;
body.statusText = res.statusText;
if (res.status !== 200 && !body.err) {
body.err = true;
}
return body;
}
async write(path: string, options: WriteValueRequest): Promise<WriteValueResponse> {
return backendSrv.post<WriteValueResponse>(`/api/storage/write/${path}`, options);
}
async getConfig() {
return getBackendSrv().get<StorageInfo[]>('/api/storage/config');
}
async getOptions(path: string) {
return getBackendSrv().get<ItemOptions>(`/api/storage/options/${path}`);
}
}
export function filenameAlreadyExists(folderName: string, fileNames: string[]) {
const lowerCase = folderName.toLowerCase();
const trimmedLowerCase = lowerCase.trim();
const existingTrimmedLowerCaseNames = fileNames.map((f) => f.trim().toLowerCase());
return existingTrimmedLowerCaseNames.includes(trimmedLowerCase);
}
let storage: GrafanaStorage | undefined;
export function getGrafanaStorage() {
if (!storage) {
storage = new SimpleStorage();
}
return storage;
}

View File

@ -1,74 +0,0 @@
import { QueryResultMetaNotice, SelectableValue } from '@grafana/data';
export enum StorageView {
Data = 'data',
Config = 'config',
Perms = 'perms',
History = 'history',
AddRoot = 'add',
}
export interface UploadResponse {
status: number;
statusText: string;
err?: boolean;
message: string;
path: string;
}
export interface StorageInfo {
editable?: boolean;
builtin?: boolean;
ready?: boolean;
notice?: QueryResultMetaNotice[];
config: StorageConfig;
}
export interface StorageConfig {
type: string;
prefix: string;
name: string;
description: string;
underContentRoot: string;
disk?: {
path: string;
};
git?: {
remote: string;
branch: string;
root: string;
requirePullRequest: boolean;
accessToken: string;
};
sql?: {};
}
export enum WorkflowID {
Save = 'save',
PR = 'pr',
Push = 'push',
}
export interface WriteValueRequest {
kind: string;
body: {}; // json body
message?: string;
title?: string;
workflow: WorkflowID;
}
export interface WriteValueResponse {
code: number;
message?: string;
url?: string;
hash?: string;
branch?: string;
pending?: boolean;
size?: number;
}
export interface ItemOptions {
path: string;
workflows: Array<SelectableValue<WorkflowID>>;
}

View File

@ -369,13 +369,6 @@ export function getAppRoutes(): RouteDescriptor[] {
)
: () => <Navigate replace to="/admin" />,
},
{
path: '/admin/storage/:path/*',
roles: () => ['Admin'],
component: SafeDynamicImport(
() => import(/* webpackChunkName: "StoragePage" */ 'app/features/storage/StoragePage')
),
},
{
path: '/admin/stats',
component: SafeDynamicImport(

View File

@ -101,7 +101,6 @@ export enum DashboardRoutes {
Home = 'home-dashboard',
New = 'new-dashboard',
Normal = 'normal-dashboard',
Path = 'path-dashboard',
Scripted = 'scripted-dashboard',
Public = 'public-dashboard',
Embedded = 'embedded-dashboard',