mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-09-10 22:14:48 -05:00
feat(xo6): add possibility to create VIF (#9677)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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}` +
|
||||
')$'
|
||||
)
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 !== '')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
58
@xen-orchestra/web/src/modules/vif/form/use-vif-form-base.ts
Normal file
58
@xen-orchestra/web/src/modules/vif/form/use-vif-form-base.ts
Normal 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) },
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
49
@xen-orchestra/web/src/modules/vif/jobs/xo-vif-create.job.ts
Normal file
49
@xen-orchestra/web/src/modules/vif/jobs/xo-vif-create.job.ts
Normal 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'))
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
108
@xen-orchestra/web/src/pages/vif/new.vue
Normal file
108
@xen-orchestra/web/src/pages/vif/new.vue
Normal 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>
|
||||
@@ -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()
|
||||
|
||||
13
@xen-orchestra/web/src/route-map.d.ts
vendored
13
@xen-orchestra/web/src/route-map.d.ts
vendored
@@ -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]'
|
||||
|
||||
@@ -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-->
|
||||
|
||||
Reference in New Issue
Block a user