✨ feat(server): /api/info aggregator + frontend version footer (#40)
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:
@@ -1,3 +1,5 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
13
frontend/components/AppFooter.vue
Normal file
13
frontend/components/AppFooter.vue
Normal 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>
|
||||
45
frontend/components/AppFooterView.vue
Normal file
45
frontend/components/AppFooterView.vue
Normal 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>
|
||||
17
frontend/layouts/default.vue
Normal file
17
frontend/layouts/default.vue
Normal 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>
|
||||
67
frontend/tests/e2e/app-footer.spec.ts
Normal file
67
frontend/tests/e2e/app-footer.spec.ts
Normal 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
16
frontend/utils/uptime.ts
Normal 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`
|
||||
}
|
||||
Reference in New Issue
Block a user