@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.
@@ -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