5
common/resources/client/utils/http/error-status-is.ts
Executable file
5
common/resources/client/utils/http/error-status-is.ts
Executable file
@@ -0,0 +1,5 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export function errorStatusIs(err: unknown, status: number): boolean {
|
||||
return axios.isAxiosError(err) && err.response?.status == status;
|
||||
}
|
||||
18
common/resources/client/utils/http/get-axios-error-message.ts
Executable file
18
common/resources/client/utils/http/get-axios-error-message.ts
Executable file
@@ -0,0 +1,18 @@
|
||||
import axios from 'axios';
|
||||
import {BackendErrorResponse} from '../../errors/backend-error-response';
|
||||
|
||||
export function getAxiosErrorMessage(
|
||||
err: unknown,
|
||||
field?: string | null
|
||||
): string | undefined {
|
||||
if (axios.isAxiosError(err) && err.response) {
|
||||
const response = err.response.data as BackendErrorResponse;
|
||||
|
||||
if (field != null) {
|
||||
const fieldMessage = response.errors?.[field];
|
||||
return Array.isArray(fieldMessage) ? fieldMessage[0] : fieldMessage;
|
||||
}
|
||||
|
||||
return response?.message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
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 {
|
||||
IgnitionErrorPayload,
|
||||
IgnitionFrame,
|
||||
} from '@common/utils/http/ignition-error-dialog/ignition-error-payload';
|
||||
import {Chip} from '@common/ui/forms/input-field/chip-field/chip';
|
||||
import React, {memo, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {ErrorIcon} from '@common/icons/material/Error';
|
||||
import {highlightCode} from '@common/text-editor/highlight/highlight-code';
|
||||
import {
|
||||
IgnitionFilePath,
|
||||
IgnitionStackTrace,
|
||||
} from '@common/utils/http/ignition-error-dialog/ignition-stack-trace';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
|
||||
interface Props {
|
||||
error: IgnitionErrorPayload;
|
||||
}
|
||||
export function IgnitionErrorDialog({error}: Props) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(() => {
|
||||
for (const frame of error.trace) {
|
||||
if (!('vendorGroup' in frame)) {
|
||||
return frame.flatIndex;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
const selectedFrame = useMemo(() => {
|
||||
for (const frame of error.trace) {
|
||||
if ('vendorGroup' in frame) {
|
||||
for (const vendorFrame of frame.items) {
|
||||
if (vendorFrame.flatIndex === selectedIndex) {
|
||||
return vendorFrame;
|
||||
}
|
||||
}
|
||||
} else if (frame.flatIndex === selectedIndex) {
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
}, [error, selectedIndex]);
|
||||
|
||||
return (
|
||||
<Dialog size="fullscreen">
|
||||
<DialogHeader
|
||||
showDivider
|
||||
leftAdornment={<ErrorIcon />}
|
||||
color="text-danger"
|
||||
actions={<DownloadButton />}
|
||||
>
|
||||
<Trans message="An error occured" />
|
||||
</DialogHeader>
|
||||
<DialogBody padding="p-0 stack">
|
||||
<div className="sticky top-0 z-10 border-b bg p-24">
|
||||
<Chip className="w-max" radius="rounded-panel">
|
||||
{error.exception}
|
||||
</Chip>
|
||||
<div className="mt-16 line-clamp-2 text-lg font-semibold leading-snug">
|
||||
{error.message}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-stretch gap-10">
|
||||
<IgnitionStackTrace
|
||||
trace={error.trace}
|
||||
onSelectedIndexChange={setSelectedIndex}
|
||||
selectedIndex={selectedIndex}
|
||||
totalVendorGroups={error.totalVendorGroups}
|
||||
/>
|
||||
{selectedFrame && <CodeSnippet frame={selectedFrame} />}
|
||||
</div>
|
||||
</DialogBody>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface CodeSnippetProps {
|
||||
frame: IgnitionFrame;
|
||||
}
|
||||
function CodeSnippet({frame}: CodeSnippetProps) {
|
||||
const codeRef = useRef<HTMLPreElement>(null!);
|
||||
|
||||
const lineNumbers = Object.keys(frame.codeSnippet).map(Number);
|
||||
const highlightedIndex = lineNumbers.indexOf(frame.lineNumber);
|
||||
const lines = Object.values(frame.codeSnippet);
|
||||
|
||||
return (
|
||||
<div className="sticky top-120 flex-auto">
|
||||
<div className="px-30 py-16 text-right text-muted">
|
||||
<IgnitionFilePath frame={frame} />
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="mr-8 select-none text-right">
|
||||
{lineNumbers.map((lineNumber, index) => (
|
||||
<div
|
||||
className={clsx(
|
||||
'px-8 font-mono leading-loose text-muted',
|
||||
index == highlightedIndex && 'bg-danger/30',
|
||||
)}
|
||||
key={index}
|
||||
>
|
||||
{lineNumber}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="compact-scrollbar flex-grow overflow-x-auto">
|
||||
<pre>
|
||||
<code className="language-php" ref={codeRef}>
|
||||
{lines.map((line, index) => (
|
||||
<CodeSnippetLine
|
||||
isHighlighted={highlightedIndex === index}
|
||||
key={`${frame.path}.${index}`}
|
||||
line={line}
|
||||
/>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CodeSnippetLineProps {
|
||||
line: string;
|
||||
isHighlighted: boolean;
|
||||
}
|
||||
const CodeSnippetLine = memo(({line, isHighlighted}: CodeSnippetLineProps) => {
|
||||
const ref = useRef<HTMLSpanElement>(null!);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
highlightCode(el, 'light');
|
||||
return () => {
|
||||
delete el.dataset.highlighted;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={clsx('block leading-loose', isHighlighted && 'bg-danger/20')}
|
||||
>
|
||||
<span className="language-php" ref={ref}>
|
||||
{line + '\n'}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
function DownloadButton() {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-main"
|
||||
elementType="a"
|
||||
download
|
||||
href="api/v1/logs/error/download-latest"
|
||||
size="2xs"
|
||||
>
|
||||
<Trans message="Download log" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface IgnitionVendorGroup {
|
||||
vendorGroup: true;
|
||||
items: IgnitionFrame[];
|
||||
}
|
||||
|
||||
export interface IgnitionErrorPayload {
|
||||
ignitionTrace: true;
|
||||
message: string;
|
||||
exception: string;
|
||||
line: number;
|
||||
trace: (IgnitionVendorGroup | IgnitionFrame)[];
|
||||
totalVendorGroups: number;
|
||||
}
|
||||
|
||||
export interface IgnitionFrame {
|
||||
codeSnippet: Record<number, string>;
|
||||
path: string[];
|
||||
lineNumber: number;
|
||||
method: string;
|
||||
flatIndex: number;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
IgnitionErrorPayload,
|
||||
IgnitionFrame,
|
||||
} from '@common/utils/http/ignition-error-dialog/ignition-error-payload';
|
||||
import React, {Fragment, useState} from 'react';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {KeyboardArrowDownIcon} from '@common/icons/material/KeyboardArrowDown';
|
||||
import clsx from 'clsx';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {UnfoldMoreIcon} from '@common/icons/material/UnfoldMore';
|
||||
import {UnfoldLessIcon} from '@common/icons/material/UnfoldLess';
|
||||
|
||||
interface StackTraceProps {
|
||||
trace: IgnitionErrorPayload['trace'];
|
||||
onSelectedIndexChange: (index: number) => void;
|
||||
selectedIndex: number;
|
||||
totalVendorGroups: number;
|
||||
}
|
||||
export function IgnitionStackTrace({
|
||||
trace,
|
||||
onSelectedIndexChange,
|
||||
selectedIndex,
|
||||
totalVendorGroups,
|
||||
}: StackTraceProps) {
|
||||
const [expandedVendorGroups, setExpandedVendorGroups] = useState<number[]>(
|
||||
[],
|
||||
);
|
||||
const allVendorGroupsExpanded =
|
||||
expandedVendorGroups.length === totalVendorGroups;
|
||||
return (
|
||||
<div className="max-w-440 border-r text-sm">
|
||||
<div className="border-b px-30 py-16">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="2xs"
|
||||
startIcon={
|
||||
allVendorGroupsExpanded ? <UnfoldLessIcon /> : <UnfoldMoreIcon />
|
||||
}
|
||||
onClick={() => {
|
||||
if (allVendorGroupsExpanded) {
|
||||
setExpandedVendorGroups([]);
|
||||
} else {
|
||||
setExpandedVendorGroups(
|
||||
trace
|
||||
.map((frame, index) => ('vendorGroup' in frame ? index : -1))
|
||||
.filter(index => index !== -1),
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{allVendorGroupsExpanded ? (
|
||||
<Trans message="Collapse vendor frames" />
|
||||
) : (
|
||||
<Trans message="Expand vendor frames" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{trace.map((frame, index) => {
|
||||
if ('vendorGroup' in frame) {
|
||||
// vendor group is expanded, display all vendor frames
|
||||
if (expandedVendorGroups.includes(index)) {
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
{frame.items.map((vendorFrame, index) => (
|
||||
<StackTrackItem
|
||||
key={`vendor-${index}`}
|
||||
frame={vendorFrame}
|
||||
onClick={() => onSelectedIndexChange(vendorFrame.flatIndex)}
|
||||
isSelected={selectedIndex === vendorFrame.flatIndex}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
// vendor group is collapsed, only show vendor group header
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 border-b px-30 py-16 hover:bg-hover"
|
||||
key={index}
|
||||
onClick={() => setExpandedVendorGroups(prev => [...prev, index])}
|
||||
>
|
||||
<Trans
|
||||
message=":count vendor [one frame|other frames]"
|
||||
values={{count: frame.items.length}}
|
||||
/>
|
||||
<KeyboardArrowDownIcon className="text-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// app frame item
|
||||
return (
|
||||
<StackTrackItem
|
||||
key={index}
|
||||
frame={frame}
|
||||
onClick={() => onSelectedIndexChange(frame.flatIndex)}
|
||||
isSelected={selectedIndex === frame.flatIndex}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StackTrackItemProps {
|
||||
frame: IgnitionFrame;
|
||||
onClick: () => void;
|
||||
isSelected: boolean;
|
||||
}
|
||||
function StackTrackItem({frame, onClick, isSelected}: StackTrackItemProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
'cursor-pointer border-b px-30 py-16',
|
||||
isSelected ? 'bg-danger text-on-primary' : 'hover:bg-danger/10',
|
||||
)}
|
||||
>
|
||||
<IgnitionFilePath frame={frame} />
|
||||
<div className="font-semibold">{frame.method}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface IgnitionFilePath {
|
||||
frame: IgnitionFrame;
|
||||
}
|
||||
export function IgnitionFilePath({frame}: IgnitionFilePath) {
|
||||
return (
|
||||
<div className="inline-flex flex-wrap items-baseline">
|
||||
{frame.path.map((part, index) =>
|
||||
frame.path.length - 1 === index ? (
|
||||
<div key={index} className="font-semibold">
|
||||
{part}
|
||||
</div>
|
||||
) : (
|
||||
<div key={index}>{part}/</div>
|
||||
),
|
||||
)}
|
||||
<div>:{frame.lineNumber}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
common/resources/client/utils/http/lazy-loader.ts
Executable file
129
common/resources/client/utils/http/lazy-loader.ts
Executable file
@@ -0,0 +1,129 @@
|
||||
import {isAbsoluteUrl} from '../urls/is-absolute-url';
|
||||
|
||||
interface Options {
|
||||
id?: string;
|
||||
force?: boolean;
|
||||
type?: 'js' | 'css';
|
||||
parentEl?: HTMLElement;
|
||||
document?: Document;
|
||||
}
|
||||
|
||||
interface LoadAssetOptions {
|
||||
url: string;
|
||||
id: string;
|
||||
resolve: (value?: any | PromiseLike<any>) => void;
|
||||
parentEl?: HTMLElement;
|
||||
document?: Document;
|
||||
}
|
||||
|
||||
class LazyLoader {
|
||||
private loadedAssets: Record<
|
||||
string,
|
||||
{
|
||||
state: 'loaded' | Promise<void>;
|
||||
doc?: Document;
|
||||
}
|
||||
> = {};
|
||||
|
||||
loadAsset(url: string, params: Options = {type: 'js'}): Promise<any> {
|
||||
const currentState = this.loadedAssets[url]?.state;
|
||||
|
||||
// script is already loaded, return resolved promise
|
||||
if (currentState === 'loaded' && !params.force) {
|
||||
return new Promise<void>(resolve => resolve());
|
||||
}
|
||||
|
||||
const neverLoaded =
|
||||
!currentState || this.loadedAssets[url].doc !== params.document;
|
||||
// script has never been loaded before, load it, return promise and resolve on script load event
|
||||
if (neverLoaded || (params.force && currentState === 'loaded')) {
|
||||
this.loadedAssets[url] = {
|
||||
state: new Promise(resolve => {
|
||||
const finalUrl = isAbsoluteUrl(url) ? url : `assets/${url}`;
|
||||
const finalId = buildId(url, params.id);
|
||||
|
||||
const assetOptions: LoadAssetOptions = {
|
||||
url: finalUrl,
|
||||
id: finalId,
|
||||
resolve,
|
||||
parentEl: params.parentEl,
|
||||
document: params.document,
|
||||
};
|
||||
|
||||
if (params.type === 'css') {
|
||||
this.loadStyleAsset(assetOptions);
|
||||
} else {
|
||||
this.loadScriptAsset(assetOptions);
|
||||
}
|
||||
}),
|
||||
doc: params.document,
|
||||
};
|
||||
return this.loadedAssets[url].state as Promise<void>;
|
||||
}
|
||||
|
||||
// script is still loading, return existing promise
|
||||
return this.loadedAssets[url].state as Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether asset is loading or has already loaded.
|
||||
*/
|
||||
isLoadingOrLoaded(url: string): boolean {
|
||||
return this.loadedAssets[url] != null;
|
||||
}
|
||||
|
||||
private loadStyleAsset(options: LoadAssetOptions) {
|
||||
const doc = options.document || document;
|
||||
const parentEl = options.parentEl || doc.head;
|
||||
const style = doc.createElement('link');
|
||||
const prefixedId = buildId(options.url, options.id);
|
||||
|
||||
style.rel = 'stylesheet';
|
||||
style.id = prefixedId;
|
||||
style.href = options.url;
|
||||
|
||||
try {
|
||||
if (parentEl.querySelector(`#${prefixedId}`)) {
|
||||
parentEl.querySelector(`#${prefixedId}`)?.remove();
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
style.onload = () => {
|
||||
this.loadedAssets[options.url].state = 'loaded';
|
||||
options.resolve();
|
||||
};
|
||||
|
||||
parentEl.appendChild(style);
|
||||
}
|
||||
|
||||
private loadScriptAsset(options: LoadAssetOptions) {
|
||||
const doc = options.document || document;
|
||||
const parentEl = options.parentEl || doc.body;
|
||||
const script: HTMLScriptElement = doc.createElement('script');
|
||||
const prefixedId = buildId(options.url, options.id);
|
||||
|
||||
script.async = true;
|
||||
script.id = prefixedId;
|
||||
script.src = options.url;
|
||||
|
||||
try {
|
||||
if (parentEl.querySelector(`#${prefixedId}`)) {
|
||||
parentEl.querySelector(`#${prefixedId}`)?.remove();
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
script.onload = () => {
|
||||
this.loadedAssets[options.url].state = 'loaded';
|
||||
options.resolve();
|
||||
};
|
||||
|
||||
(parentEl || parentEl).appendChild(script);
|
||||
}
|
||||
}
|
||||
|
||||
function buildId(url: string, id?: string): string {
|
||||
if (id) return id;
|
||||
return btoa(url.split('/').pop() as string);
|
||||
}
|
||||
|
||||
export default new LazyLoader();
|
||||
24
common/resources/client/utils/http/load-image.ts
Executable file
24
common/resources/client/utils/http/load-image.ts
Executable file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Load image avoiding xhr/fetch CORS issues. Server status can't be obtained this way
|
||||
* unfortunately, so this uses "naturalWidth" to determine if the image has been loaded. By
|
||||
* default, it checks if it is at least 1px.
|
||||
*/
|
||||
export const loadImage = (
|
||||
src: string,
|
||||
minWidth = 1
|
||||
): Promise<HTMLImageElement> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
const handler = () => {
|
||||
// @ts-expect-error
|
||||
delete image.onload;
|
||||
// @ts-expect-error
|
||||
delete image.onerror;
|
||||
if (image.naturalWidth >= minWidth) {
|
||||
resolve(image);
|
||||
} else {
|
||||
reject('Could not load youtube image');
|
||||
}
|
||||
};
|
||||
Object.assign(image, {onload: handler, onerror: handler, src});
|
||||
});
|
||||
25
common/resources/client/utils/http/show-http-error-toast.ts
Executable file
25
common/resources/client/utils/http/show-http-error-toast.ts
Executable file
@@ -0,0 +1,25 @@
|
||||
import {toast} from '../../ui/toast/toast';
|
||||
import {getAxiosErrorMessage} from './get-axios-error-message';
|
||||
import {message} from '../../i18n/message';
|
||||
import {ToastOptions} from '@common/ui/toast/toast-store';
|
||||
import axios from 'axios';
|
||||
import {openDialog} from '@common/ui/overlays/store/dialog-store';
|
||||
import {IgnitionErrorDialog} from '@common/utils/http/ignition-error-dialog/ignition-error-dialog';
|
||||
|
||||
const defaultErrorMessage = message('There was an issue. Please try again.');
|
||||
|
||||
export function showHttpErrorToast(
|
||||
err: unknown,
|
||||
defaultMessage = defaultErrorMessage,
|
||||
field?: string | null,
|
||||
toastOptions?: ToastOptions,
|
||||
) {
|
||||
if (axios.isAxiosError(err) && err.response?.data?.ignitionTrace) {
|
||||
openDialog(IgnitionErrorDialog, {error: err.response.data});
|
||||
} else {
|
||||
toast.danger(getAxiosErrorMessage(err, field) || defaultMessage, {
|
||||
action: (err as any).response?.data?.action,
|
||||
...toastOptions,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user