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.
Files changed (95) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +227 -0
  5. data/Rakefile +12 -0
  6. data/app/assets/stylesheets/railscope/application.css +504 -0
  7. data/app/controllers/railscope/api/entries_controller.rb +103 -0
  8. data/app/controllers/railscope/application_controller.rb +12 -0
  9. data/app/controllers/railscope/dashboard_controller.rb +33 -0
  10. data/app/controllers/railscope/entries_controller.rb +29 -0
  11. data/app/helpers/railscope/dashboard_helper.rb +157 -0
  12. data/app/jobs/railscope/application_job.rb +6 -0
  13. data/app/jobs/railscope/purge_job.rb +15 -0
  14. data/app/models/railscope/application_record.rb +12 -0
  15. data/app/models/railscope/entry.rb +51 -0
  16. data/app/views/layouts/railscope/application.html.erb +14 -0
  17. data/app/views/railscope/application/index.html.erb +1 -0
  18. data/app/views/railscope/dashboard/index.html.erb +70 -0
  19. data/app/views/railscope/entries/show.html.erb +93 -0
  20. data/client/.gitignore +1 -0
  21. data/client/index.html +12 -0
  22. data/client/package-lock.json +2735 -0
  23. data/client/package.json +28 -0
  24. data/client/postcss.config.js +6 -0
  25. data/client/src/App.tsx +60 -0
  26. data/client/src/api/client.ts +25 -0
  27. data/client/src/api/entries.ts +36 -0
  28. data/client/src/components/Layout.tsx +17 -0
  29. data/client/src/components/PlaceholderPage.tsx +32 -0
  30. data/client/src/components/Sidebar.tsx +198 -0
  31. data/client/src/components/ui/Badge.tsx +67 -0
  32. data/client/src/components/ui/Card.tsx +38 -0
  33. data/client/src/components/ui/JsonViewer.tsx +80 -0
  34. data/client/src/components/ui/Pagination.tsx +45 -0
  35. data/client/src/components/ui/SearchInput.tsx +70 -0
  36. data/client/src/components/ui/Table.tsx +68 -0
  37. data/client/src/index.css +28 -0
  38. data/client/src/lib/hooks.ts +37 -0
  39. data/client/src/lib/types.ts +61 -0
  40. data/client/src/lib/utils.ts +38 -0
  41. data/client/src/main.tsx +13 -0
  42. data/client/src/screens/cache/Index.tsx +15 -0
  43. data/client/src/screens/client-requests/Index.tsx +15 -0
  44. data/client/src/screens/commands/Index.tsx +133 -0
  45. data/client/src/screens/commands/Show.tsx +395 -0
  46. data/client/src/screens/dumps/Index.tsx +15 -0
  47. data/client/src/screens/events/Index.tsx +15 -0
  48. data/client/src/screens/exceptions/Index.tsx +155 -0
  49. data/client/src/screens/exceptions/Show.tsx +480 -0
  50. data/client/src/screens/gates/Index.tsx +15 -0
  51. data/client/src/screens/jobs/Index.tsx +153 -0
  52. data/client/src/screens/jobs/Show.tsx +529 -0
  53. data/client/src/screens/logs/Index.tsx +15 -0
  54. data/client/src/screens/mail/Index.tsx +15 -0
  55. data/client/src/screens/models/Index.tsx +15 -0
  56. data/client/src/screens/notifications/Index.tsx +15 -0
  57. data/client/src/screens/queries/Index.tsx +159 -0
  58. data/client/src/screens/queries/Show.tsx +346 -0
  59. data/client/src/screens/redis/Index.tsx +15 -0
  60. data/client/src/screens/requests/Index.tsx +123 -0
  61. data/client/src/screens/requests/Show.tsx +395 -0
  62. data/client/src/screens/schedule/Index.tsx +15 -0
  63. data/client/src/screens/views/Index.tsx +141 -0
  64. data/client/src/screens/views/Show.tsx +337 -0
  65. data/client/tailwind.config.js +22 -0
  66. data/client/tsconfig.json +25 -0
  67. data/client/tsconfig.node.json +10 -0
  68. data/client/vite.config.ts +37 -0
  69. data/config/routes.rb +17 -0
  70. data/db/migrate/20260131023242_create_railscope_entries.rb +41 -0
  71. data/lib/generators/railscope/install_generator.rb +33 -0
  72. data/lib/generators/railscope/templates/initializer.rb +34 -0
  73. data/lib/railscope/context.rb +91 -0
  74. data/lib/railscope/engine.rb +85 -0
  75. data/lib/railscope/entry_data.rb +112 -0
  76. data/lib/railscope/filter.rb +113 -0
  77. data/lib/railscope/middleware.rb +162 -0
  78. data/lib/railscope/storage/base.rb +90 -0
  79. data/lib/railscope/storage/database.rb +83 -0
  80. data/lib/railscope/storage/redis_storage.rb +314 -0
  81. data/lib/railscope/subscribers/base_subscriber.rb +52 -0
  82. data/lib/railscope/subscribers/command_subscriber.rb +237 -0
  83. data/lib/railscope/subscribers/exception_subscriber.rb +113 -0
  84. data/lib/railscope/subscribers/job_subscriber.rb +249 -0
  85. data/lib/railscope/subscribers/query_subscriber.rb +130 -0
  86. data/lib/railscope/subscribers/request_subscriber.rb +121 -0
  87. data/lib/railscope/subscribers/view_subscriber.rb +201 -0
  88. data/lib/railscope/version.rb +5 -0
  89. data/lib/railscope.rb +145 -0
  90. data/lib/tasks/railscope_sample.rake +30 -0
  91. data/public/railscope/assets/app.css +1 -0
  92. data/public/railscope/assets/app.js +70 -0
  93. data/public/railscope/assets/index.html +13 -0
  94. data/sig/railscope.rbs +4 -0
  95. metadata +157 -0
@@ -0,0 +1,395 @@
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 { MethodBadge, StatusBadge } from '@/components/ui/Badge'
7
+ import { JsonViewer } from '@/components/ui/JsonViewer'
8
+
9
+ type TabType = 'payload' | 'headers'
10
+ type ResponseTabType = 'response' | 'response_headers' | 'session'
11
+
12
+ export default function RequestsShow() {
13
+ const { id } = useParams()
14
+ const navigate = useNavigate()
15
+ const [entry, setEntry] = useState<Entry | null>(null)
16
+ const [batch, setBatch] = useState<Entry[]>([])
17
+ const [loading, setLoading] = useState(true)
18
+ const [requestTab, setRequestTab] = useState<TabType>('payload')
19
+ const [responseTab, setResponseTab] = useState<ResponseTabType>('response')
20
+
21
+ useEffect(() => {
22
+ loadEntry()
23
+ }, [id])
24
+
25
+ async function loadEntry() {
26
+ if (!id) return
27
+ setLoading(true)
28
+ try {
29
+ const response = await getEntry(id)
30
+ setEntry(response.data)
31
+ setBatch(response.batch || [])
32
+ } catch (error) {
33
+ console.error('Failed to load entry:', error)
34
+ } finally {
35
+ setLoading(false)
36
+ }
37
+ }
38
+
39
+ if (loading) {
40
+ return (
41
+ <div className="p-6">
42
+ <Card>
43
+ <CardContent className="py-12 text-center text-dark-muted">
44
+ Loading...
45
+ </CardContent>
46
+ </Card>
47
+ </div>
48
+ )
49
+ }
50
+
51
+ if (!entry) {
52
+ return (
53
+ <div className="p-6">
54
+ <Card>
55
+ <CardContent className="py-12 text-center text-dark-muted">
56
+ Request not found.
57
+ </CardContent>
58
+ </Card>
59
+ </div>
60
+ )
61
+ }
62
+
63
+ const payload = entry.payload as Record<string, unknown>
64
+
65
+ const formattedTime = new Date(entry.occurred_at).toLocaleString('en-US', {
66
+ year: 'numeric',
67
+ month: 'long',
68
+ day: 'numeric',
69
+ hour: 'numeric',
70
+ minute: '2-digit',
71
+ second: '2-digit',
72
+ hour12: true
73
+ })
74
+ const timeAgoStr = getTimeAgo(entry.occurred_at)
75
+
76
+ // Get tab content
77
+ const getRequestTabContent = () => {
78
+ if (requestTab === 'payload') {
79
+ return payload.payload || {}
80
+ }
81
+ return payload.headers || {}
82
+ }
83
+
84
+ const getResponseTabContent = () => {
85
+ if (responseTab === 'response') {
86
+ return payload.response || {}
87
+ }
88
+ if (responseTab === 'response_headers') {
89
+ return payload.response_headers || {}
90
+ }
91
+ return payload.session || {}
92
+ }
93
+
94
+ return (
95
+ <div className="p-6 space-y-5">
96
+ {/* Request Details Card */}
97
+ <Card>
98
+ <CardHeader>
99
+ <CardTitle>Request Details</CardTitle>
100
+ </CardHeader>
101
+ <CardContent className="p-0">
102
+ <table className="w-full">
103
+ <tbody>
104
+ <tr className="border-t border-dark-border">
105
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap w-32">Time</td>
106
+ <td className="px-4 py-3">{formattedTime} ({timeAgoStr})</td>
107
+ </tr>
108
+ <tr className="border-t border-dark-border">
109
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">Method</td>
110
+ <td className="px-4 py-3">
111
+ <MethodBadge method={String(payload.method)} />
112
+ </td>
113
+ </tr>
114
+ <tr className="border-t border-dark-border">
115
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">Controller Action</td>
116
+ <td className="px-4 py-3 font-mono text-sm">
117
+ {String(payload.controller_action || `${payload.controller}@${payload.action}`)}
118
+ </td>
119
+ </tr>
120
+ <tr className="border-t border-dark-border">
121
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">Path</td>
122
+ <td className="px-4 py-3 font-mono text-sm">{String(payload.path)}</td>
123
+ </tr>
124
+ <tr className="border-t border-dark-border">
125
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">Status</td>
126
+ <td className="px-4 py-3">
127
+ <StatusBadge status={Number(payload.status)} />
128
+ </td>
129
+ </tr>
130
+ <tr className="border-t border-dark-border">
131
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">Duration</td>
132
+ <td className="px-4 py-3">{String(payload.duration ?? '-')}ms</td>
133
+ </tr>
134
+ {payload.ip_address ? (
135
+ <tr className="border-t border-dark-border">
136
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">IP Address</td>
137
+ <td className="px-4 py-3">{String(payload.ip_address)}</td>
138
+ </tr>
139
+ ) : null}
140
+ {payload.hostname ? (
141
+ <tr className="border-t border-dark-border">
142
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">Hostname</td>
143
+ <td className="px-4 py-3 font-mono text-sm">{String(payload.hostname)}</td>
144
+ </tr>
145
+ ) : null}
146
+ {payload.db_runtime ? (
147
+ <tr className="border-t border-dark-border">
148
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">DB Runtime</td>
149
+ <td className="px-4 py-3">{String(payload.db_runtime)}ms</td>
150
+ </tr>
151
+ ) : null}
152
+ {payload.view_runtime ? (
153
+ <tr className="border-t border-dark-border">
154
+ <td className="px-4 py-3 text-dark-muted whitespace-nowrap">View Runtime</td>
155
+ <td className="px-4 py-3">{String(payload.view_runtime)}ms</td>
156
+ </tr>
157
+ ) : null}
158
+ </tbody>
159
+ </table>
160
+ </CardContent>
161
+ </Card>
162
+
163
+ {/* Request Card with Tabs */}
164
+ <Card>
165
+ <div className="flex border-b border-dark-border">
166
+ <button
167
+ onClick={() => setRequestTab('payload')}
168
+ className={`px-4 py-2.5 text-sm font-medium ${
169
+ requestTab === 'payload'
170
+ ? 'bg-blue-500 text-white'
171
+ : 'text-dark-muted hover:text-dark-text'
172
+ }`}
173
+ >
174
+ Payload
175
+ </button>
176
+ <button
177
+ onClick={() => setRequestTab('headers')}
178
+ className={`px-4 py-2.5 text-sm font-medium ${
179
+ requestTab === 'headers'
180
+ ? 'bg-blue-500 text-white'
181
+ : 'text-dark-muted hover:text-dark-text'
182
+ }`}
183
+ >
184
+ Headers
185
+ </button>
186
+ </div>
187
+ <div className="bg-[#1a1a2e]">
188
+ <JsonViewer data={getRequestTabContent()} className="border-0 bg-transparent" />
189
+ </div>
190
+ </Card>
191
+
192
+ {/* Response Card with Tabs */}
193
+ <Card>
194
+ <div className="flex border-b border-dark-border">
195
+ <button
196
+ onClick={() => setResponseTab('response')}
197
+ className={`px-4 py-2.5 text-sm font-medium ${
198
+ responseTab === 'response'
199
+ ? 'bg-blue-500 text-white'
200
+ : 'text-dark-muted hover:text-dark-text'
201
+ }`}
202
+ >
203
+ Response
204
+ </button>
205
+ <button
206
+ onClick={() => setResponseTab('response_headers')}
207
+ className={`px-4 py-2.5 text-sm font-medium ${
208
+ responseTab === 'response_headers'
209
+ ? 'bg-blue-500 text-white'
210
+ : 'text-dark-muted hover:text-dark-text'
211
+ }`}
212
+ >
213
+ Headers
214
+ </button>
215
+ <button
216
+ onClick={() => setResponseTab('session')}
217
+ className={`px-4 py-2.5 text-sm font-medium ${
218
+ responseTab === 'session'
219
+ ? 'bg-blue-500 text-white'
220
+ : 'text-dark-muted hover:text-dark-text'
221
+ }`}
222
+ >
223
+ Session
224
+ </button>
225
+ </div>
226
+ <div className="bg-[#1a1a2e]">
227
+ <JsonViewer data={getResponseTabContent()} className="border-0 bg-transparent" />
228
+ </div>
229
+ </Card>
230
+
231
+ {/* Related Entries */}
232
+ {batch.length > 0 && <RelatedEntries entries={batch} currentEntryId={entry.id} navigate={navigate} />}
233
+ </div>
234
+ )
235
+ }
236
+
237
+ function getTimeAgo(date: string): string {
238
+ const seconds = Math.floor((new Date().getTime() - new Date(date).getTime()) / 1000)
239
+ if (seconds < 60) return `${seconds}s ago`
240
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
241
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`
242
+ return `${Math.floor(seconds / 86400)}d ago`
243
+ }
244
+
245
+ interface RelatedEntriesProps {
246
+ entries: Entry[]
247
+ currentEntryId: number
248
+ navigate: ReturnType<typeof useNavigate>
249
+ }
250
+
251
+ function RelatedEntries({ entries, currentEntryId, navigate }: RelatedEntriesProps) {
252
+ const [currentTab, setCurrentTab] = useState<string>('')
253
+
254
+ // Filter out the current entry from related entries
255
+ const filteredEntries = entries.filter(e => e.id !== currentEntryId)
256
+
257
+ const groupedEntries = filteredEntries.reduce((acc, entry) => {
258
+ const type = entry.entry_type
259
+ if (!acc[type]) acc[type] = []
260
+ acc[type].push(entry)
261
+ return acc
262
+ }, {} as Record<string, Entry[]>)
263
+
264
+ const tabs = Object.entries(groupedEntries).map(([type, items]) => ({
265
+ type,
266
+ label: getTypeLabel(type),
267
+ count: items.length
268
+ }))
269
+
270
+ useEffect(() => {
271
+ if (tabs.length > 0 && !currentTab) {
272
+ setCurrentTab(tabs[0].type)
273
+ }
274
+ }, [tabs.length])
275
+
276
+ if (tabs.length === 0) return null
277
+
278
+ const currentEntries = groupedEntries[currentTab] || []
279
+
280
+ return (
281
+ <Card>
282
+ <CardHeader>
283
+ <CardTitle>Related Entries</CardTitle>
284
+ </CardHeader>
285
+ <div className="flex flex-wrap border-b border-dark-border">
286
+ {tabs.map((tab) => (
287
+ <button
288
+ key={tab.type}
289
+ onClick={() => setCurrentTab(tab.type)}
290
+ className={`px-4 py-2.5 text-sm font-medium ${
291
+ currentTab === tab.type
292
+ ? 'bg-blue-500 text-white'
293
+ : 'text-dark-muted hover:text-dark-text'
294
+ }`}
295
+ >
296
+ {tab.label} ({tab.count})
297
+ </button>
298
+ ))}
299
+ </div>
300
+ <table className="w-full">
301
+ <tbody>
302
+ {currentEntries.map((relatedEntry) => {
303
+ const relPayload = relatedEntry.payload as Record<string, unknown>
304
+ const path = getEntryPath(relatedEntry)
305
+
306
+ return (
307
+ <tr
308
+ key={relatedEntry.id}
309
+ onClick={() => navigate(path)}
310
+ className="border-b border-dark-border hover:bg-white/[0.02] cursor-pointer"
311
+ >
312
+ <td className="px-4 py-3">
313
+ <span className="text-dark-text text-sm">{getEntryDescription(relatedEntry, relPayload)}</span>
314
+ </td>
315
+ <td className="px-4 py-3 text-right text-dark-muted text-xs">
316
+ {getTimeAgo(relatedEntry.occurred_at)}
317
+ </td>
318
+ <td className="px-4 py-3 w-12">
319
+ <ArrowIcon />
320
+ </td>
321
+ </tr>
322
+ )
323
+ })}
324
+ </tbody>
325
+ </table>
326
+ </Card>
327
+ )
328
+ }
329
+
330
+ function ArrowIcon() {
331
+ return (
332
+ <svg className="w-5 h-5 text-dark-muted" viewBox="0 0 20 20" fill="currentColor">
333
+ <path
334
+ fillRule="evenodd"
335
+ 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"
336
+ clipRule="evenodd"
337
+ />
338
+ </svg>
339
+ )
340
+ }
341
+
342
+ function getEntryPath(entry: Entry): string {
343
+ switch (entry.entry_type) {
344
+ case 'query': return `/queries/${entry.id}`
345
+ case 'exception': return `/exceptions/${entry.id}`
346
+ case 'job_enqueue':
347
+ case 'job_perform': return `/jobs/${entry.id}`
348
+ case 'request': return `/requests/${entry.id}`
349
+ case 'command': return `/commands/${entry.id}`
350
+ case 'view': return `/views/${entry.id}`
351
+ default: return `/${entry.entry_type}s/${entry.id}`
352
+ }
353
+ }
354
+
355
+ function getEntryDescription(entry: Entry, payload: Record<string, unknown>): string {
356
+ switch (entry.entry_type) {
357
+ case 'query':
358
+ return String(payload.sql || '').substring(0, 100)
359
+ case 'request':
360
+ return `${payload.method} ${payload.path}`
361
+ case 'command':
362
+ return `Command: ${payload.command}`
363
+ case 'exception':
364
+ return `${payload.class}: ${String(payload.message || '').substring(0, 50)}`
365
+ case 'job_enqueue':
366
+ case 'job_perform':
367
+ return `Job: ${payload.job_class}`
368
+ case 'view':
369
+ return `${payload.view_type}: ${payload.name || payload.path}`
370
+ default:
371
+ return entry.entry_type
372
+ }
373
+ }
374
+
375
+ function getTypeLabel(type: string): string {
376
+ const labels: Record<string, string> = {
377
+ query: 'Queries',
378
+ exception: 'Exceptions',
379
+ job_enqueue: 'Enqueued',
380
+ job_perform: 'Performed',
381
+ request: 'Requests',
382
+ command: 'Commands',
383
+ log: 'Logs',
384
+ cache: 'Cache',
385
+ event: 'Events',
386
+ mail: 'Mail',
387
+ notification: 'Notifications',
388
+ model: 'Models',
389
+ gate: 'Gates',
390
+ redis: 'Redis',
391
+ view: 'Views',
392
+ client_request: 'HTTP Client'
393
+ }
394
+ return labels[type] || type
395
+ }
@@ -0,0 +1,15 @@
1
+ import PlaceholderPage from '@/components/PlaceholderPage'
2
+
3
+ export default function ScheduleIndex() {
4
+ return (
5
+ <PlaceholderPage
6
+ title="Schedule"
7
+ description="Scheduled tasks and cron jobs in 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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
11
+ </svg>
12
+ }
13
+ />
14
+ )
15
+ }
@@ -0,0 +1,141 @@
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 { Pagination } from '@/components/ui/Pagination'
9
+ import { SearchInput } from '@/components/ui/SearchInput'
10
+
11
+ export default function ViewsIndex() {
12
+ const navigate = useNavigate()
13
+ const [searchParams, setSearchParams] = useSearchParams()
14
+ const [entries, setEntries] = useState<Entry[]>([])
15
+ const [loading, setLoading] = useState(true)
16
+ const [page, setPage] = useState(1)
17
+ const [totalPages, setTotalPages] = useState(1)
18
+
19
+ const tagFilter = searchParams.get('tag') || ''
20
+
21
+ useEffect(() => {
22
+ loadEntries()
23
+ }, [page, tagFilter])
24
+
25
+ async function loadEntries() {
26
+ setLoading(true)
27
+ try {
28
+ const response = await getEntries({
29
+ type: 'view',
30
+ tag: tagFilter || undefined,
31
+ page
32
+ })
33
+ setEntries(response.data)
34
+ setTotalPages(response.meta.total_pages)
35
+ } catch (error) {
36
+ console.error('Failed to load entries:', error)
37
+ } finally {
38
+ setLoading(false)
39
+ }
40
+ }
41
+
42
+ function handleSearch(value: string) {
43
+ setPage(1)
44
+ if (value) {
45
+ setSearchParams({ tag: value })
46
+ } else {
47
+ setSearchParams({})
48
+ }
49
+ }
50
+
51
+ function handlePageChange(newPage: number) {
52
+ setPage(newPage)
53
+ }
54
+
55
+ function getViewTypeBadge(viewType: string) {
56
+ const colors: Record<string, string> = {
57
+ template: 'bg-blue-500/20 text-blue-400',
58
+ partial: 'bg-purple-500/20 text-purple-400',
59
+ layout: 'bg-green-500/20 text-green-400'
60
+ }
61
+ return (
62
+ <span className={`px-2 py-0.5 rounded text-xs font-medium ${colors[viewType] || 'bg-gray-500/20 text-gray-400'}`}>
63
+ {viewType}
64
+ </span>
65
+ )
66
+ }
67
+
68
+ function getDurationColor(duration: number) {
69
+ if (duration > 100) return 'text-red-400'
70
+ if (duration > 50) return 'text-yellow-400'
71
+ return 'text-dark-muted'
72
+ }
73
+
74
+ return (
75
+ <div className="p-6">
76
+ <div className="mb-6">
77
+ <h1 className="text-2xl font-semibold text-white">Views</h1>
78
+ <p className="text-dark-muted text-sm mt-1">View templates rendered by ActionView</p>
79
+ </div>
80
+
81
+ <div className="mb-4">
82
+ <SearchInput
83
+ placeholder="Search by tag..."
84
+ value={tagFilter}
85
+ onChange={handleSearch}
86
+ className="max-w-sm"
87
+ />
88
+ </div>
89
+
90
+ <Card>
91
+ <Table>
92
+ <TableHeader>
93
+ <TableRow>
94
+ <TableHead>Type</TableHead>
95
+ <TableHead>Name</TableHead>
96
+ <TableHead>Path</TableHead>
97
+ <TableHead className="text-right">Duration</TableHead>
98
+ <TableHead>Happened</TableHead>
99
+ </TableRow>
100
+ </TableHeader>
101
+ <TableBody>
102
+ {loading ? (
103
+ <TableRow>
104
+ <TableCell className="text-center text-dark-muted py-8" colSpan={5}>
105
+ Loading...
106
+ </TableCell>
107
+ </TableRow>
108
+ ) : entries.length === 0 ? (
109
+ <TableRow>
110
+ <TableCell className="text-center text-dark-muted py-8" colSpan={5}>
111
+ {tagFilter ? `No views found with tag "${tagFilter}".` : 'No views recorded yet.'}
112
+ </TableCell>
113
+ </TableRow>
114
+ ) : (
115
+ entries.map((entry) => (
116
+ <TableRow key={entry.id} onClick={() => navigate(`/views/${entry.id}`)}>
117
+ <TableCell>
118
+ {getViewTypeBadge(String(entry.payload.view_type))}
119
+ </TableCell>
120
+ <TableCell className="font-mono text-sm text-white" title={String(entry.payload.name)}>
121
+ {truncate(String(entry.payload.name), 40)}
122
+ </TableCell>
123
+ <TableCell className="font-mono text-sm text-dark-muted" title={String(entry.payload.path)}>
124
+ {truncate(String(entry.payload.path), 40)}
125
+ </TableCell>
126
+ <TableCell className={`text-right ${getDurationColor(Number(entry.payload.duration))}`}>
127
+ {String(entry.payload.duration)}ms
128
+ </TableCell>
129
+ <TableCell className="text-dark-muted" title={entry.occurred_at}>
130
+ {timeAgo(entry.occurred_at)}
131
+ </TableCell>
132
+ </TableRow>
133
+ ))
134
+ )}
135
+ </TableBody>
136
+ </Table>
137
+ <Pagination currentPage={page} totalPages={totalPages} onPageChange={handlePageChange} />
138
+ </Card>
139
+ </div>
140
+ )
141
+ }