first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View 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;
}

View 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;
}
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View 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();

View 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});
});

View 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,
});
}
}