@magnet-cms/plugin-sentry 1.0.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.
- package/dist/backend/index.cjs +921 -0
- package/dist/backend/index.d.cts +21 -0
- package/dist/backend/index.d.ts +21 -0
- package/dist/backend/index.js +7 -0
- package/dist/chunk-NDBQHOLK.js +907 -0
- package/dist/frontend/bundle.iife.js +24998 -0
- package/dist/frontend/bundle.iife.js.map +1 -0
- package/dist/index.cjs +922 -0
- package/dist/index.d.cts +305 -0
- package/dist/index.d.ts +305 -0
- package/dist/index.js +6 -0
- package/package.json +87 -0
- package/src/admin/components/error-metrics.tsx +54 -0
- package/src/admin/components/feedback-widget.tsx +76 -0
- package/src/admin/components/recent-issues.tsx +100 -0
- package/src/admin/hooks/use-project-filter.ts +59 -0
- package/src/admin/index.ts +121 -0
- package/src/admin/pages/sentry-dashboard.tsx +149 -0
- package/src/admin/pages/sentry-issues.tsx +269 -0
- package/src/admin/pages/sentry-settings.tsx +182 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { PageHeader, useAdapter } from '@magnet-cms/admin'
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
Button,
|
|
5
|
+
DataTable,
|
|
6
|
+
type DataTableColumn,
|
|
7
|
+
type DataTableRenderContext,
|
|
8
|
+
Input,
|
|
9
|
+
Select,
|
|
10
|
+
SelectContent,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectTrigger,
|
|
13
|
+
SelectValue,
|
|
14
|
+
Skeleton,
|
|
15
|
+
} from '@magnet-cms/ui/components'
|
|
16
|
+
import { Search } from 'lucide-react'
|
|
17
|
+
import { useMemo, useState } from 'react'
|
|
18
|
+
import { ALL_PROJECTS, useProjectFilter } from '../hooks/use-project-filter'
|
|
19
|
+
|
|
20
|
+
interface SentryIssue {
|
|
21
|
+
id: string
|
|
22
|
+
shortId: string
|
|
23
|
+
title: string
|
|
24
|
+
status: string
|
|
25
|
+
count: string
|
|
26
|
+
lastSeen: string
|
|
27
|
+
permalink: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getStatusVariant(
|
|
31
|
+
status: string,
|
|
32
|
+
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
|
33
|
+
switch (status) {
|
|
34
|
+
case 'resolved':
|
|
35
|
+
return 'secondary'
|
|
36
|
+
case 'ignored':
|
|
37
|
+
return 'outline'
|
|
38
|
+
default:
|
|
39
|
+
return 'destructive'
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function formatRelativeTime(dateStr: string): string {
|
|
44
|
+
const diff = Date.now() - new Date(dateStr).getTime()
|
|
45
|
+
const minutes = Math.floor(diff / 60_000)
|
|
46
|
+
if (minutes < 60) return `${minutes}m ago`
|
|
47
|
+
const hours = Math.floor(minutes / 60)
|
|
48
|
+
if (hours < 24) return `${hours}h ago`
|
|
49
|
+
return `${Math.floor(hours / 24)}d ago`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const columns: DataTableColumn<SentryIssue>[] = [
|
|
53
|
+
{
|
|
54
|
+
type: 'custom',
|
|
55
|
+
header: 'Title',
|
|
56
|
+
cell: (row) => (
|
|
57
|
+
<div>
|
|
58
|
+
<p className="text-sm font-medium text-foreground">
|
|
59
|
+
{row.original.title}
|
|
60
|
+
</p>
|
|
61
|
+
<p className="text-xs text-muted-foreground font-mono">
|
|
62
|
+
{row.original.shortId}
|
|
63
|
+
</p>
|
|
64
|
+
</div>
|
|
65
|
+
),
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
type: 'custom',
|
|
69
|
+
header: 'Status',
|
|
70
|
+
cell: (row) => (
|
|
71
|
+
<Badge variant={getStatusVariant(row.original.status)}>
|
|
72
|
+
{row.original.status}
|
|
73
|
+
</Badge>
|
|
74
|
+
),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
type: 'text',
|
|
78
|
+
header: 'Events',
|
|
79
|
+
accessorKey: 'count',
|
|
80
|
+
format: (value) => (
|
|
81
|
+
<span className="text-sm text-muted-foreground">{value as string}</span>
|
|
82
|
+
),
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
type: 'text',
|
|
86
|
+
header: 'Last Seen',
|
|
87
|
+
accessorKey: 'lastSeen',
|
|
88
|
+
format: (value) => (
|
|
89
|
+
<span className="text-sm text-muted-foreground">
|
|
90
|
+
{formatRelativeTime(value as string)}
|
|
91
|
+
</span>
|
|
92
|
+
),
|
|
93
|
+
},
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
const SentryIssues = () => {
|
|
97
|
+
const adapter = useAdapter()
|
|
98
|
+
const [issues, setIssues] = useState<SentryIssue[]>([])
|
|
99
|
+
const [dataLoading, setDataLoading] = useState(false)
|
|
100
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
101
|
+
|
|
102
|
+
const { projects, selectedProject, loading, handleProjectChange } =
|
|
103
|
+
useProjectFilter(adapter, async (slug) => {
|
|
104
|
+
setDataLoading(true)
|
|
105
|
+
try {
|
|
106
|
+
const params =
|
|
107
|
+
slug && slug !== ALL_PROJECTS
|
|
108
|
+
? `?project=${encodeURIComponent(slug)}`
|
|
109
|
+
: ''
|
|
110
|
+
const data = await adapter.request<SentryIssue[]>(
|
|
111
|
+
`/sentry/admin/issues${params}`,
|
|
112
|
+
)
|
|
113
|
+
setIssues(data)
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error('[Sentry] Failed to fetch issues:', error)
|
|
116
|
+
} finally {
|
|
117
|
+
setDataLoading(false)
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const filteredIssues = useMemo(() => {
|
|
122
|
+
const q = searchQuery.trim().toLowerCase()
|
|
123
|
+
if (!q) return issues
|
|
124
|
+
return issues.filter(
|
|
125
|
+
(i) =>
|
|
126
|
+
i.title.toLowerCase().includes(q) ||
|
|
127
|
+
i.shortId.toLowerCase().includes(q),
|
|
128
|
+
)
|
|
129
|
+
}, [issues, searchQuery])
|
|
130
|
+
|
|
131
|
+
const projectSelector =
|
|
132
|
+
projects.length > 0 ? (
|
|
133
|
+
<Select value={selectedProject} onValueChange={handleProjectChange}>
|
|
134
|
+
<SelectTrigger className="w-[180px]">
|
|
135
|
+
<SelectValue />
|
|
136
|
+
</SelectTrigger>
|
|
137
|
+
<SelectContent>
|
|
138
|
+
<SelectItem value={ALL_PROJECTS}>All Projects</SelectItem>
|
|
139
|
+
{projects.map((p) => (
|
|
140
|
+
<SelectItem key={p.slug} value={p.slug}>
|
|
141
|
+
{p.name}
|
|
142
|
+
</SelectItem>
|
|
143
|
+
))}
|
|
144
|
+
</SelectContent>
|
|
145
|
+
</Select>
|
|
146
|
+
) : null
|
|
147
|
+
|
|
148
|
+
const renderToolbar = () => (
|
|
149
|
+
<div className="px-6 py-4 flex flex-col sm:flex-row gap-3 items-center justify-between flex-none border-b border-border bg-background">
|
|
150
|
+
<div className="relative w-full sm:w-80">
|
|
151
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
152
|
+
<Input
|
|
153
|
+
placeholder="Search issues..."
|
|
154
|
+
value={searchQuery}
|
|
155
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
156
|
+
className="pl-9"
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
<Button variant="ghost" size="sm" onClick={() => setSearchQuery('')}>
|
|
160
|
+
Clear Filters
|
|
161
|
+
</Button>
|
|
162
|
+
</div>
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
const renderPagination = (table: DataTableRenderContext<SentryIssue>) => {
|
|
166
|
+
const { pageIndex, pageSize } = table.getState().pagination
|
|
167
|
+
const totalRows = table.getFilteredRowModel().rows.length
|
|
168
|
+
const startRow = pageIndex * pageSize + 1
|
|
169
|
+
const endRow = Math.min((pageIndex + 1) * pageSize, totalRows)
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<div className="flex-none px-6 py-4 border-t border-border bg-background flex items-center justify-between">
|
|
173
|
+
<div className="text-xs text-muted-foreground">
|
|
174
|
+
Showing{' '}
|
|
175
|
+
<span className="font-medium text-foreground">{startRow}</span> to{' '}
|
|
176
|
+
<span className="font-medium text-foreground">{endRow}</span> of{' '}
|
|
177
|
+
<span className="font-medium text-foreground">{totalRows}</span>{' '}
|
|
178
|
+
results
|
|
179
|
+
</div>
|
|
180
|
+
<div className="flex items-center gap-2">
|
|
181
|
+
<Button
|
|
182
|
+
variant="outline"
|
|
183
|
+
size="sm"
|
|
184
|
+
disabled={!table.getCanPreviousPage()}
|
|
185
|
+
onClick={() => table.previousPage()}
|
|
186
|
+
>
|
|
187
|
+
Previous
|
|
188
|
+
</Button>
|
|
189
|
+
<Button
|
|
190
|
+
variant="outline"
|
|
191
|
+
size="sm"
|
|
192
|
+
disabled={!table.getCanNextPage()}
|
|
193
|
+
onClick={() => table.nextPage()}
|
|
194
|
+
>
|
|
195
|
+
Next
|
|
196
|
+
</Button>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (loading || dataLoading) {
|
|
203
|
+
return (
|
|
204
|
+
<div className="flex-1 flex flex-col min-w-0 bg-background h-full relative overflow-hidden">
|
|
205
|
+
<PageHeader
|
|
206
|
+
title="Sentry Issues"
|
|
207
|
+
actions={projectSelector ?? undefined}
|
|
208
|
+
/>
|
|
209
|
+
<div className="flex-1 p-6">
|
|
210
|
+
<Skeleton className="h-96 w-full" />
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<div className="flex-1 flex flex-col min-w-0 bg-background h-full relative overflow-hidden">
|
|
218
|
+
<PageHeader
|
|
219
|
+
title="Sentry Issues"
|
|
220
|
+
description={`${issues.length} issue(s) loaded.`}
|
|
221
|
+
actions={projectSelector ?? undefined}
|
|
222
|
+
/>
|
|
223
|
+
<div className="flex-1 flex flex-col overflow-hidden bg-muted/50">
|
|
224
|
+
<div className="flex-1 overflow-hidden relative">
|
|
225
|
+
<div className="absolute inset-0 overflow-auto">
|
|
226
|
+
<DataTable
|
|
227
|
+
columns={columns}
|
|
228
|
+
data={filteredIssues}
|
|
229
|
+
options={{
|
|
230
|
+
rowActions: {
|
|
231
|
+
items: [
|
|
232
|
+
{
|
|
233
|
+
label: 'Open in Sentry',
|
|
234
|
+
onSelect: (row) => {
|
|
235
|
+
window.open(
|
|
236
|
+
row.permalink,
|
|
237
|
+
'_blank',
|
|
238
|
+
'noopener,noreferrer',
|
|
239
|
+
)
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
}}
|
|
245
|
+
getRowId={(row) => row.id}
|
|
246
|
+
renderToolbar={renderToolbar}
|
|
247
|
+
renderPagination={renderPagination}
|
|
248
|
+
enablePagination
|
|
249
|
+
pageSizeOptions={[5, 10, 20, 30, 50]}
|
|
250
|
+
initialPagination={{ pageIndex: 0, pageSize: 10 }}
|
|
251
|
+
showCount={false}
|
|
252
|
+
className="h-full flex flex-col"
|
|
253
|
+
variant="content-manager"
|
|
254
|
+
renderEmpty={() => (
|
|
255
|
+
<span className="text-sm text-muted-foreground">
|
|
256
|
+
{issues.length === 0
|
|
257
|
+
? 'No issues found'
|
|
258
|
+
: 'No matching issues'}
|
|
259
|
+
</span>
|
|
260
|
+
)}
|
|
261
|
+
/>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export default SentryIssues
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { PageContent, PageHeader, useAdapter } from '@magnet-cms/admin'
|
|
2
|
+
import { Badge, Card } from '@magnet-cms/ui/components'
|
|
3
|
+
import { Fragment, useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
interface SentryClientConfig {
|
|
6
|
+
dsn: string
|
|
7
|
+
enabled: boolean
|
|
8
|
+
environment: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SentryApiStatus {
|
|
12
|
+
connected: boolean
|
|
13
|
+
organization: string | undefined
|
|
14
|
+
project: string | undefined
|
|
15
|
+
lastSync: string | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface SentryTokenScopes {
|
|
19
|
+
orgRead: boolean
|
|
20
|
+
projectRead: boolean
|
|
21
|
+
eventRead: boolean
|
|
22
|
+
alertsRead: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SCOPE_LABELS: Record<keyof SentryTokenScopes, string> = {
|
|
26
|
+
orgRead: 'org:read',
|
|
27
|
+
projectRead: 'project:read',
|
|
28
|
+
eventRead: 'event:read',
|
|
29
|
+
alertsRead: 'alerts:read',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Sentry plugin settings page in the admin UI.
|
|
34
|
+
*
|
|
35
|
+
* Displays the current Sentry connection status, API connectivity, and
|
|
36
|
+
* detected token scope availability.
|
|
37
|
+
* Accessible at /sentry/settings in the admin sidebar.
|
|
38
|
+
*/
|
|
39
|
+
const SentrySettings = () => {
|
|
40
|
+
const adapter = useAdapter()
|
|
41
|
+
const [config, setConfig] = useState<SentryClientConfig | null>(null)
|
|
42
|
+
const [apiStatus, setApiStatus] = useState<SentryApiStatus | null>(null)
|
|
43
|
+
const [scopes, setScopes] = useState<SentryTokenScopes | null>(null)
|
|
44
|
+
const [loading, setLoading] = useState(true)
|
|
45
|
+
const [error, setError] = useState<string | null>(null)
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
async function fetchData() {
|
|
49
|
+
try {
|
|
50
|
+
const [configData, statusData, scopesData] = await Promise.all([
|
|
51
|
+
adapter.request<SentryClientConfig>('/sentry/config'),
|
|
52
|
+
adapter.request<SentryApiStatus>('/sentry/admin/status'),
|
|
53
|
+
adapter.request<SentryTokenScopes>('/sentry/admin/scopes'),
|
|
54
|
+
])
|
|
55
|
+
setConfig(configData)
|
|
56
|
+
setApiStatus(statusData)
|
|
57
|
+
setScopes(scopesData)
|
|
58
|
+
} catch {
|
|
59
|
+
setError('Failed to load Sentry configuration.')
|
|
60
|
+
} finally {
|
|
61
|
+
setLoading(false)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
fetchData()
|
|
65
|
+
}, [adapter])
|
|
66
|
+
|
|
67
|
+
const maskedDsn = config?.dsn
|
|
68
|
+
? config.dsn.replace(/\/\/(.+?)@/, '//***@')
|
|
69
|
+
: '—'
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<>
|
|
73
|
+
<PageHeader
|
|
74
|
+
title="Sentry"
|
|
75
|
+
description="Error tracking & performance monitoring"
|
|
76
|
+
/>
|
|
77
|
+
<PageContent>
|
|
78
|
+
{loading ? (
|
|
79
|
+
<div className="p-6 text-muted-foreground">Loading...</div>
|
|
80
|
+
) : error ? (
|
|
81
|
+
<div className="p-6 text-destructive">{error}</div>
|
|
82
|
+
) : (
|
|
83
|
+
<div className="space-y-4 p-6">
|
|
84
|
+
<Card className="p-6">
|
|
85
|
+
<h3 className="text-base font-semibold mb-4">
|
|
86
|
+
Connection Status
|
|
87
|
+
</h3>
|
|
88
|
+
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
|
89
|
+
<dt className="text-muted-foreground">Status</dt>
|
|
90
|
+
<dd>
|
|
91
|
+
<span
|
|
92
|
+
className={
|
|
93
|
+
config?.enabled
|
|
94
|
+
? 'text-green-600 font-medium'
|
|
95
|
+
: 'text-muted-foreground'
|
|
96
|
+
}
|
|
97
|
+
>
|
|
98
|
+
{config?.enabled ? 'Enabled' : 'Disabled'}
|
|
99
|
+
</span>
|
|
100
|
+
</dd>
|
|
101
|
+
<dt className="text-muted-foreground">DSN</dt>
|
|
102
|
+
<dd className="font-mono text-xs break-all">{maskedDsn}</dd>
|
|
103
|
+
<dt className="text-muted-foreground">Environment</dt>
|
|
104
|
+
<dd>{config?.environment ?? '—'}</dd>
|
|
105
|
+
</dl>
|
|
106
|
+
</Card>
|
|
107
|
+
|
|
108
|
+
<Card className="p-6">
|
|
109
|
+
<h3 className="text-base font-semibold mb-4">API Connection</h3>
|
|
110
|
+
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
|
111
|
+
<dt className="text-muted-foreground">Status</dt>
|
|
112
|
+
<dd>
|
|
113
|
+
<Badge variant={apiStatus?.connected ? 'default' : 'outline'}>
|
|
114
|
+
{apiStatus?.connected ? 'Connected' : 'Not configured'}
|
|
115
|
+
</Badge>
|
|
116
|
+
</dd>
|
|
117
|
+
{apiStatus?.organization && (
|
|
118
|
+
<>
|
|
119
|
+
<dt className="text-muted-foreground">Organization</dt>
|
|
120
|
+
<dd className="font-mono text-xs">
|
|
121
|
+
{apiStatus.organization}
|
|
122
|
+
</dd>
|
|
123
|
+
</>
|
|
124
|
+
)}
|
|
125
|
+
{apiStatus?.project && (
|
|
126
|
+
<>
|
|
127
|
+
<dt className="text-muted-foreground">Project</dt>
|
|
128
|
+
<dd className="font-mono text-xs">{apiStatus.project}</dd>
|
|
129
|
+
</>
|
|
130
|
+
)}
|
|
131
|
+
{apiStatus?.lastSync && (
|
|
132
|
+
<>
|
|
133
|
+
<dt className="text-muted-foreground">Last sync</dt>
|
|
134
|
+
<dd className="text-muted-foreground">
|
|
135
|
+
{new Date(apiStatus.lastSync).toLocaleString()}
|
|
136
|
+
</dd>
|
|
137
|
+
</>
|
|
138
|
+
)}
|
|
139
|
+
</dl>
|
|
140
|
+
{!apiStatus?.connected && (
|
|
141
|
+
<p className="text-xs text-muted-foreground mt-3">
|
|
142
|
+
Set <code className="font-mono">SENTRY_AUTH_TOKEN</code>,{' '}
|
|
143
|
+
<code className="font-mono">SENTRY_ORG</code>, and{' '}
|
|
144
|
+
<code className="font-mono">SENTRY_PROJECT</code> to enable
|
|
145
|
+
API access.
|
|
146
|
+
</p>
|
|
147
|
+
)}
|
|
148
|
+
</Card>
|
|
149
|
+
|
|
150
|
+
{apiStatus?.connected && scopes && (
|
|
151
|
+
<Card className="p-6">
|
|
152
|
+
<h3 className="text-base font-semibold mb-4">Token Scopes</h3>
|
|
153
|
+
<p className="text-xs text-muted-foreground mb-4">
|
|
154
|
+
Detected permissions for the configured{' '}
|
|
155
|
+
<code className="font-mono">SENTRY_AUTH_TOKEN</code>.
|
|
156
|
+
</p>
|
|
157
|
+
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
|
158
|
+
{(
|
|
159
|
+
Object.keys(SCOPE_LABELS) as (keyof SentryTokenScopes)[]
|
|
160
|
+
).map((key) => (
|
|
161
|
+
<Fragment key={key}>
|
|
162
|
+
<dt className="text-muted-foreground font-mono text-xs self-center">
|
|
163
|
+
{SCOPE_LABELS[key]}
|
|
164
|
+
</dt>
|
|
165
|
+
<dd>
|
|
166
|
+
<Badge variant={scopes[key] ? 'default' : 'outline'}>
|
|
167
|
+
{scopes[key] ? 'Available' : 'Not available'}
|
|
168
|
+
</Badge>
|
|
169
|
+
</dd>
|
|
170
|
+
</Fragment>
|
|
171
|
+
))}
|
|
172
|
+
</dl>
|
|
173
|
+
</Card>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
</PageContent>
|
|
178
|
+
</>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export default SentrySettings
|