mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
feat(lite): add possibility to edit a VIF (#10505)
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
- [Lite] Display dates and times as `YYYY-MM-DD HH:mm:ss` (PR [#10547](https://github.com/vatesfr/xen-orchestra/pull/10547))
|
||||
- [Pool/Hosts] Add a side panel displaying the selected host's details, and a search bar to filter the hosts table (PR [#10580](https://github.com/vatesfr/xen-orchestra/pull/10580))
|
||||
- [VM/Host] Add console clipboard management (PR [#10625](https://github.com/vatesfr/xen-orchestra/pull/10625))
|
||||
- [VM/Network] Add the possibility to edit a VIF (PR [#10505](https://github.com/vatesfr/xen-orchestra/pull/10505))
|
||||
|
||||
## **0.26.0** (2026-10-01)
|
||||
|
||||
|
||||
@@ -25,6 +25,14 @@ export function createVifOperations(xenApi: XenApi) {
|
||||
qos_algorithm_type?: string
|
||||
}
|
||||
|
||||
const setOtherConfig = (vifRefs: VifRefs, key: string, value: string) =>
|
||||
Promise.all(
|
||||
toArray(vifRefs).map(async vifRef => {
|
||||
await xenApi.call('VIF.remove_from_other_config', [vifRef, key])
|
||||
await xenApi.call('VIF.add_to_other_config', [vifRef, key, value])
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
create: async (vifs: VifCreateParams[]) => {
|
||||
const results: VifRefs = []
|
||||
@@ -78,5 +86,34 @@ export function createVifOperations(xenApi: XenApi) {
|
||||
plug: (vifRefs: VifRefs) => Promise.all(toArray(vifRefs).map(vifRef => xenApi.call('VIF.plug', [vifRef]))),
|
||||
|
||||
unplug: (vifRefs: VifRefs) => Promise.all(toArray(vifRefs).map(vifRef => xenApi.call('VIF.unplug', [vifRef]))),
|
||||
|
||||
unplugForce: (vifRefs: VifRefs) =>
|
||||
Promise.all(toArray(vifRefs).map(vifRef => xenApi.call('VIF.unplug_force', [vifRef]))),
|
||||
|
||||
move: (vifRefs: VifRefs, networkRef: NetworkRef) =>
|
||||
Promise.all(toArray(vifRefs).map(vifRef => xenApi.call('VIF.move', [vifRef, networkRef]))),
|
||||
|
||||
setIpv4Allowed: (vifRefs: VifRefs, ips: string[]) =>
|
||||
Promise.all(toArray(vifRefs).map(vifRef => xenApi.call('VIF.set_ipv4_allowed', [vifRef, ips]))),
|
||||
|
||||
setIpv6Allowed: (vifRefs: VifRefs, ips: string[]) =>
|
||||
Promise.all(toArray(vifRefs).map(vifRef => xenApi.call('VIF.set_ipv6_allowed', [vifRef, ips]))),
|
||||
|
||||
setLockingMode: (vifRefs: VifRefs, lockingMode: VIF_LOCKING_MODE) =>
|
||||
Promise.all(toArray(vifRefs).map(vifRef => xenApi.call('VIF.set_locking_mode', [vifRef, lockingMode]))),
|
||||
|
||||
setRateLimit: (vifRefs: VifRefs, kbps: number | null) =>
|
||||
Promise.all(
|
||||
toArray(vifRefs).map(async vifRef => {
|
||||
await xenApi.call('VIF.set_qos_algorithm_type', [vifRef, kbps === null ? '' : 'ratelimit'])
|
||||
await xenApi.call('VIF.remove_from_qos_algorithm_params', [vifRef, 'kbps'])
|
||||
|
||||
if (kbps !== null) {
|
||||
await xenApi.call('VIF.add_to_qos_algorithm_params', [vifRef, 'kbps', String(kbps)])
|
||||
}
|
||||
})
|
||||
),
|
||||
|
||||
setTxChecksumming: (vifRefs: VifRefs, enabled: boolean) => setOtherConfig(vifRefs, 'ethtool-tx', String(enabled)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { XenApiNetwork, XenApiVif, XenApiVm } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import { useVifConnection } from '@/modules/vif/composables/use-vif-connection.composable.ts'
|
||||
import { useVifEdit } from '@/modules/vif/composables/use-vif-edit.composable.ts'
|
||||
import { useNetworkStore } from '@/stores/xen-api/network.store.ts'
|
||||
import { useVifStore } from '@/stores/xen-api/vif.store.ts'
|
||||
import { useVmGuestMetricsStore } from '@/stores/xen-api/vm-guest-metrics.store.ts'
|
||||
@@ -59,6 +60,8 @@ const { getByOpaqueRef: getGuestMetricsByOpaqueRef } = useVmGuestMetricsStore().
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { goToVifEdit } = useVifEdit()
|
||||
|
||||
const selectedVifId = useRouteQuery('id')
|
||||
|
||||
const getNetworkName = (networkRef: XenApiNetwork['$ref']) => getNetworkByOpaqueRef(networkRef)?.name_label ?? ''
|
||||
@@ -147,7 +150,10 @@ const { HeadCells, BodyCells } = useVifColumns({
|
||||
actions: r =>
|
||||
r({
|
||||
onClick: () => (selectedVifId.value = vif.uuid),
|
||||
actions: [connectionAction.value],
|
||||
actions: [
|
||||
{ label: t('action:edit'), icon: 'action:edit', onClick: () => goToVifEdit(vif) } satisfies ActionItem,
|
||||
connectionAction.value,
|
||||
],
|
||||
}),
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<UiButton size="medium" variant="tertiary" accent="brand" left-icon="action:edit" @click="goToVifEdit(vif)">
|
||||
{{ t('action:edit') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { XenApiVif } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import { useVifEdit } from '@/modules/vif/composables/use-vif-edit.composable.ts'
|
||||
import UiButton from '@core/components/ui/button/UiButton.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { vif } = defineProps<{
|
||||
vif: XenApiVif
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { goToVifEdit } = useVifEdit()
|
||||
</script>
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="new-vif-buttons-section">
|
||||
<div class="vif-form-buttons-section">
|
||||
<UiLink :to="cancelTo" size="medium">
|
||||
{{ t('cancel') }}
|
||||
</UiLink>
|
||||
<UiButton type="submit" size="medium" accent="brand" variant="primary">
|
||||
{{ t('action:create-vif') }}
|
||||
{{ submitLabel }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -17,13 +17,14 @@ import { type RouteLocationRaw } from 'vue-router'
|
||||
|
||||
defineProps<{
|
||||
cancelTo: RouteLocationRaw
|
||||
submitLabel: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.new-vif-buttons-section {
|
||||
.vif-form-buttons-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<VtsForm class="edit-vif-form" @submit="onSubmit()">
|
||||
<div class="row">
|
||||
<VifNetworkSelect v-bind="networkSelectBindings">
|
||||
<template #option="{ option }">
|
||||
<VtsOption :option>
|
||||
<span class="network-select-option">
|
||||
<VtsIcon :name="option.properties.icon" size="medium" />
|
||||
{{ option.properties.label }}
|
||||
</span>
|
||||
</VtsOption>
|
||||
</template>
|
||||
</VifNetworkSelect>
|
||||
<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>
|
||||
<VifFormButtonsSection :cancel-to :submit-label="t('action:save')" />
|
||||
</VtsForm>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { XenApiVif } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import VifAllowedIpsTextarea from '@/modules/vif/components/form/inputs/VifAllowedIpsTextarea.vue'
|
||||
import VifMacInput from '@/modules/vif/components/form/inputs/VifMacInput.vue'
|
||||
import VifNetworkSelect from '@/modules/vif/components/form/inputs/VifNetworkSelect.vue'
|
||||
import VifRateLimitInput from '@/modules/vif/components/form/inputs/VifRateLimitInput.vue'
|
||||
import VifTxChecksummingCheckbox from '@/modules/vif/components/form/inputs/VifTxChecksummingCheckbox.vue'
|
||||
import VifFormButtonsSection from '@/modules/vif/components/form/VifFormButtonsSection.vue'
|
||||
import { useEditVifForm } from '@/modules/vif/form/edit/use-edit-vif-form.ts'
|
||||
import type { EditVifPayload } from '@/modules/vif/jobs/vif-edit.job.ts'
|
||||
import VtsForm from '@core/components/form/VtsForm.vue'
|
||||
import VtsIcon from '@core/components/icon/VtsIcon.vue'
|
||||
import VtsOption from '@core/components/select/VtsOption.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
const { vif } = defineProps<{
|
||||
vif: XenApiVif
|
||||
cancelTo: RouteLocationRaw
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [data: EditVifPayload]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
networkSelectBindings,
|
||||
macInputBindings,
|
||||
rateLimitInputBindings,
|
||||
allowedIpsTextareaBindings,
|
||||
txChecksummingCheckboxBindings,
|
||||
validateAndBuildPayload,
|
||||
} = useEditVifForm(vif)
|
||||
|
||||
async function onSubmit() {
|
||||
const payload = await validateAndBuildPayload()
|
||||
|
||||
if (payload !== undefined) {
|
||||
emit('save', payload)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.edit-vif-form {
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
flex-direction: column;
|
||||
gap: 2.4rem;
|
||||
|
||||
& > * {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.network-select-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
@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,26 @@
|
||||
<template>
|
||||
<VtsInputWrapper :label="t('mac-address')" :message="messages" wrap-message>
|
||||
<UiInput v-model.trim="model" accent="brand" :required :placeholder="required ? undefined : 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 { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { info, error } = defineProps<{
|
||||
error?: InputWrapperMessage
|
||||
info?: string
|
||||
required?: boolean
|
||||
}>()
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const messages = computed<InputWrapperMessage>(
|
||||
() => [info, error].filter(message => message !== undefined) as InputWrapperMessage
|
||||
)
|
||||
</script>
|
||||
@@ -20,23 +20,24 @@
|
||||
<div class="tx-checksumming">
|
||||
<VifTxChecksummingCheckbox v-bind="txChecksummingCheckboxBindings" />
|
||||
</div>
|
||||
<NewVifButtonsSection :cancel-to />
|
||||
<VifFormButtonsSection :cancel-to :submit-label="t('action:create-vif')" />
|
||||
</VtsForm>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { XenApiVm } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import VifAllowedIpsTextarea from '@/modules/vif/components/form/new/inputs/VifAllowedIpsTextarea.vue'
|
||||
import VifMacInput from '@/modules/vif/components/form/new/inputs/VifMacInput.vue'
|
||||
import VifNetworkSelect from '@/modules/vif/components/form/new/inputs/VifNetworkSelect.vue'
|
||||
import VifRateLimitInput from '@/modules/vif/components/form/new/inputs/VifRateLimitInput.vue'
|
||||
import VifTxChecksummingCheckbox from '@/modules/vif/components/form/new/inputs/VifTxChecksummingCheckbox.vue'
|
||||
import NewVifButtonsSection from '@/modules/vif/components/form/new/NewVifButtonsSection.vue'
|
||||
import VifAllowedIpsTextarea from '@/modules/vif/components/form/inputs/VifAllowedIpsTextarea.vue'
|
||||
import VifMacInput from '@/modules/vif/components/form/inputs/VifMacInput.vue'
|
||||
import VifNetworkSelect from '@/modules/vif/components/form/inputs/VifNetworkSelect.vue'
|
||||
import VifRateLimitInput from '@/modules/vif/components/form/inputs/VifRateLimitInput.vue'
|
||||
import VifTxChecksummingCheckbox from '@/modules/vif/components/form/inputs/VifTxChecksummingCheckbox.vue'
|
||||
import VifFormButtonsSection from '@/modules/vif/components/form/VifFormButtonsSection.vue'
|
||||
import { useNewVifForm } from '@/modules/vif/form/new/use-new-vif-form.ts'
|
||||
import type { NewVifPayload } from '@/modules/vif/jobs/vif-create.job.ts'
|
||||
import VtsForm from '@core/components/form/VtsForm.vue'
|
||||
import VtsIcon from '@core/components/icon/VtsIcon.vue'
|
||||
import VtsOption from '@core/components/select/VtsOption.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
const { vmRef } = defineProps<{
|
||||
@@ -48,6 +49,8 @@ const emit = defineEmits<{
|
||||
create: [data: NewVifPayload]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
networkSelectBindings,
|
||||
macInputBindings,
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<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>
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<VtsSidePanel :has-selection="!!vif" class="vm-vifs-side-panel" @close="emit('close')">
|
||||
<template v-if="vif" #actions>
|
||||
<VifEditButton :vif />
|
||||
<VifConnectionToggleButton v-if="vm" :vif :vm />
|
||||
</template>
|
||||
<template v-if="vif" #default>
|
||||
@@ -51,6 +52,15 @@
|
||||
<VtsCopyButton :value="String(vif.MTU)" />
|
||||
</template>
|
||||
</VtsCardRowKeyValue>
|
||||
<!-- RATE LIMIT -->
|
||||
<VtsCardRowKeyValue>
|
||||
<template #key>
|
||||
{{ t('rate-limit') }}
|
||||
</template>
|
||||
<template #value>
|
||||
{{ rateLimit !== undefined ? `${rateLimit} ${t('bytes:kbs')}` : '' }}
|
||||
</template>
|
||||
</VtsCardRowKeyValue>
|
||||
<!-- LOCKING MODE -->
|
||||
<VtsCardRowKeyValue>
|
||||
<template #key>
|
||||
@@ -60,7 +70,15 @@
|
||||
{{ vif.locking_mode }}
|
||||
</template>
|
||||
</VtsCardRowKeyValue>
|
||||
<!-- TODO Need to add TX Checksumming -->
|
||||
<!-- TX CHECK SUMMING -->
|
||||
<VtsCardRowKeyValue>
|
||||
<template #key>
|
||||
{{ t('check-summing') }}
|
||||
</template>
|
||||
<template #value>
|
||||
{{ isTxChecksummingEnabled(vif) }}
|
||||
</template>
|
||||
</VtsCardRowKeyValue>
|
||||
</div>
|
||||
</UiPanelCard>
|
||||
<!-- VIF NETWORK INFORMATION -->
|
||||
@@ -109,6 +127,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { XenApiVif } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import VifConnectionToggleButton from '@/modules/vif/components/actions/connection/VifConnectionToggleButton.vue'
|
||||
import VifEditButton from '@/modules/vif/components/actions/edit/VifEditButton.vue'
|
||||
import { isTxChecksummingEnabled } from '@/modules/vif/utils/vif-tx-checksumming.util.ts'
|
||||
import { useNetworkStore } from '@/stores/xen-api/network.store.ts'
|
||||
import { useVmGuestMetricsStore } from '@/stores/xen-api/vm-guest-metrics.store.ts'
|
||||
import { useVmStore } from '@/stores/xen-api/vm.store.ts'
|
||||
@@ -155,6 +175,14 @@ const ipAddresses = computed(() => {
|
||||
const network = computed(() => (vif !== undefined ? getNetworkByOpaqueRef(vif.network) : undefined))
|
||||
|
||||
const status = computed(() => (vif?.currently_attached ? 'connected' : 'disconnected'))
|
||||
|
||||
const rateLimit = computed(() => {
|
||||
if (vif?.qos_algorithm_type !== 'ratelimit') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return vif.qos_algorithm_params.kbps
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { XenApiVif } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export function useVifEdit() {
|
||||
const router = useRouter()
|
||||
|
||||
function goToVifEdit(vif: XenApiVif) {
|
||||
return router.push({ name: '/vif/[uuid]/edit', params: { uuid: vif.uuid } })
|
||||
}
|
||||
|
||||
return { goToVifEdit }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { XenApiVif } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import { useVifForm } from '@/modules/vif/form/use-vif-form-base.ts'
|
||||
import { isTxChecksummingEnabled } from '@/modules/vif/utils/vif-tx-checksumming.util.ts'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
export function useEditVifForm(vif: XenApiVif) {
|
||||
const { t } = useI18n()
|
||||
|
||||
const { kbps } = vif.qos_algorithm_params
|
||||
|
||||
return useVifForm(
|
||||
{
|
||||
network: vif.network,
|
||||
mac: vif.MAC,
|
||||
rateLimit: vif.qos_algorithm_type === 'ratelimit' && kbps !== undefined ? Number(kbps) : undefined,
|
||||
allowedIps: [...vif.ipv4_allowed, ...vif.ipv6_allowed].join('; '),
|
||||
txChecksumming: isTxChecksummingEnabled(vif),
|
||||
},
|
||||
{ isMacRequired: true, macInfo: t('mac-address-change-cut-connection') }
|
||||
)
|
||||
}
|
||||
@@ -1,79 +1,38 @@
|
||||
import type { XenApiNetwork, XenApiVm } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import { useNetworkUtils } from '@/modules/network/composables/network-utils.composable.ts'
|
||||
import {
|
||||
type BaseVifFormData,
|
||||
buildBaseVifPayload,
|
||||
useVifFormBaseValidation,
|
||||
} from '@/modules/vif/form/use-vif-form-base.ts'
|
||||
import type { XenApiVm } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import { useVifForm } from '@/modules/vif/form/use-vif-form-base.ts'
|
||||
import type { NewVifPayload } from '@/modules/vif/jobs/vif-create.job.ts'
|
||||
import { useNetworkStore } from '@/stores/xen-api/network.store.ts'
|
||||
import { objectIcon } from '@core/icons'
|
||||
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 { type MaybeRefOrGetter, reactive } from 'vue'
|
||||
|
||||
export type NewVifFormData = BaseVifFormData & {
|
||||
network: XenApiNetwork['$ref'] | undefined
|
||||
}
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
export function useNewVifForm(rawVmRef: MaybeRefOrGetter<XenApiVm['$ref']>) {
|
||||
const vmRef = toComputed(rawVmRef)
|
||||
|
||||
const { records: networks } = useNetworkStore().subscribe()
|
||||
|
||||
const { getNetworkStatus } = useNetworkUtils()
|
||||
|
||||
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 { validateAndBuildPayload: validateAndBuildVifPayload, ...bindings } = useVifForm(
|
||||
{
|
||||
network: undefined,
|
||||
mac: '',
|
||||
rateLimit: undefined,
|
||||
allowedIps: '',
|
||||
txChecksumming: true,
|
||||
},
|
||||
{ isMacRequired: false }
|
||||
)
|
||||
|
||||
const { id: networkSelectId } = useFormSelect('network', networks, {
|
||||
searchable: true,
|
||||
required: true,
|
||||
option: {
|
||||
id: '$ref',
|
||||
label: 'name_label',
|
||||
value: '$ref',
|
||||
properties: network => ({ icon: objectIcon('network', getNetworkStatus(network)) }),
|
||||
},
|
||||
})
|
||||
|
||||
async function validateAndBuildPayload(): Promise<NewVifPayload | undefined> {
|
||||
const isValid = await validate()
|
||||
const payload = await validateAndBuildVifPayload()
|
||||
|
||||
if (!isValid || formData.network === undefined) {
|
||||
if (payload === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
vmRef: vmRef.value,
|
||||
network: formData.network,
|
||||
...buildBaseVifPayload(formData),
|
||||
...payload,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
networkSelectBindings: useSelect(networkSelectId),
|
||||
macInputBindings: useField('mac'),
|
||||
rateLimitInputBindings: useField('rateLimit'),
|
||||
allowedIpsTextareaBindings: useField('allowedIps'),
|
||||
txChecksummingCheckboxBindings: useField('txChecksumming'),
|
||||
...bindings,
|
||||
validateAndBuildPayload,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,41 @@
|
||||
import type { BaseVifPayload } from '@/modules/vif/jobs/vif-create.job.ts'
|
||||
import type { XenApiNetwork } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import { useNetworkUtils } from '@/modules/network/composables/network-utils.composable.ts'
|
||||
import type { BaseVifPayload, VifPayload } from '@/modules/vif/jobs/vif-create.job.ts'
|
||||
import { useNetworkStore } from '@/stores/xen-api/network.store.ts'
|
||||
import { objectIcon } from '@core/icons'
|
||||
import {
|
||||
type FormValidationConfig,
|
||||
ipAddresses,
|
||||
macAddress,
|
||||
mergeValidationConfigs,
|
||||
minValue,
|
||||
or,
|
||||
required,
|
||||
withMessage,
|
||||
} from '@core/packages/form-validation'
|
||||
import { useValidatedForm } from '@core/packages/validated-form'
|
||||
import { type IpAddress, isIpv6, parseIpList } from '@core/utils/ip-address.utils.ts'
|
||||
import { VIF_LOCKING_MODE } from '@vates/types'
|
||||
import { reactive } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
export type BaseVifFormData = {
|
||||
type BaseVifFormData = {
|
||||
mac: string
|
||||
rateLimit: number | undefined
|
||||
allowedIps: string
|
||||
txChecksumming: boolean
|
||||
}
|
||||
|
||||
export function useVifFormBaseValidation(): FormValidationConfig<BaseVifFormData> {
|
||||
type VifFormData = BaseVifFormData & {
|
||||
network: XenApiNetwork['$ref'] | undefined
|
||||
}
|
||||
|
||||
type VifFormOptions = {
|
||||
isMacRequired: boolean
|
||||
macInfo?: string
|
||||
}
|
||||
|
||||
function useVifFormBaseValidation(): FormValidationConfig<BaseVifFormData> {
|
||||
const { t } = useI18n()
|
||||
|
||||
return {
|
||||
@@ -38,7 +55,7 @@ export function useVifFormBaseValidation(): FormValidationConfig<BaseVifFormData
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBaseVifPayload(formData: BaseVifFormData): BaseVifPayload {
|
||||
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))
|
||||
@@ -56,3 +73,59 @@ export function buildBaseVifPayload(formData: BaseVifFormData): BaseVifPayload {
|
||||
other_config: { 'ethtool-tx': String(formData.txChecksumming) },
|
||||
}
|
||||
}
|
||||
|
||||
export function useVifForm(initialData: VifFormData, { isMacRequired, macInfo }: VifFormOptions) {
|
||||
const { records: networks } = useNetworkStore().subscribe()
|
||||
|
||||
const { getNetworkStatus } = useNetworkUtils()
|
||||
|
||||
const formData = reactive<VifFormData>(initialData)
|
||||
|
||||
const { useField, useFormSelect, useSelect, validate } = useValidatedForm(
|
||||
formData,
|
||||
mergeValidationConfigs<BaseVifFormData, VifFormData>(useVifFormBaseValidation(), {
|
||||
errors: {
|
||||
onSubmit: () => {
|
||||
if (isMacRequired) {
|
||||
return { network: { required }, mac: { required } }
|
||||
}
|
||||
|
||||
return { network: { required } }
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const { id: networkSelectId } = useFormSelect('network', networks, {
|
||||
searchable: true,
|
||||
required: true,
|
||||
option: {
|
||||
id: '$ref',
|
||||
label: 'name_label',
|
||||
value: '$ref',
|
||||
properties: network => ({ icon: objectIcon('network', getNetworkStatus(network)) }),
|
||||
},
|
||||
})
|
||||
|
||||
async function validateAndBuildPayload(): Promise<VifPayload | undefined> {
|
||||
const isValid = await validate()
|
||||
|
||||
if (!isValid || formData.network === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
network: formData.network,
|
||||
...buildBaseVifPayload(formData),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
networkSelectBindings: useSelect(networkSelectId),
|
||||
macInputBindings: useField('mac', () => ({ required: isMacRequired, info: macInfo })),
|
||||
rateLimitInputBindings: useField('rateLimit'),
|
||||
allowedIpsTextareaBindings: useField('allowedIps'),
|
||||
txChecksummingCheckboxBindings: useField('txChecksumming'),
|
||||
validateAndBuildPayload,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,3 +5,8 @@ export const vifsArg = defineJobArg({
|
||||
identify: (vif: XenApiVif) => vif.$ref,
|
||||
toArray: true,
|
||||
})
|
||||
|
||||
export const vifArg = defineJobArg({
|
||||
identify: (vif: XenApiVif) => vif?.$ref,
|
||||
toArray: false,
|
||||
})
|
||||
|
||||
@@ -16,11 +16,14 @@ export type BaseVifPayload = {
|
||||
other_config: Record<string, string>
|
||||
}
|
||||
|
||||
export type NewVifPayload = BaseVifPayload & {
|
||||
vmRef: XenApiVm['$ref']
|
||||
export type VifPayload = BaseVifPayload & {
|
||||
network: XenApiNetwork['$ref']
|
||||
}
|
||||
|
||||
export type NewVifPayload = VifPayload & {
|
||||
vmRef: XenApiVm['$ref']
|
||||
}
|
||||
|
||||
export const useVifCreateJob = defineJob('vif.create', [newVifPayloadArg], () => {
|
||||
const xapi = useXenApiStore().getXapi()
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { EditVifPayload } from '@/modules/vif/jobs/vif-edit.job.ts'
|
||||
import { defineJobArg } from '@core/packages/job'
|
||||
|
||||
export const editVifPayloadArg = defineJobArg<EditVifPayload>({
|
||||
identify: false,
|
||||
toArray: false,
|
||||
})
|
||||
130
@xen-orchestra/lite/src/modules/vif/jobs/vif-edit.job.ts
Normal file
130
@xen-orchestra/lite/src/modules/vif/jobs/vif-edit.job.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { XenApiVif } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import { vifArg } from '@/modules/vif/jobs/vif-args.ts'
|
||||
import type { VifPayload } from '@/modules/vif/jobs/vif-create.job.ts'
|
||||
import { editVifPayloadArg } from '@/modules/vif/jobs/vif-edit-args.ts'
|
||||
import { isTxChecksummingEnabled } from '@/modules/vif/utils/vif-tx-checksumming.util.ts'
|
||||
import { useXenApiStore } from '@/stores/xen-api.store.ts'
|
||||
import { defineJob, JobError, JobRunningError } from '@core/packages/job'
|
||||
import { VIF_LOCKING_MODE } from '@vates/types'
|
||||
import { xor } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
export type EditVifPayload = VifPayload
|
||||
|
||||
export const useVifEditJob = defineJob('vif.edit', [vifArg, editVifPayloadArg], () => {
|
||||
const xapi = useXenApiStore().getXapi()
|
||||
const { t } = useI18n()
|
||||
|
||||
function getLockingMode(vif: XenApiVif, payload: EditVifPayload): VIF_LOCKING_MODE {
|
||||
if (payload.locking_mode !== undefined) {
|
||||
return payload.locking_mode
|
||||
}
|
||||
|
||||
const isNetworkChanged = payload.network !== vif.network
|
||||
|
||||
if (isNetworkChanged) {
|
||||
return VIF_LOCKING_MODE.NETWORK_DEFAULT
|
||||
}
|
||||
|
||||
const hadAllowedIps = vif.ipv4_allowed.length > 0 || vif.ipv6_allowed.length > 0
|
||||
const areAllowedIpsRemoved = hadAllowedIps && vif.locking_mode === VIF_LOCKING_MODE.LOCKED
|
||||
|
||||
if (areAllowedIpsRemoved) {
|
||||
return VIF_LOCKING_MODE.NETWORK_DEFAULT
|
||||
}
|
||||
|
||||
return vif.locking_mode
|
||||
}
|
||||
|
||||
async function recreate(vif: XenApiVif, payload: EditVifPayload) {
|
||||
if (vif.currently_attached) {
|
||||
await xapi.vif.unplugForce(vif.$ref)
|
||||
}
|
||||
|
||||
await xapi.vif.delete(vif.$ref)
|
||||
|
||||
const [newVifRef] = await xapi.vif.create([
|
||||
{
|
||||
...payload,
|
||||
vmRef: vif.VM,
|
||||
device: vif.device,
|
||||
MTU: vif.MTU,
|
||||
locking_mode: getLockingMode(vif, payload),
|
||||
other_config: { ...vif.other_config, ...payload.other_config },
|
||||
},
|
||||
])
|
||||
|
||||
if (vif.currently_attached) {
|
||||
await xapi.vif.plug(newVifRef)
|
||||
}
|
||||
|
||||
return newVifRef
|
||||
}
|
||||
|
||||
const isSameIpList = (a: string[], b: string[]) => xor(a, b).length === 0
|
||||
|
||||
async function update(vif: XenApiVif, payload: EditVifPayload) {
|
||||
if (payload.network !== vif.network) {
|
||||
try {
|
||||
await xapi.vif.move(vif.$ref, payload.network)
|
||||
} catch (error) {
|
||||
console.warn(`VIF.move failed for ${vif.uuid}, recreating it instead:`, error)
|
||||
return recreate(vif, payload)
|
||||
}
|
||||
}
|
||||
|
||||
const ipv4 = payload.ipv4_allowed ?? []
|
||||
const ipv6 = payload.ipv6_allowed ?? []
|
||||
|
||||
if (!isSameIpList(ipv4, vif.ipv4_allowed)) {
|
||||
await xapi.vif.setIpv4Allowed(vif.$ref, ipv4)
|
||||
}
|
||||
|
||||
if (!isSameIpList(ipv6, vif.ipv6_allowed)) {
|
||||
await xapi.vif.setIpv6Allowed(vif.$ref, ipv6)
|
||||
}
|
||||
|
||||
const lockingMode = getLockingMode(vif, payload)
|
||||
|
||||
if (lockingMode !== vif.locking_mode) {
|
||||
await xapi.vif.setLockingMode(vif.$ref, lockingMode)
|
||||
}
|
||||
|
||||
const kbps = payload.qos_algorithm_params?.kbps
|
||||
const currentKbps = vif.qos_algorithm_type === 'ratelimit' ? vif.qos_algorithm_params.kbps : undefined
|
||||
|
||||
if (kbps !== currentKbps) {
|
||||
await xapi.vif.setRateLimit(vif.$ref, kbps === undefined ? null : Number(kbps))
|
||||
}
|
||||
|
||||
const txChecksumming = payload.other_config['ethtool-tx'] === 'true'
|
||||
|
||||
if (txChecksumming !== isTxChecksummingEnabled(vif)) {
|
||||
await xapi.vif.setTxChecksumming(vif.$ref, txChecksumming)
|
||||
}
|
||||
|
||||
return vif.$ref
|
||||
}
|
||||
|
||||
return {
|
||||
run(vif, payload): Promise<XenApiVif['$ref']> {
|
||||
const isMacChanged = payload.MAC !== undefined && payload.MAC.toLowerCase() !== vif.MAC.toLowerCase()
|
||||
|
||||
if (isMacChanged) {
|
||||
return recreate(vif, payload)
|
||||
}
|
||||
|
||||
return update(vif, payload)
|
||||
},
|
||||
|
||||
validate: (isRunning, vif, payload) => {
|
||||
if (isRunning) {
|
||||
throw new JobRunningError(t('job:edit:in-progress'))
|
||||
}
|
||||
|
||||
if (vif === undefined || payload === undefined) {
|
||||
throw new JobError(t('job:arg:missing-payload'))
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { XenApiVif } from '@/libs/xen-api/xen-api.types.ts'
|
||||
|
||||
export function isTxChecksummingEnabled(vif: XenApiVif) {
|
||||
const value = vif.other_config['ethtool-tx']
|
||||
|
||||
return value !== 'false' && value !== 'off'
|
||||
}
|
||||
112
@xen-orchestra/lite/src/pages/vif/[uuid]/edit.vue
Normal file
112
@xen-orchestra/lite/src/pages/vif/[uuid]/edit.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<UiHeadBar icon="fa:edit">
|
||||
{{ t('edit-vif') }}
|
||||
</UiHeadBar>
|
||||
|
||||
<div class="card-container">
|
||||
<VtsOperationPendingCard v-if="isRunning" :title="t('editing-vif')" />
|
||||
|
||||
<VtsOperationErrorCard
|
||||
v-else-if="error"
|
||||
:title="t('unable-to-edit-vif')"
|
||||
:error
|
||||
:error-message="t('edit-vif:error-message')"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" accent="brand" size="medium" @click="handleGoBack()">
|
||||
{{ t('action:go-back') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</VtsOperationErrorCard>
|
||||
|
||||
<VtsStateHero v-else-if="!areVifsReady" format="page" type="busy" size="large" />
|
||||
|
||||
<VtsStateHero v-else-if="!vif" format="page" type="not-found" size="large">
|
||||
{{ t('object-not-found', { id: vifUuid }) }}
|
||||
</VtsStateHero>
|
||||
|
||||
<UiCard v-if="vif" v-show="canDisplayForm">
|
||||
<UiTitle>{{ t('configuration') }}</UiTitle>
|
||||
<EditVifForm :vif :cancel-to="cancelRoute" @save="editVif" />
|
||||
</UiCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { XenApiVif } from '@/libs/xen-api/xen-api.types.ts'
|
||||
import EditVifForm from '@/modules/vif/components/form/edit/EditVifForm.vue'
|
||||
import { type EditVifPayload, useVifEditJob } from '@/modules/vif/jobs/vif-edit.job.ts'
|
||||
import { useVifStore } from '@/stores/xen-api/vif.store.ts'
|
||||
import { useVmStore } from '@/stores/xen-api/vm.store.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<'/vif/[uuid]/edit'>()
|
||||
|
||||
const { isReady: areVifsReady, getByUuid: getVifByUuid } = useVifStore().subscribe()
|
||||
const { getByOpaqueRef: getVmByOpaqueRef } = useVmStore().subscribe()
|
||||
|
||||
const vifUuid = computed(() => route.params.uuid as XenApiVif['uuid'])
|
||||
|
||||
const vif = computed(() => getVifByUuid(vifUuid.value))
|
||||
|
||||
const vm = computed(() => (vif.value === undefined ? undefined : getVmByOpaqueRef(vif.value.VM)))
|
||||
|
||||
const formPayload = ref<EditVifPayload>()
|
||||
|
||||
const error = ref<Error | undefined>()
|
||||
|
||||
const editedVif = ref<XenApiVif>()
|
||||
|
||||
const { canRun, run: edit, isRunning } = useVifEditJob(editedVif, formPayload)
|
||||
|
||||
const canDisplayForm = computed(() => !isRunning.value && error.value === undefined)
|
||||
|
||||
const cancelRoute = computed<RouteLocationRaw>(() => {
|
||||
if (!vm.value) {
|
||||
return { name: '/' }
|
||||
}
|
||||
|
||||
return { name: '/vm/[uuid]/network', params: { uuid: vm.value.uuid } }
|
||||
})
|
||||
|
||||
async function editVif(payload: EditVifPayload) {
|
||||
editedVif.value = vif.value
|
||||
formPayload.value = payload
|
||||
|
||||
if (!canRun.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const redirectRoute = cancelRoute.value
|
||||
|
||||
try {
|
||||
await edit()
|
||||
|
||||
await router.push(redirectRoute)
|
||||
} catch (_error) {
|
||||
error.value = _error as Error
|
||||
}
|
||||
}
|
||||
|
||||
function handleGoBack() {
|
||||
error.value = undefined
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.card-container {
|
||||
padding: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
13
@xen-orchestra/lite/src/route-map.d.ts
vendored
13
@xen-orchestra/lite/src/route-map.d.ts
vendored
@@ -1024,6 +1024,13 @@ declare module 'vue-router/auto-routes' {
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/vif/[uuid]/edit': RouteRecordInfo<
|
||||
'/vif/[uuid]/edit',
|
||||
'/vif/:uuid/edit',
|
||||
{ uuid: ParamValue<true> },
|
||||
{ uuid: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'/vif/new': RouteRecordInfo<
|
||||
'/vif/new',
|
||||
'/vif/new',
|
||||
@@ -1984,6 +1991,12 @@ declare module 'vue-router/auto-routes' {
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/vif/[uuid]/edit.vue': {
|
||||
routes:
|
||||
| '/vif/[uuid]/edit'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/vif/new.vue': {
|
||||
routes:
|
||||
| '/vif/new'
|
||||
|
||||
@@ -422,6 +422,9 @@
|
||||
"duplicating-vm": "Duplicating VM",
|
||||
"duplication-method": "Duplication method",
|
||||
"duration": "Duration",
|
||||
"edit-vif": "Edit VIF",
|
||||
"edit-vif:error-message": "An error occurred while editing the VIF. Please review your configuration settings and try again.",
|
||||
"editing-vif": "Editing VIF",
|
||||
"email": "Email",
|
||||
"empty": "Empty",
|
||||
"enabled": "Enabled",
|
||||
@@ -803,6 +806,7 @@
|
||||
"logs": "Logs",
|
||||
"mac-address": "MAC address",
|
||||
"mac-addresses": "MAC addresses",
|
||||
"mac-address-change-cut-connection": "Changing the MAC address will temporarily cut the connection",
|
||||
"manage-citrix-pv-drivers-via-windows-update": "Manage citrix PV drivers via Windows Update",
|
||||
"manage-vm-lifecycle-in-xo-5": "Manage VM lifecycle in XO-5",
|
||||
"management": "Management",
|
||||
@@ -1409,6 +1413,7 @@
|
||||
"unable-to-create-new-vif": "Unable to create new VIF",
|
||||
"unable-to-delete-network": "Unable to delete network",
|
||||
"unable-to-duplicate-vm": "Unable to duplicate VM",
|
||||
"unable-to-edit-vif": "Unable to edit VIF",
|
||||
"unable-to-migrate-vdi": "Unable to migrate VDI on SR",
|
||||
"unauthorized": "Unauthorized",
|
||||
"unknown": "Unknown",
|
||||
|
||||
@@ -422,6 +422,9 @@
|
||||
"duplicating-vm": "Duplication de la VM",
|
||||
"duplication-method": "Méthode de duplication",
|
||||
"duration": "Durée",
|
||||
"edit-vif": "Modifier le VIF",
|
||||
"edit-vif:error-message": "Une erreur s'est produite lors de la modification du VIF. Veuillez vérifier vos paramètres et réessayer.",
|
||||
"editing-vif": "Modification du VIF",
|
||||
"email": "Email",
|
||||
"empty": "Vide",
|
||||
"enabled": "Activé",
|
||||
@@ -803,6 +806,7 @@
|
||||
"logs": "Logs",
|
||||
"mac-address": "Adresse MAC",
|
||||
"mac-addresses": "Adresses MAC",
|
||||
"mac-address-change-cut-connection": "Modifier l'adresse MAC coupera temporairement la connexion",
|
||||
"manage-citrix-pv-drivers-via-windows-update": "Gérer les pilotes Citrix PV via Windows Update",
|
||||
"manage-vm-lifecycle-in-xo-5": "Gérer le cycle de vie de la VM dans XO-5",
|
||||
"management": "Gestion",
|
||||
@@ -1409,6 +1413,7 @@
|
||||
"unable-to-create-new-vif": "Impossible de créer un nouveau VIF",
|
||||
"unable-to-delete-network": "Impossible de supprimer le réseau",
|
||||
"unable-to-duplicate-vm": "Impossible de dupliquer la VM",
|
||||
"unable-to-edit-vif": "Impossible de modifier le VIF",
|
||||
"unable-to-migrate-vdi": "Impossible de migrer le VDI sur le SR",
|
||||
"unauthorized": "Non autorisé",
|
||||
"unknown": "Inconnu",
|
||||
|
||||
@@ -31,4 +31,6 @@
|
||||
|
||||
<!--packages-start-->
|
||||
|
||||
- @xen-orchestra/web-core minor
|
||||
|
||||
<!--packages-end-->
|
||||
|
||||
Reference in New Issue
Block a user