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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +227 -0
- data/Rakefile +12 -0
- data/app/assets/stylesheets/railscope/application.css +504 -0
- data/app/controllers/railscope/api/entries_controller.rb +103 -0
- data/app/controllers/railscope/application_controller.rb +12 -0
- data/app/controllers/railscope/dashboard_controller.rb +33 -0
- data/app/controllers/railscope/entries_controller.rb +29 -0
- data/app/helpers/railscope/dashboard_helper.rb +157 -0
- data/app/jobs/railscope/application_job.rb +6 -0
- data/app/jobs/railscope/purge_job.rb +15 -0
- data/app/models/railscope/application_record.rb +12 -0
- data/app/models/railscope/entry.rb +51 -0
- data/app/views/layouts/railscope/application.html.erb +14 -0
- data/app/views/railscope/application/index.html.erb +1 -0
- data/app/views/railscope/dashboard/index.html.erb +70 -0
- data/app/views/railscope/entries/show.html.erb +93 -0
- data/client/.gitignore +1 -0
- data/client/index.html +12 -0
- data/client/package-lock.json +2735 -0
- data/client/package.json +28 -0
- data/client/postcss.config.js +6 -0
- data/client/src/App.tsx +60 -0
- data/client/src/api/client.ts +25 -0
- data/client/src/api/entries.ts +36 -0
- data/client/src/components/Layout.tsx +17 -0
- data/client/src/components/PlaceholderPage.tsx +32 -0
- data/client/src/components/Sidebar.tsx +198 -0
- data/client/src/components/ui/Badge.tsx +67 -0
- data/client/src/components/ui/Card.tsx +38 -0
- data/client/src/components/ui/JsonViewer.tsx +80 -0
- data/client/src/components/ui/Pagination.tsx +45 -0
- data/client/src/components/ui/SearchInput.tsx +70 -0
- data/client/src/components/ui/Table.tsx +68 -0
- data/client/src/index.css +28 -0
- data/client/src/lib/hooks.ts +37 -0
- data/client/src/lib/types.ts +61 -0
- data/client/src/lib/utils.ts +38 -0
- data/client/src/main.tsx +13 -0
- data/client/src/screens/cache/Index.tsx +15 -0
- data/client/src/screens/client-requests/Index.tsx +15 -0
- data/client/src/screens/commands/Index.tsx +133 -0
- data/client/src/screens/commands/Show.tsx +395 -0
- data/client/src/screens/dumps/Index.tsx +15 -0
- data/client/src/screens/events/Index.tsx +15 -0
- data/client/src/screens/exceptions/Index.tsx +155 -0
- data/client/src/screens/exceptions/Show.tsx +480 -0
- data/client/src/screens/gates/Index.tsx +15 -0
- data/client/src/screens/jobs/Index.tsx +153 -0
- data/client/src/screens/jobs/Show.tsx +529 -0
- data/client/src/screens/logs/Index.tsx +15 -0
- data/client/src/screens/mail/Index.tsx +15 -0
- data/client/src/screens/models/Index.tsx +15 -0
- data/client/src/screens/notifications/Index.tsx +15 -0
- data/client/src/screens/queries/Index.tsx +159 -0
- data/client/src/screens/queries/Show.tsx +346 -0
- data/client/src/screens/redis/Index.tsx +15 -0
- data/client/src/screens/requests/Index.tsx +123 -0
- data/client/src/screens/requests/Show.tsx +395 -0
- data/client/src/screens/schedule/Index.tsx +15 -0
- data/client/src/screens/views/Index.tsx +141 -0
- data/client/src/screens/views/Show.tsx +337 -0
- data/client/tailwind.config.js +22 -0
- data/client/tsconfig.json +25 -0
- data/client/tsconfig.node.json +10 -0
- data/client/vite.config.ts +37 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20260131023242_create_railscope_entries.rb +41 -0
- data/lib/generators/railscope/install_generator.rb +33 -0
- data/lib/generators/railscope/templates/initializer.rb +34 -0
- data/lib/railscope/context.rb +91 -0
- data/lib/railscope/engine.rb +85 -0
- data/lib/railscope/entry_data.rb +112 -0
- data/lib/railscope/filter.rb +113 -0
- data/lib/railscope/middleware.rb +162 -0
- data/lib/railscope/storage/base.rb +90 -0
- data/lib/railscope/storage/database.rb +83 -0
- data/lib/railscope/storage/redis_storage.rb +314 -0
- data/lib/railscope/subscribers/base_subscriber.rb +52 -0
- data/lib/railscope/subscribers/command_subscriber.rb +237 -0
- data/lib/railscope/subscribers/exception_subscriber.rb +113 -0
- data/lib/railscope/subscribers/job_subscriber.rb +249 -0
- data/lib/railscope/subscribers/query_subscriber.rb +130 -0
- data/lib/railscope/subscribers/request_subscriber.rb +121 -0
- data/lib/railscope/subscribers/view_subscriber.rb +201 -0
- data/lib/railscope/version.rb +5 -0
- data/lib/railscope.rb +145 -0
- data/lib/tasks/railscope_sample.rake +30 -0
- data/public/railscope/assets/app.css +1 -0
- data/public/railscope/assets/app.js +70 -0
- data/public/railscope/assets/index.html +13 -0
- data/sig/railscope.rbs +4 -0
- metadata +157 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import { useParams, useNavigate, Link } 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 { Badge } from '@/components/ui/Badge'
|
|
7
|
+
import { JsonViewer } from '@/components/ui/JsonViewer'
|
|
8
|
+
|
|
9
|
+
type TabType = 'data' | 'exception' | 'location' | 'stacktrace'
|
|
10
|
+
|
|
11
|
+
export default function JobsShow() {
|
|
12
|
+
const { id } = useParams()
|
|
13
|
+
const navigate = useNavigate()
|
|
14
|
+
const [entry, setEntry] = useState<Entry | null>(null)
|
|
15
|
+
const [batch, setBatch] = useState<Entry[]>([])
|
|
16
|
+
const [loading, setLoading] = useState(true)
|
|
17
|
+
const [currentTab, setCurrentTab] = useState<TabType>('data')
|
|
18
|
+
const [showAllTrace, setShowAllTrace] = useState(false)
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
loadEntry()
|
|
22
|
+
}, [id])
|
|
23
|
+
|
|
24
|
+
async function loadEntry() {
|
|
25
|
+
if (!id) return
|
|
26
|
+
setLoading(true)
|
|
27
|
+
try {
|
|
28
|
+
const response = await getEntry(id)
|
|
29
|
+
setEntry(response.data)
|
|
30
|
+
setBatch(response.batch || [])
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.error('Failed to load entry:', error)
|
|
33
|
+
} finally {
|
|
34
|
+
setLoading(false)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (loading) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="p-6">
|
|
41
|
+
<Card>
|
|
42
|
+
<CardContent className="py-12 text-center text-dark-muted">
|
|
43
|
+
Loading...
|
|
44
|
+
</CardContent>
|
|
45
|
+
</Card>
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!entry) {
|
|
51
|
+
return (
|
|
52
|
+
<div className="p-6">
|
|
53
|
+
<Card>
|
|
54
|
+
<CardContent className="py-12 text-center text-dark-muted">
|
|
55
|
+
Job not found.
|
|
56
|
+
</CardContent>
|
|
57
|
+
</Card>
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const payload = entry.payload as Record<string, unknown>
|
|
63
|
+
const isFailed = entry.tags.includes('failed')
|
|
64
|
+
const isPerform = entry.entry_type === 'job_perform'
|
|
65
|
+
const exception = payload.exception as Record<string, unknown> | undefined
|
|
66
|
+
|
|
67
|
+
// Determine status
|
|
68
|
+
const status = isFailed ? 'failed' : isPerform ? 'processed' : 'pending'
|
|
69
|
+
const statusColors: Record<string, string> = {
|
|
70
|
+
pending: 'bg-yellow-500/20 text-yellow-400',
|
|
71
|
+
processed: 'bg-green-500/20 text-green-400',
|
|
72
|
+
failed: 'bg-red-500/20 text-red-400'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const formattedTime = new Date(entry.occurred_at).toLocaleString('en-US', {
|
|
76
|
+
year: 'numeric',
|
|
77
|
+
month: 'long',
|
|
78
|
+
day: 'numeric',
|
|
79
|
+
hour: 'numeric',
|
|
80
|
+
minute: '2-digit',
|
|
81
|
+
second: '2-digit',
|
|
82
|
+
hour12: true
|
|
83
|
+
})
|
|
84
|
+
const timeAgo = getTimeAgo(entry.occurred_at)
|
|
85
|
+
|
|
86
|
+
// Exception data
|
|
87
|
+
const exceptionBacktrace = (exception?.backtrace as string[] | undefined) || []
|
|
88
|
+
const exceptionLinePreview = exception?.line_preview as Record<string, string> | undefined
|
|
89
|
+
const exceptionFile = exception?.file ? String(exception.file) : ''
|
|
90
|
+
const exceptionLine = exception?.line ? Number(exception.line) : 0
|
|
91
|
+
const displayedTrace = showAllTrace ? exceptionBacktrace : exceptionBacktrace.slice(0, 5)
|
|
92
|
+
|
|
93
|
+
// Find related request/command in batch
|
|
94
|
+
const relatedRequest = batch.find(e => e.entry_type === 'request')
|
|
95
|
+
const relatedCommand = batch.find(e => e.entry_type === 'command')
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className="p-6 space-y-5">
|
|
99
|
+
{/* Job Details Card */}
|
|
100
|
+
<Card>
|
|
101
|
+
<CardHeader>
|
|
102
|
+
<CardTitle>Job Details</CardTitle>
|
|
103
|
+
</CardHeader>
|
|
104
|
+
<CardContent className="p-0">
|
|
105
|
+
<table className="w-full">
|
|
106
|
+
<tbody>
|
|
107
|
+
<tr className="border-t border-dark-border">
|
|
108
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap w-32">Time</td>
|
|
109
|
+
<td className="px-4 py-3">{formattedTime} ({timeAgo})</td>
|
|
110
|
+
</tr>
|
|
111
|
+
{payload.hostname ? (
|
|
112
|
+
<tr className="border-t border-dark-border">
|
|
113
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Hostname</td>
|
|
114
|
+
<td className="px-4 py-3">{String(payload.hostname)}</td>
|
|
115
|
+
</tr>
|
|
116
|
+
) : null}
|
|
117
|
+
<tr className="border-t border-dark-border">
|
|
118
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Status</td>
|
|
119
|
+
<td className="px-4 py-3">
|
|
120
|
+
<span className={`px-2 py-0.5 text-sm rounded ${statusColors[status]}`}>
|
|
121
|
+
{status}
|
|
122
|
+
</span>
|
|
123
|
+
</td>
|
|
124
|
+
</tr>
|
|
125
|
+
<tr className="border-t border-dark-border">
|
|
126
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Job</td>
|
|
127
|
+
<td className="px-4 py-3">
|
|
128
|
+
<span className={isFailed ? 'text-red-400' : 'text-green-400'}>
|
|
129
|
+
{String(payload.job_class)}
|
|
130
|
+
</span>
|
|
131
|
+
</td>
|
|
132
|
+
</tr>
|
|
133
|
+
{payload.connection ? (
|
|
134
|
+
<tr className="border-t border-dark-border">
|
|
135
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Connection</td>
|
|
136
|
+
<td className="px-4 py-3">{String(payload.connection)}</td>
|
|
137
|
+
</tr>
|
|
138
|
+
) : null}
|
|
139
|
+
<tr className="border-t border-dark-border">
|
|
140
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Queue</td>
|
|
141
|
+
<td className="px-4 py-3">{String(payload.queue_name)}</td>
|
|
142
|
+
</tr>
|
|
143
|
+
{payload.executions ? (
|
|
144
|
+
<tr className="border-t border-dark-border">
|
|
145
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Attempts</td>
|
|
146
|
+
<td className="px-4 py-3">{String(payload.executions)}</td>
|
|
147
|
+
</tr>
|
|
148
|
+
) : null}
|
|
149
|
+
{payload.duration ? (
|
|
150
|
+
<tr className="border-t border-dark-border">
|
|
151
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Duration</td>
|
|
152
|
+
<td className="px-4 py-3">{String(payload.duration)}ms</td>
|
|
153
|
+
</tr>
|
|
154
|
+
) : null}
|
|
155
|
+
{payload.scheduled_at ? (
|
|
156
|
+
<tr className="border-t border-dark-border">
|
|
157
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Scheduled At</td>
|
|
158
|
+
<td className="px-4 py-3">{String(payload.scheduled_at)}</td>
|
|
159
|
+
</tr>
|
|
160
|
+
) : null}
|
|
161
|
+
{relatedRequest ? (
|
|
162
|
+
<tr className="border-t border-dark-border">
|
|
163
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Request</td>
|
|
164
|
+
<td className="px-4 py-3">
|
|
165
|
+
<Link to={`/requests/${relatedRequest.id}`} className="text-blue-400 hover:text-blue-300">
|
|
166
|
+
View Request
|
|
167
|
+
</Link>
|
|
168
|
+
</td>
|
|
169
|
+
</tr>
|
|
170
|
+
) : null}
|
|
171
|
+
{relatedCommand ? (
|
|
172
|
+
<tr className="border-t border-dark-border">
|
|
173
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Command</td>
|
|
174
|
+
<td className="px-4 py-3">
|
|
175
|
+
<Link to={`/commands/${relatedCommand.id}`} className="text-blue-400 hover:text-blue-300">
|
|
176
|
+
View Command
|
|
177
|
+
</Link>
|
|
178
|
+
</td>
|
|
179
|
+
</tr>
|
|
180
|
+
) : null}
|
|
181
|
+
{entry.tags.length > 0 ? (
|
|
182
|
+
<tr className="border-t border-dark-border">
|
|
183
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Tags</td>
|
|
184
|
+
<td className="px-4 py-3">
|
|
185
|
+
<div className="flex flex-wrap gap-1">
|
|
186
|
+
{entry.tags.map((tag) => (
|
|
187
|
+
<Link key={tag} to={`/jobs?tag=${tag}`}>
|
|
188
|
+
<Badge variant={tag === 'failed' ? 'error' : 'info'}>{tag}</Badge>
|
|
189
|
+
</Link>
|
|
190
|
+
))}
|
|
191
|
+
</div>
|
|
192
|
+
</td>
|
|
193
|
+
</tr>
|
|
194
|
+
) : null}
|
|
195
|
+
</tbody>
|
|
196
|
+
</table>
|
|
197
|
+
</CardContent>
|
|
198
|
+
</Card>
|
|
199
|
+
|
|
200
|
+
{/* Data / Exception Tabs Card */}
|
|
201
|
+
<Card>
|
|
202
|
+
<div className="flex border-b border-dark-border">
|
|
203
|
+
<button
|
|
204
|
+
onClick={() => setCurrentTab('data')}
|
|
205
|
+
className={`px-4 py-2.5 text-sm font-medium ${
|
|
206
|
+
currentTab === 'data'
|
|
207
|
+
? 'bg-blue-500 text-white'
|
|
208
|
+
: 'text-dark-muted hover:text-dark-text'
|
|
209
|
+
}`}
|
|
210
|
+
>
|
|
211
|
+
Data
|
|
212
|
+
</button>
|
|
213
|
+
{exception ? (
|
|
214
|
+
<>
|
|
215
|
+
<button
|
|
216
|
+
onClick={() => setCurrentTab('exception')}
|
|
217
|
+
className={`px-4 py-2.5 text-sm font-medium ${
|
|
218
|
+
currentTab === 'exception'
|
|
219
|
+
? 'bg-red-500 text-white'
|
|
220
|
+
: 'text-dark-muted hover:text-dark-text'
|
|
221
|
+
}`}
|
|
222
|
+
>
|
|
223
|
+
Exception Message
|
|
224
|
+
</button>
|
|
225
|
+
{exceptionLinePreview ? (
|
|
226
|
+
<button
|
|
227
|
+
onClick={() => setCurrentTab('location')}
|
|
228
|
+
className={`px-4 py-2.5 text-sm font-medium ${
|
|
229
|
+
currentTab === 'location'
|
|
230
|
+
? 'bg-red-500 text-white'
|
|
231
|
+
: 'text-dark-muted hover:text-dark-text'
|
|
232
|
+
}`}
|
|
233
|
+
>
|
|
234
|
+
Exception Location
|
|
235
|
+
</button>
|
|
236
|
+
) : null}
|
|
237
|
+
<button
|
|
238
|
+
onClick={() => setCurrentTab('stacktrace')}
|
|
239
|
+
className={`px-4 py-2.5 text-sm font-medium ${
|
|
240
|
+
currentTab === 'stacktrace'
|
|
241
|
+
? 'bg-red-500 text-white'
|
|
242
|
+
: 'text-dark-muted hover:text-dark-text'
|
|
243
|
+
}`}
|
|
244
|
+
>
|
|
245
|
+
Stacktrace
|
|
246
|
+
</button>
|
|
247
|
+
</>
|
|
248
|
+
) : null}
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
{currentTab === 'data' && (
|
|
252
|
+
<div className="bg-[#1a1a2e]">
|
|
253
|
+
{payload.arguments && (Array.isArray(payload.arguments) ? (payload.arguments as unknown[]).length > 0 : Object.keys(payload.arguments as object).length > 0) ? (
|
|
254
|
+
<JsonViewer
|
|
255
|
+
data={payload.arguments}
|
|
256
|
+
className="border-0 bg-transparent"
|
|
257
|
+
/>
|
|
258
|
+
) : (
|
|
259
|
+
<div className="p-4 text-dark-muted text-sm">No arguments</div>
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
)}
|
|
263
|
+
|
|
264
|
+
{currentTab === 'exception' && exception ? (
|
|
265
|
+
<div className="bg-[#1a1a2e] p-4">
|
|
266
|
+
<div className="text-red-400 font-medium mb-2">{String(exception.class)}</div>
|
|
267
|
+
<pre className="text-dark-muted whitespace-pre-wrap break-words font-mono text-sm">
|
|
268
|
+
{String(exception.message)}
|
|
269
|
+
</pre>
|
|
270
|
+
</div>
|
|
271
|
+
) : null}
|
|
272
|
+
|
|
273
|
+
{currentTab === 'location' && exceptionLinePreview ? (
|
|
274
|
+
<div className="bg-[#1a1a2e]">
|
|
275
|
+
<CodePreview
|
|
276
|
+
file={exceptionFile}
|
|
277
|
+
line={exceptionLine}
|
|
278
|
+
linePreview={exceptionLinePreview}
|
|
279
|
+
/>
|
|
280
|
+
</div>
|
|
281
|
+
) : null}
|
|
282
|
+
|
|
283
|
+
{currentTab === 'stacktrace' && exception ? (
|
|
284
|
+
<div className="bg-[#1a1a2e]">
|
|
285
|
+
<table className="w-full">
|
|
286
|
+
<tbody>
|
|
287
|
+
{displayedTrace.map((traceLine, i) => (
|
|
288
|
+
<tr key={i} className="border-b border-dark-border/50">
|
|
289
|
+
<td className="px-4 py-2">
|
|
290
|
+
<code className="text-dark-muted text-xs font-mono">{traceLine}</code>
|
|
291
|
+
</td>
|
|
292
|
+
</tr>
|
|
293
|
+
))}
|
|
294
|
+
{!showAllTrace && exceptionBacktrace.length > 5 ? (
|
|
295
|
+
<tr>
|
|
296
|
+
<td className="px-4 py-2">
|
|
297
|
+
<button
|
|
298
|
+
onClick={() => setShowAllTrace(true)}
|
|
299
|
+
className="text-blue-400 hover:text-blue-300 text-sm"
|
|
300
|
+
>
|
|
301
|
+
Show All ({exceptionBacktrace.length} lines)
|
|
302
|
+
</button>
|
|
303
|
+
</td>
|
|
304
|
+
</tr>
|
|
305
|
+
) : null}
|
|
306
|
+
</tbody>
|
|
307
|
+
</table>
|
|
308
|
+
</div>
|
|
309
|
+
) : null}
|
|
310
|
+
</Card>
|
|
311
|
+
|
|
312
|
+
{/* Related Entries */}
|
|
313
|
+
{batch.length > 0 && <RelatedEntries entries={batch} currentEntryId={entry.id} navigate={navigate} />}
|
|
314
|
+
</div>
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function getTimeAgo(date: string): string {
|
|
319
|
+
const seconds = Math.floor((new Date().getTime() - new Date(date).getTime()) / 1000)
|
|
320
|
+
if (seconds < 60) return `${seconds}s ago`
|
|
321
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
|
|
322
|
+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`
|
|
323
|
+
return `${Math.floor(seconds / 86400)}d ago`
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
interface CodePreviewProps {
|
|
327
|
+
file: string
|
|
328
|
+
line: number
|
|
329
|
+
linePreview?: Record<string, string>
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function CodePreview({ file, line, linePreview }: CodePreviewProps) {
|
|
333
|
+
if (!linePreview || Object.keys(linePreview).length === 0) {
|
|
334
|
+
return (
|
|
335
|
+
<div className="p-4 font-mono text-sm">
|
|
336
|
+
<div className="text-dark-muted mb-2">File:</div>
|
|
337
|
+
<div className="text-blue-400 mb-4 break-all">{file}</div>
|
|
338
|
+
<div className="text-dark-muted mb-2">Line:</div>
|
|
339
|
+
<div className="inline-block px-3 py-1 bg-red-500/20 text-red-400 rounded">
|
|
340
|
+
{line}
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const lineNumbers = Object.keys(linePreview).map(Number).sort((a, b) => a - b)
|
|
347
|
+
|
|
348
|
+
return (
|
|
349
|
+
<div className="overflow-x-auto">
|
|
350
|
+
<pre className="text-sm">
|
|
351
|
+
{lineNumbers.map((lineNum) => {
|
|
352
|
+
const isHighlighted = lineNum === line
|
|
353
|
+
const code = linePreview[lineNum] || ''
|
|
354
|
+
|
|
355
|
+
return (
|
|
356
|
+
<div
|
|
357
|
+
key={lineNum}
|
|
358
|
+
className={`flex ${isHighlighted ? 'bg-red-500/30' : ''}`}
|
|
359
|
+
>
|
|
360
|
+
<span
|
|
361
|
+
className={`px-3 py-0.5 text-right select-none min-w-[3rem] ${
|
|
362
|
+
isHighlighted ? 'text-red-400 font-bold' : 'text-dark-muted'
|
|
363
|
+
}`}
|
|
364
|
+
>
|
|
365
|
+
{lineNum}
|
|
366
|
+
</span>
|
|
367
|
+
<code
|
|
368
|
+
className={`px-3 py-0.5 flex-1 whitespace-pre ${
|
|
369
|
+
isHighlighted ? 'text-white' : 'text-dark-text'
|
|
370
|
+
}`}
|
|
371
|
+
>
|
|
372
|
+
{code}
|
|
373
|
+
</code>
|
|
374
|
+
</div>
|
|
375
|
+
)
|
|
376
|
+
})}
|
|
377
|
+
</pre>
|
|
378
|
+
</div>
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
interface RelatedEntriesProps {
|
|
383
|
+
entries: Entry[]
|
|
384
|
+
currentEntryId: number
|
|
385
|
+
navigate: ReturnType<typeof useNavigate>
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function RelatedEntries({ entries, currentEntryId, navigate }: RelatedEntriesProps) {
|
|
389
|
+
const [currentTab, setCurrentTab] = useState<string>('')
|
|
390
|
+
|
|
391
|
+
// Filter out the current entry from related entries
|
|
392
|
+
const filteredEntries = entries.filter(e => e.id !== currentEntryId)
|
|
393
|
+
|
|
394
|
+
const groupedEntries = filteredEntries.reduce((acc, entry) => {
|
|
395
|
+
const type = entry.entry_type
|
|
396
|
+
if (!acc[type]) acc[type] = []
|
|
397
|
+
acc[type].push(entry)
|
|
398
|
+
return acc
|
|
399
|
+
}, {} as Record<string, Entry[]>)
|
|
400
|
+
|
|
401
|
+
const tabs = Object.entries(groupedEntries).map(([type, items]) => ({
|
|
402
|
+
type,
|
|
403
|
+
label: getTypeLabel(type),
|
|
404
|
+
count: items.length
|
|
405
|
+
}))
|
|
406
|
+
|
|
407
|
+
useEffect(() => {
|
|
408
|
+
if (tabs.length > 0 && !currentTab) {
|
|
409
|
+
setCurrentTab(tabs[0].type)
|
|
410
|
+
}
|
|
411
|
+
}, [tabs.length])
|
|
412
|
+
|
|
413
|
+
if (tabs.length === 0) return null
|
|
414
|
+
|
|
415
|
+
const currentEntries = groupedEntries[currentTab] || []
|
|
416
|
+
|
|
417
|
+
return (
|
|
418
|
+
<Card>
|
|
419
|
+
<CardHeader>
|
|
420
|
+
<CardTitle>Related Entries</CardTitle>
|
|
421
|
+
</CardHeader>
|
|
422
|
+
<div className="flex flex-wrap border-b border-dark-border">
|
|
423
|
+
{tabs.map((tab) => (
|
|
424
|
+
<button
|
|
425
|
+
key={tab.type}
|
|
426
|
+
onClick={() => setCurrentTab(tab.type)}
|
|
427
|
+
className={`px-4 py-2.5 text-sm font-medium ${
|
|
428
|
+
currentTab === tab.type
|
|
429
|
+
? 'bg-blue-500 text-white'
|
|
430
|
+
: 'text-dark-muted hover:text-dark-text'
|
|
431
|
+
}`}
|
|
432
|
+
>
|
|
433
|
+
{tab.label} ({tab.count})
|
|
434
|
+
</button>
|
|
435
|
+
))}
|
|
436
|
+
</div>
|
|
437
|
+
<table className="w-full">
|
|
438
|
+
<tbody>
|
|
439
|
+
{currentEntries.map((relatedEntry) => {
|
|
440
|
+
const relPayload = relatedEntry.payload as Record<string, unknown>
|
|
441
|
+
const path = getEntryPath(relatedEntry)
|
|
442
|
+
|
|
443
|
+
return (
|
|
444
|
+
<tr
|
|
445
|
+
key={relatedEntry.id}
|
|
446
|
+
onClick={() => navigate(path)}
|
|
447
|
+
className="border-b border-dark-border hover:bg-white/[0.02] cursor-pointer"
|
|
448
|
+
>
|
|
449
|
+
<td className="px-4 py-3">
|
|
450
|
+
<span className="text-dark-text text-sm">{getEntryDescription(relatedEntry, relPayload)}</span>
|
|
451
|
+
</td>
|
|
452
|
+
<td className="px-4 py-3 text-right text-dark-muted text-xs">
|
|
453
|
+
{getTimeAgo(relatedEntry.occurred_at)}
|
|
454
|
+
</td>
|
|
455
|
+
<td className="px-4 py-3 w-12">
|
|
456
|
+
<ArrowIcon />
|
|
457
|
+
</td>
|
|
458
|
+
</tr>
|
|
459
|
+
)
|
|
460
|
+
})}
|
|
461
|
+
</tbody>
|
|
462
|
+
</table>
|
|
463
|
+
</Card>
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function ArrowIcon() {
|
|
468
|
+
return (
|
|
469
|
+
<svg className="w-5 h-5 text-dark-muted" viewBox="0 0 20 20" fill="currentColor">
|
|
470
|
+
<path
|
|
471
|
+
fillRule="evenodd"
|
|
472
|
+
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"
|
|
473
|
+
clipRule="evenodd"
|
|
474
|
+
/>
|
|
475
|
+
</svg>
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function getEntryPath(entry: Entry): string {
|
|
480
|
+
switch (entry.entry_type) {
|
|
481
|
+
case 'query': return `/queries/${entry.id}`
|
|
482
|
+
case 'exception': return `/exceptions/${entry.id}`
|
|
483
|
+
case 'job_enqueue':
|
|
484
|
+
case 'job_perform': return `/jobs/${entry.id}`
|
|
485
|
+
case 'request': return `/requests/${entry.id}`
|
|
486
|
+
case 'command': return `/commands/${entry.id}`
|
|
487
|
+
default: return `/${entry.entry_type}s/${entry.id}`
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function getEntryDescription(entry: Entry, payload: Record<string, unknown>): string {
|
|
492
|
+
switch (entry.entry_type) {
|
|
493
|
+
case 'query':
|
|
494
|
+
return String(payload.sql || '').substring(0, 100)
|
|
495
|
+
case 'request':
|
|
496
|
+
return `${payload.method} ${payload.path}`
|
|
497
|
+
case 'command':
|
|
498
|
+
return `Command: ${payload.command}`
|
|
499
|
+
case 'exception':
|
|
500
|
+
return `${payload.class}: ${String(payload.message || '').substring(0, 50)}`
|
|
501
|
+
case 'job_enqueue':
|
|
502
|
+
case 'job_perform':
|
|
503
|
+
return `Job: ${payload.job_class}`
|
|
504
|
+
default:
|
|
505
|
+
return entry.entry_type
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function getTypeLabel(type: string): string {
|
|
510
|
+
const labels: Record<string, string> = {
|
|
511
|
+
query: 'Queries',
|
|
512
|
+
exception: 'Exceptions',
|
|
513
|
+
job_enqueue: 'Enqueued',
|
|
514
|
+
job_perform: 'Performed',
|
|
515
|
+
request: 'Requests',
|
|
516
|
+
command: 'Commands',
|
|
517
|
+
log: 'Logs',
|
|
518
|
+
cache: 'Cache',
|
|
519
|
+
event: 'Events',
|
|
520
|
+
mail: 'Mail',
|
|
521
|
+
notification: 'Notifications',
|
|
522
|
+
model: 'Models',
|
|
523
|
+
gate: 'Gates',
|
|
524
|
+
redis: 'Redis',
|
|
525
|
+
view: 'Views',
|
|
526
|
+
client_request: 'HTTP Client'
|
|
527
|
+
}
|
|
528
|
+
return labels[type] || type
|
|
529
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import PlaceholderPage from '@/components/PlaceholderPage'
|
|
2
|
+
|
|
3
|
+
export default function LogsIndex() {
|
|
4
|
+
return (
|
|
5
|
+
<PlaceholderPage
|
|
6
|
+
title="Logs"
|
|
7
|
+
description="Application log entries from Rails.logger"
|
|
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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
11
|
+
</svg>
|
|
12
|
+
}
|
|
13
|
+
/>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import PlaceholderPage from '@/components/PlaceholderPage'
|
|
2
|
+
|
|
3
|
+
export default function MailIndex() {
|
|
4
|
+
return (
|
|
5
|
+
<PlaceholderPage
|
|
6
|
+
title="Mail"
|
|
7
|
+
description="Emails sent by ActionMailer 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="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
11
|
+
</svg>
|
|
12
|
+
}
|
|
13
|
+
/>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import PlaceholderPage from '@/components/PlaceholderPage'
|
|
2
|
+
|
|
3
|
+
export default function ModelsIndex() {
|
|
4
|
+
return (
|
|
5
|
+
<PlaceholderPage
|
|
6
|
+
title="Models"
|
|
7
|
+
description="ActiveRecord model events (create, update, delete)"
|
|
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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
11
|
+
</svg>
|
|
12
|
+
}
|
|
13
|
+
/>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import PlaceholderPage from '@/components/PlaceholderPage'
|
|
2
|
+
|
|
3
|
+
export default function NotificationsIndex() {
|
|
4
|
+
return (
|
|
5
|
+
<PlaceholderPage
|
|
6
|
+
title="Notifications"
|
|
7
|
+
description="Notifications sent via ActionMailer and other channels"
|
|
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="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
|
11
|
+
</svg>
|
|
12
|
+
}
|
|
13
|
+
/>
|
|
14
|
+
)
|
|
15
|
+
}
|