Fix horizontal page overflow caused by wide tables

Wide tables stretched the whole page because the shell's main is a flex
item without min-width:0 and no table had a scroll container, so the
header and the summary tiles scrolled out of view. Every table now
scrolls inside its own overflow-x-auto container, the sidebar no longer
shrinks, and long CVE ids, versions, targets and image references are
kept on one line or truncated with a title tooltip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-03 19:30:03 +02:00
parent 7ac0a16261
commit 7e606cc884
11 changed files with 526 additions and 417 deletions

View File

@ -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)
})

View File

@ -23,7 +23,7 @@ async function signOut() {
<template>
<div class="flex min-h-screen bg-gray-50 text-gray-900">
<aside class="flex w-56 flex-col border-r border-gray-200 bg-white">
<aside class="flex w-56 shrink-0 flex-col border-r border-gray-200 bg-white">
<div class="px-5 py-4 text-lg font-semibold">SoftVisor Monitoring</div>
<nav class="flex-1 space-y-1 px-3">
<template v-for="item in nav" :key="item.to">
@ -44,7 +44,7 @@ async function signOut() {
<button class="mt-2 text-blue-600 hover:underline" @click="signOut">Sign out</button>
</div>
</aside>
<main class="flex-1 p-8">
<main class="min-w-0 flex-1 p-8">
<RouterView />
</main>
</div>

View File

@ -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')
})
})

View File

@ -14,6 +14,7 @@ const sev: Record<string, string> = {
</script>
<template>
<div data-testid="findings-scroll" class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="border-b border-gray-200 text-gray-500">
<tr>
@ -39,16 +40,26 @@ const sev: Record<string, string> = {
f.severity
}}</span>
</td>
<td class="font-mono">{{ f.cve_id }}</td>
<td class="font-mono">{{ f.package }}</td>
<td class="font-mono text-xs">{{ f.installed_version }}</td>
<td class="font-mono text-xs" :class="f.fixed_version ? 'text-green-700' : 'text-gray-400'">
<td class="whitespace-nowrap font-mono">{{ f.cve_id }}</td>
<td class="max-w-[14rem] truncate font-mono" :title="f.package">{{ f.package }}</td>
<td class="max-w-[12rem] truncate font-mono text-xs" :title="f.installed_version">
{{ f.installed_version }}
</td>
<td
class="max-w-[12rem] truncate font-mono text-xs"
:class="f.fixed_version ? 'text-green-700' : 'text-gray-400'"
:title="f.fixed_version ?? ''"
>
{{ f.fixed_version ?? '–' }}
</td>
<td class="max-w-xs truncate font-mono text-xs" :title="f.target">{{ f.target }}</td>
<td class="max-w-[18rem] truncate font-mono text-xs" :title="f.target">{{ f.target }}</td>
<td>{{ f.status }}</td>
<td class="space-x-3 whitespace-nowrap text-right">
<button name="details" class="text-blue-600 hover:underline" @click="$emit('select', f)">
<td class="space-x-3 whitespace-nowrap pl-2 text-right">
<button
name="details"
class="text-blue-600 hover:underline"
@click="$emit('select', f)"
>
Details
</button>
<button
@ -66,4 +77,5 @@ const sev: Record<string, string> = {
</tr>
</tbody>
</table>
</div>
</template>

View File

@ -57,6 +57,7 @@ const filtered = computed(() =>
<span class="pb-2 text-sm text-gray-500">{{ filtered.length }} of {{ packages.length }}</span>
</div>
<div data-testid="packages-scroll" class="overflow-x-auto">
<table class="mt-4 w-full text-left text-sm">
<thead class="border-b border-gray-200 text-gray-500">
<tr>
@ -104,4 +105,5 @@ const filtered = computed(() =>
</tr>
</tbody>
</table>
</div>
</template>

View File

@ -6,6 +6,7 @@ defineEmits<{ restart: [w: Workload]; scale: [w: Workload]; image: [w: Workload]
</script>
<template>
<div data-testid="workloads-scroll" class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="border-b border-gray-200 text-gray-500">
<tr>
@ -30,7 +31,7 @@ defineEmits<{ restart: [w: Workload]; scale: [w: Workload]; image: [w: Workload]
<td :class="w.ready < w.desired ? 'font-medium text-red-700' : ''">
{{ w.ready }}/{{ w.desired }}
</td>
<td class="font-mono text-xs text-gray-600">
<td class="max-w-[22rem] font-mono text-xs break-all text-gray-600">
<div v-for="c in w.containers" :key="c.name">{{ c.image }}</div>
</td>
<td class="space-x-3 whitespace-nowrap text-right">
@ -58,4 +59,5 @@ defineEmits<{ restart: [w: Workload]; scale: [w: Workload]; image: [w: Workload]
</tr>
</tbody>
</table>
</div>
</template>

View File

@ -176,6 +176,7 @@ const fmt = (d: string) => new Date(d).toLocaleString()
New target
</button>
</div>
<div data-testid="targets-scroll" class="overflow-x-auto">
<table class="mt-3 w-full text-left text-sm">
<thead class="border-b border-gray-200 text-gray-500">
<tr>
@ -207,7 +208,9 @@ const fmt = (d: string) => new Date(d).toLocaleString()
>
Edit
</button>
<button class="text-red-600 hover:underline" @click="deleteTarget(t)">Delete</button>
<button class="text-red-600 hover:underline" @click="deleteTarget(t)">
Delete
</button>
</template>
</td>
</tr>
@ -218,6 +221,7 @@ const fmt = (d: string) => new Date(d).toLocaleString()
</tr>
</tbody>
</table>
</div>
</section>
<section class="mt-10">
@ -232,6 +236,7 @@ const fmt = (d: string) => new Date(d).toLocaleString()
New strategy
</button>
</div>
<div data-testid="strategies-scroll" class="overflow-x-auto">
<table class="mt-3 w-full text-left text-sm">
<thead class="border-b border-gray-200 text-gray-500">
<tr>
@ -301,6 +306,7 @@ const fmt = (d: string) => new Date(d).toLocaleString()
</tr>
</tbody>
</table>
</div>
</section>
<div
@ -338,6 +344,7 @@ const fmt = (d: string) => new Date(d).toLocaleString()
<h2 class="text-lg font-semibold">Backups of {{ dialog.strategy.name }}</h2>
<button class="text-blue-600 hover:underline" @click="dialog = null">Close</button>
</div>
<div data-testid="records-scroll" class="overflow-x-auto">
<table data-testid="backup-records" class="mt-4 w-full text-left text-sm">
<thead class="border-b border-gray-200 text-gray-500">
<tr>
@ -359,6 +366,7 @@ const fmt = (d: string) => new Date(d).toLocaleString()
</tr>
</tbody>
</table>
</div>
</template>
</div>
</div>

View File

@ -104,6 +104,7 @@ function setImage(w: Workload) {
/>
<h2 class="mt-8 text-lg font-medium">Persistent volume claims</h2>
<div data-testid="pvc-scroll" class="overflow-x-auto">
<table class="mt-3 w-full text-left text-sm">
<thead class="border-b border-gray-200 text-gray-500">
<tr>
@ -128,5 +129,6 @@ function setImage(w: Workload) {
</tr>
</tbody>
</table>
</div>
</template>
</template>

View File

@ -98,6 +98,7 @@ const tile = 'block rounded-lg border border-gray-200 bg-white p-4 hover:border-
>Manage</RouterLink
>
</div>
<div data-testid="dash-backups-scroll" class="overflow-x-auto">
<table class="mt-3 w-full text-left text-sm">
<tbody>
<tr
@ -122,12 +123,14 @@ const tile = 'block rounded-lg border border-gray-200 bg-white p-4 hover:border-
</tr>
</tbody>
</table>
</div>
</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>
<div data-testid="dash-jobs-scroll" class="overflow-x-auto">
<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">
@ -143,13 +146,16 @@ const tile = 'block rounded-lg border border-gray-200 bg-white p-4 hover:border-
>
{{ j.status }}
</td>
<td class="text-xs text-gray-500">{{ fmt(j.started_at) }} · {{ j.triggered_by }}</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>
</div>
</section>
</div>
</template>

View File

@ -74,6 +74,7 @@ const fmt = (d: string | null) => (d ? new Date(d).toLocaleString() : '–')
</div>
</div>
<div data-testid="jobs-scroll" class="overflow-x-auto">
<table class="mt-6 w-full text-left text-sm">
<thead class="border-b border-gray-200 text-gray-500">
<tr>
@ -101,6 +102,7 @@ const fmt = (d: string | null) => (d ? new Date(d).toLocaleString() : '–')
</tr>
</tbody>
</table>
</div>
<div v-if="selected" class="mt-6 rounded-md border border-gray-200 bg-white">
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-2 text-sm">

View File

@ -86,7 +86,8 @@ async function resetPassword() {
</div>
<p v-if="loading" class="mt-6 text-gray-500">Loading…</p>
<table v-else class="mt-6 w-full text-left text-sm">
<div v-else data-testid="users-scroll" class="overflow-x-auto">
<table class="mt-6 w-full text-left text-sm">
<thead class="border-b border-gray-200 text-gray-500">
<tr>
<th class="py-2">Email</th>
@ -124,6 +125,7 @@ async function resetPassword() {
</tr>
</tbody>
</table>
</div>
<div v-if="dialog" class="fixed inset-0 flex items-center justify-center bg-black/30">
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-lg" role="dialog">