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,480 @@
|
|
|
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, MethodBadge } from '@/components/ui/Badge'
|
|
7
|
+
|
|
8
|
+
type TabType = 'message' | 'location' | 'context' | 'stacktrace'
|
|
9
|
+
|
|
10
|
+
export default function ExceptionsShow() {
|
|
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>('message')
|
|
17
|
+
const [showAllTrace, setShowAllTrace] = useState(false)
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
loadEntry()
|
|
21
|
+
}, [id])
|
|
22
|
+
|
|
23
|
+
async function loadEntry() {
|
|
24
|
+
if (!id) return
|
|
25
|
+
setLoading(true)
|
|
26
|
+
try {
|
|
27
|
+
const response = await getEntry(id)
|
|
28
|
+
setEntry(response.data)
|
|
29
|
+
setBatch(response.batch || [])
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.error('Failed to load entry:', error)
|
|
32
|
+
} finally {
|
|
33
|
+
setLoading(false)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (loading) {
|
|
38
|
+
return <div className="p-6 text-dark-muted">Loading...</div>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!entry) {
|
|
42
|
+
return <div className="p-6 text-dark-muted">Entry not found</div>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const payload = entry.payload as Record<string, unknown>
|
|
46
|
+
const backtrace = (payload.backtrace as string[] | undefined) || []
|
|
47
|
+
const context = payload.context as Record<string, unknown> | undefined
|
|
48
|
+
const source = payload.source as string | undefined // 'command', 'job', or undefined (request)
|
|
49
|
+
|
|
50
|
+
// Use file and line from payload (extracted by backend)
|
|
51
|
+
const file = payload.file ? String(payload.file) : ''
|
|
52
|
+
const line = payload.line ? String(payload.line) : ''
|
|
53
|
+
|
|
54
|
+
const formattedTime = new Date(entry.occurred_at).toLocaleString('en-US', {
|
|
55
|
+
year: 'numeric',
|
|
56
|
+
month: 'long',
|
|
57
|
+
day: 'numeric',
|
|
58
|
+
hour: 'numeric',
|
|
59
|
+
minute: '2-digit',
|
|
60
|
+
second: '2-digit',
|
|
61
|
+
hour12: true
|
|
62
|
+
})
|
|
63
|
+
const timeAgo = getTimeAgo(entry.occurred_at)
|
|
64
|
+
|
|
65
|
+
// Limit trace lines unless showing all
|
|
66
|
+
const displayedTrace = showAllTrace ? backtrace : backtrace.slice(0, 5)
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="p-6 space-y-5">
|
|
70
|
+
{/* Exception Details Card */}
|
|
71
|
+
<Card>
|
|
72
|
+
<CardHeader>
|
|
73
|
+
<CardTitle>Exception Details</CardTitle>
|
|
74
|
+
</CardHeader>
|
|
75
|
+
<CardContent className="p-0">
|
|
76
|
+
<table className="w-full">
|
|
77
|
+
<tbody>
|
|
78
|
+
<tr className="border-t border-dark-border">
|
|
79
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap w-32">Time</td>
|
|
80
|
+
<td className="px-4 py-3">{formattedTime} ({timeAgo})</td>
|
|
81
|
+
</tr>
|
|
82
|
+
<tr className="border-t border-dark-border">
|
|
83
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Type</td>
|
|
84
|
+
<td className="px-4 py-3">
|
|
85
|
+
<span className="text-red-400 font-medium">{String(payload.class)}</span>
|
|
86
|
+
</td>
|
|
87
|
+
</tr>
|
|
88
|
+
{file && (
|
|
89
|
+
<tr className="border-t border-dark-border">
|
|
90
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Location</td>
|
|
91
|
+
<td className="px-4 py-3 font-mono text-sm">{file}:{line}</td>
|
|
92
|
+
</tr>
|
|
93
|
+
)}
|
|
94
|
+
{source === 'command' ? (
|
|
95
|
+
<tr className="border-t border-dark-border">
|
|
96
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Command</td>
|
|
97
|
+
<td className="px-4 py-3">
|
|
98
|
+
<code className="text-blue-400">{String(payload.command)}</code>
|
|
99
|
+
</td>
|
|
100
|
+
</tr>
|
|
101
|
+
) : source === 'job' ? (
|
|
102
|
+
<>
|
|
103
|
+
<tr className="border-t border-dark-border">
|
|
104
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Job</td>
|
|
105
|
+
<td className="px-4 py-3">
|
|
106
|
+
<code className="text-blue-400">{String(payload.job_class)}</code>
|
|
107
|
+
</td>
|
|
108
|
+
</tr>
|
|
109
|
+
{payload.queue_name ? (
|
|
110
|
+
<tr className="border-t border-dark-border">
|
|
111
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Queue</td>
|
|
112
|
+
<td className="px-4 py-3">{String(payload.queue_name)}</td>
|
|
113
|
+
</tr>
|
|
114
|
+
) : null}
|
|
115
|
+
</>
|
|
116
|
+
) : (
|
|
117
|
+
<>
|
|
118
|
+
{payload.method ? (
|
|
119
|
+
<tr className="border-t border-dark-border">
|
|
120
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Request</td>
|
|
121
|
+
<td className="px-4 py-3 flex items-center gap-2">
|
|
122
|
+
<MethodBadge method={String(payload.method)} />
|
|
123
|
+
<span className="font-mono text-sm">{String(payload.path)}</span>
|
|
124
|
+
</td>
|
|
125
|
+
</tr>
|
|
126
|
+
) : null}
|
|
127
|
+
{payload.controller ? (
|
|
128
|
+
<tr className="border-t border-dark-border">
|
|
129
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Controller</td>
|
|
130
|
+
<td className="px-4 py-3 font-mono text-sm">
|
|
131
|
+
{String(payload.controller)}#{String(payload.action)}
|
|
132
|
+
</td>
|
|
133
|
+
</tr>
|
|
134
|
+
) : null}
|
|
135
|
+
</>
|
|
136
|
+
)}
|
|
137
|
+
<tr className="border-t border-dark-border">
|
|
138
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Occurrences</td>
|
|
139
|
+
<td className="px-4 py-3">
|
|
140
|
+
<Link
|
|
141
|
+
to={`/exceptions?family_hash=${entry.family_hash}`}
|
|
142
|
+
className="text-blue-400 hover:text-blue-300"
|
|
143
|
+
>
|
|
144
|
+
View Other Occurrences
|
|
145
|
+
</Link>
|
|
146
|
+
</td>
|
|
147
|
+
</tr>
|
|
148
|
+
</tbody>
|
|
149
|
+
</table>
|
|
150
|
+
</CardContent>
|
|
151
|
+
</Card>
|
|
152
|
+
|
|
153
|
+
{/* Message / Location / Context / Stacktrace Card */}
|
|
154
|
+
<Card>
|
|
155
|
+
<div className="flex border-b border-dark-border">
|
|
156
|
+
<button
|
|
157
|
+
onClick={() => setCurrentTab('message')}
|
|
158
|
+
className={`px-4 py-2.5 text-sm font-medium ${
|
|
159
|
+
currentTab === 'message'
|
|
160
|
+
? 'bg-blue-500 text-white'
|
|
161
|
+
: 'text-dark-muted hover:text-dark-text'
|
|
162
|
+
}`}
|
|
163
|
+
>
|
|
164
|
+
Message
|
|
165
|
+
</button>
|
|
166
|
+
{file && (
|
|
167
|
+
<button
|
|
168
|
+
onClick={() => setCurrentTab('location')}
|
|
169
|
+
className={`px-4 py-2.5 text-sm font-medium ${
|
|
170
|
+
currentTab === 'location'
|
|
171
|
+
? 'bg-blue-500 text-white'
|
|
172
|
+
: 'text-dark-muted hover:text-dark-text'
|
|
173
|
+
}`}
|
|
174
|
+
>
|
|
175
|
+
Location
|
|
176
|
+
</button>
|
|
177
|
+
)}
|
|
178
|
+
{context && Object.keys(context).length > 0 && (
|
|
179
|
+
<button
|
|
180
|
+
onClick={() => setCurrentTab('context')}
|
|
181
|
+
className={`px-4 py-2.5 text-sm font-medium ${
|
|
182
|
+
currentTab === 'context'
|
|
183
|
+
? 'bg-blue-500 text-white'
|
|
184
|
+
: 'text-dark-muted hover:text-dark-text'
|
|
185
|
+
}`}
|
|
186
|
+
>
|
|
187
|
+
Context
|
|
188
|
+
</button>
|
|
189
|
+
)}
|
|
190
|
+
<button
|
|
191
|
+
onClick={() => setCurrentTab('stacktrace')}
|
|
192
|
+
className={`px-4 py-2.5 text-sm font-medium ${
|
|
193
|
+
currentTab === 'stacktrace'
|
|
194
|
+
? 'bg-blue-500 text-white'
|
|
195
|
+
: 'text-dark-muted hover:text-dark-text'
|
|
196
|
+
}`}
|
|
197
|
+
>
|
|
198
|
+
Stacktrace
|
|
199
|
+
</button>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
{currentTab === 'message' && (
|
|
203
|
+
<div className="bg-[#1a1a2e] p-4">
|
|
204
|
+
<pre className="text-red-400 whitespace-pre-wrap break-words font-mono text-sm">
|
|
205
|
+
{String(payload.message)}
|
|
206
|
+
</pre>
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
|
|
210
|
+
{currentTab === 'location' && file && (
|
|
211
|
+
<div className="bg-[#1a1a2e]">
|
|
212
|
+
<CodePreview
|
|
213
|
+
file={file}
|
|
214
|
+
line={Number(line)}
|
|
215
|
+
linePreview={payload.line_preview as Record<string, string> | undefined}
|
|
216
|
+
/>
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
|
|
220
|
+
{currentTab === 'context' && context && (
|
|
221
|
+
<div className="bg-[#1a1a2e] p-4">
|
|
222
|
+
<pre className="text-dark-text whitespace-pre-wrap font-mono text-sm">
|
|
223
|
+
{JSON.stringify(context, null, 2)}
|
|
224
|
+
</pre>
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
|
|
228
|
+
{currentTab === 'stacktrace' && (
|
|
229
|
+
<div className="bg-[#1a1a2e]">
|
|
230
|
+
<table className="w-full">
|
|
231
|
+
<tbody>
|
|
232
|
+
{displayedTrace.map((traceLine, i) => (
|
|
233
|
+
<tr key={i} className="border-b border-dark-border/50">
|
|
234
|
+
<td className="px-4 py-2">
|
|
235
|
+
<code className="text-dark-muted text-xs font-mono">{traceLine}</code>
|
|
236
|
+
</td>
|
|
237
|
+
</tr>
|
|
238
|
+
))}
|
|
239
|
+
{!showAllTrace && backtrace.length > 5 && (
|
|
240
|
+
<tr>
|
|
241
|
+
<td className="px-4 py-2">
|
|
242
|
+
<button
|
|
243
|
+
onClick={() => setShowAllTrace(true)}
|
|
244
|
+
className="text-blue-400 hover:text-blue-300 text-sm"
|
|
245
|
+
>
|
|
246
|
+
Show All ({backtrace.length} lines)
|
|
247
|
+
</button>
|
|
248
|
+
</td>
|
|
249
|
+
</tr>
|
|
250
|
+
)}
|
|
251
|
+
</tbody>
|
|
252
|
+
</table>
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
255
|
+
</Card>
|
|
256
|
+
|
|
257
|
+
{/* Tags Card */}
|
|
258
|
+
<Card>
|
|
259
|
+
<CardHeader>
|
|
260
|
+
<CardTitle>Tags</CardTitle>
|
|
261
|
+
</CardHeader>
|
|
262
|
+
<CardContent>
|
|
263
|
+
<div className="flex flex-wrap gap-2">
|
|
264
|
+
{entry.tags.map((tag) => (
|
|
265
|
+
<Badge key={tag} variant={tag === 'exception' ? 'error' : 'default'}>{tag}</Badge>
|
|
266
|
+
))}
|
|
267
|
+
</div>
|
|
268
|
+
</CardContent>
|
|
269
|
+
</Card>
|
|
270
|
+
|
|
271
|
+
{/* Related Entries */}
|
|
272
|
+
{batch.length > 0 && <RelatedEntries entries={batch} navigate={navigate} />}
|
|
273
|
+
</div>
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function getTimeAgo(date: string): string {
|
|
278
|
+
const seconds = Math.floor((new Date().getTime() - new Date(date).getTime()) / 1000)
|
|
279
|
+
if (seconds < 60) return `${seconds}s ago`
|
|
280
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
|
|
281
|
+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`
|
|
282
|
+
return `${Math.floor(seconds / 86400)}d ago`
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
interface RelatedEntriesProps {
|
|
286
|
+
entries: Entry[]
|
|
287
|
+
navigate: ReturnType<typeof useNavigate>
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function RelatedEntries({ entries, navigate }: RelatedEntriesProps) {
|
|
291
|
+
const [currentTab, setCurrentTab] = useState<string>('')
|
|
292
|
+
|
|
293
|
+
const groupedEntries = entries.reduce((acc, entry) => {
|
|
294
|
+
const type = entry.entry_type
|
|
295
|
+
if (!acc[type]) acc[type] = []
|
|
296
|
+
acc[type].push(entry)
|
|
297
|
+
return acc
|
|
298
|
+
}, {} as Record<string, Entry[]>)
|
|
299
|
+
|
|
300
|
+
const tabs = Object.entries(groupedEntries).map(([type, items]) => ({
|
|
301
|
+
type,
|
|
302
|
+
label: getTypeLabel(type),
|
|
303
|
+
count: items.length
|
|
304
|
+
}))
|
|
305
|
+
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
if (tabs.length > 0 && !currentTab) {
|
|
308
|
+
setCurrentTab(tabs[0].type)
|
|
309
|
+
}
|
|
310
|
+
}, [tabs.length])
|
|
311
|
+
|
|
312
|
+
if (tabs.length === 0) return null
|
|
313
|
+
|
|
314
|
+
const currentEntries = groupedEntries[currentTab] || []
|
|
315
|
+
|
|
316
|
+
return (
|
|
317
|
+
<Card>
|
|
318
|
+
<CardHeader>
|
|
319
|
+
<CardTitle>Related Entries</CardTitle>
|
|
320
|
+
</CardHeader>
|
|
321
|
+
<div className="flex flex-wrap border-b border-dark-border">
|
|
322
|
+
{tabs.map((tab) => (
|
|
323
|
+
<button
|
|
324
|
+
key={tab.type}
|
|
325
|
+
onClick={() => setCurrentTab(tab.type)}
|
|
326
|
+
className={`px-4 py-2.5 text-sm font-medium ${
|
|
327
|
+
currentTab === tab.type
|
|
328
|
+
? 'bg-blue-500 text-white'
|
|
329
|
+
: 'text-dark-muted hover:text-dark-text'
|
|
330
|
+
}`}
|
|
331
|
+
>
|
|
332
|
+
{tab.label} ({tab.count})
|
|
333
|
+
</button>
|
|
334
|
+
))}
|
|
335
|
+
</div>
|
|
336
|
+
<table className="w-full">
|
|
337
|
+
<tbody>
|
|
338
|
+
{currentEntries.map((relatedEntry) => {
|
|
339
|
+
const relPayload = relatedEntry.payload as Record<string, unknown>
|
|
340
|
+
const path = getEntryPath(relatedEntry)
|
|
341
|
+
|
|
342
|
+
return (
|
|
343
|
+
<tr
|
|
344
|
+
key={relatedEntry.id}
|
|
345
|
+
onClick={() => navigate(path)}
|
|
346
|
+
className="border-b border-dark-border hover:bg-white/[0.02] cursor-pointer"
|
|
347
|
+
>
|
|
348
|
+
<td className="px-4 py-3">
|
|
349
|
+
<span className="text-dark-muted text-sm">{getEntryDescription(relatedEntry, relPayload)}</span>
|
|
350
|
+
</td>
|
|
351
|
+
<td className="px-4 py-3 w-12">
|
|
352
|
+
<ArrowIcon />
|
|
353
|
+
</td>
|
|
354
|
+
</tr>
|
|
355
|
+
)
|
|
356
|
+
})}
|
|
357
|
+
</tbody>
|
|
358
|
+
</table>
|
|
359
|
+
</Card>
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function ArrowIcon() {
|
|
364
|
+
return (
|
|
365
|
+
<svg className="w-5 h-5 text-dark-muted" viewBox="0 0 20 20" fill="currentColor">
|
|
366
|
+
<path
|
|
367
|
+
fillRule="evenodd"
|
|
368
|
+
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"
|
|
369
|
+
clipRule="evenodd"
|
|
370
|
+
/>
|
|
371
|
+
</svg>
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function getEntryPath(entry: Entry): string {
|
|
376
|
+
switch (entry.entry_type) {
|
|
377
|
+
case 'query': return `/queries/${entry.id}`
|
|
378
|
+
case 'exception': return `/exceptions/${entry.id}`
|
|
379
|
+
case 'job_enqueue':
|
|
380
|
+
case 'job_perform': return `/jobs/${entry.id}`
|
|
381
|
+
case 'request': return `/requests/${entry.id}`
|
|
382
|
+
case 'command': return `/commands/${entry.id}`
|
|
383
|
+
default: return `/${entry.entry_type}s/${entry.id}`
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function getEntryDescription(entry: Entry, payload: Record<string, unknown>): string {
|
|
388
|
+
switch (entry.entry_type) {
|
|
389
|
+
case 'query':
|
|
390
|
+
return String(payload.sql || '').substring(0, 80)
|
|
391
|
+
case 'request':
|
|
392
|
+
return `${payload.method} ${payload.path}`
|
|
393
|
+
case 'command':
|
|
394
|
+
return `Command: ${payload.command}`
|
|
395
|
+
case 'job_enqueue':
|
|
396
|
+
case 'job_perform':
|
|
397
|
+
return `Job: ${payload.job_class}`
|
|
398
|
+
default:
|
|
399
|
+
return entry.entry_type
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function getTypeLabel(type: string): string {
|
|
404
|
+
const labels: Record<string, string> = {
|
|
405
|
+
query: 'Queries',
|
|
406
|
+
exception: 'Exceptions',
|
|
407
|
+
job_enqueue: 'Jobs',
|
|
408
|
+
job_perform: 'Jobs',
|
|
409
|
+
request: 'Requests',
|
|
410
|
+
command: 'Commands',
|
|
411
|
+
log: 'Logs',
|
|
412
|
+
cache: 'Cache',
|
|
413
|
+
event: 'Events',
|
|
414
|
+
mail: 'Mail',
|
|
415
|
+
notification: 'Notifications',
|
|
416
|
+
model: 'Models',
|
|
417
|
+
gate: 'Gates',
|
|
418
|
+
redis: 'Redis',
|
|
419
|
+
view: 'Views',
|
|
420
|
+
client_request: 'HTTP Client'
|
|
421
|
+
}
|
|
422
|
+
return labels[type] || type
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
interface CodePreviewProps {
|
|
426
|
+
file: string
|
|
427
|
+
line: number
|
|
428
|
+
linePreview?: Record<string, string>
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function CodePreview({ file, line, linePreview }: CodePreviewProps) {
|
|
432
|
+
if (!linePreview || Object.keys(linePreview).length === 0) {
|
|
433
|
+
// Fallback when no line preview available
|
|
434
|
+
return (
|
|
435
|
+
<div className="p-4 font-mono text-sm">
|
|
436
|
+
<div className="text-dark-muted mb-2">File:</div>
|
|
437
|
+
<div className="text-blue-400 mb-4 break-all">{file}</div>
|
|
438
|
+
<div className="text-dark-muted mb-2">Line:</div>
|
|
439
|
+
<div className="inline-block px-3 py-1 bg-red-500/20 text-red-400 rounded">
|
|
440
|
+
{line}
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const lineNumbers = Object.keys(linePreview).map(Number).sort((a, b) => a - b)
|
|
447
|
+
|
|
448
|
+
return (
|
|
449
|
+
<div className="overflow-x-auto">
|
|
450
|
+
<pre className="text-sm">
|
|
451
|
+
{lineNumbers.map((lineNum) => {
|
|
452
|
+
const isHighlighted = lineNum === line
|
|
453
|
+
const code = linePreview[lineNum] || ''
|
|
454
|
+
|
|
455
|
+
return (
|
|
456
|
+
<div
|
|
457
|
+
key={lineNum}
|
|
458
|
+
className={`flex ${isHighlighted ? 'bg-red-500/30' : ''}`}
|
|
459
|
+
>
|
|
460
|
+
<span
|
|
461
|
+
className={`px-3 py-0.5 text-right select-none min-w-[3rem] ${
|
|
462
|
+
isHighlighted ? 'text-red-400 font-bold' : 'text-dark-muted'
|
|
463
|
+
}`}
|
|
464
|
+
>
|
|
465
|
+
{lineNum}
|
|
466
|
+
</span>
|
|
467
|
+
<code
|
|
468
|
+
className={`px-3 py-0.5 flex-1 whitespace-pre ${
|
|
469
|
+
isHighlighted ? 'text-white' : 'text-dark-text'
|
|
470
|
+
}`}
|
|
471
|
+
>
|
|
472
|
+
{code}
|
|
473
|
+
</code>
|
|
474
|
+
</div>
|
|
475
|
+
)
|
|
476
|
+
})}
|
|
477
|
+
</pre>
|
|
478
|
+
</div>
|
|
479
|
+
)
|
|
480
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import PlaceholderPage from '@/components/PlaceholderPage'
|
|
2
|
+
|
|
3
|
+
export default function GatesIndex() {
|
|
4
|
+
return (
|
|
5
|
+
<PlaceholderPage
|
|
6
|
+
title="Gates"
|
|
7
|
+
description="Authorization checks from Pundit, CanCanCan, or custom policies"
|
|
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 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
11
|
+
</svg>
|
|
12
|
+
}
|
|
13
|
+
/>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
3
|
+
import { getEntries } from '@/api/entries'
|
|
4
|
+
import { Entry } from '@/lib/types'
|
|
5
|
+
import { timeAgo } from '@/lib/utils'
|
|
6
|
+
import { Card } from '@/components/ui/Card'
|
|
7
|
+
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/Table'
|
|
8
|
+
import { Badge } from '@/components/ui/Badge'
|
|
9
|
+
import { Pagination } from '@/components/ui/Pagination'
|
|
10
|
+
import { SearchInput } from '@/components/ui/SearchInput'
|
|
11
|
+
|
|
12
|
+
export default function JobsIndex() {
|
|
13
|
+
const navigate = useNavigate()
|
|
14
|
+
const [searchParams, setSearchParams] = useSearchParams()
|
|
15
|
+
const [entries, setEntries] = useState<Entry[]>([])
|
|
16
|
+
const [loading, setLoading] = useState(true)
|
|
17
|
+
const [page, setPage] = useState(1)
|
|
18
|
+
const [totalPages, setTotalPages] = useState(1)
|
|
19
|
+
const [filter, setFilter] = useState<'all' | 'enqueue' | 'perform'>('all')
|
|
20
|
+
|
|
21
|
+
const tagFilter = searchParams.get('tag') || ''
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
loadEntries()
|
|
25
|
+
}, [page, filter, tagFilter])
|
|
26
|
+
|
|
27
|
+
async function loadEntries() {
|
|
28
|
+
setLoading(true)
|
|
29
|
+
try {
|
|
30
|
+
const type = filter === 'all' ? undefined : `job_${filter}`
|
|
31
|
+
const response = await getEntries({
|
|
32
|
+
type,
|
|
33
|
+
tag: tagFilter || (filter === 'all' ? 'job' : undefined),
|
|
34
|
+
page
|
|
35
|
+
})
|
|
36
|
+
setEntries(response.data)
|
|
37
|
+
setTotalPages(response.meta.total_pages)
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error('Failed to load entries:', error)
|
|
40
|
+
} finally {
|
|
41
|
+
setLoading(false)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function handleSearch(value: string) {
|
|
46
|
+
setPage(1)
|
|
47
|
+
if (value) {
|
|
48
|
+
setSearchParams({ tag: value })
|
|
49
|
+
} else {
|
|
50
|
+
setSearchParams({})
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function handleFilterChange(newFilter: 'all' | 'enqueue' | 'perform') {
|
|
55
|
+
setFilter(newFilter)
|
|
56
|
+
setPage(1)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function handlePageChange(newPage: number) {
|
|
60
|
+
setPage(newPage)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className="p-6">
|
|
65
|
+
<div className="mb-6">
|
|
66
|
+
<h1 className="text-2xl font-semibold text-white">Jobs</h1>
|
|
67
|
+
<p className="text-dark-muted text-sm mt-1">Background jobs in your application</p>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div className="flex flex-wrap items-center gap-4 mb-4">
|
|
71
|
+
<div className="flex gap-2">
|
|
72
|
+
{(['all', 'enqueue', 'perform'] as const).map((f) => (
|
|
73
|
+
<button
|
|
74
|
+
key={f}
|
|
75
|
+
onClick={() => handleFilterChange(f)}
|
|
76
|
+
className={`px-3 py-1.5 text-sm rounded-md border ${
|
|
77
|
+
filter === f
|
|
78
|
+
? 'bg-blue-500 border-blue-500 text-white'
|
|
79
|
+
: 'border-dark-border text-dark-muted hover:text-dark-text'
|
|
80
|
+
}`}
|
|
81
|
+
>
|
|
82
|
+
{f === 'all' ? 'All' : f === 'enqueue' ? 'Enqueued' : 'Performed'}
|
|
83
|
+
</button>
|
|
84
|
+
))}
|
|
85
|
+
</div>
|
|
86
|
+
<SearchInput
|
|
87
|
+
placeholder="Search by tag (failed, queue name...)"
|
|
88
|
+
value={tagFilter}
|
|
89
|
+
onChange={handleSearch}
|
|
90
|
+
className="flex-1 max-w-sm"
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<Card>
|
|
95
|
+
<Table>
|
|
96
|
+
<TableHeader>
|
|
97
|
+
<TableRow>
|
|
98
|
+
<TableHead>Type</TableHead>
|
|
99
|
+
<TableHead>Job</TableHead>
|
|
100
|
+
<TableHead>Queue</TableHead>
|
|
101
|
+
<TableHead className="text-right">Duration</TableHead>
|
|
102
|
+
<TableHead>Happened</TableHead>
|
|
103
|
+
</TableRow>
|
|
104
|
+
</TableHeader>
|
|
105
|
+
<TableBody>
|
|
106
|
+
{loading ? (
|
|
107
|
+
<TableRow>
|
|
108
|
+
<TableCell className="text-center text-dark-muted py-8" colSpan={5}>
|
|
109
|
+
Loading...
|
|
110
|
+
</TableCell>
|
|
111
|
+
</TableRow>
|
|
112
|
+
) : entries.length === 0 ? (
|
|
113
|
+
<TableRow>
|
|
114
|
+
<TableCell className="text-center text-dark-muted py-8" colSpan={5}>
|
|
115
|
+
{tagFilter ? `No jobs found with tag "${tagFilter}".` : 'No jobs recorded yet.'}
|
|
116
|
+
</TableCell>
|
|
117
|
+
</TableRow>
|
|
118
|
+
) : (
|
|
119
|
+
entries.map((entry) => {
|
|
120
|
+
const payload = entry.payload as Record<string, unknown>
|
|
121
|
+
const isFailed = entry.tags.includes('failed')
|
|
122
|
+
return (
|
|
123
|
+
<TableRow key={entry.id} onClick={() => navigate(`/jobs/${entry.id}`)}>
|
|
124
|
+
<TableCell>
|
|
125
|
+
<Badge variant={entry.entry_type === 'job_enqueue' ? 'info' : 'success'}>
|
|
126
|
+
{entry.entry_type === 'job_enqueue' ? 'enqueue' : 'perform'}
|
|
127
|
+
</Badge>
|
|
128
|
+
</TableCell>
|
|
129
|
+
<TableCell>
|
|
130
|
+
<span className={isFailed ? 'text-red-400' : 'text-green-400'}>
|
|
131
|
+
{String(payload.job_class)}
|
|
132
|
+
</span>
|
|
133
|
+
</TableCell>
|
|
134
|
+
<TableCell className="text-dark-muted">
|
|
135
|
+
{String(payload.queue_name)}
|
|
136
|
+
</TableCell>
|
|
137
|
+
<TableCell className="text-right text-dark-muted">
|
|
138
|
+
{payload.duration ? `${String(payload.duration)}ms` : '-'}
|
|
139
|
+
</TableCell>
|
|
140
|
+
<TableCell className="text-dark-muted" title={entry.occurred_at}>
|
|
141
|
+
{timeAgo(entry.occurred_at)}
|
|
142
|
+
</TableCell>
|
|
143
|
+
</TableRow>
|
|
144
|
+
)
|
|
145
|
+
})
|
|
146
|
+
)}
|
|
147
|
+
</TableBody>
|
|
148
|
+
</Table>
|
|
149
|
+
<Pagination currentPage={page} totalPages={totalPages} onPageChange={handlePageChange} />
|
|
150
|
+
</Card>
|
|
151
|
+
</div>
|
|
152
|
+
)
|
|
153
|
+
}
|