1
common/resources/client/admin/logging/error/bug-fixing.svg
Executable file
1
common/resources/client/admin/logging/error/bug-fixing.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 40 KiB |
44
common/resources/client/admin/logging/error/error-log-datatable-columns.tsx
Executable file
44
common/resources/client/admin/logging/error/error-log-datatable-columns.tsx
Executable file
@@ -0,0 +1,44 @@
|
||||
import {ColumnConfig} from '@common/datatable/column-config';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import React from 'react';
|
||||
import {ErrorLogItem} from '@common/admin/logging/error/error-log-item';
|
||||
import {InfoIcon} from '@common/icons/material/Info';
|
||||
import {ErrorIcon} from '@common/icons/material/Error';
|
||||
import clsx from 'clsx';
|
||||
import {FormattedRelativeTime} from '@common/i18n/formatted-relative-time';
|
||||
|
||||
export const ErrorLogDatatableColumns: ColumnConfig<ErrorLogItem>[] = [
|
||||
{
|
||||
key: 'message',
|
||||
visibleInMode: 'all',
|
||||
width: 'flex-3 min-w-200',
|
||||
header: () => <Trans message="Message" />,
|
||||
body: item => item.message,
|
||||
},
|
||||
{
|
||||
key: 'datetime',
|
||||
header: () => <Trans message="Date" />,
|
||||
body: item => <FormattedRelativeTime date={item.datetime} />,
|
||||
},
|
||||
{
|
||||
key: 'severity',
|
||||
header: () => <Trans message="Severity" />,
|
||||
body: item => {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'flex items-center gap-6 text-xs capitalize',
|
||||
item.level === 'error' ? 'text-danger' : 'text-primary',
|
||||
)}
|
||||
>
|
||||
{item.level === 'error' ? (
|
||||
<ErrorIcon size="sm" />
|
||||
) : (
|
||||
<InfoIcon size="sm" />
|
||||
)}
|
||||
{item.level}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
161
common/resources/client/admin/logging/error/error-log-datatable.tsx
Executable file
161
common/resources/client/admin/logging/error/error-log-datatable.tsx
Executable file
@@ -0,0 +1,161 @@
|
||||
import {DataTablePage} from '@common/datatable/page/data-table-page';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {DataTableEmptyStateMessage} from '@common/datatable/page/data-table-emty-state-message';
|
||||
import React, {Fragment, useEffect, useRef, useState} from 'react';
|
||||
import bugFixingImage from '@common/admin/logging/error/bug-fixing.svg';
|
||||
import {DataTableAddItemButton} from '@common/datatable/data-table-add-item-button';
|
||||
import {DownloadIcon} from '@common/icons/material/Download';
|
||||
import {ErrorLogDatatableColumns} from '@common/admin/logging/error/error-log-datatable-columns';
|
||||
import {closeDialog, openDialog} from '@common/ui/overlays/store/dialog-store';
|
||||
import {ErrorLogEntryDialog} from '@common/admin/logging/error/error-log-entry-dialog';
|
||||
import {useDataTable} from '@common/datatable/page/data-table-context';
|
||||
import {Select} from '@common/ui/forms/select/select';
|
||||
import {Item} from '@common/ui/forms/listbox/item';
|
||||
import {Skeleton} from '@common/ui/skeleton/skeleton';
|
||||
import {ErrorLogItem} from '@common/admin/logging/error/error-log-item';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
|
||||
import {useDeleteErrorLog} from '@common/admin/logging/error/use-delete-error-log';
|
||||
import {FormattedBytes} from '@common/uploads/formatted-bytes';
|
||||
|
||||
interface ErrorLogFile {
|
||||
name: string;
|
||||
identifier: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export function ErrorLogDatatable() {
|
||||
return (
|
||||
<DataTablePage
|
||||
padding="pt-12 md:pt-24"
|
||||
endpoint="logs/error"
|
||||
title={<Trans message="Error log" />}
|
||||
onRowAction={item => {
|
||||
openDialog(ErrorLogEntryDialog, {error: item});
|
||||
}}
|
||||
columns={ErrorLogDatatableColumns}
|
||||
actions={<Actions />}
|
||||
enableSelection={false}
|
||||
emptyStateMessage={
|
||||
<DataTableEmptyStateMessage
|
||||
image={bugFixingImage}
|
||||
title={<Trans message="No errors have been logged yet" />}
|
||||
filteringTitle={<Trans message="No matching error log entries" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Actions() {
|
||||
const {query, setParams} = useDataTable<
|
||||
ErrorLogItem,
|
||||
{files: ErrorLogFile[]; selectedFile?: string}
|
||||
>();
|
||||
|
||||
const setOnce = useRef(false);
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
|
||||
// set initial selected file once files are loaded
|
||||
useEffect(() => {
|
||||
if (query.data?.files?.length && !setOnce.current) {
|
||||
setOnce.current = true;
|
||||
const firstFile = query.data.files[0].identifier;
|
||||
setSelectedFile(query.data.files[0].identifier);
|
||||
// prevent unnecessary http call
|
||||
if (firstFile !== query.data.selectedFile) {
|
||||
setParams({file: query.data.files[0].identifier});
|
||||
}
|
||||
}
|
||||
}, [query.data, setParams, setOnce]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<FileSelector
|
||||
files={query.data?.files ?? null}
|
||||
selectedFile={selectedFile}
|
||||
onSelected={file => {
|
||||
setSelectedFile(file.identifier);
|
||||
setParams({file: file.identifier});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="danger"
|
||||
disabled={!selectedFile}
|
||||
onClick={() =>
|
||||
openDialog(ConfirmDeleteDialog, {identifier: selectedFile})
|
||||
}
|
||||
>
|
||||
<Trans message="Delete" />
|
||||
</Button>
|
||||
{selectedFile && (
|
||||
<DataTableAddItemButton
|
||||
elementType="a"
|
||||
download={
|
||||
query.data?.files.find(f => f.identifier === selectedFile)?.name
|
||||
}
|
||||
href={`api/v1/logs/error/${selectedFile}/download`}
|
||||
icon={<DownloadIcon />}
|
||||
>
|
||||
<Trans message="Download log" />
|
||||
</DataTableAddItemButton>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileSelectorProps {
|
||||
files: ErrorLogFile[] | null;
|
||||
selectedFile: string | null;
|
||||
onSelected: (file: ErrorLogFile) => void;
|
||||
}
|
||||
function FileSelector({files, selectedFile, onSelected}: FileSelectorProps) {
|
||||
// files have not loaded yet, show skeleton
|
||||
if (!files) {
|
||||
return <Skeleton variant="rect" className="max-w-[210px]" />;
|
||||
}
|
||||
|
||||
// no error logs yet, hide select completely
|
||||
if (!files.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
selectionMode="single"
|
||||
selectedValue={selectedFile}
|
||||
size="sm"
|
||||
minWidth="min-w-[210px]"
|
||||
>
|
||||
{files?.map(file => (
|
||||
<Item
|
||||
key={file.identifier}
|
||||
value={file.identifier}
|
||||
onSelected={() => onSelected(file)}
|
||||
>
|
||||
{file.name} (<FormattedBytes bytes={file.size} />)
|
||||
</Item>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConfirmDeleteDialogProps {
|
||||
identifier: string;
|
||||
}
|
||||
function ConfirmDeleteDialog({identifier}: ConfirmDeleteDialogProps) {
|
||||
const deleteLog = useDeleteErrorLog();
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
title={<Trans message="Delete log file" />}
|
||||
body={<Trans message="Are you sure you want to delete this log file?" />}
|
||||
confirm={<Trans message="Delete" />}
|
||||
onConfirm={() =>
|
||||
deleteLog.mutate({identifier}, {onSuccess: () => closeDialog()})
|
||||
}
|
||||
isLoading={deleteLog.isPending}
|
||||
isDanger
|
||||
/>
|
||||
);
|
||||
}
|
||||
50
common/resources/client/admin/logging/error/error-log-entry-dialog.tsx
Executable file
50
common/resources/client/admin/logging/error/error-log-entry-dialog.tsx
Executable file
@@ -0,0 +1,50 @@
|
||||
import {Dialog} from '@common/ui/overlays/dialog/dialog';
|
||||
import {DialogHeader} from '@common/ui/overlays/dialog/dialog-header';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {ErrorLogItem} from '@common/admin/logging/error/error-log-item';
|
||||
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
|
||||
interface Props {
|
||||
error: ErrorLogItem;
|
||||
}
|
||||
export function ErrorLogEntryDialog({error}: Props) {
|
||||
return (
|
||||
<Dialog size="fullscreen">
|
||||
<DialogHeader
|
||||
showDivider
|
||||
padding="px-24 py-10"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => downloadLogItem(error)}
|
||||
>
|
||||
<Trans message="Download" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Trans message="Error details" />
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<pre className="whitespace-pre-wrap break-words text-xs leading-5">
|
||||
{error.exception}
|
||||
</pre>
|
||||
</DialogBody>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function downloadLogItem(item: ErrorLogItem) {
|
||||
const el = document.createElement('a');
|
||||
el.setAttribute(
|
||||
'href',
|
||||
'data:text/plain;charset=utf-8,' + encodeURIComponent(item.exception),
|
||||
);
|
||||
el.setAttribute('download', `error-${item.id}.log`);
|
||||
|
||||
el.style.display = 'none';
|
||||
document.body.appendChild(el);
|
||||
el.click();
|
||||
document.body.removeChild(el);
|
||||
}
|
||||
8
common/resources/client/admin/logging/error/error-log-item.tsx
Executable file
8
common/resources/client/admin/logging/error/error-log-item.tsx
Executable file
@@ -0,0 +1,8 @@
|
||||
export interface ErrorLogItem {
|
||||
id: number;
|
||||
index: number;
|
||||
level: string;
|
||||
datetime: string;
|
||||
message: string;
|
||||
exception: string;
|
||||
}
|
||||
29
common/resources/client/admin/logging/error/use-delete-error-log.ts
Executable file
29
common/resources/client/admin/logging/error/use-delete-error-log.ts
Executable file
@@ -0,0 +1,29 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';
|
||||
|
||||
interface Payload {
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
export function useDeleteErrorLog() {
|
||||
const {trans} = useTrans();
|
||||
return useMutation({
|
||||
mutationFn: (payload: Payload) => deleteLogFile(payload),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: DatatableDataQueryKey('logs/error'),
|
||||
});
|
||||
toast(trans(message('Log file deleted')));
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
|
||||
function deleteLogFile({identifier}: Payload) {
|
||||
return apiClient.delete(`logs/error/${identifier}`).then(r => r.data);
|
||||
}
|
||||
29
common/resources/client/admin/logging/logs-page.tsx
Executable file
29
common/resources/client/admin/logging/logs-page.tsx
Executable file
@@ -0,0 +1,29 @@
|
||||
import {Tabs} from '@common/ui/tabs/tabs';
|
||||
import {Tab} from '@common/ui/tabs/tab';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {TabList} from '@common/ui/tabs/tab-list';
|
||||
import {Link, Outlet, useLocation} from 'react-router-dom';
|
||||
|
||||
const tabs = ['schedule', 'error', 'outgoing-email'];
|
||||
|
||||
export function LogsPage() {
|
||||
const {pathname} = useLocation();
|
||||
const activeTab = pathname.split('/').pop() as string;
|
||||
const activeIndex = tabs.includes(activeTab) ? tabs.indexOf(activeTab) : 0;
|
||||
return (
|
||||
<Tabs className="p-12 md:p-24" selectedTab={activeIndex}>
|
||||
<TabList>
|
||||
<Tab elementType={Link} to="/admin/logs/schedule" replace>
|
||||
<Trans message="Schedule" />
|
||||
</Tab>
|
||||
<Tab elementType={Link} to="/admin/logs/error" replace>
|
||||
<Trans message="Error" />
|
||||
</Tab>
|
||||
<Tab elementType={Link} to="/admin/logs/outgoing-email" replace>
|
||||
<Trans message="Email" />
|
||||
</Tab>
|
||||
</TabList>
|
||||
<Outlet />
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
1
common/resources/client/admin/logging/outgoing-email/opened.svg
Executable file
1
common/resources/client/admin/logging/outgoing-email/opened.svg
Executable file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" width="561" height="493" viewBox="0 0 561 493" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M876.03027,689.45c-.98047,1.37-1.97021,2.73-2.95019,4.08A16.82838,16.82838,0,0,1,863.5,696.5h-527a16.90383,16.90383,0,0,1-9.21-2.72c-.91016-1.2-1.81006-2.41-2.72022-3.62006l.91016-.5L592.27,541.78a16.01919,16.01919,0,0,1,15.47021-.02L875.12988,688.95Z" transform="translate(-319.5 -203.5)" fill="rgb(var(--be-primary))"/><path d="M863.5,378.5,632.28169,244.96964a64.023,64.023,0,0,0-63.98147-.03153L336.5,378.5a17.0241,17.0241,0,0,0-17,17v284a17.01984,17.01984,0,0,0,17,17h527a17.02879,17.02879,0,0,0,17-17v-284A17.02408,17.02408,0,0,0,863.5,378.5Zm15,301a15.03649,15.03649,0,0,1-15,15h-527a15.02706,15.02706,0,0,1-15-15v-284a15.01828,15.01828,0,0,1,15-15L568.30022,246.93811a64.023,64.023,0,0,1,63.98147.03153L863.5,380.5a15.01828,15.01828,0,0,1,15,15Z" transform="translate(-319.5 -203.5)" fill="#3f3d56"/><path d="M600.2998,539.18018a15.36345,15.36345,0,0,1-5.116-.8584l-.30249-.10694-.06128-.67236c-.18848.09277-.37866.18164-.56909.26563l-.20118.08837-.20141-.08886c-.42139-.18506-.83985-.39453-1.24365-.62207L408.5,433.73242V222.5A18.5208,18.5208,0,0,1,427,204H773a18.5208,18.5208,0,0,1,18.5,18.5V434.00244l-.25488.14356-183.25,103.04A15.75694,15.75694,0,0,1,600.2998,539.18018Z" transform="translate(-319.5 -203.5)" fill="#fff"/><path d="M600.2998,539.68018a15.85649,15.85649,0,0,1-5.282-.88672l-.60547-.21338-.02588-.28565-.33691.14795-.40234-.17676c-.43653-.19189-.86963-.40869-1.28784-.64453L408,434.02539V222.5a19.02154,19.02154,0,0,1,19-19H773a19.02162,19.02162,0,0,1,19,19V434.29492L608.24,537.62158A16.2527,16.2527,0,0,1,600.2998,539.68018Zm-4.01342-2.57666a14.49247,14.49247,0,0,0,10.97436-1.22559L790,433.125V222.5a17.01917,17.01917,0,0,0-17-17H427a17.01909,17.01909,0,0,0-17,17V432.85449l11.98962,6.7334,171.35047,96.29053q.34973.197.71.3706.36035-.17358.70923-.37011l1.34668-.75879Z" transform="translate(-319.5 -203.5)" fill="#3f3d56"/><path d="M876.06982,385.88,803.5,426.68,791,433.71,607.75,536.75a15.24213,15.24213,0,0,1-7.4502,1.93,14.91079,14.91079,0,0,1-4.9497-.83,12.05366,12.05366,0,0,1-1.3003-.5q-.61449-.27-1.1997-.6L421.5,440.46,409,433.44l-84.91992-47.72a1.011,1.011,0,0,1-.37988-1.37.99933.99933,0,0,1,1.35986-.38L409,431.14l12.5,7.02L593.83008,535a13.07441,13.07441,0,0,0,1.77978.83c.26026.1.53028.19.8003.27A13.26424,13.26424,0,0,0,606.77,535L791,431.42l12.5-7.03,71.58984-40.25a.99849.99849,0,1,1,.98,1.74Z" transform="translate(-319.5 -203.5)" fill="#3f3d56"/><path d="M483.5748,269.5h-28a8,8,0,0,1,0-16h28a8,8,0,0,1,0,16Z" transform="translate(-319.5 -203.5)" fill="rgb(var(--be-primary))"/><path d="M516.5748,296.5h-61a8,8,0,0,1,0-16h61a8,8,0,0,1,0,16Z" transform="translate(-319.5 -203.5)" fill="#e6e6e6"/><path d="M687,368.5H514a8,8,0,0,1,0-16H687a8,8,0,0,1,0,16Z" transform="translate(-319.5 -203.5)" fill="rgb(var(--be-primary))"/><path d="M703,399.5H497a8,8,0,0,1,0-16H703a8,8,0,0,1,0,16Z" transform="translate(-319.5 -203.5)" fill="#e6e6e6"/><path d="M703,429.5H497a8,8,0,0,1,0-16H703a8,8,0,0,1,0,16Z" transform="translate(-319.5 -203.5)" fill="#e6e6e6"/></svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,117 @@
|
||||
import {ColumnConfig} from '@common/datatable/column-config';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {NameWithAvatar} from '@common/datatable/column-templates/name-with-avatar';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import React, {ReactNode} from 'react';
|
||||
import {useRerunScheduledCommand} from '@common/admin/logging/schedule/use-rerurun-scheduled-command';
|
||||
import {OutgoingEmailLogItem} from '@common/admin/logging/outgoing-email/outgoing-email-log-item';
|
||||
import {FormattedRelativeTime} from '@common/i18n/formatted-relative-time';
|
||||
import {Chip, ChipProps} from '@common/ui/forms/input-field/chip-field/chip';
|
||||
import {VisibilityIcon} from '@common/icons/material/Visibility';
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {OutgoingEmailLogEntryDialog} from '@common/admin/logging/outgoing-email/outgoing-email-log-entry-dialog';
|
||||
|
||||
export const OutgoingEmailLogDatatableColumns: ColumnConfig<OutgoingEmailLogItem>[] =
|
||||
[
|
||||
{
|
||||
key: 'message_id',
|
||||
allowsSorting: true,
|
||||
visibleInMode: 'all',
|
||||
width: 'flex-3 min-w-200',
|
||||
header: () => <Trans message="Subject" />,
|
||||
body: item => (
|
||||
<NameWithAvatar label={item.subject} description={item.message_id} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
allowsSorting: true,
|
||||
header: () => <Trans message="Status" />,
|
||||
body: item => {
|
||||
switch (item.status) {
|
||||
case 'sent':
|
||||
return (
|
||||
<StatusChip color="positive">
|
||||
<Trans message="Sent" />
|
||||
</StatusChip>
|
||||
);
|
||||
case 'not-sent':
|
||||
return (
|
||||
<StatusChip color={undefined}>
|
||||
<Trans message="Not sent" />
|
||||
</StatusChip>
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<StatusChip color="danger">
|
||||
<Trans message="Error" />
|
||||
</StatusChip>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'from',
|
||||
allowsSorting: true,
|
||||
header: () => <Trans message="From" />,
|
||||
body: item => item.from,
|
||||
},
|
||||
{
|
||||
key: 'to',
|
||||
allowsSorting: true,
|
||||
header: () => <Trans message="To" />,
|
||||
body: item => item.to,
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
allowsSorting: true,
|
||||
header: () => <Trans message="Date" />,
|
||||
body: item => <FormattedRelativeTime date={item.created_at} />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: () => <Trans message="Actions" />,
|
||||
hideHeader: true,
|
||||
align: 'end',
|
||||
width: 'w-42 flex-shrink-0',
|
||||
visibleInMode: 'all',
|
||||
body: item => <PreviewEmailButton item={item} />,
|
||||
},
|
||||
];
|
||||
|
||||
interface PreviewButtonProps {
|
||||
item: OutgoingEmailLogItem;
|
||||
}
|
||||
function PreviewEmailButton({item}: PreviewButtonProps) {
|
||||
const rerunCommand = useRerunScheduledCommand();
|
||||
return (
|
||||
<DialogTrigger type="modal">
|
||||
<Tooltip label={<Trans message="Preview" />}>
|
||||
<IconButton
|
||||
size="md"
|
||||
className="text-muted"
|
||||
disabled={rerunCommand.isPending}
|
||||
onClick={() => {
|
||||
rerunCommand.mutate({id: item.id});
|
||||
}}
|
||||
>
|
||||
<VisibilityIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<OutgoingEmailLogEntryDialog logItemId={item.id} />
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatusChipProps {
|
||||
color: ChipProps['color'];
|
||||
children: ReactNode;
|
||||
}
|
||||
function StatusChip({color, children}: StatusChipProps) {
|
||||
return (
|
||||
<Chip color={color} size="xs" className="w-max min-w-50 text-center">
|
||||
{children}
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
BackendFilter,
|
||||
FilterControlType,
|
||||
FilterOperator,
|
||||
} from '@common/datatable/filters/backend-filter';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {createdAtFilter} from '@common/datatable/filters/timestamp-filters';
|
||||
|
||||
export const OutgoingEmailLogDatatableFilters: BackendFilter[] = [
|
||||
{
|
||||
key: 'status',
|
||||
label: message('Status'),
|
||||
description: message('Status of the outgoing email'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
control: {
|
||||
type: FilterControlType.Select,
|
||||
defaultValue: '01',
|
||||
options: [
|
||||
{
|
||||
key: '01',
|
||||
label: message('Not sent'),
|
||||
value: 'no-sent',
|
||||
},
|
||||
{
|
||||
key: '02',
|
||||
label: message('Sent'),
|
||||
value: 'sent',
|
||||
},
|
||||
{
|
||||
key: '03',
|
||||
label: message('Error'),
|
||||
value: 'error',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
createdAtFilter({
|
||||
description: message('Date email send was attempted'),
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,43 @@
|
||||
import {DataTablePage} from '@common/datatable/page/data-table-page';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {DataTableEmptyStateMessage} from '@common/datatable/page/data-table-emty-state-message';
|
||||
import React from 'react';
|
||||
import openedImage from '@common/admin/logging/outgoing-email/opened.svg';
|
||||
import {DataTableAddItemButton} from '@common/datatable/data-table-add-item-button';
|
||||
import {DownloadIcon} from '@common/icons/material/Download';
|
||||
import {OutgoingEmailLogDatatableColumns} from '@common/admin/logging/outgoing-email/outgoing-email-log-datatable-columns';
|
||||
import {OutgoingEmailLogDatatableFilters} from '@common/admin/logging/outgoing-email/outgoing-email-log-datatable-filters';
|
||||
|
||||
export function OutgoingEmailLogDatatable() {
|
||||
return (
|
||||
<DataTablePage
|
||||
padding="pt-12 md:pt-24"
|
||||
endpoint="logs/outgoing-email"
|
||||
title={<Trans message="Outgoing email" />}
|
||||
columns={OutgoingEmailLogDatatableColumns}
|
||||
filters={OutgoingEmailLogDatatableFilters}
|
||||
actions={<Actions />}
|
||||
enableSelection={false}
|
||||
emptyStateMessage={
|
||||
<DataTableEmptyStateMessage
|
||||
image={openedImage}
|
||||
title={<Trans message="No outgoing emails have been logged yet" />}
|
||||
filteringTitle={<Trans message="No matching emails" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Actions() {
|
||||
return (
|
||||
<DataTableAddItemButton
|
||||
elementType="a"
|
||||
href="api/v1/logs/outgoing-email/download"
|
||||
download
|
||||
icon={<DownloadIcon />}
|
||||
>
|
||||
<Trans message="Download log" />
|
||||
</DataTableAddItemButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {Dialog} from '@common/ui/overlays/dialog/dialog';
|
||||
import {DialogHeader} from '@common/ui/overlays/dialog/dialog-header';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {useOutgoingEmailLogItemWithMime} from '@common/admin/logging/outgoing-email/use-outgoing-email-log-item-with-mime';
|
||||
import {ProgressCircle} from '@common/ui/progress/progress-circle';
|
||||
import {downloadFileFromUrl} from '@common/uploads/utils/download-file-from-url';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
|
||||
interface Props {
|
||||
logItemId: number;
|
||||
}
|
||||
export function OutgoingEmailLogEntryDialog({logItemId}: Props) {
|
||||
const {data} = useOutgoingEmailLogItemWithMime(logItemId);
|
||||
const {base_url} = useSettings();
|
||||
return (
|
||||
<Dialog size="fullscreen">
|
||||
<DialogHeader
|
||||
showDivider
|
||||
padding="px-24 py-10"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={!data}
|
||||
type="button"
|
||||
onClick={
|
||||
data
|
||||
? () =>
|
||||
downloadFileFromUrl(
|
||||
`${base_url}/api/v1/logs/outgoing-email/${logItemId}/download`,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Trans message="Download" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Trans message="Email preview" />
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
{data ? (
|
||||
<iframe
|
||||
srcDoc={data.logItem.parsed_message!.body.html}
|
||||
className="h-max w-full border-none"
|
||||
onLoad={e => {
|
||||
const iframe = e.target as HTMLIFrameElement;
|
||||
iframe.style.height =
|
||||
iframe.contentWindow!.document.body.scrollHeight + 'px';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex min-h-200 items-center justify-center">
|
||||
<ProgressCircle isIndeterminate />
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface OutgoingEmailLogItem {
|
||||
id: number;
|
||||
message_id: string;
|
||||
status: 'sent' | 'not-sent' | 'error';
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
parsed_message?: {
|
||||
headers: Record<string, string>;
|
||||
body: {
|
||||
html: string;
|
||||
text: string;
|
||||
};
|
||||
};
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {OutgoingEmailLogItem} from '@common/admin/logging/outgoing-email/outgoing-email-log-item';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
logItem: OutgoingEmailLogItem;
|
||||
}
|
||||
|
||||
export function useOutgoingEmailLogItemWithMime(id: number) {
|
||||
return useQuery({
|
||||
queryKey: ['logs/outgoing-email', id],
|
||||
queryFn: () => fetchLogItem(id),
|
||||
});
|
||||
}
|
||||
|
||||
function fetchLogItem(id: number) {
|
||||
return apiClient.get<Response>(`logs/outgoing-email/${id}`).then(r => r.data);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {ColumnConfig} from '@common/datatable/column-config';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {NameWithAvatar} from '@common/datatable/column-templates/name-with-avatar';
|
||||
import {BooleanIndicator} from '@common/datatable/column-templates/boolean-indicator';
|
||||
import {FormattedNumber} from '@common/i18n/formatted-number';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import React from 'react';
|
||||
import {ScheduleLogItem} from '@common/admin/logging/schedule/schedule-log-item';
|
||||
import {useRerunScheduledCommand} from '@common/admin/logging/schedule/use-rerurun-scheduled-command';
|
||||
import {EventRepeatIcon} from '@common/icons/material/EventRepeat';
|
||||
import {FormattedRelativeTime} from '@common/i18n/formatted-relative-time';
|
||||
|
||||
export const ScheduleDatatableColumns: ColumnConfig<ScheduleLogItem>[] = [
|
||||
{
|
||||
key: 'command',
|
||||
allowsSorting: true,
|
||||
visibleInMode: 'all',
|
||||
width: 'flex-3 min-w-200',
|
||||
header: () => <Trans message="Name" />,
|
||||
body: item => (
|
||||
<NameWithAvatar label={item.command} description={item.output} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ran_at',
|
||||
allowsSorting: true,
|
||||
header: () => <Trans message="Ran at" />,
|
||||
body: item => <FormattedRelativeTime date={item.ran_at} />,
|
||||
},
|
||||
{
|
||||
key: 'duration',
|
||||
allowsSorting: true,
|
||||
header: () => <Trans message="Duration" />,
|
||||
body: item => `${item.duration}ms`,
|
||||
},
|
||||
{
|
||||
key: 'exit_code',
|
||||
allowsSorting: true,
|
||||
header: () => <Trans message="Completed" />,
|
||||
body: item => <BooleanIndicator value={item.exit_code === 0} />,
|
||||
},
|
||||
{
|
||||
key: 'count_in_last_hour',
|
||||
allowsSorting: true,
|
||||
header: () => <Trans message="Runs recently" />,
|
||||
body: item => <FormattedNumber value={item.count_in_last_hour} />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: () => <Trans message="Actions" />,
|
||||
hideHeader: true,
|
||||
align: 'end',
|
||||
width: 'w-42 flex-shrink-0',
|
||||
visibleInMode: 'all',
|
||||
body: item => <RerunButton item={item} />,
|
||||
},
|
||||
];
|
||||
|
||||
interface RerunButtonProps {
|
||||
item: ScheduleLogItem;
|
||||
}
|
||||
function RerunButton({item}: RerunButtonProps) {
|
||||
const rerunCommand = useRerunScheduledCommand();
|
||||
return (
|
||||
<Tooltip label={<Trans message="Rerun now" />}>
|
||||
<IconButton
|
||||
size="md"
|
||||
className="text-muted"
|
||||
disabled={rerunCommand.isPending}
|
||||
onClick={() => {
|
||||
rerunCommand.mutate({id: item.id});
|
||||
}}
|
||||
>
|
||||
<EventRepeatIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
41
common/resources/client/admin/logging/schedule/schedule-log-datatable.tsx
Executable file
41
common/resources/client/admin/logging/schedule/schedule-log-datatable.tsx
Executable file
@@ -0,0 +1,41 @@
|
||||
import {DataTablePage} from '@common/datatable/page/data-table-page';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {DataTableEmptyStateMessage} from '@common/datatable/page/data-table-emty-state-message';
|
||||
import React from 'react';
|
||||
import {ScheduleDatatableColumns} from '@common/admin/logging/schedule/schedule-datatable-columns';
|
||||
import timelineImage from '@common/admin/logging/schedule/timeline.svg';
|
||||
import {DataTableAddItemButton} from '@common/datatable/data-table-add-item-button';
|
||||
import {DownloadIcon} from '@common/icons/material/Download';
|
||||
|
||||
export function ScheduleLogDatatable() {
|
||||
return (
|
||||
<DataTablePage
|
||||
padding="pt-12 md:pt-24"
|
||||
endpoint="logs/schedule"
|
||||
title={<Trans message="CRON schedule log" />}
|
||||
columns={ScheduleDatatableColumns}
|
||||
actions={<Actions />}
|
||||
enableSelection={false}
|
||||
emptyStateMessage={
|
||||
<DataTableEmptyStateMessage
|
||||
image={timelineImage}
|
||||
title={<Trans message="No scheduled commands have ran yet" />}
|
||||
filteringTitle={<Trans message="No matching scheduled commands" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Actions() {
|
||||
return (
|
||||
<DataTableAddItemButton
|
||||
elementType="a"
|
||||
href="api/v1/logs/schedule/download"
|
||||
download
|
||||
icon={<DownloadIcon />}
|
||||
>
|
||||
<Trans message="Download log" />
|
||||
</DataTableAddItemButton>
|
||||
);
|
||||
}
|
||||
9
common/resources/client/admin/logging/schedule/schedule-log-item.tsx
Executable file
9
common/resources/client/admin/logging/schedule/schedule-log-item.tsx
Executable file
@@ -0,0 +1,9 @@
|
||||
export interface ScheduleLogItem {
|
||||
id: number;
|
||||
command: string;
|
||||
output: string;
|
||||
ran_at: string;
|
||||
duration: number;
|
||||
count_in_last_hour: number;
|
||||
exit_code: number;
|
||||
}
|
||||
1
common/resources/client/admin/logging/schedule/timeline.svg
Executable file
1
common/resources/client/admin/logging/schedule/timeline.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,32 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
interface Payload {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export function useRerunScheduledCommand() {
|
||||
const {trans} = useTrans();
|
||||
return useMutation({
|
||||
mutationFn: (payload: Payload) => rerunCommand(payload),
|
||||
onSuccess: async (response, props) => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: DatatableDataQueryKey('logs/schedule'),
|
||||
});
|
||||
toast.positive(trans(message('Command reran')));
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
|
||||
function rerunCommand({id}: Payload): Promise<Response> {
|
||||
return apiClient.post(`logs/schedule/rerun/${id}`).then(r => r.data);
|
||||
}
|
||||
Reference in New Issue
Block a user