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:
@ -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>
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -14,56 +14,68 @@ const sev: Record<string, string> = {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-gray-200 text-gray-500">
|
||||
<tr>
|
||||
<th class="py-2">Severity</th>
|
||||
<th>CVE</th>
|
||||
<th>Package</th>
|
||||
<th>Installed</th>
|
||||
<th>Fixed in</th>
|
||||
<th>Target</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="f in findings"
|
||||
:key="f.id"
|
||||
class="border-b border-gray-100"
|
||||
:class="f.status === 'acknowledged' ? 'text-gray-500' : ''"
|
||||
>
|
||||
<td class="py-1.5">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium" :class="sev[f.severity]">{{
|
||||
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'">
|
||||
{{ f.fixed_version ?? '–' }}
|
||||
</td>
|
||||
<td class="max-w-xs 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)">
|
||||
Details
|
||||
</button>
|
||||
<button
|
||||
v-if="canAct"
|
||||
name="ack"
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="$emit('status', f, f.status === 'acknowledged' ? 'open' : 'acknowledged')"
|
||||
<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>
|
||||
<th class="py-2">Severity</th>
|
||||
<th>CVE</th>
|
||||
<th>Package</th>
|
||||
<th>Installed</th>
|
||||
<th>Fixed in</th>
|
||||
<th>Target</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="f in findings"
|
||||
:key="f.id"
|
||||
class="border-b border-gray-100"
|
||||
:class="f.status === 'acknowledged' ? 'text-gray-500' : ''"
|
||||
>
|
||||
<td class="py-1.5">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium" :class="sev[f.severity]">{{
|
||||
f.severity
|
||||
}}</span>
|
||||
</td>
|
||||
<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.status === 'acknowledged' ? 'Reopen' : 'Acknowledge' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="findings.length === 0">
|
||||
<td colspan="8" class="py-6 text-center text-gray-500">No findings.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{{ f.fixed_version ?? '–' }}
|
||||
</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 pl-2 text-right">
|
||||
<button
|
||||
name="details"
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="$emit('select', f)"
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
<button
|
||||
v-if="canAct"
|
||||
name="ack"
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="$emit('status', f, f.status === 'acknowledged' ? 'open' : 'acknowledged')"
|
||||
>
|
||||
{{ f.status === 'acknowledged' ? 'Reopen' : 'Acknowledge' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="findings.length === 0">
|
||||
<td colspan="8" class="py-6 text-center text-gray-500">No findings.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -57,51 +57,53 @@ const filtered = computed(() =>
|
||||
<span class="pb-2 text-sm text-gray-500">{{ filtered.length }} of {{ packages.length }}</span>
|
||||
</div>
|
||||
|
||||
<table class="mt-4 w-full text-left text-sm">
|
||||
<thead class="border-b border-gray-200 text-gray-500">
|
||||
<tr>
|
||||
<th v-if="selectable" class="w-8"></th>
|
||||
<th class="py-2">Package</th>
|
||||
<th>Source</th>
|
||||
<th>Installed</th>
|
||||
<th>Available</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="p in filtered"
|
||||
:key="`${p.source}:${p.name}`"
|
||||
class="border-b border-gray-100"
|
||||
:class="p.candidate ? 'bg-amber-50' : ''"
|
||||
>
|
||||
<td v-if="selectable">
|
||||
<input
|
||||
v-if="p.candidate"
|
||||
name="select"
|
||||
type="checkbox"
|
||||
:aria-label="`Select ${p.name}`"
|
||||
:checked="selected.has(p.name)"
|
||||
@change="toggle(p.name, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-1.5 font-mono">{{ p.name }}</td>
|
||||
<td>{{ p.source }}</td>
|
||||
<td class="font-mono text-gray-600">{{ p.installed }}</td>
|
||||
<td class="font-mono">{{ p.candidate ?? '' }}</td>
|
||||
<td>
|
||||
<span
|
||||
v-if="p.is_security"
|
||||
class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800"
|
||||
>security</span
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="filtered.length === 0">
|
||||
<td :colspan="selectable ? 6 : 5" class="py-6 text-center text-gray-500">
|
||||
No packages match.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
<th v-if="selectable" class="w-8"></th>
|
||||
<th class="py-2">Package</th>
|
||||
<th>Source</th>
|
||||
<th>Installed</th>
|
||||
<th>Available</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="p in filtered"
|
||||
:key="`${p.source}:${p.name}`"
|
||||
class="border-b border-gray-100"
|
||||
:class="p.candidate ? 'bg-amber-50' : ''"
|
||||
>
|
||||
<td v-if="selectable">
|
||||
<input
|
||||
v-if="p.candidate"
|
||||
name="select"
|
||||
type="checkbox"
|
||||
:aria-label="`Select ${p.name}`"
|
||||
:checked="selected.has(p.name)"
|
||||
@change="toggle(p.name, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-1.5 font-mono">{{ p.name }}</td>
|
||||
<td>{{ p.source }}</td>
|
||||
<td class="font-mono text-gray-600">{{ p.installed }}</td>
|
||||
<td class="font-mono">{{ p.candidate ?? '' }}</td>
|
||||
<td>
|
||||
<span
|
||||
v-if="p.is_security"
|
||||
class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800"
|
||||
>security</span
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="filtered.length === 0">
|
||||
<td :colspan="selectable ? 6 : 5" class="py-6 text-center text-gray-500">
|
||||
No packages match.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -6,56 +6,58 @@ defineEmits<{ restart: [w: Workload]; scale: [w: Workload]; image: [w: Workload]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-gray-200 text-gray-500">
|
||||
<tr>
|
||||
<th class="py-2">Namespace</th>
|
||||
<th>Name</th>
|
||||
<th>Kind</th>
|
||||
<th>Ready</th>
|
||||
<th>Images</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="w in workloads"
|
||||
:key="`${w.namespace}/${w.kind}/${w.name}`"
|
||||
class="border-b border-gray-100"
|
||||
:class="w.ready < w.desired ? 'bg-red-50' : ''"
|
||||
>
|
||||
<td class="py-1.5">{{ w.namespace }}</td>
|
||||
<td class="font-mono">{{ w.name }}</td>
|
||||
<td>{{ w.kind }}</td>
|
||||
<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">
|
||||
<div v-for="c in w.containers" :key="c.name">{{ c.image }}</div>
|
||||
</td>
|
||||
<td class="space-x-3 whitespace-nowrap text-right">
|
||||
<template v-if="canAct">
|
||||
<button
|
||||
name="restart"
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="$emit('restart', w)"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
v-if="w.kind !== 'daemonset'"
|
||||
name="scale"
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="$emit('scale', w)"
|
||||
>
|
||||
Scale
|
||||
</button>
|
||||
<button name="image" class="text-blue-600 hover:underline" @click="$emit('image', w)">
|
||||
Set image
|
||||
</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
<th class="py-2">Namespace</th>
|
||||
<th>Name</th>
|
||||
<th>Kind</th>
|
||||
<th>Ready</th>
|
||||
<th>Images</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="w in workloads"
|
||||
:key="`${w.namespace}/${w.kind}/${w.name}`"
|
||||
class="border-b border-gray-100"
|
||||
:class="w.ready < w.desired ? 'bg-red-50' : ''"
|
||||
>
|
||||
<td class="py-1.5">{{ w.namespace }}</td>
|
||||
<td class="font-mono">{{ w.name }}</td>
|
||||
<td>{{ w.kind }}</td>
|
||||
<td :class="w.ready < w.desired ? 'font-medium text-red-700' : ''">
|
||||
{{ w.ready }}/{{ w.desired }}
|
||||
</td>
|
||||
<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">
|
||||
<template v-if="canAct">
|
||||
<button
|
||||
name="restart"
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="$emit('restart', w)"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
v-if="w.kind !== 'daemonset'"
|
||||
name="scale"
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="$emit('scale', w)"
|
||||
>
|
||||
Scale
|
||||
</button>
|
||||
<button name="image" class="text-blue-600 hover:underline" @click="$emit('image', w)">
|
||||
Set image
|
||||
</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -176,48 +176,52 @@ const fmt = (d: string) => new Date(d).toLocaleString()
|
||||
New target
|
||||
</button>
|
||||
</div>
|
||||
<table class="mt-3 w-full text-left text-sm">
|
||||
<thead class="border-b border-gray-200 text-gray-500">
|
||||
<tr>
|
||||
<th class="py-2">Name</th>
|
||||
<th>Type</th>
|
||||
<th>Location</th>
|
||||
<th>User</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in targets" :key="t.id" class="border-b border-gray-100">
|
||||
<td class="py-1.5 font-medium">{{ t.name }}</td>
|
||||
<td class="uppercase">{{ t.kind }}{{ t.tls ? 'S' : '' }}</td>
|
||||
<td class="font-mono text-xs">
|
||||
{{
|
||||
t.kind === 'smb'
|
||||
? `//${t.host}/${t.share}/${t.path}`
|
||||
: `ftp://${t.host}${t.port ? ':' + t.port : ''}/${t.path}`
|
||||
}}
|
||||
</td>
|
||||
<td>{{ t.username || '–' }}</td>
|
||||
<td class="space-x-3 whitespace-nowrap text-right">
|
||||
<template v-if="auth.isAdmin">
|
||||
<button class="text-blue-600 hover:underline" @click="testTarget(t)">Test</button>
|
||||
<button
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="dialog = { kind: 'target', current: t }"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button class="text-red-600 hover:underline" @click="deleteTarget(t)">Delete</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="targets.length === 0">
|
||||
<td colspan="5" class="py-4 text-center text-gray-500">
|
||||
No targets yet. Add an SMB share or FTP server first.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
<th class="py-2">Name</th>
|
||||
<th>Type</th>
|
||||
<th>Location</th>
|
||||
<th>User</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in targets" :key="t.id" class="border-b border-gray-100">
|
||||
<td class="py-1.5 font-medium">{{ t.name }}</td>
|
||||
<td class="uppercase">{{ t.kind }}{{ t.tls ? 'S' : '' }}</td>
|
||||
<td class="font-mono text-xs">
|
||||
{{
|
||||
t.kind === 'smb'
|
||||
? `//${t.host}/${t.share}/${t.path}`
|
||||
: `ftp://${t.host}${t.port ? ':' + t.port : ''}/${t.path}`
|
||||
}}
|
||||
</td>
|
||||
<td>{{ t.username || '–' }}</td>
|
||||
<td class="space-x-3 whitespace-nowrap text-right">
|
||||
<template v-if="auth.isAdmin">
|
||||
<button class="text-blue-600 hover:underline" @click="testTarget(t)">Test</button>
|
||||
<button
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="dialog = { kind: 'target', current: t }"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button class="text-red-600 hover:underline" @click="deleteTarget(t)">
|
||||
Delete
|
||||
</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="targets.length === 0">
|
||||
<td colspan="5" class="py-4 text-center text-gray-500">
|
||||
No targets yet. Add an SMB share or FTP server first.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-10">
|
||||
@ -232,75 +236,77 @@ const fmt = (d: string) => new Date(d).toLocaleString()
|
||||
New strategy
|
||||
</button>
|
||||
</div>
|
||||
<table class="mt-3 w-full text-left text-sm">
|
||||
<thead class="border-b border-gray-200 text-gray-500">
|
||||
<tr>
|
||||
<th class="py-2">Name</th>
|
||||
<th>Source</th>
|
||||
<th>Schedule</th>
|
||||
<th>Target</th>
|
||||
<th>Keep</th>
|
||||
<th>Last backup</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="s in strategies"
|
||||
:key="s.id"
|
||||
class="border-b border-gray-100"
|
||||
:class="s.enabled ? '' : 'text-gray-400'"
|
||||
>
|
||||
<td class="py-1.5 font-medium">
|
||||
{{ s.name }}
|
||||
<span v-if="s.encrypted" class="ml-1 rounded bg-gray-100 px-1 text-xs text-gray-600"
|
||||
>encrypted</span
|
||||
>
|
||||
<span v-if="!s.enabled" class="ml-1 rounded bg-gray-100 px-1 text-xs">disabled</span>
|
||||
</td>
|
||||
<td class="font-mono text-xs">{{ sourceLabel(s) }}</td>
|
||||
<td class="font-mono text-xs">{{ s.schedule }}</td>
|
||||
<td>{{ s.target_name }}</td>
|
||||
<td>{{ s.retention }}</td>
|
||||
<td class="text-xs">
|
||||
<template v-if="running[s.id]?.status === 'running'"
|
||||
><span class="text-blue-600">running…</span></template
|
||||
>
|
||||
<template v-else-if="s.last_backup">
|
||||
<div class="font-mono">{{ s.last_backup.filename }}</div>
|
||||
<div class="text-gray-500">
|
||||
{{ fmt(s.last_backup.created_at) }} · {{ size(s.last_backup.size_bytes) }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>never</template>
|
||||
</td>
|
||||
<td class="space-x-3 whitespace-nowrap text-right">
|
||||
<button class="text-blue-600 hover:underline" @click="history(s)">History</button>
|
||||
<template v-if="auth.isAdmin">
|
||||
<button
|
||||
class="text-blue-600 hover:underline"
|
||||
:disabled="running[s.id]?.status === 'running'"
|
||||
@click="runNow(s)"
|
||||
<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>
|
||||
<th class="py-2">Name</th>
|
||||
<th>Source</th>
|
||||
<th>Schedule</th>
|
||||
<th>Target</th>
|
||||
<th>Keep</th>
|
||||
<th>Last backup</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="s in strategies"
|
||||
:key="s.id"
|
||||
class="border-b border-gray-100"
|
||||
:class="s.enabled ? '' : 'text-gray-400'"
|
||||
>
|
||||
<td class="py-1.5 font-medium">
|
||||
{{ s.name }}
|
||||
<span v-if="s.encrypted" class="ml-1 rounded bg-gray-100 px-1 text-xs text-gray-600"
|
||||
>encrypted</span
|
||||
>
|
||||
Run now
|
||||
</button>
|
||||
<button
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="dialog = { kind: 'strategy', current: s }"
|
||||
<span v-if="!s.enabled" class="ml-1 rounded bg-gray-100 px-1 text-xs">disabled</span>
|
||||
</td>
|
||||
<td class="font-mono text-xs">{{ sourceLabel(s) }}</td>
|
||||
<td class="font-mono text-xs">{{ s.schedule }}</td>
|
||||
<td>{{ s.target_name }}</td>
|
||||
<td>{{ s.retention }}</td>
|
||||
<td class="text-xs">
|
||||
<template v-if="running[s.id]?.status === 'running'"
|
||||
><span class="text-blue-600">running…</span></template
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button class="text-red-600 hover:underline" @click="deleteStrategy(s)">
|
||||
Delete
|
||||
</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="strategies.length === 0">
|
||||
<td colspan="7" class="py-4 text-center text-gray-500">No strategies yet.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<template v-else-if="s.last_backup">
|
||||
<div class="font-mono">{{ s.last_backup.filename }}</div>
|
||||
<div class="text-gray-500">
|
||||
{{ fmt(s.last_backup.created_at) }} · {{ size(s.last_backup.size_bytes) }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>never</template>
|
||||
</td>
|
||||
<td class="space-x-3 whitespace-nowrap text-right">
|
||||
<button class="text-blue-600 hover:underline" @click="history(s)">History</button>
|
||||
<template v-if="auth.isAdmin">
|
||||
<button
|
||||
class="text-blue-600 hover:underline"
|
||||
:disabled="running[s.id]?.status === 'running'"
|
||||
@click="runNow(s)"
|
||||
>
|
||||
Run now
|
||||
</button>
|
||||
<button
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="dialog = { kind: 'strategy', current: s }"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button class="text-red-600 hover:underline" @click="deleteStrategy(s)">
|
||||
Delete
|
||||
</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="strategies.length === 0">
|
||||
<td colspan="7" class="py-4 text-center text-gray-500">No strategies yet.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
@ -338,27 +344,29 @@ 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>
|
||||
<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>
|
||||
<th class="py-2">File</th>
|
||||
<th>Created</th>
|
||||
<th>Size</th>
|
||||
<th>SHA-256</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in dialog.records" :key="r.id" class="border-b border-gray-100">
|
||||
<td class="py-1 font-mono text-xs">{{ r.filename }}</td>
|
||||
<td class="text-xs">{{ fmt(r.created_at) }}</td>
|
||||
<td class="text-xs">{{ size(r.size_bytes) }}</td>
|
||||
<td class="font-mono text-xs" :title="r.sha256">{{ r.sha256.slice(0, 12) }}…</td>
|
||||
</tr>
|
||||
<tr v-if="dialog.records.length === 0">
|
||||
<td colspan="4" class="py-3 text-center text-gray-500">No backups yet.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
<th class="py-2">File</th>
|
||||
<th>Created</th>
|
||||
<th>Size</th>
|
||||
<th>SHA-256</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in dialog.records" :key="r.id" class="border-b border-gray-100">
|
||||
<td class="py-1 font-mono text-xs">{{ r.filename }}</td>
|
||||
<td class="text-xs">{{ fmt(r.created_at) }}</td>
|
||||
<td class="text-xs">{{ size(r.size_bytes) }}</td>
|
||||
<td class="font-mono text-xs" :title="r.sha256">{{ r.sha256.slice(0, 12) }}…</td>
|
||||
</tr>
|
||||
<tr v-if="dialog.records.length === 0">
|
||||
<td colspan="4" class="py-3 text-center text-gray-500">No backups yet.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -104,29 +104,31 @@ function setImage(w: Workload) {
|
||||
/>
|
||||
|
||||
<h2 class="mt-8 text-lg font-medium">Persistent volume claims</h2>
|
||||
<table class="mt-3 w-full text-left text-sm">
|
||||
<thead class="border-b border-gray-200 text-gray-500">
|
||||
<tr>
|
||||
<th class="py-2">Namespace</th>
|
||||
<th>Name</th>
|
||||
<th>Capacity</th>
|
||||
<th>Storage class</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="p in overview.volume_claims"
|
||||
:key="`${p.namespace}/${p.name}`"
|
||||
class="border-b border-gray-100"
|
||||
>
|
||||
<td class="py-1.5">{{ p.namespace }}</td>
|
||||
<td class="font-mono">{{ p.name }}</td>
|
||||
<td>{{ p.capacity }}</td>
|
||||
<td>{{ p.storage_class }}</td>
|
||||
<td>{{ p.status }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
<th class="py-2">Namespace</th>
|
||||
<th>Name</th>
|
||||
<th>Capacity</th>
|
||||
<th>Storage class</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="p in overview.volume_claims"
|
||||
:key="`${p.namespace}/${p.name}`"
|
||||
class="border-b border-gray-100"
|
||||
>
|
||||
<td class="py-1.5">{{ p.namespace }}</td>
|
||||
<td class="font-mono">{{ p.name }}</td>
|
||||
<td>{{ p.capacity }}</td>
|
||||
<td>{{ p.storage_class }}</td>
|
||||
<td>{{ p.status }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@ -98,58 +98,64 @@ const tile = 'block rounded-lg border border-gray-200 bg-white p-4 hover:border-
|
||||
>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>
|
||||
<div data-testid="dash-backups-scroll" class="overflow-x-auto">
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -74,33 +74,35 @@ const fmt = (d: string | null) => (d ? new Date(d).toLocaleString() : '–')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">Job</th>
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
<th>Finished</th>
|
||||
<th>Triggered by</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in runs" :key="r.id" class="border-b border-gray-100">
|
||||
<td class="py-2 font-mono">{{ r.kind }}</td>
|
||||
<td><StatusBadge :status="badge(r.status)" :label="r.status" /></td>
|
||||
<td>{{ fmt(r.started_at) }}</td>
|
||||
<td>{{ fmt(r.finished_at) }}</td>
|
||||
<td>{{ r.triggered_by }}</td>
|
||||
<td class="text-right">
|
||||
<button class="text-blue-600 hover:underline" @click="selected = r">Log</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="runs.length === 0">
|
||||
<td colspan="6" class="py-6 text-center text-gray-500">No job runs yet.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
<th class="py-2">Job</th>
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
<th>Finished</th>
|
||||
<th>Triggered by</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in runs" :key="r.id" class="border-b border-gray-100">
|
||||
<td class="py-2 font-mono">{{ r.kind }}</td>
|
||||
<td><StatusBadge :status="badge(r.status)" :label="r.status" /></td>
|
||||
<td>{{ fmt(r.started_at) }}</td>
|
||||
<td>{{ fmt(r.finished_at) }}</td>
|
||||
<td>{{ r.triggered_by }}</td>
|
||||
<td class="text-right">
|
||||
<button class="text-blue-600 hover:underline" @click="selected = r">Log</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="runs.length === 0">
|
||||
<td colspan="6" class="py-6 text-center text-gray-500">No job runs yet.</td>
|
||||
</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">
|
||||
|
||||
@ -86,44 +86,46 @@ 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">
|
||||
<thead class="border-b border-gray-200 text-gray-500">
|
||||
<tr>
|
||||
<th class="py-2">Email</th>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in users" :key="u.id" class="border-b border-gray-100">
|
||||
<td class="py-2">{{ u.email }}</td>
|
||||
<td>{{ u.display_name }}</td>
|
||||
<td class="capitalize">{{ u.role }}</td>
|
||||
<td>
|
||||
<StatusBadge
|
||||
:status="u.is_active ? 'ok' : 'error'"
|
||||
:label="u.is_active ? 'active' : 'inactive'"
|
||||
/>
|
||||
</td>
|
||||
<td class="space-x-3 text-right">
|
||||
<button
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="dialog = { mode: 'edit', user: u }"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="dialog = { mode: 'password', user: u }"
|
||||
>
|
||||
Reset password
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in users" :key="u.id" class="border-b border-gray-100">
|
||||
<td class="py-2">{{ u.email }}</td>
|
||||
<td>{{ u.display_name }}</td>
|
||||
<td class="capitalize">{{ u.role }}</td>
|
||||
<td>
|
||||
<StatusBadge
|
||||
:status="u.is_active ? 'ok' : 'error'"
|
||||
:label="u.is_active ? 'active' : 'inactive'"
|
||||
/>
|
||||
</td>
|
||||
<td class="space-x-3 text-right">
|
||||
<button
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="dialog = { mode: 'edit', user: u }"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="dialog = { mode: 'password', user: u }"
|
||||
>
|
||||
Reset password
|
||||
</button>
|
||||
</td>
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user