railscope 0.1.0

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 (95) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +227 -0
  5. data/Rakefile +12 -0
  6. data/app/assets/stylesheets/railscope/application.css +504 -0
  7. data/app/controllers/railscope/api/entries_controller.rb +103 -0
  8. data/app/controllers/railscope/application_controller.rb +12 -0
  9. data/app/controllers/railscope/dashboard_controller.rb +33 -0
  10. data/app/controllers/railscope/entries_controller.rb +29 -0
  11. data/app/helpers/railscope/dashboard_helper.rb +157 -0
  12. data/app/jobs/railscope/application_job.rb +6 -0
  13. data/app/jobs/railscope/purge_job.rb +15 -0
  14. data/app/models/railscope/application_record.rb +12 -0
  15. data/app/models/railscope/entry.rb +51 -0
  16. data/app/views/layouts/railscope/application.html.erb +14 -0
  17. data/app/views/railscope/application/index.html.erb +1 -0
  18. data/app/views/railscope/dashboard/index.html.erb +70 -0
  19. data/app/views/railscope/entries/show.html.erb +93 -0
  20. data/client/.gitignore +1 -0
  21. data/client/index.html +12 -0
  22. data/client/package-lock.json +2735 -0
  23. data/client/package.json +28 -0
  24. data/client/postcss.config.js +6 -0
  25. data/client/src/App.tsx +60 -0
  26. data/client/src/api/client.ts +25 -0
  27. data/client/src/api/entries.ts +36 -0
  28. data/client/src/components/Layout.tsx +17 -0
  29. data/client/src/components/PlaceholderPage.tsx +32 -0
  30. data/client/src/components/Sidebar.tsx +198 -0
  31. data/client/src/components/ui/Badge.tsx +67 -0
  32. data/client/src/components/ui/Card.tsx +38 -0
  33. data/client/src/components/ui/JsonViewer.tsx +80 -0
  34. data/client/src/components/ui/Pagination.tsx +45 -0
  35. data/client/src/components/ui/SearchInput.tsx +70 -0
  36. data/client/src/components/ui/Table.tsx +68 -0
  37. data/client/src/index.css +28 -0
  38. data/client/src/lib/hooks.ts +37 -0
  39. data/client/src/lib/types.ts +61 -0
  40. data/client/src/lib/utils.ts +38 -0
  41. data/client/src/main.tsx +13 -0
  42. data/client/src/screens/cache/Index.tsx +15 -0
  43. data/client/src/screens/client-requests/Index.tsx +15 -0
  44. data/client/src/screens/commands/Index.tsx +133 -0
  45. data/client/src/screens/commands/Show.tsx +395 -0
  46. data/client/src/screens/dumps/Index.tsx +15 -0
  47. data/client/src/screens/events/Index.tsx +15 -0
  48. data/client/src/screens/exceptions/Index.tsx +155 -0
  49. data/client/src/screens/exceptions/Show.tsx +480 -0
  50. data/client/src/screens/gates/Index.tsx +15 -0
  51. data/client/src/screens/jobs/Index.tsx +153 -0
  52. data/client/src/screens/jobs/Show.tsx +529 -0
  53. data/client/src/screens/logs/Index.tsx +15 -0
  54. data/client/src/screens/mail/Index.tsx +15 -0
  55. data/client/src/screens/models/Index.tsx +15 -0
  56. data/client/src/screens/notifications/Index.tsx +15 -0
  57. data/client/src/screens/queries/Index.tsx +159 -0
  58. data/client/src/screens/queries/Show.tsx +346 -0
  59. data/client/src/screens/redis/Index.tsx +15 -0
  60. data/client/src/screens/requests/Index.tsx +123 -0
  61. data/client/src/screens/requests/Show.tsx +395 -0
  62. data/client/src/screens/schedule/Index.tsx +15 -0
  63. data/client/src/screens/views/Index.tsx +141 -0
  64. data/client/src/screens/views/Show.tsx +337 -0
  65. data/client/tailwind.config.js +22 -0
  66. data/client/tsconfig.json +25 -0
  67. data/client/tsconfig.node.json +10 -0
  68. data/client/vite.config.ts +37 -0
  69. data/config/routes.rb +17 -0
  70. data/db/migrate/20260131023242_create_railscope_entries.rb +41 -0
  71. data/lib/generators/railscope/install_generator.rb +33 -0
  72. data/lib/generators/railscope/templates/initializer.rb +34 -0
  73. data/lib/railscope/context.rb +91 -0
  74. data/lib/railscope/engine.rb +85 -0
  75. data/lib/railscope/entry_data.rb +112 -0
  76. data/lib/railscope/filter.rb +113 -0
  77. data/lib/railscope/middleware.rb +162 -0
  78. data/lib/railscope/storage/base.rb +90 -0
  79. data/lib/railscope/storage/database.rb +83 -0
  80. data/lib/railscope/storage/redis_storage.rb +314 -0
  81. data/lib/railscope/subscribers/base_subscriber.rb +52 -0
  82. data/lib/railscope/subscribers/command_subscriber.rb +237 -0
  83. data/lib/railscope/subscribers/exception_subscriber.rb +113 -0
  84. data/lib/railscope/subscribers/job_subscriber.rb +249 -0
  85. data/lib/railscope/subscribers/query_subscriber.rb +130 -0
  86. data/lib/railscope/subscribers/request_subscriber.rb +121 -0
  87. data/lib/railscope/subscribers/view_subscriber.rb +201 -0
  88. data/lib/railscope/version.rb +5 -0
  89. data/lib/railscope.rb +145 -0
  90. data/lib/tasks/railscope_sample.rake +30 -0
  91. data/public/railscope/assets/app.css +1 -0
  92. data/public/railscope/assets/app.js +70 -0
  93. data/public/railscope/assets/index.html +13 -0
  94. data/sig/railscope.rbs +4 -0
  95. metadata +157 -0
@@ -0,0 +1,395 @@
1
+ import { useState, useEffect } from 'react'
2
+ import { useParams, useNavigate } from 'react-router-dom'
3
+ import { getEntry } from '@/api/entries'
4
+ import { Entry } from '@/lib/types'
5
+ import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'
6
+ import { JsonViewer } from '@/components/ui/JsonViewer'
7
+
8
+ type TabType = 'arguments' | 'options'
9
+
10
+ export default function CommandsShow() {
11
+ const { id } = useParams()
12
+ const navigate = useNavigate()
13
+ const [entry, setEntry] = useState<Entry | null>(null)
14
+ const [batch, setBatch] = useState<Entry[]>([])
15
+ const [loading, setLoading] = useState(true)
16
+ const [currentTab, setCurrentTab] = useState<TabType>('arguments')
17
+
18
+ useEffect(() => {
19
+ loadEntry()
20
+ }, [id])
21
+
22
+ async function loadEntry() {
23
+ if (!id) return
24
+ setLoading(true)
25
+ try {
26
+ const response = await getEntry(id)
27
+ setEntry(response.data)
28
+ setBatch(response.batch || [])
29
+ } catch (error) {
30
+ console.error('Failed to load entry:', error)
31
+ } finally {
32
+ setLoading(false)
33
+ }
34
+ }
35
+
36
+ if (loading) {
37
+ return (
38
+ <div className="p-6">
39
+ <Card>
40
+ <CardContent className="py-12 text-center text-dark-muted">
41
+ Fetching...
42
+ </CardContent>
43
+ </Card>
44
+ </div>
45
+ )
46
+ }
47
+
48
+ if (!entry) {
49
+ return (
50
+ <div className="p-6">
51
+ <Card>
52
+ <CardContent className="py-12 text-center text-dark-muted">
53
+ No entry found.
54
+ </CardContent>
55
+ </Card>
56
+ </div>
57
+ )
58
+ }
59
+
60
+ const payload = entry.payload as Record<string, unknown>
61
+ const exitCode = Number(payload.exit_code ?? 0)
62
+
63
+ // Always include command in arguments like Telescope does
64
+ const rawArgs = (payload.arguments || {}) as Record<string, unknown>
65
+ const args = Object.keys(rawArgs).length > 0
66
+ ? rawArgs
67
+ : { command: payload.command }
68
+
69
+ // Show all options including false/null values (like Telescope does)
70
+ const options = (payload.options || {}) as Record<string, unknown>
71
+
72
+ const formattedTime = new Date(entry.occurred_at).toLocaleString('en-US', {
73
+ year: 'numeric',
74
+ month: 'long',
75
+ day: 'numeric',
76
+ hour: 'numeric',
77
+ minute: '2-digit',
78
+ second: '2-digit',
79
+ hour12: true
80
+ })
81
+ const timeAgo = getTimeAgo(entry.occurred_at)
82
+
83
+ return (
84
+ <div className="p-6 space-y-5">
85
+ {/* Command Details Card */}
86
+ <Card>
87
+ <CardHeader>
88
+ <CardTitle>Command Details</CardTitle>
89
+ </CardHeader>
90
+ <CardContent className="p-0">
91
+ <table className="w-full">
92
+ <tbody>
93
+ <tr className="border-t border-dark-border">
94
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap w-28">Time</td>
95
+ <td className="px-4 py-3">{formattedTime} ({timeAgo})</td>
96
+ </tr>
97
+ {payload.hostname ? (
98
+ <tr className="border-t border-dark-border">
99
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">Hostname</td>
100
+ <td className="px-4 py-3">{String(payload.hostname)}</td>
101
+ </tr>
102
+ ) : null}
103
+ <tr className="border-t border-dark-border">
104
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">Command</td>
105
+ <td className="px-4 py-3">
106
+ <code className="text-blue-400">{String(payload.command)}</code>
107
+ </td>
108
+ </tr>
109
+ <tr className="border-t border-dark-border">
110
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">Exit Code</td>
111
+ <td className="px-4 py-3">
112
+ <span className={exitCode === 0 ? 'text-green-400' : 'text-red-400'}>
113
+ {exitCode}
114
+ </span>
115
+ </td>
116
+ </tr>
117
+ {payload.duration ? (
118
+ <tr className="border-t border-dark-border">
119
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">Duration</td>
120
+ <td className="px-4 py-3">{String(payload.duration)}ms</td>
121
+ </tr>
122
+ ) : null}
123
+ {payload.description ? (
124
+ <tr className="border-t border-dark-border">
125
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">Description</td>
126
+ <td className="px-4 py-3 text-dark-muted">{String(payload.description)}</td>
127
+ </tr>
128
+ ) : null}
129
+ </tbody>
130
+ </table>
131
+ </CardContent>
132
+ </Card>
133
+
134
+ {/* Arguments / Options Card */}
135
+ <Card>
136
+ <div className="flex border-b border-dark-border">
137
+ <button
138
+ onClick={() => setCurrentTab('arguments')}
139
+ className={`px-4 py-2.5 text-sm font-medium ${
140
+ currentTab === 'arguments'
141
+ ? 'bg-blue-500 text-white'
142
+ : 'text-dark-muted hover:text-dark-text'
143
+ }`}
144
+ >
145
+ Arguments ({Object.keys(args).length})
146
+ </button>
147
+ <button
148
+ onClick={() => setCurrentTab('options')}
149
+ className={`px-4 py-2.5 text-sm font-medium ${
150
+ currentTab === 'options'
151
+ ? 'bg-blue-500 text-white'
152
+ : 'text-dark-muted hover:text-dark-text'
153
+ }`}
154
+ >
155
+ Options ({Object.keys(options).length})
156
+ </button>
157
+ </div>
158
+ <div className="bg-[#1a1a2e] p-4">
159
+ {currentTab === 'arguments' && Object.keys(args).length === 0 ? (
160
+ <div className="text-dark-muted text-sm">No arguments provided</div>
161
+ ) : currentTab === 'options' && Object.keys(options).length === 0 ? (
162
+ <div className="text-dark-muted text-sm">No options provided</div>
163
+ ) : (
164
+ <JsonViewer
165
+ data={currentTab === 'arguments' ? args : options}
166
+ className="border-0 bg-transparent"
167
+ />
168
+ )}
169
+ </div>
170
+ </Card>
171
+
172
+ {/* Related Entries (batch) - exceptions will appear here */}
173
+ {batch.length > 0 && <RelatedEntries entries={batch} navigate={navigate} />}
174
+ </div>
175
+ )
176
+ }
177
+
178
+ function getTimeAgo(date: string): string {
179
+ const seconds = Math.floor((new Date().getTime() - new Date(date).getTime()) / 1000)
180
+ if (seconds < 60) return `${seconds}s ago`
181
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
182
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`
183
+ return `${Math.floor(seconds / 86400)}d ago`
184
+ }
185
+
186
+ interface RelatedEntriesProps {
187
+ entries: Entry[]
188
+ navigate: ReturnType<typeof useNavigate>
189
+ }
190
+
191
+ function RelatedEntries({ entries, navigate }: RelatedEntriesProps) {
192
+ const [currentTab, setCurrentTab] = useState<string>('')
193
+
194
+ const groupedEntries = entries.reduce((acc, entry) => {
195
+ const type = entry.entry_type
196
+ if (!acc[type]) acc[type] = []
197
+ acc[type].push(entry)
198
+ return acc
199
+ }, {} as Record<string, Entry[]>)
200
+
201
+ const tabs = Object.entries(groupedEntries).map(([type, items]) => ({
202
+ type,
203
+ label: getTypeLabel(type),
204
+ count: items.length
205
+ }))
206
+
207
+ useEffect(() => {
208
+ if (tabs.length > 0 && !currentTab) {
209
+ setCurrentTab(tabs[0].type)
210
+ }
211
+ }, [tabs, currentTab])
212
+
213
+ if (tabs.length === 0) return null
214
+
215
+ const currentEntries = groupedEntries[currentTab] || []
216
+
217
+ return (
218
+ <Card>
219
+ <div className="flex flex-wrap border-b border-dark-border">
220
+ {tabs.map((tab) => (
221
+ <button
222
+ key={tab.type}
223
+ onClick={() => setCurrentTab(tab.type)}
224
+ className={`px-4 py-2.5 text-sm font-medium ${
225
+ currentTab === tab.type
226
+ ? 'bg-blue-500 text-white'
227
+ : 'text-dark-muted hover:text-dark-text'
228
+ }`}
229
+ >
230
+ {tab.label} ({tab.count})
231
+ </button>
232
+ ))}
233
+ </div>
234
+ <table className="w-full">
235
+ <thead>
236
+ <tr className="border-b border-dark-border">
237
+ {currentTab === 'query' && (
238
+ <>
239
+ <th className="px-4 py-3 text-left text-sm font-medium text-dark-muted">Query</th>
240
+ <th className="px-4 py-3 text-right text-sm font-medium text-dark-muted">Duration</th>
241
+ <th className="w-12"></th>
242
+ </>
243
+ )}
244
+ {currentTab === 'exception' && (
245
+ <>
246
+ <th className="px-4 py-3 text-left text-sm font-medium text-dark-muted">Message</th>
247
+ <th className="w-12"></th>
248
+ </>
249
+ )}
250
+ {(currentTab === 'job_enqueue' || currentTab === 'job_perform') && (
251
+ <>
252
+ <th className="px-4 py-3 text-left text-sm font-medium text-dark-muted">Job</th>
253
+ <th className="px-4 py-3 text-left text-sm font-medium text-dark-muted">Status</th>
254
+ <th className="w-12"></th>
255
+ </>
256
+ )}
257
+ {!['query', 'exception', 'job_enqueue', 'job_perform'].includes(currentTab) && (
258
+ <>
259
+ <th className="px-4 py-3 text-left text-sm font-medium text-dark-muted">Entry</th>
260
+ <th className="w-12"></th>
261
+ </>
262
+ )}
263
+ </tr>
264
+ </thead>
265
+ <tbody>
266
+ {currentEntries.map((entry) => {
267
+ const payload = entry.payload as Record<string, unknown>
268
+ const path = getEntryPath(entry)
269
+
270
+ return (
271
+ <tr
272
+ key={entry.id}
273
+ onClick={() => navigate(path)}
274
+ className="border-b border-dark-border hover:bg-white/[0.02] cursor-pointer"
275
+ >
276
+ {currentTab === 'query' && (
277
+ <>
278
+ <td className="px-4 py-3" title={String(payload.sql)}>
279
+ <code className="text-xs text-dark-muted">
280
+ {String(payload.sql || '').substring(0, 110)}
281
+ </code>
282
+ </td>
283
+ <td className="px-4 py-3 text-right whitespace-nowrap">
284
+ {entry.tags.includes('slow') ? (
285
+ <span className="px-2 py-0.5 bg-red-500/20 text-red-400 text-xs rounded">
286
+ {String(payload.duration)}ms
287
+ </span>
288
+ ) : (
289
+ <span className="text-dark-muted">{String(payload.duration)}ms</span>
290
+ )}
291
+ </td>
292
+ <td className="px-4 py-3">
293
+ <ArrowIcon />
294
+ </td>
295
+ </>
296
+ )}
297
+ {currentTab === 'exception' && (
298
+ <>
299
+ <td className="px-4 py-3" title={String(payload.class)}>
300
+ <span>{String(payload.class)}</span>
301
+ <br />
302
+ <small className="text-dark-muted">
303
+ {String(payload.message || '').substring(0, 200)}
304
+ </small>
305
+ </td>
306
+ <td className="px-4 py-3">
307
+ <ArrowIcon />
308
+ </td>
309
+ </>
310
+ )}
311
+ {(currentTab === 'job_enqueue' || currentTab === 'job_perform') && (
312
+ <>
313
+ <td className="px-4 py-3">
314
+ <span title={String(payload.job_class)}>
315
+ {String(payload.job_class)}
316
+ </span>
317
+ <br />
318
+ <small className="text-dark-muted">
319
+ Queue: {String(payload.queue_name)}
320
+ </small>
321
+ </td>
322
+ <td className="px-4 py-3">
323
+ <span className={`px-2 py-0.5 text-xs rounded ${
324
+ entry.tags.includes('failed')
325
+ ? 'bg-red-500/20 text-red-400'
326
+ : 'bg-green-500/20 text-green-400'
327
+ }`}>
328
+ {entry.tags.includes('failed') ? 'failed' : 'processed'}
329
+ </span>
330
+ </td>
331
+ <td className="px-4 py-3">
332
+ <ArrowIcon />
333
+ </td>
334
+ </>
335
+ )}
336
+ {!['query', 'exception', 'job_enqueue', 'job_perform'].includes(currentTab) && (
337
+ <>
338
+ <td className="px-4 py-3 text-dark-muted">{entry.entry_type}</td>
339
+ <td className="px-4 py-3">
340
+ <ArrowIcon />
341
+ </td>
342
+ </>
343
+ )}
344
+ </tr>
345
+ )
346
+ })}
347
+ </tbody>
348
+ </table>
349
+ </Card>
350
+ )
351
+ }
352
+
353
+ function ArrowIcon() {
354
+ return (
355
+ <svg className="w-5 h-5 text-dark-muted" viewBox="0 0 20 20" fill="currentColor">
356
+ <path
357
+ fillRule="evenodd"
358
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zM6.75 9.25a.75.75 0 000 1.5h4.59l-2.1 1.95a.75.75 0 001.02 1.1l3.5-3.25a.75.75 0 000-1.1l-3.5-3.25a.75.75 0 10-1.02 1.1l2.1 1.95H6.75z"
359
+ clipRule="evenodd"
360
+ />
361
+ </svg>
362
+ )
363
+ }
364
+
365
+ function getEntryPath(entry: Entry): string {
366
+ switch (entry.entry_type) {
367
+ case 'query': return `/queries/${entry.id}`
368
+ case 'exception': return `/exceptions/${entry.id}`
369
+ case 'job_enqueue':
370
+ case 'job_perform': return `/jobs/${entry.id}`
371
+ case 'request': return `/requests/${entry.id}`
372
+ default: return `/${entry.entry_type}s/${entry.id}`
373
+ }
374
+ }
375
+
376
+ function getTypeLabel(type: string): string {
377
+ const labels: Record<string, string> = {
378
+ query: 'Queries',
379
+ exception: 'Exceptions',
380
+ job_enqueue: 'Jobs',
381
+ job_perform: 'Jobs',
382
+ request: 'Requests',
383
+ log: 'Logs',
384
+ cache: 'Cache',
385
+ event: 'Events',
386
+ mail: 'Mail',
387
+ notification: 'Notifications',
388
+ model: 'Models',
389
+ gate: 'Gates',
390
+ redis: 'Redis',
391
+ view: 'Views',
392
+ client_request: 'HTTP Client'
393
+ }
394
+ return labels[type] || type
395
+ }
@@ -0,0 +1,15 @@
1
+ import PlaceholderPage from '@/components/PlaceholderPage'
2
+
3
+ export default function DumpsIndex() {
4
+ return (
5
+ <PlaceholderPage
6
+ title="Dumps"
7
+ description="Debug dumps from puts, pp, and Rails.logger.debug calls"
8
+ icon={
9
+ <svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
10
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
11
+ </svg>
12
+ }
13
+ />
14
+ )
15
+ }
@@ -0,0 +1,15 @@
1
+ import PlaceholderPage from '@/components/PlaceholderPage'
2
+
3
+ export default function EventsIndex() {
4
+ return (
5
+ <PlaceholderPage
6
+ title="Events"
7
+ description="ActiveSupport::Notifications events in your application"
8
+ icon={
9
+ <svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
10
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13 10V3L4 14h7v7l9-11h-7z" />
11
+ </svg>
12
+ }
13
+ />
14
+ )
15
+ }
@@ -0,0 +1,155 @@
1
+ import { useState, useEffect } from 'react'
2
+ import { useNavigate, useSearchParams } from 'react-router-dom'
3
+ import { getEntries, getFamilyEntries } from '@/api/entries'
4
+ import { Entry } from '@/lib/types'
5
+ import { timeAgo, truncate } from '@/lib/utils'
6
+ import { Card } from '@/components/ui/Card'
7
+ import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/Table'
8
+ import { Pagination } from '@/components/ui/Pagination'
9
+ import { SearchInput } from '@/components/ui/SearchInput'
10
+
11
+ export default function ExceptionsIndex() {
12
+ const navigate = useNavigate()
13
+ const [searchParams, setSearchParams] = useSearchParams()
14
+ const [entries, setEntries] = useState<Entry[]>([])
15
+ const [loading, setLoading] = useState(true)
16
+ const [page, setPage] = useState(1)
17
+ const [totalPages, setTotalPages] = useState(1)
18
+
19
+ const tagFilter = searchParams.get('tag') || ''
20
+ const familyHashFilter = searchParams.get('family_hash') || ''
21
+
22
+ useEffect(() => {
23
+ loadEntries()
24
+ }, [page, tagFilter, familyHashFilter])
25
+
26
+ async function loadEntries() {
27
+ setLoading(true)
28
+ try {
29
+ if (familyHashFilter) {
30
+ // Use family endpoint for "View Other Occurrences"
31
+ const response = await getFamilyEntries(familyHashFilter, page)
32
+ setEntries(response.data)
33
+ setTotalPages(response.meta.total_pages)
34
+ } else {
35
+ const response = await getEntries({
36
+ type: 'exception',
37
+ tag: tagFilter || undefined,
38
+ page
39
+ })
40
+ setEntries(response.data)
41
+ setTotalPages(response.meta.total_pages)
42
+ }
43
+ } catch (error) {
44
+ console.error('Failed to load entries:', error)
45
+ } finally {
46
+ setLoading(false)
47
+ }
48
+ }
49
+
50
+ function handleSearch(value: string) {
51
+ setPage(1)
52
+ if (value) {
53
+ setSearchParams({ tag: value })
54
+ } else {
55
+ setSearchParams({})
56
+ }
57
+ }
58
+
59
+ function handlePageChange(newPage: number) {
60
+ setPage(newPage)
61
+ }
62
+
63
+ function clearFamilyFilter() {
64
+ setSearchParams({})
65
+ }
66
+
67
+ return (
68
+ <div className="p-6">
69
+ <div className="mb-6">
70
+ <h1 className="text-2xl font-semibold text-white">Exceptions</h1>
71
+ <p className="text-dark-muted text-sm mt-1">
72
+ {familyHashFilter
73
+ ? 'Viewing all occurrences of this exception'
74
+ : 'Unhandled exceptions in your application'}
75
+ </p>
76
+ </div>
77
+
78
+ {familyHashFilter ? (
79
+ <div className="mb-4">
80
+ <button
81
+ onClick={clearFamilyFilter}
82
+ className="text-blue-400 hover:text-blue-300 text-sm"
83
+ >
84
+ ← Back to all exceptions
85
+ </button>
86
+ </div>
87
+ ) : (
88
+ <div className="mb-4">
89
+ <SearchInput
90
+ placeholder="Search by tag..."
91
+ value={tagFilter}
92
+ onChange={handleSearch}
93
+ className="max-w-sm"
94
+ />
95
+ </div>
96
+ )}
97
+
98
+ <Card>
99
+ <Table>
100
+ <TableHeader>
101
+ <TableRow>
102
+ <TableHead>Exception</TableHead>
103
+ <TableHead>Location</TableHead>
104
+ <TableHead>Happened</TableHead>
105
+ </TableRow>
106
+ </TableHeader>
107
+ <TableBody>
108
+ {loading ? (
109
+ <TableRow>
110
+ <TableCell className="text-center text-dark-muted py-8" colSpan={3}>
111
+ Loading...
112
+ </TableCell>
113
+ </TableRow>
114
+ ) : entries.length === 0 ? (
115
+ <TableRow>
116
+ <TableCell className="text-center text-dark-muted py-8" colSpan={3}>
117
+ {familyHashFilter
118
+ ? 'No other occurrences found.'
119
+ : tagFilter
120
+ ? `No exceptions found with tag "${tagFilter}".`
121
+ : "No exceptions recorded. That's a good thing!"}
122
+ </TableCell>
123
+ </TableRow>
124
+ ) : (
125
+ entries.map((entry) => {
126
+ const payload = entry.payload as Record<string, unknown>
127
+ // Show file:line as location (like Telescope)
128
+ const fileLocation = payload.file && payload.line
129
+ ? `${String(payload.file)}:${payload.line}`
130
+ : null
131
+ return (
132
+ <TableRow key={entry.id} onClick={() => navigate(`/exceptions/${entry.id}`)}>
133
+ <TableCell>
134
+ <div className="text-red-400 font-medium">{String(payload.class)}</div>
135
+ <div className="text-dark-muted text-sm">
136
+ {truncate(String(payload.message), 60)}
137
+ </div>
138
+ </TableCell>
139
+ <TableCell className="font-mono text-xs text-dark-muted">
140
+ {fileLocation ? truncate(fileLocation, 50) : '-'}
141
+ </TableCell>
142
+ <TableCell className="text-dark-muted" title={entry.occurred_at}>
143
+ {timeAgo(entry.occurred_at)}
144
+ </TableCell>
145
+ </TableRow>
146
+ )
147
+ })
148
+ )}
149
+ </TableBody>
150
+ </Table>
151
+ <Pagination currentPage={page} totalPages={totalPages} onPageChange={handlePageChange} />
152
+ </Card>
153
+ </div>
154
+ )
155
+ }