Skip to content

Commit 8e10fbe

Browse files
authored
Merge pull request #2853 from Brain-up/feat/server-down-error-page
Show server-down page when server is unavailable
2 parents 38c8093 + 77944d1 commit 8e10fbe

10 files changed

Lines changed: 323 additions & 25 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { TOC } from '@ember/component/template-only';
2+
import isServerError from 'brn/utils/is-server-error';
3+
import ServerDown from 'brn/components/server-down';
4+
5+
interface Signature {
6+
Args: {
7+
model: unknown;
8+
};
9+
}
10+
11+
function errorMessage(error: unknown): string {
12+
if (error instanceof Error) return error.message;
13+
return String(error ?? '');
14+
}
15+
16+
const ErrorPage: TOC<Signature> = <template>
17+
{{#if (isServerError @model)}}
18+
<ServerDown />
19+
{{else}}
20+
oooops...
21+
<pre class="overflow-x-auto whitespace-pre-wrap break-words">
22+
{{errorMessage @model}}
23+
</pre>
24+
{{/if}}
25+
</template>;
26+
27+
export default ErrorPage;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { t } from 'ember-intl';
2+
3+
const TELEGRAM_URL = 'https://t.me/BrainUpUsers';
4+
5+
<template>
6+
<div class="flex min-h-[60vh] items-center justify-center" data-test-server-down>
7+
<div class="mx-auto max-w-lg rounded-lg bg-white p-8 text-center shadow-lg">
8+
<div class="mb-4 text-6xl">&#x26A0;</div>
9+
<h1 class="mb-4 text-2xl font-bold text-gray-800" data-test-server-down-title>
10+
{{t "server_down.title"}}
11+
</h1>
12+
<p class="mb-4 text-gray-600" data-test-server-down-message>
13+
{{t "server_down.message"}}
14+
</p>
15+
<p class="mb-2 text-gray-600">
16+
{{t "server_down.telegram_prompt"}}
17+
</p>
18+
<a
19+
href={{TELEGRAM_URL}}
20+
target="_blank"
21+
rel="noopener noreferrer"
22+
class="mb-4 inline-block text-lg font-semibold text-blue-600 hover:text-blue-800 hover:underline"
23+
data-test-server-down-telegram-link
24+
>
25+
{{TELEGRAM_URL}}
26+
</a>
27+
<p class="mt-4 text-sm text-gray-500" data-test-server-down-fix-promise>
28+
{{t "server_down.fix_promise"}}
29+
</p>
30+
</div>
31+
</div>
32+
</template>

frontend/app/routes/application.js

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { service } from '@ember/service';
33
import { isTesting } from '@embroider/macros';
44
import translationsForRuRu from 'virtual:ember-intl/translations/ru-ru';
55
import translationsForEnUs from 'virtual:ember-intl/translations/en-us';
6+
import isServerError from 'brn/utils/is-server-error';
67

78
export default class ApplicationRoute extends Route {
89
@service('session') session;
@@ -14,24 +15,28 @@ export default class ApplicationRoute extends Route {
1415
async beforeModel() {
1516
await this.session.setup();
1617

18+
this.intl.addTranslations('ru-ru', translationsForRuRu);
19+
this.intl.addTranslations('en-us', translationsForEnUs);
20+
21+
const navigatorLanguage = navigator.languages.filter(el => el.includes('-')).map(el => el.toLowerCase())[0];
22+
const rawLocale = localStorage.getItem('locale') || navigatorLanguage;
23+
const locale = rawLocale === 'ru-ru' ? 'ru-ru' : 'en-us';
24+
this.intl.setLocale([locale]);
25+
1726
if (this.session.isAuthenticated) {
1827
try {
1928
await Promise.all([
2029
this.network.loadCurrentUser(),
2130
this.tasksManager.loadTodayCompletedExercises(),
2231
]);
23-
} catch {
24-
// handled by loadCurrentUser (redirects to login)
32+
} catch (e) {
33+
// Re-throw server/network errors so the error template is shown
34+
if (isServerError(e)) {
35+
throw e;
36+
}
37+
// Other errors (e.g. 401) are handled by loadCurrentUser (redirects to login)
2538
}
2639
}
27-
28-
this.intl.addTranslations('ru-ru', translationsForRuRu);
29-
this.intl.addTranslations('en-us', translationsForEnUs);
30-
31-
const navigatorLanguage = navigator.languages.filter(el => el.includes('-')).map(el => el.toLowerCase())[0];
32-
const rawLocale = localStorage.getItem('locale') || navigatorLanguage;
33-
const locale = rawLocale === 'ru-ru' ? 'ru-ru' : 'en-us';
34-
this.intl.setLocale([locale]);
3540
}
3641

3742
redirect(_/* : unknown */, { to }/*: Transition*/) {

frontend/app/templates/error.gts

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,8 @@
1-
import type { TOC } from '@ember/component/template-only';
21
import RouteTemplate from 'ember-route-template';
2+
import ErrorPage from 'brn/components/error-page';
33

4-
interface Signature {
5-
Args: {
6-
model: string;
7-
};
8-
}
9-
10-
const tpl: TOC<Signature> = <template>
11-
oooops...
12-
<pre class="overflow-x-auto whitespace-pre-wrap break-words">
13-
{{@model}}
14-
</pre>
15-
</template>;
16-
17-
export default RouteTemplate(tpl);
4+
export default RouteTemplate<{ model: unknown }>(
5+
<template>
6+
<ErrorPage @model={{@model}} />
7+
</template>
8+
);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
interface ErrorLike {
2+
status?: number;
3+
code?: number;
4+
isRequestError?: boolean;
5+
name?: string;
6+
message?: string;
7+
errors?: Array<{ status?: number | string }>;
8+
}
9+
10+
function isErrorLike(error: unknown): error is ErrorLike {
11+
return typeof error === 'object' && error !== null;
12+
}
13+
14+
/**
15+
* Detects whether an error represents a server/network failure
16+
* (as opposed to a client error like 401/404).
17+
*
18+
* Returns true for:
19+
* - WarpDrive FetchError with status >= 500 or status 0 (network error)
20+
* - HTTP 5xx errors from error payloads
21+
* - TypeError from fetch (network failures)
22+
*/
23+
export default function isServerError(error: unknown): boolean {
24+
if (!isErrorLike(error)) return false;
25+
26+
// WarpDrive FetchError with status >= 500 or network error (status 0)
27+
if (error.isRequestError) {
28+
const status = error.status ?? error.code ?? 0;
29+
return status === 0 || status >= 500;
30+
}
31+
32+
// HTTP 5xx from error payload
33+
const status = error.status ?? Number(error.errors?.[0]?.status);
34+
if (status && status >= 500) return true;
35+
36+
// Network failures (fetch throws TypeError for network errors)
37+
if (error instanceof TypeError && error.message?.includes('fetch')) return true;
38+
39+
return false;
40+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// @ts-nocheck -- QUnit test context typing not supported with @types/qunit v2.9
2+
import { module, test } from 'qunit';
3+
import { setupIntl } from 'ember-intl/test-support';
4+
import { setupRenderingTest } from 'ember-qunit';
5+
import { render } from '@ember/test-helpers';
6+
import ServerDown from 'brn/components/server-down';
7+
8+
module('Integration | Component | server-down', function (hooks) {
9+
setupRenderingTest(hooks);
10+
setupIntl(hooks, 'en-us');
11+
12+
test('it renders the server down page', async function (assert) {
13+
await render(<template><ServerDown /></template>);
14+
15+
assert.dom('[data-test-server-down]').exists();
16+
assert.dom('[data-test-server-down-title]').hasText('t:server_down.title');
17+
assert.dom('[data-test-server-down-message]').hasText('t:server_down.message');
18+
});
19+
20+
test('it shows the Telegram link with correct attributes', async function (assert) {
21+
await render(<template><ServerDown /></template>);
22+
23+
const link = assert.dom('[data-test-server-down-telegram-link]');
24+
link.hasAttribute('href', 'https://t.me/BrainUpUsers');
25+
link.hasAttribute('target', '_blank');
26+
link.hasAttribute('rel', 'noopener noreferrer');
27+
link.hasText('https://t.me/BrainUpUsers');
28+
});
29+
30+
test('it shows the fix promise message', async function (assert) {
31+
await render(<template><ServerDown /></template>);
32+
33+
assert.dom('[data-test-server-down-fix-promise]').hasText('t:server_down.fix_promise');
34+
});
35+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// @ts-nocheck -- QUnit test context typing not supported with @types/qunit v2.9
2+
import { module, test } from 'qunit';
3+
import { setupIntl } from 'ember-intl/test-support';
4+
import { setupRenderingTest } from 'ember-qunit';
5+
import { render } from '@ember/test-helpers';
6+
import ErrorPage from 'brn/components/error-page';
7+
8+
module('Integration | Component | error-page', function (hooks) {
9+
setupRenderingTest(hooks);
10+
setupIntl(hooks, 'en-us');
11+
12+
test('it renders ServerDown for WarpDrive FetchError with status 500', async function (assert) {
13+
const model = { isRequestError: true, status: 500, code: 500 };
14+
15+
await render(<template><ErrorPage @model={{model}} /></template>);
16+
17+
assert.dom('[data-test-server-down]').exists('shows server-down page');
18+
assert.dom('pre').doesNotExist('does not show generic error');
19+
});
20+
21+
test('it renders ServerDown for network error (status 0)', async function (assert) {
22+
const model = { isRequestError: true, status: 0, code: 0 };
23+
24+
await render(<template><ErrorPage @model={{model}} /></template>);
25+
26+
assert.dom('[data-test-server-down]').exists('shows server-down page for network error');
27+
});
28+
29+
test('it renders ServerDown for status 503', async function (assert) {
30+
const model = { isRequestError: true, status: 503, code: 503 };
31+
32+
await render(<template><ErrorPage @model={{model}} /></template>);
33+
34+
assert.dom('[data-test-server-down]').exists('shows server-down page for 503');
35+
});
36+
37+
test('it renders ServerDown for plain status 500 object', async function (assert) {
38+
const model = { status: 500 };
39+
40+
await render(<template><ErrorPage @model={{model}} /></template>);
41+
42+
assert.dom('[data-test-server-down]').exists('shows server-down page for plain 500');
43+
});
44+
45+
test('it renders generic error for client errors (status 404)', async function (assert) {
46+
const model = { isRequestError: true, status: 404, code: 404 };
47+
48+
await render(<template><ErrorPage @model={{model}} /></template>);
49+
50+
assert.dom('[data-test-server-down]').doesNotExist('does not show server-down page');
51+
assert.dom('pre').exists('shows generic error pre block');
52+
});
53+
54+
test('it renders generic error for string model', async function (assert) {
55+
const model = 'Something went wrong';
56+
57+
await render(<template><ErrorPage @model={{model}} /></template>);
58+
59+
assert.dom('[data-test-server-down]').doesNotExist('does not show server-down page');
60+
assert.dom('pre').hasText('Something went wrong');
61+
});
62+
63+
test('it renders generic error for a regular Error object', async function (assert) {
64+
const model = new Error('Unknown failure');
65+
66+
await render(<template><ErrorPage @model={{model}} /></template>);
67+
68+
assert.dom('[data-test-server-down]').doesNotExist('does not show server-down page');
69+
assert.dom('pre').exists('shows generic error');
70+
});
71+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { module, test } from 'qunit';
2+
import isServerError from 'brn/utils/is-server-error';
3+
4+
module('Unit | Utils | is-server-error', function () {
5+
test('returns false for null/undefined', function (assert) {
6+
assert.false(isServerError(null));
7+
assert.false(isServerError(undefined));
8+
});
9+
10+
test('returns false for non-object values', function (assert) {
11+
assert.false(isServerError('some string error'));
12+
assert.false(isServerError(42));
13+
assert.false(isServerError(true));
14+
});
15+
16+
test('returns true for WarpDrive FetchError with status 500', function (assert) {
17+
const error = { isRequestError: true, status: 500, code: 500 };
18+
assert.true(isServerError(error));
19+
});
20+
21+
test('returns true for WarpDrive FetchError with status 503', function (assert) {
22+
const error = { isRequestError: true, status: 503, code: 503 };
23+
assert.true(isServerError(error));
24+
});
25+
26+
test('returns true for WarpDrive FetchError with status 0 (network error)', function (assert) {
27+
const error = { isRequestError: true, status: 0, code: 0 };
28+
assert.true(isServerError(error));
29+
});
30+
31+
test('returns false for WarpDrive FetchError with status 401', function (assert) {
32+
const error = { isRequestError: true, status: 401, code: 401 };
33+
assert.false(isServerError(error));
34+
});
35+
36+
test('returns false for WarpDrive FetchError with status 404', function (assert) {
37+
const error = { isRequestError: true, status: 404, code: 404 };
38+
assert.false(isServerError(error));
39+
});
40+
41+
test('returns true for error with status 500 in errors array', function (assert) {
42+
const error = { errors: [{ status: 500 }] };
43+
assert.true(isServerError(error));
44+
});
45+
46+
test('returns true for error with string status "502" in errors array', function (assert) {
47+
const error = { errors: [{ status: '502' }] };
48+
assert.true(isServerError(error));
49+
});
50+
51+
test('returns false for error with status 400 in errors array', function (assert) {
52+
const error = { errors: [{ status: 400 }] };
53+
assert.false(isServerError(error));
54+
});
55+
56+
test('returns true for TypeError with fetch message', function (assert) {
57+
const error = new TypeError('Failed to fetch');
58+
assert.true(isServerError(error));
59+
});
60+
61+
test('returns false for TypeError without fetch message', function (assert) {
62+
const error = new TypeError('Cannot read property of undefined');
63+
assert.false(isServerError(error));
64+
});
65+
66+
test('returns false for AbortError (user-initiated navigation)', function (assert) {
67+
const error = { name: 'AbortError', message: 'The operation was aborted' };
68+
assert.false(isServerError(error));
69+
});
70+
71+
test('returns false for generic Error', function (assert) {
72+
const error = new Error('Something went wrong');
73+
assert.false(isServerError(error));
74+
});
75+
76+
test('returns true for direct status 500 on error object', function (assert) {
77+
const error = { status: 500 };
78+
assert.true(isServerError(error));
79+
});
80+
81+
test('returns false for direct status 422 on error object', function (assert) {
82+
const error = { status: 422 };
83+
assert.false(isServerError(error));
84+
});
85+
});

frontend/translations/en-us.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,12 @@ doctor:
462462
add_failed: Failed to add patient
463463
header_link: Patients
464464

465+
server_down:
466+
title: Server is not responding
467+
message: "Our server is currently not responding: it may be reloading or experiencing an issue."
468+
telegram_prompt: "Please let us know in our Telegram chat:"
469+
fix_promise: We will try to fix the problem as soon as possible.
470+
465471
contributors:
466472
title: "Our Team"
467473
subtitle: "We are all here to make your life easier!"

frontend/translations/ru-ru.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,12 @@ doctor:
463463
add_failed: Не удалось добавить пациента
464464
header_link: Пациенты
465465

466+
server_down:
467+
title: Сервер не отвечает
468+
message: "Наш сервер сейчас не отвечает: он перезагружается или возникла какая-то проблема."
469+
telegram_prompt: "Пожалуйста, напишите об этом в наш Telegram чат:"
470+
fix_promise: Мы постараемся исправить проблему как можно скорее.
471+
466472
contributors:
467473
title: "Наша команда"
468474
subtitle: "Мы собрались все вместе, чтобы сделать вашу жизнь проще!"

0 commit comments

Comments
 (0)