0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

feat(console): log details page (#1064)

This commit is contained in:
Wang Sijie 2022-06-08 15:58:50 +08:00 committed by GitHub
parent 8b1e9c2a81
commit 04211957e1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 299 additions and 7 deletions

View file

@ -21,6 +21,7 @@ const ApiResources = React.lazy(async () => import('@/pages/ApiResources'));
const ApplicationDetails = React.lazy(async () => import('@/pages/ApplicationDetails'));
const Applications = React.lazy(async () => import('@/pages/Applications'));
const AuditLogs = React.lazy(async () => import('@/pages/AuditLogs'));
const AuditLogDetails = React.lazy(async () => import('@/pages/AuditLogDetails'));
const Callback = React.lazy(async () => import('@/pages/Callback'));
const ConnectorDetails = React.lazy(async () => import('@/pages/ConnectorDetails'));
const Connectors = React.lazy(async () => import('@/pages/Connectors'));
@ -76,6 +77,7 @@ const Main = () => {
<Route path="settings" element={<Settings />} />
<Route path="audit-logs">
<Route index element={<AuditLogs />} />
<Route path=":logId" element={<AuditLogDetails />} />
</Route>
<Route path="dashboard" element={<Dashboard />} />
</Route>

View file

@ -0,0 +1,4 @@
.link {
text-decoration: none;
color: var(--color-text-link);
}

View file

@ -1,21 +1,33 @@
import { Application } from '@logto/schemas';
import { adminConsoleApplicationId } from '@logto/schemas/lib/seeds';
import React from 'react';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
import * as styles from './index.module.scss';
type Props = {
applicationId: string;
isLink?: boolean;
};
const ApplicationName = ({ applicationId }: Props) => {
const ApplicationName = ({ applicationId, isLink = false }: Props) => {
const isAdminConsole = applicationId === adminConsoleApplicationId;
const { data } = useSWR<Application>(!isAdminConsole && `/api/applications/${applicationId}`);
const name = isAdminConsole ? 'Admin Console' : data?.name;
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
return <span>{name || '-'}</span>;
const name = (isAdminConsole ? 'Admin Console' : data?.name) || '-';
if (isLink && !isAdminConsole) {
return (
<Link className={styles.link} to={`/applications/${applicationId}`} target="_blank">
{name}
</Link>
);
}
return <span>{name}</span>;
};
export default ApplicationName;

View file

@ -9,4 +9,9 @@
color: var(--color-caption);
margin-left: _.unit(1);
}
.link {
text-decoration: none;
color: var(--color-text-link);
}
}

View file

@ -1,6 +1,7 @@
import { User } from '@logto/schemas';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
import { RequestError } from '@/hooks/use-api';
@ -9,13 +10,15 @@ import * as styles from './index.module.scss';
type Props = {
userId: string;
isLink?: boolean;
};
const UserName = ({ userId }: Props) => {
const UserName = ({ userId, isLink = false }: Props) => {
const { data, error } = useSWR<User, RequestError>(`/api/users/${userId}`);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const isLoading = !data && !error;
const name = data?.name ?? t('users.unnamed');
if (isLoading) {
return null;
@ -23,7 +26,13 @@ const UserName = ({ userId }: Props) => {
return (
<div className={styles.userName}>
<span>{data?.name ?? t('users.unnamed')}</span>
{isLink ? (
<Link to={`/users/${userId}`} target="_blank" className={styles.link}>
{name}
</Link>
) : (
<span>{name}</span>
)}
<span className={styles.userId}>{userId}</span>
</div>
);

View file

@ -16,6 +16,6 @@ export const logEventTitle: LogEventTitle = Object.freeze({
SignInSocialBind: 'Sign in with social related account',
SignInSocial: 'Sign in with social account',
CodeExchangeToken: 'Exchange token by auth code',
RefreshTokenExchangeToken: 'Exchagne token by refresh token',
RefreshTokenExchangeToken: 'Exchange token by refresh token',
RevokeToken: 'Revoke token',
});

View file

@ -0,0 +1,12 @@
import React from 'react';
const Failed = () => (
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M49.1918 10.5544C47.5974 9.52057 45.469 9.97445 44.4363 11.5676L25.1691 41.2661L15.7728 32.5781C14.3779 31.2887 12.2036 31.3735 10.9141 32.7684C9.62469 34.1622 9.70951 36.3387 11.1044 37.627L23.4418 49.0338C23.4418 49.0338 23.796 49.3386 23.9599 49.4452C24.5376 49.8212 25.1863 50 25.8282 50C26.9537 50 28.0575 49.4475 28.7165 48.432L50.205 15.3099C51.2388 13.7167 50.7849 11.5871 49.1918 10.5544Z"
fill="currentColor"
/>
</svg>
);
export default Failed;

View file

@ -0,0 +1,12 @@
import React from 'react';
const Success = () => (
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M49.1918 10.5544C47.5974 9.52057 45.469 9.97445 44.4363 11.5676L25.1691 41.2661L15.7728 32.5781C14.3779 31.2887 12.2036 31.3735 10.9141 32.7684C9.62469 34.1622 9.70951 36.3387 11.1044 37.627L23.4418 49.0338C23.4418 49.0338 23.796 49.3386 23.9599 49.4452C24.5376 49.8212 25.1863 50 25.8282 50C26.9537 50 28.0575 49.4475 28.7165 48.432L50.205 15.3099C51.2388 13.7167 50.7849 11.5871 49.1918 10.5544Z"
fill="currentColor"
/>
</svg>
);
export default Success;

View file

@ -0,0 +1,18 @@
.icon {
width: 60px;
height: 60px;
&.success {
color: var(--color-success-60);
}
&.fail {
color: var(--color-error-40);
}
}
.label {
color: var(--color-icon);
font: var(--font-body-medium);
text-align: center;
}

View file

@ -0,0 +1,28 @@
import classNames from 'classnames';
import React from 'react';
import { useTranslation } from 'react-i18next';
import Failed from './Failed';
import Success from './Success';
import * as styles from './index.module.scss';
type Props = {
isSuccess: boolean;
};
const EventIcon = ({ isSuccess }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<div>
<div className={classNames(styles.icon, isSuccess ? styles.success : styles.fail)}>
{isSuccess ? <Success /> : <Failed />}
</div>
<div className={styles.label}>
{t(isSuccess ? 'log_details.success' : 'log_details.failed')}
</div>
</div>
);
};
export default EventIcon;

View file

@ -0,0 +1,56 @@
@use '@/scss/underscore' as _;
.backLink {
margin: _.unit(1) 0 0 _.unit(1);
}
.header {
padding: _.unit(6);
display: flex;
> *:not(:first-child) {
margin-left: _.unit(6);
}
.content {
flex: 1;
.eventName {
color: var(--color-text);
font: var(--font-title-large);
}
.basicInfo {
display: flex;
align-items: center;
flex-wrap: wrap;
padding-top: _.unit(4);
> * {
width: 270px;
margin: 0 _.unit(4) _.unit(4) 0;
}
}
.infoItem {
color: var(--color-text);
font: var(--font-body-medium);
.label {
color: var(--color-caption);
font: var(--font-subhead-2);
margin-bottom: _.unit(2);
}
}
}
}
.body {
> :not(:first-child) {
margin-top: _.unit(4);
}
.main {
padding-bottom: _.unit(4);
}
}

View file

@ -0,0 +1,104 @@
import { LogDTO } from '@logto/schemas';
import classNames from 'classnames';
import dayjs from 'dayjs';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import useSWR from 'swr';
import ApplicationName from '@/components/ApplicationName';
import Card from '@/components/Card';
import CodeEditor from '@/components/CodeEditor';
import DetailsSkeleton from '@/components/DetailsSkeleton';
import LinkButton from '@/components/LinkButton';
import TabNav, { TabNavItem } from '@/components/TabNav';
import UserName from '@/components/UserName';
import { logEventTitle } from '@/consts/logs';
import { RequestError } from '@/hooks/use-api';
import Back from '@/icons/Back';
import * as detailsStyles from '@/scss/details.module.scss';
import EventIcon from './components/EventIcon';
import * as styles from './index.module.scss';
const AuditLogDetails = () => {
const { logId } = useParams();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data, error } = useSWR<LogDTO, RequestError>(logId && `/api/logs/${logId}`);
const isLoading = !data && !error;
return (
<div className={detailsStyles.container}>
<LinkButton
to="/audit-logs"
icon={<Back />}
title="admin_console.log_details.back_to_logs"
className={styles.backLink}
/>
{isLoading && <DetailsSkeleton />}
{!data && error && <div>{`error occurred: ${error.body?.message ?? error.message}`}</div>}
{data && (
<>
<Card className={styles.header}>
<EventIcon isSuccess={data.payload.result === 'Success'} />
<div className={styles.content}>
<div className={styles.eventName}>{logEventTitle[data.type]}</div>
<div className={styles.basicInfo}>
<div className={styles.infoItem}>
<div className={styles.label}>{t('log_details.event_type')}</div>
<div>{data.type}</div>
</div>
<div className={styles.infoItem}>
<div className={styles.label}>{t('log_details.application')}</div>
<div>
{data.payload.applicationId ? (
<ApplicationName isLink applicationId={data.payload.applicationId} />
) : (
'-'
)}
</div>
</div>
<div className={styles.infoItem}>
<div className={styles.label}>{t('log_details.ip_address')}</div>
<div>{data.payload.ip ?? '-'}</div>
</div>
<div className={styles.infoItem}>
<div className={styles.label}>{t('log_details.user')}</div>
<div>
{data.payload.userId ? <UserName isLink userId={data.payload.userId} /> : '-'}
</div>
</div>
<div className={styles.infoItem}>
<div className={styles.label}>{t('log_details.log_id')}</div>
<div>{data.id}</div>
</div>
<div className={styles.infoItem}>
<div className={styles.label}>{t('log_details.time')}</div>
<div>{dayjs(data.createdAt).toDate().toLocaleString()}</div>
</div>
</div>
<div>
<div className={styles.infoItem}>
<div className={styles.label}>{t('log_details.user_agent')}</div>
<div>{data.payload.userAgent}</div>
</div>
</div>
</div>
</Card>
<Card className={classNames(styles.body, detailsStyles.body)}>
<TabNav>
<TabNavItem href={`/audit-logs/${logId ?? ''}`}>
{t('log_details.tab_details')}
</TabNavItem>
</TabNav>
<div className={styles.main}>
<CodeEditor language="json" value={JSON.stringify(data.payload, null, 2)} />
</div>
</Card>
</>
)}
</div>
);
};
export default AuditLogDetails;

View file

@ -545,6 +545,20 @@ const translation = {
time: 'Time',
filter_by: 'Filter by',
},
log_details: {
back_to_logs: 'Back to Audit Logs',
success: 'Success',
failed: 'Failed',
event_type: 'Event Type',
application: 'Application',
ip_address: 'IP Address',
user: 'User',
log_id: 'Log ID',
time: 'Time',
user_agent: 'User Agent',
tab_details: 'Details',
raw_data: 'Raw data',
},
session_expired: {
title: 'Session Expired',
subtitle:

View file

@ -529,6 +529,20 @@ const translation = {
time: '时间',
filter_by: '过滤',
},
log_details: {
back_to_logs: '返回审计日志',
success: '成功',
failed: '失败',
event_type: '事件类型',
application: '应用',
ip_address: 'IP 地址',
user: '用户',
log_id: '日志 ID',
time: '时间',
user_agent: '用户代理',
tab_details: '详情',
raw_data: '原始数据',
},
session_expired: {
title: '会话已过期',
subtitle: '会话可能已过期,您已被登出. 请点击下方按钮重新登录到管理界面.',

View file

@ -145,5 +145,7 @@ export type LogDTO = Omit<Log, 'payload'> & {
userId?: string;
applicationId?: string;
result?: string;
userAgent?: string;
ip?: string;
};
};