WP-40/41/42: dashboard, security hardening, deployment and operations docs

Dashboard endpoint and page aggregating inventory, vulnerabilities, cluster
health, backups and recent jobs. Security headers (CSP, nosniff, DENY,
referrer policy), 1 MB body limit, configurable login rate limit, audit
steps in CI. Installer script, systemd unit, install/architecture docs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 23:26:01 +02:00
parent a89395ae18
commit 9234e1ba47
29 changed files with 851 additions and 21 deletions

View File

@ -231,3 +231,32 @@ export interface StrategyStatus extends BackupStrategyView {
target_name: string
last_backup: BackupRecord | null
}
export interface Dashboard {
inventory: {
refreshed_at: string | null
os: OsInfo | null
total: number
upgradable: number
security: number
reboot_required: boolean
}
vulnerabilities: VulnSummary
cluster: {
reachable: boolean
error: string | null
nodes_ready: number
nodes: number
workloads: number
unhealthy: number
}
backups: {
id: string
name: string
enabled: boolean
schedule: string
last_backup: BackupRecord | null
}[]
recent_jobs: Omit<JobRun, 'log'>[]
failed_jobs_24h: number
}

View File

@ -0,0 +1,101 @@
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import DashboardPage from './DashboardPage.vue'
import { api } from '../api/client'
import type { Dashboard } from '../api/types'
vi.mock('../api/client', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}))
vi.mock('vue-router', () => ({
RouterLink: { template: '<a><slot /></a>' },
useRouter: () => ({ push: vi.fn() }),
}))
const data: Dashboard = {
inventory: {
refreshed_at: '2026-09-02T10:00:00Z',
os: {
hostname: 'srv',
name: 'Debian GNU/Linux 12 (bookworm)',
version: '12',
kernel: '6.1',
uptime_secs: 100,
reboot_required: true,
},
total: 430,
upgradable: 3,
security: 2,
reboot_required: true,
},
vulnerabilities: {
total: { critical: 5, high: 10, medium: 2, low: 1, unknown: 0 },
os: { critical: 1, high: 2, medium: 0, low: 0, unknown: 0 },
images: { critical: 4, high: 8, medium: 2, low: 1, unknown: 0 },
last_scan: '2026-09-02T09:00:00Z',
scanner: '0.74.0',
},
cluster: { reachable: true, error: null, nodes_ready: 1, nodes: 1, workloads: 16, unhealthy: 1 },
backups: [
{ id: 'b1', name: 'Gitea DB', enabled: true, schedule: '0 0 2 * * *', last_backup: null },
{
id: 'b2',
name: 'Repos',
enabled: true,
schedule: '0 0 3 * * *',
last_backup: {
id: 'r',
strategy_id: 'b2',
filename: 'repos_x.tar.gz',
size_bytes: 1000,
sha256: 'x',
created_at: '2026-09-02T03:00:00Z',
},
},
],
recent_jobs: [
{
id: 'j1',
kind: 'vulnerability_scan',
status: 'failed',
started_at: '2026-09-02T09:00:00Z',
finished_at: '2026-09-02T09:05:00Z',
triggered_by: 'scheduler',
},
],
failed_jobs_24h: 1,
}
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(api.get).mockResolvedValue(data)
})
describe('DashboardPage', () => {
it('renders the key numbers of every area', async () => {
const w = mount(DashboardPage)
await flushPromises()
expect(w.text()).toContain('Debian GNU/Linux 12')
expect(w.find('[data-testid=tile-upgradable]').text()).toContain('3')
expect(w.text()).toContain('Reboot required')
expect(w.find('[data-testid=tile-critical]').text()).toContain('5')
expect(w.find('[data-testid=tile-workloads]').text()).toContain('16')
expect(w.text()).toContain('1 unhealthy')
expect(w.text()).toContain('Gitea DB')
expect(w.text()).toContain('never')
expect(w.text()).toContain('repos_x.tar.gz')
expect(w.find('[data-testid=tile-failed-jobs]').text()).toContain('1')
})
it('shows the cluster as unreachable with the error', async () => {
vi.mocked(api.get).mockResolvedValue({
...data,
cluster: { ...data.cluster, reachable: false, error: 'connection refused' },
})
const w = mount(DashboardPage)
await flushPromises()
expect(w.text()).toContain('unreachable')
expect(w.text()).toContain('connection refused')
})
})

View File

@ -1,6 +1,156 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, ApiError } from '../api/client'
import type { Dashboard } from '../api/types'
import { useToastStore } from '../stores/toast'
const toast = useToastStore()
const d = ref<Dashboard | null>(null)
onMounted(async () => {
try {
d.value = await api.get<Dashboard>('/api/dashboard')
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Request failed')
}
})
const fmt = (s: string | null | undefined) => (s ? new Date(s).toLocaleString() : 'never')
const tile = 'block rounded-lg border border-gray-200 bg-white p-4 hover:border-blue-300'
</script>
<template>
<h1 class="text-2xl font-semibold">Dashboard</h1>
<p class="mt-2 text-gray-600">
Server overview, update status, vulnerabilities and backups will appear here.
</p>
<p v-if="!d" class="mt-2 text-gray-600">Loading overview…</p>
<template v-if="d">
<div class="mt-6 grid gap-4 md:grid-cols-4">
<RouterLink to="/updates" :class="tile" class="md:col-span-2">
<div class="text-xs uppercase text-gray-500">Operating system</div>
<div class="mt-1 text-lg font-medium">
{{ d.inventory.os?.name ?? 'not inventoried yet' }}
</div>
<div class="text-sm text-gray-600">
<template v-if="d.inventory.os"
>{{ d.inventory.os.hostname }} · kernel {{ d.inventory.os.kernel }} ·
{{ d.inventory.total }} packages</template
>
<template v-else>Run a package refresh on the Updates page.</template>
</div>
<span
v-if="d.inventory.reboot_required"
class="mt-2 inline-block rounded-md bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800"
>Reboot required</span
>
</RouterLink>
<RouterLink to="/updates" :class="tile" data-testid="tile-upgradable">
<div class="text-xs uppercase text-gray-500">Upgradable packages</div>
<div
class="mt-1 text-2xl font-semibold"
:class="d.inventory.security ? 'text-red-700' : ''"
>
{{ d.inventory.upgradable }}
</div>
<div class="text-sm text-gray-600">
{{ d.inventory.security }} security · refreshed {{ fmt(d.inventory.refreshed_at) }}
</div>
</RouterLink>
<RouterLink to="/vulnerabilities" :class="tile" data-testid="tile-critical">
<div class="text-xs uppercase text-gray-500">Vulnerabilities</div>
<div class="mt-1 text-2xl font-semibold text-red-700">
{{ d.vulnerabilities.total.critical }}
</div>
<div class="text-sm text-gray-600">
critical · {{ d.vulnerabilities.total.high }} high · scan
{{ fmt(d.vulnerabilities.last_scan) }}
</div>
</RouterLink>
<RouterLink to="/cluster" :class="tile" data-testid="tile-workloads">
<div class="text-xs uppercase text-gray-500">Kubernetes</div>
<template v-if="d.cluster.reachable">
<div class="mt-1 text-2xl font-semibold">{{ d.cluster.workloads }}</div>
<div class="text-sm" :class="d.cluster.unhealthy ? 'text-red-700' : 'text-gray-600'">
workloads · {{ d.cluster.unhealthy }} unhealthy · {{ d.cluster.nodes_ready }}/{{
d.cluster.nodes
}}
nodes ready
</div>
</template>
<template v-else>
<div class="mt-1 text-lg font-medium text-red-700">unreachable</div>
<div class="text-sm text-gray-600">{{ d.cluster.error }}</div>
</template>
</RouterLink>
<RouterLink to="/jobs" :class="tile" data-testid="tile-failed-jobs">
<div class="text-xs uppercase text-gray-500">Failed jobs (24h)</div>
<div class="mt-1 text-2xl font-semibold" :class="d.failed_jobs_24h ? 'text-red-700' : ''">
{{ d.failed_jobs_24h }}
</div>
<div class="text-sm text-gray-600">{{ d.recent_jobs.length }} recent runs</div>
</RouterLink>
</div>
<div class="mt-8 grid gap-6 lg:grid-cols-2">
<section class="rounded-lg border border-gray-200 bg-white p-4">
<div class="flex items-center justify-between">
<h2 class="font-medium">Backups</h2>
<RouterLink to="/backups" class="text-sm text-blue-600 hover:underline"
>Manage</RouterLink
>
</div>
<table class="mt-3 w-full text-left text-sm">
<tbody>
<tr
v-for="b in d.backups"
:key="b.id"
class="border-t border-gray-100"
:class="b.enabled ? '' : 'text-gray-400'"
>
<td class="py-1.5 font-medium">{{ b.name }}</td>
<td class="font-mono text-xs">{{ b.schedule }}</td>
<td class="text-xs">
<template v-if="b.last_backup"
>{{ b.last_backup.filename }}<br /><span class="text-gray-500">{{
fmt(b.last_backup.created_at)
}}</span></template
>
<template v-else>never</template>
</td>
</tr>
<tr v-if="d.backups.length === 0">
<td class="py-3 text-gray-500">No backup strategies configured.</td>
</tr>
</tbody>
</table>
</section>
<section class="rounded-lg border border-gray-200 bg-white p-4">
<div class="flex items-center justify-between">
<h2 class="font-medium">Recent jobs</h2>
<RouterLink to="/jobs" class="text-sm text-blue-600 hover:underline">All jobs</RouterLink>
</div>
<table class="mt-3 w-full text-left text-sm">
<tbody>
<tr v-for="j in d.recent_jobs" :key="j.id" class="border-t border-gray-100">
<td class="py-1.5 font-mono text-xs">{{ j.kind }}</td>
<td
:class="
j.status === 'failed'
? 'font-medium text-red-700'
: j.status === 'running'
? 'text-blue-600'
: 'text-green-700'
"
>
{{ j.status }}
</td>
<td class="text-xs text-gray-500">{{ fmt(j.started_at) }} · {{ j.triggered_by }}</td>
</tr>
<tr v-if="d.recent_jobs.length === 0">
<td class="py-3 text-gray-500">No jobs have run yet.</td>
</tr>
</tbody>
</table>
</section>
</div>
</template>
</template>