rusty-replay 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.js +10 -0
- package/.vscode/settings.json +3 -0
- package/README.md +92 -0
- package/apps/web/README.md +11 -0
- package/apps/web/api/auth/keys.ts +3 -0
- package/apps/web/api/auth/types.ts +25 -0
- package/apps/web/api/auth/use-query-profile.ts +19 -0
- package/apps/web/api/auth/use-sign-in.ts +24 -0
- package/apps/web/api/axios.ts +122 -0
- package/apps/web/api/error-code.ts +36 -0
- package/apps/web/api/event/keys.ts +14 -0
- package/apps/web/api/event/types.ts +91 -0
- package/apps/web/api/event/use-mutation-event-assignee.ts +103 -0
- package/apps/web/api/event/use-mutation-event-priority.ts +97 -0
- package/apps/web/api/event/use-mutation-event-status.ts +198 -0
- package/apps/web/api/event/use-query-event-detail.ts +25 -0
- package/apps/web/api/event/use-query-event-list.ts +42 -0
- package/apps/web/api/health-check/index.ts +21 -0
- package/apps/web/api/project/keys.ts +4 -0
- package/apps/web/api/project/types.ts +28 -0
- package/apps/web/api/project/use-create-project.ts +30 -0
- package/apps/web/api/project/use-query-project-list.ts +19 -0
- package/apps/web/api/project/use-query-project-users.ts +23 -0
- package/apps/web/api/types.ts +44 -0
- package/apps/web/app/(auth)/layout.tsx +5 -0
- package/apps/web/app/(auth)/sign-in/page.tsx +20 -0
- package/apps/web/app/(auth)/sign-up/page.tsx +5 -0
- package/apps/web/app/(project)/project/[project_id]/issues/[issue_id]/page.tsx +17 -0
- package/apps/web/app/(project)/project/[project_id]/issues/page.tsx +15 -0
- package/apps/web/app/(project)/project/[project_id]/page.tsx +10 -0
- package/apps/web/app/(project)/project/page.tsx +10 -0
- package/apps/web/app/(report)/error-list/page.tsx +7 -0
- package/apps/web/app/favicon.ico +0 -0
- package/apps/web/app/layout.tsx +35 -0
- package/apps/web/app/page.tsx +3 -0
- package/apps/web/components/.gitkeep +0 -0
- package/apps/web/components/event-list/event-detail.tsx +242 -0
- package/apps/web/components/event-list/event-list.tsx +376 -0
- package/apps/web/components/event-list/preview.tsx +573 -0
- package/apps/web/components/layouts/default-layout.tsx +59 -0
- package/apps/web/components/login-form.tsx +124 -0
- package/apps/web/components/project/create-project.tsx +130 -0
- package/apps/web/components/project/hooks/use-get-event-params.ts +9 -0
- package/apps/web/components/project/hooks/use-get-project-params.ts +10 -0
- package/apps/web/components/project/project-detail.tsx +240 -0
- package/apps/web/components/project/project-list.tsx +137 -0
- package/apps/web/components/providers.tsx +25 -0
- package/apps/web/components/ui/assignee-dropdown.tsx +176 -0
- package/apps/web/components/ui/event-status-dropdown.tsx +104 -0
- package/apps/web/components/ui/priority-dropdown.tsx +123 -0
- package/apps/web/components/widget/app-sidebar.tsx +225 -0
- package/apps/web/components/widget/nav-main.tsx +73 -0
- package/apps/web/components/widget/nav-projects.tsx +84 -0
- package/apps/web/components/widget/nav-user.tsx +113 -0
- package/apps/web/components.json +20 -0
- package/apps/web/constants/routes.ts +12 -0
- package/apps/web/eslint.config.js +4 -0
- package/apps/web/hooks/use-boolean-state.ts +13 -0
- package/apps/web/lib/.gitkeep +0 -0
- package/apps/web/next-env.d.ts +5 -0
- package/apps/web/next.config.mjs +6 -0
- package/apps/web/package.json +60 -0
- package/apps/web/postcss.config.mjs +1 -0
- package/apps/web/providers/flag-provider.tsx +35 -0
- package/apps/web/providers/query-client-provider.tsx +17 -0
- package/apps/web/providers/telemetry-provider.tsx +12 -0
- package/apps/web/tsconfig.json +24 -0
- package/apps/web/utils/avatar.ts +26 -0
- package/apps/web/utils/date.ts +26 -0
- package/apps/web/utils/front-end-tracer.ts +119 -0
- package/apps/web/utils/schema/project.schema.ts +12 -0
- package/apps/web/utils/span-processor.ts +36 -0
- package/package.json +21 -0
- package/packages/eslint-config/README.md +3 -0
- package/packages/eslint-config/base.js +32 -0
- package/packages/eslint-config/next.js +51 -0
- package/packages/eslint-config/package.json +25 -0
- package/packages/eslint-config/react-internal.js +41 -0
- package/packages/rusty-replay/README.md +165 -0
- package/packages/rusty-replay/package.json +67 -0
- package/packages/rusty-replay/src/environment.ts +27 -0
- package/packages/rusty-replay/src/error-batcher.ts +75 -0
- package/packages/rusty-replay/src/front-end-tracer.ts +86 -0
- package/packages/rusty-replay/src/handler.ts +37 -0
- package/packages/rusty-replay/src/index.ts +8 -0
- package/packages/rusty-replay/src/recorder.ts +71 -0
- package/packages/rusty-replay/src/reporter.ts +115 -0
- package/packages/rusty-replay/src/utils.ts +13 -0
- package/packages/rusty-replay/tsconfig.build.json +13 -0
- package/packages/rusty-replay/tsconfig.json +27 -0
- package/packages/rusty-replay/tsup.config.ts +39 -0
- package/packages/typescript-config/README.md +3 -0
- package/packages/typescript-config/base.json +20 -0
- package/packages/typescript-config/nextjs.json +13 -0
- package/packages/typescript-config/package.json +9 -0
- package/packages/typescript-config/react-library.json +8 -0
- package/packages/ui/components.json +20 -0
- package/packages/ui/eslint.config.js +4 -0
- package/packages/ui/package.json +60 -0
- package/packages/ui/postcss.config.mjs +6 -0
- package/packages/ui/src/components/.gitkeep +0 -0
- package/packages/ui/src/components/avatar.tsx +53 -0
- package/packages/ui/src/components/badge.tsx +46 -0
- package/packages/ui/src/components/breadcrumb.tsx +109 -0
- package/packages/ui/src/components/button.tsx +59 -0
- package/packages/ui/src/components/calendar.tsx +75 -0
- package/packages/ui/src/components/calendars/date-picker.tsx +43 -0
- package/packages/ui/src/components/calendars/date-range-picker.tsx +79 -0
- package/packages/ui/src/components/card.tsx +92 -0
- package/packages/ui/src/components/checkbox.tsx +32 -0
- package/packages/ui/src/components/collapsible.tsx +33 -0
- package/packages/ui/src/components/dialog.tsx +135 -0
- package/packages/ui/src/components/dialogs/confirmation-modal.tsx +216 -0
- package/packages/ui/src/components/dropdown-menu.tsx +261 -0
- package/packages/ui/src/components/input.tsx +30 -0
- package/packages/ui/src/components/label.tsx +24 -0
- package/packages/ui/src/components/login-form.tsx +68 -0
- package/packages/ui/src/components/mode-switcher.tsx +34 -0
- package/packages/ui/src/components/popover.tsx +48 -0
- package/packages/ui/src/components/scroll-area.tsx +58 -0
- package/packages/ui/src/components/select.tsx +185 -0
- package/packages/ui/src/components/separator.tsx +28 -0
- package/packages/ui/src/components/sheet.tsx +139 -0
- package/packages/ui/src/components/sidebar.tsx +726 -0
- package/packages/ui/src/components/skeleton.tsx +13 -0
- package/packages/ui/src/components/sonner.tsx +25 -0
- package/packages/ui/src/components/table.tsx +116 -0
- package/packages/ui/src/components/tabs.tsx +66 -0
- package/packages/ui/src/components/team-switcher.tsx +91 -0
- package/packages/ui/src/components/textarea.tsx +18 -0
- package/packages/ui/src/components/tooltip.tsx +61 -0
- package/packages/ui/src/hooks/.gitkeep +0 -0
- package/packages/ui/src/hooks/use-meta-color.ts +28 -0
- package/packages/ui/src/hooks/use-mobile.ts +19 -0
- package/packages/ui/src/lib/utils.ts +6 -0
- package/packages/ui/src/styles/globals.css +138 -0
- package/packages/ui/tsconfig.json +13 -0
- package/packages/ui/tsconfig.lint.json +8 -0
- package/pnpm-workspace.yaml +4 -0
- package/tsconfig.json +4 -0
- package/turbo.json +21 -0
@@ -0,0 +1,242 @@
|
|
1
|
+
'use client';
|
2
|
+
|
3
|
+
import React, { useEffect, useState, useRef } from 'react';
|
4
|
+
import { useQueryEventDetail } from '@/api/event/use-query-event-detail';
|
5
|
+
import { useRouter } from 'next/navigation';
|
6
|
+
import {
|
7
|
+
Tabs,
|
8
|
+
TabsContent,
|
9
|
+
TabsList,
|
10
|
+
TabsTrigger,
|
11
|
+
} from '@workspace/ui/components/tabs';
|
12
|
+
|
13
|
+
import 'dayjs/locale/ko';
|
14
|
+
import 'rrweb-player/dist/style.css';
|
15
|
+
|
16
|
+
import {
|
17
|
+
BackButton,
|
18
|
+
LoadingSkeleton,
|
19
|
+
EventNotFound,
|
20
|
+
OverviewTab,
|
21
|
+
StacktraceTab,
|
22
|
+
ReplayTab,
|
23
|
+
} from './preview';
|
24
|
+
import { decompressFromBase64 } from '@workspace/rusty-replay/index';
|
25
|
+
import { routes } from '@/constants/routes';
|
26
|
+
|
27
|
+
interface ErrorDetailProps {
|
28
|
+
params: {
|
29
|
+
projectId: number | undefined;
|
30
|
+
eventId: number | undefined;
|
31
|
+
};
|
32
|
+
}
|
33
|
+
|
34
|
+
export default function EventDetail({ params }: ErrorDetailProps) {
|
35
|
+
const router = useRouter();
|
36
|
+
const projectId = params.projectId;
|
37
|
+
const eventId = params.eventId;
|
38
|
+
const playerRef = useRef<HTMLDivElement>(null);
|
39
|
+
const [playerInitialized, setPlayerInitialized] = useState(false);
|
40
|
+
const [playerError, setPlayerError] = useState<string | null>(null);
|
41
|
+
const [activeTab, setActiveTab] = useState('overview');
|
42
|
+
|
43
|
+
const { data: error, isLoading } = useQueryEventDetail({
|
44
|
+
projectId: projectId as number,
|
45
|
+
eventId: eventId as number,
|
46
|
+
options: {
|
47
|
+
refetchOnWindowFocus: false,
|
48
|
+
enabled: !!(eventId && projectId),
|
49
|
+
},
|
50
|
+
});
|
51
|
+
|
52
|
+
const hasReplay =
|
53
|
+
!!error?.replay &&
|
54
|
+
((typeof error.replay === 'string' && error.replay.trim().length > 0) ||
|
55
|
+
(Array.isArray(error.replay) && error.replay.length > 0));
|
56
|
+
|
57
|
+
useEffect(() => {
|
58
|
+
if (
|
59
|
+
activeTab !== 'replay' ||
|
60
|
+
!hasReplay ||
|
61
|
+
!error?.replay ||
|
62
|
+
typeof window === 'undefined'
|
63
|
+
) {
|
64
|
+
return;
|
65
|
+
}
|
66
|
+
|
67
|
+
const initPlayer = async () => {
|
68
|
+
try {
|
69
|
+
setPlayerInitialized(false);
|
70
|
+
setPlayerError(null);
|
71
|
+
|
72
|
+
if (playerRef.current) {
|
73
|
+
playerRef.current.innerHTML = '';
|
74
|
+
}
|
75
|
+
|
76
|
+
const { default: Player } = await import('rrweb-player');
|
77
|
+
|
78
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
79
|
+
let events: any = error.replay;
|
80
|
+
|
81
|
+
if (typeof events === 'string') {
|
82
|
+
try {
|
83
|
+
const decompressed = decompressFromBase64(events);
|
84
|
+
|
85
|
+
if (!decompressed) {
|
86
|
+
throw new Error('Base64 디코딩 결과가 null입니다.');
|
87
|
+
}
|
88
|
+
|
89
|
+
events = decompressed;
|
90
|
+
} catch (e) {
|
91
|
+
console.error('리플레이 base64 디코딩 또는 파싱 실패:', e);
|
92
|
+
setPlayerError(
|
93
|
+
'리플레이 데이터 압축 해제 또는 파싱에 실패했습니다.'
|
94
|
+
);
|
95
|
+
return;
|
96
|
+
}
|
97
|
+
}
|
98
|
+
|
99
|
+
if (!Array.isArray(events)) {
|
100
|
+
console.error('Replay data is not an array:', events);
|
101
|
+
setPlayerError('리플레이 데이터가 올바른 형식이 아닙니다.');
|
102
|
+
return;
|
103
|
+
}
|
104
|
+
|
105
|
+
if (events.length === 0) {
|
106
|
+
console.error('Replay data is empty');
|
107
|
+
setPlayerError('리플레이 데이터가 비어있습니다.');
|
108
|
+
return;
|
109
|
+
}
|
110
|
+
|
111
|
+
const validEvents = events.every(
|
112
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
113
|
+
(event: any) =>
|
114
|
+
typeof event === 'object' &&
|
115
|
+
event !== null &&
|
116
|
+
typeof event.type === 'number' &&
|
117
|
+
typeof event.timestamp === 'number'
|
118
|
+
);
|
119
|
+
|
120
|
+
if (!validEvents) {
|
121
|
+
console.error('Some events are invalid:', events.slice(0, 3));
|
122
|
+
setPlayerError('일부 이벤트 데이터가 올바르지 않습니다.');
|
123
|
+
return;
|
124
|
+
}
|
125
|
+
|
126
|
+
new Player({
|
127
|
+
target: playerRef.current!,
|
128
|
+
props: {
|
129
|
+
events,
|
130
|
+
width: 1200,
|
131
|
+
height: 800,
|
132
|
+
autoPlay: false,
|
133
|
+
showController: true,
|
134
|
+
skipInactive: true,
|
135
|
+
},
|
136
|
+
});
|
137
|
+
|
138
|
+
setPlayerInitialized(true);
|
139
|
+
} catch (err) {
|
140
|
+
console.error('리플레이 플레이어 초기화 오류:', err);
|
141
|
+
setPlayerError(
|
142
|
+
`리플레이 플레이어 초기화 오류: ${err instanceof Error ? err.message : String(err)}`
|
143
|
+
);
|
144
|
+
}
|
145
|
+
};
|
146
|
+
|
147
|
+
initPlayer();
|
148
|
+
|
149
|
+
return () => {
|
150
|
+
if (playerRef.current) {
|
151
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
152
|
+
playerRef.current.innerHTML = '';
|
153
|
+
}
|
154
|
+
};
|
155
|
+
}, [error, hasReplay, activeTab]);
|
156
|
+
|
157
|
+
const handleTabChange = (value: string) => {
|
158
|
+
setActiveTab(value);
|
159
|
+
};
|
160
|
+
|
161
|
+
const goBack = () => {
|
162
|
+
router.push(routes.event.list(projectId as number));
|
163
|
+
};
|
164
|
+
|
165
|
+
const formatStacktrace = (stacktrace: string) => {
|
166
|
+
if (!stacktrace) return 'No stacktrace available';
|
167
|
+
|
168
|
+
const lines = stacktrace.split('\n').map((line, index) => {
|
169
|
+
if (index === 0) {
|
170
|
+
return `<div class="text-red-600 font-semibold">${line}</div>`;
|
171
|
+
}
|
172
|
+
|
173
|
+
return line
|
174
|
+
.replace(
|
175
|
+
/at\s+([^\s\\(]+)/g,
|
176
|
+
'at <span class="text-blue-600 font-medium">$1</span>'
|
177
|
+
)
|
178
|
+
.replace(
|
179
|
+
/\(([^:]+):(\d+):(\d+)\)/g,
|
180
|
+
'(<span class="text-gray-600">$1</span>:<span class="text-orange-500 font-medium">$2</span>:<span class="text-purple-500 font-medium">$3</span>)'
|
181
|
+
);
|
182
|
+
});
|
183
|
+
|
184
|
+
return lines.join('\n');
|
185
|
+
};
|
186
|
+
|
187
|
+
return (
|
188
|
+
<div className="space-y-6">
|
189
|
+
<div>
|
190
|
+
<BackButton onClick={goBack} />
|
191
|
+
</div>
|
192
|
+
|
193
|
+
{isLoading ? (
|
194
|
+
<LoadingSkeleton />
|
195
|
+
) : error ? (
|
196
|
+
<Tabs
|
197
|
+
value={activeTab}
|
198
|
+
onValueChange={handleTabChange}
|
199
|
+
className="w-full"
|
200
|
+
>
|
201
|
+
<TabsList className="mb-4">
|
202
|
+
<TabsTrigger value="overview">개요</TabsTrigger>
|
203
|
+
<TabsTrigger value="stacktrace">스택트레이스</TabsTrigger>
|
204
|
+
{hasReplay && (
|
205
|
+
<TabsTrigger value="replay">세션 리플레이</TabsTrigger>
|
206
|
+
)}
|
207
|
+
</TabsList>
|
208
|
+
|
209
|
+
<TabsContent value="overview">
|
210
|
+
<OverviewTab
|
211
|
+
error={error}
|
212
|
+
formatStacktrace={formatStacktrace}
|
213
|
+
hasReplay={hasReplay}
|
214
|
+
setActiveTab={setActiveTab}
|
215
|
+
projectId={projectId}
|
216
|
+
/>
|
217
|
+
</TabsContent>
|
218
|
+
|
219
|
+
<TabsContent value="stacktrace">
|
220
|
+
<StacktraceTab error={error} formatStacktrace={formatStacktrace} />
|
221
|
+
</TabsContent>
|
222
|
+
|
223
|
+
{hasReplay && (
|
224
|
+
<TabsContent value="replay">
|
225
|
+
<ReplayTab
|
226
|
+
error={error}
|
227
|
+
playerRef={playerRef}
|
228
|
+
playerInitialized={playerInitialized}
|
229
|
+
playerError={playerError}
|
230
|
+
setPlayerError={setPlayerError}
|
231
|
+
setPlayerInitialized={setPlayerInitialized}
|
232
|
+
setActiveTab={setActiveTab}
|
233
|
+
/>
|
234
|
+
</TabsContent>
|
235
|
+
)}
|
236
|
+
</Tabs>
|
237
|
+
) : (
|
238
|
+
<EventNotFound eventId={eventId} goBack={goBack} />
|
239
|
+
)}
|
240
|
+
</div>
|
241
|
+
);
|
242
|
+
}
|
@@ -0,0 +1,376 @@
|
|
1
|
+
'use client';
|
2
|
+
|
3
|
+
import React, { useReducer, useState } from 'react';
|
4
|
+
import {
|
5
|
+
Card,
|
6
|
+
CardContent,
|
7
|
+
CardDescription,
|
8
|
+
CardHeader,
|
9
|
+
CardTitle,
|
10
|
+
} from '@workspace/ui/components/card';
|
11
|
+
import {
|
12
|
+
Table,
|
13
|
+
TableBody,
|
14
|
+
TableCell,
|
15
|
+
TableHead,
|
16
|
+
TableHeader,
|
17
|
+
TableRow,
|
18
|
+
} from '@workspace/ui/components/table';
|
19
|
+
import { Button } from '@workspace/ui/components/button';
|
20
|
+
import { Checkbox } from '@workspace/ui/components/checkbox';
|
21
|
+
import { Skeleton } from '@workspace/ui/components/skeleton';
|
22
|
+
import { Badge } from '@workspace/ui/components/badge';
|
23
|
+
import { Input } from '@workspace/ui/components/input';
|
24
|
+
import {
|
25
|
+
Select,
|
26
|
+
SelectContent,
|
27
|
+
SelectItem,
|
28
|
+
SelectTrigger,
|
29
|
+
SelectValue,
|
30
|
+
} from '@workspace/ui/components/select';
|
31
|
+
import { Search, Clock, Smartphone, Globe, Play } from 'lucide-react';
|
32
|
+
import dayjs from 'dayjs';
|
33
|
+
import 'dayjs/locale/ko';
|
34
|
+
import relativeTime from 'dayjs/plugin/relativeTime';
|
35
|
+
import { DateRange } from 'react-day-picker';
|
36
|
+
import { useQueryErrorList } from '@/api/event/use-query-event-list';
|
37
|
+
import { useMutationEventPriority } from '@/api/event/use-mutation-event-priority';
|
38
|
+
import { useMutationEventStatus } from '@/api/event/use-mutation-event-status';
|
39
|
+
import { useRouter } from 'next/navigation';
|
40
|
+
import { DateRangePicker } from '@workspace/ui/components/calendars/date-range-picker';
|
41
|
+
import { formatDate, formatDateFromNow } from '@/utils/date';
|
42
|
+
import { PriorityDropdown } from '../ui/priority-dropdown';
|
43
|
+
import { AssigneeDropdown } from '../ui/assignee-dropdown';
|
44
|
+
import { useQueryProjectUsers } from '@/api/project/use-query-project-users';
|
45
|
+
import { EventPriorityType, EventStatusType } from '@/api/event/types';
|
46
|
+
import { EventStatusDropdown } from '../ui/event-status-dropdown';
|
47
|
+
import { routes } from '@/constants/routes';
|
48
|
+
|
49
|
+
dayjs.extend(relativeTime);
|
50
|
+
dayjs.locale('ko');
|
51
|
+
|
52
|
+
type State = {
|
53
|
+
searchTerm: string;
|
54
|
+
filter: string;
|
55
|
+
page: number;
|
56
|
+
pageSize: number;
|
57
|
+
dateRange: DateRange | undefined;
|
58
|
+
};
|
59
|
+
|
60
|
+
type Action =
|
61
|
+
| { type: 'SET_SEARCH_TERM'; payload: string }
|
62
|
+
| { type: 'SET_FILTER'; payload: string }
|
63
|
+
| { type: 'SET_PAGE'; payload: number }
|
64
|
+
| { type: 'SET_DATE_RANGE'; payload: DateRange | undefined };
|
65
|
+
|
66
|
+
const initialState: State = {
|
67
|
+
searchTerm: '',
|
68
|
+
filter: 'all',
|
69
|
+
page: 1,
|
70
|
+
pageSize: 30,
|
71
|
+
dateRange: undefined,
|
72
|
+
};
|
73
|
+
|
74
|
+
function reducer(state: State, action: Action): State {
|
75
|
+
switch (action.type) {
|
76
|
+
case 'SET_SEARCH_TERM':
|
77
|
+
return { ...state, searchTerm: action.payload, page: 1 };
|
78
|
+
case 'SET_FILTER':
|
79
|
+
return { ...state, filter: action.payload, page: 1 };
|
80
|
+
case 'SET_PAGE':
|
81
|
+
return { ...state, page: action.payload };
|
82
|
+
case 'SET_DATE_RANGE':
|
83
|
+
return { ...state, dateRange: action.payload, page: 1 };
|
84
|
+
default:
|
85
|
+
return state;
|
86
|
+
}
|
87
|
+
}
|
88
|
+
|
89
|
+
export default function EventList({ projectId }: { projectId?: number }) {
|
90
|
+
const router = useRouter();
|
91
|
+
const [state, dispatch] = useReducer(reducer, initialState);
|
92
|
+
|
93
|
+
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
94
|
+
const [batchPriority, setBatchPriority] = useState<EventPriorityType>();
|
95
|
+
const [batchStatus, setBatchStatus] = useState<EventStatusType>();
|
96
|
+
|
97
|
+
const { data: userList } = useQueryProjectUsers({ projectId: projectId! });
|
98
|
+
|
99
|
+
const { data: errorList, isLoading } = useQueryErrorList({
|
100
|
+
projectId: projectId as number,
|
101
|
+
eventQuery: {
|
102
|
+
search: state.searchTerm,
|
103
|
+
page: state.page,
|
104
|
+
pageSize: state.pageSize,
|
105
|
+
startDate: state.dateRange?.from
|
106
|
+
? dayjs(state.dateRange.from).startOf('day').toISOString()
|
107
|
+
: null,
|
108
|
+
endDate: state.dateRange?.to
|
109
|
+
? dayjs(state.dateRange.to).endOf('day').toISOString()
|
110
|
+
: null,
|
111
|
+
},
|
112
|
+
options: { enabled: !!projectId },
|
113
|
+
});
|
114
|
+
|
115
|
+
const { mutateAsync: mutatePriority, isPending: isMutatingPriority } =
|
116
|
+
useMutationEventPriority({ projectId: projectId! });
|
117
|
+
const { mutateAsync: mutateStatus, isPending: isMutatingStatus } =
|
118
|
+
useMutationEventStatus({ projectId: projectId! });
|
119
|
+
|
120
|
+
const toggleSelect = (id: number) => {
|
121
|
+
setSelectedIds((prev) => {
|
122
|
+
const next = new Set(prev);
|
123
|
+
next.has(id) ? next.delete(id) : next.add(id);
|
124
|
+
return next;
|
125
|
+
});
|
126
|
+
};
|
127
|
+
const toggleAll = (checked: boolean) => {
|
128
|
+
if (checked && errorList) {
|
129
|
+
setSelectedIds(new Set(errorList.content.map((e) => e.id)));
|
130
|
+
} else {
|
131
|
+
setSelectedIds(new Set());
|
132
|
+
}
|
133
|
+
};
|
134
|
+
|
135
|
+
const applyBatchPriority = async () => {
|
136
|
+
if (batchPriority && selectedIds.size > 0) {
|
137
|
+
await mutatePriority(
|
138
|
+
{ eventIds: Array.from(selectedIds), priority: batchPriority },
|
139
|
+
{ onSuccess: () => setSelectedIds(new Set()) }
|
140
|
+
);
|
141
|
+
}
|
142
|
+
};
|
143
|
+
const applyBatchStatus = async () => {
|
144
|
+
if (batchStatus && selectedIds.size > 0) {
|
145
|
+
await mutateStatus(
|
146
|
+
{ eventIds: Array.from(selectedIds), status: batchStatus },
|
147
|
+
{ onSuccess: () => setSelectedIds(new Set()) }
|
148
|
+
);
|
149
|
+
}
|
150
|
+
};
|
151
|
+
|
152
|
+
const navigateToDetail = (issueId: number) => {
|
153
|
+
router.push(routes.event.detail(projectId!, issueId));
|
154
|
+
};
|
155
|
+
|
156
|
+
return (
|
157
|
+
<div className="space-y-6">
|
158
|
+
{/* 검색 / 필터 바 */}
|
159
|
+
<div className="flex flex-col sm:flex-row justify-between mb-6 space-y-4 sm:space-y-0 sm:space-x-4">
|
160
|
+
<div className="relative w-full sm:w-1/3">
|
161
|
+
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
162
|
+
<Input
|
163
|
+
placeholder="에러 메시지, 버전 또는 해시로 검색..."
|
164
|
+
className="pl-8"
|
165
|
+
value={state.searchTerm}
|
166
|
+
onChange={(e) =>
|
167
|
+
dispatch({ type: 'SET_SEARCH_TERM', payload: e.target.value })
|
168
|
+
}
|
169
|
+
/>
|
170
|
+
</div>
|
171
|
+
<Select
|
172
|
+
value={state.filter}
|
173
|
+
onValueChange={(val) =>
|
174
|
+
dispatch({ type: 'SET_FILTER', payload: val })
|
175
|
+
}
|
176
|
+
>
|
177
|
+
<SelectTrigger className="w-full sm:w-1/3">
|
178
|
+
<SelectValue placeholder="환경 선택" />
|
179
|
+
</SelectTrigger>
|
180
|
+
<SelectContent>
|
181
|
+
<SelectItem value="all">모든 환경</SelectItem>
|
182
|
+
<SelectItem value="production">Production</SelectItem>
|
183
|
+
<SelectItem value="staging">Staging</SelectItem>
|
184
|
+
<SelectItem value="development">Development</SelectItem>
|
185
|
+
</SelectContent>
|
186
|
+
</Select>
|
187
|
+
<div className="w-full sm:w-1/3">
|
188
|
+
<DateRangePicker
|
189
|
+
dateRange={state.dateRange}
|
190
|
+
onDateRangeChange={(range) =>
|
191
|
+
dispatch({ type: 'SET_DATE_RANGE', payload: range })
|
192
|
+
}
|
193
|
+
/>
|
194
|
+
</div>
|
195
|
+
</div>
|
196
|
+
|
197
|
+
<Card>
|
198
|
+
<CardHeader>
|
199
|
+
<CardTitle className="text-2xl font-bold">Issue log</CardTitle>
|
200
|
+
<CardDescription>
|
201
|
+
프로젝트에서 발생한 모든 에러를 확인할 수 있습니다.
|
202
|
+
</CardDescription>
|
203
|
+
</CardHeader>
|
204
|
+
|
205
|
+
<CardContent>
|
206
|
+
{isLoading ? (
|
207
|
+
<div className="space-y-4">
|
208
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
209
|
+
<Skeleton key={i} className="h-10 w-full" />
|
210
|
+
))}
|
211
|
+
</div>
|
212
|
+
) : (
|
213
|
+
<>
|
214
|
+
{/* 배치 액션 바 */}
|
215
|
+
{selectedIds.size > 0 && (
|
216
|
+
<div className="flex items-center space-x-2 mb-4">
|
217
|
+
<Select
|
218
|
+
value={batchPriority}
|
219
|
+
onValueChange={(v) =>
|
220
|
+
setBatchPriority(v as EventPriorityType)
|
221
|
+
}
|
222
|
+
>
|
223
|
+
<SelectTrigger className="w-32">
|
224
|
+
<SelectValue placeholder="Priority" />
|
225
|
+
</SelectTrigger>
|
226
|
+
<SelectContent>
|
227
|
+
<SelectItem value="HIGH">HIGH</SelectItem>
|
228
|
+
<SelectItem value="MED">MED</SelectItem>
|
229
|
+
<SelectItem value="LOW">LOW</SelectItem>
|
230
|
+
</SelectContent>
|
231
|
+
</Select>
|
232
|
+
<Button
|
233
|
+
onClick={applyBatchPriority}
|
234
|
+
disabled={!batchPriority || isMutatingPriority}
|
235
|
+
>
|
236
|
+
Apply Priority
|
237
|
+
</Button>
|
238
|
+
<Select
|
239
|
+
value={batchStatus}
|
240
|
+
onValueChange={(v) => setBatchStatus(v as EventStatusType)}
|
241
|
+
>
|
242
|
+
<SelectTrigger className="w-32">
|
243
|
+
<SelectValue placeholder="Status" />
|
244
|
+
</SelectTrigger>
|
245
|
+
<SelectContent>
|
246
|
+
<SelectItem value="UNRESOLVED">UNRESOLVED</SelectItem>
|
247
|
+
<SelectItem value="RESOLVED">RESOLVED</SelectItem>
|
248
|
+
</SelectContent>
|
249
|
+
</Select>
|
250
|
+
<Button
|
251
|
+
onClick={applyBatchStatus}
|
252
|
+
disabled={!batchStatus || isMutatingStatus}
|
253
|
+
>
|
254
|
+
Apply Status
|
255
|
+
</Button>
|
256
|
+
</div>
|
257
|
+
)}
|
258
|
+
|
259
|
+
<Table>
|
260
|
+
<TableHeader>
|
261
|
+
<TableRow>
|
262
|
+
<TableHead>
|
263
|
+
<Checkbox
|
264
|
+
checked={
|
265
|
+
errorList
|
266
|
+
? selectedIds.size === errorList.content.length
|
267
|
+
: false
|
268
|
+
}
|
269
|
+
onCheckedChange={toggleAll}
|
270
|
+
/>
|
271
|
+
</TableHead>
|
272
|
+
<TableHead>Issue</TableHead>
|
273
|
+
<TableHead>시간</TableHead>
|
274
|
+
<TableHead>기기</TableHead>
|
275
|
+
<TableHead>리플레이</TableHead>
|
276
|
+
<TableHead>Priority</TableHead>
|
277
|
+
<TableHead>Status</TableHead>
|
278
|
+
<TableHead>Assignee</TableHead>
|
279
|
+
</TableRow>
|
280
|
+
</TableHeader>
|
281
|
+
<TableBody>
|
282
|
+
{errorList?.content.length === 0 ? (
|
283
|
+
<TableRow>
|
284
|
+
<TableCell colSpan={8} className="h-24 text-center">
|
285
|
+
검색 조건에 맞는 에러가 없습니다.
|
286
|
+
</TableCell>
|
287
|
+
</TableRow>
|
288
|
+
) : (
|
289
|
+
errorList?.content.map((error) => (
|
290
|
+
<TableRow key={error.id}>
|
291
|
+
<TableCell>
|
292
|
+
<Checkbox
|
293
|
+
checked={selectedIds.has(error.id)}
|
294
|
+
onCheckedChange={() => toggleSelect(error.id)}
|
295
|
+
/>
|
296
|
+
</TableCell>
|
297
|
+
<TableCell
|
298
|
+
className="cursor-pointer"
|
299
|
+
onClick={() => navigateToDetail(error.id)}
|
300
|
+
>
|
301
|
+
<div className="font-medium line-clamp-1 max-w-xs">
|
302
|
+
{error.message}
|
303
|
+
</div>
|
304
|
+
<div className="text-xs text-muted-foreground mt-1">
|
305
|
+
Hash: {error.groupHash}
|
306
|
+
</div>
|
307
|
+
</TableCell>
|
308
|
+
<TableCell>
|
309
|
+
<div className="flex items-center gap-2">
|
310
|
+
<Clock size={14} />
|
311
|
+
<div
|
312
|
+
title={error.timestamp}
|
313
|
+
className="flex items-center gap-3"
|
314
|
+
>
|
315
|
+
<span>{formatDateFromNow(error.timestamp)}</span>
|
316
|
+
<span>{formatDate(error.timestamp)}</span>
|
317
|
+
</div>
|
318
|
+
</div>
|
319
|
+
</TableCell>
|
320
|
+
<TableCell>
|
321
|
+
{error.browser ? (
|
322
|
+
<div className="flex items-center gap-2">
|
323
|
+
<Globe size={14} />
|
324
|
+
<span>
|
325
|
+
{error.browser} / {error.os || 'Unknown'}
|
326
|
+
</span>
|
327
|
+
</div>
|
328
|
+
) : (
|
329
|
+
<div className="flex items-center gap-2">
|
330
|
+
<Smartphone size={14} />
|
331
|
+
<span>앱</span>
|
332
|
+
</div>
|
333
|
+
)}
|
334
|
+
</TableCell>
|
335
|
+
<TableCell>
|
336
|
+
{error.hasReplay && (
|
337
|
+
<Badge variant="outline">
|
338
|
+
<Play />
|
339
|
+
</Badge>
|
340
|
+
)}
|
341
|
+
</TableCell>
|
342
|
+
<TableCell>
|
343
|
+
<PriorityDropdown
|
344
|
+
priority={error.priority}
|
345
|
+
projectId={projectId!}
|
346
|
+
eventId={error.id}
|
347
|
+
/>
|
348
|
+
</TableCell>
|
349
|
+
<TableCell>
|
350
|
+
{/* {error.status} */}
|
351
|
+
<EventStatusDropdown
|
352
|
+
projectId={projectId!}
|
353
|
+
eventId={error.id}
|
354
|
+
status={error.status}
|
355
|
+
/>
|
356
|
+
</TableCell>
|
357
|
+
<TableCell>
|
358
|
+
<AssigneeDropdown
|
359
|
+
projectId={projectId!}
|
360
|
+
eventId={error.id}
|
361
|
+
userList={userList}
|
362
|
+
currentAssigneeId={error.assignedTo}
|
363
|
+
/>
|
364
|
+
</TableCell>
|
365
|
+
</TableRow>
|
366
|
+
))
|
367
|
+
)}
|
368
|
+
</TableBody>
|
369
|
+
</Table>
|
370
|
+
</>
|
371
|
+
)}
|
372
|
+
</CardContent>
|
373
|
+
</Card>
|
374
|
+
</div>
|
375
|
+
);
|
376
|
+
}
|