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,159 @@
|
|
|
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, truncate } 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 QueriesIndex() {
|
|
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
|
+
|
|
20
|
+
const tagFilter = searchParams.get('tag') || ''
|
|
21
|
+
const familyHashFilter = searchParams.get('family_hash') || ''
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
loadEntries()
|
|
25
|
+
}, [page, tagFilter, familyHashFilter])
|
|
26
|
+
|
|
27
|
+
async function loadEntries() {
|
|
28
|
+
setLoading(true)
|
|
29
|
+
try {
|
|
30
|
+
if (familyHashFilter) {
|
|
31
|
+
const response = await getFamilyEntries(familyHashFilter, page)
|
|
32
|
+
setEntries(response.data)
|
|
33
|
+
setTotalPages(response.meta.total_pages)
|
|
34
|
+
} else {
|
|
35
|
+
const response = await getEntries({
|
|
36
|
+
type: 'query',
|
|
37
|
+
tag: tagFilter || undefined,
|
|
38
|
+
page
|
|
39
|
+
})
|
|
40
|
+
setEntries(response.data)
|
|
41
|
+
setTotalPages(response.meta.total_pages)
|
|
42
|
+
}
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error('Failed to load entries:', error)
|
|
45
|
+
} finally {
|
|
46
|
+
setLoading(false)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function handleSearch(value: string) {
|
|
51
|
+
setPage(1)
|
|
52
|
+
if (value) {
|
|
53
|
+
setSearchParams({ tag: value })
|
|
54
|
+
} else {
|
|
55
|
+
setSearchParams({})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function handlePageChange(newPage: number) {
|
|
60
|
+
setPage(newPage)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function clearFamilyFilter() {
|
|
64
|
+
setSearchParams({})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="p-6">
|
|
69
|
+
<div className="mb-6">
|
|
70
|
+
<h1 className="text-2xl font-semibold text-white">Queries</h1>
|
|
71
|
+
<p className="text-dark-muted text-sm mt-1">
|
|
72
|
+
{familyHashFilter
|
|
73
|
+
? 'Viewing similar queries'
|
|
74
|
+
: 'SQL queries executed by your application'}
|
|
75
|
+
</p>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
{familyHashFilter ? (
|
|
79
|
+
<div className="mb-4">
|
|
80
|
+
<button
|
|
81
|
+
onClick={clearFamilyFilter}
|
|
82
|
+
className="text-blue-400 hover:text-blue-300 text-sm"
|
|
83
|
+
>
|
|
84
|
+
← Back to all queries
|
|
85
|
+
</button>
|
|
86
|
+
</div>
|
|
87
|
+
) : (
|
|
88
|
+
<div className="mb-4">
|
|
89
|
+
<SearchInput
|
|
90
|
+
placeholder="Search by tag (slow, select, insert...)"
|
|
91
|
+
value={tagFilter}
|
|
92
|
+
onChange={handleSearch}
|
|
93
|
+
className="max-w-sm"
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
<Card>
|
|
99
|
+
<Table>
|
|
100
|
+
<TableHeader>
|
|
101
|
+
<TableRow>
|
|
102
|
+
<TableHead>Query</TableHead>
|
|
103
|
+
<TableHead className="text-right">Duration</TableHead>
|
|
104
|
+
<TableHead>Tags</TableHead>
|
|
105
|
+
<TableHead>Happened</TableHead>
|
|
106
|
+
</TableRow>
|
|
107
|
+
</TableHeader>
|
|
108
|
+
<TableBody>
|
|
109
|
+
{loading ? (
|
|
110
|
+
<TableRow>
|
|
111
|
+
<TableCell className="text-center text-dark-muted py-8" colSpan={4}>
|
|
112
|
+
Loading...
|
|
113
|
+
</TableCell>
|
|
114
|
+
</TableRow>
|
|
115
|
+
) : entries.length === 0 ? (
|
|
116
|
+
<TableRow>
|
|
117
|
+
<TableCell className="text-center text-dark-muted py-8" colSpan={4}>
|
|
118
|
+
{familyHashFilter
|
|
119
|
+
? 'No similar queries found.'
|
|
120
|
+
: tagFilter
|
|
121
|
+
? `No queries found with tag "${tagFilter}".`
|
|
122
|
+
: 'No queries recorded yet.'}
|
|
123
|
+
</TableCell>
|
|
124
|
+
</TableRow>
|
|
125
|
+
) : (
|
|
126
|
+
entries.map((entry) => {
|
|
127
|
+
const payload = entry.payload as Record<string, unknown>
|
|
128
|
+
const isSlow = entry.tags.includes('slow')
|
|
129
|
+
return (
|
|
130
|
+
<TableRow key={entry.id} onClick={() => navigate(`/queries/${entry.id}`)}>
|
|
131
|
+
<TableCell className="font-mono text-xs max-w-md">
|
|
132
|
+
<code className="text-dark-muted">
|
|
133
|
+
{truncate(String(payload.sql), 80)}
|
|
134
|
+
</code>
|
|
135
|
+
</TableCell>
|
|
136
|
+
<TableCell className={`text-right ${isSlow ? 'text-red-400' : 'text-dark-muted'}`}>
|
|
137
|
+
{String(payload.duration)}ms
|
|
138
|
+
</TableCell>
|
|
139
|
+
<TableCell>
|
|
140
|
+
<div className="flex gap-1">
|
|
141
|
+
{entry.tags.slice(0, 3).map((tag) => (
|
|
142
|
+
<Badge key={tag} variant={tag === 'slow' ? 'error' : 'default'}>{tag}</Badge>
|
|
143
|
+
))}
|
|
144
|
+
</div>
|
|
145
|
+
</TableCell>
|
|
146
|
+
<TableCell className="text-dark-muted" title={entry.occurred_at}>
|
|
147
|
+
{timeAgo(entry.occurred_at)}
|
|
148
|
+
</TableCell>
|
|
149
|
+
</TableRow>
|
|
150
|
+
)
|
|
151
|
+
})
|
|
152
|
+
)}
|
|
153
|
+
</TableBody>
|
|
154
|
+
</Table>
|
|
155
|
+
<Pagination currentPage={page} totalPages={totalPages} onPageChange={handlePageChange} />
|
|
156
|
+
</Card>
|
|
157
|
+
</div>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
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
|
+
|
|
7
|
+
export default function QueriesShow() {
|
|
8
|
+
const { id } = useParams()
|
|
9
|
+
const navigate = useNavigate()
|
|
10
|
+
const [entry, setEntry] = useState<Entry | null>(null)
|
|
11
|
+
const [batch, setBatch] = useState<Entry[]>([])
|
|
12
|
+
const [loading, setLoading] = useState(true)
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
loadEntry()
|
|
16
|
+
}, [id])
|
|
17
|
+
|
|
18
|
+
async function loadEntry() {
|
|
19
|
+
if (!id) return
|
|
20
|
+
setLoading(true)
|
|
21
|
+
try {
|
|
22
|
+
const response = await getEntry(id)
|
|
23
|
+
setEntry(response.data)
|
|
24
|
+
setBatch(response.batch || [])
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.error('Failed to load entry:', error)
|
|
27
|
+
} finally {
|
|
28
|
+
setLoading(false)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (loading) {
|
|
33
|
+
return (
|
|
34
|
+
<div className="p-6">
|
|
35
|
+
<Card>
|
|
36
|
+
<CardContent className="py-12 text-center text-dark-muted">
|
|
37
|
+
Loading...
|
|
38
|
+
</CardContent>
|
|
39
|
+
</Card>
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!entry) {
|
|
45
|
+
return (
|
|
46
|
+
<div className="p-6">
|
|
47
|
+
<Card>
|
|
48
|
+
<CardContent className="py-12 text-center text-dark-muted">
|
|
49
|
+
Query not found.
|
|
50
|
+
</CardContent>
|
|
51
|
+
</Card>
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const payload = entry.payload as Record<string, unknown>
|
|
57
|
+
const isSlow = entry.tags.includes('slow')
|
|
58
|
+
const isCached = Boolean(payload.cached)
|
|
59
|
+
|
|
60
|
+
const formattedTime = new Date(entry.occurred_at).toLocaleString('en-US', {
|
|
61
|
+
year: 'numeric',
|
|
62
|
+
month: 'long',
|
|
63
|
+
day: 'numeric',
|
|
64
|
+
hour: 'numeric',
|
|
65
|
+
minute: '2-digit',
|
|
66
|
+
second: '2-digit',
|
|
67
|
+
hour12: true
|
|
68
|
+
})
|
|
69
|
+
const timeAgo = getTimeAgo(entry.occurred_at)
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="p-6 space-y-5">
|
|
73
|
+
{/* Query Details Card */}
|
|
74
|
+
<Card>
|
|
75
|
+
<CardHeader>
|
|
76
|
+
<CardTitle>Query Details</CardTitle>
|
|
77
|
+
</CardHeader>
|
|
78
|
+
<CardContent className="p-0">
|
|
79
|
+
<table className="w-full">
|
|
80
|
+
<tbody>
|
|
81
|
+
<tr className="border-t border-dark-border">
|
|
82
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap w-32">Time</td>
|
|
83
|
+
<td className="px-4 py-3">{formattedTime} ({timeAgo})</td>
|
|
84
|
+
</tr>
|
|
85
|
+
{payload.connection ? (
|
|
86
|
+
<tr className="border-t border-dark-border">
|
|
87
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Connection</td>
|
|
88
|
+
<td className="px-4 py-3">{String(payload.connection)}</td>
|
|
89
|
+
</tr>
|
|
90
|
+
) : null}
|
|
91
|
+
{payload.name ? (
|
|
92
|
+
<tr className="border-t border-dark-border">
|
|
93
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Name</td>
|
|
94
|
+
<td className="px-4 py-3">{String(payload.name)}</td>
|
|
95
|
+
</tr>
|
|
96
|
+
) : null}
|
|
97
|
+
{payload.file ? (
|
|
98
|
+
<tr className="border-t border-dark-border">
|
|
99
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Location</td>
|
|
100
|
+
<td className="px-4 py-3 font-mono text-sm">
|
|
101
|
+
{String(payload.file)}:{String(payload.line)}
|
|
102
|
+
</td>
|
|
103
|
+
</tr>
|
|
104
|
+
) : null}
|
|
105
|
+
<tr className="border-t border-dark-border">
|
|
106
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Duration</td>
|
|
107
|
+
<td className="px-4 py-3">
|
|
108
|
+
{isSlow ? (
|
|
109
|
+
<span className="px-2 py-0.5 bg-red-500/20 text-red-400 text-sm rounded">
|
|
110
|
+
{String(payload.duration)}ms
|
|
111
|
+
</span>
|
|
112
|
+
) : (
|
|
113
|
+
<span>{String(payload.duration)}ms</span>
|
|
114
|
+
)}
|
|
115
|
+
</td>
|
|
116
|
+
</tr>
|
|
117
|
+
{isCached && (
|
|
118
|
+
<tr className="border-t border-dark-border">
|
|
119
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Cached</td>
|
|
120
|
+
<td className="px-4 py-3">
|
|
121
|
+
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-sm rounded">
|
|
122
|
+
Yes
|
|
123
|
+
</span>
|
|
124
|
+
</td>
|
|
125
|
+
</tr>
|
|
126
|
+
)}
|
|
127
|
+
{payload.row_count != null && (
|
|
128
|
+
<tr className="border-t border-dark-border">
|
|
129
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Row Count</td>
|
|
130
|
+
<td className="px-4 py-3">{String(payload.row_count)}</td>
|
|
131
|
+
</tr>
|
|
132
|
+
)}
|
|
133
|
+
<tr className="border-t border-dark-border">
|
|
134
|
+
<td className="px-4 py-3 text-dark-muted whitespace-nowrap">Occurrences</td>
|
|
135
|
+
<td className="px-4 py-3">
|
|
136
|
+
<Link
|
|
137
|
+
to={`/queries?family_hash=${entry.family_hash}`}
|
|
138
|
+
className="text-blue-400 hover:text-blue-300"
|
|
139
|
+
>
|
|
140
|
+
View Similar Queries
|
|
141
|
+
</Link>
|
|
142
|
+
</td>
|
|
143
|
+
</tr>
|
|
144
|
+
</tbody>
|
|
145
|
+
</table>
|
|
146
|
+
</CardContent>
|
|
147
|
+
</Card>
|
|
148
|
+
|
|
149
|
+
{/* Query SQL Card */}
|
|
150
|
+
<Card>
|
|
151
|
+
<div className="flex border-b border-dark-border">
|
|
152
|
+
<span className="px-4 py-2.5 text-sm font-medium bg-blue-500 text-white">
|
|
153
|
+
Query
|
|
154
|
+
</span>
|
|
155
|
+
</div>
|
|
156
|
+
<div className="bg-[#1a1a2e] p-4 overflow-x-auto">
|
|
157
|
+
<pre className="font-mono text-sm text-purple-300 whitespace-pre-wrap">
|
|
158
|
+
{formatSQL(String(payload.sql))}
|
|
159
|
+
</pre>
|
|
160
|
+
</div>
|
|
161
|
+
</Card>
|
|
162
|
+
|
|
163
|
+
{/* Related Entries */}
|
|
164
|
+
{batch.length > 0 && <RelatedEntries entries={batch} navigate={navigate} />}
|
|
165
|
+
</div>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Simple SQL formatter
|
|
170
|
+
function formatSQL(sql: string): string {
|
|
171
|
+
const keywords = [
|
|
172
|
+
'SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'JOIN', 'LEFT JOIN', 'RIGHT JOIN',
|
|
173
|
+
'INNER JOIN', 'OUTER JOIN', 'ON', 'ORDER BY', 'GROUP BY', 'HAVING',
|
|
174
|
+
'LIMIT', 'OFFSET', 'INSERT INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE FROM',
|
|
175
|
+
'CREATE TABLE', 'ALTER TABLE', 'DROP TABLE', 'INDEX', 'DISTINCT', 'AS',
|
|
176
|
+
'UNION', 'EXCEPT', 'INTERSECT', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END'
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
let formatted = sql
|
|
180
|
+
|
|
181
|
+
// Add newlines before major keywords
|
|
182
|
+
keywords.forEach(keyword => {
|
|
183
|
+
const regex = new RegExp(`\\b${keyword}\\b`, 'gi')
|
|
184
|
+
formatted = formatted.replace(regex, `\n${keyword}`)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// Clean up multiple newlines and trim
|
|
188
|
+
formatted = formatted.replace(/\n+/g, '\n').trim()
|
|
189
|
+
|
|
190
|
+
// Remove leading newline if starts with SELECT/INSERT/UPDATE/DELETE
|
|
191
|
+
if (formatted.startsWith('\n')) {
|
|
192
|
+
formatted = formatted.substring(1)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return formatted
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function getTimeAgo(date: string): string {
|
|
199
|
+
const seconds = Math.floor((new Date().getTime() - new Date(date).getTime()) / 1000)
|
|
200
|
+
if (seconds < 60) return `${seconds}s ago`
|
|
201
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
|
|
202
|
+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`
|
|
203
|
+
return `${Math.floor(seconds / 86400)}d ago`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
interface RelatedEntriesProps {
|
|
207
|
+
entries: Entry[]
|
|
208
|
+
navigate: ReturnType<typeof useNavigate>
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function RelatedEntries({ entries, navigate }: RelatedEntriesProps) {
|
|
212
|
+
const [currentTab, setCurrentTab] = useState<string>('')
|
|
213
|
+
|
|
214
|
+
const groupedEntries = entries.reduce((acc, entry) => {
|
|
215
|
+
const type = entry.entry_type
|
|
216
|
+
if (!acc[type]) acc[type] = []
|
|
217
|
+
acc[type].push(entry)
|
|
218
|
+
return acc
|
|
219
|
+
}, {} as Record<string, Entry[]>)
|
|
220
|
+
|
|
221
|
+
const tabs = Object.entries(groupedEntries).map(([type, items]) => ({
|
|
222
|
+
type,
|
|
223
|
+
label: getTypeLabel(type),
|
|
224
|
+
count: items.length
|
|
225
|
+
}))
|
|
226
|
+
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
if (tabs.length > 0 && !currentTab) {
|
|
229
|
+
setCurrentTab(tabs[0].type)
|
|
230
|
+
}
|
|
231
|
+
}, [tabs.length])
|
|
232
|
+
|
|
233
|
+
if (tabs.length === 0) return null
|
|
234
|
+
|
|
235
|
+
const currentEntries = groupedEntries[currentTab] || []
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<Card>
|
|
239
|
+
<CardHeader>
|
|
240
|
+
<CardTitle>Related Entries</CardTitle>
|
|
241
|
+
</CardHeader>
|
|
242
|
+
<div className="flex flex-wrap border-b border-dark-border">
|
|
243
|
+
{tabs.map((tab) => (
|
|
244
|
+
<button
|
|
245
|
+
key={tab.type}
|
|
246
|
+
onClick={() => setCurrentTab(tab.type)}
|
|
247
|
+
className={`px-4 py-2.5 text-sm font-medium ${
|
|
248
|
+
currentTab === tab.type
|
|
249
|
+
? 'bg-blue-500 text-white'
|
|
250
|
+
: 'text-dark-muted hover:text-dark-text'
|
|
251
|
+
}`}
|
|
252
|
+
>
|
|
253
|
+
{tab.label} ({tab.count})
|
|
254
|
+
</button>
|
|
255
|
+
))}
|
|
256
|
+
</div>
|
|
257
|
+
<table className="w-full">
|
|
258
|
+
<tbody>
|
|
259
|
+
{currentEntries.map((relatedEntry) => {
|
|
260
|
+
const relPayload = relatedEntry.payload as Record<string, unknown>
|
|
261
|
+
const path = getEntryPath(relatedEntry)
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<tr
|
|
265
|
+
key={relatedEntry.id}
|
|
266
|
+
onClick={() => navigate(path)}
|
|
267
|
+
className="border-b border-dark-border hover:bg-white/[0.02] cursor-pointer"
|
|
268
|
+
>
|
|
269
|
+
<td className="px-4 py-3">
|
|
270
|
+
<span className="text-dark-muted text-sm">{getEntryDescription(relatedEntry, relPayload)}</span>
|
|
271
|
+
</td>
|
|
272
|
+
<td className="px-4 py-3 w-12">
|
|
273
|
+
<ArrowIcon />
|
|
274
|
+
</td>
|
|
275
|
+
</tr>
|
|
276
|
+
)
|
|
277
|
+
})}
|
|
278
|
+
</tbody>
|
|
279
|
+
</table>
|
|
280
|
+
</Card>
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function ArrowIcon() {
|
|
285
|
+
return (
|
|
286
|
+
<svg className="w-5 h-5 text-dark-muted" viewBox="0 0 20 20" fill="currentColor">
|
|
287
|
+
<path
|
|
288
|
+
fillRule="evenodd"
|
|
289
|
+
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"
|
|
290
|
+
clipRule="evenodd"
|
|
291
|
+
/>
|
|
292
|
+
</svg>
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function getEntryPath(entry: Entry): string {
|
|
297
|
+
switch (entry.entry_type) {
|
|
298
|
+
case 'query': return `/queries/${entry.id}`
|
|
299
|
+
case 'exception': return `/exceptions/${entry.id}`
|
|
300
|
+
case 'job_enqueue':
|
|
301
|
+
case 'job_perform': return `/jobs/${entry.id}`
|
|
302
|
+
case 'request': return `/requests/${entry.id}`
|
|
303
|
+
case 'command': return `/commands/${entry.id}`
|
|
304
|
+
default: return `/${entry.entry_type}s/${entry.id}`
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function getEntryDescription(entry: Entry, payload: Record<string, unknown>): string {
|
|
309
|
+
switch (entry.entry_type) {
|
|
310
|
+
case 'query':
|
|
311
|
+
return String(payload.sql || '').substring(0, 80)
|
|
312
|
+
case 'request':
|
|
313
|
+
return `${payload.method} ${payload.path}`
|
|
314
|
+
case 'command':
|
|
315
|
+
return `Command: ${payload.command}`
|
|
316
|
+
case 'exception':
|
|
317
|
+
return `${payload.class}: ${String(payload.message || '').substring(0, 50)}`
|
|
318
|
+
case 'job_enqueue':
|
|
319
|
+
case 'job_perform':
|
|
320
|
+
return `Job: ${payload.job_class}`
|
|
321
|
+
default:
|
|
322
|
+
return entry.entry_type
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function getTypeLabel(type: string): string {
|
|
327
|
+
const labels: Record<string, string> = {
|
|
328
|
+
query: 'Queries',
|
|
329
|
+
exception: 'Exceptions',
|
|
330
|
+
job_enqueue: 'Jobs',
|
|
331
|
+
job_perform: 'Jobs',
|
|
332
|
+
request: 'Requests',
|
|
333
|
+
command: 'Commands',
|
|
334
|
+
log: 'Logs',
|
|
335
|
+
cache: 'Cache',
|
|
336
|
+
event: 'Events',
|
|
337
|
+
mail: 'Mail',
|
|
338
|
+
notification: 'Notifications',
|
|
339
|
+
model: 'Models',
|
|
340
|
+
gate: 'Gates',
|
|
341
|
+
redis: 'Redis',
|
|
342
|
+
view: 'Views',
|
|
343
|
+
client_request: 'HTTP Client'
|
|
344
|
+
}
|
|
345
|
+
return labels[type] || type
|
|
346
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import PlaceholderPage from '@/components/PlaceholderPage'
|
|
2
|
+
|
|
3
|
+
export default function RedisIndex() {
|
|
4
|
+
return (
|
|
5
|
+
<PlaceholderPage
|
|
6
|
+
title="Redis"
|
|
7
|
+
description="Redis commands executed by your application"
|
|
8
|
+
icon={
|
|
9
|
+
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
10
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
|
11
|
+
</svg>
|
|
12
|
+
}
|
|
13
|
+
/>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
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, truncate } from '@/lib/utils'
|
|
6
|
+
import { Card } from '@/components/ui/Card'
|
|
7
|
+
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/Table'
|
|
8
|
+
import { MethodBadge, StatusBadge } from '@/components/ui/Badge'
|
|
9
|
+
import { Pagination } from '@/components/ui/Pagination'
|
|
10
|
+
import { SearchInput } from '@/components/ui/SearchInput'
|
|
11
|
+
|
|
12
|
+
export default function RequestsIndex() {
|
|
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
|
+
|
|
20
|
+
const tagFilter = searchParams.get('tag') || ''
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
loadEntries()
|
|
24
|
+
}, [page, tagFilter])
|
|
25
|
+
|
|
26
|
+
async function loadEntries() {
|
|
27
|
+
setLoading(true)
|
|
28
|
+
try {
|
|
29
|
+
const response = await getEntries({
|
|
30
|
+
type: 'request',
|
|
31
|
+
tag: tagFilter || undefined,
|
|
32
|
+
page
|
|
33
|
+
})
|
|
34
|
+
setEntries(response.data)
|
|
35
|
+
setTotalPages(response.meta.total_pages)
|
|
36
|
+
} catch (error) {
|
|
37
|
+
console.error('Failed to load entries:', error)
|
|
38
|
+
} finally {
|
|
39
|
+
setLoading(false)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function handleSearch(value: string) {
|
|
44
|
+
setPage(1)
|
|
45
|
+
if (value) {
|
|
46
|
+
setSearchParams({ tag: value })
|
|
47
|
+
} else {
|
|
48
|
+
setSearchParams({})
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function handlePageChange(newPage: number) {
|
|
53
|
+
setPage(newPage)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="p-6">
|
|
58
|
+
<div className="mb-6">
|
|
59
|
+
<h1 className="text-2xl font-semibold text-white">Requests</h1>
|
|
60
|
+
<p className="text-dark-muted text-sm mt-1">HTTP requests to your application</p>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div className="mb-4">
|
|
64
|
+
<SearchInput
|
|
65
|
+
placeholder="Search by tag..."
|
|
66
|
+
value={tagFilter}
|
|
67
|
+
onChange={handleSearch}
|
|
68
|
+
className="max-w-sm"
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<Card>
|
|
73
|
+
<Table>
|
|
74
|
+
<TableHeader>
|
|
75
|
+
<TableRow>
|
|
76
|
+
<TableHead>Verb</TableHead>
|
|
77
|
+
<TableHead>Path</TableHead>
|
|
78
|
+
<TableHead className="text-center">Status</TableHead>
|
|
79
|
+
<TableHead className="text-right">Duration</TableHead>
|
|
80
|
+
<TableHead>Happened</TableHead>
|
|
81
|
+
</TableRow>
|
|
82
|
+
</TableHeader>
|
|
83
|
+
<TableBody>
|
|
84
|
+
{loading ? (
|
|
85
|
+
<TableRow>
|
|
86
|
+
<TableCell className="text-center text-dark-muted py-8" colSpan={5}>
|
|
87
|
+
Loading...
|
|
88
|
+
</TableCell>
|
|
89
|
+
</TableRow>
|
|
90
|
+
) : entries.length === 0 ? (
|
|
91
|
+
<TableRow>
|
|
92
|
+
<TableCell className="text-center text-dark-muted py-8" colSpan={5}>
|
|
93
|
+
{tagFilter ? `No requests found with tag "${tagFilter}".` : 'No requests recorded yet.'}
|
|
94
|
+
</TableCell>
|
|
95
|
+
</TableRow>
|
|
96
|
+
) : (
|
|
97
|
+
entries.map((entry) => (
|
|
98
|
+
<TableRow key={entry.id} onClick={() => navigate(`/requests/${entry.id}`)}>
|
|
99
|
+
<TableCell>
|
|
100
|
+
<MethodBadge method={String(entry.payload.method)} />
|
|
101
|
+
</TableCell>
|
|
102
|
+
<TableCell className="font-mono text-sm" title={String(entry.payload.path)}>
|
|
103
|
+
{truncate(String(entry.payload.path), 50)}
|
|
104
|
+
</TableCell>
|
|
105
|
+
<TableCell className="text-center">
|
|
106
|
+
<StatusBadge status={Number(entry.payload.status)} />
|
|
107
|
+
</TableCell>
|
|
108
|
+
<TableCell className="text-right text-dark-muted">
|
|
109
|
+
{String(entry.payload.duration)}ms
|
|
110
|
+
</TableCell>
|
|
111
|
+
<TableCell className="text-dark-muted" title={entry.occurred_at}>
|
|
112
|
+
{timeAgo(entry.occurred_at)}
|
|
113
|
+
</TableCell>
|
|
114
|
+
</TableRow>
|
|
115
|
+
))
|
|
116
|
+
)}
|
|
117
|
+
</TableBody>
|
|
118
|
+
</Table>
|
|
119
|
+
<Pagination currentPage={page} totalPages={totalPages} onPageChange={handlePageChange} />
|
|
120
|
+
</Card>
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
}
|