feat(app): add diagnostics and update runtime
This commit is contained in:
@@ -23,9 +23,13 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
// No remote telemetry — log to the local console so a curious user
|
||||
// (or dev tools session) can capture it.
|
||||
console.error('[ErrorBoundary]', error, info.componentStack)
|
||||
void window.api?.reportRendererError?.({
|
||||
source: 'ErrorBoundary',
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
componentStack: info.componentStack ?? undefined
|
||||
})
|
||||
}
|
||||
|
||||
reset = (): void => this.setState({ error: null })
|
||||
|
||||
@@ -224,6 +224,7 @@ export const ru: Dict = {
|
||||
'settings.section.language': 'Язык',
|
||||
'settings.section.updates': 'Обновления',
|
||||
'settings.section.data': 'Данные',
|
||||
'settings.section.diagnostics': 'Диагностика',
|
||||
'settings.data.export.label': 'Экспортировать всё',
|
||||
'settings.data.export.hint':
|
||||
'Сохрани резервную копию упражнений, истории, челленджей и настроек в JSON-файл.',
|
||||
@@ -238,6 +239,20 @@ export const ru: Dict = {
|
||||
'Все текущие упражнения, история и настройки будут заменены содержимым файла. Продолжить?',
|
||||
'settings.data.import.ok': 'Восстановлено',
|
||||
'settings.data.import.err': 'Файл не подошёл — это не наша резервная копия?',
|
||||
'settings.diagnostics.app.label': 'Приложение',
|
||||
'settings.diagnostics.data.label': 'Данные',
|
||||
'settings.diagnostics.data.legend': 'упр/еда/чел/ист',
|
||||
'settings.diagnostics.gsi.label': 'Dota GSI',
|
||||
'settings.diagnostics.hint':
|
||||
'Технический снимок без токенов: версии, пути, статусы и счетчики.',
|
||||
'settings.diagnostics.loading': 'Загружаем…',
|
||||
'settings.diagnostics.err': 'Не удалось собрать диагностику',
|
||||
'settings.diagnostics.refresh': 'Обновить диагностику',
|
||||
'settings.diagnostics.copy.btn': 'Копировать',
|
||||
'settings.diagnostics.copy.ok': 'Диагностика скопирована',
|
||||
'settings.diagnostics.logs.btn': 'Логи',
|
||||
'settings.diagnostics.logs.ok': 'Папка логов открыта',
|
||||
'settings.diagnostics.logs.err': 'Не удалось открыть папку логов',
|
||||
'settings.section.about': 'О приложении',
|
||||
'settings.version.label': 'Версия',
|
||||
'settings.version.hint': 'Текущая установленная версия приложения.',
|
||||
@@ -623,6 +638,7 @@ export const en: Dict = {
|
||||
'settings.section.language': 'Language',
|
||||
'settings.section.updates': 'Updates',
|
||||
'settings.section.data': 'Data',
|
||||
'settings.section.diagnostics': 'Diagnostics',
|
||||
'settings.data.export.label': 'Export everything',
|
||||
'settings.data.export.hint':
|
||||
'Save a backup of exercises, history, challenges and settings to a JSON file.',
|
||||
@@ -637,6 +653,20 @@ export const en: Dict = {
|
||||
'All current exercises, history and settings will be replaced with the file contents. Continue?',
|
||||
'settings.data.import.ok': 'Restored',
|
||||
'settings.data.import.err': "Couldn't read the file — not our backup?",
|
||||
'settings.diagnostics.app.label': 'Application',
|
||||
'settings.diagnostics.data.label': 'Data',
|
||||
'settings.diagnostics.data.legend': 'ex/meal/ch/hist',
|
||||
'settings.diagnostics.gsi.label': 'Dota GSI',
|
||||
'settings.diagnostics.hint':
|
||||
'Technical snapshot without tokens: versions, paths, statuses and counts.',
|
||||
'settings.diagnostics.loading': 'Loading…',
|
||||
'settings.diagnostics.err': 'Could not collect diagnostics',
|
||||
'settings.diagnostics.refresh': 'Refresh diagnostics',
|
||||
'settings.diagnostics.copy.btn': 'Copy',
|
||||
'settings.diagnostics.copy.ok': 'Diagnostics copied',
|
||||
'settings.diagnostics.logs.btn': 'Logs',
|
||||
'settings.diagnostics.logs.ok': 'Logs folder opened',
|
||||
'settings.diagnostics.logs.err': 'Could not open logs folder',
|
||||
'settings.section.about': 'About',
|
||||
'settings.version.label': 'Version',
|
||||
'settings.version.hint': 'Currently installed app version.',
|
||||
|
||||
32
src/renderer/src/lib/reporting.ts
Normal file
32
src/renderer/src/lib/reporting.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
function errorFields(err: unknown): { message: string; stack?: string } {
|
||||
if (err instanceof Error) {
|
||||
return {
|
||||
message: err.message || err.name,
|
||||
stack: err.stack
|
||||
}
|
||||
}
|
||||
if (typeof err === 'string') return { message: err }
|
||||
try {
|
||||
return { message: JSON.stringify(err) }
|
||||
} catch {
|
||||
return { message: String(err) }
|
||||
}
|
||||
}
|
||||
|
||||
function report(source: string, err: unknown): void {
|
||||
const { message, stack } = errorFields(err)
|
||||
if (!message) return
|
||||
void window.api
|
||||
?.reportRendererError?.({ source, message, stack })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
export function installRendererErrorReporting(): void {
|
||||
window.addEventListener('error', (event) => {
|
||||
report('window.error', event.error ?? event.message)
|
||||
})
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
report('window.unhandledrejection', event.reason)
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import './styles/globals.css'
|
||||
import App from './App'
|
||||
import ReminderApp from './ReminderApp'
|
||||
import { ThemeProvider } from './providers/ThemeProvider'
|
||||
import { installRendererErrorReporting } from './lib/reporting'
|
||||
|
||||
installRendererErrorReporting()
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const which = params.get('window') ?? 'main'
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Copy, FolderOpen, RefreshCw } from 'lucide-react'
|
||||
import { useAppStore } from '../store/appStore'
|
||||
import { Switch } from '../components/ui/Switch'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Card, Row, SectionHeader } from '../components/ui/Card'
|
||||
import { UpdaterCard } from '../components/UpdaterCard'
|
||||
import { WhatsNewModal } from '../components/WhatsNewModal'
|
||||
@@ -8,14 +10,16 @@ import { ConfirmModal } from '../components/ui/ConfirmModal'
|
||||
import { Skeleton } from '../components/ui/Skeleton'
|
||||
import { Spinner } from '../components/ui/Spinner'
|
||||
import { RELEASE_NOTES } from '@shared/release-notes'
|
||||
import { useT } from '../i18n'
|
||||
import { translate, useT } from '../i18n'
|
||||
import type {
|
||||
DiagnosticsInfo,
|
||||
Language,
|
||||
NotificationMode,
|
||||
QuietHours,
|
||||
Settings as SettingsType,
|
||||
Theme
|
||||
} from '@shared/types'
|
||||
import { parseHHMM } from '@shared/types'
|
||||
|
||||
export default function SettingsPage(): JSX.Element {
|
||||
const settings = useAppStore((s) => s.state?.settings)
|
||||
@@ -192,6 +196,11 @@ export default function SettingsPage(): JSX.Element {
|
||||
<DataCard />
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<SectionHeader title={t('settings.section.diagnostics')} />
|
||||
<DiagnosticsCard />
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<SectionHeader title={t('settings.section.about')} />
|
||||
<AboutCard />
|
||||
@@ -201,6 +210,153 @@ export default function SettingsPage(): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
function DiagnosticsCard(): JSX.Element {
|
||||
const { t, lang } = useT()
|
||||
const [info, setInfo] = useState<DiagnosticsInfo | null>(null)
|
||||
const [busy, setBusy] = useState<'refresh' | 'copy' | 'logs' | null>(null)
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
setBusy('refresh')
|
||||
try {
|
||||
setInfo(await window.api.getDiagnostics())
|
||||
} catch {
|
||||
setToast(translate(lang, 'settings.diagnostics.err'))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}, [lang])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return
|
||||
const id = setTimeout(() => setToast(null), 4000)
|
||||
return () => clearTimeout(id)
|
||||
}, [toast])
|
||||
|
||||
async function copy(): Promise<void> {
|
||||
setBusy('copy')
|
||||
try {
|
||||
setInfo(await window.api.copyDiagnostics())
|
||||
setToast(t('settings.diagnostics.copy.ok'))
|
||||
} catch {
|
||||
setToast(t('settings.diagnostics.err'))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function openLogs(): Promise<void> {
|
||||
setBusy('logs')
|
||||
try {
|
||||
const r = await window.api.openLogsFolder()
|
||||
setToast(
|
||||
r.ok
|
||||
? t('settings.diagnostics.logs.ok')
|
||||
: t('settings.diagnostics.logs.err')
|
||||
)
|
||||
} catch {
|
||||
setToast(t('settings.diagnostics.logs.err'))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const appLine = info
|
||||
? `v${info.app.version} · Electron ${info.runtime.electron}`
|
||||
: t('settings.diagnostics.loading')
|
||||
const dataLine = info
|
||||
? `${info.store.exercises}/${info.store.meals}/${info.store.challenges}/${info.store.history}`
|
||||
: '—'
|
||||
const gsiLine = info
|
||||
? `${info.gsi.running ? 'live' : 'off'} · ${info.gsi.baseUrl}`
|
||||
: '—'
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Row>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[15px] font-semibold leading-tight">
|
||||
{t('settings.diagnostics.app.label')}
|
||||
</div>
|
||||
<div className="text-[13px] text-text/65 mt-1 leading-snug">
|
||||
{appLine}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="plain"
|
||||
onClick={refresh}
|
||||
disabled={busy !== null}
|
||||
title={t('settings.diagnostics.refresh')}
|
||||
aria-label={t('settings.diagnostics.refresh')}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</Button>
|
||||
</Row>
|
||||
<Row>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[15px] font-semibold leading-tight">
|
||||
{t('settings.diagnostics.data.label')}
|
||||
</div>
|
||||
<div className="text-[13px] text-text/65 mt-1 leading-snug">
|
||||
{dataLine}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[12px] text-text/50 font-semibold whitespace-nowrap">
|
||||
{t('settings.diagnostics.data.legend')}
|
||||
</div>
|
||||
</Row>
|
||||
<Row>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[15px] font-semibold leading-tight">
|
||||
{t('settings.diagnostics.gsi.label')}
|
||||
</div>
|
||||
<div className="text-[13px] text-text/65 mt-1 leading-snug truncate">
|
||||
{gsiLine}
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
<Row last className="flex-wrap justify-end">
|
||||
<div className="flex-1 min-w-[180px] text-[13px] text-text/65 leading-snug">
|
||||
{t('settings.diagnostics.hint')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="tinted"
|
||||
onClick={openLogs}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
{t('settings.diagnostics.logs.btn')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="filled"
|
||||
onClick={copy}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
<Copy size={16} />
|
||||
{t('settings.diagnostics.copy.btn')}
|
||||
</Button>
|
||||
</div>
|
||||
</Row>
|
||||
{toast && (
|
||||
<div className="px-4 py-2.5 text-[13px] text-text/75 bg-accent/8 truncate font-medium">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function AboutCard(): JSX.Element {
|
||||
const { t } = useT()
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -399,11 +555,10 @@ function QuietTimesRow({
|
||||
}): JSX.Element {
|
||||
const { t } = useT()
|
||||
// Local mirror of from/to so typing doesn't fire an IPC + disk write per
|
||||
// keystroke. We commit on blur (or when validation passes during typing).
|
||||
// The HH:MM regex catches the moment the user has typed a full time.
|
||||
// keystroke. We commit on blur and only send values accepted by the shared
|
||||
// HH:MM parser.
|
||||
const [from, setFrom] = useState(qh.from)
|
||||
const [to, setTo] = useState(qh.to)
|
||||
const HHMM = /^\d{1,2}:\d{2}$/
|
||||
|
||||
// Sync from props when an external state change happens (lang switch,
|
||||
// pause toggle), but only if user isn't mid-edit.
|
||||
@@ -417,7 +572,7 @@ function QuietTimesRow({
|
||||
const commit = (next: { from?: string; to?: string }): void => {
|
||||
const f = next.from ?? from
|
||||
const tt = next.to ?? to
|
||||
if (!HHMM.test(f) || !HHMM.test(tt)) return
|
||||
if (parseHHMM(f) === null || parseHHMM(tt) === null) return
|
||||
if (f === qh.from && tt === qh.to) return
|
||||
onChange({ ...qh, from: f, to: tt })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user