diff --git a/k8s/absd.yaml b/k8s/absd.yaml index 0886fd57178dc80e9627331a4b59d54c1d2ed795..2a097309ecb77bde3685bcd83b51e2e9ff8683ae 100644 --- a/k8s/absd.yaml +++ b/k8s/absd.yaml @@ -63,9 +63,9 @@ spec: httpGet: path: /ready port: 3000 - initialDelaySeconds: 30 - periodSeconds: 30 - timeoutSeconds: 5 + initialDelaySeconds: 0 + periodSeconds: 5 + timeoutSeconds: 2 failureThreshold: 5 resources: requests: diff --git a/src/client/api.ts b/src/client/api.ts index 666d89893cf47ac7060361c0a8ee1c7e355297c3..515656503ec0bc2eeedf784744a2f39444058304 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -69,5 +69,8 @@ export default Object.freeze({ }, archiveURL: (archiveName: string): string => { return `/api/downloads/${archiveName}` + }, + databaseIsReady: async (): Promise<AxiosResponse<any>> => { + return await base.get('/api/readyDatabase') } }) diff --git a/src/client/components/App.vue b/src/client/components/App.vue index 6503fb9a969ee8981b13c636bcaeac30e8310dab..79657acfe231035b5b72e8ca529390ecfc7540ad 100644 --- a/src/client/components/App.vue +++ b/src/client/components/App.vue @@ -14,14 +14,23 @@ <script setup lang="ts"> import { onMounted } from 'vue' -import { useStore } from '../store' import AppFooter from './AppFooter.vue' import AppHeader from './AppHeader.vue' +import router from '../router' +import api from '../api' +import { AxiosError } from 'axios' -const store = useStore() - -onMounted(() => { - store.getStatistics() +onMounted(async () => { + await api.databaseIsReady() + .then() + .catch((err: AxiosError) => { + if (err.status === 503) { + router.replace({ name: 'WaitingPage' }) + } else { + alert(err.message) + console.log(err) + } + }) }) </script> diff --git a/src/client/components/AppTitle.vue b/src/client/components/AppTitle.vue new file mode 100644 index 0000000000000000000000000000000000000000..8eaedf77c205a35df0a4291da8bbd41b54d8bb46 --- /dev/null +++ b/src/client/components/AppTitle.vue @@ -0,0 +1,20 @@ +<template> + <h1> + <img + src="/absd-title.png" + alt="ABSD logo with title" + height="90" + width="325"> + <span class="title-for-a11y"> + ABSD - AntiBody Sequence database + </span> + </h1> +</template> + +<script setup lang="ts"></script> + +<style scoped> +.title-for-a11y { + display: none; +} +</style> diff --git a/src/client/components/The404Page.vue b/src/client/components/The404Page.vue index b7e40624d18c6e7f1f938076a19f993338aa7802..144831c76f951ebca356928df6d2c99b8bbf2b1f 100644 --- a/src/client/components/The404Page.vue +++ b/src/client/components/The404Page.vue @@ -1,17 +1,33 @@ <template> - <h1>404 - Not Found</h1> - <p>The page or resource you requested doesn't seem to exist here...</p> + <AppTitle></AppTitle> + <section> + <h2>404 - Not Found</h2> + <p>The page or resource you requested doesn't seem to exist here...</p> + <RouterLink :to="{ name: 'HomePage' }"> + Go back home + </RouterLink> + </section> </template> <script setup lang="ts"> +import AppTitle from './AppTitle.vue' </script> <style scoped> +section { + display: flex; + flex-flow: column; + align-items: center; + gap: var(--spacing); +} + +h2, p { text-align: center; } -p { +a, p { font-size: 20px; + margin-top: 0; } </style> diff --git a/src/client/components/TheHomePage.vue b/src/client/components/TheHomePage.vue index d81ccf518dc22630ddbb8faae7d0443b4e35e3a0..11f92be7eea4eefdce8cf47c9f9013a2a84febab 100644 --- a/src/client/components/TheHomePage.vue +++ b/src/client/components/TheHomePage.vue @@ -1,15 +1,5 @@ <template> - <h1> - <img - src="/absd-title.png" - alt="ABSD logo with title" - height="90" - width="325" - > - <span class="title-for-a11y"> - ABSD - AntiBody Sequence database - </span> - </h1> + <AppTitle></AppTitle> <section class="page-description"> <p> @@ -141,6 +131,7 @@ <script setup lang="ts"> import { computed, onMounted } from 'vue' import { useStore } from '../store' +import AppTitle from './AppTitle.vue' import AntibodiesTable from './AntibodiesTable.vue' import NoResults from './NoResults.vue' import SearchBar from './SearchBar.vue' @@ -154,8 +145,10 @@ const versionDate = computed(() => { return store.antibodiesVersionDate }) * give the user a preview of what to expect after a search. */ onMounted((): void => { - store.fetchAntibodies(store.request.fetchParams()) - store.countAntibodies(store.request.countParams()) + store.getStatistics().then(() => { + store.fetchAntibodies(store.request.fetchParams()) + store.countAntibodies(store.request.countParams()) + }) }) </script> diff --git a/src/client/components/TheWaitingPage.vue b/src/client/components/TheWaitingPage.vue new file mode 100644 index 0000000000000000000000000000000000000000..eaf174bb744663f73f5b0ee740cdd979e52169cf --- /dev/null +++ b/src/client/components/TheWaitingPage.vue @@ -0,0 +1,72 @@ +<template> + <AppTitle></AppTitle> + + <section> + <p> + The Database is currently being updated, + please come back in a few minutes. + </p> + + <div class="spinner"></div> + </section> +</template> + +<script setup lang="ts"> +import { onMounted, onUnmounted } from 'vue' +import AppTitle from './AppTitle.vue' +import api from '../api' +import { AxiosError } from 'axios' +import router from '../router' + +/** ID of the interval timer started on page load */ +let intervalID: number + +/** + * After page load, check for database readiness immediately + * and every 5 seconds. + */ +onMounted(() => { + checkDatabaseIsReady() + intervalID = setInterval(checkDatabaseIsReady, 5000) +}) + +/** + * Remove the timer when leaving the page to ensure + * memory safety. + */ +onUnmounted(() => { + clearInterval(intervalID) +}) + +/** + * Check is the database is ready and reroute to + * the home page in that case. + */ +function checkDatabaseIsReady() { + api.databaseIsReady() + .then(() => { + router.replace({ name: 'HomePage' }) + }) + .catch((err: AxiosError) => { + if (err.status === 503) return + else { + alert(err.message) + console.log(err) + } + }) +} +</script> + +<style scoped> +section { + align-items: center; + display: flex; + flex-flow: column wrap; + gap: var(--spacing); +} + +.spinner { + height: 50px; + width: 50px; +} +</style> diff --git a/src/client/router.ts b/src/client/router.ts index 6636662a552b9e6bd0fbe93da0feded1a5181d01..0e6d020b67548bad8fa68a83d6664759fb822220 100644 --- a/src/client/router.ts +++ b/src/client/router.ts @@ -51,6 +51,11 @@ export default createRouter({ component: async () => await import('./components/TheAboutPage.vue'), name: 'AboutPage' }, + { + path: '/waiting', + component: async () => await import('./components/TheWaitingPage.vue'), + name: 'WaitingPage' + }, { path: '/:pathMatch(.*)*', component: async () => await import('./components/The404Page.vue'), diff --git a/src/client/store.ts b/src/client/store.ts index 402666a3012f542cfcdcb98cb0fda0976716f83e..e003d5e0cd8d2b90368418d4f85671f3c51d4809 100644 --- a/src/client/store.ts +++ b/src/client/store.ts @@ -170,8 +170,8 @@ export const useStore = defineStore('store', { * Get the database statistics from the server and stores it in the * corresponding variable in the store. */ - getStatistics(): void { - api.getStatistics().then(response => { + getStatistics(): Promise<void> { + return api.getStatistics().then(response => { this.statistics = Object.freeze(response.data) }).catch((err: AxiosError) => { alert(err.message) diff --git a/src/server/app.js b/src/server/app.js index ac0adf5803e008023db408782618f603b13c5e76..8465c03daf4c9c3198f60a4bbe8385b0f7fcccd2 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -36,6 +36,7 @@ import downloadsFileRoute from './routes/downloadsFileRoute.js' import downloadsRoute from './routes/downloadsRoute.js' import healthCheckRoute from './routes/healthCheckRoute.js' import readyCheckRoute from './routes/readyCheckRoute.js' +import readyDatabaseRoute from './routes/readyDatabaseRoute.js' import statisticsRoute from './routes/statisticsRoute.js' /** @@ -86,9 +87,10 @@ fastify.register(fastifyMongo, { // Route controllers // ========================================================================= -// Decorate the server with a boolean marking when it is ready. -// Used by the readyCheck route. +// Decorate the server with booleans marking when resources are ready. +// Used by the readiness routes. fastify.decorate('serverIsReady', false) +fastify.decorate('databaseIsReady', true) // Decorate the server with an object // to store the workers while then run. @@ -101,7 +103,7 @@ fastify.addHook('onRequest', async (request) => { request.where = {} }) -// Register all controllers +// Register all API controllers fastify.route(antibodiesCountRoute) fastify.route(antibodiesDownloadArchiveFileRoute) fastify.route(antibodiesDownloadArchiveRoute) @@ -114,6 +116,9 @@ fastify.route(antibodiesVGeneSegmentsRoute) fastify.route(downloadsFileRoute) fastify.route(downloadsRoute) fastify.route(statisticsRoute) +fastify.route(readyDatabaseRoute) + +// Register health checks fastify.route(healthCheckRoute) fastify.route(readyCheckRoute) @@ -164,15 +169,19 @@ fastify.listen({ host: envConfig.ABSD_SERVER_HOST, port: envConfig.ABSD_SERVER_PORT }).then(async () => { + fastify.serverIsReady = true + fastify.log.info({ envConfig }) + if (envConfig.NODE_ENV === 'production') { + fastify.databaseIsReady = false return await buildDatabase() } }).then((dbResults) => { if (dbResults) { fastify.log.info(`${envConfig.ABSD_DB_NAME} database created`) } - fastify.log.info({ envConfig }) - fastify.serverIsReady = true + + fastify.databaseIsReady = true }).catch(err => { fastify.log.error(err) process.exit(1) diff --git a/src/server/routes/readyDatabaseRoute.js b/src/server/routes/readyDatabaseRoute.js new file mode 100644 index 0000000000000000000000000000000000000000..9998ae593a0f3308a21ed44d391281b34ac17fe3 --- /dev/null +++ b/src/server/routes/readyDatabaseRoute.js @@ -0,0 +1,36 @@ +// ABSD +// Copyright (C) 2025 Institut Pasteur +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +/** + * Route returning if the database is available or not. + * False when the database is inserting data. + */ +export default { + method: 'GET', + url: '/api/readyDatabase', + logLevel: 'warn', + handler: function (request, reply) { + if (this.databaseIsReady) { + return reply + .code(200) + .send({ ready: true }) + } + + return reply + .code(503) + .send({ ready: false }) + } +}