From 3cb10394a3a59b8b0d5ac7fc5e268a0d4db70f41 Mon Sep 17 00:00:00 2001
From: Simon Malesys <simon.malesys@pasteur.fr>
Date: Thu, 27 Mar 2025 11:39:08 +0100
Subject: [PATCH] Add a waiting page for when the database is not ready yet

---
 src/client/api.ts                        |  3 +
 src/client/components/App.vue            | 19 +++++--
 src/client/components/AppTitle.vue       | 20 +++++++
 src/client/components/The404Page.vue     | 22 +++++++-
 src/client/components/TheHomePage.vue    | 14 +----
 src/client/components/TheWaitingPage.vue | 72 ++++++++++++++++++++++++
 src/client/router.ts                     |  5 ++
 src/server/app.js                        | 13 ++++-
 src/server/routes/readyDatabaseRoute.js  | 36 ++++++++++++
 9 files changed, 182 insertions(+), 22 deletions(-)
 create mode 100644 src/client/components/AppTitle.vue
 create mode 100644 src/client/components/TheWaitingPage.vue
 create mode 100644 src/server/routes/readyDatabaseRoute.js

diff --git a/src/client/api.ts b/src/client/api.ts
index 666d8989..51565650 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 6503fb9a..79657acf 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 00000000..8eaedf77
--- /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 b7e40624..144831c7 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 d81ccf51..8f1d92d5 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,6 +145,7 @@ const versionDate = computed(() => { return store.antibodiesVersionDate })
  * give the user a preview of what to expect after a search.
  */
 onMounted((): void => {
+  store.getStatistics()
   store.fetchAntibodies(store.request.fetchParams())
   store.countAntibodies(store.request.countParams())
 })
diff --git a/src/client/components/TheWaitingPage.vue b/src/client/components/TheWaitingPage.vue
new file mode 100644
index 00000000..eaf174bb
--- /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 6636662a..0e6d020b 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/server/app.js b/src/server/app.js
index ac0adf58..c5cd4bf1 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)
 
@@ -165,6 +170,7 @@ fastify.listen({
   port: envConfig.ABSD_SERVER_PORT
 }).then(async () => {
   if (envConfig.NODE_ENV === 'production') {
+    fastify.databaseIsReady = false
     return await buildDatabase()
   }
 }).then((dbResults) => {
@@ -173,6 +179,7 @@ fastify.listen({
   }
   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 00000000..9998ae59
--- /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 })
+  }
+}
-- 
GitLab