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:
@ -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
|
||||
}
|
||||
|
||||
101
frontend/src/pages/DashboardPage.test.ts
Normal file
101
frontend/src/pages/DashboardPage.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user