diff --git a/frontend/e2e/layout.spec.ts b/frontend/e2e/layout.spec.ts
new file mode 100644
index 0000000..5950714
--- /dev/null
+++ b/frontend/e2e/layout.spec.ts
@@ -0,0 +1,61 @@
+import { test, expect, type Page } from '@playwright/test'
+
+// Wide content (tables) must scroll inside its own container; the page itself must never
+// scroll horizontally, otherwise the header and the tiles get cut off.
+const PAGES = [
+ '/',
+ '/updates',
+ '/cluster',
+ '/vulnerabilities',
+ '/backups',
+ '/jobs',
+ '/users',
+ '/settings',
+]
+
+async function login(page: Page) {
+ await page.goto('/login')
+ await page.getByLabel('Email').fill('admin@example.com')
+ await page.getByLabel('Password').fill('admin-password-123')
+ await page.getByRole('button', { name: 'Sign in' }).click()
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
+}
+
+const pageOverflow = (page: Page) =>
+ page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
+
+test('no page scrolls horizontally, with data and on a narrow window', async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 800 })
+ await login(page)
+
+ // fill the app with the data that makes the tables wide
+ await page.goto('/updates')
+ await page.getByRole('button', { name: 'Refresh inventory' }).click()
+ await expect(page.getByTestId('os-name')).toContainText('Debian', { timeout: 15_000 })
+ await page.goto('/vulnerabilities')
+ await page.getByRole('button', { name: 'Scan now' }).click()
+ await expect(page.getByTestId('count-critical')).not.toHaveText('0', { timeout: 20_000 })
+
+ for (const width of [1440, 1280, 1024, 900]) {
+ await page.setViewportSize({ width, height: 800 })
+ for (const path of PAGES) {
+ await page.goto(path)
+ await expect(page.getByRole('heading').first()).toBeVisible()
+ expect(await pageOverflow(page), `${path} at ${width}px scrolls horizontally`).toBe(0)
+ }
+ }
+})
+
+test('the findings table scrolls inside its own container', async ({ page }) => {
+ await page.setViewportSize({ width: 900, height: 800 })
+ await login(page)
+ await page.goto('/vulnerabilities')
+ await page.getByRole('button', { name: 'Scan now' }).click()
+ await expect(page.getByTestId('count-critical')).not.toHaveText('0', { timeout: 20_000 })
+
+ expect(await pageOverflow(page)).toBe(0)
+ const scrollable = await page
+ .getByTestId('findings-scroll')
+ .evaluate((el) => el.scrollWidth > el.clientWidth && getComputedStyle(el).overflowX === 'auto')
+ expect(scrollable).toBe(true)
+})
diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue
index 5916a59..b16c091 100644
--- a/frontend/src/components/AppShell.vue
+++ b/frontend/src/components/AppShell.vue
@@ -23,7 +23,7 @@ async function signOut() {
-
-
+
diff --git a/frontend/src/components/FindingTable.test.ts b/frontend/src/components/FindingTable.test.ts
index b2faaa8..0fbc464 100644
--- a/frontend/src/components/FindingTable.test.ts
+++ b/frontend/src/components/FindingTable.test.ts
@@ -56,3 +56,15 @@ describe('FindingTable', () => {
expect(w.findAll('button[name=ack]')).toHaveLength(0)
})
})
+
+describe('FindingTable layout', () => {
+ it('keeps the table in a horizontal scroll container and the CVE on one line', () => {
+ const w = mount(FindingTable, { props: { findings, canAct: true } })
+ const wrapper = w.find('[data-testid=findings-scroll]')
+ expect(wrapper.exists()).toBe(true)
+ expect(wrapper.classes()).toContain('overflow-x-auto')
+ expect(wrapper.find('table').exists()).toBe(true)
+ const cve = w.findAll('tbody tr')[0].findAll('td')[1]
+ expect(cve.classes()).toContain('whitespace-nowrap')
+ })
+})
diff --git a/frontend/src/components/FindingTable.vue b/frontend/src/components/FindingTable.vue
index d20f36b..1269a12 100644
--- a/frontend/src/components/FindingTable.vue
+++ b/frontend/src/components/FindingTable.vue
@@ -14,56 +14,68 @@ const sev: Record = {
-
-
-
- | Severity |
- CVE |
- Package |
- Installed |
- Fixed in |
- Target |
- Status |
- |
-
-
-
-
- |
- {{
- f.severity
- }}
- |
- {{ f.cve_id }} |
- {{ f.package }} |
- {{ f.installed_version }} |
-
- {{ f.fixed_version ?? '–' }}
- |
- {{ f.target }} |
- {{ f.status }} |
-
-
- |
+ {{ f.target }} |
+ {{ f.status }} |
+
+
+
+ |
+
+
+ | No findings. |
+
+
+
+
diff --git a/frontend/src/components/PackageTable.vue b/frontend/src/components/PackageTable.vue
index 6e90d9f..08fbb3c 100644
--- a/frontend/src/components/PackageTable.vue
+++ b/frontend/src/components/PackageTable.vue
@@ -57,51 +57,53 @@ const filtered = computed(() =>
{{ filtered.length }} of {{ packages.length }}
-
+
diff --git a/frontend/src/components/WorkloadTable.vue b/frontend/src/components/WorkloadTable.vue
index 77e00ec..8f18617 100644
--- a/frontend/src/components/WorkloadTable.vue
+++ b/frontend/src/components/WorkloadTable.vue
@@ -6,56 +6,58 @@ defineEmits<{ restart: [w: Workload]; scale: [w: Workload]; image: [w: Workload]
-
-
-
- | Namespace |
- Name |
- Kind |
- Ready |
- Images |
- |
-
-
-
-
- | {{ w.namespace }} |
- {{ w.name }} |
- {{ w.kind }} |
-
- {{ w.ready }}/{{ w.desired }}
- |
-
- {{ c.image }}
- |
-
-
-
-
-
-
- |
-
-
-
+
+
+
+
+ | Namespace |
+ Name |
+ Kind |
+ Ready |
+ Images |
+ |
+
+
+
+
+ | {{ w.namespace }} |
+ {{ w.name }} |
+ {{ w.kind }} |
+
+ {{ w.ready }}/{{ w.desired }}
+ |
+
+ {{ c.image }}
+ |
+
+
+
+
+
+
+ |
+
+
+
+
diff --git a/frontend/src/pages/BackupsPage.vue b/frontend/src/pages/BackupsPage.vue
index 34a8814..91cf7b3 100644
--- a/frontend/src/pages/BackupsPage.vue
+++ b/frontend/src/pages/BackupsPage.vue
@@ -176,48 +176,52 @@ const fmt = (d: string) => new Date(d).toLocaleString()
New target
-
-
-
- | Name |
- Type |
- Location |
- User |
- |
-
-
-
-
- | {{ t.name }} |
- {{ t.kind }}{{ t.tls ? 'S' : '' }} |
-
- {{
- t.kind === 'smb'
- ? `//${t.host}/${t.share}/${t.path}`
- : `ftp://${t.host}${t.port ? ':' + t.port : ''}/${t.path}`
- }}
- |
- {{ t.username || '–' }} |
-
-
-
-
-
-
- |
-
-
- |
- No targets yet. Add an SMB share or FTP server first.
- |
-
-
-
+
+
+
+
+ | Name |
+ Type |
+ Location |
+ User |
+ |
+
+
+
+
+ | {{ t.name }} |
+ {{ t.kind }}{{ t.tls ? 'S' : '' }} |
+
+ {{
+ t.kind === 'smb'
+ ? `//${t.host}/${t.share}/${t.path}`
+ : `ftp://${t.host}${t.port ? ':' + t.port : ''}/${t.path}`
+ }}
+ |
+ {{ t.username || '–' }} |
+
+
+
+
+
+
+ |
+
+
+ |
+ No targets yet. Add an SMB share or FTP server first.
+ |
+
+
+
+
@@ -232,75 +236,77 @@ const fmt = (d: string) => new Date(d).toLocaleString()
New strategy
-
-
-
- | Name |
- Source |
- Schedule |
- Target |
- Keep |
- Last backup |
- |
-
-
-
-
- |
- {{ s.name }}
- encrypted
- disabled
- |
- {{ sourceLabel(s) }} |
- {{ s.schedule }} |
- {{ s.target_name }} |
- {{ s.retention }} |
-
- running…
-
- {{ s.last_backup.filename }}
-
- {{ fmt(s.last_backup.created_at) }} · {{ size(s.last_backup.size_bytes) }}
-
-
- never
- |
-
-
-
- |
+
+ History
+
+
+ Run now
+
+
+ Edit
+
+
+ Delete
+
+
+ |
+
+
+ | No strategies yet. |
+
+
+
+
new Date(d).toLocaleString()
Backups of {{ dialog.strategy.name }}
Close
-
-
-
- | File |
- Created |
- Size |
- SHA-256 |
-
-
-
-
- | {{ r.filename }} |
- {{ fmt(r.created_at) }} |
- {{ size(r.size_bytes) }} |
- {{ r.sha256.slice(0, 12) }}… |
-
-
- | No backups yet. |
-
-
-
+
+
+
+
+ | File |
+ Created |
+ Size |
+ SHA-256 |
+
+
+
+
+ | {{ r.filename }} |
+ {{ fmt(r.created_at) }} |
+ {{ size(r.size_bytes) }} |
+ {{ r.sha256.slice(0, 12) }}… |
+
+
+ | No backups yet. |
+
+
+
+
diff --git a/frontend/src/pages/ClusterPage.vue b/frontend/src/pages/ClusterPage.vue
index 7407191..fd49287 100644
--- a/frontend/src/pages/ClusterPage.vue
+++ b/frontend/src/pages/ClusterPage.vue
@@ -104,29 +104,31 @@ function setImage(w: Workload) {
/>
Persistent volume claims
-
-
-
- | Namespace |
- Name |
- Capacity |
- Storage class |
- Status |
-
-
-
-
- | {{ p.namespace }} |
- {{ p.name }} |
- {{ p.capacity }} |
- {{ p.storage_class }} |
- {{ p.status }} |
-
-
-
+
+
+
+
+ | Namespace |
+ Name |
+ Capacity |
+ Storage class |
+ Status |
+
+
+
+
+ | {{ p.namespace }} |
+ {{ p.name }} |
+ {{ p.capacity }} |
+ {{ p.storage_class }} |
+ {{ p.status }} |
+
+
+
+
diff --git a/frontend/src/pages/DashboardPage.vue b/frontend/src/pages/DashboardPage.vue
index 59cdcf3..63aa16a 100644
--- a/frontend/src/pages/DashboardPage.vue
+++ b/frontend/src/pages/DashboardPage.vue
@@ -98,58 +98,64 @@ const tile = 'block rounded-lg border border-gray-200 bg-white p-4 hover:border-
>Manage
-
-
-
- | {{ b.name }} |
- {{ b.schedule }} |
-
- {{ b.last_backup.filename }} {{
- fmt(b.last_backup.created_at)
- }}
- never
- |
-
-
- | No backup strategies configured. |
-
-
-
+
+
+
+
+ | {{ b.name }} |
+ {{ b.schedule }} |
+
+ {{ b.last_backup.filename }} {{
+ fmt(b.last_backup.created_at)
+ }}
+ never
+ |
+
+
+ | No backup strategies configured. |
+
+
+
+
Recent jobs
All jobs
-
-
-
- | {{ j.kind }} |
-
- {{ j.status }}
- |
- {{ fmt(j.started_at) }} · {{ j.triggered_by }} |
-
-
- | No jobs have run yet. |
-
-
-
+
+
+
+
+ | {{ j.kind }} |
+
+ {{ j.status }}
+ |
+
+ {{ fmt(j.started_at) }} · {{ j.triggered_by }}
+ |
+
+
+ | No jobs have run yet. |
+
+
+
+
diff --git a/frontend/src/pages/JobsPage.vue b/frontend/src/pages/JobsPage.vue
index 520c72b..fed18f5 100644
--- a/frontend/src/pages/JobsPage.vue
+++ b/frontend/src/pages/JobsPage.vue
@@ -74,33 +74,35 @@ const fmt = (d: string | null) => (d ? new Date(d).toLocaleString() : '–')
-
-
-
- | Job |
- Status |
- Started |
- Finished |
- Triggered by |
- |
-
-
-
-
- | {{ r.kind }} |
- |
- {{ fmt(r.started_at) }} |
- {{ fmt(r.finished_at) }} |
- {{ r.triggered_by }} |
-
- Log
- |
-
-
- | No job runs yet. |
-
-
-
+
+
+
+
+ | Job |
+ Status |
+ Started |
+ Finished |
+ Triggered by |
+ |
+
+
+
+
+ | {{ r.kind }} |
+ |
+ {{ fmt(r.started_at) }} |
+ {{ fmt(r.finished_at) }} |
+ {{ r.triggered_by }} |
+
+ Log
+ |
+
+
+ | No job runs yet. |
+
+
+
+
diff --git a/frontend/src/pages/UsersPage.vue b/frontend/src/pages/UsersPage.vue
index 713a4e5..9e87c60 100644
--- a/frontend/src/pages/UsersPage.vue
+++ b/frontend/src/pages/UsersPage.vue
@@ -86,44 +86,46 @@ async function resetPassword() {
Loading…
-
-
-
- | Email |
- Name |
- Role |
- Status |
- |
-
-
-
-
- | {{ u.email }} |
- {{ u.display_name }} |
- {{ u.role }} |
-
-
- |
-
-
- Edit
-
-
- Reset password
-
- |
-
-
-
+
+
+
+
+ | Email |
+ Name |
+ Role |
+ Status |
+ |
+
+
+
+
+ | {{ u.email }} |
+ {{ u.display_name }} |
+ {{ u.role }} |
+
+
+ |
+
+
+ Edit
+
+
+ Reset password
+
+ |
+
+
+
+