Error reporting (#196)
* implement error reporting modal and service integration * Add session diagnostics for error reporting and enhance API failure tracking
This commit is contained in:
30
src/App.jsx
30
src/App.jsx
@@ -1,22 +1,23 @@
|
|||||||
import NavBar from '@/views/components/NavBar'
|
import './styles/safe-area.css'
|
||||||
|
|
||||||
import { Button, Typography, useColorScheme } from '@mui/joy'
|
import { Button, Typography, useColorScheme } from '@mui/joy'
|
||||||
import { useCallback, useEffect } from 'react'
|
import { useCallback, useEffect } from 'react'
|
||||||
import { Outlet, useNavigate } from 'react-router-dom'
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||||
|
|
||||||
|
import NavBar from '@/views/components/NavBar'
|
||||||
|
|
||||||
import { registerCapacitorListeners } from './CapacitorListener'
|
import { registerCapacitorListeners } from './CapacitorListener'
|
||||||
import PageTransition from './components/animations/PageTransition'
|
import PageTransition from './components/animations/PageTransition'
|
||||||
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
||||||
|
import SSEProvider from './contexts/SSEContext'
|
||||||
import { AuthProvider } from './hooks/useAuth.jsx'
|
import { AuthProvider } from './hooks/useAuth.jsx'
|
||||||
|
|
||||||
import useOnboardingGate from './hooks/useOnboardingGate'
|
import useOnboardingGate from './hooks/useOnboardingGate'
|
||||||
import useStatusBar from './hooks/useStatusBar'
|
import useStatusBar from './hooks/useStatusBar'
|
||||||
import { useResource } from './queries/ResourceQueries'
|
|
||||||
import './styles/safe-area.css'
|
|
||||||
|
|
||||||
import SSEProvider from './contexts/SSEContext'
|
|
||||||
import { useNotification } from './service/NotificationProvider'
|
|
||||||
|
|
||||||
import { useSyncOnReconnect } from './hooks/useSyncOnReconnect'
|
import { useSyncOnReconnect } from './hooks/useSyncOnReconnect'
|
||||||
|
import { useResource } from './queries/ResourceQueries'
|
||||||
|
import { recordRoute } from './service/DiagnosticsSession'
|
||||||
|
import { useNotification } from './service/NotificationProvider'
|
||||||
import NetworkBanner from './views/components/NetworkBanner'
|
import NetworkBanner from './views/components/NetworkBanner'
|
||||||
|
|
||||||
const add = className => {
|
const add = className => {
|
||||||
@@ -32,8 +33,15 @@ const intervalMS = 5 * 60 * 1000 // 5 minutes
|
|||||||
|
|
||||||
const AppContent = () => {
|
const AppContent = () => {
|
||||||
const { showNotification } = useNotification()
|
const { showNotification } = useNotification()
|
||||||
|
const location = useLocation()
|
||||||
useSyncOnReconnect()
|
useSyncOnReconnect()
|
||||||
|
|
||||||
|
// Every route renders through this Outlet, so one listener here gives crash
|
||||||
|
// reports the trail that led to the failure.
|
||||||
|
useEffect(() => {
|
||||||
|
recordRoute(location.pathname)
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
// // First-launch native users see the onboarding flow before anything else.
|
// // First-launch native users see the onboarding flow before anything else.
|
||||||
useOnboardingGate()
|
useOnboardingGate()
|
||||||
|
|
||||||
@@ -41,8 +49,8 @@ const AppContent = () => {
|
|||||||
useStatusBar()
|
useStatusBar()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
offlineReady: [offlineReady, setOfflineReady], // eslint-disable-line no-unused-vars
|
|
||||||
needRefresh: [needRefresh, setNeedRefresh],
|
needRefresh: [needRefresh, setNeedRefresh],
|
||||||
|
offlineReady: [offlineReady, setOfflineReady],
|
||||||
updateServiceWorker,
|
updateServiceWorker,
|
||||||
} = useRegisterSW({
|
} = useRegisterSW({
|
||||||
onRegistered(r) {
|
onRegistered(r) {
|
||||||
@@ -100,7 +108,7 @@ const AppContent = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const resource = useResource() // eslint-disable-line no-unused-vars
|
const resource = useResource()
|
||||||
const { mode, systemMode } = useColorScheme()
|
const { mode, systemMode } = useColorScheme()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
|||||||
189
src/service/DiagnosticsSession.js
Normal file
189
src/service/DiagnosticsSession.js
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* Ambient session state worth having the moment something breaks: how long the
|
||||||
|
* user has been in the app, how they got to the screen that failed, which
|
||||||
|
* server they were talking to and what it had already refused.
|
||||||
|
*
|
||||||
|
* Deliberately dependency-free — ApiClient imports it on the request path, so
|
||||||
|
* anything imported here would risk a module cycle. Everything is in memory
|
||||||
|
* and dies with the tab; nothing is persisted.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SESSION_STARTED_AT = Date.now()
|
||||||
|
const SESSION_ID = `${SESSION_STARTED_AT.toString(36)}-${Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.slice(2, 8)}`
|
||||||
|
|
||||||
|
// A cold start (vs. a reload of an already-running app) changes what a crash
|
||||||
|
// means: a reload loop looks very different from a first-launch failure.
|
||||||
|
const NAVIGATION_TYPE =
|
||||||
|
performance.getEntriesByType?.('navigation')?.[0]?.type ?? 'unknown'
|
||||||
|
|
||||||
|
const MAX_ROUTES = 10
|
||||||
|
const MAX_API_FAILURES = 8
|
||||||
|
|
||||||
|
const routeTrail = []
|
||||||
|
const apiFailures = []
|
||||||
|
let backgroundedCount = 0
|
||||||
|
let serverVersion = null
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Route trail
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a navigation and closes out the dwell time on the previous screen.
|
||||||
|
* "The crash happened 400ms after landing on /chores/12 from /chores" is a
|
||||||
|
* far better bug report than "the crash happened on /chores/12".
|
||||||
|
*/
|
||||||
|
export const recordRoute = path => {
|
||||||
|
if (!path) return
|
||||||
|
const now = Date.now()
|
||||||
|
const previous = routeTrail[routeTrail.length - 1]
|
||||||
|
if (previous) {
|
||||||
|
if (previous.path === path) return
|
||||||
|
previous.dwellMs = now - previous.at
|
||||||
|
}
|
||||||
|
routeTrail.push({ path, at: now })
|
||||||
|
if (routeTrail.length > MAX_ROUTES) routeTrail.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getRouteTrail = () => {
|
||||||
|
const now = Date.now()
|
||||||
|
return routeTrail.map((entry, index) => ({
|
||||||
|
path: entry.path,
|
||||||
|
// The current screen has no closing dwell yet; measure it up to now.
|
||||||
|
dwellMs:
|
||||||
|
entry.dwellMs ??
|
||||||
|
(index === routeTrail.length - 1 ? now - entry.at : null),
|
||||||
|
msAgo: now - entry.at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The screen the user came from, which is usually where the bug was planted. */
|
||||||
|
export const getPreviousRoute = () =>
|
||||||
|
routeTrail.length > 1 ? routeTrail[routeTrail.length - 2].path : null
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Server identity
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the server build out of response headers. Costs nothing when the
|
||||||
|
* server doesn't send them — the field simply stays null.
|
||||||
|
*/
|
||||||
|
export const recordServerVersionFromResponse = response => {
|
||||||
|
if (serverVersion) return
|
||||||
|
try {
|
||||||
|
serverVersion =
|
||||||
|
response?.headers?.get?.('x-donetick-version') ||
|
||||||
|
response?.headers?.get?.('x-api-version') ||
|
||||||
|
null
|
||||||
|
} catch {
|
||||||
|
// headers may be inaccessible on opaque responses; not worth reporting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setServerVersion = version => {
|
||||||
|
if (version) serverVersion = version
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerVersion = () => serverVersion
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// API failures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Strips ids and query strings so failures group by endpoint, not by row. */
|
||||||
|
const normalizeEndpoint = endpoint =>
|
||||||
|
String(endpoint || '')
|
||||||
|
.split('?')[0]
|
||||||
|
.replace(/\/\d+/g, '/:id')
|
||||||
|
|
||||||
|
export const recordApiFailure = ({ endpoint, method, status }) => {
|
||||||
|
apiFailures.push({
|
||||||
|
at: Date.now(),
|
||||||
|
method: method || 'GET',
|
||||||
|
endpoint: normalizeEndpoint(endpoint),
|
||||||
|
status: status ?? 'network',
|
||||||
|
})
|
||||||
|
if (apiFailures.length > MAX_API_FAILURES) apiFailures.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getApiFailures = () => {
|
||||||
|
const now = Date.now()
|
||||||
|
return apiFailures.map(failure => ({
|
||||||
|
method: failure.method,
|
||||||
|
endpoint: failure.endpoint,
|
||||||
|
status: failure.status,
|
||||||
|
msAgo: now - failure.at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'hidden') backgroundedCount += 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Snapshot
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const getServiceWorkerState = async () => {
|
||||||
|
if (!('serviceWorker' in navigator)) return { supported: false }
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.getRegistration()
|
||||||
|
return {
|
||||||
|
supported: true,
|
||||||
|
controlled: Boolean(navigator.serviceWorker.controller),
|
||||||
|
// A waiting worker means the user is running a stale bundle against a
|
||||||
|
// newer deploy — the usual cause of chunk-load failures after a release.
|
||||||
|
updateWaiting: Boolean(registration?.waiting),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { supported: true, controlled: null, updateWaiting: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStorageState = async () => {
|
||||||
|
try {
|
||||||
|
const { quota, usage } = await navigator.storage.estimate()
|
||||||
|
return {
|
||||||
|
usageMb: Math.round((usage / 1048576) * 10) / 10,
|
||||||
|
quotaMb: Math.round(quota / 1048576),
|
||||||
|
// Storage pressure produces failures that look like anything but.
|
||||||
|
pressure: quota ? Math.round((usage / quota) * 100) : null,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSessionDiagnostics = async () => {
|
||||||
|
const [serviceWorker, storage] = await Promise.all([
|
||||||
|
getServiceWorkerState(),
|
||||||
|
getStorageState(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: SESSION_ID,
|
||||||
|
sessionStartedAt: new Date(SESSION_STARTED_AT).toISOString(),
|
||||||
|
sessionDurationMs: Date.now() - SESSION_STARTED_AT,
|
||||||
|
navigationType: NAVIGATION_TYPE,
|
||||||
|
backgroundedCount,
|
||||||
|
serverVersion,
|
||||||
|
previousRoute: getPreviousRoute(),
|
||||||
|
routeTrail: getRouteTrail(),
|
||||||
|
apiFailures: getApiFailures(),
|
||||||
|
// Chromium only; absent elsewhere rather than faked.
|
||||||
|
heapUsedMb: performance.memory
|
||||||
|
? Math.round(performance.memory.usedJSHeapSize / 1048576)
|
||||||
|
: null,
|
||||||
|
storage,
|
||||||
|
serviceWorker,
|
||||||
|
}
|
||||||
|
}
|
||||||
295
src/service/ErrorReportService.js
Normal file
295
src/service/ErrorReportService.js
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import { getSessionDiagnostics } from './DiagnosticsSession'
|
||||||
|
import { collectFeedbackContext } from './FeedbackService'
|
||||||
|
|
||||||
|
const GITHUB_URL = 'https://github.com/donetick/donetick'
|
||||||
|
|
||||||
|
// Reports go to the same relay as feedback unless a dedicated one is set, so
|
||||||
|
// self-hosters who point at their own Worker get both for the price of one.
|
||||||
|
const REPORT_URL =
|
||||||
|
import.meta.env.VITE_ERROR_REPORT_WEBHOOK_URL ||
|
||||||
|
import.meta.env.VITE_FEEDBACK_WEBHOOK_URL
|
||||||
|
|
||||||
|
// Same trap as feedback: a chat webhook pasted straight in would reject our
|
||||||
|
// schema and ship inside the public bundle. Relay through the Worker instead.
|
||||||
|
const isRawChatWebhook = url =>
|
||||||
|
/^https:\/\/(discord(app)?\.com\/api\/webhooks|hooks\.slack\.com)/i.test(
|
||||||
|
url || '',
|
||||||
|
)
|
||||||
|
|
||||||
|
export const SUBMIT_RESULT = {
|
||||||
|
SENT: 'sent',
|
||||||
|
FAILED: 'failed',
|
||||||
|
UNCONFIGURED: 'unconfigured',
|
||||||
|
MISCONFIGURED: 'misconfigured',
|
||||||
|
SELF_HOSTED: 'self-hosted',
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_STACK = 4000
|
||||||
|
|
||||||
|
const clamp = (value, max) =>
|
||||||
|
typeof value === 'string' && value.length > max
|
||||||
|
? `${value.slice(0, max)}\n… truncated`
|
||||||
|
: value
|
||||||
|
|
||||||
|
/** Short, human-readable handle the user can quote back to support. */
|
||||||
|
export const newReportId = () =>
|
||||||
|
`DT-${Date.now().toString(36).toUpperCase().slice(-5)}-${Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(2, 6)}`
|
||||||
|
|
||||||
|
const safeMessage = error => {
|
||||||
|
const message = error?.message ?? error?.statusText
|
||||||
|
if (!message || message === '[object Object]') return null
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything about the failure itself, normalised across throw shapes. */
|
||||||
|
const describeError = (error, errorInfo) => {
|
||||||
|
if (!error) return { name: 'Unknown', message: null }
|
||||||
|
return {
|
||||||
|
name: error.name ?? error.constructor?.name ?? typeof error,
|
||||||
|
message: safeMessage(error) ?? String(error).slice(0, 500),
|
||||||
|
// react-router route errors carry an HTTP shape instead of a stack.
|
||||||
|
status: error.status ?? error.response?.status ?? null,
|
||||||
|
statusText: error.statusText ?? null,
|
||||||
|
// Vite/react-router attach a digest to server-side thrown responses.
|
||||||
|
digest: error.digest ?? null,
|
||||||
|
stack: clamp(error.stack ?? null, MAX_STACK),
|
||||||
|
componentStack: clamp(errorInfo?.componentStack ?? null, MAX_STACK),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything about the environment the failure happened in. */
|
||||||
|
const describeRuntime = () => {
|
||||||
|
const connection =
|
||||||
|
navigator.connection ||
|
||||||
|
navigator.mozConnection ||
|
||||||
|
navigator.webkitConnection
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: window.location.href,
|
||||||
|
route: window.location.pathname + window.location.search,
|
||||||
|
referrer: document.referrer || null,
|
||||||
|
viewport: `${window.innerWidth}×${window.innerHeight}`,
|
||||||
|
screen: `${window.screen?.width}×${window.screen?.height}`,
|
||||||
|
devicePixelRatio: window.devicePixelRatio,
|
||||||
|
online: navigator.onLine,
|
||||||
|
connectionType: connection?.effectiveType ?? null,
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
colorScheme: window.matchMedia?.('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light',
|
||||||
|
standalone: window.matchMedia?.('(display-mode: standalone)').matches,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedUser = () => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem('user') || 'null')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full diagnostic bundle. Deliberately assembled in one place so the copy
|
||||||
|
* button, the GitHub fallback and the webhook all describe the same crash.
|
||||||
|
*
|
||||||
|
* Reads the signed-in user from cache rather than the network: by the time
|
||||||
|
* this runs the app has already failed, and a fetch may be exactly what broke.
|
||||||
|
*/
|
||||||
|
export const collectErrorReport = async ({ error, errorInfo, reportId }) => {
|
||||||
|
const user = cachedUser()
|
||||||
|
const [context, session] = await Promise.all([
|
||||||
|
collectFeedbackContext({
|
||||||
|
feature: window.location.pathname,
|
||||||
|
userProfile: user,
|
||||||
|
}).catch(() => ({})),
|
||||||
|
getSessionDiagnostics().catch(() => ({})),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
reportId: reportId ?? newReportId(),
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
error: describeError(error, errorInfo),
|
||||||
|
runtime: describeRuntime(),
|
||||||
|
app: context,
|
||||||
|
session,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The plain-text rendering used by the copy button and the details panel. */
|
||||||
|
const formatDuration = ms => {
|
||||||
|
if (ms == null) return 'unknown'
|
||||||
|
if (ms < 1000) return `${ms}ms`
|
||||||
|
if (ms < 60_000) return `${Math.round(ms / 1000)}s`
|
||||||
|
const minutes = Math.floor(ms / 60_000)
|
||||||
|
if (minutes < 60) return `${minutes}m ${Math.round((ms % 60_000) / 1000)}s`
|
||||||
|
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatErrorReport = report => {
|
||||||
|
if (!report) return ''
|
||||||
|
const { app, error, runtime, session = {} } = report
|
||||||
|
const lines = [
|
||||||
|
`Report ID: ${report.reportId}`,
|
||||||
|
`Time: ${report.occurredAt}`,
|
||||||
|
'',
|
||||||
|
`Error: ${error.name}${error.message ? `: ${error.message}` : ''}`,
|
||||||
|
error.status
|
||||||
|
? `HTTP: ${error.status} ${error.statusText ?? ''}`.trim()
|
||||||
|
: null,
|
||||||
|
'',
|
||||||
|
`URL: ${runtime.url}`,
|
||||||
|
session.previousRoute ? `Came from: ${session.previousRoute}` : null,
|
||||||
|
'',
|
||||||
|
`App: ${app.appVersion} · ${app.platform}${app.isNative ? ' (native)' : ''}`,
|
||||||
|
`Server: ${session.serverVersion ?? 'not reported'}`,
|
||||||
|
`Session: ${formatDuration(session.sessionDurationMs)} active · ${
|
||||||
|
session.navigationType ?? 'unknown'
|
||||||
|
} start · backgrounded ${session.backgroundedCount ?? 0}×`,
|
||||||
|
`Device: ${app.deviceModel} · ${app.osVersion}`,
|
||||||
|
`Viewport: ${runtime.viewport} @${runtime.devicePixelRatio}x · ${runtime.colorScheme}`,
|
||||||
|
`Locale: ${app.locale} · ${runtime.timezone}`,
|
||||||
|
`Network: ${runtime.online ? 'online' : 'offline'}${
|
||||||
|
runtime.connectionType ? ` (${runtime.connectionType})` : ''
|
||||||
|
}`,
|
||||||
|
`Hosting: ${app.hosting}`,
|
||||||
|
app.userId ? `User: ${app.userId}` : null,
|
||||||
|
session.storage
|
||||||
|
? `Storage: ${session.storage.usageMb}MB / ${session.storage.quotaMb}MB (${session.storage.pressure}%)`
|
||||||
|
: null,
|
||||||
|
session.heapUsedMb ? `Heap: ${session.heapUsedMb}MB` : null,
|
||||||
|
session.serviceWorker?.supported
|
||||||
|
? `Service worker: ${
|
||||||
|
session.serviceWorker.controlled ? 'controlling' : 'not controlling'
|
||||||
|
}${session.serviceWorker.updateWaiting ? ' · UPDATE WAITING' : ''}`
|
||||||
|
: null,
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
if (session.routeTrail?.length) {
|
||||||
|
lines.push(
|
||||||
|
'',
|
||||||
|
'Route trail (oldest first):',
|
||||||
|
...session.routeTrail.map(
|
||||||
|
entry =>
|
||||||
|
`- ${entry.path} · ${formatDuration(entry.dwellMs)} · ${formatDuration(
|
||||||
|
entry.msAgo,
|
||||||
|
)} ago`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (session.apiFailures?.length) {
|
||||||
|
lines.push(
|
||||||
|
'',
|
||||||
|
'Recent API failures:',
|
||||||
|
...session.apiFailures.map(
|
||||||
|
failure =>
|
||||||
|
`- ${failure.method} ${failure.endpoint} → ${
|
||||||
|
failure.status
|
||||||
|
} (${formatDuration(failure.msAgo)} ago)`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (app.recentErrors?.length) {
|
||||||
|
lines.push('', 'Recent errors:', ...app.recentErrors.map(e => `- ${e}`))
|
||||||
|
}
|
||||||
|
if (error.componentStack) {
|
||||||
|
lines.push('', 'Component stack:', error.componentStack.trim())
|
||||||
|
}
|
||||||
|
if (error.stack) {
|
||||||
|
lines.push('', 'Stack:', error.stack)
|
||||||
|
}
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-filled GitHub issue for self-hosted instances — their crash data never
|
||||||
|
* leaves infrastructure they control, and they see it before it is published.
|
||||||
|
*/
|
||||||
|
export const buildErrorIssueUrl = ({ description, report }) => {
|
||||||
|
const title = `[crash] ${
|
||||||
|
report.error.message?.slice(0, 80) ||
|
||||||
|
report.error.name ||
|
||||||
|
'Unexpected error'
|
||||||
|
}`
|
||||||
|
const body = [
|
||||||
|
'### What happened',
|
||||||
|
description?.trim() || '_no description provided_',
|
||||||
|
'',
|
||||||
|
'### Diagnostics',
|
||||||
|
'```',
|
||||||
|
formatErrorReport(report),
|
||||||
|
'```',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
return `${GITHUB_URL}/issues/new?labels=bug&title=${encodeURIComponent(
|
||||||
|
title,
|
||||||
|
)}&body=${encodeURIComponent(body)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts the report to the relay. Never throws — a failed crash report must not
|
||||||
|
* produce a second crash, so every path resolves to a result the UI can show.
|
||||||
|
*/
|
||||||
|
export const submitErrorReport = async ({
|
||||||
|
contactEmail,
|
||||||
|
description,
|
||||||
|
report,
|
||||||
|
}) => {
|
||||||
|
const payload = {
|
||||||
|
source: 'donetick-app',
|
||||||
|
kind: 'error-report',
|
||||||
|
reportId: report.reportId,
|
||||||
|
description: description?.trim() || null,
|
||||||
|
contactEmail: contactEmail?.trim() || null,
|
||||||
|
report,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforced here, not only in the UI, so no future caller can relay a
|
||||||
|
// self-hosted instance's stack traces to the hosted endpoint.
|
||||||
|
if (report.app?.hosting !== 'cloud') {
|
||||||
|
return {
|
||||||
|
result: SUBMIT_RESULT.SELF_HOSTED,
|
||||||
|
githubUrl: buildErrorIssueUrl({ description, report }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!REPORT_URL) {
|
||||||
|
console.info('ErrorReportService: no endpoint configured, report:', payload)
|
||||||
|
return {
|
||||||
|
result: SUBMIT_RESULT.UNCONFIGURED,
|
||||||
|
githubUrl: buildErrorIssueUrl({ description, report }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRawChatWebhook(REPORT_URL)) {
|
||||||
|
console.error(
|
||||||
|
'ErrorReportService: the report URL points directly at a Discord/Slack ' +
|
||||||
|
'webhook. Deploy the relay Worker and point the variable at it.',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
result: SUBMIT_RESULT.MISCONFIGURED,
|
||||||
|
githubUrl: buildErrorIssueUrl({ description, report }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(REPORT_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
if (response.ok) return { result: SUBMIT_RESULT.SENT }
|
||||||
|
} catch (submitError) {
|
||||||
|
console.warn('ErrorReportService: submission failed', submitError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: SUBMIT_RESULT.FAILED,
|
||||||
|
githubUrl: buildErrorIssueUrl({ description, report }),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { Preferences } from '@capacitor/preferences'
|
import { Preferences } from '@capacitor/preferences'
|
||||||
|
|
||||||
import { API_URL } from '../Config'
|
import { API_URL } from '../Config'
|
||||||
import { networkManager } from '../hooks/NetworkManager'
|
import { networkManager } from '../hooks/NetworkManager'
|
||||||
|
import {
|
||||||
|
recordApiFailure,
|
||||||
|
recordServerVersionFromResponse,
|
||||||
|
} from '../service/DiagnosticsSession'
|
||||||
import { logout, RefreshToken } from './Fetcher'
|
import { logout, RefreshToken } from './Fetcher'
|
||||||
import { isOAuthExchangeInProgress } from './OAuthExchangeState'
|
import { isOAuthExchangeInProgress } from './OAuthExchangeState'
|
||||||
import { offlineDB } from './OfflineDB'
|
import { offlineDB } from './OfflineDB'
|
||||||
@@ -129,7 +134,7 @@ class ApiClient {
|
|||||||
|
|
||||||
// Process queued requests after refresh attempt
|
// Process queued requests after refresh attempt
|
||||||
processQueue(error, token = null) {
|
processQueue(error, token = null) {
|
||||||
this.failedQueue.forEach(({ resolve, reject }) => {
|
this.failedQueue.forEach(({ reject, resolve }) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
reject(error)
|
reject(error)
|
||||||
} else {
|
} else {
|
||||||
@@ -199,6 +204,17 @@ class ApiClient {
|
|||||||
let response = await fetch(url, config)
|
let response = await fetch(url, config)
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
|
// Passive diagnostics: learn the server build from whatever it already
|
||||||
|
// answers, and keep the last few refusals for crash reports.
|
||||||
|
recordServerVersionFromResponse(response)
|
||||||
|
if (!response.ok) {
|
||||||
|
recordApiFailure({
|
||||||
|
endpoint,
|
||||||
|
method: config.method,
|
||||||
|
status: response.status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Check for 401 (Unauthorized)
|
// 2. Check for 401 (Unauthorized)
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
// Always queue this request first
|
// Always queue this request first
|
||||||
@@ -280,6 +296,7 @@ class ApiClient {
|
|||||||
error?.name === 'AbortError' && options.signal?.aborted
|
error?.name === 'AbortError' && options.signal?.aborted
|
||||||
if (!externalAbort) {
|
if (!externalAbort) {
|
||||||
networkManager.setServerUnreachable()
|
networkManager.setServerUnreachable()
|
||||||
|
recordApiFailure({ endpoint, method: config.method, status: 'network' })
|
||||||
}
|
}
|
||||||
console.error('Request failed', error)
|
console.error('Request failed', error)
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
@@ -372,7 +372,6 @@ const MyChores = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
processEffectAsync()
|
processEffectAsync()
|
||||||
// throw new Error('Fake Error to test posthog')
|
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
membersLoading,
|
membersLoading,
|
||||||
|
|||||||
@@ -10,9 +10,15 @@ import {
|
|||||||
SearchOffRounded,
|
SearchOffRounded,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { Box, Button, IconButton, Snackbar, Typography } from '@mui/joy'
|
import { Box, Button, IconButton, Snackbar, Typography } from '@mui/joy'
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useRouteError } from 'react-router-dom'
|
import { Link, useRouteError } from 'react-router-dom'
|
||||||
|
|
||||||
|
import {
|
||||||
|
collectErrorReport,
|
||||||
|
formatErrorReport,
|
||||||
|
} from '../service/ErrorReportService'
|
||||||
|
import ErrorReportModal from './Modals/ErrorReportModal'
|
||||||
|
|
||||||
const getErrorKind = error => {
|
const getErrorKind = error => {
|
||||||
if (!error)
|
if (!error)
|
||||||
return { label: 'Unknown Error', color: 'danger', Icon: ErrorRounded }
|
return { label: 'Unknown Error', color: 'danger', Icon: ErrorRounded }
|
||||||
@@ -48,27 +54,30 @@ const safeMessage = error => {
|
|||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildErrorText = (error, url) => {
|
|
||||||
const lines = [
|
|
||||||
`URL: ${url}`,
|
|
||||||
`Time: ${new Date().toISOString()}`,
|
|
||||||
`Error: ${safeMessage(error) ?? String(error)}`,
|
|
||||||
]
|
|
||||||
if (error?.stack) lines.push(`\nStack:\n${error.stack}`)
|
|
||||||
return lines.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
const Error = () => {
|
const Error = () => {
|
||||||
const error = useRouteError()
|
const error = useRouteError()
|
||||||
const [showDetails, setShowDetails] = useState(false)
|
const [showDetails, setShowDetails] = useState(false)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [reportOpen, setReportOpen] = useState(false)
|
||||||
|
const [reportText, setReportText] = useState('')
|
||||||
|
|
||||||
const { color, Icon } = getErrorKind(error)
|
const { Icon, color } = getErrorKind(error)
|
||||||
const message = safeMessage(error)
|
const message = safeMessage(error)
|
||||||
const url = window.location.href
|
|
||||||
|
// The same bundle the report modal sends, so what the user copies and what
|
||||||
|
// we receive can never disagree.
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
collectErrorReport({ error }).then(report => {
|
||||||
|
if (active) setReportText(formatErrorReport(report))
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [error])
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
navigator.clipboard.writeText(buildErrorText(error, url)).then(() => {
|
navigator.clipboard.writeText(reportText).then(() => {
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -209,11 +218,24 @@ const Error = () => {
|
|||||||
size='lg'
|
size='lg'
|
||||||
startDecorator={<RefreshRounded />}
|
startDecorator={<RefreshRounded />}
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
sx={{ width: '100%', mb: 2 }}
|
sx={{ width: '100%', mb: 1.5 }}
|
||||||
>
|
>
|
||||||
Try again
|
Try again
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Reporting is one tap from the failure, where the context is still
|
||||||
|
fresh — asking people to find it in Settings afterwards never works. */}
|
||||||
|
<Button
|
||||||
|
variant='outlined'
|
||||||
|
color='neutral'
|
||||||
|
size='lg'
|
||||||
|
startDecorator={<BugReportRounded />}
|
||||||
|
onClick={() => setReportOpen(true)}
|
||||||
|
sx={{ width: '100%', mb: 2 }}
|
||||||
|
>
|
||||||
|
Report this problem
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* Secondary actions */}
|
{/* Secondary actions */}
|
||||||
<Box sx={{ display: 'flex', gap: 3, mb: 5 }}>
|
<Box sx={{ display: 'flex', gap: 3, mb: 5 }}>
|
||||||
<Button
|
<Button
|
||||||
@@ -251,16 +273,7 @@ const Error = () => {
|
|||||||
textAlign='center'
|
textAlign='center'
|
||||||
sx={{ color: 'text.tertiary', mb: 1.5 }}
|
sx={{ color: 'text.tertiary', mb: 1.5 }}
|
||||||
>
|
>
|
||||||
If this keeps happening,{' '}
|
If this keeps happening, send us a report please consider sending us report so we can take a look.
|
||||||
<a
|
|
||||||
href='https://github.com/donetick/donetick/issues/new'
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
style={{ textDecoration: 'underline' }}
|
|
||||||
>
|
|
||||||
open an issue
|
|
||||||
</a>{' '}
|
|
||||||
and include the error details below.
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{(error?.stack || message) && (
|
{(error?.stack || message) && (
|
||||||
@@ -313,7 +326,7 @@ const Error = () => {
|
|||||||
color: 'text.secondary',
|
color: 'text.secondary',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{buildErrorText(error, url)}
|
{reportText || 'Collecting error details…'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -321,6 +334,12 @@ const Error = () => {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<ErrorReportModal
|
||||||
|
open={reportOpen}
|
||||||
|
onClose={() => setReportOpen(false)}
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
|
||||||
<Snackbar
|
<Snackbar
|
||||||
open={copied}
|
open={copied}
|
||||||
autoHideDuration={2500}
|
autoHideDuration={2500}
|
||||||
|
|||||||
429
src/views/Modals/ErrorReportModal.jsx
Normal file
429
src/views/Modals/ErrorReportModal.jsx
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
import { Browser } from '@capacitor/browser'
|
||||||
|
import { Capacitor } from '@capacitor/core'
|
||||||
|
import {
|
||||||
|
BugReportRounded,
|
||||||
|
CheckRounded,
|
||||||
|
ContentCopyRounded,
|
||||||
|
ExpandMoreRounded,
|
||||||
|
GitHub,
|
||||||
|
} from '@mui/icons-material'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
FormLabel,
|
||||||
|
Input,
|
||||||
|
Link,
|
||||||
|
Stack,
|
||||||
|
Textarea,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/joy'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
|
||||||
|
import {
|
||||||
|
collectErrorReport,
|
||||||
|
formatErrorReport,
|
||||||
|
SUBMIT_RESULT,
|
||||||
|
submitErrorReport,
|
||||||
|
} from '../../service/ErrorReportService'
|
||||||
|
|
||||||
|
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
|
||||||
|
|
||||||
|
const enter = (delay = 0) => ({
|
||||||
|
animation: `errorReportIn 420ms ${EASE} ${delay}ms both`,
|
||||||
|
'@keyframes errorReportIn': {
|
||||||
|
from: { opacity: 0, transform: 'translateY(10px)' },
|
||||||
|
to: { opacity: 1, transform: 'none' },
|
||||||
|
},
|
||||||
|
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const STEP = { FORM: 'form', SENT: 'sent', FALLBACK: 'fallback' }
|
||||||
|
|
||||||
|
// Native webviews swallow target="_blank"; route through the system browser.
|
||||||
|
const openUrl = async url => {
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
await Browser.open({ url })
|
||||||
|
} else {
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same halo treatment the onboarding views use, so the crash path doesn't
|
||||||
|
* introduce a third visual language for "here's the thing this screen is about".
|
||||||
|
*/
|
||||||
|
const IconHalo = ({ color = 'primary', icon }) => (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
mx: 'auto',
|
||||||
|
'&::before': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
width: 110,
|
||||||
|
height: 110,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: `${color}.softBg`,
|
||||||
|
opacity: 0.6,
|
||||||
|
filter: 'blur(24px)',
|
||||||
|
},
|
||||||
|
'& > *': { position: 'relative' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
bgcolor: 'background.surface',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
color: `${color}.plainColor`,
|
||||||
|
'& svg': { fontSize: '1.6rem' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects a crash report from the error screen: one short answer from the
|
||||||
|
* user, everything else gathered automatically. The diagnostics are shown
|
||||||
|
* before sending rather than after — people are more willing to send a report
|
||||||
|
* they can see, and this is the one moment they already distrust the app.
|
||||||
|
*/
|
||||||
|
const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
||||||
|
const { ResponsiveModal } = useResponsiveModal()
|
||||||
|
|
||||||
|
const [report, setReport] = useState(null)
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [showDetails, setShowDetails] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [githubUrl, setGithubUrl] = useState(null)
|
||||||
|
const [step, setStep] = useState(STEP.FORM)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setStep(STEP.FORM)
|
||||||
|
setDescription('')
|
||||||
|
setShowDetails(false)
|
||||||
|
setCopied(false)
|
||||||
|
setSubmitting(false)
|
||||||
|
setGithubUrl(null)
|
||||||
|
// Snapshot the environment at open time so it reflects the crash, not
|
||||||
|
// whatever the app looks like after the user has poked at it.
|
||||||
|
collectErrorReport({ error, errorInfo }).then(setReport)
|
||||||
|
}, [open, error, errorInfo])
|
||||||
|
|
||||||
|
const reportText = report ? formatErrorReport(report) : ''
|
||||||
|
|
||||||
|
const copyDetails = () => {
|
||||||
|
navigator.clipboard.writeText(reportText).then(() => {
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!report) return
|
||||||
|
setSubmitting(true)
|
||||||
|
const { githubUrl: url, result } = await submitErrorReport({
|
||||||
|
description,
|
||||||
|
contactEmail: email,
|
||||||
|
report,
|
||||||
|
})
|
||||||
|
setSubmitting(false)
|
||||||
|
|
||||||
|
if (result === SUBMIT_RESULT.SENT) {
|
||||||
|
setStep(STEP.SENT)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Everything else — self-hosted, unconfigured, offline — hands the user a
|
||||||
|
// pre-filled issue so the report isn't simply lost.
|
||||||
|
setGithubUrl(url)
|
||||||
|
setStep(STEP.FALLBACK)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveModal open={open} onClose={onClose} size='md'>
|
||||||
|
{step === STEP.FORM && (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Box sx={{ ...enter(0) }}>
|
||||||
|
<IconHalo icon={<BugReportRounded />} color='danger' />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ textAlign: 'center', ...enter(50) }}>
|
||||||
|
<Typography
|
||||||
|
level='h4'
|
||||||
|
sx={{ fontWeight: 700, letterSpacing: '-0.01em' }}
|
||||||
|
>
|
||||||
|
Report this problem
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{ color: 'text.secondary', mt: 0.5, textWrap: 'pretty' }}
|
||||||
|
>
|
||||||
|
A sentence about what you were doing turns this into something we
|
||||||
|
can actually fix.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<FormControl sx={{ ...enter(100) }}>
|
||||||
|
<FormLabel sx={{ fontWeight: 600 }}>What were you doing?</FormLabel>
|
||||||
|
<Textarea
|
||||||
|
minRows={3}
|
||||||
|
maxRows={6}
|
||||||
|
autoFocus
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
placeholder='e.g. I tapped a chore in My Chores and the screen went blank'
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl sx={{ ...enter(140) }}>
|
||||||
|
<FormLabel sx={{ fontWeight: 600 }}>
|
||||||
|
Email{' '}
|
||||||
|
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||||
|
(optional — only if you want a reply)
|
||||||
|
</Typography>
|
||||||
|
</FormLabel>
|
||||||
|
<Input
|
||||||
|
type='email'
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
placeholder='you@example.com'
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Box sx={{ ...enter(180) }}>
|
||||||
|
<Button
|
||||||
|
variant='plain'
|
||||||
|
color='neutral'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => setShowDetails(v => !v)}
|
||||||
|
endDecorator={
|
||||||
|
<ExpandMoreRounded
|
||||||
|
sx={{
|
||||||
|
transition: 'transform 0.2s',
|
||||||
|
transform: showDetails ? 'rotate(180deg)' : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
sx={{ px: 0 }}
|
||||||
|
>
|
||||||
|
{showDetails ? 'Hide' : 'Show'} what gets sent
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{showDetails && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
mt: 1,
|
||||||
|
p: 1.5,
|
||||||
|
pr: 5,
|
||||||
|
borderRadius: '12px',
|
||||||
|
bgcolor: 'background.level2',
|
||||||
|
maxHeight: 180,
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='soft'
|
||||||
|
color={copied ? 'success' : 'neutral'}
|
||||||
|
onClick={copyDetails}
|
||||||
|
sx={{ position: 'absolute', top: 6, right: 6, minWidth: 0 }}
|
||||||
|
aria-label='Copy diagnostics'
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckRounded fontSize='small' />
|
||||||
|
) : (
|
||||||
|
<ContentCopyRounded fontSize='small' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Typography
|
||||||
|
level='body-xs'
|
||||||
|
sx={{
|
||||||
|
fontFamily:
|
||||||
|
'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
color: 'text.secondary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{reportText || 'Collecting diagnostics…'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Typography
|
||||||
|
level='body-xs'
|
||||||
|
sx={{ color: 'text.tertiary', mt: 1, display: 'block' }}
|
||||||
|
>
|
||||||
|
No chore names, notes or attachments are included.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack spacing={1} sx={{ ...enter(220) }}>
|
||||||
|
<Button
|
||||||
|
size='lg'
|
||||||
|
fullWidth
|
||||||
|
loading={submitting}
|
||||||
|
disabled={!report}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
Send report
|
||||||
|
</Button>
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Link
|
||||||
|
component='button'
|
||||||
|
type='button'
|
||||||
|
level='body-sm'
|
||||||
|
color='neutral'
|
||||||
|
underline='hover'
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Not now
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === STEP.SENT && (
|
||||||
|
<Stack spacing={2} sx={{ textAlign: 'center' }}>
|
||||||
|
<Box sx={{ ...enter(0) }}>
|
||||||
|
<IconHalo icon={<CheckRounded />} color='success' />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ ...enter(50) }}>
|
||||||
|
<Typography level='h4' sx={{ fontWeight: 700 }}>
|
||||||
|
Report sent
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{ color: 'text.secondary', mt: 0.5 }}
|
||||||
|
>
|
||||||
|
Thanks — this goes straight to the people who can fix it.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
...enter(100),
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 1.5,
|
||||||
|
px: 2,
|
||||||
|
py: 1.25,
|
||||||
|
borderRadius: '16px',
|
||||||
|
bgcolor: 'background.surface',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ textAlign: 'left' }}>
|
||||||
|
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||||
|
Reference
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{report?.reportId}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant='soft'
|
||||||
|
color={copied ? 'success' : 'primary'}
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(report?.reportId ?? '')
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}}
|
||||||
|
sx={{ minWidth: 0 }}
|
||||||
|
aria-label='Copy reference'
|
||||||
|
>
|
||||||
|
{copied ? <CheckRounded /> : <ContentCopyRounded />}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button size='lg' fullWidth onClick={onClose} sx={{ ...enter(150) }}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === STEP.FALLBACK && (
|
||||||
|
<Stack spacing={2} sx={{ textAlign: 'center' }}>
|
||||||
|
<Box sx={{ ...enter(0) }}>
|
||||||
|
<IconHalo icon={<GitHub />} color='neutral' />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ ...enter(50) }}>
|
||||||
|
<Typography level='h4' sx={{ fontWeight: 700 }}>
|
||||||
|
Finish on GitHub
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
level='body-sm'
|
||||||
|
sx={{ color: 'text.secondary', mt: 0.5, textWrap: 'pretty' }}
|
||||||
|
>
|
||||||
|
Nothing has been sent. We've filled in an issue with your
|
||||||
|
notes and the diagnostics — review it and post when you're
|
||||||
|
happy with it.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack spacing={1} sx={{ ...enter(100) }}>
|
||||||
|
<Button
|
||||||
|
size='lg'
|
||||||
|
fullWidth
|
||||||
|
startDecorator={<GitHub />}
|
||||||
|
onClick={() => {
|
||||||
|
openUrl(githubUrl)
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Open pre-filled issue
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='plain'
|
||||||
|
color='neutral'
|
||||||
|
startDecorator={
|
||||||
|
copied ? <CheckRounded /> : <ContentCopyRounded />
|
||||||
|
}
|
||||||
|
onClick={copyDetails}
|
||||||
|
>
|
||||||
|
{copied ? 'Copied' : 'Copy details instead'}
|
||||||
|
</Button>
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Link
|
||||||
|
component='button'
|
||||||
|
type='button'
|
||||||
|
level='body-sm'
|
||||||
|
color='neutral'
|
||||||
|
underline='hover'
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</ResponsiveModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorReportModal
|
||||||
Reference in New Issue
Block a user