feat(app): add diagnostics and update runtime

This commit is contained in:
Codex
2026-06-06 20:42:34 +07:00
parent 925181a3b7
commit 7c40558cd3
23 changed files with 2232 additions and 2792 deletions

View File

@@ -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 })

View File

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

View 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)
})
}

View File

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

View File

@@ -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 })
}