[Files] Files management (#144425)

## Summary

Files management UI that rounds out the files MVP. This is UI is
intended to be progressively enhanced and provides a way for system
administrators get some insight and manage the files created and stored
in Kibana.

## To reviewers

* This is UI for retrieval and deletion of files (the R+D of CRUD)
* Creating and deleting tags to be supported in a future version
* This UI is intended to form part of the broader content management
experience
* We use the `TableListView` component as far as possible

## How to test

1. Start Kibana with `yarn start --run-examples`
2. Go to the "Developer Examples" from the left nav menu
3. Go to the "Files example" plugin
4. Click the "Upload file" button, upload a few different image types
(PNG, JPG and WEBP)
5. Go to "Stack management" > "Files"
6. Behold your files in the management UI
7. (Bonus) check that the UI and API `GET /api/files/find`, `GET
/api/files/metrics` and `DELETE /api/files/blobs` are not accessible to
non-admin or appropriately privileged users (i.e., those with "Files
management" access).

## List of functionality

 - [x] List all saved objects (scoped to admin)
 - [x] Is able to bulk-delete files
 - [x] Shows basic storage diagnostics
 - [x] Is able to search and filter files

## Screenshots

<details>
<summary>screenshots</summary>

<img width="1545" alt="Screenshot 2022-11-08 at 13 56 54"
src="https://user-images.githubusercontent.com/8155004/200570783-cfefdbf3-c5ff-4ece-ba24-48a455fcca75.png">

<img width="910" alt="Screenshot 2022-11-10 at 12 52 35"
src="https://user-images.githubusercontent.com/8155004/201083812-bc9f25f5-b423-43a6-9229-5e2a4cdd943a.png">

<img width="451" alt="Screenshot 2022-11-10 at 12 37 07"
src="https://user-images.githubusercontent.com/8155004/201081039-832a1980-684c-4abb-bb05-0c7c6a849d4d.png">

<img width="959" alt="Screenshot 2022-11-08 at 13 57 15"
src="https://user-images.githubusercontent.com/8155004/200570797-f122cff5-7043-4e01-9b51-d5663c1b26d6.png">

<img width="500" alt="Screenshot 2022-11-08 at 13 57 38"
src="https://user-images.githubusercontent.com/8155004/200570801-35cdbd99-0256-4dee-9f78-2f6ad853305f.png">

</details>

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jean-Louis Leysens 2022-11-17 13:53:31 +01:00 committed by GitHub
parent 759fb032f7
commit a166fba83d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 1031 additions and 59 deletions

View file

@ -39,6 +39,7 @@
"eventAnnotation": "src/plugins/event_annotation",
"fieldFormats": "src/plugins/field_formats",
"files": "src/plugins/files",
"filesManagement": "src/plugins/files_management",
"flot": "packages/kbn-flot-charts/lib",
"guidedOnboarding": "src/plugins/guided_onboarding",
"guidedOnboardingPackage": "packages/kbn-guided-onboarding",

View file

@ -180,6 +180,10 @@ for use in their own application.
|The files service provides functionality to manage, retrieve, share files in Kibana.
|{kib-repo}blob/{branch}/src/plugins/files_management/README.md[filesManagement]
|Minimal interface for admins to manage files in Kibana.
|{kib-repo}blob/{branch}/src/plugins/guided_onboarding/README.md[guidedOnboarding]
|This plugin contains the code for the Guided Onboarding project. Guided onboarding consists of guides for Solutions (Enterprise Search, Observability, Security) that can be completed as a checklist of steps. The guides help users to ingest their data and to navigate to the correct Solutions pages.

View file

@ -1,7 +0,0 @@
{
"prefix": "filesExample",
"paths": {
"filesExample": "."
},
"translations": ["translations/ja-JP.json"]
}

View file

@ -17,7 +17,7 @@ const httpTags = {
export const exampleFileKind: FileKind = {
id: PLUGIN_ID,
allowedMimeTypes: ['image/png'],
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'text/plain'],
http: {
create: httpTags,
delete: httpTags,

View file

@ -140,4 +140,8 @@ export const getStoryArgTypes = () => ({
},
defaultValue: 20,
},
asManagementSection: {
control: 'boolean',
defaultValue: false,
},
});

View file

@ -79,6 +79,21 @@ export interface Props<T extends UserContentCommonSchema = UserContentCommonSche
createItem?(): void;
deleteItems?(items: T[]): Promise<void>;
editItem?(item: T): void;
/**
* Name for the column containing the "title" value.
*/
titleColumnName?: string;
/**
* Additional actions (buttons) to be placed in the page header.
* @note only the first two values will be used.
*/
additionalRightSideActions?: ReactNode[];
/**
* This assumes the content is already wrapped in an outer PageTemplate component.
* @note Hack! This is being used as a workaround so that this page can be rendered in the Kibana management UI
* @deprecated
*/
withoutPageTemplateWrapper?: boolean;
inspector?: InspectorConfig;
}
@ -135,6 +150,9 @@ function TableListViewComp<T extends UserContentCommonSchema>({
id = 'userContent',
inspector = { enabled: false },
children,
titleColumnName,
additionalRightSideActions = [],
withoutPageTemplateWrapper,
}: Props<T>) {
if (!getDetailViewLink && !onClickTitle) {
throw new Error(
@ -260,9 +278,11 @@ function TableListViewComp<T extends UserContentCommonSchema>({
const columns: Array<EuiBasicTableColumn<T>> = [
{
field: 'attributes.title',
name: i18n.translate('contentManagement.tableList.mainColumnName', {
defaultMessage: 'Name, description, tags',
}),
name:
titleColumnName ??
i18n.translate('contentManagement.tableList.mainColumnName', {
defaultMessage: 'Name, description, tags',
}),
sortable: true,
render: (field: keyof T, record: T) => {
return (
@ -363,6 +383,7 @@ function TableListViewComp<T extends UserContentCommonSchema>({
return columns;
}, [
titleColumnName,
customTableColumn,
hasUpdatedAtMetadata,
editItem,
@ -551,23 +572,30 @@ function TableListViewComp<T extends UserContentCommonSchema>({
return null;
}
const PageTemplate = withoutPageTemplateWrapper
? (React.Fragment as unknown as typeof KibanaPageTemplate)
: KibanaPageTemplate;
if (!showFetchError && hasNoItems) {
return (
<KibanaPageTemplate panelled isEmptyState={true} data-test-subj={pageDataTestSubject}>
<PageTemplate panelled isEmptyState={true} data-test-subj={pageDataTestSubject}>
<KibanaPageTemplate.Section
aria-labelledby={hasInitialFetchReturned ? headingId : undefined}
>
{renderNoItemsMessage()}
</KibanaPageTemplate.Section>
</KibanaPageTemplate>
</PageTemplate>
);
}
return (
<KibanaPageTemplate panelled data-test-subj={pageDataTestSubject}>
<PageTemplate panelled data-test-subj={pageDataTestSubject}>
<KibanaPageTemplate.Header
pageTitle={<span id={headingId}>{tableListTitle}</span>}
rightSideItems={[renderCreateButton() ?? <span />]}
rightSideItems={[
renderCreateButton() ?? <span />,
...additionalRightSideActions?.slice(0, 2),
]}
data-test-subj="top-nav"
/>
<KibanaPageTemplate.Section aria-labelledby={hasInitialFetchReturned ? headingId : undefined}>
@ -623,7 +651,7 @@ function TableListViewComp<T extends UserContentCommonSchema>({
/>
)}
</KibanaPageTemplate.Section>
</KibanaPageTemplate>
</PageTemplate>
);
}

View file

@ -54,6 +54,7 @@ pageLoadAssetSize:
features: 21723
fieldFormats: 65209
files: 22673
filesManagement: 18683
fileUpload: 25664
fleet: 126917
globalSearch: 29696

View file

@ -1,7 +0,0 @@
{
"prefix": "files",
"paths": {
"files": "."
},
"translations": ["translations/ja-JP.json"]
}

View file

@ -72,3 +72,4 @@ export type { Endpoint as FileUnshareHttpEndpoint } from '../server/routes/file_
export type { Endpoint as FileGetShareHttpEndpoint } from '../server/routes/file_kind/share/get';
export type { Endpoint as FileListSharesHttpEndpoint } from '../server/routes/file_kind/share/list';
export type { Endpoint as FilePublicDownloadHttpEndpoint } from '../server/routes/public_facing/download';
export type { Endpoint as BulkDeleteHttpEndpoint } from '../server/routes/bulk_delete';

View file

@ -48,6 +48,7 @@ export const apiRoutes = {
*/
getFindRoute: () => `${API_BASE_PATH}/find`,
getMetricsRoute: () => `${API_BASE_PATH}/metrics`,
getBulkDeleteRoute: () => `${API_BASE_PATH}/blobs`,
};
/**
@ -86,6 +87,12 @@ export function createFilesClient({
fileKind?: string;
}): FilesClient | ScopedFilesClient {
const api: FilesClient = {
bulkDelete: (args) => {
return http.delete(apiRoutes.getBulkDeleteRoute(), {
headers: commonBodyHeaders,
body: JSON.stringify(args),
});
},
create: ({ kind, ...args }) => {
return http.post(apiRoutes.getCreateFileRoute(scopedFileKind ?? kind), {
headers: commonBodyHeaders,

View file

@ -12,6 +12,7 @@ import type { FilesClient } from './types';
// TODO: Remove this once we have access to the shared file client mock
export const createMockFilesClient = (): DeeplyMockedKeys<FilesClient> => ({
create: jest.fn(),
bulkDelete: jest.fn(),
delete: jest.fn(),
download: jest.fn(),
find: jest.fn(),

View file

@ -10,6 +10,7 @@ import { FileJSON } from '../common';
import type {
FindFilesHttpEndpoint,
FileShareHttpEndpoint,
BulkDeleteHttpEndpoint,
FileUnshareHttpEndpoint,
FileGetShareHttpEndpoint,
FilesMetricsHttpEndpoint,
@ -58,6 +59,12 @@ interface GlobalEndpoints {
* @param args - File filters
*/
find: UnscopedClientMethodFrom<FindFilesHttpEndpoint>;
/**
* Bulk a delete a set of files given their IDs.
*
* @param args - Bulk delete args
*/
bulkDelete: UnscopedClientMethodFrom<BulkDeleteHttpEndpoint>;
}
/**

View file

@ -46,10 +46,6 @@ export interface UpdateFileArgs {
* File ID.
*/
id: string;
/**
* File kind, must correspond to a registered {@link FileKind}.
*/
fileKind: string;
/**
* Attributes to update.
*/
@ -64,10 +60,6 @@ export interface DeleteFileArgs {
* File ID.
*/
id: string;
/**
* File kind, must correspond to a registered {@link FileKind}.
*/
fileKind: string;
}
/**
@ -78,10 +70,6 @@ export interface GetByIdArgs {
* File ID.
*/
id: string;
/**
* File kind, must correspond to a registered {@link FileKind}.
*/
fileKind: string;
}
/**

View file

@ -54,13 +54,13 @@ export class InternalFileService {
}
}
public async updateFile({ attributes, fileKind, id }: UpdateFileArgs): Promise<IFile> {
const file = await this.getById({ fileKind, id });
public async updateFile({ attributes, id }: UpdateFileArgs): Promise<IFile> {
const file = await this.getById({ id });
return await file.update(attributes);
}
public async deleteFile({ id, fileKind }: DeleteFileArgs): Promise<void> {
const file = await this.getById({ id, fileKind });
public async deleteFile({ id }: DeleteFileArgs): Promise<void> {
const file = await this.getById({ id });
await file.delete();
}
@ -80,12 +80,8 @@ export class InternalFileService {
}
}
public async getById({ fileKind, id }: GetByIdArgs): Promise<IFile> {
const file = await this.get(id);
if (file.data.fileKind !== fileKind) {
throw new Error(`Unexpected file kind "${file.data.fileKind}", expected "${fileKind}".`);
}
return file;
public async getById({ id }: GetByIdArgs): Promise<IFile> {
return await this.get(id);
}
public getFileKind(id: string): FileKind {

View file

@ -134,7 +134,7 @@ describe('FileService', () => {
it('retrieves a file', async () => {
const { id } = await createDisposableFile({ fileKind, name: 'test' });
const myFile = await fileService.getById({ id, fileKind });
const myFile = await fileService.getById({ id });
expect(myFile?.id).toMatch(id);
});
@ -203,7 +203,7 @@ describe('FileService', () => {
expect(updatedFile1.data.alt).toBe(updatableFields.alt);
// Fetch the file anew to be doubly sure
const updatedFile2 = await fileService.getById<CustomMeta>({ fileKind, id: file.id });
const updatedFile2 = await fileService.getById<CustomMeta>({ id: file.id });
expect(updatedFile2.data.meta).toEqual(expect.objectContaining(updatableFields.meta));
// Below also tests that our meta type is work as expected by using `some` field.
expect(updatedFile2.data.meta?.some).toBe(updatableFields.meta.some);

View file

@ -17,6 +17,7 @@ export * from '../../common/api_routes';
export const FILES_API_ROUTES = {
find: `${API_BASE_PATH}/find`,
bulkDelete: `${API_BASE_PATH}/blobs`,
metrics: `${API_BASE_PATH}/metrics`,
public: {
download: `${FILES_PUBLIC_API_BASE_PATH}/blob/{fileName?}`,

View file

@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { schema } from '@kbn/config-schema';
import type { CreateHandler, FilesRouter } from './types';
import { FILES_MANAGE_PRIVILEGE } from '../../common/constants';
import { FILES_API_ROUTES, CreateRouteDefinition } from './api_routes';
const method = 'delete' as const;
const rt = {
body: schema.object({
ids: schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1, maxSize: 100 }),
}),
};
interface Result {
/**
* The files that were deleted
*/
succeeded: string[];
/**
* Any failed deletions. Only included in the response if there were failures.
*/
failed?: Array<[id: string, reason: string]>;
}
export type Endpoint = CreateRouteDefinition<typeof rt, Result>;
const handler: CreateHandler<Endpoint> = async ({ files }, req, res) => {
const fileService = (await files).fileService.asCurrentUser();
const {
body: { ids },
} = req;
const succeeded: Result['succeeded'] = [];
const failed: Result['failed'] = [];
for (const id of ids) {
try {
await fileService.delete({ id });
succeeded.push(id);
} catch (e) {
failed.push([id, e.message]);
}
}
const body: Endpoint['output'] = {
succeeded,
failed: failed.length ? failed : undefined,
};
return res.ok({
body,
});
};
export function register(router: FilesRouter) {
router[method](
{
path: FILES_API_ROUTES.bulkDelete,
validate: { ...rt },
options: {
tags: [`access:${FILES_MANAGE_PRIVILEGE}`],
},
},
handler
);
}

View file

@ -24,7 +24,7 @@ export async function getById(
): Promise<ResultOrHttpError> {
let result: undefined | File;
try {
result = await fileService.getById({ id, fileKind });
result = await fileService.getById({ id });
} catch (e) {
let error: undefined | IKibanaResponse;
if (e instanceof errors.FileNotFoundError) {

View file

@ -15,8 +15,8 @@ import { page, pageSize } from './common_schemas';
const method = 'post' as const;
const string64 = schema.string({ maxLength: 64 });
const string256 = schema.string({ maxLength: 256 });
const string64 = schema.string({ minLength: 1, maxLength: 64 });
const string256 = schema.string({ minLength: 1, maxLength: 256 });
export const stringOrArrayOfStrings = schema.oneOf([string64, schema.arrayOf(string64)]);
export const nameStringOrArrayOfNameStrings = schema.oneOf([string256, schema.arrayOf(string256)]);

View file

@ -10,12 +10,13 @@ import { FilesRouter } from './types';
import * as find from './find';
import * as metrics from './metrics';
import * as bulkDelete from './bulk_delete';
import * as publicDownload from './public_facing/download';
export { registerFileKindRoutes } from './file_kind';
export function registerRoutes(router: FilesRouter) {
[find, metrics, publicDownload].forEach((endpoint) => {
[find, metrics, bulkDelete, publicDownload].forEach((endpoint) => {
endpoint.register(router);
});
}

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import type { FileJSON } from '../../../common';
import type { CreateFileKindHttpEndpoint } from '../../../common/api_routes';
import { setupIntegrationEnvironment, TestEnvironmentUtils } from '../../test_utils';
@ -201,6 +202,32 @@ describe('File HTTP API', () => {
});
});
describe('bulk delete', () => {
afterEach(async () => {
await testHarness.cleanupAfterEach();
});
it('bulk deletes files', async () => {
const [file1] = await Promise.all([
createFile({}, { deleteAfterTest: false }),
createFile(),
createFile(),
]);
{
const { body: response } = await request
.delete(root, `/api/files/blobs`)
.send({ ids: [file1.id, 'unknown'] })
.expect(200);
expect(response.succeeded).toEqual([file1.id]);
expect(response.failed).toEqual([['unknown', 'File not found']]);
}
{
const { body: response } = await request.post(root, `/api/files/find`).send({}).expect(200);
expect(response.files).toHaveLength(2);
expect(response.files.find((file: FileJSON) => file.id === file1.id)).toBeUndefined();
}
});
});
describe('public download', () => {
afterEach(async () => {
await testHarness.cleanupAfterEach();

View file

@ -32,7 +32,8 @@ export async function setupIntegrationEnvironment() {
alt: string;
meta: Record<string, any>;
mimeType: string;
}> = {}
}> = {},
{ deleteAfterTest = true }: { deleteAfterTest?: boolean } = {}
): Promise<FileJSON> => {
const result = await request
.post(root, `/api/files/files/${fileKind}`)
@ -45,12 +46,14 @@ export async function setupIntegrationEnvironment() {
})
)
.expect(200);
disposables.push(async () => {
await request
.delete(root, `/api/files/files/${fileKind}/${result.body.file.id}`)
.send()
.expect(200);
});
if (deleteAfterTest) {
disposables.push(async () => {
await request
.delete(root, `/api/files/files/${fileKind}/${result.body.file.id}`)
.send()
.expect(200);
});
}
return result.body.file;
};

View file

@ -0,0 +1,12 @@
# Files management
Minimal interface for admins to manage files in Kibana.
## Notes
This UI is intended to become part of the Kibana Content Management UI. This will
be a broader user-experience than only files, i.e., aimed at "content" more generally.
Do not add new file-specific features that does not fit with this vision.

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
export const PLUGIN_ID = 'filesManagement';
export const PLUGIN_NAME = i18n.translate('filesManagement.name', {
defaultMessage: 'Files',
});

View file

@ -0,0 +1,15 @@
{
"id": "filesManagement",
"version": "1.0.0",
"kibanaVersion": "kibana",
"owner": {
"name": "@elastic/kibana-global-experience",
"githubTeam": "@elastic/kibana-global-experience"
},
"description": "Simple UI for managing files in Kibana",
"server": false,
"ui": true,
"requiredPlugins": ["files", "management"],
"optionalPlugins": [],
"requiredBundles": ["kibanaReact"]
}

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { FunctionComponent } from 'react';
import React, { useState } from 'react';
import { EuiButtonEmpty } from '@elastic/eui';
import { TableListView, UserContentCommonSchema } from '@kbn/content-management-table-list';
import numeral from '@elastic/numeral';
import type { FileJSON } from '@kbn/files-plugin/common';
import { useFilesManagementContext } from './context';
import { i18nTexts } from './i18n_texts';
import { EmptyPrompt, DiagnosticsFlyout, FileFlyout } from './components';
type FilesUserContentSchema = UserContentCommonSchema;
function naivelyFuzzify(query: string): string {
return query.includes('*') ? query : `*${query}*`;
}
export const App: FunctionComponent = () => {
const { filesClient } = useFilesManagementContext();
const [showDiagnosticsFlyout, setShowDiagnosticsFlyout] = useState<boolean>(false);
const [selectedFile, setSelectedFile] = useState<undefined | FileJSON>(undefined);
return (
<>
<TableListView<FilesUserContentSchema>
titleColumnName={i18nTexts.titleColumnName}
emptyPrompt={<EmptyPrompt />}
entityName={i18nTexts.entityName}
entityNamePlural={i18nTexts.entityNamePlural}
findItems={(searchQuery) =>
filesClient
.find({ name: searchQuery ? naivelyFuzzify(searchQuery) : undefined })
.then(({ files, total }) => ({
hits: files.map((file) => ({
id: file.id,
updatedAt: file.updated,
references: [],
type: 'file',
attributes: {
title: file.name + (file.extension ? `.${file.extension}` : ''),
...file,
},
})),
total,
}))
}
customTableColumn={{
name: i18nTexts.size,
field: 'attributes.size',
render: (value: any) => value && numeral(value).format('0[.]0 b'),
sortable: true,
}}
initialFilter=""
initialPageSize={50}
listingLimit={1000}
tableListTitle={i18nTexts.tableListTitle}
onClickTitle={({ attributes }) => setSelectedFile(attributes as unknown as FileJSON)}
deleteItems={async (items) => {
await filesClient.bulkDelete({ ids: items.map(({ id }) => id) });
}}
withoutPageTemplateWrapper
additionalRightSideActions={[
<EuiButtonEmpty onClick={() => setShowDiagnosticsFlyout(true)}>
{i18nTexts.diagnosticsFlyoutTitle}
</EuiButtonEmpty>,
]}
/>
{showDiagnosticsFlyout && (
<DiagnosticsFlyout onClose={() => setShowDiagnosticsFlyout(false)} />
)}
{Boolean(selectedFile) && (
<FileFlyout file={selectedFile!} onClose={() => setSelectedFile(undefined)} />
)}
</>
);
};

View file

@ -0,0 +1,136 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useQuery } from '@tanstack/react-query';
import {
EuiFlyout,
EuiFlyoutHeader,
EuiFlyoutBody,
EuiButton,
EuiLoadingSpinner,
EuiPanel,
EuiTitle,
EuiEmptyPrompt,
EuiStat,
EuiFlexGroup,
EuiSpacer,
EuiFlexItem,
} from '@elastic/eui';
import { Chart, Axis, Position, HistogramBarSeries, ScaleType } from '@elastic/charts';
import numeral from '@elastic/numeral';
import type { FunctionComponent } from 'react';
import React from 'react';
import { i18nTexts } from '../i18n_texts';
import { useFilesManagementContext } from '../context';
interface Props {
onClose: () => void;
}
export const DiagnosticsFlyout: FunctionComponent<Props> = ({ onClose }) => {
const { filesClient } = useFilesManagementContext();
const { status, refetch, data, isLoading, error } = useQuery(['filesDiagnostics'], async () => {
return filesClient.getMetrics();
});
return (
<EuiFlyout ownFocus onClose={onClose} size="s">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>{i18nTexts.diagnosticsFlyoutTitle}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
{status === 'error' ? (
<EuiEmptyPrompt
titleSize="xs"
title={<h3>{i18nTexts.failedToFetchDiagnostics}</h3>}
body={(error as Error)?.message ?? ''}
color="danger"
actions={[
<EuiButton isLoading={isLoading} color="danger" onClick={() => refetch()}>
{i18nTexts.retry}
</EuiButton>,
]}
/>
) : status === 'loading' ? (
<EuiLoadingSpinner size="xl" />
) : (
<>
<EuiPanel hasBorder hasShadow={false}>
<EuiTitle size="xs">
<h3>{i18nTexts.diagnosticsFlyoutSummarySectionTitle}</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize="none">
<EuiFlexItem grow={1}>
<EuiStat
title={numeral(data.storage.esFixedSizeIndex.used).format('0[.]0 b')}
description={i18nTexts.diagnosticsSpaceUsed}
titleSize="s"
/>
</EuiFlexItem>
<EuiFlexItem grow={1}>
<EuiStat
title={Object.values(data.countByStatus).reduce((acc, value) => acc + value, 0)}
description={i18nTexts.diagnosticsTotalCount}
titleSize="s"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
<EuiSpacer />
<EuiPanel hasBorder hasShadow={false}>
<EuiTitle size="xs">
<h3>{i18nTexts.diagnosticsBreakdownsStatus}</h3>
</EuiTitle>
<Chart size={{ height: 200, width: '100%' }}>
<Axis id="y" position={Position.Left} showOverlappingTicks />
<Axis id="x" position={Position.Bottom} showOverlappingTicks />
<HistogramBarSeries
data={Object.entries(data.countByStatus).map(([key, count]) => ({
key,
count,
}))}
id="Status"
xAccessor={'key'}
yAccessors={['count']}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
timeZone="local"
/>
</Chart>
</EuiPanel>
<EuiSpacer />
<EuiPanel hasBorder hasShadow={false}>
<EuiTitle size="xs">
<h3>{i18nTexts.diagnosticsBreakdownsExtension}</h3>
</EuiTitle>
<Chart size={{ height: 200, width: '100%' }}>
<Axis id="y" position={Position.Left} showOverlappingTicks />
<Axis id="x" position={Position.Bottom} showOverlappingTicks />
<HistogramBarSeries
data={Object.entries(data.countByExtension).map(([key, count]) => ({
key,
count,
}))}
id="Extension"
xAccessor={'key'}
yAccessors={['count']}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
timeZone="local"
/>
</Chart>
</EuiPanel>
</>
)}
</EuiFlyoutBody>
</EuiFlyout>
);
};

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { FunctionComponent } from 'react';
import React from 'react';
import { EuiEmptyPrompt } from '@elastic/eui';
import { i18nTexts } from '../i18n_texts';
export const EmptyPrompt: FunctionComponent = () => {
return (
<EuiEmptyPrompt
title={<h3>{i18nTexts.emptyPromptTitle}</h3>}
body={i18nTexts.emptyPromptDescription}
/>
);
};

View file

@ -0,0 +1,121 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
EuiFlyout,
EuiFlyoutHeader,
EuiFlyoutBody,
EuiDescriptionList,
EuiTitle,
EuiFlexGroup,
EuiFlexItem,
EuiHealth,
EuiSpacer,
EuiFlyoutFooter,
EuiButtonEmpty,
EuiHorizontalRule,
} from '@elastic/eui';
import numeral from '@elastic/numeral';
import type { FileJSON } from '@kbn/files-plugin/common';
import type { FunctionComponent } from 'react';
import { Image } from '@kbn/files-plugin/public';
import React from 'react';
import { i18nTexts } from '../i18n_texts';
import { useFilesManagementContext } from '../context';
interface Props {
file: FileJSON;
onClose: () => void;
}
export const FileFlyout: FunctionComponent<Props> = ({ onClose, file }) => {
const { filesClient } = useFilesManagementContext();
return (
<EuiFlyout ownFocus onClose={onClose} size="m">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h2>{file.name}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionList
type="column"
listItems={[
{
title: i18nTexts.filesFlyoutStatus,
description: (
<EuiHealth
color={
file.status === 'READY'
? 'success'
: file.status === 'AWAITING_UPLOAD' || file.status === 'UPLOADING'
? 'primary'
: 'warning'
}
>
{i18nTexts.filesStatus[file.status]}
</EuiHealth>
),
},
{
title: i18nTexts.filesFlyoutSize,
description: numeral(file.size).format('0[.]0 b'),
},
{
title: i18nTexts.filesFlyoutExtension,
description: file.extension?.toUpperCase() ?? '',
},
]}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionList
type="column"
listItems={[
{
title: i18nTexts.filesFlyoutMimeType,
description: file.mimeType ?? '',
},
{
title: i18nTexts.filesFlyoutCreated,
description: file.created,
},
{
title: i18nTexts.filesFlyoutUpdated,
description: file.updated,
},
]}
/>
</EuiFlexItem>
</EuiFlexGroup>
{file.mimeType?.startsWith('image/') && (
<>
<EuiSpacer size="l" />
<EuiHorizontalRule />
<EuiTitle size="s">
<h3>{i18nTexts.filesFlyoutPreview}</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="center" gutterSize="none">
<Image size="xl" alt={file.alt ?? ''} src={filesClient.getDownloadHref(file)} />
</EuiFlexGroup>
</>
)}
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="flexEnd">
<EuiButtonEmpty href={filesClient.getDownloadHref(file)} iconType="download">
{i18nTexts.filesFlyoutDownload}
</EuiButtonEmpty>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { EmptyPrompt } from './empty_prompt';
export { DiagnosticsFlyout } from './diagnostics_flyout';
export { FileFlyout } from './file_flyout';

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { createContext, useContext, FC } from 'react';
import type { AppContext } from './types';
const FilesManagementAppContext = createContext<AppContext>(null as unknown as AppContext);
export const FilesManagementAppContextProvider: FC<AppContext> = ({ children, filesClient }) => {
return (
<FilesManagementAppContext.Provider value={{ filesClient }}>
{children}
</FilesManagementAppContext.Provider>
);
};
export const useFilesManagementContext = () => {
const ctx = useContext(FilesManagementAppContext);
if (!ctx) {
throw new Error(
'useFilesManagementContext must be used within a FilesManagementAppContextProvider'
);
}
return ctx;
};

View file

@ -0,0 +1,101 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { FileStatus } from '@kbn/files-plugin/common';
import { i18n } from '@kbn/i18n';
export const i18nTexts = {
titleColumnName: i18n.translate('filesManagement.table.titleColumnName', {
defaultMessage: 'Name',
}),
tableListTitle: i18n.translate('filesManagement.table.title', { defaultMessage: 'Files' }),
entityName: i18n.translate('filesManagement.entityName.title', { defaultMessage: 'file' }),
retry: i18n.translate('filesManagement.button.retry', {
defaultMessage: 'Retry',
}),
entityNamePlural: i18n.translate('filesManagement.entityNamePlural.title', {
defaultMessage: 'files',
}),
emptyPromptTitle: i18n.translate('filesManagement.emptyPrompt.title', {
defaultMessage: 'No files found',
}),
emptyPromptDescription: i18n.translate('filesManagement.emptyPrompt.description', {
defaultMessage: 'Any files created in Kibana will be listed here.',
}),
size: i18n.translate('filesManagement.table.sizeColumnName', {
defaultMessage: 'Size',
}),
diagnosticsFlyoutTitle: i18n.translate('filesManagement.diagnostics.flyoutTitle', {
defaultMessage: 'Statistics',
}),
diagnosticsFlyoutSummarySectionTitle: i18n.translate(
'filesManagement.diagnostics.summarySectionTitle',
{
defaultMessage: 'Summary',
}
),
failedToFetchDiagnostics: i18n.translate('filesManagement.diagnostics.errorMessage', {
defaultMessage: 'Could not fetch statistics',
}),
diagnosticsSpaceUsed: i18n.translate('filesManagement.diagnostics.spaceUsedLabel', {
defaultMessage: 'Disk space used',
}),
diagnosticsTotalCount: i18n.translate('filesManagement.diagnostics.totalCountLabel', {
defaultMessage: 'Number of files',
}),
diagnosticsBreakdownsStatus: i18n.translate('filesManagement.diagnostics.breakdownStatusTitle', {
defaultMessage: 'Count by status',
}),
diagnosticsBreakdownsExtension: i18n.translate(
'filesManagement.diagnostics.breakdownExtensionTitle',
{
defaultMessage: 'Count by extension',
}
),
filesFlyoutSize: i18n.translate('filesManagement.filesFlyout.sizeLabel', {
defaultMessage: 'Size',
}),
filesFlyoutExtension: i18n.translate('filesManagement.filesFlyout.extensionLabel', {
defaultMessage: 'Extension',
}),
filesFlyoutMimeType: i18n.translate('filesManagement.filesFlyout.mimeTypeLabel', {
defaultMessage: 'MIME type',
}),
filesFlyoutStatus: i18n.translate('filesManagement.filesFlyout.statusLabel', {
defaultMessage: 'Status',
}),
filesFlyoutCreated: i18n.translate('filesManagement.filesFlyout.createdLabel', {
defaultMessage: 'Created',
}),
filesFlyoutUpdated: i18n.translate('filesManagement.filesFlyout.updatedLabel', {
defaultMessage: 'Updated',
}),
filesFlyoutDownload: i18n.translate('filesManagement.filesFlyout.downloadButtonLabel', {
defaultMessage: 'Download',
}),
filesFlyoutPreview: i18n.translate('filesManagement.filesFlyout.previewSectionTitle', {
defaultMessage: 'Preview',
}),
filesStatus: {
AWAITING_UPLOAD: i18n.translate('filesManagement.filesFlyout.status.awaitingUpload', {
defaultMessage: 'Awaiting upload',
}),
DELETED: i18n.translate('filesManagement.filesFlyout.status.deleted', {
defaultMessage: 'Deleted',
}),
READY: i18n.translate('filesManagement.filesFlyout.status.ready', {
defaultMessage: 'Ready to download',
}),
UPLOADING: i18n.translate('filesManagement.filesFlyout.status.uploading', {
defaultMessage: 'Uploading',
}),
UPLOAD_ERROR: i18n.translate('filesManagement.filesFlyout.status.uploadError', {
defaultMessage: 'Upload error',
}),
} as Record<FileStatus, string>,
};

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { FilesManagementPlugin } from './plugin';
export function plugin() {
return new FilesManagementPlugin();
}

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { I18nProvider, FormattedRelative } from '@kbn/i18n-react';
import type { CoreStart } from '@kbn/core/public';
import type { ManagementAppMountParams } from '@kbn/management-plugin/public';
import {
TableListViewKibanaProvider,
TableListViewKibanaDependencies,
} from '@kbn/content-management-table-list';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { StartDependencies } from './types';
import { App } from './app';
import { FilesManagementAppContextProvider } from './context';
const queryClient = new QueryClient();
export const mountManagementSection = (
coreStart: CoreStart,
startDeps: StartDependencies,
{ element }: ManagementAppMountParams
) => {
ReactDOM.render(
<I18nProvider>
<QueryClientProvider client={queryClient}>
<TableListViewKibanaProvider
{...{
core: coreStart as unknown as TableListViewKibanaDependencies['core'],
toMountPoint,
FormattedRelative,
}}
>
<FilesManagementAppContextProvider
filesClient={startDeps.files.filesClientFactory.asUnscoped()}
>
<App />
</FilesManagementAppContextProvider>
</TableListViewKibanaProvider>
</QueryClientProvider>
</I18nProvider>,
element
);
return () => {
ReactDOM.unmountComponentAtNode(element);
};
};

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { CoreSetup, Plugin } from '@kbn/core/public';
import type { ManagementAppMountParams } from '@kbn/management-plugin/public';
import { PLUGIN_ID, PLUGIN_NAME } from '../common';
import type { SetupDependencies, StartDependencies } from './types';
export class FilesManagementPlugin
implements Plugin<void, void, SetupDependencies, StartDependencies>
{
public setup(core: CoreSetup<StartDependencies>, { management }: SetupDependencies): void {
management.sections.section.kibana.registerApp({
id: PLUGIN_ID,
title: PLUGIN_NAME,
order: 1,
async mount(params: ManagementAppMountParams) {
const { mountManagementSection } = await import('./mount_management_section');
const [coreStart, depsStart] = await core.getStartServices();
return mountManagementSection(coreStart, depsStart, params);
},
});
}
public start() {}
}

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { FilesClient, FilesSetup, FilesStart } from '@kbn/files-plugin/public';
import { ManagementSetup } from '@kbn/management-plugin/public';
export interface AppContext {
filesClient: FilesClient;
}
export interface SetupDependencies {
files: FilesSetup;
management: ManagementSetup;
}
export interface StartDependencies {
files: FilesStart;
}

View file

@ -0,0 +1,14 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./target/types",
"emitDeclarationOnly": true,
"declaration": true,
},
"include": ["common/**/*", "public/**/*", "server/**/*", ".storybook/**/*"],
"kbn_references": [
{ "path": "../../core/tsconfig.json" },
{ "path": "../files/tsconfig.json" },
{ "path": "../management/tsconfig.json" }
]
}

View file

@ -828,6 +828,8 @@
"@kbn/expressions-plugin/*": ["src/plugins/expressions/*"],
"@kbn/field-formats-plugin": ["src/plugins/field_formats"],
"@kbn/field-formats-plugin/*": ["src/plugins/field_formats/*"],
"@kbn/files-management-plugin": ["src/plugins/files_management"],
"@kbn/files-management-plugin/*": ["src/plugins/files_management/*"],
"@kbn/files-plugin": ["src/plugins/files"],
"@kbn/files-plugin/*": ["src/plugins/files/*"],
"@kbn/guided-onboarding-plugin": ["src/plugins/guided_onboarding"],

View file

@ -165,6 +165,10 @@ Array [
"id": "indexPatterns",
"subFeatures": undefined,
},
Object {
"id": "filesManagement",
"subFeatures": undefined,
},
Object {
"id": "savedObjectsManagement",
"subFeatures": undefined,
@ -449,6 +453,10 @@ Array [
"id": "indexPatterns",
"subFeatures": undefined,
},
Object {
"id": "filesManagement",
"subFeatures": undefined,
},
Object {
"id": "savedObjectsManagement",
"subFeatures": undefined,
@ -759,6 +767,57 @@ Array [
]
`;
exports[`buildOSSFeatures with a basic license returns the filesManagement feature augmented with appropriate sub feature privileges 1`] = `
Array [
Object {
"privilege": Object {
"api": Array [
"files:manageFiles",
],
"app": Array [
"kibana",
],
"management": Object {
"kibana": Array [
"filesManagement",
],
},
"savedObject": Object {
"all": Array [
"files",
],
"read": Array [],
},
"ui": Array [],
},
"privilegeId": "all",
},
Object {
"privilege": Object {
"api": Array [
"files:manageFiles",
],
"app": Array [
"kibana",
],
"management": Object {
"kibana": Array [
"filesManagement",
],
},
"savedObject": Object {
"all": Array [],
"read": Array [
"files",
],
},
"ui": Array [],
},
"privilegeId": "read",
},
]
`;
exports[`buildOSSFeatures with a basic license returns the indexPatterns feature augmented with appropriate sub feature privileges 1`] = `
Array [
Object {
@ -1267,6 +1326,57 @@ Array [
]
`;
exports[`buildOSSFeatures with a enterprise license returns the filesManagement feature augmented with appropriate sub feature privileges 1`] = `
Array [
Object {
"privilege": Object {
"api": Array [
"files:manageFiles",
],
"app": Array [
"kibana",
],
"management": Object {
"kibana": Array [
"filesManagement",
],
},
"savedObject": Object {
"all": Array [
"files",
],
"read": Array [],
},
"ui": Array [],
},
"privilegeId": "all",
},
Object {
"privilege": Object {
"api": Array [
"files:manageFiles",
],
"app": Array [
"kibana",
],
"management": Object {
"kibana": Array [
"filesManagement",
],
},
"savedObject": Object {
"all": Array [],
"read": Array [
"files",
],
},
"ui": Array [],
},
"privilegeId": "read",
},
]
`;
exports[`buildOSSFeatures with a enterprise license returns the indexPatterns feature augmented with appropriate sub feature privileges 1`] = `
Array [
Object {

View file

@ -422,6 +422,45 @@ export const buildOSSFeatures = ({
},
},
},
{
id: 'filesManagement',
name: i18n.translate('xpack.features.filesManagementFeatureName', {
defaultMessage: 'Files Management',
}),
order: 1600,
category: DEFAULT_APP_CATEGORIES.management,
app: ['kibana'],
catalogue: [],
management: {
kibana: ['filesManagement'],
},
privileges: {
all: {
app: ['kibana'],
management: {
kibana: ['filesManagement'],
},
savedObject: {
all: ['files'],
read: [],
},
ui: [],
api: ['files:manageFiles'],
},
read: {
app: ['kibana'],
management: {
kibana: ['filesManagement'],
},
savedObject: {
all: [],
read: ['files'],
},
ui: [],
api: ['files:manageFiles'],
},
},
},
{
id: 'savedObjectsManagement',
name: i18n.translate('xpack.features.savedObjectsManagementFeatureName', {

View file

@ -66,6 +66,7 @@ describe('Features Plugin', () => {
"dev_tools",
"advancedSettings",
"indexPatterns",
"filesManagement",
"savedObjectsManagement",
]
`);

View file

@ -100,6 +100,7 @@ export default function ({ getService }: FtrProviderContext) {
'dev_tools',
'actions',
'enterpriseSearch',
'filesManagement',
'advancedSettings',
'indexPatterns',
'graph',

View file

@ -78,6 +78,7 @@ export default function ({ getService }: FtrProviderContext) {
'packs_all',
'packs_read',
],
filesManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
},
reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'],
};

View file

@ -45,6 +45,7 @@ export default function ({ getService }: FtrProviderContext) {
fleet: ['all', 'read', 'minimal_all', 'minimal_read'],
stackAlerts: ['all', 'read', 'minimal_all', 'minimal_read'],
actions: ['all', 'read', 'minimal_all', 'minimal_read'],
filesManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
},
global: ['all', 'read'],
space: ['all', 'read'],
@ -131,6 +132,7 @@ export default function ({ getService }: FtrProviderContext) {
dev_tools: ['all', 'read', 'minimal_all', 'minimal_read'],
advancedSettings: ['all', 'read', 'minimal_all', 'minimal_read'],
indexPatterns: ['all', 'read', 'minimal_all', 'minimal_read'],
filesManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
savedObjectsManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
osquery: [
'all',

View file

@ -68,7 +68,15 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
});
expect(sections[1]).to.eql({
sectionId: 'kibana',
sectionLinks: ['dataViews', 'objects', 'tags', 'search_sessions', 'spaces', 'settings'],
sectionLinks: [
'dataViews',
'filesManagement',
'objects',
'tags',
'search_sessions',
'spaces',
'settings',
],
});
});
});