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

feat(console): add webhook logs page (#3862)

This commit is contained in:
Xiao Yijun 2023-05-22 22:19:15 +08:00 committed by GitHub
parent b92508db3a
commit af42e87bc0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 247 additions and 47 deletions

View file

@ -1,25 +1,27 @@
import { useTranslation } from 'react-i18next';
import Select from '@/components/Select';
import Select, { type Option } from '@/components/Select';
import { logEventTitle } from '@/consts/logs';
type Props = {
value?: string;
onChange: (value?: string) => void;
options?: Array<Option<string>>;
};
function EventSelector({ value, onChange }: Props) {
const defaultEventOptions = Object.entries(logEventTitle).map(([value, title]) => ({
value,
title: title ?? value,
}));
function EventSelector({ value, onChange, options }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const options = Object.entries(logEventTitle).map(([value, title]) => ({
value,
title: title ?? value,
}));
return (
<Select
isClearable
value={value}
options={options}
options={options ?? defaultEventOptions}
placeholder={t('logs.event')}
onChange={onChange}
/>

View file

@ -61,6 +61,8 @@
tbody {
tr {
cursor: default;
td {
font: var(--font-body-2);
border-top: 1px solid var(--color-divider);

View file

@ -1,7 +1,7 @@
@use '@/scss/underscore' as _;
.tag {
display: flex;
display: inline-flex;
align-items: center;
font: var(--font-body-2);

View file

@ -10,6 +10,7 @@ export enum ConnectorsTabs {
export enum WebhookDetailsTabs {
Settings = 'settings',
RecentRequests = 'recent-requests',
}
export enum SignInExperiencePage {

View file

@ -1,5 +1,5 @@
import { type AdminConsoleKey } from '@logto/phrases';
import { HookEvent } from '@logto/schemas';
import { HookEvent, type LogKey } from '@logto/schemas';
import { yes } from '@silverhand/essentials';
type HookEventLabel = {
@ -12,4 +12,14 @@ export const hookEventLabel = Object.freeze({
[HookEvent.PostSignIn]: 'webhooks.events.post_sign_in',
}) satisfies HookEventLabel;
type HookEventLogKey = {
[key in HookEvent]: LogKey;
};
export const hookEventLogKey = Object.freeze({
[HookEvent.PostRegister]: 'TriggerHook.PostRegister',
[HookEvent.PostResetPassword]: 'TriggerHook.PostResetPassword',
[HookEvent.PostSignIn]: 'TriggerHook.PostSignIn',
}) satisfies HookEventLogKey;
export const isHookFeatureEnabled = yes(process.env.HOOK_FEATURE_ENABLED);

View file

@ -40,6 +40,7 @@ import UserRoles from '@/pages/UserDetails/UserRoles';
import UserSettings from '@/pages/UserDetails/UserSettings';
import Users from '@/pages/Users';
import WebhookDetails from '@/pages/WebhookDetails';
import WebhookLogs from '@/pages/WebhookDetails/WebhookLogs';
import WebhookSettings from '@/pages/WebhookDetails/WebhookSettings';
import Webhooks from '@/pages/Webhooks';
@ -96,7 +97,12 @@ function ConsoleContent() {
<Route path=":id" element={<WebhookDetails />}>
<Route index element={<Navigate replace to={WebhookDetailsTabs.Settings} />} />
<Route path={WebhookDetailsTabs.Settings} element={<WebhookSettings />} />
<Route path={WebhookDetailsTabs.RecentRequests} element={<WebhookLogs />} />
</Route>
<Route
path={`:hookId/${WebhookDetailsTabs.RecentRequests}/:logId`}
element={<AuditLogDetails />}
/>
</Route>
)}
<Route path="users">
@ -108,7 +114,10 @@ function ConsoleContent() {
<Route path={UserDetailsTabs.Roles} element={<UserRoles />} />
<Route path={UserDetailsTabs.Logs} element={<UserLogs />} />
</Route>
<Route path={`:id/${UserDetailsTabs.Logs}/:logId`} element={<AuditLogDetails />} />
<Route
path={`:userId/${UserDetailsTabs.Logs}/:logId`}
element={<AuditLogDetails />}
/>
</Route>
<Route path="audit-logs">
<Route index element={<AuditLogs />} />

View file

@ -1,6 +1,7 @@
import { withAppInsights } from '@logto/app-insights/react';
import type { User, Log } from '@logto/schemas';
import type { User, Log, Hook } from '@logto/schemas';
import { demoAppApplicationId } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useTranslation } from 'react-i18next';
import { useLocation, useParams } from 'react-router-dom';
import useSWR from 'swr';
@ -16,6 +17,7 @@ import TabNav, { TabNavItem } from '@/components/TabNav';
import UserName from '@/components/UserName';
import { logEventTitle } from '@/consts/logs';
import type { RequestError } from '@/hooks/use-api';
import { getUserTitle } from '@/utils/user';
import EventIcon from './components/EventIcon';
import * as styles from './index.module.scss';
@ -27,18 +29,31 @@ const getDetailsTabNavLink = (logId: string, userId?: string) =>
userId ? `/users/${userId}/logs/${logId}` : `/audit-logs/${logId}`;
function AuditLogDetails() {
const { id, logId } = useParams();
const { userId, hookId, logId } = useParams();
const { pathname } = useLocation();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data, error, mutate } = useSWR<Log, RequestError>(logId && `api/logs/${logId}`);
const { data: userData } = useSWR<User, RequestError>(id && `api/users/${id}`);
const { data: userData } = useSWR<User, RequestError>(userId && `api/users/${userId}`);
const { data: hookData } = useSWR<Hook, RequestError>(hookId && `api/hooks/${hookId}`);
const isLoading = !data && !error;
const backLink = getAuditLogDetailsRelatedResourceLink(pathname);
const backLinkTitle = id
? t('log_details.back_to_user', { name: userData?.name ?? t('users.unnamed') })
: t('log_details.back_to_logs');
const backLinkTitle =
conditional(
userId &&
t('log_details.back_to', {
name: conditional(userData && getUserTitle(userData)) ?? t('users.unnamed'),
})
) ??
conditional(
hookId &&
t('log_details.back_to', {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
name: hookData?.name || t('users.unnamed'),
})
) ??
t('log_details.back_to_logs');
if (!logId) {
return null;
@ -105,7 +120,7 @@ function AuditLogDetails() {
</div>
</Card>
<TabNav>
<TabNavItem href={getDetailsTabNavLink(logId, id)}>
<TabNavItem href={getDetailsTabNavLink(logId, userId)}>
{t('log_details.tab_details')}
</TabNavItem>
</TabNav>

View file

@ -0,0 +1,25 @@
@use '@/scss/underscore' as _;
.logs {
flex: 1;
margin-bottom: _.unit(6);
overflow-y: auto;
}
.filter {
display: flex;
justify-content: flex-end;
align-items: center;
.title {
color: var(--color-text-secondary);
font: var(--font-body-2);
}
.eventSelector {
width: 300px;
margin-left: _.unit(2);
}
}

View file

@ -0,0 +1,126 @@
import { type Log, HookEvent } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate, useOutletContext } from 'react-router-dom';
import useSWR from 'swr';
import { z } from 'zod';
import EventSelector from '@/components/AuditLogTable/components/EventSelector';
import DynamicT from '@/components/DynamicT';
import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder';
import Table from '@/components/Table';
import Tag from '@/components/Tag';
import { defaultPageSize } from '@/consts';
import { hookEventLabel, hookEventLogKey } from '@/consts/webhooks';
import { type RequestError } from '@/hooks/use-api';
import useSearchParametersWatcher from '@/hooks/use-search-parameters-watcher';
import { buildUrl } from '@/utils/url';
import { type WebhookDetailsOutletContext } from '../types';
import * as styles from './index.module.scss';
const hooLogEventOptions = Object.values(HookEvent).map((event) => ({
title: <DynamicT forKey={hookEventLabel[event]} />,
value: hookEventLogKey[event],
}));
function WebhookLogs() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { pathname } = useLocation();
const navigate = useNavigate();
const {
hook: { id },
} = useOutletContext<WebhookDetailsOutletContext>();
const pageSize = defaultPageSize;
const [{ page, event }, updateSearchParameters] = useSearchParametersWatcher({
page: 1,
event: '',
startTimeExclusive: '',
});
const url = buildUrl(`api/hooks/${id}/recent-logs`, {
page: String(page),
page_size: String(pageSize),
...conditional(event && { logKey: event }),
});
const { data, error, mutate } = useSWR<[Log[], number], RequestError>(url);
const isLoading = !data && !error;
const [logs, totalCount] = data ?? [];
return (
<Table
className={styles.logs}
rowGroups={[{ key: 'logs', data: logs }]}
rowIndexKey="id"
rowClickHandler={({ id }) => {
navigate(`${pathname}/${id}`);
}}
filter={
<div className={styles.filter}>
<div className={styles.title}>{t('logs.filter_by')}</div>
<div className={styles.eventSelector}>
<EventSelector
value={event}
options={hooLogEventOptions}
onChange={(event) => {
updateSearchParameters({ event, page: undefined });
}}
/>
</div>
</div>
}
columns={[
{
title: 'Status',
dataIndex: 'status',
colSpan: 5,
render: ({ payload }) => {
const result = z
.object({ response: z.object({ statusCode: z.number().optional() }) })
.optional()
.safeParse(payload);
const statusCode = result.success ? result.data?.response.statusCode : undefined;
const isError = !statusCode || statusCode >= 400;
return (
<Tag type="result" status={isError ? 'error' : 'success'}>
{statusCode ?? 'Request error'}
</Tag>
);
},
},
{
title: t('logs.event'),
dataIndex: 'event',
colSpan: 6,
render: ({ key }) => {
const event = Object.values(HookEvent).find((event) => hookEventLogKey[event] === key);
return conditional(event && t(hookEventLabel[event])) ?? '-';
},
},
{
title: t('logs.time'),
dataIndex: 'time',
colSpan: 5,
render: ({ createdAt }) => new Date(createdAt).toLocaleString(),
},
]}
placeholder={<EmptyDataPlaceholder />}
pagination={{
page,
totalCount,
pageSize,
onChange: (page) => {
updateSearchParameters({ page });
},
}}
isLoading={isLoading}
errorMessage={error?.body?.message ?? error?.message}
onRetry={async () => mutate(undefined, true)}
/>
);
}
export default WebhookLogs;

View file

@ -1,5 +1,9 @@
@use '@/scss/underscore' as _;
.containsTableLayout {
height: 100%;
}
.header {
padding: _.unit(6);
display: flex;

View file

@ -1,5 +1,6 @@
import { withAppInsights } from '@logto/app-insights/react';
import { type Hook } from '@logto/schemas';
import classNames from 'classnames';
import { useEffect, useState } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
@ -32,6 +33,7 @@ import { type WebhookDetailsOutletContext } from './types';
function WebhookDetails() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { pathname } = useLocation();
const isPageHasTable = pathname.endsWith(WebhookDetailsTabs.RecentRequests);
const navigate = useNavigate();
const { id } = useParams();
const { data, error, mutate } = useSWR<Hook, RequestError>(id && `api/hooks/${id}`);
@ -93,6 +95,7 @@ function WebhookDetails() {
<DetailsPage
backLink="/webhooks"
backLinkTitle="webhook_details.back_to_webhooks"
className={classNames(isPageHasTable && styles.containsTableLayout)}
isLoading={isLoading}
error={error}
onRetry={mutate}
@ -178,6 +181,9 @@ function WebhookDetails() {
<TabNavItem href={`/webhooks/${data.id}/${WebhookDetailsTabs.Settings}`}>
<DynamicT forKey="webhook_details.settings_tab" />
</TabNavItem>
<TabNavItem href={`/webhooks/${data.id}/${WebhookDetailsTabs.RecentRequests}`}>
<DynamicT forKey="webhook_details.recent_requests_tab" />
</TabNavItem>
</TabNav>
<Outlet
context={

View file

@ -1,7 +1,7 @@
const log_details = {
page_title: 'Audit Log Details',
back_to_logs: 'Zurück zu Audit Logs',
back_to_user: 'Zurück zu {{name}}',
back_to: 'Zurück zu {{name}}',
success: 'Erfolgreich',
failed: 'Fehlgeschlagen',
event_key: 'Event Schlüssel',

View file

@ -15,7 +15,7 @@ const webhook_details = {
'Sie entfernen diesen Webhook. Nach dem Löschen wird keine HTTP-Anforderung an die Endpunkt-URL gesendet.',
deleted: 'Der Webhook wurde erfolgreich gelöscht.',
settings_tab: 'Einstellungen',
recent_requests_tab: 'Letzte Anforderungen',
recent_requests_tab: 'Letzte Anforderungen (24h)',
settings: {
settings: 'Einstellungen',
settings_description:

View file

@ -1,7 +1,7 @@
const log_details = {
page_title: 'Audit Log details',
back_to_logs: 'Back to Audit Logs',
back_to_user: 'Back to {{name}}',
back_to: 'Back to {{name}}',
success: 'Success',
failed: 'Failed',
event_key: 'Event Key',

View file

@ -15,7 +15,7 @@ const webhook_details = {
'You are removing this webhook. After deleting it will not send HTTP request to endpoint URL.',
deleted: 'The webhook has been successfully deleted.',
settings_tab: 'Settings',
recent_requests_tab: 'Recent requests',
recent_requests_tab: 'Recent requests (24h)',
settings: {
settings: 'Settings',
settings_description:

View file

@ -1,7 +1,7 @@
const log_details = {
page_title: 'Detalle del registro de auditoría',
back_to_logs: 'Volver a los registros de auditoría',
back_to_user: 'Volver a {{name}}',
back_to: 'Volver a {{name}}',
success: 'Éxito',
failed: 'Fallido',
event_key: 'Clave del evento',

View file

@ -15,7 +15,7 @@ const webhook_details = {
'Está eliminando este webhook. Después de eliminarlo, no se enviará ninguna solicitud HTTP a la URL de extremo.',
deleted: 'El webhook se ha eliminado correctamente.',
settings_tab: 'Configuración',
recent_requests_tab: 'Solicitudes recientes',
recent_requests_tab: 'Solicitudes recientes (24 h)',
settings: {
settings: 'Configuración',
settings_description:

View file

@ -1,7 +1,7 @@
const log_details = {
page_title: "Détails du journal d'audit",
back_to_logs: "Retour aux journaux d'audit",
back_to_user: 'Retour à {{name}}',
back_to: 'Retour à {{name}}',
success: 'Succès',
failed: 'Échoué',
event_key: "Clé d'événement",

View file

@ -15,7 +15,7 @@ const webhook_details = {
"Vous êtes en train de supprimer ce webhook. Après suppression, il n'enverra plus de requête HTTP à l'endpoint URL.",
deleted: 'Le webhook a été supprimé avec succès.',
settings_tab: 'Paramètres',
recent_requests_tab: 'Demandes récentes',
recent_requests_tab: 'Demandes récentes (24 h)',
settings: {
settings: 'Paramètres',
settings_description:

View file

@ -1,7 +1,7 @@
const log_details = {
page_title: 'Dettagli registro di verifica',
back_to_logs: 'Torna ai log di verifica',
back_to_user: 'Torna a {{name}}',
back_to: 'Torna a {{name}}',
success: 'Successo',
failed: 'Fallito',
event_key: 'Chiave evento',

View file

@ -15,7 +15,7 @@ const webhook_details = {
"Stai rimuovendo questo webhook. Dopo la cancellazione non invierà una richiesta HTTP all'URL dell'endpoint.",
deleted: 'Il webhook è stato eliminato con successo.',
settings_tab: 'Impostazioni',
recent_requests_tab: 'Richieste recenti',
recent_requests_tab: 'Richieste recenti (24h)',
settings: {
settings: 'Impostazioni',
settings_description:

View file

@ -1,7 +1,7 @@
const log_details = {
page_title: '監査ログの詳細',
back_to_logs: '監査ログに戻る',
back_to_user: '{{name}}に戻る',
back_to: '{{name}}に戻る',
success: '成功',
failed: '失敗',
event_key: 'イベントキー',

View file

@ -15,7 +15,7 @@ const webhook_details = {
'このWebhookを削除しています。削除した後はエンドポイントURLにHTTPリクエストが送信されなくなります。',
deleted: 'Webhookは削除されました。',
settings_tab: '設定',
recent_requests_tab: '最近のリクエスト',
recent_requests_tab: '最近のリクエスト24時間',
settings: {
settings: '設定',
settings_description:

View file

@ -1,7 +1,7 @@
const log_details = {
page_title: '감사 로그 세부 정보',
back_to_logs: '감사 기록으로 돌아가기',
back_to_user: '{{name}}으로 돌아가기',
back_to: '{{name}}으로 돌아가기',
success: '성공',
failed: '실패',
event_key: '이벤트 키',

View file

@ -15,7 +15,7 @@ const webhook_details = {
'이 webhook을 삭제하고 있습니다. 삭제 후, 엔드포인트 URL에 HTTP request를 보내지 않습니다.',
deleted: 'Webhook이 성공적으로 삭제되었습니다.',
settings_tab: '설정',
recent_requests_tab: '최근 요청',
recent_requests_tab: '최근 요청 (24시간)',
settings: {
settings: '설정',
settings_description:

View file

@ -1,7 +1,7 @@
const log_details = {
page_title: 'Szczegóły dziennika audytu',
back_to_logs: 'Powróć do dziennika audytu',
back_to_user: 'Powróć do {{name}}',
back_to: 'Powróć do {{name}}',
success: 'Sukces',
failed: 'Niepowodzenie',
event_key: 'Klucz zdarzenia',

View file

@ -15,7 +15,7 @@ const webhook_details = {
'Usuwasz ten webhook. Po jego usunięciu nie będzie wysyłany żaden żądanie HTTP do adresu URL końcowego.',
deleted: 'Webhook został pomyślnie usunięty.',
settings_tab: 'Ustawienia',
recent_requests_tab: 'Najnowsze żądania',
recent_requests_tab: 'Najnowsze żądania (24h)',
settings: {
settings: 'Ustawienia',
settings_description:

View file

@ -1,7 +1,7 @@
const log_details = {
page_title: 'Detalhes do registro de auditoria',
back_to_logs: 'Voltar para registros',
back_to_user: 'Voltar para {{name}}',
back_to: 'Voltar para {{name}}',
success: 'Sucesso',
failed: 'Falhou',
event_key: 'Chave do evento',

View file

@ -15,7 +15,7 @@ const webhook_details = {
'Você está removendo este webhook. Depois de excluí-lo, não enviará solicitação HTTP para o URL do endpoint.',
deleted: 'O webhook foi excluído com sucesso.',
settings_tab: 'Configurações',
recent_requests_tab: 'Solicitações recentes',
recent_requests_tab: 'Solicitações recentes (24h)',
settings: {
settings: 'Configurações',
settings_description:

View file

@ -1,7 +1,7 @@
const log_details = {
page_title: 'Detalhes do registo de auditoria',
back_to_logs: 'Voltar aos registos de auditoria',
back_to_user: 'Voltar para {{name}}',
back_to: 'Voltar para {{name}}',
success: 'Sucesso',
failed: 'Falha',
event_key: 'Chave do Evento',

View file

@ -15,7 +15,7 @@ const webhook_details = {
'Você está removendo este webhook. Depois de excluí-lo, ele não enviará solicitação HTTP para o URL do endpoint.',
deleted: 'O webhook foi excluído com sucesso.',
settings_tab: 'Configurações',
recent_requests_tab: 'Solicitações recentes',
recent_requests_tab: 'Solicitações recentes (24h)',
settings: {
settings: 'Configurações',
settings_description:

View file

@ -1,7 +1,7 @@
const log_details = {
page_title: 'Детали журнала аудита',
back_to_logs: 'Назад к журналу аудита',
back_to_user: 'Назад к {{name}}',
back_to: 'Назад к {{name}}',
success: 'Успешно',
failed: 'Не удалось',
event_key: 'Ключ события',

View file

@ -15,7 +15,7 @@ const webhook_details = {
'Вы удаляете этот вебхук. После удаления он не будет отправлять HTTP-запрос на URL-адрес точки доступа.',
deleted: 'Вебхук был успешно удален.',
settings_tab: 'Настройки',
recent_requests_tab: 'Недавние запросы',
recent_requests_tab: 'Недавние запросы (за 24ч)',
settings: {
settings: 'Настройки',
settings_description:

View file

@ -1,7 +1,7 @@
const log_details = {
page_title: 'Denetim Kaydı Detayları',
back_to_logs: 'Denetim Kayıtlarına Geri Dön',
back_to_user: "{{name}}'in Kayıtlarına Geri Dön",
back_to: "{{name}}'in Kayıtlarına Geri Dön",
success: 'Başarılı',
failed: 'Başarısız',
event_key: 'Olay Anahtarı',

View file

@ -15,7 +15,7 @@ const webhook_details = {
'Bu webhooku kaldırıyorsunuz. Silindikten sonra, HTTP isteği uç nokta URLye gönderilmeyecektir.',
deleted: 'Webhook başarıyla silindi.',
settings_tab: 'Ayarlar',
recent_requests_tab: 'Son istekler',
recent_requests_tab: 'Son istekler (24s)',
settings: {
settings: 'Ayarlar',
settings_description:

View file

@ -1,7 +1,7 @@
const log_details = {
page_title: '审计日志详情',
back_to_logs: '返回审计日志',
back_to_user: '返回 {{name}}',
back_to: '返回 {{name}}',
success: '成功',
failed: '失败',
event_key: '事件 Key',

View file

@ -13,7 +13,7 @@ const webhook_details = {
deletion_reminder: '您正在删除此 Webhook。删除后将不会向端点 URL 发送 HTTP 请求。',
deleted: 'Webhook 已成功删除。',
settings_tab: '设置',
recent_requests_tab: '最近请求',
recent_requests_tab: '最近请求24小时',
settings: {
settings: '设置',
settings_description:

View file

@ -1,7 +1,7 @@
const log_details = {
page_title: '審計日誌詳情',
back_to_logs: '返回審計日誌',
back_to_user: '返回 {{name}}',
back_to: '返回 {{name}}',
success: '成功',
failed: '失敗',
event_key: '事件 Key',

View file

@ -13,7 +13,7 @@ const webhook_details = {
deletion_reminder: '您正在刪除此 webhook。刪除後不會對端點 URL 發送 HTTP 請求。',
deleted: 'Webhook 已成功刪除。',
settings_tab: '設置',
recent_requests_tab: '最新請求',
recent_requests_tab: '最新請求24h',
settings: {
settings: '設置',
settings_description:

View file

@ -1,7 +1,7 @@
const log_details = {
page_title: '審計日誌詳情',
back_to_logs: '返回審計日誌',
back_to_user: '返回 {{name}}',
back_to: '返回 {{name}}',
success: '成功',
failed: '失敗',
event_key: '事件 Key',

View file

@ -13,7 +13,7 @@ const webhook_details = {
deletion_reminder: '您正在移除此 Webhook。刪除後將不會向端點 URL 發送 HTTP 請求。',
deleted: 'Webhook 已成功刪除。',
settings_tab: '設置',
recent_requests_tab: '最近的請求',
recent_requests_tab: '最近的請求 (24h)',
settings: {
settings: '設置',
settings_description: