WP-11: package and OS updates from the UI
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

package_upgrade job streams apt-get output into the job log (streaming
CommandRunner, DebianUpdater with dist-upgrade or --only-upgrade), refreshes
the inventory afterwards and flags reboot-required. POST /api/system/upgrade
(admin), package name validation, selectable package table with confirm
dialog and live log on the Updates page. Fake updater for dev.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 22:27:28 +02:00
parent 1f56f015a2
commit 684695e71a
9 changed files with 277 additions and 33 deletions

View File

@ -2,8 +2,16 @@
import { computed, ref } from 'vue'
import type { Package } from '../api/types'
const props = defineProps<{ packages: Package[] }>()
const props = defineProps<{ packages: Package[]; selectable?: boolean }>()
const emit = defineEmits<{ 'update:selected': [names: string[]] }>()
const selected = ref<Set<string>>(new Set())
const search = ref('')
function toggle(name: string, on: boolean) {
if (on) selected.value.add(name)
else selected.value.delete(name)
emit('update:selected', [...selected.value])
}
const upgradableOnly = ref(false)
const source = ref<'all' | 'apt' | 'snap'>('all')
@ -52,6 +60,7 @@ const filtered = computed(() =>
<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>
@ -66,6 +75,16 @@ const filtered = computed(() =>
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>
@ -79,7 +98,9 @@ const filtered = computed(() =>
</td>
</tr>
<tr v-if="filtered.length === 0">
<td colspan="5" class="py-6 text-center text-gray-500">No packages match.</td>
<td :colspan="selectable ? 6 : 5" class="py-6 text-center text-gray-500">
No packages match.
</td>
</tr>
</tbody>
</table>

View File

@ -10,7 +10,11 @@ const auth = useAuthStore()
const toast = useToastStore()
const inv = ref<InventoryResponse | null>(null)
const refreshing = ref(false)
const selected = ref<string[]>([])
const confirm = ref<{ packages: string[] } | null>(null)
const upgradeRun = ref<JobRun | null>(null)
let timer: ReturnType<typeof setInterval> | undefined
let upgradeTimer: ReturnType<typeof setInterval> | undefined
async function load() {
inv.value = await api.get<InventoryResponse>('/api/system/inventory')
@ -19,7 +23,10 @@ async function load() {
onMounted(() => {
load().catch((e) => toast.error(e instanceof ApiError ? e.message : 'Request failed'))
})
onUnmounted(() => clearInterval(timer))
onUnmounted(() => {
clearInterval(timer)
clearInterval(upgradeTimer)
})
async function refresh() {
refreshing.value = true
@ -44,6 +51,29 @@ async function refresh() {
}
}
async function startUpgrade() {
if (!confirm.value) return
const packages = confirm.value.packages
confirm.value = null
try {
upgradeRun.value = await api.post<JobRun>('/api/system/upgrade', { packages })
upgradeTimer = setInterval(async () => {
if (!upgradeRun.value) return
const r = await api.get<JobRun>(`/api/jobs/${upgradeRun.value.id}`)
upgradeRun.value = r
if (r.status !== 'running') {
clearInterval(upgradeTimer)
selected.value = []
if (r.status === 'success') toast.success('Update finished')
else toast.error('Update failed, see the log')
await load()
}
}, 1000)
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Request failed')
}
}
const uptime = (s: number) => {
const d = Math.floor(s / 86400)
const h = Math.floor((s % 86400) / 3600)
@ -99,7 +129,75 @@ const uptime = (s: number) => {
No inventory yet. Run a refresh to query the host.
</p>
<div v-if="upgradeRun" 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">
<span>
Update
<span class="font-medium" :class="upgradeRun.status === 'failed' ? 'text-red-700' : ''">{{
upgradeRun.status
}}</span>
· started {{ new Date(upgradeRun.started_at).toLocaleTimeString() }}
</span>
<button
v-if="upgradeRun.status !== 'running'"
class="text-blue-600 hover:underline"
@click="upgradeRun = null"
>
Close
</button>
</div>
<pre data-testid="upgrade-log" class="max-h-80 overflow-auto p-4 text-xs">{{
upgradeRun.log || '(waiting for output)'
}}</pre>
</div>
<div v-if="inv && inv.packages.length" class="mt-8">
<PackageTable :packages="inv.packages" />
<div v-if="auth.isAdmin && inv.summary.upgradable" class="mb-4 flex gap-2">
<button
:disabled="selected.length === 0 || upgradeRun?.status === 'running'"
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
@click="confirm = { packages: selected }"
>
Update selected ({{ selected.length }})
</button>
<button
:disabled="upgradeRun?.status === 'running'"
class="rounded-md border border-gray-300 px-4 py-2 text-sm disabled:opacity-50"
@click="confirm = { packages: [] }"
>
Update all ({{ inv.summary.upgradable }})
</button>
</div>
<PackageTable
:packages="inv.packages"
:selectable="auth.isAdmin"
@update:selected="selected = $event"
/>
</div>
<div v-if="confirm" 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">
<h2 class="text-lg font-semibold">Apply updates?</h2>
<p class="mt-2 text-sm text-gray-600">
<template v-if="confirm.packages.length">
apt-get will upgrade: <span class="font-mono">{{ confirm.packages.join(', ') }}</span>
</template>
<template v-else
>apt-get dist-upgrade will upgrade all {{ inv?.summary.upgradable }} packages.</template
>
A reboot may be required afterwards.
</p>
<div class="mt-6 flex justify-end gap-2">
<button class="rounded-md border border-gray-300 px-4 py-2 text-sm" @click="confirm = null">
Cancel
</button>
<button
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
@click="startUpgrade"
>
Confirm update
</button>
</div>
</div>
</div>
</template>