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 +4 -4
- data/client/src/App.tsx +2 -0
- data/client/src/screens/commands/Show.tsx +1 -0
- data/client/src/screens/exceptions/Show.tsx +3 -0
- data/client/src/screens/jobs/Show.tsx +3 -0
- data/client/src/screens/models/Index.tsx +162 -10
- data/client/src/screens/models/Show.tsx +289 -0
- data/client/src/screens/queries/Show.tsx +3 -0
- data/client/src/screens/requests/Show.tsx +3 -0
- data/client/src/screens/views/Show.tsx +3 -0
- data/lib/generators/railscope/install_generator.rb +2 -1
- data/lib/generators/railscope/templates/initializer.rb +12 -2
- data/lib/railscope/engine.rb +3 -0
- data/lib/railscope/subscribers/model_subscriber.rb +84 -0
- data/lib/railscope/version.rb +1 -1
- data/lib/railscope.rb +9 -1
- data/public/railscope/assets/app.js +13 -13
- metadata +7 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dadd56f657d2b5a308c35d1703985824c29bf51e7063a6199c6ec3c3f799ce5c
|
|
4
|
+
data.tar.gz: b68d74eabff7abe3fd12022a6774d13ec558c2034382de2a408ff7fa47c8a006
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
+
← 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:
|
|
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
|
|
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.
|
data/lib/railscope/engine.rb
CHANGED
|
@@ -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
|
|