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 { 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 NavBar from '@/views/components/NavBar'
|
||||
|
||||
import { registerCapacitorListeners } from './CapacitorListener'
|
||||
import PageTransition from './components/animations/PageTransition'
|
||||
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
||||
import SSEProvider from './contexts/SSEContext'
|
||||
import { AuthProvider } from './hooks/useAuth.jsx'
|
||||
|
||||
import useOnboardingGate from './hooks/useOnboardingGate'
|
||||
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 { useResource } from './queries/ResourceQueries'
|
||||
import { recordRoute } from './service/DiagnosticsSession'
|
||||
import { useNotification } from './service/NotificationProvider'
|
||||
import NetworkBanner from './views/components/NetworkBanner'
|
||||
|
||||
const add = className => {
|
||||
@@ -32,8 +33,15 @@ const intervalMS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
const AppContent = () => {
|
||||
const { showNotification } = useNotification()
|
||||
const location = useLocation()
|
||||
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.
|
||||
useOnboardingGate()
|
||||
|
||||
@@ -41,8 +49,8 @@ const AppContent = () => {
|
||||
useStatusBar()
|
||||
|
||||
const {
|
||||
offlineReady: [offlineReady, setOfflineReady], // eslint-disable-line no-unused-vars
|
||||
needRefresh: [needRefresh, setNeedRefresh],
|
||||
offlineReady: [offlineReady, setOfflineReady],
|
||||
updateServiceWorker,
|
||||
} = useRegisterSW({
|
||||
onRegistered(r) {
|
||||
@@ -100,7 +108,7 @@ const AppContent = () => {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const resource = useResource() // eslint-disable-line no-unused-vars
|
||||
const resource = useResource()
|
||||
const { mode, systemMode } = useColorScheme()
|
||||
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 { API_URL } from '../Config'
|
||||
import { networkManager } from '../hooks/NetworkManager'
|
||||
import {
|
||||
recordApiFailure,
|
||||
recordServerVersionFromResponse,
|
||||
} from '../service/DiagnosticsSession'
|
||||
import { logout, RefreshToken } from './Fetcher'
|
||||
import { isOAuthExchangeInProgress } from './OAuthExchangeState'
|
||||
import { offlineDB } from './OfflineDB'
|
||||
@@ -129,7 +134,7 @@ class ApiClient {
|
||||
|
||||
// Process queued requests after refresh attempt
|
||||
processQueue(error, token = null) {
|
||||
this.failedQueue.forEach(({ resolve, reject }) => {
|
||||
this.failedQueue.forEach(({ reject, resolve }) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
@@ -199,6 +204,17 @@ class ApiClient {
|
||||
let response = await fetch(url, config)
|
||||
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)
|
||||
if (response.status === 401) {
|
||||
// Always queue this request first
|
||||
@@ -280,6 +296,7 @@ class ApiClient {
|
||||
error?.name === 'AbortError' && options.signal?.aborted
|
||||
if (!externalAbort) {
|
||||
networkManager.setServerUnreachable()
|
||||
recordApiFailure({ endpoint, method: config.method, status: 'network' })
|
||||
}
|
||||
console.error('Request failed', error)
|
||||
throw error
|
||||
|
||||
@@ -372,7 +372,6 @@ const MyChores = () => {
|
||||
}
|
||||
|
||||
processEffectAsync()
|
||||
// throw new Error('Fake Error to test posthog')
|
||||
}
|
||||
}, [
|
||||
membersLoading,
|
||||
|
||||
@@ -10,9 +10,15 @@ import {
|
||||
SearchOffRounded,
|
||||
} from '@mui/icons-material'
|
||||
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 {
|
||||
collectErrorReport,
|
||||
formatErrorReport,
|
||||
} from '../service/ErrorReportService'
|
||||
import ErrorReportModal from './Modals/ErrorReportModal'
|
||||
|
||||
const getErrorKind = error => {
|
||||
if (!error)
|
||||
return { label: 'Unknown Error', color: 'danger', Icon: ErrorRounded }
|
||||
@@ -48,27 +54,30 @@ const safeMessage = error => {
|
||||
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 = useRouteError()
|
||||
const [showDetails, setShowDetails] = 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 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 = () => {
|
||||
navigator.clipboard.writeText(buildErrorText(error, url)).then(() => {
|
||||
navigator.clipboard.writeText(reportText).then(() => {
|
||||
setCopied(true)
|
||||
})
|
||||
}
|
||||
@@ -209,11 +218,24 @@ const Error = () => {
|
||||
size='lg'
|
||||
startDecorator={<RefreshRounded />}
|
||||
onClick={() => window.location.reload()}
|
||||
sx={{ width: '100%', mb: 2 }}
|
||||
sx={{ width: '100%', mb: 1.5 }}
|
||||
>
|
||||
Try again
|
||||
</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 */}
|
||||
<Box sx={{ display: 'flex', gap: 3, mb: 5 }}>
|
||||
<Button
|
||||
@@ -251,16 +273,7 @@ const Error = () => {
|
||||
textAlign='center'
|
||||
sx={{ color: 'text.tertiary', mb: 1.5 }}
|
||||
>
|
||||
If this keeps happening,{' '}
|
||||
<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.
|
||||
If this keeps happening, send us a report please consider sending us report so we can take a look.
|
||||
</Typography>
|
||||
|
||||
{(error?.stack || message) && (
|
||||
@@ -313,7 +326,7 @@ const Error = () => {
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{buildErrorText(error, url)}
|
||||
{reportText || 'Collecting error details…'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
@@ -321,6 +334,12 @@ const Error = () => {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ErrorReportModal
|
||||
open={reportOpen}
|
||||
onClose={() => setReportOpen(false)}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
<Snackbar
|
||||
open={copied}
|
||||
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