railscope 0.1.0 → 0.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20e48ad7fe43ee5328e378275905e48d2143baa04911db450d21e1d15e1003db
4
- data.tar.gz: 4b38dabf84090b06b4b8df241501a1d8cc8efb4186352e4e4146be07cc8b94e1
3
+ metadata.gz: dadd56f657d2b5a308c35d1703985824c29bf51e7063a6199c6ec3c3f799ce5c
4
+ data.tar.gz: b68d74eabff7abe3fd12022a6774d13ec558c2034382de2a408ff7fa47c8a006
5
5
  SHA512:
6
- metadata.gz: '09038c22c62ec87672ce438011f9e1e6daea7e3528edba9deae60a3c962330124dcf74ea928b93b4bd1d909876c1b55c9f225f3fb37c93cd56a2e909a469fdb1'
7
- data.tar.gz: 002a7e89bd8c83bcd79287c6dbe17ba55e15cceebcb126d00f7b1030714061244b54147429d22301d6ba7c6d683e00aea913b3b4cc85c4147b1e9f16ba474ff3
6
+ metadata.gz: c88511328083da1c08c632b90265b8632b1f4a52d1a94bd1cc06b9adfd97896742566c7f9b56cd0c4fac0c38acbf5e77c81ad33c00945157cadae4a2d525d821
7
+ data.tar.gz: a814a913eb8b326b6c3ddfc17f563b38c898fd9f018144cdc8b4a8abcffcf3e82caae1ad708c359a91873cbc8aa0980658fc8bafaf7b8f27c4c6083e44dc854c
data/client/src/App.tsx CHANGED
@@ -14,6 +14,7 @@ import DumpsIndex from './screens/dumps/Index'
14
14
  import QueriesIndex from './screens/queries/Index'
15
15
  import QueriesShow from './screens/queries/Show'
16
16
  import ModelsIndex from './screens/models/Index'
17
+ import ModelsShow from './screens/models/Show'
17
18
  import EventsIndex from './screens/events/Index'
18
19
  import MailIndex from './screens/mail/Index'
19
20
  import NotificationsIndex from './screens/notifications/Index'
@@ -43,6 +44,7 @@ function App() {
43
44
  <Route path="/queries" element={<QueriesIndex />} />
44
45
  <Route path="/queries/:id" element={<QueriesShow />} />
45
46
  <Route path="/models" element={<ModelsIndex />} />
47
+ <Route path="/models/:id" element={<ModelsShow />} />
46
48
  <Route path="/events" element={<EventsIndex />} />
47
49
  <Route path="/mail" element={<MailIndex />} />
48
50
  <Route path="/notifications" element={<NotificationsIndex />} />
@@ -369,6 +369,7 @@ function getEntryPath(entry: Entry): string {
369
369
  case 'job_enqueue':
370
370
  case 'job_perform': return `/jobs/${entry.id}`
371
371
  case 'request': return `/requests/${entry.id}`
372
+ case 'model': return `/models/${entry.id}`
372
373
  default: return `/${entry.entry_type}s/${entry.id}`
373
374
  }
374
375
  }
@@ -380,6 +380,7 @@ function getEntryPath(entry: Entry): string {
380
380
  case 'job_perform': return `/jobs/${entry.id}`
381
381
  case 'request': return `/requests/${entry.id}`
382
382
  case 'command': return `/commands/${entry.id}`
383
+ case 'model': return `/models/${entry.id}`
383
384
  default: return `/${entry.entry_type}s/${entry.id}`
384
385
  }
385
386
  }
@@ -395,6 +396,8 @@ function getEntryDescription(entry: Entry, payload: Record<string, unknown>): st
395
396
  case 'job_enqueue':
396
397
  case 'job_perform':
397
398
  return `Job: ${payload.job_class}`
399
+ case 'model':
400
+ return `${payload.action} ${payload.model}`
398
401
  default:
399
402
  return entry.entry_type
400
403
  }
@@ -484,6 +484,7 @@ function getEntryPath(entry: Entry): string {
484
484
  case 'job_perform': return `/jobs/${entry.id}`
485
485
  case 'request': return `/requests/${entry.id}`
486
486
  case 'command': return `/commands/${entry.id}`
487
+ case 'model': return `/models/${entry.id}`
487
488
  default: return `/${entry.entry_type}s/${entry.id}`
488
489
  }
489
490
  }
@@ -501,6 +502,8 @@ function getEntryDescription(entry: Entry, payload: Record<string, unknown>): st
501
502
  case 'job_enqueue':
502
503
  case 'job_perform':
503
504
  return `Job: ${payload.job_class}`
505
+ case 'model':
506
+ return `${payload.action} ${payload.model}`
504
507
  default:
505
508
  return entry.entry_type
506
509
  }
@@ -1,15 +1,167 @@
1
- import PlaceholderPage from '@/components/PlaceholderPage'
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 } 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
+ type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'info' | 'purple'
13
+
14
+ const actionVariant: Record<string, BadgeVariant> = {
15
+ created: 'success',
16
+ updated: 'info',
17
+ deleted: 'error',
18
+ }
2
19
 
3
20
  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>
21
+ const navigate = useNavigate()
22
+ const [searchParams, setSearchParams] = useSearchParams()
23
+ const [entries, setEntries] = useState<Entry[]>([])
24
+ const [loading, setLoading] = useState(true)
25
+ const [page, setPage] = useState(1)
26
+ const [totalPages, setTotalPages] = useState(1)
27
+
28
+ const tagFilter = searchParams.get('tag') || ''
29
+ const familyHashFilter = searchParams.get('family_hash') || ''
30
+
31
+ useEffect(() => {
32
+ loadEntries()
33
+ }, [page, tagFilter, familyHashFilter])
34
+
35
+ async function loadEntries() {
36
+ setLoading(true)
37
+ try {
38
+ if (familyHashFilter) {
39
+ const response = await getFamilyEntries(familyHashFilter, page)
40
+ setEntries(response.data)
41
+ setTotalPages(response.meta.total_pages)
42
+ } else {
43
+ const response = await getEntries({
44
+ type: 'model',
45
+ tag: tagFilter || undefined,
46
+ page
47
+ })
48
+ setEntries(response.data)
49
+ setTotalPages(response.meta.total_pages)
12
50
  }
13
- />
51
+ } catch (error) {
52
+ console.error('Failed to load entries:', error)
53
+ } finally {
54
+ setLoading(false)
55
+ }
56
+ }
57
+
58
+ function handleSearch(value: string) {
59
+ setPage(1)
60
+ if (value) {
61
+ setSearchParams({ tag: value })
62
+ } else {
63
+ setSearchParams({})
64
+ }
65
+ }
66
+
67
+ function handlePageChange(newPage: number) {
68
+ setPage(newPage)
69
+ }
70
+
71
+ function clearFamilyFilter() {
72
+ setSearchParams({})
73
+ }
74
+
75
+ return (
76
+ <div className="p-6">
77
+ <div className="mb-6">
78
+ <h1 className="text-2xl font-semibold text-white">Models</h1>
79
+ <p className="text-dark-muted text-sm mt-1">
80
+ {familyHashFilter
81
+ ? 'Viewing similar model events'
82
+ : 'ActiveRecord model events (create, update, delete)'}
83
+ </p>
84
+ </div>
85
+
86
+ {familyHashFilter ? (
87
+ <div className="mb-4">
88
+ <button
89
+ onClick={clearFamilyFilter}
90
+ className="text-blue-400 hover:text-blue-300 text-sm"
91
+ >
92
+ &larr; Back to all models
93
+ </button>
94
+ </div>
95
+ ) : (
96
+ <div className="mb-4">
97
+ <SearchInput
98
+ placeholder="Search by tag (created, updated, deleted, user...)"
99
+ value={tagFilter}
100
+ onChange={handleSearch}
101
+ className="max-w-sm"
102
+ />
103
+ </div>
104
+ )}
105
+
106
+ <Card>
107
+ <Table>
108
+ <TableHeader>
109
+ <TableRow>
110
+ <TableHead>Model</TableHead>
111
+ <TableHead>Action</TableHead>
112
+ <TableHead>Tags</TableHead>
113
+ <TableHead>Happened</TableHead>
114
+ </TableRow>
115
+ </TableHeader>
116
+ <TableBody>
117
+ {loading ? (
118
+ <TableRow>
119
+ <TableCell className="text-center text-dark-muted py-8" colSpan={4}>
120
+ Loading...
121
+ </TableCell>
122
+ </TableRow>
123
+ ) : entries.length === 0 ? (
124
+ <TableRow>
125
+ <TableCell className="text-center text-dark-muted py-8" colSpan={4}>
126
+ {familyHashFilter
127
+ ? 'No similar model events found.'
128
+ : tagFilter
129
+ ? `No model events found with tag "${tagFilter}".`
130
+ : 'No model events recorded yet.'}
131
+ </TableCell>
132
+ </TableRow>
133
+ ) : (
134
+ entries.map((entry) => {
135
+ const payload = entry.payload as Record<string, unknown>
136
+ const action = String(payload.action || '')
137
+ return (
138
+ <TableRow key={entry.id} onClick={() => navigate(`/models/${entry.id}`)}>
139
+ <TableCell className="font-mono text-sm">
140
+ {String(payload.model || '')}
141
+ </TableCell>
142
+ <TableCell>
143
+ <Badge variant={actionVariant[action] || 'default'}>
144
+ {action}
145
+ </Badge>
146
+ </TableCell>
147
+ <TableCell>
148
+ <div className="flex gap-1">
149
+ {entry.tags.slice(0, 3).map((tag) => (
150
+ <Badge key={tag} variant="default">{tag}</Badge>
151
+ ))}
152
+ </div>
153
+ </TableCell>
154
+ <TableCell className="text-dark-muted" title={entry.occurred_at}>
155
+ {timeAgo(entry.occurred_at)}
156
+ </TableCell>
157
+ </TableRow>
158
+ )
159
+ })
160
+ )}
161
+ </TableBody>
162
+ </Table>
163
+ <Pagination currentPage={page} totalPages={totalPages} onPageChange={handlePageChange} />
164
+ </Card>
165
+ </div>
14
166
  )
15
167
  }
@@ -0,0 +1,289 @@
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
+
8
+ type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'info' | 'purple'
9
+
10
+ const actionVariant: Record<string, BadgeVariant> = {
11
+ created: 'success',
12
+ updated: 'info',
13
+ deleted: 'error',
14
+ }
15
+
16
+ export default function ModelsShow() {
17
+ const { id } = useParams()
18
+ const navigate = useNavigate()
19
+ const [entry, setEntry] = useState<Entry | null>(null)
20
+ const [batch, setBatch] = useState<Entry[]>([])
21
+ const [loading, setLoading] = useState(true)
22
+
23
+ useEffect(() => {
24
+ loadEntry()
25
+ }, [id])
26
+
27
+ async function loadEntry() {
28
+ if (!id) return
29
+ setLoading(true)
30
+ try {
31
+ const response = await getEntry(id)
32
+ setEntry(response.data)
33
+ setBatch(response.batch || [])
34
+ } catch (error) {
35
+ console.error('Failed to load entry:', error)
36
+ } finally {
37
+ setLoading(false)
38
+ }
39
+ }
40
+
41
+ if (loading) {
42
+ return (
43
+ <div className="p-6">
44
+ <Card>
45
+ <CardContent className="py-12 text-center text-dark-muted">
46
+ Loading...
47
+ </CardContent>
48
+ </Card>
49
+ </div>
50
+ )
51
+ }
52
+
53
+ if (!entry) {
54
+ return (
55
+ <div className="p-6">
56
+ <Card>
57
+ <CardContent className="py-12 text-center text-dark-muted">
58
+ Model event not found.
59
+ </CardContent>
60
+ </Card>
61
+ </div>
62
+ )
63
+ }
64
+
65
+ const payload = entry.payload as Record<string, unknown>
66
+ const action = String(payload.action || '')
67
+ const changes = payload.changes as Record<string, unknown> | undefined
68
+
69
+ const formattedTime = new Date(entry.occurred_at).toLocaleString('en-US', {
70
+ year: 'numeric',
71
+ month: 'long',
72
+ day: 'numeric',
73
+ hour: 'numeric',
74
+ minute: '2-digit',
75
+ second: '2-digit',
76
+ hour12: true
77
+ })
78
+ const timeAgoStr = getTimeAgo(entry.occurred_at)
79
+
80
+ return (
81
+ <div className="p-6 space-y-5">
82
+ {/* Model Details Card */}
83
+ <Card>
84
+ <CardHeader>
85
+ <CardTitle>Model Details</CardTitle>
86
+ </CardHeader>
87
+ <CardContent className="p-0">
88
+ <table className="w-full">
89
+ <tbody>
90
+ <tr className="border-t border-dark-border">
91
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap w-32">Model</td>
92
+ <td className="px-4 py-3 font-mono">{String(payload.model || '')}</td>
93
+ </tr>
94
+ <tr className="border-t border-dark-border">
95
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">Action</td>
96
+ <td className="px-4 py-3">
97
+ <Badge variant={actionVariant[action] || 'default'}>
98
+ {action}
99
+ </Badge>
100
+ </td>
101
+ </tr>
102
+ <tr className="border-t border-dark-border">
103
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">Time</td>
104
+ <td className="px-4 py-3">{formattedTime} ({timeAgoStr})</td>
105
+ </tr>
106
+ <tr className="border-t border-dark-border">
107
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">Occurrences</td>
108
+ <td className="px-4 py-3">
109
+ <Link
110
+ to={`/models?family_hash=${entry.family_hash}`}
111
+ className="text-blue-400 hover:text-blue-300"
112
+ >
113
+ View Similar Events
114
+ </Link>
115
+ </td>
116
+ </tr>
117
+ </tbody>
118
+ </table>
119
+ </CardContent>
120
+ </Card>
121
+
122
+ {/* Changes Card */}
123
+ {action !== 'deleted' && changes && Object.keys(changes).length > 0 && (
124
+ <Card>
125
+ <div className="flex border-b border-dark-border">
126
+ <span className="px-4 py-2.5 text-sm font-medium bg-blue-500 text-white">
127
+ Changes
128
+ </span>
129
+ </div>
130
+ <div className="bg-[#1a1a2e] p-4 overflow-x-auto">
131
+ <pre className="font-mono text-sm text-purple-300 whitespace-pre-wrap">
132
+ {JSON.stringify(changes, null, 2)}
133
+ </pre>
134
+ </div>
135
+ </Card>
136
+ )}
137
+
138
+ {/* Related Entries */}
139
+ {batch.length > 0 && <RelatedEntries entries={batch} navigate={navigate} />}
140
+ </div>
141
+ )
142
+ }
143
+
144
+ function getTimeAgo(date: string): string {
145
+ const seconds = Math.floor((new Date().getTime() - new Date(date).getTime()) / 1000)
146
+ if (seconds < 60) return `${seconds}s ago`
147
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
148
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`
149
+ return `${Math.floor(seconds / 86400)}d ago`
150
+ }
151
+
152
+ interface RelatedEntriesProps {
153
+ entries: Entry[]
154
+ navigate: ReturnType<typeof useNavigate>
155
+ }
156
+
157
+ function RelatedEntries({ entries, navigate }: RelatedEntriesProps) {
158
+ const [currentTab, setCurrentTab] = useState<string>('')
159
+
160
+ const groupedEntries = entries.reduce((acc, entry) => {
161
+ const type = entry.entry_type
162
+ if (!acc[type]) acc[type] = []
163
+ acc[type].push(entry)
164
+ return acc
165
+ }, {} as Record<string, Entry[]>)
166
+
167
+ const tabs = Object.entries(groupedEntries).map(([type, items]) => ({
168
+ type,
169
+ label: getTypeLabel(type),
170
+ count: items.length
171
+ }))
172
+
173
+ useEffect(() => {
174
+ if (tabs.length > 0 && !currentTab) {
175
+ setCurrentTab(tabs[0].type)
176
+ }
177
+ }, [tabs.length])
178
+
179
+ if (tabs.length === 0) return null
180
+
181
+ const currentEntries = groupedEntries[currentTab] || []
182
+
183
+ return (
184
+ <Card>
185
+ <CardHeader>
186
+ <CardTitle>Related Entries</CardTitle>
187
+ </CardHeader>
188
+ <div className="flex flex-wrap border-b border-dark-border">
189
+ {tabs.map((tab) => (
190
+ <button
191
+ key={tab.type}
192
+ onClick={() => setCurrentTab(tab.type)}
193
+ className={`px-4 py-2.5 text-sm font-medium ${
194
+ currentTab === tab.type
195
+ ? 'bg-blue-500 text-white'
196
+ : 'text-dark-muted hover:text-dark-text'
197
+ }`}
198
+ >
199
+ {tab.label} ({tab.count})
200
+ </button>
201
+ ))}
202
+ </div>
203
+ <table className="w-full">
204
+ <tbody>
205
+ {currentEntries.map((relatedEntry) => {
206
+ const relPayload = relatedEntry.payload as Record<string, unknown>
207
+ const path = getEntryPath(relatedEntry)
208
+
209
+ return (
210
+ <tr
211
+ key={relatedEntry.id}
212
+ onClick={() => navigate(path)}
213
+ className="border-b border-dark-border hover:bg-white/[0.02] cursor-pointer"
214
+ >
215
+ <td className="px-4 py-3">
216
+ <span className="text-dark-muted text-sm">{getEntryDescription(relatedEntry, relPayload)}</span>
217
+ </td>
218
+ <td className="px-4 py-3 w-12">
219
+ <svg className="w-5 h-5 text-dark-muted" viewBox="0 0 20 20" fill="currentColor">
220
+ <path
221
+ fillRule="evenodd"
222
+ 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"
223
+ clipRule="evenodd"
224
+ />
225
+ </svg>
226
+ </td>
227
+ </tr>
228
+ )
229
+ })}
230
+ </tbody>
231
+ </table>
232
+ </Card>
233
+ )
234
+ }
235
+
236
+ function getEntryPath(entry: Entry): string {
237
+ switch (entry.entry_type) {
238
+ case 'query': return `/queries/${entry.id}`
239
+ case 'exception': return `/exceptions/${entry.id}`
240
+ case 'job_enqueue':
241
+ case 'job_perform': return `/jobs/${entry.id}`
242
+ case 'request': return `/requests/${entry.id}`
243
+ case 'command': return `/commands/${entry.id}`
244
+ case 'model': return `/models/${entry.id}`
245
+ default: return `/${entry.entry_type}s/${entry.id}`
246
+ }
247
+ }
248
+
249
+ function getEntryDescription(entry: Entry, payload: Record<string, unknown>): string {
250
+ switch (entry.entry_type) {
251
+ case 'query':
252
+ return String(payload.sql || '').substring(0, 80)
253
+ case 'request':
254
+ return `${payload.method} ${payload.path}`
255
+ case 'command':
256
+ return `Command: ${payload.command}`
257
+ case 'exception':
258
+ return `${payload.class}: ${String(payload.message || '').substring(0, 50)}`
259
+ case 'job_enqueue':
260
+ case 'job_perform':
261
+ return `Job: ${payload.job_class}`
262
+ case 'model':
263
+ return `${payload.action} ${payload.model}`
264
+ default:
265
+ return entry.entry_type
266
+ }
267
+ }
268
+
269
+ function getTypeLabel(type: string): string {
270
+ const labels: Record<string, string> = {
271
+ query: 'Queries',
272
+ exception: 'Exceptions',
273
+ job_enqueue: 'Jobs',
274
+ job_perform: 'Jobs',
275
+ request: 'Requests',
276
+ command: 'Commands',
277
+ log: 'Logs',
278
+ cache: 'Cache',
279
+ event: 'Events',
280
+ mail: 'Mail',
281
+ notification: 'Notifications',
282
+ model: 'Models',
283
+ gate: 'Gates',
284
+ redis: 'Redis',
285
+ view: 'Views',
286
+ client_request: 'HTTP Client'
287
+ }
288
+ return labels[type] || type
289
+ }
@@ -301,6 +301,7 @@ function getEntryPath(entry: Entry): string {
301
301
  case 'job_perform': return `/jobs/${entry.id}`
302
302
  case 'request': return `/requests/${entry.id}`
303
303
  case 'command': return `/commands/${entry.id}`
304
+ case 'model': return `/models/${entry.id}`
304
305
  default: return `/${entry.entry_type}s/${entry.id}`
305
306
  }
306
307
  }
@@ -318,6 +319,8 @@ function getEntryDescription(entry: Entry, payload: Record<string, unknown>): st
318
319
  case 'job_enqueue':
319
320
  case 'job_perform':
320
321
  return `Job: ${payload.job_class}`
322
+ case 'model':
323
+ return `${payload.action} ${payload.model}`
321
324
  default:
322
325
  return entry.entry_type
323
326
  }
@@ -347,6 +347,7 @@ function getEntryPath(entry: Entry): string {
347
347
  case 'job_perform': return `/jobs/${entry.id}`
348
348
  case 'request': return `/requests/${entry.id}`
349
349
  case 'command': return `/commands/${entry.id}`
350
+ case 'model': return `/models/${entry.id}`
350
351
  case 'view': return `/views/${entry.id}`
351
352
  default: return `/${entry.entry_type}s/${entry.id}`
352
353
  }
@@ -365,6 +366,8 @@ function getEntryDescription(entry: Entry, payload: Record<string, unknown>): st
365
366
  case 'job_enqueue':
366
367
  case 'job_perform':
367
368
  return `Job: ${payload.job_class}`
369
+ case 'model':
370
+ return `${payload.action} ${payload.model}`
368
371
  case 'view':
369
372
  return `${payload.view_type}: ${payload.name || payload.path}`
370
373
  default:
@@ -289,6 +289,7 @@ function getEntryPath(entry: Entry): string {
289
289
  case 'job_perform': return `/jobs/${entry.id}`
290
290
  case 'request': return `/requests/${entry.id}`
291
291
  case 'command': return `/commands/${entry.id}`
292
+ case 'model': return `/models/${entry.id}`
292
293
  case 'view': return `/views/${entry.id}`
293
294
  default: return `/${entry.entry_type}s/${entry.id}`
294
295
  }
@@ -307,6 +308,8 @@ function getEntryDescription(entry: Entry, payload: Record<string, unknown>): st
307
308
  case 'job_enqueue':
308
309
  case 'job_perform':
309
310
  return `Job: ${payload.job_class}`
311
+ case 'model':
312
+ return `${payload.action} ${payload.model}`
310
313
  case 'view':
311
314
  return `${payload.view_type}: ${payload.name || payload.path}`
312
315
  default:
@@ -24,7 +24,8 @@ module Railscope
24
24
  say ""
25
25
  say "Next steps:"
26
26
  say " 1. Run migrations: rails db:migrate"
27
- say " 2. Enable Railscope: add RAILSCOPE_ENABLED=true to your .env"
27
+ say " 2. Enable Railscope: set RAILSCOPE_ENABLED=true in your environment"
28
+ say " or set config.enabled = true in config/initializers/railscope.rb"
28
29
  say " 3. Start your server and visit /railscope"
29
30
  say ""
30
31
  end
@@ -3,13 +3,23 @@
3
3
  # Railscope Configuration
4
4
  # =======================
5
5
  #
6
- # Railscope is disabled by default. Enable it by setting
7
- # the RAILSCOPE_ENABLED environment variable:
6
+ # Railscope is disabled by default. Enable it via environment variable:
8
7
  #
9
8
  # RAILSCOPE_ENABLED=true
10
9
  #
10
+ # Or programmatically (useful with application.yml, credentials, etc.):
11
+ #
12
+ # config.enabled = true
13
+ #
11
14
 
12
15
  Railscope.configure do |config|
16
+ # Enable/Disable
17
+ # --------------
18
+ # Set this directly if your project doesn't use ENV variables
19
+ # (e.g., application.yml without Figaro, Rails credentials, etc.)
20
+ #
21
+ # config.enabled = true
22
+
13
23
  # Retention Period
14
24
  # ----------------
15
25
  # Number of days to keep entries before purging.
@@ -7,6 +7,7 @@ require_relative "subscribers/exception_subscriber"
7
7
  require_relative "subscribers/job_subscriber"
8
8
  require_relative "subscribers/command_subscriber"
9
9
  require_relative "subscribers/view_subscriber"
10
+ require_relative "subscribers/model_subscriber"
10
11
 
11
12
  module Railscope
12
13
  class Engine < ::Rails::Engine
@@ -58,9 +59,11 @@ module Railscope
58
59
  # ActiveRecord subscribers
59
60
  if defined?(ActiveRecord::Base)
60
61
  Railscope::Subscribers::QuerySubscriber.subscribe
62
+ Railscope::Subscribers::ModelSubscriber.subscribe
61
63
  else
62
64
  ActiveSupport.on_load(:active_record) do
63
65
  Railscope::Subscribers::QuerySubscriber.subscribe
66
+ Railscope::Subscribers::ModelSubscriber.subscribe
64
67
  end
65
68
  end
66
69