Skip to content

Commit 9cffa52

Browse files
committed
Improve backend and database connectivity error handling
Backend: - Add ping() to IDatabaseAdapter for isolated connectivity testing (MySQL + Postgres) - Refactor handleHealth: explicit connectivity test before schema/data queries - Return status "error" with dbStatus ("db_unreachable" / "schema_mismatch") and dbError - Auto-initialize database in health handler when DB becomes reachable after outage - Add periodic 30s DB liveness ping with edge-triggered logging (state transitions only) - Strip stack traces from user-facing db_unreachable error messages Frontend: - Show "Server Unreachable" error card (not permanent spinner) when backend is down at startup - Show "Connection Error" with DB details when health reports db_unreachable - Reorder health checks: status "error" before !initialized so db_unreachable is caught - BackendDisconnectedDialog poll now detects db_unreachable and shows DB error detail - Fix spinner centering by removing hardcoded width/height from progressIndicatorCard - Fix shouldComponentUpdate to include backendUnreachable and startupError - Replace unnecessary setStatePromise with setState where immediate return follows - Change "Backend connection restored" toast from error to info Signed-off-by: Mike Lischke <mike@lischke-online.de>
1 parent 146e25b commit 9cffa52

8 files changed

Lines changed: 316 additions & 40 deletions

File tree

src/App.scss

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,10 +135,57 @@ h6 {
135135
display: flex;
136136
justify-content: center;
137137
align-items: center;
138-
width: 180px;
139-
height: 180px;
140138
background: var(--color-base-100);
141-
border-radius: 12px;
139+
border-radius: 1rem;
140+
}
141+
142+
.backend-unreachable-card {
143+
background: var(--color-base-200);
144+
z-index: 9999;
145+
}
146+
147+
.backend-unreachable-content {
148+
text-align: center;
149+
max-width: 420px;
150+
padding: 2rem;
151+
background: var(--color-base-100);
152+
border-radius: 1rem;
153+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
154+
155+
h2 {
156+
margin: 0 0 0.75rem 0;
157+
font-size: 1.5rem;
158+
color: var(--color-error);
159+
}
160+
161+
p {
162+
margin: 0 0 1.5rem 0;
163+
line-height: 1.6;
164+
color: var(--color-base-content);
165+
}
166+
167+
.startup-error-message {
168+
margin: 0 0 1.5rem 0;
169+
padding: 0.75rem 1rem;
170+
background: var(--color-base-200);
171+
border-radius: 0.5rem;
172+
font-family: monospace;
173+
font-size: 0.875rem;
174+
line-height: 1.5;
175+
white-space: pre-wrap;
176+
word-break: break-word;
177+
color: var(--color-error);
178+
text-align: left;
179+
}
180+
181+
button {
182+
min-width: 120px;
183+
}
184+
}
185+
186+
.backend-unreachable-icon {
187+
font-size: 3rem;
188+
margin-bottom: 0.75rem;
142189
}
143190

144191
#wrapper {

src/App.tsx

Lines changed: 99 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,12 @@ interface IAppState {
113113
printOptions?: IPrintOptions;
114114

115115
instrumentEditorEnabled: boolean;
116+
117+
/** When true, the backend health endpoint was unreachable. */
118+
backendUnreachable: boolean;
119+
120+
/** Error message shown during startup when the backend or database is unreachable. */
121+
startupError?: string;
116122
}
117123

118124
export class App extends UIComponent<{}, IAppState> {
@@ -160,6 +166,7 @@ export class App extends UIComponent<{}, IAppState> {
160166
headerPinned: false,
161167
printing: false,
162168
instrumentEditorEnabled: false,
169+
backendUnreachable: false,
163170
};
164171

165172
const selectionManager = new SelectionManager();
@@ -188,12 +195,15 @@ export class App extends UIComponent<{}, IAppState> {
188195
}
189196

190197
public override shouldComponentUpdate(nextProps: {}, nextState: IAppState): boolean {
191-
const { displayMode, sidebarOpen, phase, headerPinned, printing } = this.state;
198+
const { displayMode, sidebarOpen, phase, headerPinned, printing, backendUnreachable,
199+
startupError } = this.state;
192200

193201
return displayMode !== nextState.displayMode
194202
|| sidebarOpen !== nextState.sidebarOpen || phase !== nextState.phase
195203
|| headerPinned !== nextState.headerPinned
196-
|| printing !== nextState.printing;
204+
|| printing !== nextState.printing
205+
|| backendUnreachable !== nextState.backendUnreachable
206+
|| startupError !== nextState.startupError;
197207
}
198208

199209
public override componentDidUpdate(_prevProps: {}, prevState: IAppState): void {
@@ -220,7 +230,7 @@ export class App extends UIComponent<{}, IAppState> {
220230

221231
public render() {
222232
const { phase, displayMode, sidebarOpen, headerPinned, instrumentEditorEnabled, printing,
223-
printOptions } = this.state;
233+
printOptions, backendUnreachable, startupError } = this.state;
224234
const isRunning = phase === AppPhase.Running;
225235

226236
let splashContent: ComponentChild;
@@ -321,6 +331,24 @@ export class App extends UIComponent<{}, IAppState> {
321331
</Button>;
322332
}
323333

334+
let checkingContent: ComponentChild;
335+
if (phase === AppPhase.Checking) {
336+
if (backendUnreachable) {
337+
checkingContent = this.renderBackendUnreachable();
338+
} else if (startupError) {
339+
checkingContent = this.renderStartupError(startupError);
340+
} else {
341+
checkingContent = (
342+
<div className="progressIndicatorCard" style={{
343+
position: "fixed", inset: 0, display: "flex",
344+
justifyContent: "center", alignItems: "center",
345+
}}>
346+
<ProgressIndicator />
347+
</div>
348+
);
349+
}
350+
}
351+
324352
return (
325353
<>
326354
{isRunning && (
@@ -550,14 +578,7 @@ export class App extends UIComponent<{}, IAppState> {
550578

551579
<ConfirmDialog ref={this.confirmDialogRef} />
552580

553-
{phase === AppPhase.Checking && (
554-
<div className="progressIndicatorCard" style={{
555-
position: "fixed", inset: 0, display: "flex",
556-
justifyContent: "center", alignItems: "center",
557-
}}>
558-
<ProgressIndicator />
559-
</div>
560-
)}
581+
{checkingContent}
561582

562583
<Container
563584
id="splashScreen"
@@ -574,15 +595,58 @@ export class App extends UIComponent<{}, IAppState> {
574595
}
575596

576597
/**
577-
* Checks if the backend is reachable and initialised. If not, opens the setup dialog.
578-
* Once the backend is ready, proceeds with data model initialisation.
579-
*
580-
* @returns A promise that resolves when the check is complete.
598+
* Retries the backend health check after a connection failure.
581599
*/
600+
private handleRetryConnection = (): void => {
601+
void this.setStatePromise({ backendUnreachable: false, startupError: undefined }).then(() => {
602+
return this.checkBackendThenInitialize();
603+
});
604+
};
605+
606+
private renderBackendUnreachable(): ComponentChild {
607+
return (
608+
<div className="backend-unreachable-card" style={{
609+
position: "fixed", inset: 0, display: "flex",
610+
flexDirection: "column", justifyContent: "center", alignItems: "center",
611+
}}>
612+
<div className="backend-unreachable-content">
613+
<div className="backend-unreachable-icon">⚠️</div>
614+
<h2>Server Unreachable</h2>
615+
<p>
616+
The Animada Score Book server could not be reached.
617+
Make sure the backend is running and try again.
618+
</p>
619+
<button className="du-btn du-btn-primary" onClick={this.handleRetryConnection}>
620+
Retry
621+
</button>
622+
</div>
623+
</div>
624+
);
625+
}
626+
627+
private renderStartupError(error: string): ComponentChild {
628+
return (
629+
<div className="backend-unreachable-card" style={{
630+
position: "fixed", inset: 0, display: "flex",
631+
flexDirection: "column", justifyContent: "center", alignItems: "center",
632+
}}>
633+
<div className="backend-unreachable-content">
634+
<div className="backend-unreachable-icon">⚠️</div>
635+
<h2>Connection Error</h2>
636+
<pre className="startup-error-message">{error}</pre>
637+
<button className="du-btn du-btn-primary" onClick={this.handleRetryConnection}>
638+
Retry
639+
</button>
640+
</div>
641+
</div>
642+
);
643+
}
644+
582645
private async checkBackendThenInitialize(): Promise<void> {
583646
let health: {
584647
status: string; configLoaded: boolean; configError?: string;
585-
initialized: boolean; hasUsers: boolean; dbError?: string;
648+
initialized: boolean; hasUsers: boolean; dbStatus?: string; dbError?: string;
649+
engine?: string; host?: string; port?: number; database?: string;
586650
} | undefined;
587651

588652
try {
@@ -593,7 +657,8 @@ export class App extends UIComponent<{}, IAppState> {
593657
}
594658

595659
if (!health) {
596-
// Backend not reachable — handled by the splash screen with a progress indicator.
660+
this.setState({ backendUnreachable: true });
661+
597662
return;
598663
}
599664

@@ -607,6 +672,23 @@ export class App extends UIComponent<{}, IAppState> {
607672
return;
608673
}
609674

675+
if (health.status === "error") {
676+
const { dbStatus, dbError: errorMsg, engine, host, port, database } = health;
677+
678+
if (dbStatus === "db_unreachable") {
679+
const connectionInfo = `${engine}://${host}:${port ?? ""}/${database ?? ""}`;
680+
this.setState({
681+
startupError: `${errorMsg ?? "Database unreachable"}\n\n`
682+
+ `Connection: ${connectionInfo}\n`
683+
+ "Is the database server running and is the IP address correct?",
684+
});
685+
686+
return;
687+
}
688+
689+
// Schema mismatch — fall through to the dbError path below.
690+
}
691+
610692
if (!health.initialized) {
611693
await this.setStatePromise({ phase: AppPhase.Setup });
612694
await this.backendSetupDialogRef.current?.show({

src/server/Router.ts

Lines changed: 104 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -353,44 +353,130 @@ export class Router {
353353
return;
354354
}
355355

356-
let hasData = false;
357-
let anyUsers = false;
358-
let dbError: string | undefined;
356+
if (!this.auth.adapter.isInitialized()) {
357+
// Pool not created yet — the backend may have failed to auto-connect or
358+
// is waiting for first-time setup. Test connectivity to distinguish the two.
359+
const connResult = await this.auth.adapter.testConnection(this.config.database);
360+
361+
if (!connResult.success) {
362+
this.ctx.sendJson(res, {
363+
status: "error",
364+
dbStatus: "db_unreachable",
365+
dbError: `Database unreachable: ${connResult.error ?? "unknown reason"}`,
366+
configLoaded: true,
367+
initialized: false,
368+
engine: this.config.database.engine,
369+
host: this.config.database.host,
370+
port: this.config.database.port,
371+
database: this.config.database.database,
372+
hasData: false,
373+
hasUsers: false,
374+
});
359375

360-
if (this.auth.adapter.isInitialized()) {
361-
const dbVersion = await this.auth.adapter.getSchemaVersion();
362-
363-
if (dbVersion === 0) {
364-
dbError = "Database schema is from an older version without version tracking. "
365-
+ "A reset is required.";
366-
} else if (dbVersion < schemaVersion) {
367-
dbError = `Database schema is version ${dbVersion}, `
368-
+ `but version ${schemaVersion} is required. Use Reset Database to upgrade.`;
376+
return;
369377
}
370378

371-
// Always query actual data counts so the frontend can show the correct confirmation dialog.
379+
// DB server is reachable — try to auto-initialise so the backend
380+
// self-heals when the database becomes available after a temporary outage.
372381
try {
382+
await this.auth.adapter.initialize(this.config.database);
383+
384+
const { engine, host, port, database } = this.config.database;
385+
console.log(
386+
`Backend initialised via health check: ${engine} @ ${host}:${port}/${database}`,
387+
);
388+
389+
// Run the same seeding as normal startup (idempotent — seed only if empty,
390+
// anonymous user only if missing).
373391
const rows = await this.auth.adapter.query<{ cnt: number; }>(
374392
"SELECT COUNT(*) AS cnt FROM folders",
375393
);
376394

377-
hasData = (rows[0]?.cnt ?? 0) > 0;
378-
anyUsers = await this.auth.hasUsers();
395+
if ((rows[0]?.cnt ?? 0) === 0) {
396+
await this.seedIfExists(this.auth.adapter);
397+
}
398+
399+
await this.seedAnonymousUser(this.auth.adapter);
400+
401+
// Fall through to the normal health path below (pool is now ready).
379402
} catch (e) {
380-
dbError = `Schema check failed: ${convertErrorToString(e)}`;
403+
this.ctx.sendJson(res, {
404+
status: "error",
405+
dbStatus: "db_unreachable",
406+
dbError: `Database initialisation failed: ${(e as Error).message}`,
407+
configLoaded: true,
408+
initialized: false,
409+
engine: this.config.database.engine,
410+
host: this.config.database.host,
411+
port: this.config.database.port,
412+
database: this.config.database.database,
413+
hasData: false,
414+
hasUsers: false,
415+
});
416+
417+
return;
381418
}
382419
}
383420

421+
// Isolated infrastructure check — a lightweight SELECT 1 on the existing pool.
422+
// If this fails we know it is a connectivity problem, not a schema or data issue.
423+
try {
424+
await this.auth.adapter.ping();
425+
} catch (e) {
426+
this.ctx.sendJson(res, {
427+
status: "error",
428+
dbStatus: "db_unreachable",
429+
dbError: `Database unreachable: ${(e as Error).message}`,
430+
configLoaded: true,
431+
initialized: true,
432+
engine: this.config.database.engine,
433+
host: this.config.database.host,
434+
port: this.config.database.port,
435+
database: this.config.database.database,
436+
hasData: false,
437+
hasUsers: false,
438+
});
439+
440+
return;
441+
}
442+
443+
// Connectivity is confirmed — schema and data queries are trusted to produce
444+
// meaningful errors if they fail.
445+
let hasData = false;
446+
let anyUsers = false;
447+
let dbError: string | undefined;
448+
let dbStatus: string | undefined;
449+
450+
const dbVersion = await this.auth.adapter.getSchemaVersion();
451+
452+
if (dbVersion === 0) {
453+
dbStatus = "schema_mismatch";
454+
dbError = "Database schema is from an older version without version tracking. "
455+
+ "A reset is required.";
456+
} else if (dbVersion < schemaVersion) {
457+
dbStatus = "schema_mismatch";
458+
dbError = `Database schema is version ${dbVersion}, `
459+
+ `but version ${schemaVersion} is required. Use Reset Database to upgrade.`;
460+
}
461+
462+
const rows = await this.auth.adapter.query<{ cnt: number; }>(
463+
"SELECT COUNT(*) AS cnt FROM folders",
464+
);
465+
466+
hasData = (rows[0]?.cnt ?? 0) > 0;
467+
anyUsers = await this.auth.hasUsers();
468+
384469
this.ctx.sendJson(res, {
385-
status: "ok",
470+
status: dbStatus ? "error" : "ok",
386471
configLoaded: true,
387-
initialized: this.auth.adapter.isInitialized(),
472+
initialized: true,
388473
engine: this.config.database.engine,
389474
host: this.config.database.host,
390475
port: this.config.database.port,
391476
database: this.config.database.database,
392477
hasData,
393478
hasUsers: anyUsers,
479+
dbStatus,
394480
dbError,
395481
});
396482
};

0 commit comments

Comments
 (0)