Add error boundary, error page, and startup timeout
Prevent infinite spinner when DB connection fails at startup by adding a 10s timeout on connectActiveProfile(). Add ErrorBoundary to catch React crashes and ErrorPage with refresh, update check, and contact links. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f126d08da3
commit
849945f339
9 changed files with 239 additions and 12 deletions
|
|
@ -2,6 +2,11 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Error boundary catches React crashes and displays an error page instead of a white screen
|
||||||
|
- Startup timeout (10s) on database connection — shows error page instead of infinite spinner
|
||||||
|
- Error page with "Refresh", "Check for updates", and contact/issue links
|
||||||
|
|
||||||
## [0.4.4]
|
## [0.4.4]
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
12
CLAUDE.md
12
CLAUDE.md
|
|
@ -9,7 +9,7 @@
|
||||||
**Stockage :** SQLite local (tauri-plugin-sql)
|
**Stockage :** SQLite local (tauri-plugin-sql)
|
||||||
**Langues supportées :** Français (FR) et Anglais (EN)
|
**Langues supportées :** Français (FR) et Anglais (EN)
|
||||||
**Plateformes :** Windows, Linux
|
**Plateformes :** Windows, Linux
|
||||||
**Version actuelle :** 0.3.11
|
**Version actuelle :** 0.4.4
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── components/ # 49 composants React organisés par domaine
|
├── components/ # 53 composants React organisés par domaine
|
||||||
│ ├── adjustments/ # Ajustements
|
│ ├── adjustments/ # Ajustements
|
||||||
│ ├── budget/ # Budget
|
│ ├── budget/ # Budget
|
||||||
│ ├── categories/ # Catégories hiérarchiques
|
│ ├── categories/ # Catégories hiérarchiques
|
||||||
|
|
@ -67,7 +67,7 @@ src-tauri/
|
||||||
│ │ ├── schema.sql # Schéma initial (v1)
|
│ │ ├── schema.sql # Schéma initial (v1)
|
||||||
│ │ ├── seed_categories.sql # Seed catégories (v2)
|
│ │ ├── seed_categories.sql # Seed catégories (v2)
|
||||||
│ │ └── consolidated_schema.sql # Schéma complet (nouveaux profils)
|
│ │ └── consolidated_schema.sql # Schéma complet (nouveaux profils)
|
||||||
│ ├── lib.rs # Point d'entrée, 6 migrations inline, plugins
|
│ ├── lib.rs # Point d'entrée, 7 migrations inline, plugins
|
||||||
│ └── main.rs
|
│ └── main.rs
|
||||||
└── Cargo.toml
|
└── Cargo.toml
|
||||||
```
|
```
|
||||||
|
|
@ -114,8 +114,8 @@ src-tauri/
|
||||||
|
|
||||||
## Base de données
|
## Base de données
|
||||||
|
|
||||||
- **13 tables** SQLite, **9 index** (voir `docs/architecture.md` pour le détail)
|
- **13 tables** SQLite, **15 index** (voir `docs/architecture.md` pour le détail)
|
||||||
- **6 migrations inline** dans `lib.rs` (via `tauri_plugin_sql::Migration`)
|
- **7 migrations inline** dans `lib.rs` (via `tauri_plugin_sql::Migration`)
|
||||||
- **Schéma consolidé** (`consolidated_schema.sql`) pour l'initialisation des nouveaux profils
|
- **Schéma consolidé** (`consolidated_schema.sql`) pour l'initialisation des nouveaux profils
|
||||||
- Les migrations appliquées sont protégées par checksum — ne jamais modifier une migration existante, toujours en créer une nouvelle
|
- Les migrations appliquées sont protégées par checksum — ne jamais modifier une migration existante, toujours en créer une nouvelle
|
||||||
|
|
||||||
|
|
@ -153,7 +153,7 @@ Pour maintenir l'éligibilité aux crédits d'impôt R&D (RS&DE fédéral + CRIC
|
||||||
## CI/CD
|
## CI/CD
|
||||||
|
|
||||||
- GitHub Actions (`release.yml`) déclenché par tags `v*`
|
- GitHub Actions (`release.yml`) déclenché par tags `v*`
|
||||||
- Build Windows (NSIS `.exe`) + Linux (`.deb`, `.AppImage`)
|
- Build Windows (NSIS `.exe`) + Linux (`.deb`, `.rpm`)
|
||||||
- Signature des binaires + JSON d'updater pour mises à jour automatiques
|
- Signature des binaires + JSON d'updater pour mises à jour automatiques
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ simpl-resultat/
|
||||||
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
|
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
|
||||||
│ │ ├── reports/ # 8 composants (graphiques + rapport dynamique)
|
│ │ ├── reports/ # 8 composants (graphiques + rapport dynamique)
|
||||||
│ │ ├── settings/ # 2 composants
|
│ │ ├── settings/ # 2 composants
|
||||||
│ │ ├── shared/ # 4 composants réutilisables
|
│ │ ├── shared/ # 6 composants réutilisables
|
||||||
│ │ └── transactions/ # 5 composants
|
│ │ └── transactions/ # 5 composants
|
||||||
│ ├── contexts/ # ProfileContext (état global profil)
|
│ ├── contexts/ # ProfileContext (état global profil)
|
||||||
│ ├── hooks/ # 12 hooks custom (useReducer)
|
│ ├── hooks/ # 12 hooks custom (useReducer)
|
||||||
|
|
@ -176,6 +176,12 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
||||||
|
|
||||||
Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `AppShell` (sidebar + layout). L'accès est contrôlé par `ProfileContext` (gate).
|
Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `AppShell` (sidebar + layout). L'accès est contrôlé par `ProfileContext` (gate).
|
||||||
|
|
||||||
|
### Gestion d'erreurs
|
||||||
|
|
||||||
|
- **`ErrorBoundary`** (class component) : wrape `<App />` dans `main.tsx`, attrape les crashs React et affiche `ErrorPage` en fallback
|
||||||
|
- **`ErrorPage`** : page d'erreur réutilisable avec détails techniques (collapsible), bouton "Actualiser", vérification de mises à jour, et liens de contact/issues
|
||||||
|
- **Timeout au démarrage** : `App.tsx` applique un timeout de 10 secondes sur `connectActiveProfile()` — affiche `ErrorPage` au lieu d'un spinner infini si la connexion DB échoue
|
||||||
|
|
||||||
| Route | Page | Description |
|
| Route | Page | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `/` | `DashboardPage` | Tableau de bord avec graphiques |
|
| `/` | `DashboardPage` | Tableau de bord avec graphiques |
|
||||||
|
|
|
||||||
36
src/App.tsx
36
src/App.tsx
|
|
@ -1,5 +1,6 @@
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useProfile } from "./contexts/ProfileContext";
|
import { useProfile } from "./contexts/ProfileContext";
|
||||||
import AppShell from "./components/layout/AppShell";
|
import AppShell from "./components/layout/AppShell";
|
||||||
import DashboardPage from "./pages/DashboardPage";
|
import DashboardPage from "./pages/DashboardPage";
|
||||||
|
|
@ -12,19 +13,42 @@ import ReportsPage from "./pages/ReportsPage";
|
||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
import DocsPage from "./pages/DocsPage";
|
import DocsPage from "./pages/DocsPage";
|
||||||
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
|
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
|
||||||
|
import ErrorPage from "./components/shared/ErrorPage";
|
||||||
|
|
||||||
|
const STARTUP_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { activeProfile, isLoading, refreshKey, connectActiveProfile } = useProfile();
|
const { activeProfile, isLoading, refreshKey, connectActiveProfile } = useProfile();
|
||||||
const [dbReady, setDbReady] = useState(false);
|
const [dbReady, setDbReady] = useState(false);
|
||||||
|
const [startupError, setStartupError] = useState<string | null>(null);
|
||||||
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeProfile && !isLoading) {
|
if (activeProfile && !isLoading) {
|
||||||
setDbReady(false);
|
setDbReady(false);
|
||||||
|
setStartupError(null);
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setStartupError(t("error.startupTimeout"));
|
||||||
|
}, STARTUP_TIMEOUT_MS);
|
||||||
|
|
||||||
connectActiveProfile()
|
connectActiveProfile()
|
||||||
.then(() => setDbReady(true))
|
.then(() => {
|
||||||
.catch((err) => console.error("Failed to connect profile:", err));
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
setDbReady(true);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
console.error("Failed to connect profile:", err);
|
||||||
|
setStartupError(err instanceof Error ? err.message : String(err));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [activeProfile, isLoading, connectActiveProfile]);
|
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
};
|
||||||
|
}, [activeProfile, isLoading, connectActiveProfile, t]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -34,6 +58,10 @@ export default function App() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (startupError) {
|
||||||
|
return <ErrorPage error={startupError} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!activeProfile) {
|
if (!activeProfile) {
|
||||||
return <ProfileSelectionPage />;
|
return <ProfileSelectionPage />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
src/components/shared/ErrorBoundary.tsx
Normal file
34
src/components/shared/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { Component, type ReactNode } from "react";
|
||||||
|
import ErrorPage from "./ErrorPage";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error: error.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
console.error("ErrorBoundary caught an error:", error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return <ErrorPage error={this.state.error ?? undefined} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/components/shared/ErrorPage.tsx
Normal file
123
src/components/shared/ErrorPage.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AlertTriangle, ChevronDown, ChevronUp, RefreshCw, Download, Mail, Bug } from "lucide-react";
|
||||||
|
import { check } from "@tauri-apps/plugin-updater";
|
||||||
|
|
||||||
|
interface ErrorPageProps {
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ErrorPage({ error }: ErrorPageProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
|
const [updateStatus, setUpdateStatus] = useState<"idle" | "checking" | "available" | "upToDate" | "error">("idle");
|
||||||
|
const [updateVersion, setUpdateVersion] = useState<string | null>(null);
|
||||||
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCheckUpdate = async () => {
|
||||||
|
setUpdateStatus("checking");
|
||||||
|
setUpdateError(null);
|
||||||
|
try {
|
||||||
|
const update = await check();
|
||||||
|
if (update) {
|
||||||
|
setUpdateStatus("available");
|
||||||
|
setUpdateVersion(update.version);
|
||||||
|
} else {
|
||||||
|
setUpdateStatus("upToDate");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setUpdateStatus("error");
|
||||||
|
setUpdateError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-[var(--background)] p-4">
|
||||||
|
<div className="max-w-md w-full space-y-6 text-center">
|
||||||
|
<AlertTriangle className="mx-auto h-16 w-16 text-[var(--destructive)]" />
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold text-[var(--foreground)]">
|
||||||
|
{t("error.title")}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDetails(!showDetails)}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||||
|
>
|
||||||
|
{showDetails ? t("error.hideDetails") : t("error.showDetails")}
|
||||||
|
{showDetails ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
{showDetails && (
|
||||||
|
<pre className="mt-2 p-3 bg-[var(--muted)] rounded-md text-xs text-left text-[var(--muted-foreground)] overflow-auto max-h-40">
|
||||||
|
{error}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
{t("error.refresh")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleCheckUpdate}
|
||||||
|
disabled={updateStatus === "checking"}
|
||||||
|
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
{updateStatus === "checking" ? t("common.loading") : t("error.checkUpdate")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{updateStatus === "available" && updateVersion && (
|
||||||
|
<p className="text-sm text-[var(--primary)]">
|
||||||
|
{t("error.updateAvailable", { version: updateVersion })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{updateStatus === "upToDate" && (
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{t("error.upToDate")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{updateStatus === "error" && updateError && (
|
||||||
|
<p className="text-sm text-[var(--destructive)]">{updateError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-[var(--border)]">
|
||||||
|
<p className="text-sm font-medium text-[var(--foreground)] mb-3">
|
||||||
|
{t("error.contactUs")}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-2 text-sm">
|
||||||
|
<a
|
||||||
|
href="mailto:lacompagniemaximus@protonmail.com"
|
||||||
|
className="inline-flex items-center justify-center gap-2 text-[var(--primary)] hover:underline"
|
||||||
|
>
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
{t("error.contactEmail")} lacompagniemaximus@protonmail.com
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center justify-center gap-2 text-[var(--primary)] hover:underline"
|
||||||
|
>
|
||||||
|
<Bug className="h-4 w-4" />
|
||||||
|
{t("error.reportIssue")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -778,6 +778,20 @@
|
||||||
"manageProfiles": "Manage Profiles",
|
"manageProfiles": "Manage Profiles",
|
||||||
"default": "Default"
|
"default": "Default"
|
||||||
},
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "An error occurred",
|
||||||
|
"startupTimeout": "Database connection timed out",
|
||||||
|
"unexpectedError": "An unexpected error occurred",
|
||||||
|
"showDetails": "Show details",
|
||||||
|
"hideDetails": "Hide details",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"checkUpdate": "Check for updates",
|
||||||
|
"updateAvailable": "Update available: v{{version}}",
|
||||||
|
"upToDate": "The application is up to date",
|
||||||
|
"contactUs": "Contact us",
|
||||||
|
"contactEmail": "Send an email to",
|
||||||
|
"reportIssue": "Report an issue"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
|
|
||||||
|
|
@ -778,6 +778,20 @@
|
||||||
"manageProfiles": "Gérer les profils",
|
"manageProfiles": "Gérer les profils",
|
||||||
"default": "Par défaut"
|
"default": "Par défaut"
|
||||||
},
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "Une erreur est survenue",
|
||||||
|
"startupTimeout": "La connexion à la base de données a expiré",
|
||||||
|
"unexpectedError": "Une erreur inattendue s'est produite",
|
||||||
|
"showDetails": "Afficher les détails",
|
||||||
|
"hideDetails": "Masquer les détails",
|
||||||
|
"refresh": "Actualiser",
|
||||||
|
"checkUpdate": "Vérifier les mises à jour",
|
||||||
|
"updateAvailable": "Mise à jour disponible : v{{version}}",
|
||||||
|
"upToDate": "L'application est à jour",
|
||||||
|
"contactUs": "Nous contacter",
|
||||||
|
"contactEmail": "Envoyez un email à",
|
||||||
|
"reportIssue": "Signaler un problème"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,16 @@ import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { ProfileProvider } from "./contexts/ProfileContext";
|
import { ProfileProvider } from "./contexts/ProfileContext";
|
||||||
|
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
||||||
import "./i18n/config";
|
import "./i18n/config";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ProfileProvider>
|
<ProfileProvider>
|
||||||
|
<ErrorBoundary>
|
||||||
<App />
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</ProfileProvider>
|
</ProfileProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue