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,70 @@
1
+ import { useState, useCallback } from 'react'
2
+ import { useDebouncedCallback } from '@/lib/hooks'
3
+
4
+ interface SearchInputProps {
5
+ placeholder?: string
6
+ value?: string
7
+ onChange: (value: string) => void
8
+ className?: string
9
+ }
10
+
11
+ export function SearchInput({
12
+ placeholder = 'Search...',
13
+ value = '',
14
+ onChange,
15
+ className = ''
16
+ }: SearchInputProps) {
17
+ const [inputValue, setInputValue] = useState(value)
18
+
19
+ const debouncedOnChange = useDebouncedCallback((val: string) => {
20
+ onChange(val)
21
+ }, 300)
22
+
23
+ const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
24
+ const newValue = e.target.value
25
+ setInputValue(newValue)
26
+ debouncedOnChange(newValue)
27
+ }, [debouncedOnChange])
28
+
29
+ const handleClear = useCallback(() => {
30
+ setInputValue('')
31
+ onChange('')
32
+ }, [onChange])
33
+
34
+ return (
35
+ <div className={`relative ${className}`}>
36
+ <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
37
+ <svg
38
+ className="h-4 w-4 text-dark-muted"
39
+ fill="none"
40
+ viewBox="0 0 24 24"
41
+ stroke="currentColor"
42
+ >
43
+ <path
44
+ strokeLinecap="round"
45
+ strokeLinejoin="round"
46
+ strokeWidth={2}
47
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
48
+ />
49
+ </svg>
50
+ </div>
51
+ <input
52
+ type="text"
53
+ value={inputValue}
54
+ onChange={handleChange}
55
+ placeholder={placeholder}
56
+ className="w-full pl-10 pr-10 py-2 bg-dark-surface border border-dark-border rounded-md text-sm text-dark-text placeholder-dark-muted focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
57
+ />
58
+ {inputValue && (
59
+ <button
60
+ onClick={handleClear}
61
+ className="absolute inset-y-0 right-0 pr-3 flex items-center text-dark-muted hover:text-dark-text"
62
+ >
63
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
64
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
65
+ </svg>
66
+ </button>
67
+ )}
68
+ </div>
69
+ )
70
+ }
@@ -0,0 +1,68 @@
1
+ import { cn } from '@/lib/utils'
2
+
3
+ interface TableProps {
4
+ children: React.ReactNode
5
+ className?: string
6
+ }
7
+
8
+ interface TableCellProps extends TableProps {
9
+ colSpan?: number
10
+ title?: string
11
+ }
12
+
13
+ export function Table({ children, className }: TableProps) {
14
+ return (
15
+ <div className={cn('overflow-x-auto', className)}>
16
+ <table className="w-full">
17
+ {children}
18
+ </table>
19
+ </div>
20
+ )
21
+ }
22
+
23
+ export function TableHeader({ children, className }: TableProps) {
24
+ return (
25
+ <thead className={cn('bg-black/20', className)}>
26
+ {children}
27
+ </thead>
28
+ )
29
+ }
30
+
31
+ export function TableBody({ children, className }: TableProps) {
32
+ return (
33
+ <tbody className={cn('divide-y divide-dark-border', className)}>
34
+ {children}
35
+ </tbody>
36
+ )
37
+ }
38
+
39
+ export function TableRow({ children, className, onClick }: TableProps & { onClick?: () => void }) {
40
+ return (
41
+ <tr
42
+ className={cn(
43
+ 'hover:bg-white/[0.02] transition-colors',
44
+ onClick && 'cursor-pointer',
45
+ className
46
+ )}
47
+ onClick={onClick}
48
+ >
49
+ {children}
50
+ </tr>
51
+ )
52
+ }
53
+
54
+ export function TableHead({ children, className }: TableProps) {
55
+ return (
56
+ <th className={cn('px-4 py-3 text-left text-xs font-semibold text-dark-muted uppercase tracking-wide', className)}>
57
+ {children}
58
+ </th>
59
+ )
60
+ }
61
+
62
+ export function TableCell({ children, className, colSpan, title }: TableCellProps) {
63
+ return (
64
+ <td className={cn('px-4 py-3 text-sm', className)} colSpan={colSpan} title={title}>
65
+ {children}
66
+ </td>
67
+ )
68
+ }
@@ -0,0 +1,28 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ body {
6
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
7
+ background-color: #0d1117;
8
+ color: #c9d1d9;
9
+ }
10
+
11
+ /* Scrollbar styling */
12
+ ::-webkit-scrollbar {
13
+ width: 8px;
14
+ height: 8px;
15
+ }
16
+
17
+ ::-webkit-scrollbar-track {
18
+ background: #161b22;
19
+ }
20
+
21
+ ::-webkit-scrollbar-thumb {
22
+ background: #30363d;
23
+ border-radius: 4px;
24
+ }
25
+
26
+ ::-webkit-scrollbar-thumb:hover {
27
+ background: #484f58;
28
+ }
@@ -0,0 +1,37 @@
1
+ import { useCallback, useRef, useEffect } from 'react'
2
+
3
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
4
+ export function useDebouncedCallback<T extends (...args: any[]) => any>(
5
+ callback: T,
6
+ delay: number
7
+ ): T {
8
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
9
+ const callbackRef = useRef(callback)
10
+
11
+ // Update callback ref when callback changes
12
+ useEffect(() => {
13
+ callbackRef.current = callback
14
+ }, [callback])
15
+
16
+ // Cleanup on unmount
17
+ useEffect(() => {
18
+ return () => {
19
+ if (timeoutRef.current) {
20
+ clearTimeout(timeoutRef.current)
21
+ }
22
+ }
23
+ }, [])
24
+
25
+ return useCallback(
26
+ ((...args: Parameters<T>) => {
27
+ if (timeoutRef.current) {
28
+ clearTimeout(timeoutRef.current)
29
+ }
30
+
31
+ timeoutRef.current = setTimeout(() => {
32
+ callbackRef.current(...args)
33
+ }, delay)
34
+ }) as T,
35
+ [delay]
36
+ )
37
+ }
@@ -0,0 +1,61 @@
1
+ export interface Entry {
2
+ id: number
3
+ uuid: string
4
+ batch_id: string | null
5
+ family_hash: string | null
6
+ entry_type: string
7
+ payload: Record<string, unknown>
8
+ tags: string[]
9
+ occurred_at: string
10
+ created_at: string
11
+ family_count?: number
12
+ }
13
+
14
+ export interface PaginatedResponse<T> {
15
+ data: T[]
16
+ meta: {
17
+ current_page: number
18
+ total_pages: number
19
+ total_count: number
20
+ per_page: number
21
+ }
22
+ }
23
+
24
+ export interface EntryResponse {
25
+ data: Entry
26
+ batch: Entry[]
27
+ }
28
+
29
+ export interface BatchResponse {
30
+ data: Entry[]
31
+ }
32
+
33
+ export interface FamilyResponse {
34
+ data: Entry[]
35
+ meta: {
36
+ current_page: number
37
+ total_pages: number
38
+ total_count: number
39
+ per_page: number
40
+ }
41
+ }
42
+
43
+ export type EntryType =
44
+ | 'request'
45
+ | 'query'
46
+ | 'exception'
47
+ | 'job_enqueue'
48
+ | 'job_perform'
49
+ | 'command'
50
+ | 'schedule'
51
+ | 'log'
52
+ | 'dump'
53
+ | 'model'
54
+ | 'event'
55
+ | 'mail'
56
+ | 'notification'
57
+ | 'gate'
58
+ | 'cache'
59
+ | 'redis'
60
+ | 'view'
61
+ | 'client_request'
@@ -0,0 +1,38 @@
1
+ import { clsx, type ClassValue } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+ import { Entry } from './types'
4
+
5
+ export function cn(...inputs: ClassValue[]) {
6
+ return twMerge(clsx(inputs))
7
+ }
8
+
9
+ export function groupEntriesByType(entries: Entry[]): Record<string, Entry[]> {
10
+ return entries.reduce((acc, entry) => {
11
+ const type = entry.entry_type
12
+ if (!acc[type]) {
13
+ acc[type] = []
14
+ }
15
+ acc[type].push(entry)
16
+ return acc
17
+ }, {} as Record<string, Entry[]>)
18
+ }
19
+
20
+ export function timeAgo(date: string): string {
21
+ const seconds = Math.floor((new Date().getTime() - new Date(date).getTime()) / 1000)
22
+
23
+ if (seconds < 60) return `${seconds}s ago`
24
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
25
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`
26
+ return `${Math.floor(seconds / 86400)}d ago`
27
+ }
28
+
29
+ export function truncate(str: string, length: number): string {
30
+ if (str.length <= length) return str
31
+ return str.slice(0, length) + '...'
32
+ }
33
+
34
+ export function formatDuration(ms: number): string {
35
+ if (ms < 1) return '<1ms'
36
+ if (ms < 1000) return `${ms.toFixed(0)}ms`
37
+ return `${(ms / 1000).toFixed(2)}s`
38
+ }
@@ -0,0 +1,13 @@
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import { BrowserRouter } from 'react-router-dom'
4
+ import App from './App'
5
+ import './index.css'
6
+
7
+ ReactDOM.createRoot(document.getElementById('root')!).render(
8
+ <React.StrictMode>
9
+ <BrowserRouter basename="/railscope">
10
+ <App />
11
+ </BrowserRouter>
12
+ </React.StrictMode>,
13
+ )
@@ -0,0 +1,15 @@
1
+ import PlaceholderPage from '@/components/PlaceholderPage'
2
+
3
+ export default function CacheIndex() {
4
+ return (
5
+ <PlaceholderPage
6
+ title="Cache"
7
+ description="Rails.cache operations (read, write, delete, fetch)"
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="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
11
+ </svg>
12
+ }
13
+ />
14
+ )
15
+ }
@@ -0,0 +1,15 @@
1
+ import PlaceholderPage from '@/components/PlaceholderPage'
2
+
3
+ export default function ClientRequestsIndex() {
4
+ return (
5
+ <PlaceholderPage
6
+ title="HTTP Client"
7
+ description="Outgoing HTTP requests made 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="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
11
+ </svg>
12
+ }
13
+ />
14
+ )
15
+ }
@@ -0,0 +1,133 @@
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 } 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 CommandsIndex() {
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: 'command',
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">Commands</h1>
60
+ <p className="text-dark-muted text-sm mt-1">Rake tasks executed in 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>Command</TableHead>
77
+ <TableHead>Exit Code</TableHead>
78
+ <TableHead className="text-right">Duration</TableHead>
79
+ <TableHead>Happened</TableHead>
80
+ </TableRow>
81
+ </TableHeader>
82
+ <TableBody>
83
+ {loading ? (
84
+ <TableRow>
85
+ <TableCell className="text-center text-dark-muted py-8" colSpan={4}>
86
+ Loading...
87
+ </TableCell>
88
+ </TableRow>
89
+ ) : entries.length === 0 ? (
90
+ <TableRow>
91
+ <TableCell className="text-center text-dark-muted py-8" colSpan={4}>
92
+ {tagFilter ? `No commands found with tag "${tagFilter}".` : 'No commands recorded yet.'}
93
+ </TableCell>
94
+ </TableRow>
95
+ ) : (
96
+ entries.map((entry) => {
97
+ const payload = entry.payload as Record<string, unknown>
98
+ const isFailed = entry.tags.includes('failed')
99
+ const exitCode = Number(payload.exit_code ?? 0)
100
+ return (
101
+ <TableRow key={entry.id} onClick={() => navigate(`/commands/${entry.id}`)}>
102
+ <TableCell>
103
+ <span className={`font-mono ${isFailed ? 'text-red-400' : 'text-green-400'}`}>
104
+ {String(payload.command)}
105
+ </span>
106
+ {payload.description ? (
107
+ <div className="text-dark-muted text-xs mt-0.5">
108
+ {String(payload.description)}
109
+ </div>
110
+ ) : null}
111
+ </TableCell>
112
+ <TableCell>
113
+ <Badge variant={exitCode === 0 ? 'success' : 'error'}>
114
+ {exitCode}
115
+ </Badge>
116
+ </TableCell>
117
+ <TableCell className="text-right text-dark-muted">
118
+ {payload.duration ? `${String(payload.duration)}ms` : '-'}
119
+ </TableCell>
120
+ <TableCell className="text-dark-muted" title={entry.occurred_at}>
121
+ {timeAgo(entry.occurred_at)}
122
+ </TableCell>
123
+ </TableRow>
124
+ )
125
+ })
126
+ )}
127
+ </TableBody>
128
+ </Table>
129
+ <Pagination currentPage={page} totalPages={totalPages} onPageChange={handlePageChange} />
130
+ </Card>
131
+ </div>
132
+ )
133
+ }