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.
Files changed (141) hide show
  1. package/.eslintrc.js +10 -0
  2. package/.vscode/settings.json +3 -0
  3. package/README.md +92 -0
  4. package/apps/web/README.md +11 -0
  5. package/apps/web/api/auth/keys.ts +3 -0
  6. package/apps/web/api/auth/types.ts +25 -0
  7. package/apps/web/api/auth/use-query-profile.ts +19 -0
  8. package/apps/web/api/auth/use-sign-in.ts +24 -0
  9. package/apps/web/api/axios.ts +122 -0
  10. package/apps/web/api/error-code.ts +36 -0
  11. package/apps/web/api/event/keys.ts +14 -0
  12. package/apps/web/api/event/types.ts +91 -0
  13. package/apps/web/api/event/use-mutation-event-assignee.ts +103 -0
  14. package/apps/web/api/event/use-mutation-event-priority.ts +97 -0
  15. package/apps/web/api/event/use-mutation-event-status.ts +198 -0
  16. package/apps/web/api/event/use-query-event-detail.ts +25 -0
  17. package/apps/web/api/event/use-query-event-list.ts +42 -0
  18. package/apps/web/api/health-check/index.ts +21 -0
  19. package/apps/web/api/project/keys.ts +4 -0
  20. package/apps/web/api/project/types.ts +28 -0
  21. package/apps/web/api/project/use-create-project.ts +30 -0
  22. package/apps/web/api/project/use-query-project-list.ts +19 -0
  23. package/apps/web/api/project/use-query-project-users.ts +23 -0
  24. package/apps/web/api/types.ts +44 -0
  25. package/apps/web/app/(auth)/layout.tsx +5 -0
  26. package/apps/web/app/(auth)/sign-in/page.tsx +20 -0
  27. package/apps/web/app/(auth)/sign-up/page.tsx +5 -0
  28. package/apps/web/app/(project)/project/[project_id]/issues/[issue_id]/page.tsx +17 -0
  29. package/apps/web/app/(project)/project/[project_id]/issues/page.tsx +15 -0
  30. package/apps/web/app/(project)/project/[project_id]/page.tsx +10 -0
  31. package/apps/web/app/(project)/project/page.tsx +10 -0
  32. package/apps/web/app/(report)/error-list/page.tsx +7 -0
  33. package/apps/web/app/favicon.ico +0 -0
  34. package/apps/web/app/layout.tsx +35 -0
  35. package/apps/web/app/page.tsx +3 -0
  36. package/apps/web/components/.gitkeep +0 -0
  37. package/apps/web/components/event-list/event-detail.tsx +242 -0
  38. package/apps/web/components/event-list/event-list.tsx +376 -0
  39. package/apps/web/components/event-list/preview.tsx +573 -0
  40. package/apps/web/components/layouts/default-layout.tsx +59 -0
  41. package/apps/web/components/login-form.tsx +124 -0
  42. package/apps/web/components/project/create-project.tsx +130 -0
  43. package/apps/web/components/project/hooks/use-get-event-params.ts +9 -0
  44. package/apps/web/components/project/hooks/use-get-project-params.ts +10 -0
  45. package/apps/web/components/project/project-detail.tsx +240 -0
  46. package/apps/web/components/project/project-list.tsx +137 -0
  47. package/apps/web/components/providers.tsx +25 -0
  48. package/apps/web/components/ui/assignee-dropdown.tsx +176 -0
  49. package/apps/web/components/ui/event-status-dropdown.tsx +104 -0
  50. package/apps/web/components/ui/priority-dropdown.tsx +123 -0
  51. package/apps/web/components/widget/app-sidebar.tsx +225 -0
  52. package/apps/web/components/widget/nav-main.tsx +73 -0
  53. package/apps/web/components/widget/nav-projects.tsx +84 -0
  54. package/apps/web/components/widget/nav-user.tsx +113 -0
  55. package/apps/web/components.json +20 -0
  56. package/apps/web/constants/routes.ts +12 -0
  57. package/apps/web/eslint.config.js +4 -0
  58. package/apps/web/hooks/use-boolean-state.ts +13 -0
  59. package/apps/web/lib/.gitkeep +0 -0
  60. package/apps/web/next-env.d.ts +5 -0
  61. package/apps/web/next.config.mjs +6 -0
  62. package/apps/web/package.json +60 -0
  63. package/apps/web/postcss.config.mjs +1 -0
  64. package/apps/web/providers/flag-provider.tsx +35 -0
  65. package/apps/web/providers/query-client-provider.tsx +17 -0
  66. package/apps/web/providers/telemetry-provider.tsx +12 -0
  67. package/apps/web/tsconfig.json +24 -0
  68. package/apps/web/utils/avatar.ts +26 -0
  69. package/apps/web/utils/date.ts +26 -0
  70. package/apps/web/utils/front-end-tracer.ts +119 -0
  71. package/apps/web/utils/schema/project.schema.ts +12 -0
  72. package/apps/web/utils/span-processor.ts +36 -0
  73. package/package.json +21 -0
  74. package/packages/eslint-config/README.md +3 -0
  75. package/packages/eslint-config/base.js +32 -0
  76. package/packages/eslint-config/next.js +51 -0
  77. package/packages/eslint-config/package.json +25 -0
  78. package/packages/eslint-config/react-internal.js +41 -0
  79. package/packages/rusty-replay/README.md +165 -0
  80. package/packages/rusty-replay/package.json +67 -0
  81. package/packages/rusty-replay/src/environment.ts +27 -0
  82. package/packages/rusty-replay/src/error-batcher.ts +75 -0
  83. package/packages/rusty-replay/src/front-end-tracer.ts +86 -0
  84. package/packages/rusty-replay/src/handler.ts +37 -0
  85. package/packages/rusty-replay/src/index.ts +8 -0
  86. package/packages/rusty-replay/src/recorder.ts +71 -0
  87. package/packages/rusty-replay/src/reporter.ts +115 -0
  88. package/packages/rusty-replay/src/utils.ts +13 -0
  89. package/packages/rusty-replay/tsconfig.build.json +13 -0
  90. package/packages/rusty-replay/tsconfig.json +27 -0
  91. package/packages/rusty-replay/tsup.config.ts +39 -0
  92. package/packages/typescript-config/README.md +3 -0
  93. package/packages/typescript-config/base.json +20 -0
  94. package/packages/typescript-config/nextjs.json +13 -0
  95. package/packages/typescript-config/package.json +9 -0
  96. package/packages/typescript-config/react-library.json +8 -0
  97. package/packages/ui/components.json +20 -0
  98. package/packages/ui/eslint.config.js +4 -0
  99. package/packages/ui/package.json +60 -0
  100. package/packages/ui/postcss.config.mjs +6 -0
  101. package/packages/ui/src/components/.gitkeep +0 -0
  102. package/packages/ui/src/components/avatar.tsx +53 -0
  103. package/packages/ui/src/components/badge.tsx +46 -0
  104. package/packages/ui/src/components/breadcrumb.tsx +109 -0
  105. package/packages/ui/src/components/button.tsx +59 -0
  106. package/packages/ui/src/components/calendar.tsx +75 -0
  107. package/packages/ui/src/components/calendars/date-picker.tsx +43 -0
  108. package/packages/ui/src/components/calendars/date-range-picker.tsx +79 -0
  109. package/packages/ui/src/components/card.tsx +92 -0
  110. package/packages/ui/src/components/checkbox.tsx +32 -0
  111. package/packages/ui/src/components/collapsible.tsx +33 -0
  112. package/packages/ui/src/components/dialog.tsx +135 -0
  113. package/packages/ui/src/components/dialogs/confirmation-modal.tsx +216 -0
  114. package/packages/ui/src/components/dropdown-menu.tsx +261 -0
  115. package/packages/ui/src/components/input.tsx +30 -0
  116. package/packages/ui/src/components/label.tsx +24 -0
  117. package/packages/ui/src/components/login-form.tsx +68 -0
  118. package/packages/ui/src/components/mode-switcher.tsx +34 -0
  119. package/packages/ui/src/components/popover.tsx +48 -0
  120. package/packages/ui/src/components/scroll-area.tsx +58 -0
  121. package/packages/ui/src/components/select.tsx +185 -0
  122. package/packages/ui/src/components/separator.tsx +28 -0
  123. package/packages/ui/src/components/sheet.tsx +139 -0
  124. package/packages/ui/src/components/sidebar.tsx +726 -0
  125. package/packages/ui/src/components/skeleton.tsx +13 -0
  126. package/packages/ui/src/components/sonner.tsx +25 -0
  127. package/packages/ui/src/components/table.tsx +116 -0
  128. package/packages/ui/src/components/tabs.tsx +66 -0
  129. package/packages/ui/src/components/team-switcher.tsx +91 -0
  130. package/packages/ui/src/components/textarea.tsx +18 -0
  131. package/packages/ui/src/components/tooltip.tsx +61 -0
  132. package/packages/ui/src/hooks/.gitkeep +0 -0
  133. package/packages/ui/src/hooks/use-meta-color.ts +28 -0
  134. package/packages/ui/src/hooks/use-mobile.ts +19 -0
  135. package/packages/ui/src/lib/utils.ts +6 -0
  136. package/packages/ui/src/styles/globals.css +138 -0
  137. package/packages/ui/tsconfig.json +13 -0
  138. package/packages/ui/tsconfig.lint.json +8 -0
  139. package/pnpm-workspace.yaml +4 -0
  140. package/tsconfig.json +4 -0
  141. 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
+ }