1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 05:45:22 +02:00

refactor(activity-logs): migrate activity logs table to react [EE-4714] (#10891)
Some checks are pending
ci / build_images (map[arch:amd64 platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
ci / build_images (map[arch:arm platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:arm64 platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:ppc64le platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:s390x platform:linux version:]) (push) Waiting to run
ci / build_manifests (push) Blocked by required conditions
/ triage (push) Waiting to run
Lint / Run linters (push) Waiting to run
Test / test-client (push) Waiting to run
Test / test-server (map[arch:amd64 platform:linux]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
Test / test-server (map[arch:arm64 platform:linux]) (push) Waiting to run

This commit is contained in:
Chaim Lev-Ari 2024-04-09 08:53:23 +03:00 committed by GitHub
parent 960d18998f
commit c22d280491
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 659 additions and 429 deletions

View file

@ -0,0 +1,30 @@
/* json-tree override */
.json-tree,
json-tree {
font-size: 13px;
color: var(--blue-5);
}
.json-tree .key,
json-tree .key {
color: var(--text-json-tree-color);
}
json-tree .key {
padding-right: 5px;
}
.json-tree .branch-preview,
json-tree .branch-preview {
color: var(--text-json-tree-branch-preview-color);
font-style: normal;
font-size: 11px;
opacity: 0.5;
}
.json-tree .leaf-value,
json-tree .leaf-value {
color: var(--text-json-tree-leaf-color);
}
/* !json-tree override */

View file

@ -0,0 +1,41 @@
import { ComponentProps } from 'react';
import { JsonView, defaultStyles } from 'react-json-view-lite';
import 'react-json-view-lite/dist/index.css';
import clsx from 'clsx';
import './JsonTree.css';
export function JsonTree({ style, ...props }: ComponentProps<typeof JsonView>) {
const currentStyle = getCurrentStyle(style);
return (
<JsonView
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
style={currentStyle}
/>
);
}
type StyleProps = ComponentProps<typeof JsonView>['style'];
function getCurrentStyle(style: StyleProps | undefined): StyleProps {
if (style) {
return style;
}
return {
...defaultStyles,
container: 'json-tree',
booleanValue: 'leaf-value',
nullValue: 'leaf-value',
otherValue: 'leaf-value',
numberValue: 'leaf-value',
stringValue: 'leaf-value',
undefinedValue: 'leaf-value',
label: 'key',
punctuation: 'leaf-value',
collapseIcon: clsx(defaultStyles.collapseIcon, 'key'),
expandIcon: clsx(defaultStyles.expandIcon, 'key'),
collapsedContent: clsx(defaultStyles.collapsedContent, 'branch-preview'),
};
}

View file

@ -16,6 +16,9 @@ import { TextTip } from '@@/Tip/TextTip';
import { FormValues } from './types';
import 'react-datetime-picker/dist/DateTimePicker.css';
import 'react-calendar/dist/Calendar.css';
interface Props {
disabled?: boolean;
}

View file

@ -0,0 +1,110 @@
import { createColumnHelper } from '@tanstack/react-table';
import { History, Search } from 'lucide-react';
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
import { Button } from '@@/buttons';
import { JsonTree } from '@@/JsonTree';
import { ActivityLog } from './types';
import { getSortType } from './useActivityLogs';
const columnHelper = createColumnHelper<ActivityLog>();
const columns = [
columnHelper.accessor('timestamp', {
id: 'Timestamp',
header: 'Time',
cell: ({ getValue }) => {
const value = getValue();
return value ? isoDateFromTimestamp(value) : '';
},
}),
columnHelper.accessor('username', {
id: 'Username',
header: 'User',
}),
columnHelper.accessor('context', {
id: 'Context',
header: 'Environment',
}),
columnHelper.accessor('action', {
id: 'Action',
header: 'Action',
}),
columnHelper.accessor('payload', {
header: 'Payload',
enableSorting: false,
cell: ({ row, getValue }) =>
getValue() ? (
<Button color="link" onClick={() => row.toggleExpanded()} icon={Search}>
inspect
</Button>
) : null,
}),
];
export function ActivityLogsTable({
dataset,
currentPage,
keyword,
limit,
onChangeKeyword,
onChangeLimit,
onChangePage,
onChangeSort,
sort,
totalItems,
}: {
keyword: string;
onChangeKeyword(keyword: string): void;
sort: { id: string; desc: boolean } | undefined;
onChangeSort(sort: { id: string; desc: boolean } | undefined): void;
limit: number;
onChangeLimit(limit: number): void;
currentPage: number;
onChangePage(page: number): void;
totalItems: number;
dataset?: Array<ActivityLog>;
}) {
return (
<ExpandableDatatable<ActivityLog>
title="Activity Logs"
titleIcon={History}
columns={columns}
dataset={dataset || []}
isLoading={!dataset}
settingsManager={{
pageSize: limit,
search: keyword,
setPageSize: onChangeLimit,
setSearch: onChangeKeyword,
setSortBy: (id, desc) =>
onChangeSort({ id: getSortType(id) || 'Timestamp', desc }),
sortBy: sort
? {
id: sort.id,
desc: sort.desc,
}
: undefined,
}}
page={currentPage}
onPageChange={onChangePage}
isServerSidePagination
totalCount={totalItems}
disableSelect
renderSubRow={(row) => <SubRow item={row.original} />}
/>
);
}
function SubRow({ item }: { item: ActivityLog }) {
return (
<tr>
<td colSpan={Number.MAX_SAFE_INTEGER}>
<JsonTree data={item.payload} />
</td>
</tr>
);
}

View file

@ -0,0 +1,83 @@
import { useState } from 'react';
import { PageHeader } from '@@/PageHeader';
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
import { BEOverlay } from '@@/BEFeatureIndicator/BEOverlay';
import { FeatureId } from '../../feature-flags/enums';
import { ActivityLogsTable } from './ActivityLogsTable';
import { useActivityLogs, getSortType } from './useActivityLogs';
import { useExportMutation } from './useExportMutation';
import { FilterBar } from './FilterBar';
export function ActivityLogsView() {
const exportMutation = useExportMutation();
const [range, setRange] = useState<
{ start: Date; end: Date | null } | undefined
>(undefined);
const [page, setPage] = useState(0);
const tableState = useTableStateWithoutStorage('Timestamp');
const offset = page * tableState.pageSize;
const query = {
offset,
limit: tableState.pageSize,
sortBy: getSortType(tableState.sortBy?.id),
desc: tableState.sortBy?.desc,
search: tableState.search,
...(range
? {
after: seconds(range?.start?.valueOf()),
before: seconds(range?.end?.valueOf()),
}
: undefined),
};
const logsQuery = useActivityLogs(query);
return (
<>
<PageHeader title="User Activity" breadcrumbs="Activity Logs" reload />
<div className="mx-4">
<BEOverlay featureId={FeatureId.ACTIVITY_AUDIT}>
<FilterBar
value={range}
onChange={setRange}
onExport={handleExport}
/>
<div className="-mx-[15px] mt-4">
<ActivityLogsTable
sort={tableState.sortBy}
onChangeSort={(value) =>
tableState.setSortBy(value?.id, value?.desc || false)
}
limit={tableState.pageSize}
onChangeLimit={tableState.setPageSize}
keyword={tableState.search}
onChangeKeyword={tableState.setSearch}
currentPage={page}
onChangePage={setPage}
totalItems={logsQuery.data?.totalCount || 0}
dataset={logsQuery.data?.logs}
/>
</div>
</BEOverlay>
</div>
</>
);
function handleExport() {
exportMutation.mutate(query);
}
}
function seconds(ms?: number) {
if (!ms) {
return undefined;
}
return Math.floor(ms / 1000);
}

View file

@ -0,0 +1,45 @@
import { DownloadIcon } from 'lucide-react';
import { Widget } from '@@/Widget';
import { TextTip } from '@@/Tip/TextTip';
import { Button } from '@@/buttons';
import { BEFeatureIndicator } from '@@/BEFeatureIndicator';
import { FeatureId } from '../../feature-flags/enums';
import { DateRangePicker } from '../components/DateRangePicker';
export function FilterBar({
value,
onChange,
onExport,
}: {
value: { start: Date; end: Date | null } | undefined;
onChange: (value?: { start: Date; end: Date | null }) => void;
onExport: () => void;
}) {
return (
<Widget>
<Widget.Body>
<form className="form-horizontal">
<DateRangePicker value={value} onChange={onChange} />
<TextTip color="blue">
Portainer user activity logs have a maximum retention of 7 days.
</TextTip>
<div className="mt-4">
<Button
color="primary"
icon={DownloadIcon}
onClick={onExport}
className="!ml-0"
>
Export as CSV
</Button>
<BEFeatureIndicator featureId={FeatureId.ACTIVITY_AUDIT} />
</div>
</form>
</Widget.Body>
</Widget>
);
}

View file

@ -0,0 +1,8 @@
export interface ActivityLog {
timestamp: number;
action: string;
context: string;
id: number;
payload: object;
username: string;
}

View file

@ -0,0 +1,58 @@
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { isBE } from '../../feature-flags/feature-flags.service';
import { ActivityLog } from './types';
export const sortKeys = ['Context', 'Action', 'Timestamp', 'Username'] as const;
export type SortKey = (typeof sortKeys)[number];
export function isSortKey(value?: string): value is SortKey {
return !!value && sortKeys.includes(value as SortKey);
}
export function getSortType(value?: string): SortKey | undefined {
return isSortKey(value) ? value : undefined;
}
export interface Query {
offset: number;
limit: number;
sortBy?: SortKey;
desc?: boolean;
search: string;
after?: number;
before?: number;
}
export function useActivityLogs(query: Query) {
return useQuery({
queryKey: ['activityLogs', query] as const,
queryFn: () => fetchActivityLogs(query),
keepPreviousData: true,
});
}
interface ActivityLogsResponse {
logs: Array<ActivityLog>;
totalCount: number;
}
async function fetchActivityLogs(query: Query): Promise<ActivityLogsResponse> {
try {
if (!isBE) {
return {
logs: [{}, {}, {}, {}, {}] as Array<ActivityLog>,
totalCount: 5,
};
}
const { data } = await axios.get<ActivityLogsResponse>(
'/useractivity/logs',
{ params: query }
);
return data;
} catch (err) {
throw parseAxiosError(err, 'Failed loading user activity logs csv');
}
}

View file

@ -0,0 +1,32 @@
import { useMutation } from 'react-query';
import { saveAs } from 'file-saver';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { Query } from './useActivityLogs';
export function useExportMutation() {
return useMutation({
mutationFn: exportActivityLogs,
});
}
async function exportActivityLogs(query: Omit<Query, 'limit'>) {
try {
const { data, headers } = await axios.get<Blob>('/useractivity/logs.csv', {
params: { ...query, limit: 2000 },
responseType: 'blob',
headers: {
'Content-type': 'text/csv',
},
});
const contentDispositionHeader = headers['content-disposition'] || '';
const filename =
contentDispositionHeader.replace('attachment; filename=', '').trim() ||
'logs.csv';
saveAs(data, filename);
} catch (err) {
throw parseAxiosError(err, 'Failed loading user activity logs csv');
}
}

View file

@ -0,0 +1,65 @@
import WojtekmajRangePicker from '@wojtekmaj/react-daterange-picker';
import { Calendar, X } from 'lucide-react';
import { date, object, SchemaOf } from 'yup';
import { FormikErrors } from 'formik';
import '@wojtekmaj/react-daterange-picker/dist/DateRangePicker.css';
import 'react-calendar/dist/Calendar.css';
import { FormControl } from '@@/form-components/FormControl';
import 'react-datetime-picker/dist/DateTimePicker.css';
type Value = { start: Date; end: Date | null };
export function DateRangePicker({
value,
onChange,
name,
error,
}: {
value: Value | undefined;
onChange: (value?: Value) => void;
name?: string;
error?: FormikErrors<Value>;
}) {
return (
<FormControl label="Date Range" errors={error}>
<div className="w-1/2">
<WojtekmajRangePicker
format="y-MM-dd"
className="form-control [&>div]:border-0"
value={value ? [value.start, value.end] : null}
onChange={(date) => {
if (!date) {
onChange(undefined);
return;
}
if (Array.isArray(date)) {
if (date.length === 2 && date[0] && date[1]) {
onChange({
start: date[0],
end: date[1],
});
return;
}
onChange(undefined);
return;
}
onChange({ start: date, end: null });
}}
name={name}
calendarIcon={<Calendar />}
clearIcon={<X />}
/>
</div>
</FormControl>
);
}
export function dateRangePickerValidation(): SchemaOf<Value> {
return object({
start: date().required(),
end: date().nullable().default(null).required(),
});
}