feat(xo6): add possibility to create VIF (#9677)

This commit is contained in:
Joris K
2026-07-15 11:26:42 +02:00
committed by GitHub
parent 5de65620f1
commit 2bee8c279d
25 changed files with 675 additions and 16 deletions

View File

@@ -39,6 +39,7 @@
"action:create-network": "Create network",
"action:create-traffic-rule": "Create traffic rule",
"action:create-vdi": "Create VDI",
"action:create-vif": "Create VIF",
"action:delete": "Delete",
"action:delete-filter": "Delete filter",
"action:delete-group": "Delete group",
@@ -158,6 +159,8 @@
"allocated-space": "Allocated space",
"allow": "Allow",
"allow-self-signed-ssl": "You may need to allow self-signed SSL certificates in your browser",
"allowed-ips": "Allowed IPs",
"allowed-ips-example": "For example: 192.168.0.1; 2001:db8::1",
"and": "And",
"any-property": "Any property",
"api-error-details": "API Error details",
@@ -226,6 +229,7 @@
"build-number": "Build number",
"by": "By",
"bytes:gi": "GiB",
"bytes:kbs": "kB/s",
"bytes:ki": "KiB",
"bytes:mi": "MiB",
"cancel": "Cancel",
@@ -288,6 +292,7 @@
"creating-new-network": "Creating new network",
"creating-new-traffic-rule": "Creating new traffic rule",
"creating-new-vdi": "Creating new VDI",
"creating-new-vif": "Creating new VIF",
"creation-date": "Creation date",
"cron-pattern": "Cron pattern",
"current": "Current",
@@ -420,6 +425,9 @@
"interfaces": "Interfaces",
"interrupted": "Interrupted",
"invalid-field": "Invalid field",
"invalid-ip-addresses": "One or more invalid IP addresses",
"invalid-mac-address": "Invalid MAC address",
"invalid-rate-limit": "Rate limit cannot be negative",
"ip-address": "IP address",
"ip-address-info": "You can apply a rule to a single IP address or a subnet in CIDR format. Examples: 192.168.1.10, or 10.20.0.0/16",
"ip-address-invalid": "IP address format is invalid.",
@@ -673,6 +681,8 @@
"new-vdi:add": "Add new VDI",
"new-vdi:error-message": "An error occurred while creating the VDI. Please review your configuration settings (name, storage repository, allocated space) and try again.",
"new-vif": "New VIF",
"new-vif:add": "Add new VIF",
"new-vif:error-message": "An error occurred while creating the VIF. Please review your configuration settings and try again.",
"new-vm": "New VM",
"new-vm:add": "Add a new VM",
"new-vm:description": "VM description",
@@ -1057,6 +1067,7 @@
"unable-to-create-new-network": "Unable to create new network",
"unable-to-create-new-traffic-rule": "Unable to create new traffic rule",
"unable-to-create-new-vdi": "Unable to create new VDI",
"unable-to-create-new-vif": "Unable to create new VIF",
"unable-to-delete-network": "Unable to delete network",
"unable-to-migrate-vdi": "Unable to migrate VDI on SR",
"unknown": "Unknown",

View File

@@ -39,6 +39,7 @@
"action:create-network": "Créer un réseau",
"action:create-traffic-rule": "Créer une règle de trafic",
"action:create-vdi": "Créer un VDI",
"action:create-vif": "Créer un VIF",
"action:delete": "Supprimer",
"action:delete-filter": "Supprimer le filtre",
"action:delete-group": "Supprimer le groupe",
@@ -158,6 +159,8 @@
"allocated-space": "Espace alloué",
"allow": "Autoriser",
"allow-self-signed-ssl": "Vous devrez peut-être autoriser les certificats SSL auto-signés depuis votre navigateur",
"allowed-ips": "IPs autorisées",
"allowed-ips-example": "Par exemple : 192.168.0.1; 2001:db8::1",
"and": "Et",
"any-property": "N'importe quelle propriété",
"api-error-details": "Détails de l'erreur API",
@@ -226,6 +229,7 @@
"build-number": "Numéro de build",
"by": "Par",
"bytes:gi": "Gio",
"bytes:kbs": "kB/s",
"bytes:ki": "Kio",
"bytes:mi": "Mio",
"cancel": "Annuler",
@@ -288,6 +292,7 @@
"creating-new-network": "Création du nouveau réseau",
"creating-new-traffic-rule": "Création de la nouvelle règle de trafic",
"creating-new-vdi": "Création du nouveau VDI",
"creating-new-vif": "Création du nouveau VIF",
"creation-date": "Date de création",
"cron-pattern": "Pattern cron",
"current": "Actuel",
@@ -420,6 +425,9 @@
"interfaces": "Interfaces",
"interrupted": "Interrompu",
"invalid-field": "Champ invalide",
"invalid-ip-addresses": "Une ou plusieurs adresses IPs invalides",
"invalid-mac-address": "Adresse MAC invalide",
"invalid-rate-limit": "La limite de débit ne peut pas être négative",
"ip-address": "Adresse IP",
"ip-address-info": "Vous pouvez appliquer une règle à une seule adresse IP ou à un sous-réseau au format CIDR. Exemples : 192.168.1.10 ou 10.20.0.0/16",
"ip-address-invalid": "Le format de l'adresse IP est invalide.",
@@ -673,6 +681,8 @@
"new-vdi:add": "Ajouter un nouveau VDI",
"new-vdi:error-message": "Une erreur s'est produite lors de la création du VDI. Veuillez vérifier vos paramètres (nom, dépôt de stockage, espace alloué) et réessayer.",
"new-vif": "Nouveau VIF",
"new-vif:add": "Ajouter un nouveau VIF",
"new-vif:error-message": "Une erreur s'est produite lors de la création du VIF. Veuillez vérifier vos paramètres et réessayer.",
"new-vm": "Nouvelle VM",
"new-vm:add": "Ajouter une nouvelle VM",
"new-vm:description": "Description de la VM",
@@ -1057,6 +1067,7 @@
"unable-to-create-new-network": "Impossible de créer un nouveau réseau",
"unable-to-create-new-traffic-rule": "Impossible de créer une nouvelle règle de trafic",
"unable-to-create-new-vdi": "Impossible de créer un nouveau VDI",
"unable-to-create-new-vif": "Impossible de créer un nouveau VIF",
"unable-to-delete-network": "Impossible de supprimer le réseau",
"unable-to-migrate-vdi": "Impossible de migrer le VDI sur le SR",
"unknown": "Inconnu",

View File

@@ -0,0 +1,15 @@
import { parseIpList } from '@core/utils/ip-address.utils.ts'
import { createRule, type Maybe } from '@regle/core'
import { isFilled } from '@regle/rules'
import { IPV4_REGEX, IPV6_REGEX } from './ip.regex.ts'
export const ipAddresses = createRule({
validator(value: Maybe<string>, separator: string = ';') {
if (!isFilled(value)) {
return true
}
return parseIpList(value, separator).every(ip => IPV4_REGEX.test(ip) || IPV6_REGEX.test(ip))
},
message: 'One or more invalid IP addresses',
})

View File

@@ -0,0 +1,26 @@
const IPV4_OCTET = '(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)'
const V6_SEGMENT = '[0-9a-fA-F]{1,4}'
export const IPV4_ADDRESS = `${IPV4_OCTET}(\\.${IPV4_OCTET}){3}`
export const IPV4_CIDR_PREFIX = '(3[0-2]|[12]?\\d)'
export const IPV4_REGEX = new RegExp(`^${IPV4_ADDRESS}$`)
export const IPV6_REGEX = new RegExp(
'^(' +
`(${V6_SEGMENT}:){7}${V6_SEGMENT}|` +
`(${V6_SEGMENT}:){1,7}:|` +
`(${V6_SEGMENT}:){1,6}:${V6_SEGMENT}|` +
`(${V6_SEGMENT}:){1,5}(:${V6_SEGMENT}){1,2}|` +
`(${V6_SEGMENT}:){1,4}(:${V6_SEGMENT}){1,3}|` +
`(${V6_SEGMENT}:){1,3}(:${V6_SEGMENT}){1,4}|` +
`(${V6_SEGMENT}:){1,2}(:${V6_SEGMENT}){1,5}|` +
`${V6_SEGMENT}:(:${V6_SEGMENT}){1,6}|` +
`:((:${V6_SEGMENT}){1,7}|:)|` +
`fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|` +
`::(ffff(:0{1,4})?:)?${IPV4_ADDRESS}|` +
`(${V6_SEGMENT}:){1,4}:${IPV4_ADDRESS}` +
')$'
)

View File

@@ -1,9 +1,8 @@
import { createRule, type Maybe } from '@regle/core'
import { isFilled } from '@regle/rules'
import { IPV4_ADDRESS, IPV4_CIDR_PREFIX } from './ip.regex.ts'
const IPV4_OCTET = '(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)'
const IPV4_CIDR_PREFIX = '(3[0-2]|[12]?\\d)'
const IP_OR_CIDR_REGEX = new RegExp(`^${IPV4_OCTET}(\\.${IPV4_OCTET}){3}(/${IPV4_CIDR_PREFIX})?$`)
const IP_OR_CIDR_REGEX = new RegExp(`^${IPV4_ADDRESS}(/${IPV4_CIDR_PREFIX})?$`)
export const ipv4OrCidr = createRule({
validator(value: Maybe<string>) {

View File

@@ -1,5 +1,7 @@
export * from './custom-rules/out-of-range.rule.ts'
export * from './custom-rules/ipv4-or-cidr.rule.ts'
export * from './custom-rules/ip-addresses.rule.ts'
export * from './custom-rules/ip.regex.ts'
export * from './merge-validation-configs.ts'
export * from './types.ts'
export * from './use-form-validation.ts'

View File

@@ -16,3 +16,10 @@ export const getUniqueIpAddressesForDevice = (
return [...new Set(getIpAddressesByDevice(addresses)[device])]
}
export function parseIpList(value: string, separator = ';'): string[] {
return value
.split(separator)
.map(ip => ip.trim())
.filter(ip => ip !== '')
}

View File

@@ -0,0 +1,33 @@
<template>
<div class="buttons-container">
<UiLink :to="cancelTo" size="medium">
{{ t('cancel') }}
</UiLink>
<UiButton type="submit" size="medium" accent="brand" variant="primary">
{{ t('action:create-vif') }}
</UiButton>
</div>
</template>
<script lang="ts" setup>
import UiButton from '@core/components/ui/button/UiButton.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { useI18n } from 'vue-i18n'
import type { RouteLocationRaw } from 'vue-router'
defineProps<{
cancelTo: RouteLocationRaw
}>()
const { t } = useI18n()
</script>
<style lang="postcss" scoped>
.buttons-container {
display: flex;
justify-content: center;
align-items: center;
gap: 2.4rem;
margin-block-start: 2.4rem;
}
</style>

View File

@@ -0,0 +1,91 @@
<template>
<VtsForm class="new-vif-form" @submit="onSubmit()">
<div class="row">
<VifNetworkSelect v-bind="networkSelectBindings" />
<VifMacInput v-bind="macInputBindings" />
</div>
<div class="row">
<VifRateLimitInput v-bind="rateLimitInputBindings" />
<VifAllowedIpsTextarea v-bind="allowedIpsTextareaBindings" />
</div>
<div class="tx-checksumming">
<VifTxChecksummingCheckbox v-bind="txChecksummingCheckboxBindings" />
</div>
<NewVifButtonsSection :cancel-to />
</VtsForm>
</template>
<script setup lang="ts">
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import VifMacInput from '@/modules/vif/components/form/new/inputs/VifMacInput.vue'
import NewVifButtonsSection from '@/modules/vif/components/form/new/NewVifButtonsSection.vue'
import { useNewVifForm } from '@/modules/vif/form/new/use-new-vif-form.ts'
import type { NewVifPayload } from '@/modules/vif/jobs/xo-vif-create.job.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import VtsForm from '@core/components/form/VtsForm.vue'
import type { RouteLocationRaw } from 'vue-router'
import VifAllowedIpsTextarea from './inputs/VifAllowedIpsTextarea.vue'
import VifNetworkSelect from './inputs/VifNetworkSelect.vue'
import VifRateLimitInput from './inputs/VifRateLimitInput.vue'
import VifTxChecksummingCheckbox from './inputs/VifTxChecksummingCheckbox.vue'
const { vmId, poolId } = defineProps<{
vmId: FrontXoVm['id']
poolId: FrontXoPool['id']
cancelTo: RouteLocationRaw
}>()
const emit = defineEmits<{
create: [data: NewVifPayload]
}>()
const {
networkSelectBindings,
macInputBindings,
rateLimitInputBindings,
allowedIpsTextareaBindings,
txChecksummingCheckboxBindings,
validateAndBuildPayload,
} = useNewVifForm(
() => vmId,
() => poolId
)
async function onSubmit() {
const payload = await validateAndBuildPayload()
if (payload !== undefined) {
emit('create', payload)
}
}
</script>
<style lang="postcss" scoped>
.new-vif-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;
}
}
.tx-checksumming {
margin-block-start: 2.4rem;
}
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<UiTextarea v-model.trim="model" :accent="errorMessage !== undefined ? 'danger' : 'brand'">
{{ t('allowed-ips') }}
<template #info>
<!-- TODO change to have both when component will be updated -->
<template v-if="errorMessage === undefined">{{ t('allowed-ips-example') }}</template>
<template v-else>{{ errorMessage }}</template>
</template>
</UiTextarea>
</template>
<script lang="ts" setup>
import type { InputWrapperMessage } from '@core/components/input-wrapper/VtsInputWrapper.vue'
import UiTextarea from '@core/components/ui/text-area/UiTextarea.vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { error } = defineProps<{
error?: InputWrapperMessage
}>()
const model = defineModel<string>({ required: true })
const { t } = useI18n()
const errorMessage = computed(() => {
const first = Array.isArray(error) ? error[0] : error
return typeof first === 'object' ? first.content : first
})
</script>

View File

@@ -0,0 +1,19 @@
<template>
<VtsInputWrapper :label="t('mac-address')" :message="error">
<UiInput v-model.trim="model" accent="brand" :placeholder="t('auto-generated')" />
</VtsInputWrapper>
</template>
<script lang="ts" setup>
import VtsInputWrapper, { type InputWrapperMessage } from '@core/components/input-wrapper/VtsInputWrapper.vue'
import UiInput from '@core/components/ui/input/UiInput.vue'
import { useI18n } from 'vue-i18n'
defineProps<{
error?: InputWrapperMessage
}>()
const model = defineModel<string>({ required: true })
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,39 @@
<template>
<VtsInputWrapper :label="t('network')" :message="error">
<VtsSelect :id accent="brand">
<template #default="{ option }">
<VtsOption :option>
<span class="select-option">
<VtsIcon name="object:network" size="medium" />
{{ option.properties.label }}
</span>
</VtsOption>
</template>
</VtsSelect>
</VtsInputWrapper>
</template>
<script lang="ts" setup>
import type { InputWrapperMessage } from '@core/components/input-wrapper/VtsInputWrapper.vue'
import type { FormSelectId } from '@core/packages/form-select'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import VtsInputWrapper from '@core/components/input-wrapper/VtsInputWrapper.vue'
import VtsOption from '@core/components/select/VtsOption.vue'
import VtsSelect from '@core/components/select/VtsSelect.vue'
import { useI18n } from 'vue-i18n'
defineProps<{
id: FormSelectId
error?: InputWrapperMessage
}>()
const { t } = useI18n()
</script>
<style lang="postcss" scoped>
.select-option {
display: flex;
align-items: center;
gap: 0.8rem;
}
</style>

View File

@@ -0,0 +1,30 @@
<template>
<VtsInputWrapper :label="t('rate-limit')" :message="error">
<UiInput v-model.number="model" accent="brand" type="number">
<template #right-icon>
<span class="unit typo-body-regular">{{ t('bytes:kbs') }}</span>
</template>
</UiInput>
</VtsInputWrapper>
</template>
<script lang="ts" setup>
import VtsInputWrapper, { type InputWrapperMessage } from '@core/components/input-wrapper/VtsInputWrapper.vue'
import UiInput from '@core/components/ui/input/UiInput.vue'
import { useI18n } from 'vue-i18n'
defineProps<{
error?: InputWrapperMessage
}>()
const model = defineModel<number | undefined>()
const { t } = useI18n()
</script>
<style lang="postcss" scoped>
.unit {
color: var(--color-neutral-txt-secondary);
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,14 @@
<template>
<UiCheckbox v-model="model" accent="brand">
{{ t('check-summing') }}
</UiCheckbox>
</template>
<script lang="ts" setup>
import UiCheckbox from '@core/components/ui/checkbox/UiCheckbox.vue'
import { useI18n } from 'vue-i18n'
const model = defineModel<boolean>({ required: true })
const { t } = useI18n()
</script>

View File

@@ -59,6 +59,15 @@
<VtsCopyButton :value="String(vif.MTU)" />
</template>
</VtsCardRowKeyValue>
<!-- RATE LIMIT -->
<VtsCardRowKeyValue>
<template #key>
{{ t('rate-limit') }}
</template>
<template #value>
{{ vif.rateLimit !== undefined ? `${vif.rateLimit} ${t('bytes:kbs')}` : '' }}
</template>
</VtsCardRowKeyValue>
<!-- LOCKING MODE -->
<VtsCardRowKeyValue>
<template #key>

View File

@@ -0,0 +1,88 @@
import {
type FrontXoNetwork,
useXoNetworkCollection,
} from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import {
type BaseVifFormData,
buildBaseVifPayload,
useVifFormBaseValidation,
} from '@/modules/vif/form/use-vif-form-base.ts'
import type { NewVifPayload } from '@/modules/vif/jobs/xo-vif-create.job.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { mergeValidationConfigs, required } from '@core/packages/form-validation'
import { useValidatedForm } from '@core/packages/validated-form'
import { toComputed } from '@core/utils/to-computed.util.ts'
import { useArrayFilter } from '@vueuse/shared'
import { type MaybeRefOrGetter, reactive, watch } from 'vue'
export type NewVifFormData = BaseVifFormData & {
network: FrontXoNetwork['id'] | undefined
}
export function useNewVifForm(
rawVmId: MaybeRefOrGetter<FrontXoVm['id']>,
rawPoolId: MaybeRefOrGetter<FrontXoPool['id']>
) {
const vmId = toComputed(rawVmId)
const poolId = toComputed(rawPoolId)
const { networks } = useXoNetworkCollection()
const poolNetworks = useArrayFilter(networks, network => network.$pool === poolId.value)
const formData = reactive<NewVifFormData>({
network: undefined,
mac: '',
rateLimit: undefined,
allowedIps: '',
txChecksumming: true,
})
const { useField, useFormSelect, useSelect, validate } = useValidatedForm(
formData,
mergeValidationConfigs<BaseVifFormData, NewVifFormData>(useVifFormBaseValidation(), {
errors: {
onSubmit: () => ({
network: { required },
}),
},
})
)
const { id: networkSelectId } = useFormSelect('network', poolNetworks, {
searchable: true,
required: true,
option: {
label: 'name_label',
value: 'id',
},
})
watch(poolId, () => {
formData.network = undefined
})
async function validateAndBuildPayload(): Promise<NewVifPayload | undefined> {
const isValid = await validate()
if (!isValid || formData.network === undefined) {
return undefined
}
return {
vmId: vmId.value,
networkId: formData.network,
...buildBaseVifPayload(formData),
}
}
return {
networkSelectBindings: useSelect(networkSelectId),
macInputBindings: useField('mac'),
rateLimitInputBindings: useField('rateLimit'),
allowedIpsTextareaBindings: useField('allowedIps'),
txChecksummingCheckboxBindings: useField('txChecksumming'),
validateAndBuildPayload,
}
}

View File

@@ -0,0 +1,58 @@
import type { BaseVifPayload } from '@/modules/vif/jobs/xo-vif-create.job.ts'
import { type IpAddress, isIpv6 } from '@/shared/utils/ip.utils.ts'
import {
type FormValidationConfig,
ipAddresses,
macAddress,
minValue,
or,
withMessage,
} from '@core/packages/form-validation'
import { parseIpList } from '@core/utils/ip-address.utils.ts'
import { useI18n } from 'vue-i18n'
export type BaseVifFormData = {
mac: string
rateLimit: number | undefined
allowedIps: string
txChecksumming: boolean
}
export function useVifFormBaseValidation(): FormValidationConfig<BaseVifFormData> {
const { t } = useI18n()
return {
errors: {
onBlur: () => ({
mac: {
macAddress: withMessage(or(macAddress(':'), macAddress('-')), t('invalid-mac-address')),
},
rateLimit: {
minValue: withMessage(minValue(0), t('invalid-rate-limit')),
},
allowedIps: {
ipAddresses: withMessage(ipAddresses(';'), t('invalid-ip-addresses')),
},
}),
},
}
}
export function buildBaseVifPayload(formData: BaseVifFormData): BaseVifPayload {
const allowedIps = parseIpList(formData.allowedIps) as IpAddress[]
const ipv4 = allowedIps.filter(ip => !isIpv6(ip))
const ipv6 = allowedIps.filter(ip => isIpv6(ip))
return {
...(formData.mac !== '' && { MAC: formData.mac }),
...(ipv4.length > 0 && { ipv4_allowed: ipv4 }),
...(ipv6.length > 0 && { ipv6_allowed: ipv6 }),
// allowed IPs are only enforced when the locking mode is `locked`
...(allowedIps.length > 0 && { locking_mode: 'locked' }),
...(typeof formData.rateLimit === 'number' && {
qos_algorithm_type: 'ratelimit',
qos_algorithm_params: { kbps: String(formData.rateLimit) },
}),
other_config: { 'ethtool-tx': String(formData.txChecksumming) },
}
}

View File

@@ -0,0 +1,7 @@
import type { NewVifPayload } from '@/modules/vif/jobs/xo-vif-create.job.ts'
import { defineJobArg } from '@core/packages/job'
export const payloadArg = defineJobArg<NewVifPayload>({
identify: payload => payload.vmId,
toArray: true,
})

View File

@@ -0,0 +1,49 @@
import type { FrontXoNetwork } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import { payloadArg } from '@/modules/vif/jobs/xo-vif-create-args.ts'
import type { FrontXoVif } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { fetchPost } from '@/shared/utils/fetch.util.ts'
import type { IpAddress } from '@/shared/utils/ip.utils.ts'
import { defineJob, JobError, JobRunningError } from '@core/packages/job'
import type { VIF_LOCKING_MODE } from '@vates/types'
import { useI18n } from 'vue-i18n'
export type BaseVifPayload = {
MAC?: string
ipv4_allowed?: IpAddress[]
ipv6_allowed?: IpAddress[]
locking_mode?: VIF_LOCKING_MODE
qos_algorithm_type?: string
qos_algorithm_params?: Record<string, string>
other_config: Record<string, string>
}
export type NewVifPayload = BaseVifPayload & {
vmId: FrontXoVm['id']
networkId: FrontXoNetwork['id']
}
export const useXoVifCreateJob = defineJob('vif.create', [payloadArg], () => {
const { t } = useI18n()
return {
run(payloads): Promise<PromiseSettledResult<FrontXoVif['id']>[]> {
return Promise.allSettled(
payloads.map(async payload => {
const { id } = await fetchPost<{ id: FrontXoVif['id'] }>('vifs', payload)
return id
})
)
},
validate(isRunning, payloads) {
if (isRunning) {
throw new JobRunningError(t('job:create:in-progress'))
}
if (payloads.length === 0) {
throw new JobError(t('job:arg:missing-payload'))
}
},
}
})

View File

@@ -16,10 +16,10 @@ const vifFields = [
'lockingMode',
'MAC',
'MTU',
'rateLimit',
'type',
'other_config',
'$pool',
'rateLimit',
] as const satisfies readonly (keyof XoVif)[]
export const useXoVifCollection = defineRemoteResource({

View File

@@ -14,7 +14,7 @@
<VtsOperationPendingCard v-if="isRunning" :title="t('creating-new-vdi')" />
<VtsOperationErrorCard
v-else-if="hasCreationError && error"
v-else-if="error"
:title="t('unable-to-create-new-vdi')"
:error
:error-message="t('new-vdi:error-message')"
@@ -49,6 +49,7 @@ import { useI18n } from 'vue-i18n'
import { type RouteLocationRaw, useRoute, useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
@@ -59,13 +60,12 @@ const { areVmsReady, useGetVmById } = useXoVmCollection()
const vm = useGetVmById(vmId)
const formPayload = ref<NewVdiPayload>()
const error = ref<ApiError | Error | undefined>()
const { canRun, run: create, isRunning } = useXoVdiCreateJob(formPayload)
const hasCreationError = computed(() => error.value !== undefined)
const canDisplayForm = computed(() => !isRunning.value && !hasCreationError.value)
const canDisplayForm = computed(() => !isRunning.value && error.value === undefined)
const cancelRoute = computed<RouteLocationRaw>(() => {
if (!vmId.value) {

View File

@@ -0,0 +1,108 @@
<template>
<UiHeadBar icon="fa:plus">
{{ t('new-vif:add') }}
</UiHeadBar>
<div class="card-container">
<VtsStateHero v-if="!areVmsReady" format="page" type="busy" size="large" />
<VtsStateHero v-else-if="!vm" format="page" type="not-found" size="large">
{{ t('object-not-found', { id: vmId }) }}
</VtsStateHero>
<template v-else>
<VtsOperationPendingCard v-if="isRunning" :title="t('creating-new-vif')" />
<VtsOperationErrorCard
v-else-if="error"
:title="t('unable-to-create-new-vif')"
:error
:error-message="t('new-vif: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>
<NewVifForm v-if="vm" :vm-id="vm.id" :pool-id="vm.$pool" :cancel-to="cancelRoute" @create="createVif" />
</UiCard>
</template>
</div>
</template>
<script setup lang="ts">
import NewVifForm from '@/modules/vif/components/form/new/NewVifForm.vue'
import { type NewVifPayload, useXoVifCreateJob } from '@/modules/vif/jobs/xo-vif-create.job.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import type { ApiError } from '@/shared/error/api.error.ts'
import VtsOperationErrorCard from '@core/components/operation-error-card/VtsOperationErrorCard.vue'
import VtsOperationPendingCard from '@core/components/operation-pending-card/VtsOperationPendingCard.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.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, useRoute, useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const vmId = computed(() => route.query.vmId as FrontXoVm['id'] | undefined)
const { areVmsReady, useGetVmById } = useXoVmCollection()
const vm = useGetVmById(vmId)
const formPayload = ref<NewVifPayload>()
const error = ref<ApiError | Error | undefined>()
const { canRun, run: create, isRunning } = useXoVifCreateJob(formPayload)
const canDisplayForm = computed(() => !isRunning.value && error.value === undefined)
const cancelRoute = computed<RouteLocationRaw>(() => {
if (!vmId.value) {
return { name: '/(site)/dashboard' }
}
return { name: '/vm/[id]/networks', params: { id: vmId.value } }
})
async function createVif(payload: NewVifPayload) {
formPayload.value = payload
if (!canRun.value) {
return
}
try {
const [promiseResult] = await create()
if (promiseResult.status === 'rejected') {
throw promiseResult.reason
}
await router.push(cancelRoute.value)
} catch (_error) {
error.value = _error as ApiError | Error
}
}
function handleGoBack() {
error.value = undefined
}
</script>
<style lang="postcss" scoped>
.card-container {
padding: 0.8rem;
}
</style>

View File

@@ -3,8 +3,8 @@
<UiCard class="container">
<VifsTable :vifs :vm>
<template #title-actions>
<UiLink :href="xo5VmVifHref" icon="fa:plus" size="medium">
{{ t('action:add-vifs-in-xo-5') }}
<UiLink size="medium" :to="{ name: '/vif/new', query: { vmId: vm.id } }" icon="fa:plus">
{{ t('new-vif') }}
</UiLink>
</template>
</VifsTable>
@@ -18,22 +18,17 @@ import VifSidePanel from '@/modules/vif/components/panel/VifSidePanel.vue'
import VifsTable from '@/modules/vif/components/VifsTable.vue'
import { type FrontXoVif, useXoVifCollection } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import VtsContentSidePanel from '@core/components/layout/VtsContentSidePanel.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { useRouteQuery } from '@core/composables/route-query.composable.ts'
import { useArrayFilter } from '@vueuse/shared'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { vm } = defineProps<{
vm: FrontXoVm
}>()
const { buildXo5Route } = useXoRoutes()
const xo5VmVifHref = computed(() => buildXo5Route(`/vms/${vm.id}/network`))
const { vifs: rawVifs, getVifById } = useXoVifCollection()
const { t } = useI18n()

View File

@@ -353,6 +353,13 @@ declare module 'vue-router/auto-routes' {
{ id: ParamValue<false> },
| never
>,
'/vif/new': RouteRecordInfo<
'/vif/new',
'/vif/new',
Record<never, never>,
Record<never, never>,
| never
>,
'/vm/[id]': RouteRecordInfo<
'/vm/[id]',
'/vm/:id',
@@ -729,6 +736,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/pages/vif/new.vue': {
routes:
| '/vif/new'
views:
| never
}
'src/pages/vm/[id].vue': {
routes:
| '/vm/[id]'

View File

@@ -18,6 +18,7 @@
- [REST API] Possibility of sending `autoEnable` in the body of the `/hosts/:id/actions/disable` endpoint (PR [#10040](https://github.com/vatesfr/xen-orchestra/pull/10040))
- [REST API] `PATCH /rest/v0/vdis/{id}` to update a VDI's name, description and size (PR [#9945](https://github.com/vatesfr/xen-orchestra/pull/9945))
- [Pool] XO now reconnects to a surviving pool member when the master becomes unreachable (e.g. HA promoted a new master after the old one died), instead of staying stuck on the dead master, including after an XO restart (PR [#10016](https://github.com/vatesfr/xen-orchestra/pull/10016))
- [XO6/VIF] Add possibility to create VIF from network tab in VM page (PR [#9677](https://github.com/vatesfr/xen-orchestra/pull/9677))
### Bug fixes
@@ -26,6 +27,7 @@
- [XO6] Fix negative "other" value in backup repository dashboard (PR [#10044](https://github.com/vatesfr/xen-orchestra/pull/10044))
- [Backups] Fix missing transfer size (PR [#10106](https://github.com/vatesfr/xen-orchestra/pull/10106))
### Packages to release
> When modifying a package, add it here with its release type.
@@ -44,10 +46,12 @@
- @vates/types minor
- @xen-orchestra/rest-api minor
- @xen-orchestra/web patch
- @xen-orchestra/web minor
- @xen-orchestra/web-core minor
- vhd-lib patch
- xapi-explore-sr patch
- xen-api minor
- xo-server minor
<!--packages-end-->