328 lines
13 KiB
TypeScript
328 lines
13 KiB
TypeScript
import { CloseOutlined, ExpandOutlined, ReloadOutlined } from '@ant-design/icons';
|
|
import { Alert, Button, Empty, Space, Spin, Tag, Typography } from 'antd';
|
|
import { PreviewerUI } from '../../../components/previewer';
|
|
import type { ErrorLogItem } from '../errorLogApi';
|
|
import {
|
|
buildErrorListPreviewLine,
|
|
buildErrorMetaRows,
|
|
buildErrorReferenceSummary,
|
|
formatErrorLogTime,
|
|
formatErrorSummary,
|
|
getErrorResourceKindLabel,
|
|
getErrorSourceColor,
|
|
getErrorSourceLabel,
|
|
renderErrorTabs,
|
|
} from './errorLogUtils';
|
|
import type { ErrorReferenceResource, ErrorReferenceSummary } from './types';
|
|
|
|
const { Paragraph, Text } = Typography;
|
|
|
|
type ErrorLogViewerProps = {
|
|
errorLogs: ErrorLogItem[];
|
|
selectedErrorLog: ErrorLogItem | null;
|
|
selectedErrorLogReferenceSummary: ErrorReferenceSummary | null;
|
|
activeErrorResource: ErrorReferenceResource | null;
|
|
isErrorDetailExpanded: boolean;
|
|
isLoadingErrorLogs: boolean;
|
|
errorLogLoadError: string;
|
|
errorSourceSummary: Record<string, number>;
|
|
onRefresh: () => void;
|
|
onSelectErrorLog: (id: number) => void;
|
|
onSelectResource: (url: string) => void;
|
|
onToggleExpanded: (expanded: boolean) => void;
|
|
};
|
|
|
|
function ErrorDetailContent({
|
|
selectedErrorLog,
|
|
selectedErrorLogReferenceSummary,
|
|
activeErrorResource,
|
|
onSelectResource,
|
|
}: {
|
|
selectedErrorLog: ErrorLogItem;
|
|
selectedErrorLogReferenceSummary: ErrorReferenceSummary | null;
|
|
activeErrorResource: ErrorReferenceResource | null;
|
|
onSelectResource: (url: string) => void;
|
|
}) {
|
|
return (
|
|
<>
|
|
{selectedErrorLogReferenceSummary ? (
|
|
<div className="app-chat-panel__error-summary-strip">
|
|
<div className="app-chat-panel__error-summary-pill">
|
|
<Text type="secondary">preview</Text>
|
|
<Text strong>{selectedErrorLogReferenceSummary.previewCount}</Text>
|
|
</div>
|
|
<div className="app-chat-panel__error-summary-pill">
|
|
<Text type="secondary">image</Text>
|
|
<Text strong>{selectedErrorLogReferenceSummary.imageCount}</Text>
|
|
</div>
|
|
<div className="app-chat-panel__error-summary-pill">
|
|
<Text type="secondary">link</Text>
|
|
<Text strong>{selectedErrorLogReferenceSummary.linkCount}</Text>
|
|
</div>
|
|
<div className="app-chat-panel__error-summary-pill">
|
|
<Text type="secondary">참조 합계</Text>
|
|
<Text strong>{selectedErrorLogReferenceSummary.resources.length}</Text>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{selectedErrorLogReferenceSummary?.resources.length ? (
|
|
<div className="app-chat-panel__error-reference-stage">
|
|
<div className="app-chat-panel__error-reference-rail">
|
|
{selectedErrorLogReferenceSummary.resources.map((resource, index) => (
|
|
<button
|
|
key={`${selectedErrorLog.id}-${resource.url}`}
|
|
type="button"
|
|
className={
|
|
resource.url === activeErrorResource?.url
|
|
? 'app-chat-panel__error-reference-item app-chat-panel__error-reference-item--active'
|
|
: 'app-chat-panel__error-reference-item'
|
|
}
|
|
onClick={() => {
|
|
onSelectResource(resource.url);
|
|
}}
|
|
>
|
|
<div className="app-chat-panel__error-reference-item-top">
|
|
<Text strong>{index + 1}</Text>
|
|
<Tag>{getErrorResourceKindLabel(resource.kind)}</Tag>
|
|
</div>
|
|
<Text strong>{resource.label}</Text>
|
|
<Text type="secondary">{resource.sourcePath}</Text>
|
|
<Text type="secondary" className="app-chat-panel__error-reference-item-snippet">
|
|
{resource.sourcePreview}
|
|
</Text>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{activeErrorResource ? (
|
|
<div className="app-chat-panel__error-reference-main">
|
|
<div className="app-chat-panel__error-reference-main-top">
|
|
<div>
|
|
<Text strong>{activeErrorResource.label}</Text>
|
|
<br />
|
|
<Text type="secondary">
|
|
{getErrorResourceKindLabel(activeErrorResource.kind)} · {activeErrorResource.sourcePath}
|
|
</Text>
|
|
</div>
|
|
<Button size="small" type="primary" href={activeErrorResource.url} target="_blank" rel="noreferrer">
|
|
새 탭에서 열기
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="app-chat-panel__error-reference-main-meta">
|
|
<div className="app-chat-panel__error-reference-main-meta-item">
|
|
<Text type="secondary">종류</Text>
|
|
<Text>{getErrorResourceKindLabel(activeErrorResource.kind)}</Text>
|
|
</div>
|
|
<div className="app-chat-panel__error-reference-main-meta-item">
|
|
<Text type="secondary">추출 경로</Text>
|
|
<Text>{activeErrorResource.sourcePath}</Text>
|
|
</div>
|
|
<div className="app-chat-panel__error-reference-main-meta-item app-chat-panel__error-reference-main-meta-item--wide">
|
|
<Text type="secondary">URL</Text>
|
|
<Text>{activeErrorResource.url}</Text>
|
|
</div>
|
|
</div>
|
|
|
|
{activeErrorResource.kind === 'image' ? (
|
|
<PreviewerUI
|
|
type="image"
|
|
title={activeErrorResource.label}
|
|
description={activeErrorResource.url}
|
|
value={activeErrorResource.url}
|
|
imageAlt={activeErrorResource.label}
|
|
height={420}
|
|
/>
|
|
) : activeErrorResource.kind === 'preview' ? (
|
|
<div className="app-chat-panel__error-preview-card app-chat-panel__error-preview-card--stage">
|
|
<div className="app-chat-panel__error-preview-frame-wrap">
|
|
<iframe
|
|
className="app-chat-panel__error-preview-frame app-chat-panel__error-preview-frame--stage"
|
|
src={activeErrorResource.url}
|
|
title={activeErrorResource.label}
|
|
loading="lazy"
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<PreviewerUI
|
|
type="text"
|
|
title={activeErrorResource.label}
|
|
description="외부 링크 참조"
|
|
value={activeErrorResource.url}
|
|
height="auto"
|
|
/>
|
|
)}
|
|
|
|
<Text type="secondary" className="app-chat-panel__error-preview-url">
|
|
{activeErrorResource.url}
|
|
</Text>
|
|
|
|
<div className="app-chat-panel__error-source-preview">
|
|
<Text strong>참조 추출 위치</Text>
|
|
<Text type="secondary">{activeErrorResource.sourcePath}</Text>
|
|
<pre>{activeErrorResource.sourcePreview}</pre>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="app-chat-panel__error-detail-meta">
|
|
{buildErrorMetaRows(selectedErrorLog).map((row) => (
|
|
<div key={`${selectedErrorLog.id}-${row.label}`} className="app-chat-panel__error-detail-meta-row">
|
|
<Text type="secondary">{row.label}</Text>
|
|
<Text>{row.value}</Text>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<Paragraph className="app-chat-panel__error-detail-line">
|
|
<Text strong>에러 내용</Text>
|
|
<br />
|
|
{selectedErrorLog.errorMessage}
|
|
</Paragraph>
|
|
|
|
{renderErrorTabs(selectedErrorLog)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function ErrorLogViewer({
|
|
errorLogs,
|
|
selectedErrorLog,
|
|
selectedErrorLogReferenceSummary,
|
|
activeErrorResource,
|
|
isErrorDetailExpanded,
|
|
isLoadingErrorLogs,
|
|
errorLogLoadError,
|
|
errorSourceSummary,
|
|
onRefresh,
|
|
onSelectErrorLog,
|
|
onSelectResource,
|
|
onToggleExpanded,
|
|
}: ErrorLogViewerProps) {
|
|
const errorDetailHeader = selectedErrorLog ? (
|
|
<div className="app-chat-panel__error-detail-header">
|
|
<div className="app-chat-panel__error-detail-header-meta">
|
|
<Space size={[8, 8]} wrap>
|
|
<Tag color={getErrorSourceColor(selectedErrorLog.source)}>{getErrorSourceLabel(selectedErrorLog)}</Tag>
|
|
<Tag>{selectedErrorLog.errorType}</Tag>
|
|
{selectedErrorLog.errorName ? <Tag>{selectedErrorLog.errorName}</Tag> : null}
|
|
{selectedErrorLog.statusCode ? <Tag>{selectedErrorLog.statusCode}</Tag> : null}
|
|
{selectedErrorLog.relatedPlanId ? <Tag>Plan #{selectedErrorLog.relatedPlanId}</Tag> : null}
|
|
{selectedErrorLog.relatedWorkId ? <Tag>{selectedErrorLog.relatedWorkId}</Tag> : null}
|
|
</Space>
|
|
<Text type="secondary">로그 #{selectedErrorLog.id}</Text>
|
|
</div>
|
|
<div className="app-chat-panel__error-detail-actions">
|
|
{isErrorDetailExpanded ? (
|
|
<Button type="text" size="small" icon={<CloseOutlined />} aria-label="상세영역 닫기" onClick={() => onToggleExpanded(false)} />
|
|
) : (
|
|
<Button type="text" size="small" icon={<ExpandOutlined />} aria-label="상세영역 최대화" onClick={() => onToggleExpanded(true)} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null;
|
|
|
|
return (
|
|
<div className="app-chat-panel__error-layout">
|
|
{isErrorDetailExpanded && selectedErrorLog ? (
|
|
<div className="app-chat-panel__error-detail-screen">
|
|
<div className="app-chat-panel__error-detail app-chat-panel__error-detail--expanded">
|
|
{errorDetailHeader}
|
|
<ErrorDetailContent
|
|
selectedErrorLog={selectedErrorLog}
|
|
selectedErrorLogReferenceSummary={selectedErrorLogReferenceSummary}
|
|
activeErrorResource={activeErrorResource}
|
|
onSelectResource={onSelectResource}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="app-chat-panel__error-toolbar">
|
|
<div className="app-chat-panel__error-toolbar-copy">
|
|
<Text strong>최근 에러 로그 50건</Text>
|
|
<Text type="secondary">프론트엔드, 워크서버 API, 작업 자동화 워커 오류를 함께 봅니다.</Text>
|
|
</div>
|
|
<Space size={[8, 8]} wrap>
|
|
{Object.entries(errorSourceSummary).map(([label, count]) => (
|
|
<Tag key={label}>{label} {count}</Tag>
|
|
))}
|
|
<Button size="small" icon={<ReloadOutlined />} loading={isLoadingErrorLogs} onClick={onRefresh}>
|
|
새로고침
|
|
</Button>
|
|
</Space>
|
|
</div>
|
|
|
|
{errorLogLoadError ? <Alert showIcon type="error" message={errorLogLoadError} /> : null}
|
|
|
|
{isLoadingErrorLogs ? (
|
|
<div className="app-chat-panel__error-loading">
|
|
<Spin />
|
|
</div>
|
|
) : errorLogs.length === 0 ? (
|
|
<div className="app-chat-panel__error-empty">
|
|
<Empty description="저장된 에러 로그가 없습니다." />
|
|
</div>
|
|
) : (
|
|
<div className="app-chat-panel__error-content">
|
|
<div className="app-chat-panel__error-list">
|
|
{errorLogs.map((item) => {
|
|
const referenceSummary = buildErrorReferenceSummary(item);
|
|
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
className={
|
|
item.id === selectedErrorLog?.id
|
|
? 'app-chat-panel__error-item app-chat-panel__error-item--active'
|
|
: 'app-chat-panel__error-item'
|
|
}
|
|
onClick={() => {
|
|
onSelectErrorLog(item.id);
|
|
}}
|
|
>
|
|
<div className="app-chat-panel__error-item-top">
|
|
<Text type="secondary">{formatErrorLogTime(item.createdAt)}</Text>
|
|
<Tag bordered={false} color={getErrorSourceColor(item.source)}>
|
|
{getErrorSourceLabel(item)}
|
|
</Tag>
|
|
</div>
|
|
<Text strong className="app-chat-panel__error-item-title">{formatErrorSummary(item)}</Text>
|
|
<Text type="secondary" className="app-chat-panel__error-item-message">
|
|
{buildErrorListPreviewLine(item, referenceSummary)}
|
|
</Text>
|
|
<div className="app-chat-panel__error-item-badges">
|
|
{referenceSummary.previewCount ? <Tag bordered={false}>preview {referenceSummary.previewCount}</Tag> : null}
|
|
{referenceSummary.imageCount ? <Tag bordered={false}>image {referenceSummary.imageCount}</Tag> : null}
|
|
{referenceSummary.linkCount ? <Tag bordered={false}>link {referenceSummary.linkCount}</Tag> : null}
|
|
{item.statusCode ? <Tag bordered={false}>{item.statusCode}</Tag> : null}
|
|
{item.requestMethod ? <Tag bordered={false}>{item.requestMethod}</Tag> : null}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{selectedErrorLog ? (
|
|
<div className="app-chat-panel__error-detail">
|
|
{errorDetailHeader}
|
|
<ErrorDetailContent
|
|
selectedErrorLog={selectedErrorLog}
|
|
selectedErrorLogReferenceSummary={selectedErrorLogReferenceSummary}
|
|
activeErrorResource={activeErrorResource}
|
|
onSelectResource={onSelectResource}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|