@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,54 @@
1
+ import { Card } from '@magnet-cms/ui/components'
2
+ import { AlertTriangle, BarChart3, Bug } from 'lucide-react'
3
+
4
+ interface MetricCardProps {
5
+ title: string
6
+ value: string | number
7
+ icon: React.ReactNode
8
+ }
9
+
10
+ function MetricCard({ title, value, icon }: MetricCardProps) {
11
+ return (
12
+ <Card className="p-6">
13
+ <div className="flex items-center justify-between">
14
+ <div>
15
+ <p className="text-sm font-medium text-muted-foreground">{title}</p>
16
+ <p className="text-2xl font-bold mt-1">{value}</p>
17
+ </div>
18
+ <div className="text-muted-foreground">{icon}</div>
19
+ </div>
20
+ </Card>
21
+ )
22
+ }
23
+
24
+ interface ErrorMetricsProps {
25
+ totalErrors: number
26
+ unresolvedIssues: number
27
+ errorsLast24h: number
28
+ }
29
+
30
+ export function ErrorMetrics({
31
+ totalErrors,
32
+ unresolvedIssues,
33
+ errorsLast24h,
34
+ }: ErrorMetricsProps) {
35
+ return (
36
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
37
+ <MetricCard
38
+ title="Unresolved Issues"
39
+ value={unresolvedIssues}
40
+ icon={<AlertTriangle className="h-5 w-5" />}
41
+ />
42
+ <MetricCard
43
+ title="Errors (Last 24h)"
44
+ value={errorsLast24h}
45
+ icon={<Bug className="h-5 w-5" />}
46
+ />
47
+ <MetricCard
48
+ title="Total Errors"
49
+ value={totalErrors}
50
+ icon={<BarChart3 className="h-5 w-5" />}
51
+ />
52
+ </div>
53
+ )
54
+ }
@@ -0,0 +1,76 @@
1
+ import { useAdapter } from '@magnet-cms/admin'
2
+ import { useEffect, useRef } from 'react'
3
+
4
+ interface SentryClientConfig {
5
+ dsn: string
6
+ enabled: boolean
7
+ environment: string
8
+ }
9
+
10
+ /**
11
+ * Mounts the Sentry User Feedback widget via the @sentry/browser feedbackIntegration.
12
+ *
13
+ * Fetches the public DSN from the backend (/sentry/config), initializes the
14
+ * Sentry Browser SDK if not already initialized, and attaches a floating
15
+ * feedback button to the page.
16
+ *
17
+ * The widget uses Sentry's shadow DOM isolation so it is style-independent
18
+ * from the admin UI theme.
19
+ */
20
+ export function SentryFeedbackWidget() {
21
+ const adapter = useAdapter()
22
+ const feedbackRef = useRef<{ remove: () => void } | null>(null)
23
+
24
+ useEffect(() => {
25
+ let cancelled = false
26
+
27
+ async function init() {
28
+ try {
29
+ const config =
30
+ await adapter.request<SentryClientConfig>('/sentry/config')
31
+
32
+ if (cancelled || !config.enabled || !config.dsn) return
33
+
34
+ // Dynamically import @sentry/browser to keep it out of the main bundle
35
+ const Sentry = await import('@sentry/browser')
36
+
37
+ if (cancelled) return
38
+
39
+ // Initialize Sentry Browser SDK if not already done
40
+ if (!Sentry.getClient()) {
41
+ Sentry.init({
42
+ dsn: config.dsn,
43
+ environment: config.environment,
44
+ })
45
+ }
46
+
47
+ // Attach the feedback widget — uses shadow DOM for style isolation
48
+ const feedback = Sentry.feedbackIntegration({
49
+ colorScheme: 'system',
50
+ buttonLabel: 'Report an Issue',
51
+ submitButtonLabel: 'Send Report',
52
+ messagePlaceholder: 'Describe the issue you encountered...',
53
+ showBranding: false,
54
+ })
55
+
56
+ const widget = feedback.createWidget?.()
57
+ if (widget) {
58
+ feedbackRef.current = widget
59
+ }
60
+ } catch {
61
+ // Sentry is optional — never crash the admin UI
62
+ }
63
+ }
64
+
65
+ init()
66
+
67
+ return () => {
68
+ cancelled = true
69
+ feedbackRef.current?.remove()
70
+ feedbackRef.current = null
71
+ }
72
+ }, [adapter])
73
+
74
+ // Widget is rendered by Sentry SDK into the DOM directly — no React output needed
75
+ return null
76
+ }
@@ -0,0 +1,100 @@
1
+ import {
2
+ Badge,
3
+ DataTable,
4
+ type DataTableColumn,
5
+ } from '@magnet-cms/ui/components'
6
+ import { ExternalLink } from 'lucide-react'
7
+
8
+ interface SentryIssue {
9
+ id: string
10
+ shortId: string
11
+ title: string
12
+ status: string
13
+ count: string
14
+ lastSeen: string
15
+ permalink: string
16
+ }
17
+
18
+ interface RecentIssuesProps {
19
+ issues: SentryIssue[]
20
+ }
21
+
22
+ function getStatusVariant(
23
+ status: string,
24
+ ): 'default' | 'secondary' | 'destructive' | 'outline' {
25
+ switch (status) {
26
+ case 'resolved':
27
+ return 'secondary'
28
+ case 'ignored':
29
+ return 'outline'
30
+ default:
31
+ return 'destructive'
32
+ }
33
+ }
34
+
35
+ function formatRelativeTime(dateStr: string): string {
36
+ const diff = Date.now() - new Date(dateStr).getTime()
37
+ const minutes = Math.floor(diff / 60_000)
38
+ if (minutes < 60) return `${minutes}m ago`
39
+ const hours = Math.floor(minutes / 60)
40
+ if (hours < 24) return `${hours}h ago`
41
+ return `${Math.floor(hours / 24)}d ago`
42
+ }
43
+
44
+ const columns: DataTableColumn<SentryIssue>[] = [
45
+ {
46
+ type: 'custom',
47
+ header: 'Title',
48
+ cell: (row) => (
49
+ <div className="flex items-center gap-2">
50
+ <span className="font-medium truncate max-w-[300px]">
51
+ {row.original.title}
52
+ </span>
53
+ <a
54
+ href={row.original.permalink}
55
+ target="_blank"
56
+ rel="noopener noreferrer"
57
+ onClick={(e) => e.stopPropagation()}
58
+ >
59
+ <ExternalLink className="h-3 w-3 text-muted-foreground shrink-0" />
60
+ </a>
61
+ </div>
62
+ ),
63
+ },
64
+ {
65
+ type: 'custom',
66
+ header: 'Status',
67
+ cell: (row) => (
68
+ <Badge variant={getStatusVariant(row.original.status)}>
69
+ {row.original.status}
70
+ </Badge>
71
+ ),
72
+ },
73
+ {
74
+ type: 'text',
75
+ header: 'Events',
76
+ accessorKey: 'count',
77
+ },
78
+ {
79
+ type: 'text',
80
+ header: 'Last Seen',
81
+ accessorKey: 'lastSeen',
82
+ format: (value) => (
83
+ <span className="text-muted-foreground">
84
+ {formatRelativeTime(value as string)}
85
+ </span>
86
+ ),
87
+ },
88
+ ]
89
+
90
+ export function RecentIssues({ issues }: RecentIssuesProps) {
91
+ if (issues.length === 0) {
92
+ return (
93
+ <p className="text-sm text-muted-foreground text-center py-4">
94
+ No recent issues
95
+ </p>
96
+ )
97
+ }
98
+
99
+ return <DataTable columns={columns} data={issues} />
100
+ }
@@ -0,0 +1,59 @@
1
+ import type { useAdapter } from '@magnet-cms/admin'
2
+ import { useEffect, useState } from 'react'
3
+
4
+ export interface SentryProject {
5
+ id: string
6
+ slug: string
7
+ name: string
8
+ platform: string | null
9
+ isActive: boolean
10
+ errorCount: number | null
11
+ }
12
+
13
+ /** Sentinel value for the "All Projects" aggregate option */
14
+ export const ALL_PROJECTS = '__all__'
15
+
16
+ type Adapter = ReturnType<typeof useAdapter>
17
+
18
+ /**
19
+ * Shared hook for project filter functionality used by Dashboard and Issues pages.
20
+ *
21
+ * Fetches the org's project list on mount, defaults to ALL_PROJECTS (aggregate view),
22
+ * and invokes `onProjectChange` whenever the selection changes.
23
+ * Pass ALL_PROJECTS ('') to get org-level aggregate data (no ?project= param).
24
+ */
25
+ export function useProjectFilter(
26
+ adapter: Adapter,
27
+ onProjectChange: (slug: string) => Promise<void>,
28
+ ) {
29
+ const [projects, setProjects] = useState<SentryProject[]>([])
30
+ const [selectedProject, setSelectedProject] = useState<string>(ALL_PROJECTS)
31
+ const [loading, setLoading] = useState(true)
32
+
33
+ useEffect(() => {
34
+ async function init() {
35
+ try {
36
+ const [projectsData] = await Promise.all([
37
+ adapter.request<SentryProject[]>('/sentry/admin/projects'),
38
+ // Initial data fetch for "All Projects"
39
+ onProjectChange(ALL_PROJECTS),
40
+ ])
41
+ setProjects(projectsData)
42
+ } catch (error) {
43
+ console.error('[Sentry] Failed to load projects:', error)
44
+ } finally {
45
+ setLoading(false)
46
+ }
47
+ }
48
+ init()
49
+ // onProjectChange is intentionally excluded — stable callback ref not needed
50
+ // eslint-disable-next-line react-hooks/exhaustive-deps
51
+ }, [adapter])
52
+
53
+ function handleProjectChange(slug: string) {
54
+ setSelectedProject(slug)
55
+ onProjectChange(slug).catch(console.error)
56
+ }
57
+
58
+ return { projects, selectedProject, loading, handleProjectChange }
59
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Sentry Plugin — Frontend Entry
3
+ *
4
+ * Self-registers the plugin when loaded via script injection.
5
+ * The admin app loads plugin bundles at runtime and plugins self-register
6
+ * on window.__MAGNET_PLUGINS__.
7
+ */
8
+
9
+ import type { ComponentType } from 'react'
10
+
11
+ interface FrontendPluginManifest {
12
+ pluginName: string
13
+ routes?: {
14
+ path: string
15
+ componentId: string
16
+ requiresAuth?: boolean
17
+ children?: { path: string; componentId: string }[]
18
+ }[]
19
+ sidebar?: {
20
+ id: string
21
+ title: string
22
+ url: string
23
+ icon: string
24
+ order?: number
25
+ items?: { id: string; title: string; url: string; icon: string }[]
26
+ }[]
27
+ widgets?: {
28
+ componentId: string
29
+ position: 'global' | 'dashboard' | 'header'
30
+ }[]
31
+ }
32
+
33
+ interface PluginRegistration {
34
+ manifest: FrontendPluginManifest
35
+ components: Record<string, () => Promise<{ default: ComponentType<unknown> }>>
36
+ }
37
+
38
+ const manifest: FrontendPluginManifest = {
39
+ pluginName: 'sentry',
40
+ routes: [
41
+ {
42
+ path: 'sentry',
43
+ componentId: 'SentryDashboard',
44
+ requiresAuth: true,
45
+ children: [
46
+ { path: '', componentId: 'SentryDashboard' },
47
+ { path: 'issues', componentId: 'SentryIssues' },
48
+ { path: 'settings', componentId: 'SentrySettings' },
49
+ ],
50
+ },
51
+ ],
52
+ sidebar: [
53
+ {
54
+ id: 'sentry',
55
+ title: 'Sentry',
56
+ url: '/sentry',
57
+ icon: 'AlertTriangle',
58
+ order: 80,
59
+ items: [
60
+ {
61
+ id: 'sentry-dashboard',
62
+ title: 'Dashboard',
63
+ url: '/sentry',
64
+ icon: 'BarChart3',
65
+ },
66
+ {
67
+ id: 'sentry-issues',
68
+ title: 'Issues',
69
+ url: '/sentry/issues',
70
+ icon: 'Bug',
71
+ },
72
+ {
73
+ id: 'sentry-settings',
74
+ title: 'Settings',
75
+ url: '/sentry/settings',
76
+ icon: 'Settings',
77
+ },
78
+ ],
79
+ },
80
+ ],
81
+ // Global widget — the feedback button is mounted once on the page
82
+ widgets: [
83
+ {
84
+ componentId: 'SentryFeedbackWidget',
85
+ position: 'global',
86
+ },
87
+ ],
88
+ }
89
+
90
+ const components: Record<
91
+ string,
92
+ () => Promise<{ default: ComponentType<unknown> }>
93
+ > = {
94
+ SentryDashboard: () => import('./pages/sentry-dashboard'),
95
+ SentryIssues: () => import('./pages/sentry-issues'),
96
+ SentrySettings: () => import('./pages/sentry-settings'),
97
+ SentryFeedbackWidget: () =>
98
+ import('./components/feedback-widget').then((m) => ({
99
+ default: m.SentryFeedbackWidget as ComponentType<unknown>,
100
+ })),
101
+ }
102
+
103
+ function registerPlugin() {
104
+ if (!window.__MAGNET_PLUGINS__) {
105
+ window.__MAGNET_PLUGINS__ = []
106
+ }
107
+
108
+ const alreadyRegistered = window.__MAGNET_PLUGINS__.some(
109
+ (p: PluginRegistration) => p.manifest.pluginName === manifest.pluginName,
110
+ )
111
+
112
+ if (!alreadyRegistered) {
113
+ window.__MAGNET_PLUGINS__.push({ manifest, components })
114
+ console.log(`[Magnet] Plugin registered: ${manifest.pluginName}`)
115
+ }
116
+ }
117
+
118
+ registerPlugin()
119
+
120
+ export const sentryPlugin = () => ({ manifest, components })
121
+ export default sentryPlugin
@@ -0,0 +1,149 @@
1
+ import { PageContent, PageHeader, useAdapter } from '@magnet-cms/admin'
2
+ import {
3
+ Alert,
4
+ AlertDescription,
5
+ AlertTitle,
6
+ Card,
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ Skeleton,
13
+ } from '@magnet-cms/ui/components'
14
+ import { useState } from 'react'
15
+ import { ErrorMetrics } from '../components/error-metrics'
16
+ import { RecentIssues } from '../components/recent-issues'
17
+ import { ALL_PROJECTS, useProjectFilter } from '../hooks/use-project-filter'
18
+
19
+ interface SentryStats {
20
+ isConfigured: boolean
21
+ apiError?: string
22
+ totalErrors: number
23
+ unresolvedIssues: number
24
+ errorsLast24h: number
25
+ }
26
+
27
+ interface SentryIssue {
28
+ id: string
29
+ shortId: string
30
+ title: string
31
+ status: string
32
+ count: string
33
+ lastSeen: string
34
+ permalink: string
35
+ }
36
+
37
+ function DashboardSkeleton() {
38
+ return (
39
+ <div className="space-y-6 p-6">
40
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
41
+ {['unresolved', '24h', 'total'].map((id) => (
42
+ <Skeleton key={id} className="h-24" />
43
+ ))}
44
+ </div>
45
+ <Skeleton className="h-[200px]" />
46
+ </div>
47
+ )
48
+ }
49
+
50
+ function SetupCard() {
51
+ return (
52
+ <Card className="p-6">
53
+ <h3 className="text-lg font-semibold mb-2">Sentry API Not Configured</h3>
54
+ <p className="text-sm text-muted-foreground mb-4">
55
+ To enable the error dashboard, add the following environment variables:
56
+ </p>
57
+ <ul className="text-sm font-mono space-y-1 bg-muted p-3 rounded-md">
58
+ <li>SENTRY_AUTH_TOKEN=your-auth-token</li>
59
+ <li>SENTRY_ORG=your-org-slug</li>
60
+ <li>SENTRY_PROJECT=your-project-slug</li>
61
+ </ul>
62
+ </Card>
63
+ )
64
+ }
65
+
66
+ const SentryDashboard = () => {
67
+ const adapter = useAdapter()
68
+ const [stats, setStats] = useState<SentryStats | null>(null)
69
+ const [issues, setIssues] = useState<SentryIssue[]>([])
70
+ const [dataLoading, setDataLoading] = useState(false)
71
+
72
+ const { projects, selectedProject, loading, handleProjectChange } =
73
+ useProjectFilter(adapter, async (slug) => {
74
+ setDataLoading(true)
75
+ try {
76
+ const params =
77
+ slug && slug !== ALL_PROJECTS
78
+ ? `?project=${encodeURIComponent(slug)}`
79
+ : ''
80
+ const [statsData, issuesData] = await Promise.all([
81
+ adapter.request<SentryStats>(`/sentry/admin/stats${params}`),
82
+ adapter.request<SentryIssue[]>(`/sentry/admin/issues${params}`),
83
+ ])
84
+ setStats(statsData)
85
+ setIssues(issuesData.slice(0, 5))
86
+ } catch (error) {
87
+ console.error('[Sentry] Failed to fetch dashboard data:', error)
88
+ } finally {
89
+ setDataLoading(false)
90
+ }
91
+ })
92
+
93
+ const projectSelector =
94
+ projects.length > 0 ? (
95
+ <Select value={selectedProject} onValueChange={handleProjectChange}>
96
+ <SelectTrigger className="w-[180px]">
97
+ <SelectValue />
98
+ </SelectTrigger>
99
+ <SelectContent>
100
+ <SelectItem value={ALL_PROJECTS}>All Projects</SelectItem>
101
+ {projects.map((p) => (
102
+ <SelectItem key={p.slug} value={p.slug}>
103
+ {p.name}
104
+ </SelectItem>
105
+ ))}
106
+ </SelectContent>
107
+ </Select>
108
+ ) : null
109
+
110
+ return (
111
+ <>
112
+ <PageHeader
113
+ title="Sentry Dashboard"
114
+ actions={projectSelector ?? undefined}
115
+ />
116
+ <PageContent>
117
+ {loading || dataLoading || !stats ? (
118
+ <DashboardSkeleton />
119
+ ) : !stats.isConfigured ? (
120
+ <div className="p-6">
121
+ <SetupCard />
122
+ </div>
123
+ ) : (
124
+ <div className="space-y-6 p-6">
125
+ {stats.apiError ? (
126
+ <Alert variant="destructive">
127
+ <AlertTitle>Could not load Sentry data</AlertTitle>
128
+ <AlertDescription className="text-sm whitespace-pre-wrap">
129
+ {stats.apiError}
130
+ </AlertDescription>
131
+ </Alert>
132
+ ) : null}
133
+ <ErrorMetrics
134
+ totalErrors={stats.totalErrors}
135
+ unresolvedIssues={stats.unresolvedIssues}
136
+ errorsLast24h={stats.errorsLast24h}
137
+ />
138
+ <div>
139
+ <h3 className="text-lg font-semibold mb-4">Recent Issues</h3>
140
+ <RecentIssues issues={issues} />
141
+ </div>
142
+ </div>
143
+ )}
144
+ </PageContent>
145
+ </>
146
+ )
147
+ }
148
+
149
+ export default SentryDashboard