feat(lite): add action create internal network (#10235)

* feat(lite): add create network and network bonded actions

* remove vlan required and info message

* feedback number one

* feat(lite): add create network and network bonded actions

* feat(lite): add action create internal network

* update changelog file

* fix rebase

* feedback number one

* remove as const in cancelRoute

* feedback number two
This commit is contained in:
Elise-F
2026-08-31 10:51:27 +02:00
committed by GitHub
parent c7bf0c59ef
commit d2d2d57c81
16 changed files with 389 additions and 76 deletions

View File

@@ -8,6 +8,7 @@
- [Host/Network] Add ability to rescan physical network interfaces (PIFs) (PR [#10147](https://github.com/vatesfr/xen-orchestra/pull/10147)) - [Host/Network] Add ability to rescan physical network interfaces (PIFs) (PR [#10147](https://github.com/vatesfr/xen-orchestra/pull/10147))
- Fix inconsistent spacing in side panel cards (PR [#10279](https://github.com/vatesfr/xen-orchestra/pull/10279)) - Fix inconsistent spacing in side panel cards (PR [#10279](https://github.com/vatesfr/xen-orchestra/pull/10279))
- Fix missing collapse buttons on pools and hosts in the tree sidebar (added `hasChildren` prop) (PR [#10244](https://github.com/vatesfr/xen-orchestra/pull/10244)) - Fix missing collapse buttons on pools and hosts in the tree sidebar (added `hasChildren` prop) (PR [#10244](https://github.com/vatesfr/xen-orchestra/pull/10244))
- [Pool/networks] Add the possibility to create new internal network (PR [#10235](https://github.com/vatesfr/xen-orchestra/pull/10235))
## **0.24.0** (2026-07-30) ## **0.24.0** (2026-07-30)

View File

@@ -2,6 +2,9 @@
<div class="pool-host-internal-networks-table"> <div class="pool-host-internal-networks-table">
<UiTitle> <UiTitle>
{{ t('host-internal-networks') }} {{ t('host-internal-networks') }}
<template #action>
<slot name="title-actions" />
</template>
</UiTitle> </UiTitle>
<div class="container"> <div class="container">
<UiQuerySearchBar @search="value => (searchQuery = value)" /> <UiQuerySearchBar @search="value => (searchQuery = value)" />
@@ -44,6 +47,10 @@ const { networks } = defineProps<{
networks: XenApiNetwork[] networks: XenApiNetwork[]
}>() }>()
defineSlots<{
'title-actions'(): any
}>()
const { isReady, hasError } = useNetworkStore().subscribe() const { isReady, hasError } = useNetworkStore().subscribe()
const { t } = useI18n() const { t } = useI18n()

View File

@@ -93,5 +93,22 @@ export function createNetworkOperations(xenApi: XenApi) {
return networkRef return networkRef
}, },
createInternal: async (params: BaseNetworkCreateParams): Promise<XenApiNetwork['$ref']> => {
const { nameLabel, nameDescription = '', mtu = 1500, nbd } = params
const networkRef = await createEmptyNetwork(nameLabel, nameDescription, mtu)
if (nbd) {
try {
await xenApi.call('network.add_purpose', [networkRef, 'nbd'])
} catch (error) {
await xenApi.call('network.destroy', [networkRef])
throw error
}
}
return networkRef
},
} }
} }

View File

@@ -0,0 +1,97 @@
<template>
<form class="new-internal-network-form" novalidate @submit.prevent="onSubmit()">
<NetworkFormTextInput v-bind="poolInputBindings" class="pool" />
<div class="row">
<div class="column">
<NetworkFormTextInput v-bind="nameInputBindings" />
<NetworkFormNumberInput v-bind="mtuInputBindings" />
</div>
<NewNetworkDescriptionTextarea v-bind="descriptionInputBindings" />
</div>
<div class="nbd">
<NewNetworkNbdCheckbox v-bind="nbdCheckboxBindings" />
</div>
<NewNetworkButtonsSection :cancel-to :submit-label="t('action:create-internal-network')" />
</form>
</template>
<script setup lang="ts">
import NetworkFormNumberInput from '@/modules/network/components/form/new/inputs/NetworkFormNumberInput.vue'
import NetworkFormTextInput from '@/modules/network/components/form/new/inputs/NetworkFormTextInput.vue'
import NewNetworkDescriptionTextarea from '@/modules/network/components/form/new/inputs/NewNetworkDescriptionTextarea.vue'
import NewNetworkNbdCheckbox from '@/modules/network/components/form/new/inputs/NewNetworkNbdCheckbox.vue'
import NewNetworkButtonsSection from '@/modules/network/components/form/new/NewNetworkButtonsSection.vue'
import { useNewInternalNetworkForm } from '@/modules/network/form/new-internal/use-new-internal-network-form.ts'
import type { NewInternalNetworkPayload } from '@/modules/network/jobs/internal-network-create.job.ts'
import { useI18n } from 'vue-i18n'
import type { RouteLocationRaw } from 'vue-router'
defineProps<{
cancelTo: RouteLocationRaw
}>()
const emit = defineEmits<{
create: [data: NewInternalNetworkPayload]
}>()
const { t } = useI18n()
const {
poolInputBindings,
nameInputBindings,
mtuInputBindings,
descriptionInputBindings,
nbdCheckboxBindings,
validateAndBuildPayload,
} = useNewInternalNetworkForm()
async function onSubmit() {
const payload = await validateAndBuildPayload()
if (payload !== undefined) {
emit('create', payload)
}
}
</script>
<style lang="postcss" scoped>
.new-internal-network-form {
.row {
display: flex;
align-items: start;
flex-direction: column;
gap: 2.4rem;
& > * {
width: 100%;
min-width: 0;
}
@media (--medium-or-large) {
flex-direction: row;
gap: 8rem;
max-width: 88rem;
}
&:not(:first-child) {
margin-block-start: 2.4rem;
}
.column {
display: flex;
flex-direction: column;
gap: 2.4rem;
}
}
@media (--medium-or-large) {
.pool {
max-width: 40rem;
}
}
.nbd {
margin-block-start: 2.4rem;
}
}
</style>

View File

@@ -0,0 +1,41 @@
import { type BaseNetworkFormData, useNetworkFormBase } from '@/modules/network/form/use-network-form-base.ts'
import type { NewInternalNetworkPayload } from '@/modules/network/jobs/internal-network-create.job.ts'
import { reactive } from 'vue'
export function useNewInternalNetworkForm() {
const formData = reactive<BaseNetworkFormData>({
name: '',
description: '',
mtu: undefined,
nbd: false,
})
const {
buildBasePayload,
poolInputBindings,
validate,
nameInputBindings,
descriptionInputBindings,
mtuInputBindings,
nbdCheckboxBindings,
} = useNetworkFormBase(formData)
async function validateAndBuildPayload(): Promise<NewInternalNetworkPayload | undefined> {
const valid = await validate()
if (!valid) {
return undefined
}
return buildBasePayload()
}
return {
poolInputBindings,
nameInputBindings,
descriptionInputBindings,
mtuInputBindings,
nbdCheckboxBindings,
validateAndBuildPayload,
}
}

View File

@@ -1,7 +1,7 @@
import type { NewBondedNetworkPayload } from '@/modules/network/jobs/bonded-network-create.job.ts' import type { NewBondedNetworkPayload } from '@/modules/network/jobs/bonded-network-create.job.ts'
import { defineJobArg } from '@core/packages/job' import { defineJobArg } from '@core/packages/job'
export const payloadsArg = defineJobArg<NewBondedNetworkPayload>({ export const payloadArg = defineJobArg<NewBondedNetworkPayload>({
identify: payload => payload.name, identify: payload => payload.name,
toArray: true, toArray: false,
}) })

View File

@@ -1,5 +1,5 @@
import type { XenApiNetwork, XenApiPif } from '@/libs/xen-api/xen-api.types.ts' import type { XenApiNetwork, XenApiPif } from '@/libs/xen-api/xen-api.types.ts'
import { payloadsArg } from '@/modules/network/jobs/bonded-network-create-args.ts' import { payloadArg } from '@/modules/network/jobs/bonded-network-create-args.ts'
import type { BaseNewNetworkPayload } from '@/modules/network/jobs/network-create.job.ts' import type { BaseNewNetworkPayload } from '@/modules/network/jobs/network-create.job.ts'
import { useXenApiStore } from '@/stores/xen-api.store.ts' import { useXenApiStore } from '@/stores/xen-api.store.ts'
import { defineJob, JobError, JobRunningError } from '@core/packages/job' import { defineJob, JobError, JobRunningError } from '@core/packages/job'
@@ -11,50 +11,44 @@ export type NewBondedNetworkPayload = BaseNewNetworkPayload & {
bondMode: BOND_MODE bondMode: BOND_MODE
} }
export const useBondedNetworkCreateJob = defineJob('bonded-network.create', [payloadsArg], () => { export const useBondedNetworkCreateJob = defineJob('bonded-network.create', [payloadArg], () => {
const xapi = useXenApiStore().getXapi() const xapi = useXenApiStore().getXapi()
const { t } = useI18n() const { t } = useI18n()
return { return {
run(payloads): Promise<PromiseSettledResult<XenApiNetwork['uuid']>[]> { async run(payload): Promise<XenApiNetwork['uuid']> {
return Promise.allSettled( const networkRef = await xapi.network.createBonded({
payloads.map(async payload => { nameLabel: payload.name,
const networkRef = await xapi.network.createBonded({ nameDescription: payload.description,
nameLabel: payload.name, mtu: payload.mtu,
nameDescription: payload.description, pifRefs: payload.pifRefs,
mtu: payload.mtu, bondMode: payload.bondMode,
pifRefs: payload.pifRefs, nbd: payload.nbd,
bondMode: payload.bondMode, })
nbd: payload.nbd,
})
return xapi.getField<XenApiNetwork['uuid']>('network', networkRef, 'uuid') return xapi.getField<XenApiNetwork['uuid']>('network', networkRef, 'uuid')
})
)
}, },
validate(isRunning, payloads) { validate(isRunning, payload) {
if (isRunning) { if (isRunning) {
throw new JobRunningError(t('job:create:in-progress')) throw new JobRunningError(t('job:create:in-progress'))
} }
if (payloads.length === 0) { if (payload === undefined) {
throw new JobError(t('job:arg:missing-payload')) throw new JobError(t('job:arg:missing-payload'))
} }
payloads.forEach(payload => { if (payload.name.length === 0) {
if (payload.name.length === 0) { throw new JobError(t('job:arg:name-required'))
throw new JobError(t('job:arg:name-required')) }
}
if (payload.pifRefs.length === 0) { if (payload.pifRefs.length === 0) {
throw new JobError(t('job:arg:pif-ids-required')) throw new JobError(t('job:arg:pif-ids-required'))
} }
if (payload.bondMode === undefined) { if (payload.bondMode === undefined) {
throw new JobError(t('job:arg:bond-mode-required')) throw new JobError(t('job:arg:bond-mode-required'))
} }
})
}, },
} }
}) })

View File

@@ -0,0 +1,7 @@
import type { NewInternalNetworkPayload } from '@/modules/network/jobs/internal-network-create.job.ts'
import { defineJobArg } from '@core/packages/job'
export const payloadArg = defineJobArg<NewInternalNetworkPayload>({
identify: payload => payload.name,
toArray: false,
})

View File

@@ -0,0 +1,40 @@
import type { XenApiNetwork } from '@/libs/xen-api/xen-api.types.ts'
import { payloadArg } from '@/modules/network/jobs/internal-network-create-args.ts'
import type { BaseNewNetworkPayload } from '@/modules/network/jobs/network-create.job.ts'
import { useXenApiStore } from '@/stores/xen-api.store.ts'
import { defineJob, JobError, JobRunningError } from '@core/packages/job'
import { useI18n } from 'vue-i18n'
export type NewInternalNetworkPayload = BaseNewNetworkPayload
export const useInternalNetworkCreateJob = defineJob('internal-network.create', [payloadArg], () => {
const xapi = useXenApiStore().getXapi()
const { t } = useI18n()
return {
async run(payload): Promise<XenApiNetwork['uuid']> {
const networkRef = await xapi.network.createInternal({
nameLabel: payload.name,
nameDescription: payload.description,
mtu: payload.mtu,
nbd: payload.nbd,
})
return xapi.getField<XenApiNetwork['uuid']>('network', networkRef, 'uuid')
},
validate(isRunning, payload) {
if (isRunning) {
throw new JobRunningError(t('job:create:in-progress'))
}
if (payload === undefined) {
throw new JobError(t('job:arg:missing-payload'))
}
if (payload.name.length === 0) {
throw new JobError(t('job:arg:name-required'))
}
},
}
})

View File

@@ -1,7 +1,7 @@
import type { NewNetworkPayload } from '@/modules/network/jobs/network-create.job.ts' import type { NewNetworkPayload } from '@/modules/network/jobs/network-create.job.ts'
import { defineJobArg } from '@core/packages/job' import { defineJobArg } from '@core/packages/job'
export const payloadsArg = defineJobArg<NewNetworkPayload>({ export const payloadArg = defineJobArg<NewNetworkPayload>({
identify: payload => payload.name, identify: payload => payload.name,
toArray: true, toArray: false,
}) })

View File

@@ -1,5 +1,5 @@
import type { XenApiNetwork, XenApiPif } from '@/libs/xen-api/xen-api.types.ts' import type { XenApiNetwork, XenApiPif } from '@/libs/xen-api/xen-api.types.ts'
import { payloadsArg } from '@/modules/network/jobs/network-create-args.ts' import { payloadArg } from '@/modules/network/jobs/network-create-args.ts'
import { useXenApiStore } from '@/stores/xen-api.store.ts' import { useXenApiStore } from '@/stores/xen-api.store.ts'
import { defineJob, JobError, JobRunningError } from '@core/packages/job' import { defineJob, JobError, JobRunningError } from '@core/packages/job'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@@ -16,46 +16,40 @@ export type NewNetworkPayload = BaseNewNetworkPayload & {
vlan?: number vlan?: number
} }
export const useNetworkCreateJob = defineJob('network.create', [payloadsArg], () => { export const useNetworkCreateJob = defineJob('network.create', [payloadArg], () => {
const xapi = useXenApiStore().getXapi() const xapi = useXenApiStore().getXapi()
const { t } = useI18n() const { t } = useI18n()
return { return {
run(payloads): Promise<PromiseSettledResult<XenApiNetwork['uuid']>[]> { async run(payload): Promise<XenApiNetwork['uuid']> {
return Promise.allSettled( const networkRef = await xapi.network.create({
payloads.map(async payload => { nameLabel: payload.name,
const networkRef = await xapi.network.create({ nameDescription: payload.description,
nameLabel: payload.name, mtu: payload.mtu,
nameDescription: payload.description, vlan: payload.vlan,
mtu: payload.mtu, pifRef: payload.pifRef,
vlan: payload.vlan, nbd: payload.nbd,
pifRef: payload.pifRef, })
nbd: payload.nbd,
})
return xapi.getField<XenApiNetwork['uuid']>('network', networkRef, 'uuid') return xapi.getField<XenApiNetwork['uuid']>('network', networkRef, 'uuid')
})
)
}, },
validate(isRunning, payloads) { validate(isRunning, payload) {
if (isRunning) { if (isRunning) {
throw new JobRunningError(t('job:create:in-progress')) throw new JobRunningError(t('job:create:in-progress'))
} }
if (payloads.length === 0) { if (payload === undefined) {
throw new JobError(t('job:arg:missing-payload')) throw new JobError(t('job:arg:missing-payload'))
} }
payloads.forEach(payload => { if (payload.name.length === 0) {
if (payload.name.length === 0) { throw new JobError(t('job:arg:name-required'))
throw new JobError(t('job:arg:name-required')) }
}
if (payload.pifRef === undefined) { if (payload.pifRef === undefined) {
throw new JobError(t('job:arg:pif-id-required')) throw new JobError(t('job:arg:pif-id-required'))
} }
})
}, },
} }
}) })

View File

@@ -67,15 +67,11 @@ async function createNetwork(newPayload: NewBondedNetworkPayload) {
} }
try { try {
const [promiseCreateResult] = await create() const networkId = await create()
if (promiseCreateResult.status === 'rejected') { redirectAfterSuccess(networkId)
throw promiseCreateResult.reason } catch (rawError) {
} error.value = rawError as Error
redirectAfterSuccess(promiseCreateResult.value)
} catch (_error) {
error.value = _error as Error
} }
} }

View File

@@ -0,0 +1,103 @@
<template>
<UiHeadBar icon="fa:plus">
{{ t('new-network:add-internal') }}
</UiHeadBar>
<div class="card-container">
<VtsOperationPendingCard v-if="isRunning" :title="t('creating-new-network')" />
<VtsOperationErrorCard
v-else-if="hasNetworkCreationError && error"
:title="t('unable-to-create-new-internal-network')"
:error
:error-message="t('new-network:error-message')"
>
<template #actions>
<UiButton variant="secondary" accent="brand" size="medium" @click="handleGoBack()">
{{ t('action:go-back') }}
</UiButton>
</template>
</VtsOperationErrorCard>
<UiCard v-show="canDisplayForm">
<UiTitle>{{ t('configuration') }}</UiTitle>
<NewInternalNetworkForm :cancel-to="cancelRoute" @create="createNetwork" />
</UiCard>
</div>
</template>
<script lang="ts" setup>
import type { XenApiNetwork } from '@/libs/xen-api/xen-api.types.ts'
import NewInternalNetworkForm from '@/modules/network/components/form/new/NewInternalNetworkForm.vue'
import {
type NewInternalNetworkPayload,
useInternalNetworkCreateJob,
} from '@/modules/network/jobs/internal-network-create.job.ts'
import { getPoolNetworkRoute } from '@/modules/network/utils/network.util.ts'
import { usePoolStore } from '@/stores/xen-api/pool.store.ts'
import VtsOperationErrorCard from '@core/components/operation-error-card/VtsOperationErrorCard.vue'
import VtsOperationPendingCard from '@core/components/operation-pending-card/VtsOperationPendingCard.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiHeadBar from '@core/components/ui/head-bar/UiHeadBar.vue'
import UiTitle from '@core/components/ui/title/UiTitle.vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { type RouteLocationRaw, useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const { pool } = usePoolStore().subscribe()
const error = ref<Error | undefined>()
const hasNetworkCreationError = computed(() => error.value !== undefined)
const formPayload = ref<NewInternalNetworkPayload>()
const { canRun, run: create, isRunning } = useInternalNetworkCreateJob(formPayload)
const canDisplayForm = computed(() => !isRunning.value && !hasNetworkCreationError.value)
const cancelRoute = computed<RouteLocationRaw>(() => {
if (pool.value === undefined) {
return { name: '/' }
}
return getPoolNetworkRoute(pool.value.uuid)
})
async function createNetwork(newPayload: NewInternalNetworkPayload) {
formPayload.value = newPayload
if (!canRun.value) {
return
}
try {
const networkId = await create()
redirectAfterSuccess(networkId)
} catch (rawError) {
error.value = rawError as Error
}
}
function handleGoBack() {
error.value = undefined
}
function redirectAfterSuccess(networkId: XenApiNetwork['uuid']) {
if (pool.value === undefined) {
return router.push({ name: '/' })
}
return router.push(getPoolNetworkRoute(pool.value.uuid, networkId))
}
</script>
<style lang="postcss" scoped>
.card-container {
padding: 0.8rem;
}
</style>

View File

@@ -64,15 +64,11 @@ async function createNetwork(newPayload: NewNetworkPayload) {
} }
try { try {
const [promiseCreateResult] = await create() const networkId = await create()
if (promiseCreateResult.status === 'rejected') { redirectAfterSuccess(networkId)
throw promiseCreateResult.reason } catch (rawError) {
} error.value = rawError as Error
redirectAfterSuccess(promiseCreateResult.value)
} catch (_error) {
error.value = _error as Error
} }
} }

View File

@@ -20,7 +20,13 @@
</MenuList> </MenuList>
</template> </template>
</PoolNetworksTable> </PoolNetworksTable>
<PoolHostInternalNetworksTable :networks="networksWithoutPifs" /> <PoolHostInternalNetworksTable :networks="networksWithoutPifs">
<template #title-actions>
<UiLink :to="{ name: '/network/new-internal' }" icon="fa:plus" size="medium">
{{ t('new') }}
</UiLink>
</template>
</PoolHostInternalNetworksTable>
</UiCard> </UiCard>
<PoolNetworkSidePanel :network="selectedNetwork" @close="selectedNetwork = undefined" /> <PoolNetworkSidePanel :network="selectedNetwork" @close="selectedNetwork = undefined" />
</VtsContentSidePanel> </VtsContentSidePanel>
@@ -43,6 +49,7 @@ import { useRouteQuery } from '@core/composables/route-query.composable.ts'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const { t } = useI18n() const { t } = useI18n()
usePageTitleStore().setTitle(t('network')) usePageTitleStore().setTitle(t('network'))
const { getByUuid, networksWithPifs, networksWithoutPifs, isReady, hasError } = useNetworkStore().subscribe() const { getByUuid, networksWithPifs, networksWithoutPifs, isReady, hasError } = useNetworkStore().subscribe()

View File

@@ -120,6 +120,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>, Record<never, never>,
| never | never
>, >,
'/network/new-internal': RouteRecordInfo<
'/network/new-internal',
'/network/new-internal',
Record<never, never>,
Record<never, never>,
| never
>,
'/object-not-found': RouteRecordInfo< '/object-not-found': RouteRecordInfo<
'/object-not-found', '/object-not-found',
'/object-not-found', '/object-not-found',
@@ -1144,6 +1151,12 @@ declare module 'vue-router/auto-routes' {
views: views:
| never | never
} }
'src/pages/network/new-internal.vue': {
routes:
| '/network/new-internal'
views:
| never
}
'src/pages/object-not-found.vue': { 'src/pages/object-not-found.vue': {
routes: routes:
| '/object-not-found' | '/object-not-found'