feat(frontend): add qol machine updates to omni frontend

Add some QoL updates for machine management to Omni frontend.

1. Add a copy machine UUID button to the cluster machine page
2. Add a toggle between hostnames and UUIDs to the machines list page (copy will copy what it sees, preference is saved)
3. Add kernel args tabs to machine and cluster machine pages, to allow editing kernel args. The "Update kernel args" button from machines list dropdown menu will now redirect to here instead of opening a modal.

Signed-off-by: Edward Sammut Alessi <edward.sammutalessi@siderolabs.com>
This commit is contained in:
Edward Sammut Alessi 2026-04-30 13:53:38 +02:00
parent 2fe716d2c9
commit efbd089ffb
No known key found for this signature in database
GPG Key ID: 65558E016966977A
14 changed files with 232 additions and 133 deletions

View File

@ -67,11 +67,11 @@ const modelValue = defineModel<string | number>()
}
.checked {
@apply bg-naturals-n4;
@apply bg-naturals-n6;
}
.checked span {
@apply text-naturals-n12;
@apply text-primary-p3;
}
.popper {

View File

@ -315,44 +315,46 @@ const openPage = (page: number | string) => {
<TInput v-if="search" v-model="filterValueInternal" icon="search" />
</slot>
<div class="flex items-center gap-2">
<slot name="extra-controls" :selected-filter-option />
<div class="flex justify-between gap-2">
<div class="grow">
<slot name="extra-controls" :selected-filter-option />
</div>
<div class="grow" />
<div class="flex items-center gap-2">
<TSelectList
v-if="filterOptions"
:title="filterCaption ?? 'Filter'"
:default-value="selectedFilterOption || ''"
:values="filterOptionsVariants"
@checked-value="(value) => (selectedFilterOption = value)"
/>
<TSelectList
v-if="filterOptions"
:title="filterCaption ?? 'Filter'"
:default-value="selectedFilterOption || ''"
:values="filterOptionsVariants"
@checked-value="(value) => (selectedFilterOption = value)"
/>
<TSelectList
v-if="sortOptions"
title="Sort by"
hide-selected-small-screens
:default-value="selectedSortOption || ''"
:values="sortOptionsVariants"
@checked-value="
(value: string) => {
selectedSortOption = value
}
"
/>
<TSelectList
v-if="sortOptions"
title="Sort by"
hide-selected-small-screens
:default-value="selectedSortOption || ''"
:values="sortOptionsVariants"
@checked-value="
(value: string) => {
selectedSortOption = value
}
"
/>
<TSelectList
v-if="itemsPerPage?.length > 1 && pagination"
title="Items per Page"
:default-value="selectedItemsPerPage"
:values="itemsPerPage"
@checked-value="
(value: number) => {
selectedItemsPerPage = value
currentPage = 1
}
"
/>
<TSelectList
v-if="itemsPerPage?.length > 1 && pagination"
title="Items per Page"
:default-value="selectedItemsPerPage"
:values="itemsPerPage"
@checked-value="
(value: number) => {
selectedItemsPerPage = value
currentPage = 1
}
"
/>
</div>
</div>
</template>

View File

@ -28,7 +28,6 @@ const modals: Record<string, Component> = {
configPatchDestroy: defineAsyncComponent(() => import('@/views/Modals/ConfigPatchDestroy.vue')),
userDestroy: defineAsyncComponent(() => import('@/views/Modals/UserDestroy.vue')),
userCreate: defineAsyncComponent(() => import('@/views/Modals/UserCreate.vue')),
updateKernelArgs: defineAsyncComponent(() => import('@/views/Modals/UpdateKernelArgs.vue')),
joinTokenCreate: defineAsyncComponent(() => import('@/views/Modals/JoinTokenCreate.vue')),
joinTokenRevoke: defineAsyncComponent(() => import('@/views/Modals/JoinTokenRevoke.vue')),
joinTokenDelete: defineAsyncComponent(() => import('@/views/Modals/JoinTokenDelete.vue')),

View File

@ -67,6 +67,10 @@ const routes = computed(() => {
name: 'Extensions',
to: { name: 'NodeExtensions', params: { machine: machine.value } },
},
{
name: 'Kernel Args',
to: { name: 'NodeKernelArgs' },
},
]
})
</script>

View File

@ -0,0 +1,15 @@
<!--
Copyright (c) 2026 Sidero Labs, Inc.
Use of this software is governed by the Business Source License
included in the LICENSE file.
-->
<script setup lang="ts">
import MachineKernelArgs from '@/views/Machines/MachineKernelArgs.vue'
definePage({ name: 'NodeKernelArgs' })
</script>
<template>
<MachineKernelArgs :machine="$route.params.machine" />
</template>

View File

@ -50,6 +50,10 @@ const routes = computed(() => {
name: 'Extensions',
to: { name: 'MachineExtensions' },
},
{
name: 'Kernel Args',
to: { name: 'MachineKernelArgs' },
},
]
})

View File

@ -0,0 +1,15 @@
<!--
Copyright (c) 2026 Sidero Labs, Inc.
Use of this software is governed by the Business Source License
included in the LICENSE file.
-->
<script setup lang="ts">
import MachineKernelArgs from '@/views/Machines/MachineKernelArgs.vue'
definePage({ name: 'MachineKernelArgs' })
</script>
<template>
<MachineKernelArgs :machine="$route.params.machine" />
</template>

View File

@ -25,9 +25,14 @@ import type { Label } from '@/methods/labels'
import { addMachineLabels, removeMachineLabels } from '@/methods/machine'
import ItemLabels from '@/views/ItemLabels/ItemLabels.vue'
const { machine, searchQuery = '' } = defineProps<{
const {
machine,
showUUID,
searchQuery = '',
} = defineProps<{
machine: Resource<MachineStatusLinkSpec>
panelOpen?: boolean
showUUID?: boolean
searchQuery?: string
}>()
@ -48,7 +53,9 @@ const { canManageKernelArgs, canReadConfigPatches } = useClusterPermissions(
)
const machineName = computed(() => {
return machine.spec.message_status?.network?.hostname ?? machine.metadata.id
return showUUID
? machine.metadata.id
: (machine.spec.message_status?.network?.hostname ?? machine.metadata.id)
})
const clusterName = computed(() => machine.spec.message_status?.cluster)
@ -177,9 +184,9 @@ const maintenanceUpdateDescription = computed(() => {
:disabled="!canManageKernelArgs"
@select="
$router.push({
query: {
modal: 'updateKernelArgs',
machine: machine.metadata.id,
name: 'MachineKernelArgs',
params: {
machine: machine.metadata.id!,
},
})
"

View File

@ -5,17 +5,19 @@
import { faker } from '@faker-js/faker'
import { createWatchStreamHandler } from '@msw/helpers'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { http, HttpResponse } from 'msw'
import type { Resource } from '@/api/grpc'
import type { GetRequest } from '@/api/omni/resources/resources.pb'
import type { KernelArgsSpec, KernelArgsStatusSpec } from '@/api/omni/specs/omni.pb'
import { DefaultNamespace, KernelArgsStatusType, KernelArgsType } from '@/api/resources'
import UpdateKernelArgs from './UpdateKernelArgs.vue'
import MachineKernelArgs from './MachineKernelArgs.vue'
const meta: Meta<typeof UpdateKernelArgs> = {
component: UpdateKernelArgs,
const machineId = faker.string.uuid()
const meta: Meta<typeof MachineKernelArgs> = {
component: MachineKernelArgs,
parameters: {
machine: machineId,
},
}
export default meta
@ -29,6 +31,7 @@ export const Data: Story = {
expectedOptions: {
type: KernelArgsStatusType,
namespace: DefaultNamespace,
id: machineId,
},
initialResources: [
{
@ -41,27 +44,36 @@ export const Data: Story = {
faker.helpers.slugify(faker.hacker.phrase()).toLowerCase(),
),
},
metadata: {},
metadata: {
type: KernelArgsStatusType,
namespace: DefaultNamespace,
id: machineId,
},
},
],
}).handler,
http.post<never, GetRequest>('/omni.resources.ResourceService/Get', async ({ request }) => {
const { type, namespace } = await request.clone().json()
if (type !== KernelArgsType || namespace !== DefaultNamespace) return
const resource: Resource<KernelArgsSpec> = {
spec: {
args: faker.helpers.multiple(() =>
faker.helpers.slugify(faker.hacker.phrase()).toLowerCase(),
),
createWatchStreamHandler<KernelArgsSpec>({
expectedOptions: {
type: KernelArgsType,
namespace: DefaultNamespace,
id: machineId,
},
initialResources: [
{
spec: {
args: faker.helpers.multiple(() =>
faker.helpers.slugify(faker.hacker.phrase()).toLowerCase(),
),
},
metadata: {
type: KernelArgsType,
namespace: DefaultNamespace,
id: machineId,
},
},
metadata: {},
}
return HttpResponse.json({ body: JSON.stringify(resource) })
}),
],
}).handler,
],
},
},

View File

@ -6,7 +6,6 @@ included in the LICENSE file.
-->
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Runtime } from '@/api/common/omni.pb'
import { Code } from '@/api/google/rpc/code.pb'
@ -17,24 +16,25 @@ import { DefaultNamespace, KernelArgsStatusType, KernelArgsType } from '@/api/re
import IconButton from '@/components/Button/IconButton.vue'
import TButton from '@/components/Button/TButton.vue'
import ManagedByTemplatesWarning from '@/components/ManagedByTemplatesWarning.vue'
import PageContainer from '@/components/PageContainer/PageContainer.vue'
import TInput from '@/components/TInput/TInput.vue'
import { useResourceGet } from '@/methods/useResourceGet.ts'
import { useResourceWatch } from '@/methods/useResourceWatch'
import { showError, showSuccess } from '@/notification'
import CloseButton from '@/views/Modals/CloseButton.vue'
const { machine } = defineProps<{
machine: string
}>()
const args = ref('')
const route = useRoute()
const router = useRouter()
const { data: status } = useResourceWatch<KernelArgsStatusSpec>({
const { data: status } = useResourceWatch<KernelArgsStatusSpec>(() => ({
runtime: Runtime.Omni,
resource: {
id: route.query.machine as string,
id: machine,
namespace: DefaultNamespace,
type: KernelArgsStatusType,
},
})
}))
const currentArgs = computed(() => {
return status.value?.spec.current_args?.join(' ') || ''
@ -46,16 +46,14 @@ const unmetConditions = computed(() => {
return status.value?.spec.unmet_conditions || []
})
const md = {
id: route.query.machine as string,
namespace: DefaultNamespace,
type: KernelArgsType,
}
const { data: kernelArgs } = useResourceGet<KernelArgsSpec>({
const { data: kernelArgs } = useResourceWatch<KernelArgsSpec>(() => ({
runtime: Runtime.Omni,
resource: md,
})
resource: {
id: machine,
namespace: DefaultNamespace,
type: KernelArgsType,
},
}))
const initialArgs = computed(
() => kernelArgs.value?.spec.args?.map((arg) => arg.trim()).join(' ') ?? '',
@ -72,6 +70,7 @@ const handleUpdateKernelArgs = async () => {
return
}
editArgs.value = false
showSuccess(`Kernel args are updated`)
close()
@ -90,7 +89,14 @@ const updateKernelArgs = async () => {
if (emptyArgs) {
try {
await ResourceService.Delete(md, withRuntime(Runtime.Omni))
await ResourceService.Delete(
{
id: machine,
namespace: DefaultNamespace,
type: KernelArgsType,
},
withRuntime(Runtime.Omni),
)
} catch (e) {
if (e.code === Code.NOT_FOUND) {
return
@ -105,7 +111,11 @@ const updateKernelArgs = async () => {
if (!kernelArgs.value) {
await ResourceService.Create(
{
metadata: md,
metadata: {
id: machine,
namespace: DefaultNamespace,
type: KernelArgsType,
},
spec: {
args: argsSplit,
},
@ -126,28 +136,11 @@ const updateKernelArgs = async () => {
}
const editArgs = ref(false)
let closed = false
const close = () => {
if (closed) {
return
}
closed = true
router.go(-1)
}
</script>
<template>
<div class="modal-window flex flex-col gap-2">
<div class="heading">
<h3 class="text-base text-naturals-n14">
Update Kernel Args of Machine {{ route.query.machine }}
</h3>
<CloseButton @click="close" />
</div>
<PageContainer class="flex flex-col gap-2">
<h3 class="text-base text-naturals-n14">Update Kernel Args of Machine {{ machine }}</h3>
<ManagedByTemplatesWarning :resource="kernelArgs" />
@ -167,11 +160,13 @@ const close = () => {
</template>
<div class="text-sm font-semibold text-naturals-n14">Current Kernel Cmdline</div>
<code>{{ currentCmdline || 'none' }}</code>
<code class="rounded bg-naturals-n6 px-2.5 py-2 font-mono text-xs text-naturals-n13">
{{ currentCmdline || 'none' }}
</code>
<template v-if="!editArgs">
<div class="text-sm font-semibold text-naturals-n14">Current Kernel Args</div>
<div class="my-0.5 flex items-center gap-2 rounded bg-naturals-n6 pr-2">
<code class="flex-1">
<code class="flex-1 rounded bg-naturals-n6 px-2.5 py-2 font-mono text-xs text-naturals-n13">
{{ currentArgs || 'none' }}
</code>
<IconButton
@ -192,21 +187,5 @@ const close = () => {
</TButton>
</div>
</template>
</div>
</PageContainer>
</template>
<style scoped>
@reference "../../index.css";
.modal-window {
@apply w-3/4;
}
.heading {
@apply mb-5 flex items-center justify-between text-xl text-naturals-n14;
}
code {
@apply rounded bg-naturals-n6 px-1 px-2.5 py-0.5 py-2 font-mono text-xs text-naturals-n13;
}
</style>

View File

@ -5,6 +5,7 @@ Use of this software is governed by the Business Source License
included in the LICENSE file.
-->
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
@ -29,6 +30,7 @@ import {
VirtualNamespace,
} from '@/api/resources'
import TButton from '@/components/Button/TButton.vue'
import TButtonGroup from '@/components/Button/TButtonGroup.vue'
import TList from '@/components/List/TList.vue'
import PageContainer from '@/components/PageContainer/PageContainer.vue'
import PageHeader from '@/components/PageHeader.vue'
@ -50,6 +52,7 @@ const { filter, provider } = defineProps<{
}>()
const router = useRouter()
const showUUID = useLocalStorage<'hostname' | 'uuid'>('_machines_list_show_uuid', 'hostname')
const { data: infraProviderStatuses } = useResourceWatch<InfraProviderStatusSpec>({
resource: {
@ -258,14 +261,27 @@ function updateSelected(machine: Resource<MachineStatusLinkSpec>, v?: boolean) {
</template>
<template #extra-controls>
<TButton
variant="primary"
icon="delete"
:disabled="!selectedMachines.size"
@click="deleteItems"
>
<span class="contents max-md:hidden">Delete selected</span>
</TButton>
<div class="flex w-full items-center justify-between">
<TButton
variant="primary"
icon="delete"
:disabled="!selectedMachines.size"
@click="deleteItems"
>
<span class="contents max-md:hidden">Delete selected</span>
</TButton>
<span class="flex items-center gap-1 text-xs">
Display
<TButtonGroup
v-model="showUUID"
:options="[
{ label: 'Hostnames', value: 'hostname' },
{ label: 'UUIDs', value: 'uuid' },
]"
/>
</span>
</div>
</template>
<template
@ -278,6 +294,7 @@ function updateSelected(machine: Resource<MachineStatusLinkSpec>, v?: boolean) {
:search-query="searchQuery"
:panel-open="sidePanelOpen && item.metadata.id === sidePanelSelectedItemId"
:selected="selectedMachines.has(item.metadata.id ?? '')"
:show-u-u-i-d="showUUID === 'uuid'"
@update:selected="(v) => updateSelected(item, v)"
@open-panel="openPanel(item.metadata.id ?? '')"
@filter-labels="(label) => addLabel(filterLabels, label)"

View File

@ -11,6 +11,7 @@ import { Runtime } from '@/api/common/omni.pb'
import type { Resource } from '@/api/grpc'
import type { MachineStatusSpec } from '@/api/omni/specs/omni.pb'
import { DefaultNamespace, LabelCluster, MachineStatusType } from '@/api/resources'
import CopyButton from '@/components/CopyButton/CopyButton.vue'
import TSelectList from '@/components/SelectList/TSelectList.vue'
import { useResourceWatch } from '@/methods/useResourceWatch'
@ -47,10 +48,10 @@ function getDisplayNameForMachine(machine: Resource<MachineStatusSpec>) {
</script>
<template>
<div class="items-startflex-row flex flex-wrap">
<div class="flex flex-col gap-2">
<div class="flex items-center">
<RouterLink
class="p-2 leading-none font-medium transition hover:opacity-50"
class="py-2 pr-2 leading-none font-medium transition hover:opacity-50"
:to="{ name: 'ClusterOverview', params: { cluster: clusterId } }"
>
{{ clusterId }}
@ -74,5 +75,11 @@ function getDisplayNameForMachine(machine: Resource<MachineStatusSpec>) {
@update:model-value="(v) => $router.push({ params: { machine: v } })"
/>
</div>
<div class="flex gap-1">
<span class="text-xs font-medium text-naturals-n12">Machine UUID:</span>
<span class="text-xs">{{ machineId }}</span>
<CopyButton :text="machineId" />
</div>
</div>
</template>

View File

@ -104,7 +104,7 @@ const { canRebootMachines, canRemoveMachines, canAddClusterMachines } = useClust
</script>
<template>
<div class="mb-7 flex flex-wrap items-start justify-between">
<div class="mb-7 flex flex-wrap items-start justify-between gap-2">
<NodesBreadcrumbs :cluster-id :machine-id />
<div class="flex">

View File

@ -69,6 +69,7 @@ declare module 'vue-router/auto-routes' {
| 'MachineDevices'
| 'MachineDisks'
| 'MachineExtensions'
| 'MachineKernelArgs'
| 'MachineLogs'
| 'MachinePatchEdit'
| 'Machines'
@ -82,6 +83,7 @@ declare module 'vue-router/auto-routes' {
| 'NodeDevices'
| 'NodeDisks'
| 'NodeExtensions'
| 'NodeKernelArgs'
| 'NodeLogs'
| 'NodeMonitor'
| 'NodeOverview'
@ -125,6 +127,7 @@ declare module 'vue-router/auto-routes' {
| 'NodeDevices'
| 'NodeDisks'
| 'NodeExtensions'
| 'NodeKernelArgs'
| 'NodeLogs'
| 'NodeMonitor'
| 'NodeOverview'
@ -157,6 +160,7 @@ declare module 'vue-router/auto-routes' {
| 'NodeDevices'
| 'NodeDisks'
| 'NodeExtensions'
| 'NodeKernelArgs'
| 'NodeLogs'
| 'NodeMonitor'
| 'NodeOverview'
@ -205,6 +209,13 @@ declare module 'vue-router/auto-routes' {
{ cluster: ParamValue<false>, machine: ParamValue<false> },
| never
>,
'NodeKernelArgs': RouteRecordInfo<
'NodeKernelArgs',
'/clusters/:cluster/machine/:machine/kernel-args',
{ cluster: ParamValue<true>, machine: ParamValue<true> },
{ cluster: ParamValue<false>, machine: ParamValue<false> },
| never
>,
'NodeLogs': RouteRecordInfo<
'NodeLogs',
'/clusters/:cluster/machine/:machine/logs/:service',
@ -326,6 +337,7 @@ declare module 'vue-router/auto-routes' {
| 'MachineDevices'
| 'MachineDisks'
| 'MachineExtensions'
| 'MachineKernelArgs'
| 'MachineLogs'
| 'MachinePatchEdit'
>,
@ -350,6 +362,13 @@ declare module 'vue-router/auto-routes' {
{ machine: ParamValue<false> },
| never
>,
'MachineKernelArgs': RouteRecordInfo<
'MachineKernelArgs',
'/machines/:machine/kernel-args',
{ machine: ParamValue<true> },
{ machine: ParamValue<false> },
| never
>,
'MachineLogs': RouteRecordInfo<
'MachineLogs',
'/machines/:machine/logs',
@ -628,6 +647,7 @@ declare module 'vue-router/auto-routes' {
| 'MachineDevices'
| 'MachineDisks'
| 'MachineExtensions'
| 'MachineKernelArgs'
| 'MachineLogs'
| 'MachinePatchEdit'
| 'Machines'
@ -641,6 +661,7 @@ declare module 'vue-router/auto-routes' {
| 'NodeDevices'
| 'NodeDisks'
| 'NodeExtensions'
| 'NodeKernelArgs'
| 'NodeLogs'
| 'NodeMonitor'
| 'NodeOverview'
@ -691,6 +712,7 @@ declare module 'vue-router/auto-routes' {
| 'MachineDevices'
| 'MachineDisks'
| 'MachineExtensions'
| 'MachineKernelArgs'
| 'MachineLogs'
| 'MachinePatchEdit'
| 'Machines'
@ -704,6 +726,7 @@ declare module 'vue-router/auto-routes' {
| 'NodeDevices'
| 'NodeDisks'
| 'NodeExtensions'
| 'NodeKernelArgs'
| 'NodeLogs'
| 'NodeMonitor'
| 'NodeOverview'
@ -745,6 +768,7 @@ declare module 'vue-router/auto-routes' {
| 'NodeDevices'
| 'NodeDisks'
| 'NodeExtensions'
| 'NodeKernelArgs'
| 'NodeLogs'
| 'NodeMonitor'
| 'NodeOverview'
@ -775,6 +799,7 @@ declare module 'vue-router/auto-routes' {
| 'NodeDevices'
| 'NodeDisks'
| 'NodeExtensions'
| 'NodeKernelArgs'
| 'NodeLogs'
| 'NodeMonitor'
| 'NodeOverview'
@ -819,6 +844,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/pages/(authenticated)/clusters/[cluster]/machine/[machine]/kernel-args.vue': {
routes:
| 'NodeKernelArgs'
views:
| never
}
'src/pages/(authenticated)/clusters/[cluster]/machine/[machine]/logs/[service].vue': {
routes:
| 'NodeLogs'
@ -922,6 +953,7 @@ declare module 'vue-router/auto-routes' {
| 'MachineDevices'
| 'MachineDisks'
| 'MachineExtensions'
| 'MachineKernelArgs'
| 'MachineLogs'
| 'MachinePatchEdit'
views:
@ -945,6 +977,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/pages/(authenticated)/machines/[machine]/kernel-args.vue': {
routes:
| 'MachineKernelArgs'
views:
| never
}
'src/pages/(authenticated)/machines/[machine]/logs.vue': {
routes:
| 'MachineLogs'