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
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:
parent
960d18998f
commit
c22d280491
29 changed files with 659 additions and 429 deletions
30
app/react/components/JsonTree.css
Normal file
30
app/react/components/JsonTree.css
Normal 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 */
|
41
app/react/components/JsonTree.tsx
Normal file
41
app/react/components/JsonTree.tsx
Normal 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'),
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
110
app/react/portainer/logs/ActivityLogsView/ActivityLogsTable.tsx
Normal file
110
app/react/portainer/logs/ActivityLogsView/ActivityLogsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
45
app/react/portainer/logs/ActivityLogsView/FilterBar.tsx
Normal file
45
app/react/portainer/logs/ActivityLogsView/FilterBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
8
app/react/portainer/logs/ActivityLogsView/types.ts
Normal file
8
app/react/portainer/logs/ActivityLogsView/types.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
export interface ActivityLog {
|
||||
timestamp: number;
|
||||
action: string;
|
||||
context: string;
|
||||
id: number;
|
||||
payload: object;
|
||||
username: string;
|
||||
}
|
58
app/react/portainer/logs/ActivityLogsView/useActivityLogs.ts
Normal file
58
app/react/portainer/logs/ActivityLogsView/useActivityLogs.ts
Normal 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');
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
65
app/react/portainer/logs/components/DateRangePicker.tsx
Normal file
65
app/react/portainer/logs/components/DateRangePicker.tsx
Normal 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(),
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue