Compare commits

...

4 Commits

89 changed files with 1895 additions and 278 deletions

View File

@@ -4,6 +4,7 @@
- [Side panels] Side panels can be resized like the treeview, whose default width now adapts to the screen size (PR [#10725](https://github.com/vatesfr/xen-orchestra/pull/10725))
- [Side panels] Values stay next to their label when they fit and move below it otherwise, fields with several values (IP addresses, bond devices) get a copy button per value and a "Copy all" action, and rows, links and statuses are more evenly spaced and sized (PR [#10733](https://github.com/vatesfr/xen-orchestra/pull/10733))
- [Treeview] Fix translations of the tooltip for VM and host statuses in treeview (PR [#10643](https://github.com/vatesfr/xen-orchestra/pull/10643))
## **0.27.0** (2026-10-29)

View File

@@ -1,5 +1,5 @@
<template>
<VtsQuickInfoCard class="vm-dashboard-quick-info" :loading="!isReady">
<VtsQuickInfoCard :loading="!isReady" class="vm-dashboard-quick-info">
<VtsKeyValueList>
<VtsKeyValueRow :label="t('state')">
<template #value>
@@ -19,11 +19,11 @@
<template #value>
<UiLink
v-if="host"
:to="`/host/${host.uuid}`"
size="medium"
:icon="hostIcon"
:is-primary="isPoolMaster"
:primary-tooltip="t('master')"
:to="`/host/${host.uuid}`"
size="medium"
>
{{ host.name_label }}
</UiLink>
@@ -54,9 +54,9 @@
</template>
<script lang="ts" setup>
import { VM_POWER_STATE } from '@/libs/xen-api/xen-api.enums.ts'
import type { XenApiVm } from '@/libs/xen-api/xen-api.types.ts'
import { getHostIcon } from '@/modules/host/utils/host.util.ts'
import { useVmUtils } from '@/modules/vm/composables/vm-utils.composable.ts'
import { useHostMetricsStore } from '@/stores/xen-api/host-metrics.store.ts'
import { usePoolStore } from '@/stores/xen-api/pool.store.ts'
import { useVmGuestMetricsStore } from '@/stores/xen-api/vm-guest-metrics.store.ts'
@@ -70,7 +70,6 @@ import VtsTag from '@core/components/tag/VtsTag.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import UiTagsList from '@core/components/ui/tag/UiTagsList.vue'
import { useTimeAgo } from '@core/composables/locale-time-ago.composable.ts'
import { useMapper } from '@core/packages/mapper'
import { formatSizeRaw } from '@core/utils/size.util.ts'
import { formatDate, parseDateTime } from '@core/utils/time.util.ts'
import { computed } from 'vue'
@@ -98,16 +97,7 @@ const isPoolMaster = computed(() => pool.value?.master === host.value?.$ref)
const hostIcon = computed(() => getHostIcon(host.value, getHostPowerState(host.value)))
const powerState = useMapper(
() => vm.power_state,
{
[VM_POWER_STATE.RUNNING]: { icon: 'status:running-circle', text: t('vm:status:running') },
[VM_POWER_STATE.HALTED]: { icon: 'status:halted-circle', text: t('vm:status:halted') },
[VM_POWER_STATE.PAUSED]: { icon: 'status:paused-circle', text: t('vm:status:paused') },
[VM_POWER_STATE.SUSPENDED]: { icon: 'status:suspended-circle', text: t('vm:status:suspended') },
},
VM_POWER_STATE.RUNNING
)
const { powerState } = useVmUtils(() => vm)
function getValidDateTime(dateTime: string | undefined): string | undefined {
return dateTime === '19700101T00:00:00Z' ? undefined : dateTime

View File

@@ -5,7 +5,7 @@ import { useHostMetricsStore } from '@/stores/xen-api/host-metrics.store.ts'
import { usePoolStore } from '@/stores/xen-api/pool.store.ts'
import type { IconName } from '@core/icons'
import { useTimeAgo } from '@core/composables/locale-time-ago.composable.ts'
import { useMapper } from '@core/packages/mapper'
import { createMapper, useMapper } from '@core/packages/mapper'
import { parseDateTime } from '@core/utils/time.util.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
import { HOST_POWER_STATE } from '@vates/types'
@@ -70,20 +70,31 @@ export function useHostUtils(rawHost: MaybeRefOrGetter<XenApiHost | undefined>)
const currentOperation = useMapper<string, string>(
() => pendingStateOperation.value,
{
() => ({
[HOST_OPERATION.ENABLE]: t('operation:enable'),
[HOST_OPERATION.EVACUATE]: t('operation:evacuate'),
[HOST_OPERATION.POWER_ON]: t('operation:start'),
[HOST_OPERATION.REBOOT]: t('operation:clean-reboot'),
[HOST_OPERATION.SHUTDOWN]: t('operation:shutdown'),
unknown: '',
},
}),
'unknown'
)
const getHostStatus = createMapper<HostState, { text: string; icon: IconName | undefined }>(
() => ({
running: { text: t('host:status:running'), icon: 'status:running-circle' },
halted: { text: t('host:status:halted'), icon: 'status:halted-circle' },
disabled: { text: t('host:status:disabled'), icon: 'status:host-disabled-circle' },
unknown: { text: t('host:status:unknown'), icon: undefined },
}),
'unknown'
)
return {
isMaster,
isRunning,
getHostStatus,
hostState,
powerState,
isChangingState,

View File

@@ -7,7 +7,7 @@
>
{{ branch.data.name_label || '(Host)' }}
<template #icon>
<VtsObjectIcon v-tooltip="hostState" type="host" size="medium" :state="hostState" />
<VtsObjectIcon v-tooltip="powerState.text" type="host" size="medium" :state="hostState" />
</template>
<template #addons>
<UiLoader v-if="isChangingState" v-tooltip="currentOperation" />
@@ -63,7 +63,8 @@ const { t } = useI18n()
const { isMasterHost } = usePoolStore().subscribe()
const { runningVmsCountByHostRef } = useVmStore().subscribe()
const { hostState, isChangingState, currentOperation } = useHostUtils(() => branch.data)
const { getHostStatus, hostState, isChangingState, currentOperation } = useHostUtils(() => branch.data)
const powerState = computed(() => getHostStatus(hostState.value))
const isMaster = computed(() => isMasterHost(branch.data.$ref))

View File

@@ -1,9 +1,9 @@
<template>
<VtsTreeItem expanded :node-id="leaf.dataId">
<VtsTreeItem :node-id="leaf.dataId" expanded>
<UiTreeItemLabel :route="{ name: '/vm/[uuid]', params: { uuid: leaf.data.uuid } }" no-indent>
{{ leaf.data.name_label || '(VM)' }}
<template #icon>
<VtsObjectIcon size="medium" :state="powerState" type="vm" />
<VtsObjectIcon v-tooltip="powerState.text" :state="vmState" size="medium" type="vm" />
</template>
<template #addons>
<UiLoader v-if="isChangingState" v-tooltip="{ placement: 'top', content: currentOperation }" />
@@ -11,10 +11,10 @@
<template #trigger="{ open, isOpen }">
<UiButtonIcon
v-tooltip="{ placement: 'top', content: t('quick-actions') }"
icon="action:more-actions"
accent="brand"
size="small"
:selected="isOpen"
accent="brand"
icon="action:more-actions"
size="small"
@click="open($event)"
/>
</template>
@@ -26,10 +26,10 @@
</template>
<script lang="ts" setup>
import type { VM_POWER_STATE } from '@/libs/xen-api/xen-api.enums.ts'
import type { VmLeaf } from '@/modules/treeview/types/tree.type.ts'
import VmTreeActions from '@/modules/vm/components/actions/VmTreeActions.vue'
import { useVmOperation } from '@/modules/vm/composables/vm-operation.composable.ts'
import { useVmUtils } from '@/modules/vm/composables/vm-utils.composable.ts'
import MenuList from '@core/components/menu/MenuList.vue'
import VtsObjectIcon from '@core/components/object-icon/VtsObjectIcon.vue'
import VtsTreeItem from '@core/components/tree/VtsTreeItem.vue'
@@ -37,6 +37,7 @@ import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import UiLoader from '@core/components/ui/loader/UiLoader.vue'
import UiTreeItemLabel from '@core/components/ui/tree-item-label/UiTreeItemLabel.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { toLower } from 'lodash-es'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -47,6 +48,7 @@ const { leaf } = defineProps<{
const { t } = useI18n()
const { isChangingState, currentOperation } = useVmOperation(() => leaf.data)
const { powerState } = useVmUtils(() => leaf.data)
const powerState = computed(() => leaf.data.power_state.toLowerCase() as Lowercase<VM_POWER_STATE>)
const vmState = computed(() => toLower(leaf.data.power_state))
</script>

View File

@@ -17,7 +17,7 @@ export function useVmOperation(rawVm: MaybeRefOrGetter<XenApiVm | undefined>) {
const currentOperation = useMapper<string, string>(
() => Object.values(vm.value?.current_operations ?? {})[0],
{
() => ({
start: t('operation:start'),
start_on: t('operation:start-on-host'),
pause: t('operation:pause'),
@@ -36,7 +36,7 @@ export function useVmOperation(rawVm: MaybeRefOrGetter<XenApiVm | undefined>) {
export: t('operation:export'),
import: t('operation:import'),
unknown: '',
},
}),
'unknown'
)

View File

@@ -0,0 +1,28 @@
import { VM_POWER_STATE } from '@/libs/xen-api/xen-api.enums.ts'
import type { XenApiVm } from '@/libs/xen-api/xen-api.types.ts'
import type { IconName } from '@core/icons'
import { useMapper } from '@core/packages/mapper'
import { toComputed } from '@core/utils/to-computed.util.ts'
import type { MaybeRefOrGetter } from 'vue'
import { useI18n } from 'vue-i18n'
export function useVmUtils(rawVm: MaybeRefOrGetter<XenApiVm>) {
const { t } = useI18n()
const vm = toComputed(rawVm)
const powerState = useMapper<VM_POWER_STATE, { icon: IconName; text: string }>(
() => vm.value.power_state,
() => ({
[VM_POWER_STATE.RUNNING]: { icon: 'status:running-circle', text: t('vm:status:running') },
[VM_POWER_STATE.HALTED]: { icon: 'status:halted-circle', text: t('vm:status:halted') },
[VM_POWER_STATE.PAUSED]: { icon: 'status:paused-circle', text: t('vm:status:paused') },
[VM_POWER_STATE.SUSPENDED]: { icon: 'status:suspended-circle', text: t('vm:status:suspended') },
}),
VM_POWER_STATE.RUNNING
)
return {
powerState,
}
}

View File

@@ -1,5 +1,6 @@
<template>
<UiLoader v-if="busy" v-tooltip="busyTooltip ?? false" />
<DisplayIcon v-else-if="icon && tooltip" v-tooltip="tooltip" class="vts-icon" :class="className" :icon />
<DisplayIcon v-else-if="icon" class="vts-icon" :class="className" :icon />
</template>
@@ -18,6 +19,7 @@ const { size, name } = defineProps<{
name: IconName | undefined
busy?: boolean
busyTooltip?: string
tooltip?: string
}>()
const className = computed(() =>

View File

@@ -0,0 +1,49 @@
<template>
<UiTableCell class="vts-button-icon-list-cell" align="center">
<div class="buttons">
<UiButtonIcon
v-for="(button, index) of buttons"
:key="index"
v-tooltip="button.label"
:aria-label="button.label"
:icon="button.icon"
:disabled="button.disabled"
:target-scale="1.5"
accent="brand"
size="small"
@click="button.onClick()"
/>
</div>
</UiTableCell>
</template>
<script lang="ts" setup>
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import UiTableCell from '@core/components/ui/table-cell/UiTableCell.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import type { IconName } from '@core/icons'
export type ButtonIconItem = {
icon: IconName
label: string
onClick: () => void
disabled?: boolean
}
defineProps<{
buttons: ButtonIconItem[]
}>()
</script>
<style lang="postcss" scoped>
.vts-button-icon-list-cell {
width: 8rem;
padding: 0.8rem 1.6rem 0.8rem 0.8rem;
.buttons {
display: flex;
justify-content: center;
gap: 0.8rem;
}
}
</style>

View File

@@ -1,13 +1,14 @@
<template>
<UiTableCell>
<div class="vts-double-link-cell">
<UiLink size="medium" :icon :to :href :target :disabled class="link">
<UiLink size="medium" :icon :icon-tooltip :to :href :target :disabled class="link">
<slot />
</UiLink>
<UiLink
v-if="suffix"
size="medium"
:icon="suffix.icon"
:icon-tooltip="suffix.iconTooltip"
:to="suffix.to"
:href="suffix.href"
:target="suffix.target"
@@ -27,9 +28,11 @@ import type { IconName } from '@core/icons'
export type VtsDoubleLinkCellProps = LinkOptions & {
icon?: IconName
iconTooltip?: string
suffix?: LinkOptions & {
label: string
icon?: IconName
iconTooltip?: string
}
}

View File

@@ -1,7 +1,7 @@
<template>
<UiTableCell>
<div class="vts-link-cell">
<UiLink size="medium" :icon :busy :busy-tooltip :to :href :target class="link">
<UiLink size="medium" :icon :icon-tooltip :busy :busy-tooltip :to :href :target class="link">
<slot />
<VtsIcon v-if="rightIcon" v-tooltip="rightIcon.tooltip ?? false" :name="rightIcon.icon" size="medium" />
</UiLink>
@@ -25,6 +25,7 @@ import { useI18n } from 'vue-i18n'
export type VtsLinkCellProps = LinkOptions & {
icon?: IconName
iconTooltip?: string
busy?: boolean
busyTooltip?: string
rightIcon?: {

View File

@@ -0,0 +1,56 @@
<template>
<UiTableCell class="vts-rank-cell">
<div class="content">
<span>{{ rank }}</span>
<span v-if="shiftIcon" class="shift">
<VtsIcon :name="shiftIcon" size="small" />
{{ shiftCount }}
</span>
</div>
</UiTableCell>
</template>
<script lang="ts" setup>
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import UiTableCell from '@core/components/ui/table-cell/UiTableCell.vue'
import type { IconName } from '@core/icons'
import { computed } from 'vue'
const { shift = 0 } = defineProps<{
rank: number
shift?: number
}>()
const shiftIcon = computed<IconName | undefined>(() => {
if (shift > 0) {
return 'action:move-up'
}
if (shift < 0) {
return 'action:move-down'
}
return undefined
})
const shiftCount = computed(() => Math.abs(shift))
</script>
<style lang="postcss" scoped>
.vts-rank-cell {
width: 7rem;
.content {
display: flex;
justify-content: space-between;
gap: 0.8rem;
}
.shift {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--color-neutral-txt-secondary);
}
}
</style>

View File

@@ -1,7 +1,7 @@
<!-- v6 -->
<template>
<component :is="component" :class="classes" class="ui-link" v-bind="attributes">
<VtsIcon :name="icon" :busy :busy-tooltip size="medium" />
<VtsIcon :name="icon" :tooltip="iconTooltip" :busy :busy-tooltip size="medium" />
<slot />
<VtsIcon v-if="attributes.target === '_blank'" name="action:open-in-new-tab" size="medium" class="external-icon" />
<VtsIcon v-if="isPrimary" v-tooltip="primaryTooltip" name="status:primary-circle" size="medium" />
@@ -19,6 +19,7 @@ const props = defineProps<
LinkOptions & {
size: 'small' | 'medium'
icon?: IconName
iconTooltip?: string
busy?: boolean
busyTooltip?: string
isPrimary?: boolean

View File

@@ -3,11 +3,11 @@
<div class="ui-panel-card-title">
<div class="header">
<div class="title" :class="typoClasses[size]">
<UiLink v-if="isLink" :size :icon :to :href :target :disabled>
<UiLink v-if="isLink" :size :icon :icon-tooltip :to :href :target :disabled>
<slot name="label">{{ label }}</slot>
</UiLink>
<template v-else>
<VtsIcon v-if="icon !== undefined" :name="icon" size="medium" class="icon" />
<VtsIcon v-if="icon !== undefined" :name="icon" :tooltip="iconTooltip" size="medium" class="icon" />
<slot name="label">{{ label }}</slot>
</template>
<UiCounter
@@ -40,11 +40,12 @@ import { computed } from 'vue'
type PanelCardTitleSize = 'small' | 'medium'
const { label, size, icon, id, counter, to, href, target, disabled } = defineProps<
const { label, size, icon, iconTooltip, id, counter, to, href, target, disabled } = defineProps<
LinkOptions & {
size: PanelCardTitleSize
label?: string
icon?: IconName
iconTooltip?: string
id?: string
counter?: CounterValue | { value: CounterValue; accent: CounterAccent }
}

View File

@@ -4,10 +4,12 @@ import { faCopy, faSquare as checkboxEmpty, type IconDefinition } from '@fortawe
import {
faAdd,
faArrowCircleRight,
faArrowDown,
faArrowDownLong,
faArrowRight,
faArrowRotateLeft,
faArrowRotateRight,
faArrowUp,
faArrowUpLong,
faArrowUpRightFromSquare,
faBan,
@@ -153,6 +155,8 @@ export const actionIcons = defineIconPack({
color: 'var(--color-neutral-txt-primary)',
rotate: 90,
},
'move-down': constructIcon(faArrowDown),
'move-up': constructIcon(faArrowUp),
'import-export': [
{
icon: faArrowUpLong,

View File

@@ -10,6 +10,7 @@ import {
faCamera,
faCircle,
faCircleNodes,
faCircleNotch,
faCity,
faClock,
faDatabase,
@@ -71,6 +72,22 @@ const runningNeutral = defineIcon([
},
])
const busy = defineIcon([
{
icon: faCircle,
color: 'var(--color-neutral-background-primary)',
translate: [7, 5.5],
size: 13,
},
{
icon: faCircleNotch,
color: 'var(--color-brand-item-base)',
translate: [7, 5.5],
size: 10,
spin: 1,
},
])
const arrowLeft = defineIcon([
{
icon: faCircle,
@@ -150,6 +167,7 @@ export const objectIcons = defineIconPack({
'host:disabled': [constructIcon(faServer), ...constructCircleStatus('host-disabled-circle')],
'host:warning': [constructIcon(faServer), ...constructCircleStatus('warning-circle')],
'host:halted': [constructIcon(faServer), ...constructCircleStatus('halted-circle')],
'host:busy': [constructIcon(faServer), { icon: busy }],
vm: constructIcon(faDesktop),
'vm:unknown': [
{
@@ -166,6 +184,7 @@ export const objectIcons = defineIconPack({
'vm:suspended': [constructIcon(faDesktop), ...constructCircleStatus('suspended-circle')],
'vm:warning': [constructIcon(faDesktop), ...constructCircleStatus('warning-circle')],
'vm:halted': [constructIcon(faDesktop), ...constructCircleStatus('halted-circle')],
'vm:busy': [constructIcon(faDesktop), { icon: busy }],
'vm-snapshot': [
constructIcon(faDesktop),
{
@@ -187,6 +206,7 @@ export const objectIcons = defineIconPack({
'sr:disabled': [constructIcon(faDatabase), ...constructCircleStatus('disabled')],
'sr:partially-connected': [constructIcon(faDatabase), ...constructCircleStatus('warning-circle')],
'sr:disconnected': [constructIcon(faDatabase), ...constructCircleStatus('danger-circle')],
'sr:busy': [constructIcon(faDatabase), { icon: busy }],
vdi: constructIcon(faHdd),
'vdi:unknown': [
{
@@ -202,6 +222,7 @@ export const objectIcons = defineIconPack({
'vdi:disabled': [constructIcon(faHdd), ...constructCircleStatus('disabled')],
'vdi:warning': [constructIcon(faHdd), ...constructCircleStatus('warning-circle')],
'vdi:detached': [constructIcon(faHdd), ...constructCircleStatus('danger-circle')],
'vdi:busy': [constructIcon(faHdd), { icon: busy }],
'vdi-snapshot': [
constructIcon(faHdd),
{
@@ -240,6 +261,7 @@ export const objectIcons = defineIconPack({
'network:connected': [constructIcon(faNetworkWired), ...constructCircleStatus('success-circle')],
'network:partially-connected': [constructIcon(faNetworkWired), ...constructCircleStatus('warning-circle')],
'network:disconnected': [constructIcon(faNetworkWired), ...constructCircleStatus('danger-circle')],
'network:busy': [constructIcon(faNetworkWired), { icon: busy }],
br: constructIcon(faBoxesStacked),
'br:unknown': [
{
@@ -255,6 +277,7 @@ export const objectIcons = defineIconPack({
'br:disabled': [constructIcon(faBoxesStacked), ...constructCircleStatus('disabled')],
'br:warning': [constructIcon(faBoxesStacked), ...constructCircleStatus('warning-circle')],
'br:disconnected': [constructIcon(faBoxesStacked), ...constructCircleStatus('danger-circle')],
'br:busy': [constructIcon(faBoxesStacked), { icon: busy }],
'backup-archive': constructIcon(faArchive),
'backup-job': [
constructIcon(faArchive),

View File

@@ -85,6 +85,7 @@
"action:edit-br": "@:action:edit BR",
"action:edit-config": "Edit config",
"action:edit-n-brs": "@:action:edit {n} BRs",
"action:edit-rules-order": "@:action:edit rules order within network",
"action:edit-traffic-rule": "@:action:edit @.lower:traffic-rule",
"action:emergency-shutdown": "Emergency shutdown",
"action:emergency-shutdown-host": "@:action:emergency-shutdown host",
@@ -123,7 +124,9 @@
"action:migrate": "Migrate",
"action:migrate-n-vms": "Migrate 1 VM | Migrate {n} VMs",
"action:migrate-vdi-on-sr": "Migrate on SR",
"action:move-down": "Move down",
"action:move-filters-to-parent-group": "Move filters to parent group",
"action:move-up": "Move up",
"action:open": "Open",
"action:open-app": "@:action:open {name}",
"action:open-console-in-new-tab": "Open console in new tab",
@@ -574,6 +577,7 @@
"job:arg:missing-payload": "Payload is missing",
"job:arg:name-required": "@:name-required",
"job:arg:nbd-and-insecure-nbd-exclusive": "NBD and insecure NBD cannot be enabled together",
"job:arg:network-required": "@:network-required",
"job:arg:password-required": "Password is required",
"job:arg:pif-id-required": "PIF ID is required",
"job:arg:pif-ids-required": "At least one PIF ID is required",
@@ -690,6 +694,8 @@
"job:sr-delete:missing-sr": "No SRs to delete",
"job:sr-disconnect:missing-sr": "No SRs to disconnect",
"job:traffic-rule-delete:missing-rule": "No traffic rule selected",
"job:traffic-rule-edit-order:no-free-priority": "No free priority left on this network to apply this order",
"job:traffic-rule-edit-order:not-enough-rules": "Requires at least {n} rules",
"job:vbd-connect:missing-vbd": "No VBDs to connect",
"job:vbd-connect:vbd-already-connected": "VBD is already connected",
"job:vbd-delete:missing-vbd": "No VBDs to delete",
@@ -1029,6 +1035,7 @@
"ok": "OK",
"on": "On",
"on-object": "on {object}",
"operation-in-progress": "{operation}…",
"operation:clean-reboot": "Rebooting",
"operation:clean-shutdown": "Shutting down",
"operation:destroy": "Deleting",
@@ -1394,6 +1401,8 @@
"traffic-rule": "Traffic rule",
"traffic-rule-delete-warning": "This action is irreversible. The selected traffic rule will be permanently deleted.",
"traffic-rules": "Traffic rules",
"traffic-rules:edit-order-info": "Traffic rules are evaluated by priority, starting with rule 1, then rule 2, and so on. The first matching rule is applied, and subsequent rules are skipped. Priority only matters within the same network.",
"traffic-rules:edit-order-save-info": "Changes will only take effect when you save.",
"traffic-rules:error-plugin": "You are using the direct OpenFlow protocol (legacy mode), which does not support network traffic rules. To create them, switch to the XAPI plugin mode (beta) — see {check-doc}.",
"traffic-rules:error-plugin:check-doc": "the SDN controller documentation",
"traffic-rules:format-warning": "Configuration error: Traffic rules are not in the expected format. Please {check-doc} on how to migrate them.",
@@ -1402,7 +1411,8 @@
"traffic-rules:position-on-network": "{position} rule on this network.",
"traffic-rules:rule-ordinal-suffixes": "one:st, two:nd, few:rd, other:th",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "Requires XAPI plugin mode",
"traffic-rules:xapi-plugin-required": "Requires @:xapi-plugin mode",
"traffic-rules:xapi-plugin-required-with-link": "Requires {xapi-plugin} mode",
"translation-tool": "Translation tool",
"treeview": "Treeview",
"trigger": "Trigger",
@@ -1554,6 +1564,7 @@
"with-memory": "With memory",
"write": "Write",
"writing-speed": "Writing speed",
"xapi-plugin": "XAPI plugin",
"xcp-ng": "XCP-ng",
"xen-orchestra": "Xen Orchestra",
"xo": "XO",

View File

@@ -85,6 +85,7 @@
"action:edit-br": "@:action:edit le BR",
"action:edit-config": "Modifier config",
"action:edit-n-brs": "@:action:edit {n} BRs",
"action:edit-rules-order": "@:action:edit l'ordre des règles dans le réseau",
"action:edit-traffic-rule": "@:action:edit la @.lower:traffic-rule",
"action:emergency-shutdown": "Arrêt d'urgence",
"action:emergency-shutdown-host": "@:action:emergency-shutdown de l'hôte",
@@ -123,7 +124,9 @@
"action:migrate": "Migrer",
"action:migrate-n-vms": "Migrer 1 VM | Migrer {n} VMs",
"action:migrate-vdi-on-sr": "Migrer sur le SR",
"action:move-down": "Descendre",
"action:move-filters-to-parent-group": "Déplacer les filtres vers le groupe parent",
"action:move-up": "Monter",
"action:open": "Ouvrir",
"action:open-app": "@:action:open {name}",
"action:open-console-in-new-tab": "Ouvrir la console dans un nouvel onglet",
@@ -574,6 +577,7 @@
"job:arg:missing-payload": "Aucune donnée fournie pour l'execution du job",
"job:arg:name-required": "@:name-required",
"job:arg:nbd-and-insecure-nbd-exclusive": "NBD et NBD non sécurisé ne peuvent pas être activés ensemble",
"job:arg:network-required": "@:network-required",
"job:arg:password-required": "Le mot de passe est obligatoire",
"job:arg:pif-id-required": "L'ID de PIF est obligatoire",
"job:arg:pif-ids-required": "Au moins un ID de PIF est obligatoire",
@@ -690,6 +694,8 @@
"job:sr-delete:missing-sr": "Aucun SR à supprimer",
"job:sr-disconnect:missing-sr": "Aucun SR à déconnecter",
"job:traffic-rule-delete:missing-rule": "Aucune règle de trafic sélectionnée",
"job:traffic-rule-edit-order:no-free-priority": "Plus aucune priorité libre sur ce réseau pour appliquer cet ordre",
"job:traffic-rule-edit-order:not-enough-rules": "Nécessite au moins {n} règles",
"job:vbd-connect:missing-vbd": "Aucun VBD à connecter",
"job:vbd-connect:vbd-already-connected": "Le VBD est déjà connecté",
"job:vbd-delete:missing-vbd": "Aucun VBD à supprimer",
@@ -1029,6 +1035,7 @@
"ok": "OK",
"on": "Allumé",
"on-object": "sur {object}",
"operation-in-progress": "{operation}…",
"operation:clean-reboot": "Redémarrage",
"operation:clean-shutdown": "Arrêt",
"operation:destroy": "Suppression",
@@ -1394,6 +1401,8 @@
"traffic-rule": "Règle de trafic",
"traffic-rule-delete-warning": "Cette action est irréversible. La règle de trafic sélectionnée sera définitivement supprimée.",
"traffic-rules": "Règles de trafic",
"traffic-rules:edit-order-info": "Les règles de trafic sont évaluées par priorité, en commençant par la règle 1, puis la règle 2, et ainsi de suite. La première règle correspondante est appliquée et les suivantes sont ignorées. La priorité ne compte qu'au sein d'un même réseau.",
"traffic-rules:edit-order-save-info": "Les modifications ne prendront effet qu'à l'enregistrement.",
"traffic-rules:error-plugin": "Vous utilisez le protocole OpenFlow direct (mode historique), qui ne prend pas en charge les règles de trafic réseau. Pour en créer, passez en mode plugin XAPI (bêta) — voir {check-doc}.",
"traffic-rules:error-plugin:check-doc": "la documentation du contrôleur SDN",
"traffic-rules:format-warning": "Erreur de configuration : les règles de trafic ne sont pas au format attendu. Veuillez {check-doc} la plus récente pour savoir comment les migrer.",
@@ -1402,7 +1411,8 @@
"traffic-rules:position-on-network": "{position} règle sur ce réseau.",
"traffic-rules:rule-ordinal-suffixes": "one:re, other:e",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "Nécessite le mode plugin XAPI",
"traffic-rules:xapi-plugin-required": "Nécessite le mode @:xapi-plugin",
"traffic-rules:xapi-plugin-required-with-link": "Nécessite le mode {xapi-plugin}",
"translation-tool": "Outil de traduction",
"treeview": "Arborescence",
"trigger": "Déclencheur",
@@ -1554,6 +1564,7 @@
"with-memory": "Avec mémoire",
"write": "Écriture",
"writing-speed": "Vitesse d'écriture",
"xapi-plugin": "plugin XAPI",
"xcp-ng": "XCP-ng",
"xen-orchestra": "Xen Orchestra",
"xo": "XO",

View File

@@ -0,0 +1,12 @@
import VtsButtonIconListCell, { type ButtonIconItem } from '@core/components/table/cells/VtsButtonIconListCell.vue'
import { defineColumn } from '@core/packages/table/define-column.ts'
import { renderHeadCell } from '@core/tables/helpers/render-head-cell.ts'
import type { HeaderConfig } from '@core/tables/types.ts'
import { h } from 'vue'
export type { ButtonIconItem }
export const useButtonIconListColumn = defineColumn((config?: HeaderConfig) => ({
renderHead: () => renderHeadCell(config?.headerLabel),
renderBody: (buttons: ButtonIconItem[]) => h(VtsButtonIconListCell, { buttons }),
}))

View File

@@ -0,0 +1,10 @@
import VtsRankCell from '@core/components/table/cells/VtsRankCell.vue'
import { defineColumn } from '@core/packages/table/define-column.ts'
import { renderHeadCell } from '@core/tables/helpers/render-head-cell.ts'
import type { HeaderConfig } from '@core/tables/types.ts'
import { h } from 'vue'
export const useRankColumn = defineColumn((config?: HeaderConfig) => ({
renderHead: () => renderHeadCell(config?.headerLabel),
renderBody: (rank: number, shift?: number) => h(VtsRankCell, { rank, shift }),
}))

View File

@@ -1,7 +1,9 @@
import { defineColumns } from '@core/packages/table/define-columns.ts'
import { useActionColumn } from '@core/tables/column-definitions/action-column.ts'
import { useButtonIconListColumn } from '@core/tables/column-definitions/button-icon-list-column.ts'
import { useDoubleLinkColumn } from '@core/tables/column-definitions/double-link-column.ts'
import { useLinkColumn } from '@core/tables/column-definitions/link-column.ts'
import { useRankColumn } from '@core/tables/column-definitions/rank-column.ts'
import { useTagColumn } from '@core/tables/column-definitions/tag-column.ts'
import { useTextColumn } from '@core/tables/column-definitions/text-column.ts'
import { useTruncatedTextColumn } from '@core/tables/column-definitions/truncated-text-column.ts'
@@ -13,6 +15,7 @@ export const useTrafficRulesColumns = defineColumns(() => {
return {
network: useLinkColumn({ headerLabel: () => t('network') }),
priority: useTextColumn({ headerLabel: () => t('priority') }),
rank: useRankColumn({ headerLabel: () => t('priority') }),
policy: useTagColumn({ headerLabel: () => t('policy') }),
protocol: useTextColumn({
headerLabel: () => t('protocol:port'),
@@ -24,5 +27,6 @@ export const useTrafficRulesColumns = defineColumns(() => {
}),
object: useDoubleLinkColumn({ headerLabel: () => t('object') }),
actions: useActionColumn({}),
moveButtons: useButtonIconListColumn(),
}
})

View File

@@ -1,3 +0,0 @@
export type VmState = 'running' | 'halted' | 'paused' | 'suspended'
export type HostState = 'running' | 'halted' | 'warning' | 'disabled' | 'unknown'

View File

@@ -1 +0,0 @@
export type POWER_STATE = 'running' | 'paused' | 'halted' | 'suspended'

View File

@@ -47,12 +47,8 @@
<template v-if="backedUpVmsCount > 0">
<VtsDivider class="divider" type="stretch" />
<UiCollapsibleList tag="ul" :total-items="backedUpVmsCount">
<li v-for="vm in backedUpVms" :key="vm.id">
<UiLink
size="small"
:icon="`object:vm:${toLower(vm.power_state)}`"
:to="{ name: '/vm/[id]/dashboard', params: { id: vm.id } }"
>
<li v-for="{ vm, icon, iconTooltip } in backedUpVmLinks" :key="vm.id">
<UiLink size="small" :icon :icon-tooltip :to="{ name: '/vm/[id]/dashboard', params: { id: vm.id } }">
{{ vm.name_label }}
</UiLink>
</li>
@@ -67,6 +63,8 @@ import BackupJobSmartModePoolItem from '@/modules/backup-job/components/list/pan
import BackupJobSmartModeTagsItem from '@/modules/backup-job/components/list/panel/card-items/BackupJobSmartModeTagsItem.vue'
import { useXoBackedUpVmsUtils } from '@/modules/backup-job/composables/use-xo-backed-up-vms-utils.composable.ts'
import type { FrontXoVmBackupJob } from '@/modules/backup-job/remote-resources/use-xo-backup-job-collection.ts'
import { useXoVmOperationLabel } from '@/modules/vm/composables/xo-vm-operation-label.composable.ts'
import { getVmIcon } from '@/modules/vm/utils/xo-vm.util.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsDivider from '@core/components/divider/VtsDivider.vue'
@@ -77,6 +75,7 @@ import UiLink from '@core/components/ui/link/UiLink.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
import { toLower } from 'lodash-es'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { backedUpVms: rawBackedUpVms } = defineProps<{
@@ -87,6 +86,12 @@ const { t } = useI18n()
const { backedUpVms, backedUpVmsCount, smartModePools, smartModePowerState, smartModeTags, isSmartModeEnabled } =
useXoBackedUpVmsUtils(() => rawBackedUpVms)
const { getVmIconTooltip } = useXoVmOperationLabel()
const backedUpVmLinks = computed(() =>
backedUpVms.value.map(vm => ({ vm, icon: getVmIcon(vm), iconTooltip: getVmIconTooltip(vm) }))
)
</script>
<style scoped lang="postcss">

View File

@@ -25,7 +25,9 @@ import type { BackupLogFilterableData } from '@/modules/backup-log/types/xo-back
import { getBackupLogSrIds, getBackupLogVmIds } from '@/modules/backup-log/utils/xo-backup-log.util.ts'
import { useXoSrUtils } from '@/modules/storage-repository/composables/xo-sr-utils.composable.ts'
import { useXoSrCollection } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import { useXoVmOperationLabel } from '@/modules/vm/composables/xo-vm-operation-label.composable.ts'
import { useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { getVmIcon } from '@/modules/vm/utils/xo-vm.util.ts'
import { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import VtsQueryBuilder from '@core/components/query-builder/VtsQueryBuilder.vue'
import VtsRow from '@core/components/table/VtsRow.vue'
@@ -34,12 +36,10 @@ import UiTitle from '@core/components/ui/title/UiTitle.vue'
import { usePagination } from '@core/composables/pagination.composable.ts'
import { useRouteQuery } from '@core/composables/route-query.composable.ts'
import { useTableState } from '@core/composables/table-state.composable.ts'
import { objectIcon } from '@core/icons'
import { useQueryBuilderSchema } from '@core/packages/query-builder/schema/use-query-builder-schema.ts'
import { useQueryBuilderFilter } from '@core/packages/query-builder/use-query-builder-filter.ts'
import { useBackupLogColumns } from '@core/tables/column-sets/backup-log-columns.ts'
import { useStringSchema } from '@core/utils/query-builder/use-string-schema.ts'
import { toLower } from 'lodash-es'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -59,6 +59,8 @@ const { getBackupJobById } = useXoBackupJobCollection()
const { getVmById } = useXoVmCollection()
const { getVmIconTooltip } = useXoVmOperationLabel()
const { getSrById } = useXoSrCollection()
const { getSrStatusIcon } = useXoSrUtils()
@@ -121,7 +123,8 @@ const { HeadCells, BodyCells } = useBackupLogColumns({
return r({
label: firstVm.value.name_label,
to: { name: '/vm/[id]/dashboard', params: { id: firstVm.value.id } },
icon: objectIcon('vm', toLower(firstVm.value.power_state)),
icon: getVmIcon(firstVm.value),
iconTooltip: getVmIconTooltip(firstVm.value),
extraCount: vmIds.value.length - 1,
})
}

View File

@@ -17,7 +17,7 @@
</template>
</VtsCardRowKeyValue>
<template v-if="vms.length > 0">
<VtsCardRowKeyValue v-for="({ id, vm, icon }, index) in vms" :key="id">
<VtsCardRowKeyValue v-for="({ id, vm, icon, iconTooltip }, index) in vms" :key="id">
<template #key>
<template v-if="index === 0">{{ t('vm') }}</template>
</template>
@@ -26,6 +26,7 @@
v-if="vm !== undefined"
size="small"
:icon
:icon-tooltip
:to="{ name: '/vm/[id]/dashboard', params: { id: vm.id } }"
>
{{ vm.name_label }}
@@ -96,7 +97,9 @@ import type { FrontXoBackupLog } from '@/modules/backup-log/remote-resources/use
import { getBackupLogSrIds, getBackupLogVmIds } from '@/modules/backup-log/utils/xo-backup-log.util.ts'
import { useXoSrUtils } from '@/modules/storage-repository/composables/xo-sr-utils.composable.ts'
import { useXoSrCollection } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import { useXoVmOperationLabel } from '@/modules/vm/composables/xo-vm-operation-label.composable.ts'
import { useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { getVmIcon } from '@/modules/vm/utils/xo-vm.util.ts'
import { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
@@ -106,8 +109,6 @@ import UiInfo from '@core/components/ui/info/UiInfo.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
import { objectIcon } from '@core/icons'
import { toLower } from 'lodash-es'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -123,6 +124,8 @@ const { useGetBackupJobById } = useXoBackupJobCollection()
const { getVmById } = useXoVmCollection()
const { getVmIconTooltip } = useXoVmOperationLabel()
const { getSrById } = useXoSrCollection()
const { getSrStatusIcon } = useXoSrUtils()
@@ -145,7 +148,7 @@ const vms = computed(() =>
getBackupLogVmIds(backupLog).map(id => {
const vm = getVmById(id)
return { id, vm, icon: vm !== undefined ? objectIcon('vm', toLower(vm.power_state)) : undefined }
return { id, vm, icon: vm !== undefined ? getVmIcon(vm) : undefined, iconTooltip: getVmIconTooltip(vm) }
})
)

View File

@@ -0,0 +1,140 @@
import HostHeader from '@/modules/host/components/HostHeader.vue'
import type { FrontXoHost, useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import type { FrontXoVm, useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import type { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import { createHost } from '@/test/create-host.ts'
import { createTestRouter } from '@/test/create-test-router.ts'
import { createVm } from '@/test/create-vm.ts'
import { findTooltipContent } from '@/test/find-tooltip-content.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import UiHeadBar from '@core/components/ui/head-bar/UiHeadBar.vue'
import { objectIcon } from '@core/icons'
import { HOST_ALLOWED_OPERATIONS, HOST_POWER_STATE, VM_OPERATIONS } from '@vates/types'
import { mount } from '@vue/test-utils'
import { computed } from 'vue'
const { isMasterHost, vmsByHost, buildXo5Route } = vi.hoisted(() => ({
isMasterHost: vi.fn(),
vmsByHost: vi.fn(),
buildXo5Route: vi.fn(),
}))
vi.mock(import('@/modules/host/remote-resources/use-xo-host-collection.ts'), () => ({
useXoHostCollection: (() => ({ isMasterHost })) as unknown as typeof useXoHostCollection,
}))
vi.mock(import('@/modules/vm/remote-resources/use-xo-vm-collection.ts'), () => ({
useXoVmCollection: (() => ({ vmsByHost: computed(() => vmsByHost()) })) as unknown as typeof useXoVmCollection,
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => ({
useXoRoutes: (() => ({ buildXo5Route })) as unknown as typeof useXoRoutes,
}))
beforeEach(() => {
isMasterHost.mockReset()
vmsByHost.mockReset()
buildXo5Route.mockReset()
isMasterHost.mockReturnValue(false)
vmsByHost.mockReturnValue(new Map())
buildXo5Route.mockImplementation((path: string) => `https://xo5.example.com/#${path}`)
})
function mountHeader(host: FrontXoHost = createHost()) {
return mount(HostHeader, {
props: { host },
global: createGlobalTestConfig({ router: createTestRouter() }),
})
}
function createRebootingHost() {
return createHost({
power_state: HOST_POWER_STATE.RUNNING,
enabled: true,
current_operations: { 'task-1': HOST_ALLOWED_OPERATIONS.REBOOT },
})
}
function findHeadBarIcon(wrapper: ReturnType<typeof mountHeader>) {
return wrapper.getComponent(UiHeadBar).getComponent(VtsIcon).props('name')
}
it('shows the name of the host', () => {
const wrapper = mountHeader(createHost({ name_label: 'Primary Host' }))
expect(wrapper.get('.ui-head-bar .label').text()).toBe('Primary Host')
})
it('shows the icon matching the state of the host and no loader', () => {
const wrapper = mountHeader(
createHost({ power_state: HOST_POWER_STATE.RUNNING, enabled: true, current_operations: {} })
)
expect(findHeadBarIcon(wrapper)).toBe(objectIcon('host', 'running'))
expect(wrapper.find('.ui-head-bar .ui-loader').exists()).toBe(false)
})
it('shows the busy icon and a loader next to the title while the host is changing state', () => {
const wrapper = mountHeader(createRebootingHost())
expect(findHeadBarIcon(wrapper)).toBe(objectIcon('host', 'busy'))
expect(wrapper.find('.ui-head-bar .status .ui-loader').exists()).toBe(true)
})
it('shows the pending operation in the tooltip of the loader', async () => {
const wrapper = mountHeader(createRebootingHost())
expect(await findTooltipContent(wrapper.get('.ui-head-bar .status .ui-loader').element)).toBe(
t('operation-in-progress', { operation: t('operation:clean-reboot') })
)
})
it('shows the pending operation in the tooltip of the busy icon', async () => {
const wrapper = mountHeader(createRebootingHost())
expect(await findTooltipContent(wrapper.getComponent(UiHeadBar).getComponent(VtsIcon).element)).toBe(
t('operation-in-progress', { operation: t('operation:clean-reboot') })
)
})
it('shows no tooltip on the icon while the host is not changing state', async () => {
const wrapper = mountHeader(createHost({ current_operations: {} }))
expect(await findTooltipContent(wrapper.getComponent(UiHeadBar).getComponent(VtsIcon).element)).toBeUndefined()
})
it('keeps the host state icon but shows a loader while its resident VMs are suspended for a smart reboot', () => {
const host = createHost({
power_state: HOST_POWER_STATE.RUNNING,
enabled: false,
current_operations: {},
})
vmsByHost.mockReturnValue(
new Map<FrontXoHost['id'], FrontXoVm[]>([
[host.id, [createVm({ current_operations: { 'task-1': VM_OPERATIONS.SUSPEND } })]],
])
)
const wrapper = mountHeader(host)
expect(findHeadBarIcon(wrapper)).toBe(objectIcon('host', 'disabled'))
expect(wrapper.find('.ui-head-bar .status .ui-loader').exists()).toBe(true)
})
it('shows the master icon next to the title when the host is the pool master', () => {
isMasterHost.mockReturnValue(true)
const wrapper = mountHeader(createHost({ current_operations: {} }))
expect(wrapper.get('.ui-head-bar .status').getComponent(VtsIcon).props('name')).toBe('status:primary-circle')
})
it('shows nothing next to the title when the host is neither master nor changing state', () => {
const wrapper = mountHeader(createHost({ current_operations: {} }))
expect(wrapper.find('.ui-head-bar .status').exists()).toBe(false)
})

View File

@@ -2,19 +2,17 @@
<UiHeadBar>
{{ host.name_label }}
<template #icon>
<VtsObjectIcon
<VtsIcon :name="hostIcon" :tooltip="iconTooltip" size="medium" />
</template>
<template v-if="isChangingState || isMaster" #status>
<UiLoader
v-if="isChangingState"
v-tooltip="{
placement: 'top',
content: currentOperation ? currentOperation : '',
content: currentOperation,
}"
size="medium"
type="host"
:busy="isChangingState"
:state="hostState"
/>
</template>
<template v-if="isMaster" #status>
<VtsIcon v-tooltip="t('master')" name="status:primary-circle" size="medium" />
<VtsIcon v-if="isMaster" v-tooltip="t('master')" name="status:primary-circle" size="medium" />
</template>
<template #actions>
<UiLink size="medium" :to="{ name: '/vm/new', query: { poolid: host.$pool } }" icon="fa:plus">
@@ -93,16 +91,16 @@ import HostPowerStateActions from '@/modules/host/components/actions/HostPowerSt
import HostMoreActions from '@/modules/host/components/HostMoreActions.vue'
import { useXoHostUtils } from '@/modules/host/composables/xo-host-utils.composable.ts'
import { type FrontXoHost, useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import { getHostState } from '@/modules/host/utils/xo-host.util.ts'
import { getHostIcon } from '@/modules/host/utils/xo-host.util.ts'
import { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import MenuList from '@core/components/menu/MenuList.vue'
import VtsObjectIcon from '@core/components/object-icon/VtsObjectIcon.vue'
import TabList from '@core/components/tab-list/TabList.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import UiDropdownButton from '@core/components/ui/dropdown-button/UiDropdownButton.vue'
import UiHeadBar from '@core/components/ui/head-bar/UiHeadBar.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import UiLoader from '@core/components/ui/loader/UiLoader.vue'
import UiTabItem from '@core/components/ui/tab-item/UiTabItem.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { useUiStore } from '@core/stores/ui.store.ts'
@@ -117,7 +115,7 @@ const { t } = useI18n()
const uiStore = useUiStore()
const { isChangingState, currentOperation } = useXoHostUtils(() => host)
const { isChangingState, currentOperation, iconTooltip } = useXoHostUtils(() => host)
const { buildXo5Route } = useXoRoutes()
const xo5HostStatsHref = computed(() => buildXo5Route(`/hosts/${host.id}/stats`))
@@ -125,5 +123,5 @@ const xo5HostStatsHref = computed(() => buildXo5Route(`/hosts/${host.id}/stats`)
const { isMasterHost } = useXoHostCollection()
const isMaster = computed(() => isMasterHost(host.id))
const hostState = computed(() => getHostState(host))
const hostIcon = computed(() => getHostIcon(host))
</script>

View File

@@ -3,6 +3,7 @@
:to="{ name: '/host/[id]/dashboard', params: { id: host.id } }"
:size
:icon
:icon-tooltip
:is-primary="isMaster"
:primary-tooltip="t('master')"
>
@@ -11,6 +12,7 @@
</template>
<script lang="ts" setup>
import { useXoHostOperationLabel } from '@/modules/host/composables/xo-host-operation-label.composable.ts'
import type { FrontXoHost } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import { useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import { getHostIcon } from '@/modules/host/utils/xo-host.util.ts'
@@ -26,6 +28,9 @@ const { host } = defineProps<{
const { t } = useI18n()
const { isMasterHost } = useXoHostCollection()
const { getHostIconTooltip } = useXoHostOperationLabel()
const icon = computed(() => getHostIcon(host))
const iconTooltip = computed(() => getHostIconTooltip(host))
const isMaster = computed(() => isMasterHost(host.id))
</script>

View File

@@ -19,6 +19,7 @@
<script lang="ts" setup>
import { useHostQueryBuilder } from '@/modules/host/composables/use-host-query-builder.composable.ts'
import { useXoHostOperationLabel } from '@/modules/host/composables/xo-host-operation-label.composable.ts'
import { type FrontXoHost, useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import { getHostIcon } from '@/modules/host/utils/xo-host.util.ts'
import { getPbdsConnectionStatus } from '@/modules/pbd/utils/xo-pbd.util.ts'
@@ -61,6 +62,8 @@ const { pifsByHost, arePifsReady, hasPifFetchError } = useXoPifCollection()
const { isMasterHost } = useXoHostCollection()
const { getHostIconTooltip } = useXoHostOperationLabel()
const isReady = logicAnd(() => !busy, arePifsReady)
const hasError = logicOr(() => error, hasPifFetchError)
@@ -111,6 +114,7 @@ const { HeadCells, BodyCells } = useHostColumns({
label: host.name_label,
to: { name: '/host/[id]/dashboard', params: { id: host.id } },
icon: hostIcon.value,
iconTooltip: getHostIconTooltip(host),
rightIcon: rightIcon.value,
}),
description: r => r(host.name_description),

View File

@@ -3,6 +3,7 @@
<UiPanelCardTitle
:id="host.id"
:icon="hostIcon"
:icon-tooltip="iconTooltip"
:label="host.name_label"
:to="{ name: '/host/[id]/dashboard', params: { id: host.id } }"
size="medium"
@@ -120,7 +121,7 @@ const { host } = defineProps<{
const { t } = useI18n()
const { getHostStateDisplay, relativeStartTime } = useXoHostUtils(() => host)
const { getHostStateDisplay, relativeStartTime, iconTooltip } = useXoHostUtils(() => host)
const { useGetPoolById } = useXoPoolCollection()

View File

@@ -0,0 +1,29 @@
import type { FrontXoHost } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import { getHostPendingStateOperation } from '@/modules/host/utils/xo-host.util.ts'
import { useOperationInProgressLabel } from '@/shared/composables/operation-in-progress-label.composable.ts'
import { useI18n } from 'vue-i18n'
export function useXoHostOperationLabel() {
const { t } = useI18n()
const getHostOperationLabel = useOperationInProgressLabel(() => ({
power_on: t('operation:start'),
shutdown: t('operation:shutdown'),
reboot: t('operation:clean-reboot'),
evacuate: t('operation:evacuate'),
enable: t('operation:enable'),
suspend: t('operation:vm-suspend'),
clean_shutdown: t('operation:vm-clean-shutdown'),
hard_shutdown: t('operation:vm-hard-shutdown'),
}))
const getHostIconTooltip = (host: FrontXoHost | undefined) => {
if (host === undefined) {
return ''
}
return getHostOperationLabel(getHostPendingStateOperation(host))
}
return { getHostOperationLabel, getHostIconTooltip }
}

View File

@@ -0,0 +1,47 @@
import { useXoHostOperationLabel } from '@/modules/host/composables/xo-host-operation-label.composable.ts'
import { createHost } from '@/test/create-host.ts'
import { t } from '@/test/i18n.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { HOST_ALLOWED_OPERATIONS, HOST_POWER_STATE } from '@vates/types'
function mountHostOperationLabel() {
return mountComposable(() => useXoHostOperationLabel()).wrapper.vm
}
describe('getHostOperationLabel', () => {
it('maps a host operation to its in-progress label', () => {
expect(mountHostOperationLabel().getHostOperationLabel(HOST_ALLOWED_OPERATIONS.EVACUATE)).toBe(
t('operation-in-progress', { operation: t('operation:evacuate') })
)
})
it('returns an empty label for an unknown operation', () => {
expect(mountHostOperationLabel().getHostOperationLabel('some_unknown_op')).toBe('')
})
})
describe('getHostIconTooltip', () => {
it('shows the operation changing the host state', () => {
const host = createHost({
power_state: HOST_POWER_STATE.RUNNING,
current_operations: { 'task-1': HOST_ALLOWED_OPERATIONS.REBOOT },
})
expect(mountHostOperationLabel().getHostIconTooltip(host)).toBe(
t('operation-in-progress', { operation: t('operation:clean-reboot') })
)
})
it('returns an empty tooltip while only a VM operation is pending on the host', () => {
const host = createHost({
power_state: HOST_POWER_STATE.RUNNING,
current_operations: { 'task-1': HOST_ALLOWED_OPERATIONS.VM_START },
})
expect(mountHostOperationLabel().getHostIconTooltip(host)).toBe('')
})
it('returns an empty tooltip when no host is provided', () => {
expect(mountHostOperationLabel().getHostIconTooltip(undefined)).toBe('')
})
})

View File

@@ -1,10 +1,11 @@
import { useXoHostOperationLabel } from '@/modules/host/composables/xo-host-operation-label.composable.ts'
import type { FrontXoHost } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import type { XoHostState } from '@/modules/host/utils/xo-host.util.ts'
import { getHostPendingStateOperation, getHostSmartRebootVmOperation } from '@/modules/host/utils/xo-host.util.ts'
import { useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import type { IconName } from '@core/icons'
import { useTimeAgo } from '@core/composables/locale-time-ago.composable.ts'
import { createMapper, useMapper } from '@core/packages/mapper'
import { createMapper } from '@core/packages/mapper'
import { parseDateTime } from '@core/utils/time.util.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
import { computed, type MaybeRefOrGetter } from 'vue'
@@ -18,12 +19,12 @@ export function useXoHostUtils(rawHost: MaybeRefOrGetter<FrontXoHost>) {
const { vmsByHost } = useXoVmCollection()
const getHostStateDisplay = createMapper<XoHostState, { text: string; icon: IconName | undefined }>(
{
() => ({
running: { text: t('host:status:running'), icon: 'status:running-circle' },
halted: { text: t('host:status:halted'), icon: 'status:halted-circle' },
disabled: { text: t('host:status:disabled'), icon: 'status:host-disabled-circle' },
unknown: { text: t('host:status:unknown'), icon: undefined },
},
}),
'unknown'
)
@@ -45,26 +46,17 @@ export function useXoHostUtils(rawHost: MaybeRefOrGetter<FrontXoHost>) {
const isChangingState = computed(() => pendingStateOperation.value !== undefined)
const currentOperation = useMapper<string, string>(
() => pendingStateOperation.value,
{
power_on: t('operation:start'),
shutdown: t('operation:shutdown'),
reboot: t('operation:clean-reboot'),
evacuate: t('operation:evacuate'),
enable: t('operation:enable'),
suspend: t('operation:vm-suspend'),
clean_shutdown: t('operation:vm-clean-shutdown'),
hard_shutdown: t('operation:vm-hard-shutdown'),
unknown: '',
},
'unknown'
)
const { getHostOperationLabel, getHostIconTooltip } = useXoHostOperationLabel()
const currentOperation = computed(() => getHostOperationLabel(pendingStateOperation.value))
const iconTooltip = computed(() => getHostIconTooltip(host.value))
return {
getHostStateDisplay,
relativeStartTime,
isChangingState,
currentOperation,
iconTooltip,
}
}

View File

@@ -172,7 +172,7 @@ describe('currentOperation', () => {
current_operations: { 'task-1': HOST_ALLOWED_OPERATIONS.REBOOT },
})
expect(currentOperation).toBe(t('operation:clean-reboot'))
expect(currentOperation).toBe(t('operation-in-progress', { operation: t('operation:clean-reboot') }))
})
it('maps the power-on operation of a halted host to a translated label', () => {
@@ -181,7 +181,7 @@ describe('currentOperation', () => {
current_operations: { 'task-1': HOST_ALLOWED_OPERATIONS.POWER_ON },
})
expect(currentOperation).toBe(t('operation:start'))
expect(currentOperation).toBe(t('operation-in-progress', { operation: t('operation:start') }))
})
it('maps the smart-reboot VM operation to a translated label', () => {
@@ -195,7 +195,9 @@ describe('currentOperation', () => {
return { ...useXoHostUtils(host), t }
})
expect(wrapper.vm.currentOperation).toBe(wrapper.vm.t('operation:vm-hard-shutdown'))
expect(wrapper.vm.currentOperation).toBe(
wrapper.vm.t('operation-in-progress', { operation: wrapper.vm.t('operation:vm-hard-shutdown') })
)
})
it('falls back to an empty label when there is no pending operation', () => {
@@ -212,3 +214,25 @@ describe('currentOperation', () => {
expect(currentOperation).toBe('')
})
})
describe('iconTooltip', () => {
it('shows the operation changing the host state', () => {
const result = mountHostUtils({
power_state: HOST_POWER_STATE.RUNNING,
current_operations: { 'task-1': HOST_ALLOWED_OPERATIONS.REBOOT },
})
expect(result.iconTooltip).toBe(
result.t('operation-in-progress', { operation: result.t('operation:clean-reboot') })
)
})
it('is empty while only a VM operation is pending on the host', () => {
const result = mountHostUtils({
power_state: HOST_POWER_STATE.RUNNING,
current_operations: { 'task-1': HOST_ALLOWED_OPERATIONS.VM_START },
})
expect(result.iconTooltip).toBe('')
})
})

View File

@@ -92,5 +92,13 @@ export function getHostRamProvisioning(host: FrontXoHost): { total: SizeInfo; us
}
export function getHostIcon(host: FrontXoHost | undefined) {
if (host === undefined) {
return objectIcon('host', 'unknown')
}
if (getHostPendingStateOperation(host) !== undefined) {
return objectIcon('host', 'busy')
}
return objectIcon('host', getHostState(host))
}

View File

@@ -152,4 +152,24 @@ describe('getHostIcon', () => {
it('falls back to the unknown host icon when no host is provided', () => {
expect(getHostIcon(undefined)).toBe(objectIcon('host', 'unknown'))
})
it('picks the host busy icon while the host state is changing', () => {
const host = createHost({
power_state: HOST_POWER_STATE.RUNNING,
enabled: true,
current_operations: { 'task-1': HOST_ALLOWED_OPERATIONS.REBOOT },
})
expect(getHostIcon(host)).toBe(objectIcon('host', 'busy'))
})
it('keeps the host state icon while only a VM operation is pending on the host', () => {
const host = createHost({
power_state: HOST_POWER_STATE.RUNNING,
enabled: true,
current_operations: { 'task-1': HOST_ALLOWED_OPERATIONS.VM_START },
})
expect(getHostIcon(host)).toBe(objectIcon('host', 'running'))
})
})

View File

@@ -0,0 +1,7 @@
import type { FrontXoNetwork } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import { defineJobArg } from '@core/packages/job'
export const xoNetworkArg = defineJobArg({
identify: (network: FrontXoNetwork | undefined) => network?.id,
toArray: false,
})

View File

@@ -2,7 +2,7 @@
<tr class="pif-row" :class="{ clickable: pifHost }" @click="pifHost?.redirect()">
<td v-tooltip class="typo-body-regular-small text-ellipsis host-container">
<div v-if="pifHost" class="host">
<VtsObjectIcon :state="pifHost.state" type="host" size="small" />
<VtsIcon :name="pifHost.icon" :tooltip="pifHost.iconTooltip" size="medium" />
<span v-tooltip class="typo-body-regular-small text-ellipsis host-name">
{{ pifHost.label }}
</span>
@@ -22,11 +22,12 @@
</template>
<script setup lang="ts">
import { useXoHostOperationLabel } from '@/modules/host/composables/xo-host-operation-label.composable.ts'
import { useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import { getHostState } from '@/modules/host/utils/xo-host.util.ts'
import { getHostIcon } from '@/modules/host/utils/xo-host.util.ts'
import type { FrontXoPif } from '@/modules/pif/remote-resources/use-xo-pif-collection.ts'
import { getPifStatus } from '@/modules/pif/utils/xo-pif.util.ts'
import VtsObjectIcon from '@core/components/object-icon/VtsObjectIcon.vue'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
@@ -42,6 +43,8 @@ const { t } = useI18n()
const { getHostById } = useXoHostCollection()
const { getHostIconTooltip } = useXoHostOperationLabel()
const router = useRouter()
const status = computed(() => getPifStatus(pif))
@@ -55,7 +58,8 @@ const pifHost = computed(() => {
return {
label: host.name_label,
state: getHostState(host),
icon: getHostIcon(host),
iconTooltip: getHostIconTooltip(host),
redirect() {
router.push({
name: '/host/[id]/networks',

View File

@@ -147,6 +147,7 @@ const { HeadCells, BodyCells } = useServerColumns({
label: server.primaryHostName,
to: server.master ? `/host/${server.master}/dashboard` : undefined,
icon: server.primaryHostIcon,
iconTooltip: server.primaryHostIconTooltip,
rightIcon: server.primaryHostRightIcon
? {
icon: server.primaryHostRightIcon,

View File

@@ -1,3 +1,4 @@
import { useXoHostOperationLabel } from '@/modules/host/composables/xo-host-operation-label.composable.ts'
import { type FrontXoHost, useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import { getHostIcon } from '@/modules/host/utils/xo-host.util.ts'
import { getPoolInfo } from '@/modules/pool/utils/xo-pool.util.ts'
@@ -20,12 +21,14 @@ export interface PoolDisplayData extends PoolFilterableData {
poolIcon: ReturnType<typeof icon> | undefined
hostIcon: ReturnType<typeof objectIcon> | undefined
primaryHostIcon: ReturnType<typeof objectIcon> | undefined
primaryHostIconTooltip: string
primaryHostRightIcon: ReturnType<typeof icon> | undefined
}
export function usePoolEnhancedData(rawServers: MaybeRefOrGetter<FrontXoServer[]>) {
const servers = toComputed(rawServers)
const { getHostById, isMasterHost } = useXoHostCollection()
const { getHostIconTooltip } = useXoHostOperationLabel()
/**
* Get master host icon if applicable
@@ -69,6 +72,7 @@ export function usePoolEnhancedData(rawServers: MaybeRefOrGetter<FrontXoServer[]
poolIcon: getPoolInfo(pool).icon,
hostIcon,
primaryHostIcon: hostIcon,
primaryHostIconTooltip: getHostIconTooltip(host),
primaryHostRightIcon,
}
}

View File

@@ -1,7 +1,7 @@
<template>
<div v-if="parent" class="sr-header-breadcrumb">
<UiBreadcrumb :size>
<UiLink :size :to="parent.dashboardTo" :icon="parent.icon">
<UiLink :size :to="parent.dashboardTo" :icon="parent.icon" :icon-tooltip="parent.iconTooltip">
{{ parent.label }}
</UiLink>
<UiLink :size :to="parent.storageTo">
@@ -16,6 +16,7 @@
</template>
<script setup lang="ts">
import { useXoHostOperationLabel } from '@/modules/host/composables/xo-host-operation-label.composable.ts'
import { type FrontXoHost, useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import { getHostIcon } from '@/modules/host/utils/xo-host.util.ts'
import { useXoPbdCollection } from '@/modules/pbd/remote-resources/use-xo-pbd-collection.ts'
@@ -34,6 +35,7 @@ import type { RouteLocationRaw } from 'vue-router'
type SrBreadcrumbParent = {
icon: IconName
iconTooltip?: string
label: string
dashboardTo: RouteLocationRaw
storageTo: RouteLocationRaw
@@ -46,6 +48,7 @@ const { t } = useI18n()
const uiStore = useUiStore()
const { useGetHostById } = useXoHostCollection()
const { getHostIconTooltip } = useXoHostOperationLabel()
const { useGetPoolById } = useXoPoolCollection()
const size = computed(() => (uiStore.isSmall ? 'small' : 'medium'))
@@ -66,6 +69,7 @@ const parent = computed<SrBreadcrumbParent | undefined>(() => {
return {
icon: getHostIcon(scopedHost),
iconTooltip: getHostIconTooltip(scopedHost),
label: scopedHost.name_label,
dashboardTo: { name: '/host/[id]/dashboard', params: { id: scopedHost.id } },
storageTo: { name: '/host/[id]/storage', params: { id: scopedHost.id } },

View File

@@ -46,10 +46,9 @@
import { useXoNetworkCollection } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import { getPoolNetworkRoute } from '@/modules/network/utils/xo-network.util.ts'
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import TrafficRuleTableActions from '@/modules/traffic-rules/components/actions/TrafficRuleTableActions.vue'
import { useDirectionLabels } from '@/modules/traffic-rules/composables/direction-labels.composable.ts'
import { useTrafficRuleTarget } from '@/modules/traffic-rules/composables/traffic-rule-target.composable.ts'
import { useTrafficRuleDelete } from '@/modules/traffic-rules/composables/use-traffic-rule-delete.composable.ts'
import { useTrafficRuleEdit } from '@/modules/traffic-rules/composables/use-traffic-rule-edit.composable.ts'
import type { EnrichedTrafficRule, RankedTrafficRule } from '@/modules/traffic-rules/types.ts'
import { getNetworkRules, getTrafficRuleProtocolLabel } from '@/modules/traffic-rules/utils/xo-traffic-rule.util.ts'
import { XO_LINKS } from '@/shared/constants.ts'
@@ -158,7 +157,7 @@ const state = useTableState({
})
const { HeadCells, BodyCells } = useTrafficRulesColumns({
exclude: showPriority ? [] : ['priority'],
exclude: showPriority ? ['rank', 'moveButtons'] : ['priority', 'rank', 'moveButtons'],
body: (rule: EnrichedTrafficRule) => {
const network = useGetNetworkById(() => rule.networkId)
@@ -166,11 +165,6 @@ const { HeadCells, BodyCells } = useTrafficRulesColumns({
network.value ? getPoolNetworkRoute(network.value.$pool, network.value.id) : undefined
)
const { deleteTrafficRules, canDeleteTrafficRules, isDeletingTrafficRules, deleteTrafficRulesErrorMessage } =
useTrafficRuleDelete(() => [rule])
const { editTrafficRule, canEditTrafficRule, isEditingTrafficRule } = useTrafficRuleEdit(() => rule)
return {
network: r =>
r({
@@ -188,24 +182,8 @@ const { HeadCells, BodyCells } = useTrafficRulesColumns({
actions: r =>
r({
onClick: () => (selectedRuleId.value = rule.id),
actions: [
{
label: t('action:edit'),
icon: 'action:edit',
onClick: () => editTrafficRule(),
busy: isEditingTrafficRule.value,
disabled: !canEditTrafficRule.value,
},
{
label: t('action:delete'),
icon: 'action:delete',
onClick: () => deleteTrafficRules(),
disabled: !canDeleteTrafficRules.value,
busy: isDeletingTrafficRules.value,
hint: deleteTrafficRulesErrorMessage.value,
accent: 'danger',
},
],
component: TrafficRuleTableActions,
props: { rule, rules: rawRules, showEditOrder: showPriority },
}),
}
},

View File

@@ -1,33 +1,15 @@
<template>
<UiButton
size="medium"
variant="tertiary"
accent="brand"
left-icon="action:edit"
:disabled="!canEditTrafficRule"
:busy="isEditingTrafficRule"
@click="editTrafficRule()"
>
{{ t('action:edit') }}
</UiButton>
<VtsDeleteButton :disabled="!canDeleteTrafficRules" :busy="isDeletingTrafficRules" @click="deleteTrafficRules()" />
</template>
<script lang="ts" setup>
import { useTrafficRuleDelete } from '@/modules/traffic-rules/composables/use-traffic-rule-delete.composable.ts'
import { useTrafficRuleEdit } from '@/modules/traffic-rules/composables/use-traffic-rule-edit.composable.ts'
import VtsDeleteButton from '@core/components/delete-button/VtsDeleteButton.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
import type { TrafficRule } from '@vates/types'
import { useI18n } from 'vue-i18n'
const { rule } = defineProps<{
rule: TrafficRule
}>()
const { t } = useI18n()
const { deleteTrafficRules, canDeleteTrafficRules, isDeletingTrafficRules } = useTrafficRuleDelete(() => [rule])
const { editTrafficRule, canEditTrafficRule, isEditingTrafficRule } = useTrafficRuleEdit(() => rule)
</script>

View File

@@ -0,0 +1,16 @@
<template>
<TrafficRuleEditButton :rule />
<TrafficRuleEditOrderButton v-if="showEditOrder" :rule :rules />
</template>
<script lang="ts" setup>
import TrafficRuleEditButton from '@/modules/traffic-rules/components/actions/edit/TrafficRuleEditButton.vue'
import TrafficRuleEditOrderButton from '@/modules/traffic-rules/components/actions/edit-order/TrafficRuleEditOrderButton.vue'
import type { RankedTrafficRule } from '@/modules/traffic-rules/types.ts'
defineProps<{
rule: RankedTrafficRule
rules: RankedTrafficRule[]
showEditOrder?: boolean
}>()
</script>

View File

@@ -0,0 +1,16 @@
<template>
<TrafficRuleMoreActions :rule :rules :show-edit-order />
<TrafficRuleDeleteButton :rule />
</template>
<script lang="ts" setup>
import TrafficRuleDeleteButton from '@/modules/traffic-rules/components/actions/delete/TrafficRuleDeleteButton.vue'
import TrafficRuleMoreActions from '@/modules/traffic-rules/components/actions/TrafficRuleMoreActions.vue'
import type { RankedTrafficRule } from '@/modules/traffic-rules/types.ts'
defineProps<{
rule: RankedTrafficRule
rules: RankedTrafficRule[]
showEditOrder?: boolean
}>()
</script>

View File

@@ -0,0 +1,28 @@
<template>
<MenuItem
:busy="isDeletingTrafficRules"
:disabled="!canDeleteTrafficRules"
accent="danger"
icon="action:delete"
@click="deleteTrafficRules()"
>
{{ t('action:delete') }}
<i v-if="deleteTrafficRulesErrorMessage" class="em-dash-prefix">{{ deleteTrafficRulesErrorMessage }}</i>
</MenuItem>
</template>
<script lang="ts" setup>
import { useTrafficRuleDelete } from '@/modules/traffic-rules/composables/use-traffic-rule-delete.composable.ts'
import MenuItem from '@core/components/menu/MenuItem.vue'
import type { TrafficRule } from '@vates/types'
import { useI18n } from 'vue-i18n'
const { rule } = defineProps<{
rule: TrafficRule
}>()
const { t } = useI18n()
const { deleteTrafficRules, canDeleteTrafficRules, isDeletingTrafficRules, deleteTrafficRulesErrorMessage } =
useTrafficRuleDelete(() => [rule])
</script>

View File

@@ -0,0 +1,48 @@
<template>
<MenuItem
:busy="isEditingTrafficRulesOrder"
:disabled="!canEditTrafficRulesOrder"
accent="neutral"
icon="action:edit"
@click="editTrafficRulesOrder()"
>
{{ t('action:edit-rules-order') }}
<i v-if="isXapiPluginRequired" class="em-dash-prefix">
<I18nT keypath="traffic-rules:xapi-plugin-required-with-link" scope="global">
<template #xapi-plugin>
<UiLink :href="XO_LINKS.DOC_SDN_CONTROLLER_XAPI_PLUGIN" size="small">
{{ t('xapi-plugin') }}
</UiLink>
</template>
</I18nT>
</i>
<i v-else-if="editTrafficRulesOrderErrorMessage" class="em-dash-prefix">{{ editTrafficRulesOrderErrorMessage }}</i>
</MenuItem>
</template>
<script lang="ts" setup>
import { useTrafficRuleEditOrder } from '@/modules/traffic-rules/composables/use-traffic-rule-edit-order.composable.ts'
import type { RankedTrafficRule } from '@/modules/traffic-rules/types.ts'
import { XO_LINKS } from '@/shared/constants.ts'
import MenuItem from '@core/components/menu/MenuItem.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { I18nT, useI18n } from 'vue-i18n'
const { rule, rules } = defineProps<{
rule: RankedTrafficRule
rules: RankedTrafficRule[]
}>()
const { t } = useI18n()
const {
editTrafficRulesOrder,
canEditTrafficRulesOrder,
isEditingTrafficRulesOrder,
editTrafficRulesOrderErrorMessage,
isXapiPluginRequired,
} = useTrafficRuleEditOrder(
() => rule.networkId,
() => rules
)
</script>

View File

@@ -0,0 +1,26 @@
<template>
<MenuItem
:busy="isEditingTrafficRule"
:disabled="!canEditTrafficRule"
accent="neutral"
icon="action:edit"
@click="editTrafficRule()"
>
{{ t('action:edit') }}
</MenuItem>
</template>
<script lang="ts" setup>
import { useTrafficRuleEdit } from '@/modules/traffic-rules/composables/use-traffic-rule-edit.composable.ts'
import MenuItem from '@core/components/menu/MenuItem.vue'
import type { TrafficRule } from '@vates/types'
import { useI18n } from 'vue-i18n'
const { rule } = defineProps<{
rule: TrafficRule
}>()
const { t } = useI18n()
const { editTrafficRule, canEditTrafficRule, isEditingTrafficRule } = useTrafficRuleEdit(() => rule)
</script>

View File

@@ -0,0 +1,180 @@
<template>
<UiDrawer
:on-dismiss="() => emit('cancel')"
class="traffic-rule-edit-order-drawer"
@confirm="emit('confirm', orderedRules)"
@dismiss="emit('cancel')"
>
<template #title>{{ t('action:edit-rules-order') }}</template>
<template #content>
<div class="container">
<div class="network typo-body-regular-small">
<span class="label">{{ t('network') }}</span>
<UiLink v-if="network" :to="networkTo" icon="object:network" size="small">
{{ network.name_label }}
</UiLink>
</div>
<UiAlert accent="info">
<p>{{ t('traffic-rules:edit-order-info') }}</p>
<p>{{ t('traffic-rules:edit-order-save-info') }}</p>
</UiAlert>
<VtsTable :pagination-bindings sticky="right">
<thead>
<tr>
<HeadCells />
</tr>
</thead>
<tbody>
<VtsRow v-for="rule of paginatedRules" :key="rule.id">
<BodyCells :item="rule" />
</VtsRow>
</tbody>
</VtsTable>
</div>
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')" />
<VtsOverlayConfirmButton>
{{ t('action:save') }}
</VtsOverlayConfirmButton>
</template>
</UiDrawer>
</template>
<script lang="ts" setup>
import type { FrontXoNetwork } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import { getPoolNetworkRoute } from '@/modules/network/utils/xo-network.util.ts'
import { useDirectionLabels } from '@/modules/traffic-rules/composables/direction-labels.composable.ts'
import { useTrafficRuleTarget } from '@/modules/traffic-rules/composables/traffic-rule-target.composable.ts'
import type { RankedTrafficRule } from '@/modules/traffic-rules/types.ts'
import { getTrafficRuleProtocolLabel } from '@/modules/traffic-rules/utils/xo-traffic-rule.util.ts'
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import VtsRow from '@core/components/table/VtsRow.vue'
import VtsTable from '@core/components/table/VtsTable.vue'
import UiAlert from '@core/components/ui/alert/UiAlert.vue'
import UiDrawer from '@core/components/ui/drawer/UiDrawer.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { usePagination } from '@core/composables/pagination.composable.ts'
import { useTrafficRulesColumns } from '@core/tables/column-sets/traffic-rules-columns.ts'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { network, rules } = defineProps<{
network: FrontXoNetwork | undefined
rules: RankedTrafficRule[]
}>()
const emit = defineEmits<{
cancel: []
confirm: [orderedRules: RankedTrafficRule[]]
}>()
const { t } = useI18n()
const getTarget = useTrafficRuleTarget()
const getDirectionLabels = useDirectionLabels()
const networkTo = computed(() => {
if (network === undefined) {
return undefined
}
return getPoolNetworkRoute(network.$pool, network.id)
})
const orderedRules = ref([...rules])
const {
pageRecords: paginatedRules,
paginationBindings,
seek,
} = usePagination('traffic-rules-order', orderedRules, {
ignoreRoute: true,
})
function getRuleIndex(rule: RankedTrafficRule) {
return orderedRules.value.findIndex(orderedRule => orderedRule.id === rule.id)
}
function moveRule(rule: RankedTrafficRule, offset: -1 | 1) {
const index = getRuleIndex(rule)
const newIndex = index + offset
if (index === -1 || newIndex < 0 || newIndex >= orderedRules.value.length) {
return
}
const newOrderedRules = [...orderedRules.value]
const [movedRule] = newOrderedRules.splice(index, 1)
newOrderedRules.splice(newIndex, 0, movedRule)
orderedRules.value = newOrderedRules
seek(orderedRule => orderedRule.id === rule.id)
}
const { HeadCells, BodyCells } = useTrafficRulesColumns({
exclude: ['network', 'priority', 'actions'],
body: (rule: RankedTrafficRule) => {
const [directionA, directionB] = getDirectionLabels(rule)
return {
rank: r => {
const newRank = getRuleIndex(rule) + 1
return r(newRank, rule.rank - newRank)
},
policy: r => r(t(rule.allow ? 'allow' : 'drop'), rule.allow ? 'success' : 'danger'),
protocol: r => r(getTrafficRuleProtocolLabel(rule)),
directionA: r => r(directionA),
target: r => r(rule.ipRange),
directionB: r => r(directionB),
object: r => r(getTarget(rule)),
moveButtons: r => {
const index = getRuleIndex(rule)
return r([
{
icon: 'action:move-up',
label: t('action:move-up'),
onClick: () => moveRule(rule, -1),
disabled: index === 0,
},
{
icon: 'action:move-down',
label: t('action:move-down'),
onClick: () => moveRule(rule, 1),
disabled: index === orderedRules.value.length - 1,
},
])
},
}
},
})
</script>
<style lang="postcss" scoped>
.traffic-rule-edit-order-drawer {
.container {
display: flex;
flex-direction: column;
gap: 2.4rem;
}
.network {
display: flex;
align-items: center;
gap: 2.4rem;
.label {
color: var(--color-neutral-txt-secondary);
}
}
}
</style>

View File

@@ -29,7 +29,12 @@
<template #option="{ option }">
<VtsOption :option>
<span class="option-content">
<VtsIcon v-if="option.properties.icon" :name="option.properties.icon" size="medium" />
<VtsIcon
v-if="option.properties.icon"
:name="option.properties.icon"
:tooltip="option.properties.iconTooltip"
size="medium"
/>
{{ option.properties.label }}
</span>
</VtsOption>

View File

@@ -19,6 +19,7 @@ import { computed } from 'vue'
export type TrafficRuleFormSelectOption = FormOption<{
icon?: IconName
iconTooltip?: string
status?: Status
}>

View File

@@ -40,7 +40,12 @@
<template #option="{ option }">
<VtsOption :option>
<span class="option-content">
<VtsIcon v-if="option.properties.icon" :name="option.properties.icon" size="medium" />
<VtsIcon
v-if="option.properties.icon"
:name="option.properties.icon"
:tooltip="option.properties.iconTooltip"
size="medium"
/>
{{ option.properties.label }}
<span v-if="option.properties.disabled" class="em-dash-prefix typo-body-regular-small">
{{ t('no-vif-detected') }}

View File

@@ -4,6 +4,10 @@
<TrafficRuleActions :rule />
</template>
<template v-if="rule" #more-actions>
<TrafficRuleMoreActions :rule :rules :show-edit-order="showPriority" />
</template>
<template v-if="rule" #default>
<VtsStateHero v-if="!isReady" format="panel" size="medium" type="busy" />
<template v-else>
@@ -28,6 +32,7 @@
<script lang="ts" setup>
import { useXoNetworkCollection } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import TrafficRuleActions from '@/modules/traffic-rules/components/actions/TrafficRuleActions.vue'
import TrafficRuleMoreActions from '@/modules/traffic-rules/components/actions/TrafficRuleMoreActions.vue'
import TrafficRuleNetworkInfosCard from '@/modules/traffic-rules/components/list/panel/cards/TrafficRuleNetworkInfosCard.vue'
import TrafficRuleNetworkPifsCard from '@/modules/traffic-rules/components/list/panel/cards/TrafficRuleNetworkPifsCard.vue'
import TrafficRulePriorityCard from '@/modules/traffic-rules/components/list/panel/cards/TrafficRulePriorityCard.vue'

View File

@@ -30,7 +30,12 @@
</UiLink>
<div v-if="vm">
{{ t('in') }}
<UiLink size="small" :icon="vmPowerState" :to="{ name: '/vm/[id]/dashboard', params: { id: vm.id } }">
<UiLink
size="small"
:icon="vmPowerState"
:icon-tooltip="vmIconTooltip"
:to="{ name: '/vm/[id]/dashboard', params: { id: vm.id } }"
>
{{ vm.name_label }}
</UiLink>
</div>
@@ -55,7 +60,9 @@ import { useDirectionLabels } from '@/modules/traffic-rules/composables/directio
import { useTrafficRuleTarget } from '@/modules/traffic-rules/composables/traffic-rule-target.composable.ts'
import { getTrafficRuleProtocolLabel } from '@/modules/traffic-rules/utils/xo-traffic-rule.util.ts'
import { useXoVifCollection } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
import { useXoVmOperationLabel } from '@/modules/vm/composables/xo-vm-operation-label.composable.ts'
import { useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { getVmIcon } from '@/modules/vm/utils/xo-vm.util.ts'
import { RULE_STATUS } from '@/shared/constants.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
@@ -64,9 +71,7 @@ import VtsStatus from '@core/components/status/VtsStatus.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
import { objectIcon } from '@core/icons'
import type { TrafficRule } from '@vates/types'
import { toLower } from 'lodash-es'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -98,7 +103,10 @@ const networkTo = computed(() =>
network.value ? getPoolNetworkRoute(network.value.$pool, network.value.id) : undefined
)
const vmPowerState = computed(() => objectIcon('vm', toLower(vm.value?.power_state)))
const { getVmIconTooltip } = useXoVmOperationLabel()
const vmPowerState = computed(() => getVmIcon(vm.value))
const vmIconTooltip = computed(() => getVmIconTooltip(vm.value))
const ruleTypeLabel = computed(() => (rule.type === 'VIF' ? t('vif') : t('network')))

View File

@@ -33,7 +33,12 @@
{{ t('vm') }}
</template>
<template v-if="vm" #value>
<UiLink size="small" :icon="vmStatus" :to="{ name: '/vm/[id]/dashboard', params: { id: vm.id } }">
<UiLink
size="small"
:icon="vmStatus"
:icon-tooltip="vmIconTooltip"
:to="{ name: '/vm/[id]/dashboard', params: { id: vm.id } }"
>
{{ vm.name_label }}
</UiLink>
</template>
@@ -125,7 +130,9 @@ import { getPoolNetworkRoute } from '@/modules/network/utils/xo-network.util.ts'
import { useXoPifCollection } from '@/modules/pif/remote-resources/use-xo-pif-collection.ts'
import { useXoVifUtils } from '@/modules/vif/composables/xo-vif-utils.composable.ts'
import type { FrontXoVif } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
import { useXoVmOperationLabel } from '@/modules/vm/composables/xo-vm-operation-label.composable.ts'
import { useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { getVmIcon } from '@/modules/vm/utils/xo-vm.util.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCodeSnippet from '@core/components/code-snippet/VtsCodeSnippet.vue'
@@ -134,10 +141,8 @@ import VtsStatus from '@core/components/status/VtsStatus.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
import { objectIcon } from '@core/icons'
import { CONNECTION_STATUS } from '@core/types/connection.ts'
import type { TrafficRule } from '@vates/types'
import { toLower } from 'lodash-es'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -157,7 +162,10 @@ const vifStatus = computed(() => (vif.attached ? CONNECTION_STATUS.CONNECTED : C
const vm = useGetVmById(() => vif.$VM)
const vmStatus = computed(() => objectIcon('vm', toLower(vm.value?.power_state)))
const { getVmIconTooltip } = useXoVmOperationLabel()
const vmStatus = computed(() => getVmIcon(vm.value))
const vmIconTooltip = computed(() => getVmIconTooltip(vm.value))
const network = useGetNetworkById(() => vif.$network)

View File

@@ -1,16 +1,18 @@
import { useXoNetworkCollection } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import { getPoolNetworkRoute } from '@/modules/network/utils/xo-network.util.ts'
import { useXoVifCollection } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
import { useXoVmOperationLabel } from '@/modules/vm/composables/xo-vm-operation-label.composable.ts'
import { useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { getVmIcon } from '@/modules/vm/utils/xo-vm.util.ts'
import type { LinkOptions } from '@core/composables/link-component.composable.ts'
import type { IconName } from '@core/icons'
import type { TrafficRule } from '@vates/types'
import { toLower } from 'lodash-es'
import { useI18n } from 'vue-i18n'
type TrafficRuleTargetEntry = {
label: string
icon: IconName
iconTooltip?: string
to: LinkOptions['to']
disabled?: boolean
}
@@ -24,6 +26,7 @@ export function useTrafficRuleTarget() {
const { getVifById } = useXoVifCollection()
const { getVmById } = useXoVmCollection()
const { getNetworkById } = useXoNetworkCollection()
const { getVmIconTooltip } = useXoVmOperationLabel()
return (rule: TrafficRule): TrafficRuleTarget => {
if (rule.type === 'network') {
@@ -47,7 +50,8 @@ export function useTrafficRuleTarget() {
suffix: vm
? {
label: vm.name_label,
icon: `object:vm:${toLower(vm.power_state)}`,
icon: getVmIcon(vm),
iconTooltip: getVmIconTooltip(vm),
to: { name: '/vm/[id]/dashboard', params: { id: vm.id } },
}
: undefined,

View File

@@ -0,0 +1,77 @@
import {
type FrontXoNetwork,
useXoNetworkCollection,
} from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import { useXoPoolCollection } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import { useXoTrafficRuleEditOrderJob } from '@/modules/traffic-rules/jobs/xo-traffic-rule-edit-order.job.ts'
import type { RankedTrafficRule } from '@/modules/traffic-rules/types.ts'
import { getNetworkRules, isNetworkRuleSupported } from '@/modules/traffic-rules/utils/xo-traffic-rule.util.ts'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
import { reactiveComputed } from '@vueuse/core'
import { computed, type MaybeRefOrGetter, ref } from 'vue'
export function useTrafficRuleEditOrder(
rawNetworkId: MaybeRefOrGetter<FrontXoNetwork['id']>,
rawTrafficRules: MaybeRefOrGetter<RankedTrafficRule[]>
) {
const { getNetworkById } = useXoNetworkCollection()
const { getPoolById } = useXoPoolCollection()
const networkId = toComputed(rawNetworkId)
const trafficRules = toComputed(rawTrafficRules)
const network = computed(() => getNetworkById(networkId.value))
const networkRules = computed(() => getNetworkRules(trafficRules.value, networkId.value))
const isXapiPluginRequired = computed(() => {
if (network.value === undefined) {
return false
}
const pool = getPoolById(network.value.$pool)
return !isNetworkRuleSupported(pool)
})
const orderedRules = ref<RankedTrafficRule[] | undefined>(undefined)
const {
run,
canRun: canEditTrafficRulesOrder,
isRunning: isEditingTrafficRulesOrder,
errorMessage: editTrafficRulesOrderErrorMessage,
} = useXoTrafficRuleEditOrderJob(networkRules, orderedRules, network)
const { open } = useOverlay({
component: () => import('@/modules/traffic-rules/components/drawer/TrafficRuleEditOrderDrawer.vue'),
events: {
onConfirm: (newOrderedRules: RankedTrafficRule[]) => {
orderedRules.value = newOrderedRules
run({ detached: true })
},
onCancel: true,
},
})
const props = reactiveComputed(() => ({
network: network.value,
rules: networkRules.value,
}))
function editTrafficRulesOrder() {
return open({ props })
}
return {
editTrafficRulesOrder,
canEditTrafficRulesOrder,
isEditingTrafficRulesOrder,
editTrafficRulesOrderErrorMessage,
isXapiPluginRequired,
}
}

View File

@@ -14,6 +14,7 @@ import {
} from '@/modules/traffic-rules/form/use-traffic-rule-form-base.ts'
import type { TrafficRulePayload } from '@/modules/traffic-rules/jobs/xo-traffic-rule-create.job.ts'
import { type FrontXoVif, useXoVifCollection } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
import { useXoVmOperationLabel } from '@/modules/vm/composables/xo-vm-operation-label.composable.ts'
import { type FrontXoVm, useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
import type { TrafficRule, TrafficRuleTargetType } from '@vates/types'
@@ -28,6 +29,7 @@ export type EditTrafficRuleFormData = BaseTrafficRuleFormData & {
export function useEditTrafficRuleForm(rawRule: MaybeRefOrGetter<TrafficRule>) {
const { t } = useI18n()
const { getVmIconTooltip } = useXoVmOperationLabel()
const rule = toComputed(rawRule)
@@ -66,7 +68,7 @@ export function useEditTrafficRuleForm(rawRule: MaybeRefOrGetter<TrafficRule>) {
const vmOptions = computed(() => {
if (ruleVm.value) {
const hasVifs = ruleVm.value.VIFs.length > 0
return [vmToTargetOption(ruleVm.value, hasVifs)]
return [vmToTargetOption(ruleVm.value, hasVifs, getVmIconTooltip(ruleVm.value))]
}
return []
})
@@ -100,7 +102,11 @@ export function useEditTrafficRuleForm(rawRule: MaybeRefOrGetter<TrafficRule>) {
const { id: vmSelectId } = useFormSelect('vmId', vmOptions, {
required: () => isVifTarget.value,
disabled: () => true,
option: { label: 'label', value: 'value', properties: source => ({ icon: source.icon }) },
option: {
label: 'label',
value: 'value',
properties: source => ({ icon: source.icon, iconTooltip: source.iconTooltip }),
},
})
const { id: targetSelectId } = useFormSelect('targetId', targetOptions, {

View File

@@ -14,6 +14,7 @@ import {
import type { TrafficRulePayload } from '@/modules/traffic-rules/jobs/xo-traffic-rule-create.job.ts'
import { isNetworkRuleSupported } from '@/modules/traffic-rules/utils/xo-traffic-rule.util.ts'
import { type FrontXoVif, useXoVifCollection } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
import { useXoVmOperationLabel } from '@/modules/vm/composables/xo-vm-operation-label.composable.ts'
import { type FrontXoVm, useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import type { IconName } from '@core/icons'
import { type FormValidationConfig, required, requiredIf, withMessage } from '@core/packages/form-validation'
@@ -40,6 +41,7 @@ export function useNewTrafficRuleForm(
rawVifId: MaybeRefOrGetter<FrontXoVif['id'] | undefined>
) {
const { t } = useI18n()
const { getVmIconTooltip } = useXoVmOperationLabel()
const poolId = toComputed(rawPoolId)
const vifId = toComputed(rawVifId)
@@ -121,7 +123,7 @@ export function useNewTrafficRuleForm(
poolVms.value.map(vm => {
const hasVifs = vm.VIFs.length > 0
return vmToTargetOption(vm, hasVifs)
return vmToTargetOption(vm, hasVifs, getVmIconTooltip(vm))
})
)
@@ -174,7 +176,7 @@ export function useNewTrafficRuleForm(
label: 'label',
value: 'value',
disabled: source => source.disabled,
properties: source => ({ icon: source.icon }),
properties: source => ({ icon: source.icon, iconTooltip: source.iconTooltip }),
},
})

View File

@@ -1,8 +1,8 @@
import type { FrontXoNetwork } from '@/modules/network/remote-resources/use-xo-network-collection.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 { type IconName, objectIcon } from '@core/icons'
import { toLower } from 'lodash-es'
import { getVmIcon } from '@/modules/vm/utils/xo-vm.util.ts'
import type { IconName } from '@core/icons'
export type TargetOption = {
id: FrontXoVif['id'] | FrontXoNetwork['id']
@@ -16,6 +16,7 @@ export type VmOption = {
label: string
value: FrontXoVm['id']
icon: IconName
iconTooltip: string
disabled: boolean
}
@@ -37,12 +38,13 @@ export function networkToTargetOption(network: FrontXoNetwork): TargetOption {
}
}
export function vmToTargetOption(vm: FrontXoVm, hasVifs: boolean): VmOption {
export function vmToTargetOption(vm: FrontXoVm, hasVifs: boolean, iconTooltip: string): VmOption {
return {
id: vm.id,
label: vm.name_label,
value: vm.id,
icon: objectIcon('vm', toLower(vm.power_state)),
icon: getVmIcon(vm),
iconTooltip,
disabled: !hasVifs,
}
}

View File

@@ -0,0 +1,7 @@
import { defineJobArg } from '@core/packages/job'
import type { TrafficRule } from '@vates/types'
export const orderedTrafficRulesArg = defineJobArg<TrafficRule[]>({
identify: false,
toArray: false,
})

View File

@@ -0,0 +1,76 @@
import { xoNetworkArg } from '@/modules/network/jobs/xo-network-args.ts'
import type { FrontXoNetwork } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import { useXoPoolCollection } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import type { FrontXoTask } from '@/modules/task/remote-resources/use-xo-task-collection.ts'
import { xoTrafficRulesArg } from '@/modules/traffic-rules/jobs/xo-traffic-rule-args.ts'
import { orderedTrafficRulesArg } from '@/modules/traffic-rules/jobs/xo-traffic-rule-edit-order-args.ts'
import {
getTrafficRulePriorityUpdates,
isNetworkRuleSupported,
} from '@/modules/traffic-rules/utils/xo-traffic-rule.util.ts'
import { useXoTaskUtils } from '@/shared/composables/xo-task-utils.composable.ts'
import { fetchPost } from '@/shared/utils/fetch.util.ts'
import { defineJob, JobError, JobRunningError } from '@core/packages/job'
import type { TrafficRule } from '@vates/types'
import { useI18n } from 'vue-i18n'
const MIN_RULES_TO_EDIT_ORDER = 2
export const useXoTrafficRuleEditOrderJob = defineJob(
'traffic-rule.edit-order',
[xoTrafficRulesArg, orderedTrafficRulesArg, xoNetworkArg],
() => {
const { monitorTask } = useXoTaskUtils()
const { t } = useI18n()
const { getPoolById } = useXoPoolCollection()
return {
async run(networkRules: TrafficRule[], orderedRules: TrafficRule[]) {
const updates = getTrafficRulePriorityUpdates(orderedRules, networkRules)
if (updates === undefined) {
throw new JobError(t('job:traffic-rule-edit-order:no-free-priority'))
}
for (const { rule, priority } of updates) {
const scope = rule.type === 'network' ? 'networks' : 'vifs'
const endpoint = `plugins/sdn-controller/${scope}/${rule.sourceId}/actions/update_traffic_rule`
const { taskId } = await fetchPost<{ taskId: FrontXoTask['id'] }>(endpoint, {
oldRule: {
allow: rule.allow,
direction: rule.direction,
ipRange: rule.ipRange,
protocol: rule.protocol,
port: rule.port !== undefined ? Number(rule.port) : undefined,
priority: rule.priority,
},
newRule: { priority },
})
await monitorTask(taskId)
}
},
validate(isRunning, networkRules, _orderedRules, network: FrontXoNetwork | undefined) {
if (network === undefined) {
throw new JobError(t('job:arg:network-required'))
}
if (isRunning) {
throw new JobRunningError(t('job:edit:in-progress'))
}
if (networkRules.length < MIN_RULES_TO_EDIT_ORDER) {
throw new JobError(t('job:traffic-rule-edit-order:not-enough-rules', { n: MIN_RULES_TO_EDIT_ORDER }))
}
const pool = getPoolById(network.$pool)
if (!isNetworkRuleSupported(pool)) {
throw new JobError(t('traffic-rules:xapi-plugin-required'))
}
},
}
}
)

View File

@@ -0,0 +1,204 @@
import type { FrontXoNetwork } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import type { useXoPoolCollection } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import { useXoTrafficRuleEditOrderJob } from '@/modules/traffic-rules/jobs/xo-traffic-rule-edit-order.job.ts'
import type { useXoTaskUtils } from '@/shared/composables/xo-task-utils.composable.ts'
import type { fetchPost } from '@/shared/utils/fetch.util.ts'
import { t } from '@/test/i18n.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { SDN_CONTROLLER_OF_METHOD_KEY, type TrafficRule, type XoNetwork } from '@vates/types'
const { post, monitorTask, pool } = vi.hoisted(() => ({
post: vi.fn(),
monitorTask: vi.fn(),
pool: { otherConfig: {} as Record<string, string> },
}))
vi.mock(import('@/shared/utils/fetch.util.ts'), () => ({
fetchPost: post as unknown as typeof fetchPost,
}))
// The task collection would open an SSE subscription
vi.mock(import('@/shared/composables/xo-task-utils.composable.ts'), () => ({
useXoTaskUtils: (() => ({ monitorTask })) as unknown as typeof useXoTaskUtils,
}))
vi.mock(import('@/modules/pool/remote-resources/use-xo-pool-collection.ts'), () => ({
useXoPoolCollection: (() => ({
getPoolById: () => pool,
})) as unknown as typeof useXoPoolCollection,
}))
beforeEach(() => {
vi.restoreAllMocks()
post.mockReset()
monitorTask.mockReset()
post.mockImplementation(async (url: string) => ({ taskId: `task-of-${url}` }))
monitorTask.mockResolvedValue(undefined)
pool.otherConfig = { [SDN_CONTROLLER_OF_METHOD_KEY]: 'xapi-plugin' }
})
const NETWORK_ID = 'network-1' as XoNetwork['id']
const NETWORK = { id: NETWORK_ID, $pool: 'pool-1' } as FrontXoNetwork
const ENDPOINT = `plugins/sdn-controller/networks/${NETWORK_ID}/actions/update_traffic_rule`
function createRule(id: string, priority: number | undefined): TrafficRule {
return {
id,
allow: true,
protocol: 'TCP',
ipRange: '10.0.0.0/8',
direction: 'to',
priority,
networkId: NETWORK_ID,
type: 'network',
sourceId: NETWORK_ID,
}
}
function mountEditOrderJob(
networkRules: TrafficRule[],
orderedRules: TrafficRule[] | undefined,
network: FrontXoNetwork | undefined = NETWORK
) {
return mountComposable(() =>
useXoTrafficRuleEditOrderJob(
() => networkRules,
() => orderedRules,
() => network
)
).wrapper.vm
}
function getNewPriorities() {
return post.mock.calls.map(([, body]) => body.newRule.priority)
}
describe('run', () => {
it('updates nothing when the order is unchanged', async () => {
const rules = [createRule('a', 300), createRule('b', 200), createRule('c', 100)]
await mountEditOrderJob(rules, rules).run()
expect(post).not.toHaveBeenCalled()
})
it('only updates the moved rule, with a priority above the rules it now precedes', async () => {
const [firstRule, secondRule, thirdRule] = [createRule('a', 300), createRule('b', 200), createRule('c', 100)]
await mountEditOrderJob([firstRule, secondRule, thirdRule], [thirdRule, firstRule, secondRule]).run()
expect(post.mock.calls).toEqual([
[ENDPOINT, { oldRule: expect.objectContaining({ priority: 100 }), newRule: { priority: expect.any(Number) } }],
])
expect(getNewPriorities()[0]).toBeGreaterThan(300)
})
it('never gives a priority a rule of the network already has', async () => {
const [firstRule, secondRule, thirdRule, fourthRule] = [
createRule('a', 400),
createRule('b', 300),
createRule('c', 200),
createRule('d', 100),
]
await mountEditOrderJob(
[firstRule, secondRule, thirdRule, fourthRule],
[secondRule, fourthRule, firstRule, thirdRule]
).run()
const newPriorities = getNewPriorities()
newPriorities.forEach(priority => expect([400, 300, 200, 100]).not.toContain(priority))
})
it('never gives a priority a rule of the network missing from the order already has', async () => {
const [firstRule, secondRule, thirdRule, fourthRule] = [
createRule('a', 5),
createRule('b', 2),
createRule('c', 0),
createRule('d', 1),
]
// added on the network while the order was being edited
const addedRule = createRule('x', 3)
await mountEditOrderJob(
[firstRule, secondRule, thirdRule, fourthRule, addedRule],
[firstRule, thirdRule, secondRule, fourthRule]
).run()
getNewPriorities().forEach(priority => expect(priority).not.toBe(3))
})
it('renumbers all the rules when there is no room between the kept ones', async () => {
const [firstRule, secondRule] = [createRule('a', 1), createRule('b', 0)]
await mountEditOrderJob([firstRule, secondRule], [secondRule, firstRule]).run()
const [firstPriority, secondPriority] = getNewPriorities()
expect(post.mock.calls.map(([, body]) => body.oldRule.priority)).toEqual([0, 1])
expect(firstPriority).toBeGreaterThan(secondPriority)
})
it('gives a priority to the rules that have none', async () => {
const [firstRule, secondRule] = [createRule('a', undefined), createRule('b', undefined)]
await mountEditOrderJob([firstRule, secondRule], [secondRule, firstRule]).run()
const [firstPriority, secondPriority] = getNewPriorities()
expect(firstPriority).toBeGreaterThan(secondPriority)
})
it('applies the updates one at a time and stops at the first failure', async () => {
const [firstRule, secondRule] = [createRule('a', 1), createRule('b', 0)]
const error = new Error('Update failed')
monitorTask.mockRejectedValueOnce(error)
await expect(mountEditOrderJob([firstRule, secondRule], [secondRule, firstRule]).run()).rejects.toBe(error)
expect(post).toHaveBeenCalledTimes(1)
})
})
describe('validate', () => {
it('can run with at least 2 rules on a network using the XAPI plugin', () => {
const rules = [createRule('a', 200), createRule('b', 100)]
expect(mountEditOrderJob(rules, undefined).canRun).toBe(true)
})
it('cannot run without a network', () => {
const rules = [createRule('a', 200), createRule('b', 100)]
const job = mountComposable(() =>
useXoTrafficRuleEditOrderJob(
() => rules,
() => undefined,
() => undefined
)
).wrapper.vm
expect(job.canRun).toBe(false)
expect(job.errorMessage).toBe(t('job:arg:network-required'))
})
it('cannot run with less than 2 rules', () => {
const job = mountEditOrderJob([createRule('a', 100)], undefined)
expect(job.canRun).toBe(false)
expect(job.errorMessage).toBe(t('job:traffic-rule-edit-order:not-enough-rules', { n: 2 }))
})
it('cannot run without the XAPI plugin', () => {
pool.otherConfig = {}
const job = mountEditOrderJob([createRule('a', 200), createRule('b', 100)], undefined)
expect(job.canRun).toBe(false)
expect(job.errorMessage).toBe(t('traffic-rules:xapi-plugin-required'))
})
})

View File

@@ -6,6 +6,15 @@ import { SDN_CONTROLLER_OF_METHOD_KEY, type TrafficRule } from '@vates/types'
// OpenFlow default, applied to rules stored without a priority
const DEFAULT_TRAFFIC_RULE_PRIORITY = 32768
const MIN_TRAFFIC_RULE_PRIORITY = 0
const MAX_TRAFFIC_RULE_PRIORITY = 65535
type TrafficRulePriorityUpdate = {
rule: TrafficRule
priority: number
}
export function isNetworkRuleSupported(pool: FrontXoPool | undefined): boolean {
const ofMethod = pool?.otherConfig[SDN_CONTROLLER_OF_METHOD_KEY]
@@ -67,6 +76,118 @@ export function rankTrafficRules(
})
}
function getKeptRuleIndexes(orderedRules: TrafficRule[]): number[] {
const chainLengths: number[] = []
const chainPreviousIndexes: (number | undefined)[] = []
let longestChainEndIndex: number | undefined
orderedRules.forEach((rule, index) => {
chainLengths[index] = 0
chainPreviousIndexes[index] = undefined
if (rule.priority === undefined) {
return
}
chainLengths[index] = 1
for (let previousIndex = 0; previousIndex < index; previousIndex++) {
const previousRule = orderedRules[previousIndex]
if (
previousRule.priority !== undefined &&
previousRule.priority > rule.priority &&
chainLengths[previousIndex] + 1 > chainLengths[index]
) {
chainLengths[index] = chainLengths[previousIndex] + 1
chainPreviousIndexes[index] = previousIndex
}
}
if (longestChainEndIndex === undefined || chainLengths[index] > chainLengths[longestChainEndIndex]) {
longestChainEndIndex = index
}
})
const keptIndexes: number[] = []
let index = longestChainEndIndex
while (index !== undefined) {
keptIndexes.unshift(index)
index = chainPreviousIndexes[index]
}
return keptIndexes
}
// The other rules get a priority between those of their kept neighbours, spread out to leave room for later moves.
// No new priority is one a rule of the network already has, so the updates never conflict with the other rules.
function getPriorityUpdatesBetweenKeptRules(
orderedRules: TrafficRule[],
keptIndexes: number[],
takenPriorities: Set<number | undefined>
): TrafficRulePriorityUpdate[] | undefined {
const boundIndexes = [-1, ...keptIndexes, orderedRules.length]
const updates: TrafficRulePriorityUpdate[] = []
for (let intervalIndex = 0; intervalIndex < boundIndexes.length - 1; intervalIndex++) {
const upperIndex = boundIndexes[intervalIndex]
const lowerIndex = boundIndexes[intervalIndex + 1]
const movedRules = orderedRules.slice(upperIndex + 1, lowerIndex)
if (movedRules.length === 0) {
continue
}
let upperPriority = MAX_TRAFFIC_RULE_PRIORITY + 1
if (upperIndex !== -1) {
upperPriority = getTrafficRulePriority(orderedRules[upperIndex])
}
let lowerPriority = MIN_TRAFFIC_RULE_PRIORITY - 1
if (lowerIndex !== orderedRules.length) {
lowerPriority = getTrafficRulePriority(orderedRules[lowerIndex])
}
const freePriorities: number[] = []
for (let priority = upperPriority - 1; priority > lowerPriority; priority--) {
if (!takenPriorities.has(priority)) {
freePriorities.push(priority)
}
}
if (freePriorities.length <= movedRules.length) {
return undefined
}
const step = freePriorities.length / (movedRules.length + 1)
movedRules.forEach((rule, index) => {
updates.push({ rule, priority: freePriorities[Math.floor((index + 1) * step)] })
})
}
return updates
}
export function getTrafficRulePriorityUpdates(
orderedRules: TrafficRule[],
networkRules: TrafficRule[]
): TrafficRulePriorityUpdate[] | undefined {
const takenPriorities = new Set(networkRules.map(rule => rule.priority))
const updates = getPriorityUpdatesBetweenKeptRules(orderedRules, getKeptRuleIndexes(orderedRules), takenPriorities)
if (updates !== undefined) {
return updates
}
return getPriorityUpdatesBetweenKeptRules(orderedRules, [], takenPriorities)
}
export function getOrdinalSuffix(suffixesMessage: string, locale: string, position: number): string | undefined {
const suffixes = new Map<string, string>()

View File

@@ -1,9 +1,9 @@
<template>
<VtsTreeItem :expanded="!branch.isCollapsed" :node-id="branch.data.id" :has-children="branch.hasChildren">
<UiTreeItemLabel icon="object:host" :route="`/host/${branch.data.id}`" @toggle="branch.toggleCollapse()">
<VtsTreeItem :expanded="!branch.isCollapsed" :has-children="branch.hasChildren" :node-id="branch.data.id">
<UiTreeItemLabel :route="`/host/${branch.data.id}`" icon="object:host" @toggle="branch.toggleCollapse()">
{{ branch.data.name_label }}
<template #icon>
<VtsObjectIcon v-tooltip="hostState" type="host" size="medium" :state="hostState" />
<VtsIcon :name="hostIcon" :tooltip="hostIconTooltip" size="medium" />
</template>
<template #addons>
<UiLoader
@@ -18,17 +18,17 @@
v-tooltip="t('running-vm', runningVmsCount)"
:value="runningVmsCount"
accent="brand"
variant="secondary"
size="small"
variant="secondary"
/>
<MenuList placement="bottom-start">
<template #trigger="{ open, isOpen }">
<UiButtonIcon
:aria-label="t('quick-actions')"
:selected="isOpen"
accent="brand"
icon="action:more-actions"
size="small"
:selected="isOpen"
@click="open($event)"
/>
</template>
@@ -43,12 +43,11 @@
import HostMoreActions from '@/modules/host/components/HostMoreActions.vue'
import { useXoHostUtils } from '@/modules/host/composables/xo-host-utils.composable.ts'
import { useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import { getHostState } from '@/modules/host/utils/xo-host.util.ts'
import { getHostIcon, getHostState } from '@/modules/host/utils/xo-host.util.ts'
import type { HostBranch } from '@/modules/treeview/types/tree.type.ts'
import { useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import MenuList from '@core/components/menu/MenuList.vue'
import VtsObjectIcon from '@core/components/object-icon/VtsObjectIcon.vue'
import VtsTreeItem from '@core/components/tree/VtsTreeItem.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import UiCounter from '@core/components/ui/counter/UiCounter.vue'
@@ -67,10 +66,12 @@ const { t } = useI18n()
const { isMasterHost } = useXoHostCollection()
const { runningVmsCountByContainer } = useXoVmCollection()
const { isChangingState, currentOperation } = useXoHostUtils(() => branch.data)
const { getHostStateDisplay, isChangingState, currentOperation, iconTooltip } = useXoHostUtils(() => branch.data)
const isMaster = computed(() => isMasterHost(branch.data.id))
const hostState = computed(() => getHostState(branch.data))
const hostIcon = computed(() => getHostIcon(branch.data))
const hostIconTooltip = computed(() => iconTooltip.value || getHostStateDisplay(getHostState(branch.data)).text)
const runningVmsCount = computed(() => runningVmsCountByContainer.value.get(branch.data.id) ?? 0)
</script>

View File

@@ -3,16 +3,11 @@
<UiTreeItemLabel :route="`/vm/${leaf.data.id}`" no-indent>
{{ leaf.data.name_label }}
<template #icon>
<VtsObjectIcon
v-tooltip="leaf.data.power_state"
:state="leaf.data.power_state.toLocaleLowerCase() as POWER_STATE"
size="medium"
type="vm"
/>
<VtsIcon :name="vmIcon" :tooltip="vmIconTooltip" size="medium" />
</template>
<template #addons>
<UiLoader
v-if="isChangingState"
v-if="hasPendingOperation"
v-tooltip="{
placement: 'top',
content: currentOperation,
@@ -44,14 +39,15 @@
import type { VmLeaf } from '@/modules/treeview/types/tree.type.ts'
import VmActions from '@/modules/vm/components/actions/VmActions.vue'
import { useXoVmUtils } from '@/modules/vm/composables/xo-vm-utils.composable.ts'
import type { POWER_STATE } from '@core/types/power-state.type.ts'
import { getVmIcon } from '@/modules/vm/utils/xo-vm.util.ts'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import MenuList from '@core/components/menu/MenuList.vue'
import VtsObjectIcon from '@core/components/object-icon/VtsObjectIcon.vue'
import VtsTreeItem from '@core/components/tree/VtsTreeItem.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import UiLoader from '@core/components/ui/loader/UiLoader.vue'
import UiTreeItemLabel from '@core/components/ui/tree-item-label/UiTreeItemLabel.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { leaf } = defineProps<{
@@ -60,5 +56,9 @@ const { leaf } = defineProps<{
const { t } = useI18n()
const { isChangingState, currentOperation } = useXoVmUtils(() => leaf.data)
const { powerState, hasPendingOperation, currentOperation, iconTooltip } = useXoVmUtils(() => leaf.data)
const vmIcon = computed(() => getVmIcon(leaf.data))
const vmIconTooltip = computed(() => iconTooltip.value || powerState.value.text)
</script>

View File

@@ -2,7 +2,7 @@
<div class="breadcrumb-container">
<UiBreadcrumb v-if="fromContext === VDI_PAGE_CONTEXT.VM && vm && vdi" :size>
<UiLink :size :to="{ name: '/vm/[id]/dashboard', params: { id: vm.id } }">
<VtsObjectIcon type="vm" :state="toLower(vm.power_state)" size="current" />
<VtsIcon :name="vmIcon" :tooltip="vmIconTooltip" size="current" />
{{ vm.name_label }}
</UiLink>
<UiLink :size :to="{ name: '/vm/[id]/vdis', params: { id: vm.id } }">
@@ -28,7 +28,7 @@
<UiBreadcrumb v-else-if="fromContext === VDI_PAGE_CONTEXT.SNAPSHOT && vm && vdi" :size>
<UiLink :size :to="{ name: '/vm/[id]/dashboard', params: { id: vm.id } }">
<VtsObjectIcon type="vm" :state="toLower(vm.power_state)" size="current" />
<VtsIcon :name="vmIcon" :tooltip="vmIconTooltip" size="current" />
{{ vm.name_label }}
</UiLink>
<UiLink :size :to="{ name: '/vm/[id]/snapshots', params: { id: vm.id } }">
@@ -66,14 +66,14 @@ import { useXoPoolCollection } from '@/modules/pool/remote-resources/use-xo-pool
import type { FrontXoSr } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import type { FrontXoVdi } from '@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'
import type { FrontXoVdiSnapshot } from '@/modules/vdi/remote-resources/use-xo-vdi-snapshot-collection.ts'
import { useXoVmOperationLabel } from '@/modules/vm/composables/xo-vm-operation-label.composable.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.js'
import { getVmIcon } from '@/modules/vm/utils/xo-vm.util.ts'
import { VDI_PAGE_CONTEXT, type VdiPageContext } from '@/shared/constants.ts'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import UiBreadcrumb from '@core/components/ui/breadcrumb/UiBreadcrumb.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { useUiStore } from '@core/stores/ui.store.ts'
import VtsObjectIcon from '@xen-orchestra/web-core/components/object-icon/VtsObjectIcon.vue'
import { toLower } from 'lodash-es'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -85,6 +85,11 @@ const { vm, sr, vdi, vdiSnapshot, fromContext } = defineProps<{
fromContext?: VdiPageContext
}>()
const { getVmIconTooltip } = useXoVmOperationLabel()
const vmIcon = computed(() => getVmIcon(vm))
const vmIconTooltip = computed(() => getVmIconTooltip(vm))
const uiStore = useUiStore()
const { t } = useI18n()

View File

@@ -2,7 +2,7 @@
<div class="breadcrumb-container">
<UiBreadcrumb :size>
<UiLink v-if="vm" :size :to="{ name: '/vm/[id]/dashboard', params: { id: vm.id } }">
<VtsObjectIcon type="vm" :state="vmPowerState" size="current" />
<VtsIcon :name="vmIcon" :tooltip="vmIconTooltip" size="current" />
{{ vm.name_label }}
</UiLink>
<UiLink v-if="vm && network" :size :to="{ name: '/vm/[id]/networks', params: { id: vm.id } }">
@@ -34,16 +34,16 @@
<script setup lang="ts">
import { useXoNetworkCollection } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import type { FrontXoVif } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
import { useXoVmOperationLabel } from '@/modules/vm/composables/xo-vm-operation-label.composable.ts'
import { useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { getVmIcon } from '@/modules/vm/utils/xo-vm.util.ts'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import VtsObjectIcon from '@core/components/object-icon/VtsObjectIcon.vue'
import TabList from '@core/components/tab-list/TabList.vue'
import UiBreadcrumb from '@core/components/ui/breadcrumb/UiBreadcrumb.vue'
import UiHeadBar from '@core/components/ui/head-bar/UiHeadBar.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import UiTabItem from '@core/components/ui/tab-item/UiTabItem.vue'
import { useUiStore } from '@core/stores/ui.store.ts'
import { toLower } from 'lodash-es'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -61,7 +61,10 @@ const { useGetNetworkById } = useXoNetworkCollection()
const vm = useGetVmById(() => vif.$VM)
const network = useGetNetworkById(() => vif.$network)
const vmPowerState = computed(() => toLower(vm.value?.power_state))
const { getVmIconTooltip } = useXoVmOperationLabel()
const vmIcon = computed(() => getVmIcon(vm.value))
const vmIconTooltip = computed(() => getVmIconTooltip(vm.value))
</script>
<style lang="postcss" scoped>

View File

@@ -3,11 +3,14 @@ import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collecti
import type { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import { createTestRouter } from '@/test/create-test-router.ts'
import { createVm } from '@/test/create-vm.ts'
import { findTooltipContent } from '@/test/find-tooltip-content.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import VtsObjectIcon from '@core/components/object-icon/VtsObjectIcon.vue'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import UiHeadBar from '@core/components/ui/head-bar/UiHeadBar.vue'
import { objectIcon } from '@core/icons'
import { VM_OPERATIONS, VM_POWER_STATE } from '@vates/types'
import { mount, type DOMWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
const { buildXo5Route } = vi.hoisted(() => ({
buildXo5Route: vi.fn(),
@@ -39,20 +42,8 @@ function findTabs(wrapper: Awaited<ReturnType<typeof mountHeader>>) {
return wrapper.findAll('.ui-tab-item')
}
/**
* An icon renders as bare `<svg>` paths, so the only way to name the one a
* component picked is to compare it with a reference render of the icon it was
* meant to pick — the rendering counterpart of asserting `objectIcon(…)`.
*/
function findIconPaths(wrapper: { findAll: (selector: string) => DOMWrapper<Element>[] }) {
return wrapper.findAll('.icon-path').map(path => path.attributes('d'))
}
function mountVmStateIcon(state: 'running' | 'paused' | 'halted' | 'suspended') {
return mount(VtsObjectIcon, {
props: { type: 'vm' as const, state, size: 'medium' as const },
global: createGlobalTestConfig(),
})
function findHeadBarIcon(wrapper: Awaited<ReturnType<typeof mountHeader>>) {
return wrapper.getComponent(UiHeadBar).getComponent(VtsIcon).props('name')
}
it('shows the name of the VM', async () => {
@@ -137,16 +128,36 @@ it('offers the state-change and more-actions menus', async () => {
it('shows the icon matching the power state of the VM', async () => {
const wrapper = await mountHeader(createVm({ power_state: VM_POWER_STATE.PAUSED, current_operations: {} }))
expect(findIconPaths(wrapper.get('.ui-head-bar .label-wrapper'))).toEqual(findIconPaths(mountVmStateIcon('paused')))
expect(findIconPaths(wrapper.get('.ui-head-bar .label-wrapper'))).not.toEqual(
findIconPaths(mountVmStateIcon('running'))
)
expect(wrapper.find('.ui-head-bar .label-wrapper .ui-loader').exists()).toBe(false)
expect(findHeadBarIcon(wrapper)).toBe(objectIcon('vm', 'paused'))
expect(wrapper.find('.ui-head-bar .ui-loader').exists()).toBe(false)
})
it('replaces the power state icon with a loader while the VM is changing state', async () => {
it('shows the busy icon while the VM is changing state', async () => {
const vm = createVm({ current_operations: { 'task-1': VM_OPERATIONS.CLEAN_REBOOT } })
const wrapper = await mountHeader(vm)
expect(wrapper.find('.ui-head-bar .label-wrapper .ui-loader').exists()).toBe(true)
expect(findHeadBarIcon(wrapper)).toBe(objectIcon('vm', 'busy'))
expect(wrapper.find('.ui-head-bar .status .ui-loader').exists()).toBe(true)
})
it('shows the pending operation in the tooltip of the busy icon', async () => {
const wrapper = await mountHeader(createVm({ current_operations: { 'task-1': VM_OPERATIONS.CLEAN_REBOOT } }))
expect(await findTooltipContent(wrapper.getComponent(UiHeadBar).getComponent(VtsIcon).element)).toBe(
t('operation-in-progress', { operation: t('operation:clean-reboot') })
)
})
it('shows no tooltip on the icon while another operation is pending', async () => {
const wrapper = await mountHeader(createVm({ current_operations: { 'task-1': VM_OPERATIONS.SNAPSHOT } }))
expect(await findTooltipContent(wrapper.getComponent(UiHeadBar).getComponent(VtsIcon).element)).toBeUndefined()
})
it('keeps the power state icon and shows a loader next to the title while another operation is pending', async () => {
const vm = createVm({ power_state: VM_POWER_STATE.RUNNING, current_operations: { 'task-1': VM_OPERATIONS.SNAPSHOT } })
const wrapper = await mountHeader(vm)
expect(findHeadBarIcon(wrapper)).toBe(objectIcon('vm', 'running'))
expect(wrapper.find('.ui-head-bar .status .ui-loader').exists()).toBe(true)
})

View File

@@ -2,12 +2,14 @@
<UiHeadBar>
{{ vm.name_label }}
<template #icon>
<VtsObjectIcon
size="medium"
:state="toLower(vm.power_state)"
type="vm"
:busy="isChangingState"
:busy-tooltip="currentOperation"
<VtsIcon :name="vmIcon" :tooltip="iconTooltip" size="medium" />
</template>
<template v-if="hasPendingOperation" #status>
<UiLoader
v-tooltip="{
placement: 'top',
content: currentOperation,
}"
/>
</template>
<template #actions>
@@ -91,17 +93,18 @@ import VmMoreActions from '@/modules/vm/components/actions/VmMoreActions.vue'
import VmPowerStateActions from '@/modules/vm/components/actions/VmPowerStateActions.vue'
import { useXoVmUtils } from '@/modules/vm/composables/xo-vm-utils.composable.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { getVmIcon } from '@/modules/vm/utils/xo-vm.util.ts'
import { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import MenuList from '@core/components/menu/MenuList.vue'
import VtsObjectIcon from '@core/components/object-icon/VtsObjectIcon.vue'
import TabList from '@core/components/tab-list/TabList.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import UiDropdownButton from '@core/components/ui/dropdown-button/UiDropdownButton.vue'
import UiHeadBar from '@core/components/ui/head-bar/UiHeadBar.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import UiLoader from '@core/components/ui/loader/UiLoader.vue'
import UiTabItem from '@core/components/ui/tab-item/UiTabItem.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { toLower } from 'lodash-es'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -114,5 +117,7 @@ const { buildXo5Route } = useXoRoutes()
const xo5VmGeneralHref = computed(() => buildXo5Route(`/vms/${vm.id}/general`))
const xo5VmStatsHref = computed(() => buildXo5Route(`/vms/${vm.id}/stats`))
const { isChangingState, currentOperation } = useXoVmUtils(() => vm)
const { hasPendingOperation, currentOperation, iconTooltip } = useXoVmUtils(() => vm)
const vmIcon = computed(() => getVmIcon(vm))
</script>

View File

@@ -1,16 +1,16 @@
<template>
<div class="wrapper">
<VtsObjectIcon type="host" :state="hostState" size="medium" />
<VtsIcon :name="hostIcon" :tooltip="hostIconTooltip" size="medium" />
{{ host.name_label }}
<VtsIcon v-if="isMasterHost(host.id)" name="status:primary-circle" size="medium" />
</div>
</template>
<script setup lang="ts">
import { useXoHostOperationLabel } from '@/modules/host/composables/xo-host-operation-label.composable.ts'
import { type FrontXoHost, useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import { getHostState } from '@/modules/host/utils/xo-host.util.ts'
import { getHostIcon } from '@/modules/host/utils/xo-host.util.ts'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import VtsObjectIcon from '@core/components/object-icon/VtsObjectIcon.vue'
import { computed } from 'vue'
const { host } = defineProps<{
@@ -18,7 +18,10 @@ const { host } = defineProps<{
}>()
const { isMasterHost } = useXoHostCollection()
const hostState = computed(() => getHostState(host))
const { getHostIconTooltip } = useXoHostOperationLabel()
const hostIcon = computed(() => getHostIcon(host))
const hostIconTooltip = computed(() => getHostIconTooltip(host))
</script>
<style scoped lang="postcss">

View File

@@ -13,7 +13,12 @@
<VtsTabularKeyValueList v-else full-height>
<VtsTabularKeyValueRow :label="t('vm')">
<template v-if="vm" #value>
<UiLink size="medium" :icon="vmPowerStateIcon" :to="{ name: '/vm/[id]/dashboard', params: { id: vm.id } }">
<UiLink
size="medium"
:icon="vmPowerStateIcon"
:icon-tooltip="vmIconTooltip"
:to="{ name: '/vm/[id]/dashboard', params: { id: vm.id } }"
>
{{ vm.name_label }}
</UiLink>
</template>
@@ -38,17 +43,17 @@
<script setup lang="ts">
import { useXoSrUtils } from '@/modules/storage-repository/composables/xo-sr-utils.composable.ts'
import { useXoSrCollection } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import { useXoVmOperationLabel } from '@/modules/vm/composables/xo-vm-operation-label.composable.ts'
import { useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import type { XoVmDashboard } from '@/modules/vm/types/vm-dashboard.type.ts'
import { getVmIcon } from '@/modules/vm/utils/xo-vm.util.ts'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import VtsTabularKeyValueList from '@core/components/tabular-key-value-list/VtsTabularKeyValueList.vue'
import VtsTabularKeyValueRow from '@core/components/tabular-key-value-row/VtsTabularKeyValueRow.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { objectIcon } from '@core/icons'
import { formatDateTime } from '@core/utils/time.util.ts'
import { toLower } from 'lodash-es'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -72,7 +77,10 @@ const isEmpty = computed(() => replication.value !== undefined && Object.keys(re
const vm = computed(() => getVmById(replication.value?.id))
const vmPowerStateIcon = computed(() => objectIcon('vm', toLower(vm.value?.power_state)))
const { getVmIconTooltip } = useXoVmOperationLabel()
const vmPowerStateIcon = computed(() => getVmIcon(vm.value))
const vmIconTooltip = computed(() => getVmIconTooltip(vm.value))
const storageRepository = computed(() => getSrById(replication.value?.sr))

View File

@@ -4,7 +4,12 @@
<template #default="{ option }">
<VtsOption :option>
<span class="select-option">
<VtsIcon v-if="option.properties.icon" :name="option.properties.icon" size="medium" />
<VtsIcon
v-if="option.properties.icon"
:name="option.properties.icon"
:tooltip="option.properties.iconTooltip"
size="medium"
/>
{{ option.properties.label }}
</span>
</VtsOption>
@@ -25,7 +30,7 @@ import VtsSelect from '@core/components/select/VtsSelect.vue'
import { computed } from 'vue'
const { error, info } = defineProps<{
id: FormSelectId<{ icon?: IconName; accent?: DropdownAccent }>
id: FormSelectId<{ icon?: IconName; iconTooltip?: string; accent?: DropdownAccent }>
label: string
error?: InputWrapperMessage
info?: InputWrapperMessage

View File

@@ -66,7 +66,8 @@ const { HeadCells, BodyCells } = useVmColumns({
exclude: ['checkbox'],
body: (vm: VmDisplayData) => {
return {
vm: r => r({ label: vm.name_label, to: `/vm/${vm.id}/dashboard`, icon: vm.vmIcon }),
vm: r =>
r({ label: vm.name_label, to: `/vm/${vm.id}/dashboard`, icon: vm.vmIcon, iconTooltip: vm.vmIconTooltip }),
ipAddresses: r => r(vm.ipAddresses),
vcpus: r => r(vm.CPUs.number),
ram: r => r(vm.formattedRam.value, vm.formattedRam.prefix),

View File

@@ -12,10 +12,14 @@ import { createHost } from '@/test/create-host.ts'
import { createPool } from '@/test/create-pool.ts'
import { createVm } from '@/test/create-vm.ts'
import { findCardLabelledValues, findTags } from '@/test/find-rendered-values.ts'
import { findTooltipContent } from '@/test/find-tooltip-content.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
import { objectIcon } from '@core/icons'
import type { XoUser } from '@vates/types'
import { HOST_POWER_STATE, VM_POWER_STATE } from '@vates/types'
import { HOST_POWER_STATE, VM_OPERATIONS, VM_POWER_STATE } from '@vates/types'
import { mount } from '@vue/test-utils'
import { computed } from 'vue'
@@ -93,6 +97,22 @@ it('renders the name and the id of the VM as the card title', () => {
expect(wrapper.get('.ui-panel-card-title').text()).toContain('vm-42')
})
it('shows the busy icon in the card title while the VM power state is changing', () => {
const wrapper = mountInfoCard(
createVm({ power_state: VM_POWER_STATE.RUNNING, current_operations: { 'task-1': VM_OPERATIONS.CLEAN_REBOOT } })
)
expect(wrapper.getComponent(UiPanelCardTitle).getComponent(VtsIcon).props('name')).toBe(objectIcon('vm', 'busy'))
})
it('shows the pending operation in the tooltip of the busy icon of the card title', async () => {
const wrapper = mountInfoCard(createVm({ current_operations: { 'task-1': VM_OPERATIONS.CLEAN_REBOOT } }))
expect(await findTooltipContent(wrapper.getComponent(UiPanelCardTitle).getComponent(VtsIcon).element)).toBe(
t('operation-in-progress', { operation: t('operation:clean-reboot') })
)
})
it('lists every row of the card, in order', () => {
const wrapper = mountInfoCard()

View File

@@ -5,7 +5,8 @@
size="medium"
:label="vm.name_label"
:to="{ name: '/vm/[id]/dashboard', params: { id: vm.id } }"
:icon="`object:vm:${toLower(vm.power_state)}`"
:icon="vmIcon"
:icon-tooltip="iconTooltip"
/>
<VtsCardRowList class="content">
<VtsCardRowKeyValue>
@@ -128,6 +129,7 @@ import VmGuestToolsStatus from '@/modules/vm/components/VmGuestToolsStatus.vue'
import { useXoVmUtils } from '@/modules/vm/composables/xo-vm-utils.composable.ts'
import { type FrontXoVm, useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { useXoVmTemplateCollection } from '@/modules/vm/remote-resources/use-xo-vm-template-collection.ts'
import { getVmIcon } from '@/modules/vm/utils/xo-vm.util.ts'
import { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
@@ -139,7 +141,6 @@ import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
import UiTagsList from '@core/components/ui/tag/UiTagsList.vue'
import UiUserLogo from '@core/components/ui/user-logo/UiUserLogo.vue'
import { toLower } from 'lodash-es'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -174,7 +175,9 @@ const pool = useGetPoolById(() => vm.$pool)
const host = computed(() => getVmHost(vm))
const { powerState, installDateFormatted, relativeStartTime, guestToolsDisplay } = useXoVmUtils(() => vm)
const { powerState, installDateFormatted, relativeStartTime, guestToolsDisplay, iconTooltip } = useXoVmUtils(() => vm)
const vmIcon = computed(() => getVmIcon(vm))
</script>
<style scoped lang="postcss">

View File

@@ -1,12 +1,12 @@
import { useXoVbdCollection } from '@/modules/vbd/remote-resources/use-xo-vbd-collection.ts'
import { useXoVdiCollection } from '@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'
import { useXoVmOperationLabel } from '@/modules/vm/composables/xo-vm-operation-label.composable.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { getVmIpAddresses } from '@/modules/vm/utils/xo-vm.util.ts'
import { objectIcon } from '@core/icons'
import { getVmIcon, getVmIpAddresses } from '@/modules/vm/utils/xo-vm.util.ts'
import type { IconName } from '@core/icons'
import { formatSizeRaw, type SizeInfo } from '@core/utils/size.util.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
import type { XoVdi } from '@vates/types'
import { toLower } from 'lodash-es'
import { computed, type MaybeRefOrGetter } from 'vue'
export interface VmFilterableData extends FrontXoVm {
@@ -20,13 +20,15 @@ export interface VmDisplayData extends VmFilterableData {
// Formatted values for UI display
formattedRam: SizeInfo
formattedDiskSpace: SizeInfo
vmIcon: ReturnType<typeof objectIcon>
vmIcon: IconName
vmIconTooltip: string
}
export function useVmEnhancedData(rawVms: MaybeRefOrGetter<FrontXoVm[]>) {
const vms = toComputed(rawVms)
const { getVbdsByIds } = useXoVbdCollection()
const { getVdiById } = useXoVdiCollection()
const { getVmIconTooltip } = useXoVmOperationLabel()
/**
* Calculate raw disk space in bytes
@@ -59,13 +61,15 @@ export function useVmEnhancedData(rawVms: MaybeRefOrGetter<FrontXoVm[]>) {
const getDisplayData = (vm: VmFilterableData): VmDisplayData => {
const formattedRam = formatSizeRaw(vm.ramSize, 1)
const formattedDiskSpace = formatSizeRaw(vm.diskSpaceSize, 1)
const vmIcon = objectIcon('vm', toLower(vm.power_state))
const vmIcon = getVmIcon(vm)
const vmIconTooltip = getVmIconTooltip(vm)
return {
...vm,
formattedRam,
formattedDiskSpace,
vmIcon,
vmIconTooltip,
}
}

View File

@@ -7,7 +7,7 @@ import { createVdi } from '@/test/create-vdi.ts'
import { createVm } from '@/test/create-vm.ts'
import { objectIcon } from '@core/icons'
import { formatSizeRaw } from '@core/utils/size.util.ts'
import { VM_POWER_STATE } from '@vates/types'
import { VM_OPERATIONS, VM_POWER_STATE } from '@vates/types'
import { ref } from 'vue'
const { getVbdsByIds } = vi.hoisted(() => ({
@@ -120,4 +120,12 @@ describe('getDisplayData', () => {
expect(displayData.vmIcon).toBe(objectIcon('vm', 'halted'))
})
it('maps the VM icon to the busy state while the VM power state is changing', () => {
const displayData = mountFirstDisplayData([
createVm({ power_state: VM_POWER_STATE.RUNNING, current_operations: { 'task-1': VM_OPERATIONS.CLEAN_REBOOT } }),
])
expect(displayData.vmIcon).toBe(objectIcon('vm', 'busy'))
})
})

View File

@@ -0,0 +1,40 @@
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { getVmPendingPowerStateOperation } from '@/modules/vm/utils/xo-vm.util.ts'
import { useOperationInProgressLabel } from '@/shared/composables/operation-in-progress-label.composable.ts'
import { useI18n } from 'vue-i18n'
export function useXoVmOperationLabel() {
const { t } = useI18n()
const getVmOperationLabel = useOperationInProgressLabel(() => ({
start: t('operation:start'),
start_on: t('operation:start-on-host'),
pause: t('operation:pause'),
unpause: t('operation:unpause'),
suspend: t('operation:suspend'),
resume: t('operation:resume'),
clean_reboot: t('operation:clean-reboot'),
hard_reboot: t('operation:force-reboot'),
shutdown: t('operation:shutdown'),
clean_shutdown: t('operation:clean-shutdown'),
hard_shutdown: t('operation:force-shutdown'),
destroy: t('operation:destroy'),
snapshot: t('operation:snapshot'),
clone: t('operation:duplicate'),
copy: t('operation:duplicate'),
export: t('operation:export'),
import: t('operation:import'),
migrate_send: t('operation:migrate'),
pool_migrate: t('operation:migrate'),
}))
const getVmIconTooltip = (vm: FrontXoVm | undefined) => {
if (vm === undefined) {
return ''
}
return getVmOperationLabel(getVmPendingPowerStateOperation(vm))
}
return { getVmOperationLabel, getVmIconTooltip }
}

View File

@@ -0,0 +1,43 @@
import { useXoVmOperationLabel } from '@/modules/vm/composables/xo-vm-operation-label.composable.ts'
import { createVm } from '@/test/create-vm.ts'
import { t } from '@/test/i18n.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { VM_OPERATIONS } from '@vates/types'
function mountVmOperationLabel() {
return mountComposable(() => useXoVmOperationLabel()).wrapper.vm
}
describe('getVmOperationLabel', () => {
it('maps a VM operation to its in-progress label', () => {
expect(mountVmOperationLabel().getVmOperationLabel(VM_OPERATIONS.SNAPSHOT)).toBe(
t('operation-in-progress', { operation: t('operation:snapshot') })
)
})
it('returns an empty label for an unknown operation', () => {
expect(mountVmOperationLabel().getVmOperationLabel('some_unknown_op')).toBe('')
})
})
describe('getVmIconTooltip', () => {
it('shows the operation changing the power state, even when another operation is listed first', () => {
const vm = createVm({
current_operations: { 'task-1': VM_OPERATIONS.SNAPSHOT, 'task-2': VM_OPERATIONS.HARD_REBOOT },
})
expect(mountVmOperationLabel().getVmIconTooltip(vm)).toBe(
t('operation-in-progress', { operation: t('operation:force-reboot') })
)
})
it('returns an empty tooltip while no operation changes the power state', () => {
const vm = createVm({ current_operations: { 'task-1': VM_OPERATIONS.SNAPSHOT } })
expect(mountVmOperationLabel().getVmIconTooltip(vm)).toBe('')
})
it('returns an empty tooltip when no VM is provided', () => {
expect(mountVmOperationLabel().getVmIconTooltip(undefined)).toBe('')
})
})

View File

@@ -1,5 +1,6 @@
import { useXoVmOperationLabel } from '@/modules/vm/composables/xo-vm-operation-label.composable.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { CHANGING_STATE_OPERATIONS, isVmOperationPending } from '@/modules/vm/utils/xo-vm.util.ts'
import { TRACKED_OPERATIONS, isVmOperationPending } from '@/modules/vm/utils/xo-vm.util.ts'
import { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import type { IconName } from '@core/icons'
import { useTimeAgo } from '@core/composables/locale-time-ago.composable.ts'
@@ -28,12 +29,12 @@ export function useXoVmUtils(rawVm: MaybeRefOrGetter<FrontXoVm>) {
const powerState = useMapper<VM_POWER_STATE, { icon: IconName; text: string }>(
() => vm.value.power_state,
{
() => ({
[VM_POWER_STATE.RUNNING]: { icon: 'status:running-circle', text: t('vm:status:running') },
[VM_POWER_STATE.HALTED]: { icon: 'status:halted-circle', text: t('vm:status:halted') },
[VM_POWER_STATE.PAUSED]: { icon: 'status:paused-circle', text: t('vm:status:paused') },
[VM_POWER_STATE.SUSPENDED]: { icon: 'status:suspended-circle', text: t('vm:status:suspended') },
},
}),
VM_POWER_STATE.RUNNING
)
@@ -99,34 +100,13 @@ export function useXoVmUtils(rawVm: MaybeRefOrGetter<FrontXoVm>) {
} as const
})
const isChangingState = computed(() => isVmOperationPending(vm.value, CHANGING_STATE_OPERATIONS))
const hasPendingOperation = computed(() => isVmOperationPending(vm.value, TRACKED_OPERATIONS))
const currentOperation = useMapper<string, string>(
() => Object.values(vm.value.current_operations)[0],
{
start: t('operation:start'),
start_on: t('operation:start-on-host'),
pause: t('operation:pause'),
unpause: t('operation:unpause'),
suspend: t('operation:suspend'),
resume: t('operation:resume'),
clean_reboot: t('operation:clean-reboot'),
hard_reboot: t('operation:force-reboot'),
shutdown: t('operation:shutdown'),
clean_shutdown: t('operation:clean-shutdown'),
hard_shutdown: t('operation:force-shutdown'),
destroy: t('operation:destroy'),
snapshot: t('operation:snapshot'),
clone: t('operation:duplicate'),
copy: t('operation:duplicate'),
export: t('operation:export'),
import: t('operation:import'),
migrate_send: t('operation:migrate'),
pool_migrate: t('operation:migrate'),
unknown: '',
},
'unknown'
)
const { getVmOperationLabel, getVmIconTooltip } = useXoVmOperationLabel()
const currentOperation = computed(() => getVmOperationLabel(Object.values(vm.value.current_operations)[0]))
const iconTooltip = computed(() => getVmIconTooltip(vm.value))
const { buildXo5Route } = useXoRoutes()
@@ -138,8 +118,9 @@ export function useXoVmUtils(rawVm: MaybeRefOrGetter<FrontXoVm>) {
relativeStartTime,
hasGuestTools,
guestToolsDisplay,
isChangingState,
hasPendingOperation,
currentOperation,
iconTooltip,
xo5VmAdvancedHref,
}
}

View File

@@ -201,17 +201,17 @@ describe('guestToolsDisplay', () => {
})
})
describe('isChangingState', () => {
it('is false when no state-changing operation is pending', () => {
describe('hasPendingOperation', () => {
it('is false when no tracked operation is pending', () => {
const result = mountVmUtils({ current_operations: {} })
expect(result.isChangingState).toBe(false)
expect(result.hasPendingOperation).toBe(false)
})
it('is true while a state-changing operation is pending', () => {
it('is true while a tracked operation is pending', () => {
const result = mountVmUtils({ current_operations: { task1: VM_OPERATIONS.CLEAN_REBOOT } })
expect(result.isChangingState).toBe(true)
expect(result.hasPendingOperation).toBe(true)
})
})
@@ -219,7 +219,9 @@ describe('currentOperation', () => {
it('maps the pending operation to a translated label', () => {
const result = mountVmUtils({ current_operations: { task1: VM_OPERATIONS.SNAPSHOT } })
expect(result.currentOperation).toBe(result.t('operation:snapshot'))
expect(result.currentOperation).toBe(
result.t('operation-in-progress', { operation: result.t('operation:snapshot') })
)
})
it('falls back to an empty label for an unknown operation', () => {
@@ -235,6 +237,22 @@ describe('currentOperation', () => {
})
})
describe('iconTooltip', () => {
it('shows the operation changing the power state', () => {
const result = mountVmUtils({ current_operations: { task1: VM_OPERATIONS.CLEAN_REBOOT } })
expect(result.iconTooltip).toBe(
result.t('operation-in-progress', { operation: result.t('operation:clean-reboot') })
)
})
it('is empty while no operation changes the power state', () => {
const result = mountVmUtils({ current_operations: { task1: VM_OPERATIONS.SNAPSHOT } })
expect(result.iconTooltip).toBe('')
})
})
describe('xo5VmAdvancedHref', () => {
it('builds the XO5 advanced route for the VM id', () => {
buildXo5Route.mockImplementation((path: string) => `https://xo5.example.com/#${path}`)

View File

@@ -1,3 +1,4 @@
import { useXoHostOperationLabel } from '@/modules/host/composables/xo-host-operation-label.composable.ts'
import { type FrontXoHost, useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import { getHostIcon } from '@/modules/host/utils/xo-host.util.ts'
import {
@@ -35,6 +36,7 @@ export type VmMigratePayload = {
export function useVmMigrateForm(rawVm: MaybeRefOrGetter<FrontXoVm>) {
const { t } = useI18n()
const { getHostIconTooltip } = useXoHostOperationLabel()
const vm = toComputed(rawVm)
@@ -175,7 +177,7 @@ export function useVmMigrateForm(rawVm: MaybeRefOrGetter<FrontXoVm>) {
option: {
label: 'name_label',
value: 'id',
properties: host => ({ icon: getHostIcon(host) }),
properties: host => ({ icon: getHostIcon(host), iconTooltip: getHostIconTooltip(host) }),
},
})

View File

@@ -1,9 +1,10 @@
import type { FrontXoHost } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { objectIcon } from '@core/icons'
import { COMMON_TAG, VM_OPERATIONS, VM_POWER_STATE } from '@vates/types'
import { castArray } from 'lodash-es'
import { castArray, toLower } from 'lodash-es'
export const CHANGING_STATE_OPERATIONS: Partial<VM_OPERATIONS>[] = [
const CHANGING_POWER_STATE_OPERATIONS: Partial<VM_OPERATIONS>[] = [
VM_OPERATIONS.START,
VM_OPERATIONS.START_ON,
VM_OPERATIONS.PAUSE,
@@ -15,6 +16,10 @@ export const CHANGING_STATE_OPERATIONS: Partial<VM_OPERATIONS>[] = [
VM_OPERATIONS.SHUTDOWN,
VM_OPERATIONS.CLEAN_SHUTDOWN,
VM_OPERATIONS.HARD_SHUTDOWN,
]
export const TRACKED_OPERATIONS: Partial<VM_OPERATIONS>[] = [
...CHANGING_POWER_STATE_OPERATIONS,
VM_OPERATIONS.SNAPSHOT,
VM_OPERATIONS.DESTROY,
VM_OPERATIONS.CLONE,
@@ -37,6 +42,22 @@ export function isVmOperationPending(vm: FrontXoVm, operations: VM_OPERATIONS[]
return castArray(operations).some(operation => currentOperations.includes(operation))
}
export function getVmPendingPowerStateOperation(vm: FrontXoVm) {
return getVmsPendingOperation([vm], CHANGING_POWER_STATE_OPERATIONS)
}
export function getVmIcon(vm: FrontXoVm | undefined) {
if (vm === undefined) {
return objectIcon('vm', 'unknown')
}
if (getVmPendingPowerStateOperation(vm) !== undefined) {
return objectIcon('vm', 'busy')
}
return objectIcon('vm', toLower(vm.power_state))
}
export function areVmsOperationPending(vms: FrontXoVm[], operations: VM_OPERATIONS[] | VM_OPERATIONS) {
return vms.some(vm => isVmOperationPending(vm, operations))
}

View File

@@ -3,12 +3,15 @@ import {
areAllVmsHavingPowerState,
areVmsOperationPending,
extractVmHostId,
getVmIcon,
getVmIpAddresses,
getVmPendingPowerStateOperation,
getVmsPendingOperation,
isVmOperationPending,
notAllVmsHavingPowerState,
} from '@/modules/vm/utils/xo-vm.util.ts'
import { createVm } from '@/test/create-vm.ts'
import { objectIcon } from '@core/icons'
import { VM_OPERATIONS, VM_POWER_STATE } from '@vates/types'
describe('isVmOperationPending', () => {
@@ -37,6 +40,52 @@ describe('isVmOperationPending', () => {
})
})
describe('getVmPendingPowerStateOperation', () => {
it('returns the pending operation that changes the power state, ignoring the other ones', () => {
const vm = createVm({
current_operations: { 'task-1': VM_OPERATIONS.SNAPSHOT, 'task-2': VM_OPERATIONS.HARD_REBOOT },
})
expect(getVmPendingPowerStateOperation(vm)).toBe(VM_OPERATIONS.HARD_REBOOT)
})
it('returns undefined while only an operation that does not change the power state is pending', () => {
const vm = createVm({ current_operations: { 'task-1': VM_OPERATIONS.SNAPSHOT } })
expect(getVmPendingPowerStateOperation(vm)).toBeUndefined()
})
})
describe('getVmIcon', () => {
it('falls back to the unknown VM icon when no VM is provided', () => {
expect(getVmIcon(undefined)).toBe(objectIcon('vm', 'unknown'))
})
it('picks the icon matching the VM power state', () => {
const vm = createVm({ power_state: VM_POWER_STATE.HALTED, current_operations: {} })
expect(getVmIcon(vm)).toBe(objectIcon('vm', 'halted'))
})
it('picks the busy icon while the VM power state is changing', () => {
const vm = createVm({
power_state: VM_POWER_STATE.RUNNING,
current_operations: { 'task-1': VM_OPERATIONS.SHUTDOWN },
})
expect(getVmIcon(vm)).toBe(objectIcon('vm', 'busy'))
})
it('keeps the power state icon while an operation that does not change the power state is pending', () => {
const vm = createVm({
power_state: VM_POWER_STATE.RUNNING,
current_operations: { 'task-1': VM_OPERATIONS.SNAPSHOT },
})
expect(getVmIcon(vm)).toBe(objectIcon('vm', 'running'))
})
})
describe('areVmsOperationPending', () => {
it('returns true when at least one VM has a pending operation', () => {
const idleVm = createVm({ current_operations: {} })

View File

@@ -0,0 +1,26 @@
import { createMapper } from '@core/packages/mapper'
import { type MaybeRefOrGetter, toValue } from 'vue'
import { useI18n } from 'vue-i18n'
export function useOperationInProgressLabel(operationLabels: MaybeRefOrGetter<Record<string, string>>) {
const { t } = useI18n()
const getOperationLabel = createMapper<string, string>(
() => ({ ...toValue(operationLabels), unknown: '' }),
'unknown'
)
return (operation: string | undefined) => {
if (operation === undefined) {
return ''
}
const operationLabel = getOperationLabel(operation)
if (operationLabel === '') {
return ''
}
return t('operation-in-progress', { operation: operationLabel })
}
}

View File

@@ -0,0 +1,36 @@
import { useOperationInProgressLabel } from '@/shared/composables/operation-in-progress-label.composable.ts'
import { t } from '@/test/i18n.ts'
import { mountComposable } from '@/test/mount-composable.ts'
function mountOperationInProgressLabel() {
return mountComposable(() => ({
getOperationInProgressLabel: useOperationInProgressLabel(() => ({ reboot: 'Rebooting' })),
})).wrapper.vm.getOperationInProgressLabel
}
it('marks the label of a known operation as in progress', () => {
expect(mountOperationInProgressLabel()('reboot')).toBe(t('operation-in-progress', { operation: 'Rebooting' }))
})
it('returns an empty label for an unknown operation', () => {
expect(mountOperationInProgressLabel()('unknown_operation')).toBe('')
})
it('returns an empty label when there is no operation', () => {
expect(mountOperationInProgressLabel()(undefined)).toBe('')
})
it('builds the labels only once an operation is pending', () => {
const operationLabels = vi.fn(() => ({ reboot: 'Rebooting' }))
const getOperationInProgressLabel = mountComposable(() => ({
getOperationInProgressLabel: useOperationInProgressLabel(operationLabels),
})).wrapper.vm.getOperationInProgressLabel
getOperationInProgressLabel(undefined)
expect(operationLabels).not.toHaveBeenCalled()
getOperationInProgressLabel('reboot')
expect(operationLabels).toHaveBeenCalledOnce()
})

View File

@@ -16,12 +16,15 @@
- [Treeview] A more compact tree, so that names get more room: lower rows running edge to edge and a smaller indent per level; hovering a quick actions button no longer highlights its whole row (PR [#10727](https://github.com/vatesfr/xen-orchestra/pull/10727))
- [Pool/Network] Add network dedicated page, with General and PIFs tabs (PR [#10704](https://github.com/vatesfr/xen-orchestra/pull/10704))
- [Pool/Traffic rules] Display traffic rules by priority within their network (PR [#10688](https://github.com/vatesfr/xen-orchestra/pull/10688))
- [VMs/Hosts] Show a loader in VM and host icons while their power state is changing, with the operation in progress in the tooltip, and move the header loader next to the name (PR [#10720](https://github.com/vatesfr/xen-orchestra/pull/10720))
- [Pool/Traffic rules] Edit the order of traffic rules within a network (PR [#10706](https://github.com/vatesfr/xen-orchestra/pull/10706))
### Bug fixes
> Users must be able to say: "I had this issue, happy to know it's fixed"
- [Pools] Pool side panel now shows the same pool name, UUID and icon as the pools table, and keeps the pool card when the pool is disconnected or unreachable (PR [#10714](https://github.com/vatesfr/xen-orchestra/pull/10714))
- [Treeview] Fix translations of the tooltip for VM and host statuses in treeview (PR [#10643](https://github.com/vatesfr/xen-orchestra/pull/10643))
### Packages to release