feat(fullstack): Add server health check with overlay

This commit is contained in:
2025-09-05 15:24:51 +02:00
parent 6090d543c1
commit 79c0734bd2
10 changed files with 201 additions and 8 deletions

View File

@@ -42,6 +42,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation "com.openhtmltopdf:openhtmltopdf-core:$openHtmlVersion"
implementation "com.openhtmltopdf:openhtmltopdf-pdfbox:$openHtmlVersion"
implementation "com.openhtmltopdf:openhtmltopdf-java2d:$openHtmlVersion"

View File

@@ -2,6 +2,7 @@ package com.betriebsratkanzlei.legalconsenthub.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.annotation.Order
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.invoke
@@ -16,24 +17,38 @@ import org.springframework.http.HttpMethod
class SecurityConfig {
@Bean
fun securityFilterChain(
@Order(1)
fun publicApiSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
http {
securityMatcher("/swagger-ui/**", "/v3/**", "/actuator/**", "/users")
csrf { disable() }
authorizeHttpRequests {
authorize("/swagger-ui/**", permitAll)
authorize("/v3/**", permitAll)
authorize("/actuator/**", permitAll)
// For user registration
authorize(HttpMethod.POST, "/users", permitAll)
authorize(anyRequest, denyAll)
}
}
return http.build()
}
@Bean
@Order(2)
fun protectedApiSecurityFilterChain(
http: HttpSecurity,
customJwtAuthenticationConverter: CustomJwtAuthenticationConverter
): SecurityFilterChain {
http {
csrf { disable() }
authorizeHttpRequests {
authorize("/swagger-ui/**", permitAll)
authorize("/v3/**", permitAll)
// For user registration
authorize(HttpMethod.POST, "/users", permitAll)
authorize(anyRequest, authenticated)
}
oauth2ResourceServer {
jwt { jwtAuthenticationConverter = customJwtAuthenticationConverter }
}
}
return http.build()
}

View File

@@ -5,6 +5,8 @@
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<ServerConnectionOverlay />
</UApp>
</template>

View File

@@ -0,0 +1,55 @@
<template>
<Teleport to="body">
<div
v-if="!isServerAvailable"
class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center"
@click.prevent
@keydown.prevent
>
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-xl p-8 max-w-md mx-4 text-center">
<!-- Loading Spinner -->
<div class="mb-6 flex justify-center">
<UIcon name="i-heroicons-arrow-path" class="w-12 h-12 text-primary-500 animate-spin" />
</div>
<!-- Title -->
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
{{ t('serverConnection.title') }}
</h2>
<!-- Description -->
<p class="text-gray-600 dark:text-gray-400 mb-6 leading-relaxed">
{{ t('serverConnection.message') }}
</p>
<!-- Status Information -->
<div class="space-y-2 text-sm text-gray-500 dark:text-gray-400">
<div v-if="isChecking" class="flex items-center justify-center gap-2">
<UIcon name="i-heroicons-arrow-path" class="w-4 h-4 animate-spin" />
<span>{{ t('serverConnection.checking') }}</span>
</div>
<div v-if="lastCheckTime" class="text-xs">
{{ t('serverConnection.lastCheck') }}:
{{ new Date(lastCheckTime).toLocaleTimeString() }}
</div>
<div class="text-xs">
{{ t('serverConnection.retryInfo') }}
</div>
</div>
<!-- Optional: Manual retry button -->
<UButton v-if="!isChecking" variant="ghost" size="sm" class="mt-4" @click="void checkServerHealth()">
<UIcon name="i-heroicons-arrow-path" class="w-4 h-4 mr-1" />
{{ t('serverConnection.retryNow') }}
</UButton>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
const { t } = useI18n()
const { isServerAvailable, isChecking, lastCheckTime, checkServerHealth } = useServerHealth()
</script>

View File

@@ -0,0 +1,92 @@
export const isServerAvailable = ref(true)
export const isChecking = ref(false)
export const lastCheckTime = ref<Date | null>(null)
export function useServerHealth() {
const checkInterval = ref<NodeJS.Timeout | null>(null)
const healthCheckUrl = 'http://localhost:3001/api/actuator/health'
async function checkServerHealth(): Promise<boolean> {
if (isChecking.value) return isServerAvailable.value
isChecking.value = true
lastCheckTime.value = new Date()
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
const response = await fetch(healthCheckUrl, {
method: 'GET',
signal: controller.signal,
headers: {
'Content-Type': 'application/json'
}
})
clearTimeout(timeoutId)
const wasAvailable = isServerAvailable.value
isServerAvailable.value = response.ok
if (!wasAvailable && isServerAvailable.value) {
console.info('Server is back online')
}
if (wasAvailable && !isServerAvailable.value) {
console.warn('Server is no longer available')
}
return isServerAvailable.value
} catch (error) {
const wasAvailable = isServerAvailable.value
isServerAvailable.value = false
if (wasAvailable) {
console.warn('Server health check failed:', error)
}
return false
} finally {
isChecking.value = false
}
}
async function startPeriodicHealthCheck(intervalMs: number = 60000) {
if (checkInterval.value) {
clearInterval(checkInterval.value)
}
checkServerHealth()
checkInterval.value = setInterval(() => {
checkServerHealth()
}, intervalMs)
onUnmounted(() => {
if (checkInterval.value) {
clearInterval(checkInterval.value)
checkInterval.value = null
}
})
return checkInterval.value
}
const stopHealthCheck = () => {
if (checkInterval.value) {
clearInterval(checkInterval.value)
checkInterval.value = null
}
}
return {
isServerAvailable,
isChecking,
lastCheckTime,
healthCheckUrl,
checkServerHealth,
startPeriodicHealthCheck,
stopHealthCheck
}
}

View File

@@ -5,5 +5,13 @@
"employer": "Arbeitgeber",
"worksCouncilMember": "Betriebsratsmitglied",
"worksCouncilChair": "Betriebsratsvorsitzender"
},
"serverConnection": {
"title": "Verbindung zum Server unterbrochen",
"message": "Die Verbindung zum Server ist momentan nicht verfügbar. Wir versuchen automatisch, die Verbindung wiederherzustellen.",
"checking": "Verbindung wird geprüft...",
"lastCheck": "Letzte Überprüfung",
"retryInfo": "Automatischer Wiederholungsversuch alle 60 Sekunden",
"retryNow": "Jetzt erneut versuchen"
}
}

View File

@@ -5,5 +5,13 @@
"employer": "Employer",
"worksCouncilMember": "Works Council Member",
"worksCouncilChair": "Works Council Chair"
},
"serverConnection": {
"title": "Server Connection Lost",
"message": "The connection to the server is currently unavailable. We are automatically trying to restore the connection.",
"checking": "Checking connection...",
"lastCheck": "Last check",
"retryInfo": "Automatic retry every 60 seconds",
"retryNow": "Try again now"
}
}

View File

@@ -17,7 +17,7 @@
"fix:bettersqlite": "cd node_modules/better-sqlite3 && pnpm dlx node-gyp rebuild && cd ../..",
"generate:betterauth": "pnpm dlx @better-auth/cli generate --config server/utils/auth.ts --yes",
"migrate:betterauth": "pnpm dlx @better-auth/cli migrate --config server/utils/auth.ts --yes",
"recreate-db:betterauth": "rm sqlite.db && pnpm run migrate:betterauth && pnpm run migrate:betterauth"
"recreate-db:betterauth": "[ -f sqlite.db ] && rm sqlite.db; pnpm run migrate:betterauth"
},
"dependencies": {
"@nuxt/ui-pro": "3.1.1",

View File

@@ -118,7 +118,6 @@ function onSubmit(payload: FormSubmitEvent<Schema>) {
},
onError: async (ctx) => {
console.log(ctx.error.message)
await deleteUser({ callbackURL: '/signup' })
useToast().add({
title: 'Fehler bei der Registrierung',
description: ctx.error.message,

View File

@@ -0,0 +1,13 @@
export default defineNuxtPlugin(() => {
// This plugin runs only on the client side to avoid issues with server-side rendering
if (import.meta.client) {
// Initialize server health monitoring as soon as the client is ready
const { startPeriodicHealthCheck } = useServerHealth()
// Start the health check with a 1-minute interval
// This ensures the health check starts even if app.vue's onMounted hasn't fired yet
nextTick(() => {
startPeriodicHealthCheck(60000)
})
}
})