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,337 @@
|
|
|
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 = 'data' | 'properties'
|
|
9
|
+
|
|
10
|
+
export default function ViewsShow() {
|
|
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 [activeTab, setActiveTab] = useState<TabType>('data')
|
|
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
|
+
Loading...
|
|
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
|
+
View not found.
|
|
54
|
+
</CardContent>
|
|
55
|
+
</Card>
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const payload = entry.payload as Record<string, unknown>
|
|
61
|
+
|
|
62
|
+
const formattedTime = new Date(entry.occurred_at).toLocaleString('en-US', {
|
|
63
|
+
year: 'numeric',
|
|
64
|
+
month: 'long',
|
|
65
|
+
day: 'numeric',
|
|
66
|
+
hour: 'numeric',
|
|
67
|
+
minute: '2-digit',
|
|
68
|
+
second: '2-digit',
|
|
69
|
+
hour12: true
|
|
70
|
+
})
|
|
71
|
+
const timeAgoStr = getTimeAgo(entry.occurred_at)
|
|
72
|
+
|
|
73
|
+
const viewTypeColors: Record<string, string> = {
|
|
74
|
+
template: 'bg-blue-500/20 text-blue-400',
|
|
75
|
+
partial: 'bg-purple-500/20 text-purple-400',
|
|
76
|
+
layout: 'bg-green-500/20 text-green-400'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const getTabContent = () => {
|
|
80
|
+
if (activeTab === 'data') {
|
|
81
|
+
return payload.data || {}
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
name: payload.name,
|
|
85
|
+
path: payload.path,
|
|
86
|
+
full_path: payload.full_path,
|
|
87
|
+
view_type: payload.view_type,
|
|
88
|
+
layout: payload.layout,
|
|
89
|
+
duration: payload.duration
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="p-6 space-y-5">
|
|
95
|
+
{/* View Details Card */}
|
|
96
|
+
<Card>
|
|
97
|
+
<CardHeader>
|
|
98
|
+
<CardTitle>View Details</CardTitle>
|
|
99
|
+
</CardHeader>
|
|
100
|
+
<CardContent className="p-0">
|
|
101
|
+
<table className="w-full">
|
|
102
|
+
<tbody>
|
|
103
|
+
<tr className="border-t border-dark-border">
|
|
104
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap w-32">Time</td>
|
|
105
|
+
<td className="px-4 py-3">{formattedTime} ({timeAgoStr})</td>
|
|
106
|
+
</tr>
|
|
107
|
+
<tr className="border-t border-dark-border">
|
|
108
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Type</td>
|
|
109
|
+
<td className="px-4 py-3">
|
|
110
|
+
<span className={`px-2 py-0.5 rounded text-xs font-medium ${viewTypeColors[String(payload.view_type)] || 'bg-gray-500/20 text-gray-400'}`}>
|
|
111
|
+
{String(payload.view_type)}
|
|
112
|
+
</span>
|
|
113
|
+
</td>
|
|
114
|
+
</tr>
|
|
115
|
+
<tr className="border-t border-dark-border">
|
|
116
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Name</td>
|
|
117
|
+
<td className="px-4 py-3 font-mono text-sm text-white">
|
|
118
|
+
{String(payload.name)}
|
|
119
|
+
</td>
|
|
120
|
+
</tr>
|
|
121
|
+
<tr className="border-t border-dark-border">
|
|
122
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Path</td>
|
|
123
|
+
<td className="px-4 py-3 font-mono text-sm">{String(payload.path)}</td>
|
|
124
|
+
</tr>
|
|
125
|
+
<tr className="border-t border-dark-border">
|
|
126
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Full Path</td>
|
|
127
|
+
<td className="px-4 py-3 font-mono text-xs text-dark-muted break-all">{String(payload.full_path)}</td>
|
|
128
|
+
</tr>
|
|
129
|
+
{Boolean(payload.layout) && (
|
|
130
|
+
<tr className="border-t border-dark-border">
|
|
131
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Layout</td>
|
|
132
|
+
<td className="px-4 py-3 font-mono text-sm">{String(payload.layout)}</td>
|
|
133
|
+
</tr>
|
|
134
|
+
)}
|
|
135
|
+
<tr className="border-t border-dark-border">
|
|
136
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Duration</td>
|
|
137
|
+
<td className="px-4 py-3">{String(payload.duration ?? '-')}ms</td>
|
|
138
|
+
</tr>
|
|
139
|
+
</tbody>
|
|
140
|
+
</table>
|
|
141
|
+
</CardContent>
|
|
142
|
+
</Card>
|
|
143
|
+
|
|
144
|
+
{/* Data Card with Tabs */}
|
|
145
|
+
<Card>
|
|
146
|
+
<div className="flex border-b border-dark-border">
|
|
147
|
+
<button
|
|
148
|
+
onClick={() => setActiveTab('data')}
|
|
149
|
+
className={`px-4 py-2.5 text-sm font-medium ${
|
|
150
|
+
activeTab === 'data'
|
|
151
|
+
? 'bg-blue-500 text-white'
|
|
152
|
+
: 'text-dark-muted hover:text-dark-text'
|
|
153
|
+
}`}
|
|
154
|
+
>
|
|
155
|
+
View Data
|
|
156
|
+
</button>
|
|
157
|
+
<button
|
|
158
|
+
onClick={() => setActiveTab('properties')}
|
|
159
|
+
className={`px-4 py-2.5 text-sm font-medium ${
|
|
160
|
+
activeTab === 'properties'
|
|
161
|
+
? 'bg-blue-500 text-white'
|
|
162
|
+
: 'text-dark-muted hover:text-dark-text'
|
|
163
|
+
}`}
|
|
164
|
+
>
|
|
165
|
+
Properties
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
<div className="bg-[#1a1a2e]">
|
|
169
|
+
<JsonViewer data={getTabContent()} className="border-0 bg-transparent" />
|
|
170
|
+
</div>
|
|
171
|
+
</Card>
|
|
172
|
+
|
|
173
|
+
{/* Related Entries */}
|
|
174
|
+
{batch.length > 0 && <RelatedEntries entries={batch} currentEntryId={entry.id} navigate={navigate} />}
|
|
175
|
+
</div>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function getTimeAgo(date: string): string {
|
|
180
|
+
const seconds = Math.floor((new Date().getTime() - new Date(date).getTime()) / 1000)
|
|
181
|
+
if (seconds < 60) return `${seconds}s ago`
|
|
182
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
|
|
183
|
+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`
|
|
184
|
+
return `${Math.floor(seconds / 86400)}d ago`
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface RelatedEntriesProps {
|
|
188
|
+
entries: Entry[]
|
|
189
|
+
currentEntryId: number
|
|
190
|
+
navigate: ReturnType<typeof useNavigate>
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function RelatedEntries({ entries, currentEntryId, navigate }: RelatedEntriesProps) {
|
|
194
|
+
const [currentTab, setCurrentTab] = useState<string>('')
|
|
195
|
+
|
|
196
|
+
// Filter out the current entry from related entries
|
|
197
|
+
const filteredEntries = entries.filter(e => e.id !== currentEntryId)
|
|
198
|
+
|
|
199
|
+
const groupedEntries = filteredEntries.reduce((acc, entry) => {
|
|
200
|
+
const type = entry.entry_type
|
|
201
|
+
if (!acc[type]) acc[type] = []
|
|
202
|
+
acc[type].push(entry)
|
|
203
|
+
return acc
|
|
204
|
+
}, {} as Record<string, Entry[]>)
|
|
205
|
+
|
|
206
|
+
const tabs = Object.entries(groupedEntries).map(([type, items]) => ({
|
|
207
|
+
type,
|
|
208
|
+
label: getTypeLabel(type),
|
|
209
|
+
count: items.length
|
|
210
|
+
}))
|
|
211
|
+
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (tabs.length > 0 && !currentTab) {
|
|
214
|
+
setCurrentTab(tabs[0].type)
|
|
215
|
+
}
|
|
216
|
+
}, [tabs.length])
|
|
217
|
+
|
|
218
|
+
if (tabs.length === 0) return null
|
|
219
|
+
|
|
220
|
+
const currentEntries = groupedEntries[currentTab] || []
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<Card>
|
|
224
|
+
<CardHeader>
|
|
225
|
+
<CardTitle>Related Entries</CardTitle>
|
|
226
|
+
</CardHeader>
|
|
227
|
+
<div className="flex flex-wrap border-b border-dark-border">
|
|
228
|
+
{tabs.map((tab) => (
|
|
229
|
+
<button
|
|
230
|
+
key={tab.type}
|
|
231
|
+
onClick={() => setCurrentTab(tab.type)}
|
|
232
|
+
className={`px-4 py-2.5 text-sm font-medium ${
|
|
233
|
+
currentTab === tab.type
|
|
234
|
+
? 'bg-blue-500 text-white'
|
|
235
|
+
: 'text-dark-muted hover:text-dark-text'
|
|
236
|
+
}`}
|
|
237
|
+
>
|
|
238
|
+
{tab.label} ({tab.count})
|
|
239
|
+
</button>
|
|
240
|
+
))}
|
|
241
|
+
</div>
|
|
242
|
+
<table className="w-full">
|
|
243
|
+
<tbody>
|
|
244
|
+
{currentEntries.map((relatedEntry) => {
|
|
245
|
+
const relPayload = relatedEntry.payload as Record<string, unknown>
|
|
246
|
+
const path = getEntryPath(relatedEntry)
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<tr
|
|
250
|
+
key={relatedEntry.id}
|
|
251
|
+
onClick={() => navigate(path)}
|
|
252
|
+
className="border-b border-dark-border hover:bg-white/[0.02] cursor-pointer"
|
|
253
|
+
>
|
|
254
|
+
<td className="px-4 py-3">
|
|
255
|
+
<span className="text-dark-text text-sm">{getEntryDescription(relatedEntry, relPayload)}</span>
|
|
256
|
+
</td>
|
|
257
|
+
<td className="px-4 py-3 text-right text-dark-muted text-xs">
|
|
258
|
+
{getTimeAgo(relatedEntry.occurred_at)}
|
|
259
|
+
</td>
|
|
260
|
+
<td className="px-4 py-3 w-12">
|
|
261
|
+
<ArrowIcon />
|
|
262
|
+
</td>
|
|
263
|
+
</tr>
|
|
264
|
+
)
|
|
265
|
+
})}
|
|
266
|
+
</tbody>
|
|
267
|
+
</table>
|
|
268
|
+
</Card>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function ArrowIcon() {
|
|
273
|
+
return (
|
|
274
|
+
<svg className="w-5 h-5 text-dark-muted" viewBox="0 0 20 20" fill="currentColor">
|
|
275
|
+
<path
|
|
276
|
+
fillRule="evenodd"
|
|
277
|
+
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"
|
|
278
|
+
clipRule="evenodd"
|
|
279
|
+
/>
|
|
280
|
+
</svg>
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function getEntryPath(entry: Entry): string {
|
|
285
|
+
switch (entry.entry_type) {
|
|
286
|
+
case 'query': return `/queries/${entry.id}`
|
|
287
|
+
case 'exception': return `/exceptions/${entry.id}`
|
|
288
|
+
case 'job_enqueue':
|
|
289
|
+
case 'job_perform': return `/jobs/${entry.id}`
|
|
290
|
+
case 'request': return `/requests/${entry.id}`
|
|
291
|
+
case 'command': return `/commands/${entry.id}`
|
|
292
|
+
case 'view': return `/views/${entry.id}`
|
|
293
|
+
default: return `/${entry.entry_type}s/${entry.id}`
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function getEntryDescription(entry: Entry, payload: Record<string, unknown>): string {
|
|
298
|
+
switch (entry.entry_type) {
|
|
299
|
+
case 'query':
|
|
300
|
+
return String(payload.sql || '').substring(0, 100)
|
|
301
|
+
case 'request':
|
|
302
|
+
return `${payload.method} ${payload.path}`
|
|
303
|
+
case 'command':
|
|
304
|
+
return `Command: ${payload.command}`
|
|
305
|
+
case 'exception':
|
|
306
|
+
return `${payload.class}: ${String(payload.message || '').substring(0, 50)}`
|
|
307
|
+
case 'job_enqueue':
|
|
308
|
+
case 'job_perform':
|
|
309
|
+
return `Job: ${payload.job_class}`
|
|
310
|
+
case 'view':
|
|
311
|
+
return `${payload.view_type}: ${payload.name || payload.path}`
|
|
312
|
+
default:
|
|
313
|
+
return entry.entry_type
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function getTypeLabel(type: string): string {
|
|
318
|
+
const labels: Record<string, string> = {
|
|
319
|
+
query: 'Queries',
|
|
320
|
+
exception: 'Exceptions',
|
|
321
|
+
job_enqueue: 'Enqueued',
|
|
322
|
+
job_perform: 'Performed',
|
|
323
|
+
request: 'Requests',
|
|
324
|
+
command: 'Commands',
|
|
325
|
+
log: 'Logs',
|
|
326
|
+
cache: 'Cache',
|
|
327
|
+
event: 'Events',
|
|
328
|
+
mail: 'Mail',
|
|
329
|
+
notification: 'Notifications',
|
|
330
|
+
model: 'Models',
|
|
331
|
+
gate: 'Gates',
|
|
332
|
+
redis: 'Redis',
|
|
333
|
+
view: 'Views',
|
|
334
|
+
client_request: 'HTTP Client'
|
|
335
|
+
}
|
|
336
|
+
return labels[type] || type
|
|
337
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** @type {import('tailwindcss').Config} */
|
|
2
|
+
export default {
|
|
3
|
+
content: [
|
|
4
|
+
"./index.html",
|
|
5
|
+
"./src/**/*.{js,ts,jsx,tsx}",
|
|
6
|
+
],
|
|
7
|
+
darkMode: 'class',
|
|
8
|
+
theme: {
|
|
9
|
+
extend: {
|
|
10
|
+
colors: {
|
|
11
|
+
dark: {
|
|
12
|
+
bg: '#0d1117',
|
|
13
|
+
surface: '#161b22',
|
|
14
|
+
border: '#30363d',
|
|
15
|
+
text: '#c9d1d9',
|
|
16
|
+
muted: '#8b949e',
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
plugins: [],
|
|
22
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"jsx": "react-jsx",
|
|
14
|
+
"strict": true,
|
|
15
|
+
"noUnusedLocals": true,
|
|
16
|
+
"noUnusedParameters": true,
|
|
17
|
+
"noFallthroughCasesInSwitch": true,
|
|
18
|
+
"baseUrl": ".",
|
|
19
|
+
"paths": {
|
|
20
|
+
"@/*": ["./src/*"]
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"include": ["src"],
|
|
24
|
+
"references": [{ "path": "./tsconfig.node.json" }]
|
|
25
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { defineConfig } from 'vite'
|
|
2
|
+
import react from '@vitejs/plugin-react'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
plugins: [react()],
|
|
7
|
+
base: '/railscope/assets/',
|
|
8
|
+
build: {
|
|
9
|
+
outDir: '../public/railscope/assets',
|
|
10
|
+
emptyOutDir: true,
|
|
11
|
+
rollupOptions: {
|
|
12
|
+
output: {
|
|
13
|
+
entryFileNames: 'app.js',
|
|
14
|
+
chunkFileNames: 'app-[hash].js',
|
|
15
|
+
assetFileNames: (assetInfo) => {
|
|
16
|
+
if (assetInfo.name?.endsWith('.css')) {
|
|
17
|
+
return 'app.css'
|
|
18
|
+
}
|
|
19
|
+
return 'assets/[name]-[hash][extname]'
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
resolve: {
|
|
25
|
+
alias: {
|
|
26
|
+
'@': path.resolve(__dirname, './src')
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
server: {
|
|
30
|
+
proxy: {
|
|
31
|
+
'/railscope/api': {
|
|
32
|
+
target: 'http://localhost:3000',
|
|
33
|
+
changeOrigin: true
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
})
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Railscope::Engine.routes.draw do
|
|
4
|
+
# API endpoints
|
|
5
|
+
namespace :api do
|
|
6
|
+
resources :entries, only: %i[index show destroy] do
|
|
7
|
+
collection do
|
|
8
|
+
get "batch/:batch_id", action: :batch, as: :batch
|
|
9
|
+
get "family/:family_hash", action: :family, as: :family
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Serve React SPA for all other routes
|
|
15
|
+
get "*path", to: "application#index", constraints: ->(req) { !req.path.start_with?("/api") }
|
|
16
|
+
root to: "application#index"
|
|
17
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateRailscopeEntries < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :railscope_entries do |t|
|
|
6
|
+
# UUID as public identifier
|
|
7
|
+
t.uuid :uuid, default: "gen_random_uuid()", null: false
|
|
8
|
+
|
|
9
|
+
# Batch ID groups all entries from a single request/job
|
|
10
|
+
t.uuid :batch_id
|
|
11
|
+
|
|
12
|
+
# Family hash groups similar entries (e.g., same SQL pattern)
|
|
13
|
+
t.string :family_hash
|
|
14
|
+
|
|
15
|
+
# Entry type (request, query, exception, job_perform, etc.)
|
|
16
|
+
t.string :entry_type, null: false
|
|
17
|
+
|
|
18
|
+
# Payload data as JSONB
|
|
19
|
+
t.jsonb :payload, default: {}
|
|
20
|
+
|
|
21
|
+
# Tags array for categorization
|
|
22
|
+
t.string :tags, array: true, default: []
|
|
23
|
+
|
|
24
|
+
# Control visibility on index pages
|
|
25
|
+
t.boolean :should_display_on_index, default: true, null: false
|
|
26
|
+
|
|
27
|
+
# When the event occurred
|
|
28
|
+
t.datetime :occurred_at, null: false
|
|
29
|
+
|
|
30
|
+
t.timestamps
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
add_index :railscope_entries, :uuid, unique: true
|
|
34
|
+
add_index :railscope_entries, :batch_id
|
|
35
|
+
add_index :railscope_entries, :family_hash
|
|
36
|
+
add_index :railscope_entries, :entry_type
|
|
37
|
+
add_index :railscope_entries, :occurred_at
|
|
38
|
+
add_index :railscope_entries, :tags, using: :gin
|
|
39
|
+
add_index :railscope_entries, %i[entry_type should_display_on_index], name: "idx_railscope_type_displayable"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/base"
|
|
5
|
+
|
|
6
|
+
module Railscope
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
desc "Creates a Railscope initializer and mounts the engine."
|
|
12
|
+
|
|
13
|
+
def copy_initializer
|
|
14
|
+
template "initializer.rb", "config/initializers/railscope.rb"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def mount_engine
|
|
18
|
+
route 'mount Railscope::Engine, at: "/railscope"'
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def show_post_install_message
|
|
22
|
+
say ""
|
|
23
|
+
say "Railscope installed successfully!", :green
|
|
24
|
+
say ""
|
|
25
|
+
say "Next steps:"
|
|
26
|
+
say " 1. Run migrations: rails db:migrate"
|
|
27
|
+
say " 2. Enable Railscope: add RAILSCOPE_ENABLED=true to your .env"
|
|
28
|
+
say " 3. Start your server and visit /railscope"
|
|
29
|
+
say ""
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Railscope Configuration
|
|
4
|
+
# =======================
|
|
5
|
+
#
|
|
6
|
+
# Railscope is disabled by default. Enable it by setting
|
|
7
|
+
# the RAILSCOPE_ENABLED environment variable:
|
|
8
|
+
#
|
|
9
|
+
# RAILSCOPE_ENABLED=true
|
|
10
|
+
#
|
|
11
|
+
|
|
12
|
+
Railscope.configure do |config|
|
|
13
|
+
# Retention Period
|
|
14
|
+
# ----------------
|
|
15
|
+
# Number of days to keep entries before purging.
|
|
16
|
+
# Can also be set via RAILSCOPE_RETENTION_DAYS env var.
|
|
17
|
+
#
|
|
18
|
+
# config.retention_days = 7
|
|
19
|
+
|
|
20
|
+
# Ignored Paths
|
|
21
|
+
# -------------
|
|
22
|
+
# Requests to these paths will not be recorded.
|
|
23
|
+
# Default: /railscope, /assets, /packs, /cable
|
|
24
|
+
#
|
|
25
|
+
# config.add_ignore_paths("/health", "/ping", "/metrics")
|
|
26
|
+
|
|
27
|
+
# Sensitive Keys
|
|
28
|
+
# --------------
|
|
29
|
+
# Additional parameter names to filter from payloads.
|
|
30
|
+
# By default, common sensitive keys are filtered (password, token, etc.)
|
|
31
|
+
# plus everything in Rails.application.config.filter_parameters
|
|
32
|
+
#
|
|
33
|
+
# config.add_sensitive_keys(:cpf, :ssn, :bank_account)
|
|
34
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railscope
|
|
4
|
+
class Context
|
|
5
|
+
THREAD_KEY = :railscope_context
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def current
|
|
9
|
+
Thread.current[THREAD_KEY] ||= new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def clear!
|
|
13
|
+
Thread.current[THREAD_KEY] = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def with(**attributes)
|
|
17
|
+
previous = current.to_h.dup
|
|
18
|
+
attributes.each { |key, value| current[key] = value }
|
|
19
|
+
yield
|
|
20
|
+
ensure
|
|
21
|
+
clear!
|
|
22
|
+
previous.each { |key, value| current[key] = value }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize
|
|
27
|
+
@store = {}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def []=(key, value)
|
|
31
|
+
@store[key.to_sym] = value
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def [](key)
|
|
35
|
+
@store[key.to_sym]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def fetch(key, default = nil)
|
|
39
|
+
@store.fetch(key.to_sym, default)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def merge!(hash)
|
|
43
|
+
hash.each { |key, value| self[key] = value }
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_h
|
|
48
|
+
@store.dup
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Batch ID groups all entries from a single request/job
|
|
52
|
+
def batch_id
|
|
53
|
+
self[:batch_id] ||= SecureRandom.uuid
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def batch_id=(value)
|
|
57
|
+
self[:batch_id] = value
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Request ID from Rails (for correlation with Rails logs)
|
|
61
|
+
def request_id
|
|
62
|
+
self[:request_id]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def request_id=(value)
|
|
66
|
+
self[:request_id] = value
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def tags
|
|
70
|
+
self[:tags] ||= []
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def add_tag(tag)
|
|
74
|
+
tags << tag unless tags.include?(tag)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def add_tags(*new_tags)
|
|
78
|
+
new_tags.flatten.each { |tag| add_tag(tag) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def user_id
|
|
82
|
+
self[:user_id]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def user_id=(value)
|
|
86
|
+
self[:user_id] = value
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
delegate :empty?, to: :@store
|
|
90
|
+
end
|
|
91
|
+
end
|