@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user