feat(server): /api/info aggregator + frontend version footer (#40)
Some checks failed
CI/CD Pipeline / Build Docker Cache (push) Successful in 9s
CI/CD Pipeline / CI Pipeline (push) Failing after 4m48s
CI/CD Pipeline / Trigger Docker Push (push) Has been skipped

Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
This commit was merged in pull request #40.
This commit is contained in:
2026-05-05 08:29:26 +02:00
committed by arcodange
parent 4a3f1bb138
commit a2beadc458
16 changed files with 587 additions and 1 deletions

View File

@@ -1,3 +1,5 @@
<template>
<NuxtPage />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import AppFooterView, { type AppInfo } from './AppFooterView.vue'
// Wrapper: handles data fetching, delegates rendering to AppFooterView.
// Separation of concerns (SRP) - same pattern as HealthDashboard / HealthDashboardView.
// server: false → fetch client-side only. Avoids SSR fetching through the dev proxy
// (which can fail in some local setups), and lets Playwright route mocks apply.
const { data, pending, error } = useFetch<AppInfo>('/api/info', { server: false })
</script>
<template>
<AppFooterView :data="data" :pending="pending" :error="error" />
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { humaniseUptime } from '~/utils/uptime'
export interface AppInfo {
version: string
commit_short: string
build_date: string
uptime_seconds: number
cache_enabled: boolean
healthz_status: string
}
defineProps<{
data: AppInfo | null | undefined
pending: boolean
error: { message: string } | null | undefined
}>()
</script>
<template>
<footer data-testid="app-footer">
<p v-if="pending" data-testid="app-footer-pending">v?</p>
<p v-else-if="error" data-testid="app-footer-error">v? · info unavailable</p>
<p v-else-if="data" data-testid="app-footer-info">
<span data-testid="app-footer-version">v{{ data.version }}</span>
<span> · commit </span>
<span data-testid="app-footer-commit">{{ data.commit_short }}</span>
<span> · uptime </span>
<span data-testid="app-footer-uptime">{{ humaniseUptime(data.uptime_seconds) }}</span>
</p>
</footer>
</template>
<style scoped>
footer {
border-top: 1px solid #ccc;
padding: 0.5rem 1rem;
font-size: 0.85rem;
color: #555;
text-align: center;
}
footer p {
margin: 0;
}
</style>

View File

@@ -0,0 +1,17 @@
<template>
<div class="layout-root">
<slot />
<AppFooter />
</div>
</template>
<style scoped>
.layout-root {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.layout-root > :first-child {
flex: 1;
}
</style>

View File

@@ -0,0 +1,67 @@
import { test, expect } from '@playwright/test'
// Both specs mock /api/info so they decouple from the dev-proxy plumbing.
// The integration with the real backend is covered by the BDD scenario in
// features/info/info.feature (server-side, no frontend proxy in the loop).
test('home page footer shows version, commit and uptime', async ({ page }) => {
await page.route('**/api/info', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
version: '1.4.0',
commit_short: '4a3f1bb',
build_date: '2026-05-05T00:00:00Z',
uptime_seconds: 8042,
cache_enabled: true,
healthz_status: 'healthy',
}),
})
})
await page.goto('/')
// Footer is mounted globally via layouts/default.vue
await expect(page.getByTestId('app-footer')).toBeVisible()
// The PR #32 lesson: assert content, not just visibility.
// Without the regex check the test would PASS even if the footer rendered the
// pending placeholder ("v?") indefinitely.
await expect(page.getByTestId('app-footer-info')).toBeVisible()
const versionLocator = page.getByTestId('app-footer-version')
await expect(versionLocator).toBeVisible()
await expect(versionLocator).toHaveText(/^v\d+\.\d+\.\d+$/)
// Commit and uptime should be present and non-empty.
await expect(page.getByTestId('app-footer-commit')).not.toBeEmpty()
await expect(page.getByTestId('app-footer-uptime')).not.toBeEmpty()
await page.screenshot({
path: 'tests/e2e/screenshots/app-footer-shows-version-commit-uptime.png',
fullPage: true,
})
})
// Regression spec: documents the expected error UX so we don't ship a silent failure.
// Routes /api/info to a 502 mock so the test is reproducible regardless of backend.
test('home page footer surfaces info endpoint errors gracefully', async ({ page }) => {
await page.route('**/api/info', (route) => {
route.fulfill({
status: 502,
contentType: 'application/json',
body: JSON.stringify({ error: 'simulated_backend_down' }),
})
})
await page.goto('/')
// Footer must NOT crash the page
await expect(page.getByTestId('app-footer')).toBeVisible()
await expect(page.getByTestId('app-footer-error')).toBeVisible()
// The error placeholder should NOT contain a real version pattern
await expect(page.getByTestId('app-footer-info')).not.toBeVisible()
await page.screenshot({
path: 'tests/e2e/screenshots/app-footer-surfaces-info-endpoint-errors-gracefully.png',
fullPage: true,
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

16
frontend/utils/uptime.ts Normal file
View File

@@ -0,0 +1,16 @@
// Convert a duration in seconds to a humanised string like "2h 13m" or "45m 12s".
// Returns "?" for non-finite or negative input so the UI never renders NaN/empty.
export function humaniseUptime(seconds: number | null | undefined): string {
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return '?'
const s = Math.floor(seconds)
const days = Math.floor(s / 86400)
const hours = Math.floor((s % 86400) / 3600)
const minutes = Math.floor((s % 3600) / 60)
const secs = s % 60
if (days > 0) return `${days}d ${hours}h`
if (hours > 0) return `${hours}h ${minutes}m`
if (minutes > 0) return `${minutes}m ${secs}s`
return `${secs}s`
}