feat(fullstack): Add server health check with overlay
This commit is contained in:
@@ -42,6 +42,7 @@ dependencies {
|
|||||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
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-core:$openHtmlVersion"
|
||||||
implementation "com.openhtmltopdf:openhtmltopdf-pdfbox:$openHtmlVersion"
|
implementation "com.openhtmltopdf:openhtmltopdf-pdfbox:$openHtmlVersion"
|
||||||
implementation "com.openhtmltopdf:openhtmltopdf-java2d:$openHtmlVersion"
|
implementation "com.openhtmltopdf:openhtmltopdf-java2d:$openHtmlVersion"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.betriebsratkanzlei.legalconsenthub.config
|
|||||||
|
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
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.builders.HttpSecurity
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||||
import org.springframework.security.config.annotation.web.invoke
|
import org.springframework.security.config.annotation.web.invoke
|
||||||
@@ -16,24 +17,38 @@ import org.springframework.http.HttpMethod
|
|||||||
class SecurityConfig {
|
class SecurityConfig {
|
||||||
|
|
||||||
@Bean
|
@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,
|
http: HttpSecurity,
|
||||||
customJwtAuthenticationConverter: CustomJwtAuthenticationConverter
|
customJwtAuthenticationConverter: CustomJwtAuthenticationConverter
|
||||||
): SecurityFilterChain {
|
): SecurityFilterChain {
|
||||||
http {
|
http {
|
||||||
csrf { disable() }
|
csrf { disable() }
|
||||||
authorizeHttpRequests {
|
authorizeHttpRequests {
|
||||||
authorize("/swagger-ui/**", permitAll)
|
|
||||||
authorize("/v3/**", permitAll)
|
|
||||||
// For user registration
|
|
||||||
authorize(HttpMethod.POST, "/users", permitAll)
|
|
||||||
authorize(anyRequest, authenticated)
|
authorize(anyRequest, authenticated)
|
||||||
}
|
}
|
||||||
oauth2ResourceServer {
|
oauth2ResourceServer {
|
||||||
jwt { jwtAuthenticationConverter = customJwtAuthenticationConverter }
|
jwt { jwtAuthenticationConverter = customJwtAuthenticationConverter }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return http.build()
|
return http.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
|
|
||||||
|
<ServerConnectionOverlay />
|
||||||
</UApp>
|
</UApp>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
55
legalconsenthub/components/ServerConnectionOverlay.vue
Normal file
55
legalconsenthub/components/ServerConnectionOverlay.vue
Normal 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>
|
||||||
92
legalconsenthub/composables/useServerHealth.ts
Normal file
92
legalconsenthub/composables/useServerHealth.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,5 +5,13 @@
|
|||||||
"employer": "Arbeitgeber",
|
"employer": "Arbeitgeber",
|
||||||
"worksCouncilMember": "Betriebsratsmitglied",
|
"worksCouncilMember": "Betriebsratsmitglied",
|
||||||
"worksCouncilChair": "Betriebsratsvorsitzender"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,13 @@
|
|||||||
"employer": "Employer",
|
"employer": "Employer",
|
||||||
"worksCouncilMember": "Works Council Member",
|
"worksCouncilMember": "Works Council Member",
|
||||||
"worksCouncilChair": "Works Council Chair"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"fix:bettersqlite": "cd node_modules/better-sqlite3 && pnpm dlx node-gyp rebuild && cd ../..",
|
"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",
|
"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",
|
"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": {
|
"dependencies": {
|
||||||
"@nuxt/ui-pro": "3.1.1",
|
"@nuxt/ui-pro": "3.1.1",
|
||||||
|
|||||||
@@ -118,7 +118,6 @@ function onSubmit(payload: FormSubmitEvent<Schema>) {
|
|||||||
},
|
},
|
||||||
onError: async (ctx) => {
|
onError: async (ctx) => {
|
||||||
console.log(ctx.error.message)
|
console.log(ctx.error.message)
|
||||||
await deleteUser({ callbackURL: '/signup' })
|
|
||||||
useToast().add({
|
useToast().add({
|
||||||
title: 'Fehler bei der Registrierung',
|
title: 'Fehler bei der Registrierung',
|
||||||
description: ctx.error.message,
|
description: ctx.error.message,
|
||||||
|
|||||||
13
legalconsenthub/plugins/server-health.client.ts
Normal file
13
legalconsenthub/plugins/server-health.client.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user