Skip to content

Commit

Permalink
Merge pull request #64 from aleksandrychev/auditlogs
Browse files Browse the repository at this point in the history
feat: added audit logs component to the @northern.tech/common
  • Loading branch information
aleksandrychev authored Dec 4, 2024
2 parents 5217ae8 + ff400ee commit 09a24cf
Show file tree
Hide file tree
Showing 34 changed files with 3,363 additions and 8 deletions.
Binary file added assets/img/history.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 15 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/common/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ['@northern.tech/eslint-config/react.js']
extends: ['@northern.tech/eslint-config/react.js'],
ignorePatterns: ['**/*.test.js', '**/*.test.jsx'],
};
27 changes: 22 additions & 5 deletions packages/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,15 @@
"url": "git+https://github.com/NorthernTechHQ/nt-gui.git"
},
"exports": {
"./button": {
"types": "./src/button.tsx",
"import": "./dist/button.mjs",
"require": "./dist/button.js"
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./auditlogs/*": {
"types": "./dist/auditlogs/*.d.ts",
"import": "./dist/auditlogs/*.mjs",
"require": "./dist/auditlogs/*.js"
}
},
"scripts": {
Expand All @@ -34,6 +39,18 @@
"typescript": "5.6.3"
},
"peerDependencies": {
"react": "^18.x"
"react": "^18.x",
"@northern.tech/common-ui": "*",
"@northern.tech/helptips": "*",
"@northern.tech/store": "*",
"@northern.tech/utils": "*",
"@mui/icons-material": "^6.x",
"@mui/material": "^6.x",
"tss-react": "^4.x",
"react-redux": "^9.x",
"react-router-dom": "^6.x",
"dayjs": "^1.x",
"msgpack5": "^6.x",
"universal-cookie": "^7.x"
}
}
113 changes: 113 additions & 0 deletions packages/common/src/auditlogs/AuditLogsFilter.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright 2024 Northern.tech AS
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React, { useState } from 'react';

import { TextField } from '@mui/material';

import { ControlledAutoComplete } from '@northern.tech/common-ui/forms/autocomplete';
import ClickFilter from '@northern.tech/common-ui/forms/clickfilter';
import Filters from '@northern.tech/common-ui/forms/filters';
import TimeframePicker from '@northern.tech/common-ui/forms/timeframe-picker';
import { getISOStringBoundaries } from '@northern.tech/utils/helpers';

const detailsMap = {
Deployment: 'to device group',
User: 'email'
};

const getOptionLabel = option => option.title ?? option.email ?? option;

const renderOption = (props, option) => <li {...props}>{getOptionLabel(option)}</li>;

const isUserOptionEqualToValue = ({ email, id }, value) => id === value || email === value || email === value?.email;

const autoSelectProps = {
autoSelect: true,
filterSelectedOptions: true,
getOptionLabel,
handleHomeEndKeys: true,
renderOption
};

export const AuditLogsFilter = ({ groups, users, selectionState, disabled, onFiltersChange, detailsReset, auditLogsTypes, dirtyField, setDirtyField }) => {
const { detail, endDate, user, startDate, type } = selectionState;
const [date] = useState(getISOStringBoundaries(new Date()));
const { start: today, end: tonight } = date;

const typeOptionsMap = {
Deployment: groups,
User: Object.values(users)
};
const detailOptions = typeOptionsMap[type?.title] ?? [];

return (
<ClickFilter disabled={disabled}>
<Filters
initialValues={{ startDate, endDate, user, type, detail }}
defaultValues={{ startDate: today, endDate: tonight, user: '', type: null, detail: '' }}
fieldResetTrigger={detailsReset}
dirtyField={dirtyField}
clearDirty={setDirtyField}
filters={[
{
key: 'user',
title: 'Performed by',
Component: ControlledAutoComplete,
componentProps: {
...autoSelectProps,
freeSolo: true,
isOptionEqualToValue: isUserOptionEqualToValue,
options: Object.values(users),
renderInput: params => <TextField {...params} placeholder="Select a user" InputProps={{ ...params.InputProps }} />
}
},
{
key: 'type',
title: 'Filter by changes',
Component: ControlledAutoComplete,
componentProps: {
...autoSelectProps,
options: auditLogsTypes,
isOptionEqualToValue: (option, value) => option.value === value.value && option.object_type === value.object_type,
renderInput: params => <TextField {...params} placeholder="Type" InputProps={{ ...params.InputProps }} />
}
},
{
key: 'detail',
title: '',
Component: ControlledAutoComplete,
componentProps: {
...autoSelectProps,
freeSolo: true,
options: detailOptions,
disabled: !type,
renderInput: params => <TextField {...params} placeholder={detailsMap[type] || '-'} InputProps={{ ...params.InputProps }} />
}
},
{
key: 'timeframe',
title: 'Start time',
Component: TimeframePicker,
componentProps: {
tonight
}
}
]}
onChange={onFiltersChange}
/>
</ClickFilter>
);
};

export default AuditLogsFilter;
40 changes: 40 additions & 0 deletions packages/common/src/auditlogs/AuditlogsView.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright 2020 Northern.tech AS
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from 'react';

import { Button } from '@mui/material';

import { InfoHintContainer } from '@northern.tech/common-ui/info-hint';
import Loader from '@northern.tech/common-ui/loader';

export const AuditlogsView = ({ total, csvLoading, createCsvDownload, infoHintComponent = null, auditLogsFilter, children }) => {
return (
<div className="fadeIn margin-left flexbox column" style={{ marginRight: '5%' }}>
<div className="flexbox center-aligned">
<h3 className="margin-right-small">Audit log</h3>
<InfoHintContainer>{infoHintComponent}</InfoHintContainer>
</div>
{auditLogsFilter}
<div className="flexbox center-aligned" style={{ justifyContent: 'flex-end' }}>
<Loader show={csvLoading} />
<Button variant="contained" color="secondary" disabled={csvLoading || !total} onClick={createCsvDownload} style={{ marginLeft: 15 }}>
Download results as csv
</Button>
</div>
{children}
</div>
);
};

export default AuditlogsView;
110 changes: 110 additions & 0 deletions packages/common/src/auditlogs/ColumnComponents.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Copyright 2020 Northern.tech AS
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from 'react';
import { Link } from 'react-router-dom';

import DeviceIdentityDisplay from '@northern.tech/common-ui/deviceidentity';
import Time from '@northern.tech/common-ui/time';
import { DEPLOYMENT_ROUTES, auditlogTypes, canAccess } from '@northern.tech/store/constants';

const ArtifactLink = ({ item }) => <Link to={`/releases/${item.object.artifact.name}`}>View artifact</Link>;
const DeploymentLink = ({ item }) => <Link to={`${DEPLOYMENT_ROUTES.finished.route}?open=true&id=${item.object.id}`}>View deployment</Link>;
const DeviceLink = ({ item }) => <Link to={`/devices?id=${item.object.id}`}>View device</Link>;
const DeviceRejectedLink = ({ item }) => <Link to={`/devices/rejected?id=${item.object.id}`}>View device</Link>;
const TerminalSessionLink = () => <a>View session log</a>;
const ChangeFallback = props => {
const {
item: { change = '-' }
} = props;
return <div>{change}</div>;
};

const FallbackFormatter = props => {
let result = '';
try {
result = JSON.stringify(props);
} catch (error) {
console.log(error);
}
return <div>{result}</div>;
};

const ArtifactFormatter = ({ artifact }) => <div>{artifact.name}</div>;
const DeploymentFormatter = ({ deployment }) => <div>{deployment.name}</div>;
const DeviceFormatter = ({ id }) => <DeviceIdentityDisplay device={{ id }} />;
const UserFormatter = ({ user }) => <div>{user.email}</div>;
const TenantFormatter = ({ tenant }) => <div>{tenant.name}</div>;

const defaultAccess = canAccess;
const changeMap = {
default: { component: 'div', actionFormatter: FallbackFormatter, title: 'defaultTitle', accessCheck: defaultAccess },
artifact: { actionFormatter: ArtifactFormatter, component: ArtifactLink, accessCheck: ({ canReadReleases }) => canReadReleases },
deployment: {
actionFormatter: DeploymentFormatter,
component: DeploymentLink,
accessCheck: ({ canReadDeployments }) => canReadDeployments
},
deviceDecommissioned: { actionFormatter: DeviceFormatter, component: 'div', accessCheck: defaultAccess },
deviceRejected: { actionFormatter: DeviceFormatter, component: DeviceRejectedLink, accessCheck: ({ canReadDevices }) => canReadDevices },
deviceGeneral: { actionFormatter: DeviceFormatter, component: DeviceLink, accessCheck: ({ canReadDevices }) => canReadDevices },
deviceTerminalSession: { actionFormatter: DeviceFormatter, component: TerminalSessionLink, accessCheck: defaultAccess },
user: { actionFormatter: UserFormatter, component: ChangeFallback, accessCheck: defaultAccess },
user_access_token: { actionFormatter: FallbackFormatter, component: ChangeFallback, accessCheck: defaultAccess },
tenant: { actionFormatter: TenantFormatter, component: ChangeFallback, accessCheck: defaultAccess }
};

const mapChangeToContent = item => {
let content = changeMap[item.object.type];
if (content) {
return content;
} else if (item.object.type === 'device' && item.action.includes('terminal')) {
content = changeMap.deviceTerminalSession;
} else if (item.object.type === 'device' && item.action.includes('reject')) {
content = changeMap.deviceRejected;
} else if (item.object.type === 'device' && item.action.includes('decommission')) {
content = changeMap.deviceDecommissioned;
} else if (item.object.type === 'device') {
content = changeMap.deviceGeneral;
} else {
content = changeMap.default;
}
return content;
};

const actorMap = {
user: 'email',
device: 'id'
};

export const UserDescriptor = (item, index) => <div key={`${item.time}-${index} `}>{item.actor[actorMap[item.actor.type]]}</div>;
export const ActionDescriptor = (item, index) => (
<div className="uppercased" key={`${item.time}-${index}`}>
{item.action}
</div>
);
export const TypeDescriptor = (item, index) => (
<div className="capitalized" key={`${item.time}-${index}`}>
{auditlogTypes[item.object.type]?.title ?? item.object.type}
</div>
);
export const ChangeDescriptor = (item, index) => {
const FormatterComponent = mapChangeToContent(item).actionFormatter;
return <FormatterComponent key={`${item.time}-${index}`} {...item.object} />;
};
export const ChangeDetailsDescriptor = (item, index, userCapabilities) => {
const { component: Comp, accessCheck } = mapChangeToContent(item);
const key = `${item.time}-${index}`;
return accessCheck(userCapabilities) ? <Comp key={key} item={item} /> : <div key={key} />;
};
export const TimeWrapper = (item, index) => <Time key={`${item.time}-${index}`} value={item.time} />;
53 changes: 53 additions & 0 deletions packages/common/src/auditlogs/EventDetailsDrawerContentMap.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright 2020 Northern.tech AS
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import EventDetailsFallbackComponent from './eventdetails/FallbackComponent';
import DeviceConfiguration from './eventdetails/deviceconfiguration';
import FileTransfer from './eventdetails/filetransfer';
import PortForward from './eventdetails/portforward';
import TerminalSession from './eventdetails/terminalsession';
import { UserChange } from './eventdetails/userchange';

const changeTypes = {
user: 'user',
device: 'device',
tenant: 'tenant'
};

const configChangeDescriptor = {
set_configuration: 'definition',
deploy_configuration: 'deployment'
};

const EventDetailsDrawerContentMap = (item, FallbackComponent = EventDetailsFallbackComponent) => {
const { type } = item.object || {};
let content = { title: 'Entry details', content: FallbackComponent };
if (type === changeTypes.user) {
content = { title: `${item.action}d user`, content: UserChange };
} else if (type === changeTypes.device && item.action.includes('terminal')) {
content = { title: 'Remote session log', content: TerminalSession };
} else if (type === changeTypes.device && item.action.includes('file')) {
content = { title: 'File transfer', content: FileTransfer };
} else if (type === changeTypes.device && item.action.includes('portforward')) {
content = { title: 'Port forward', content: PortForward };
} else if (type === changeTypes.device && item.action.includes('configuration')) {
content = { title: `Device configuration ${configChangeDescriptor[item.action] || ''}`, content: DeviceConfiguration };
} else if (type === changeTypes.device) {
content = { title: 'Device change', content: FallbackComponent };
} else if (type === changeTypes.tenant) {
content = { title: `${item.action}d tenant`, content: UserChange };
}
return content;
};

export default EventDetailsDrawerContentMap;
Loading

0 comments on commit 09a24cf

Please sign in to comment.