Compare commits

...

14 Commits

Author SHA1 Message Date
Elise-F
abdcbc1088 update changelog 2026-11-09 16:26:44 +01:00
Elise-F
3c4ae9c70f feat(xo6): add edit order action within network for traffic rules 2026-11-09 16:26:24 +01:00
Elise-F
8e81250ca3 fix(xo6,lite): translate VM and host state tooltips in tree view (#10643) 2026-11-09 16:25:00 +01:00
Joris K
c8c11ae63c feat(xo6/web-core): show a loader in VM and host icons during power state changes (#10720) 2026-11-09 10:49:12 +01:00
Elise-F
8df236a512 feat(xo6): displaying traffic rules by priority within network (#10688)
* feat(xo6): displaying traffic rules by priority within network

* update changelog

* clean code
2026-11-09 09:51:51 +01:00
Joris K
8cc3384850 feat(xo6/network): add dedicated network page (#10704) 2026-11-09 09:39:47 +01:00
Olivier Lambert
e4cc8df323 feat(xo6): compact treeview (#10727) 2026-11-06 19:45:39 +01:00
Joris K
43a7e3d3ac feat(web-core): adapt card rows to their content in side panels (#10733)
* feat(web-core): adapt card rows to their content in side panels

- Key and value stay on one line when they fit, the value moves below the key otherwise
- New `values` prop on `VtsCardRowKeyValue`: one copy button per value, a "Copy all"
  action and an optional "see more" limit for fields with multiple values
- New `VtsCardRowList` component holding the 0.8rem spacing between rows
- `UiInfo` text is small inside side panels (icon stays medium), medium elsewhere
- Side panel links use the small size

* docs: add changelog entries
2026-11-06 18:38:09 +01:00
Joris K
2e586f0369 feat(web-core): make the side panel resizable (#10725)
* feat(web-core): make the side panel resizable

- Resize the side panel like the treeview, with a shared `VtsResizeHandle`
  component and `useResizableWidth` composable
- Default width of the treeview and the side panel is 1/5 of the screen
  (280px minimum), max width is 1/3 of the screen (400px minimum)
- Double-click (or Enter) on the handle restores the default width
- The handle is keyboard accessible (arrows, Home, End) and exposed as an
  ARIA separator

* docs: add changelog entries
2026-11-06 18:27:57 +01:00
Bastien Nollet
93dc079f71 docs: remove british spellings (#10724) 2026-11-06 16:58:11 +01:00
Joris K
9c6a59bb89 feat(web-core): show UiButton loader on the right of the label (#10723) 2026-11-06 12:57:52 +01:00
Thomas Moraine
12aa9576b9 docs: use US spelling for licensing (#10666) 2026-11-06 10:21:31 +01:00
Joris K
85541ce5d7 fix(xo6): Fix some issue in the pool tab (#10714) 2026-11-05 15:42:09 +01:00
Joris K
56a33c1639 fix(xo6): change padding tasks (#10713) 2026-11-05 14:49:03 +01:00
242 changed files with 4070 additions and 1634 deletions

View File

@@ -772,6 +772,7 @@ export type RawTrafficRule = {
ipRange: string
direction: TrafficRuleDirection
port?: string
priority?: number
}
type BaseTrafficRule = RawTrafficRule & {

View File

@@ -9,7 +9,7 @@ async function consumeGenerator(source: AsyncGenerator) {
return 'finished'
}
test('Disk class', async t => {
const testRes = await t.test('synchronised fork disk ', async () => {
const testRes = await t.test('synchronized fork disk ', async () => {
const blockSize = 1024
const nbBlocks = 20
const source = new DebugDisk({ nbBlocks, blockSize, fillRate: 100 })

View File

@@ -18,7 +18,7 @@ ee.on('start', async function () {
// listeners have settled
await ee.emitAsync('start')
// by default, it will rejects as soon as one listener reject, you can customise
// by default, it will rejects as soon as one listener reject, you can customize
// error handling though:
await ee.emitAsync(
{

View File

@@ -36,7 +36,7 @@ ee.on('start', async function () {
// listeners have settled
await ee.emitAsync('start')
// by default, it will rejects as soon as one listener reject, you can customise
// by default, it will rejects as soon as one listener reject, you can customize
// error handling though:
await ee.emitAsync(
{

View File

@@ -1,5 +1,11 @@
# ChangeLog
## **next**
- [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)
- [New network] Choose between no NBD, secure NBD and insecure NBD (not encrypted) when creating a network (PR [#10540](https://github.com/vatesfr/xen-orchestra/pull/10540))

View File

@@ -12,7 +12,7 @@
<!-- PIF -->
<UiPanelCard>
<UiPanelCardTitle :id="pif.uuid" size="medium" :label="isBond ? t('bond') : t('pif')" />
<div class="content">
<VtsCardRowList>
<!-- NETWORK -->
<VtsCardRowKeyValue>
<template #key>
@@ -82,34 +82,15 @@
</UiTagsList>
</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
<!-- NETWORK INFORMATION -->
<UiPanelCard>
<UiPanelCardTitle size="medium" :label="t('network-information')" />
<div class="content">
<VtsCardRowList>
<!-- IP ADDRESSES -->
<div v-if="ipAddresses.length">
<VtsCardRowKeyValue v-for="(ip, index) in ipAddresses" :key="ip">
<template #key>
<div v-if="index === 0">{{ t('ip-addresses') }}</div>
</template>
<template #value>
<span v-tooltip class="text-ellipsis">{{ ip }}</span>
</template>
<template #addons>
<VtsCopyButton :value="ip" />
<VtsCopyAllMenuItem v-if="index === 0 && ipAddresses.length > 1" :values="ipAddresses" />
</template>
</VtsCardRowKeyValue>
</div>
<VtsCardRowKeyValue v-else>
<template #key>
{{ t('ip-addresses') }}
</template>
<template #value>
<span class="value" />
</template>
<VtsCardRowKeyValue :values="ipAddresses">
<template #key>{{ t('ip-addresses') }}</template>
</VtsCardRowKeyValue>
<!-- MAC ADDRESSES -->
<VtsCardRowKeyValue>
@@ -173,26 +154,15 @@
</template>
</VtsCardRowKeyValue>
<!-- BOND DEVICES -->
<div>
<VtsCardRowKeyValue v-for="(device, index) in bondDevices" :key="device">
<template #key>
<div v-if="index === 0">{{ t('bond-devices') }}</div>
</template>
<template #value>
<span v-tooltip class="text-ellipsis">{{ device }}</span>
</template>
<template v-if="device" #addons>
<VtsCopyButton :value="device" />
<VtsCopyAllMenuItem v-if="index === 0 && bondDevices.length > 1" :values="bondDevicesFiltered" />
</template>
</VtsCardRowKeyValue>
</div>
</div>
<VtsCardRowKeyValue v-if="bondDevicesFiltered.length > 0" :values="bondDevicesFiltered">
<template #key>{{ t('bond-devices') }}</template>
</VtsCardRowKeyValue>
</VtsCardRowList>
</UiPanelCard>
<!-- PROPERTIES -->
<UiPanelCard>
<UiPanelCardTitle size="medium" :label="t('properties')" />
<div class="content">
<VtsCardRowList>
<!-- MTU -->
<VtsCardRowKeyValue>
<template #key>
@@ -226,7 +196,7 @@
<VtsCopyButton :value="networkPurpose" />
</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
</VtsSidePanel>
@@ -239,7 +209,7 @@ import { useNetworkStore } from '@/stores/xen-api/network.store.ts'
import { usePifMetricsStore } from '@/stores/xen-api/pif-metrics.store.ts'
import { usePifStore } from '@/stores/xen-api/pif.store.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCopyAllMenuItem from '@core/components/copy-all-menu-item/VtsCopyAllMenuItem.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsForgetButton from '@core/components/forget-button/VtsForgetButton.vue'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
@@ -331,12 +301,6 @@ const speed = computed(() => {
<style scoped lang="postcss">
.host-pif-side-panel {
.content {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.network {
display: flex;
gap: 0.8rem;

View File

@@ -21,7 +21,7 @@
<template v-if="network" #default>
<UiPanelCard>
<UiPanelCardTitle :id="network.uuid" size="medium" :label="network.name_label" />
<div class="content">
<VtsCardRowList>
<!-- DESCRIPTION -->
<VtsCardRowKeyValue truncate align-top>
<template #key>{{ t('description') }}</template>
@@ -63,7 +63,7 @@
<template #key>{{ t('locking-mode-default') }}</template>
<template #value>{{ networkDefaultLockingMode }}</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
<UiPanelCard v-if="pifsCount && pifsCount > 0">
<UiPanelCardTitle size="medium" :label="t('pifs')" :counter="pifsCount" />
@@ -97,6 +97,7 @@ import type { XenApiNetwork } from '@/libs/xen-api/xen-api.types.ts'
import { useNetworkDelete } from '@/modules/network/composables/use-network-delete.composable.ts'
import { usePifStore } from '@/stores/xen-api/pif.store.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsDeleteButton from '@core/components/delete-button/VtsDeleteButton.vue'
import MenuItem from '@core/components/menu/MenuItem.vue'
@@ -149,12 +150,6 @@ const { copy, copied, isSupported: isClipboardSupported } = useClipboard({ sourc
<style scoped lang="postcss">
.pool-network-side-panel {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.text-left {
text-align: left;
}

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

@@ -1,7 +1,7 @@
<template>
<UiPanelCard class="host-hardware-specifications-card">
<UiPanelCard>
<UiPanelCardTitle size="medium" :label="t('hardware-specifications')" />
<div class="content">
<VtsCardRowList>
<VtsCardRowKeyValue>
<template #key>{{ t('manufacturer-info') }}</template>
<template #value>{{ manufacturerInfo }}</template>
@@ -16,7 +16,7 @@
<VtsCopyButton :value="coreSocketInfo" />
</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
@@ -24,6 +24,7 @@
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
import { getHostCoreSocketInfo, getHostManufacturerInfo } from '@/modules/host/utils/host.util.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
@@ -40,13 +41,3 @@ const manufacturerInfo = computed(() => getHostManufacturerInfo(host))
const coreSocketInfo = computed(() => getHostCoreSocketInfo(host))
</script>
<style lang="postcss" scoped>
.host-hardware-specifications-card {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>

View File

@@ -7,7 +7,7 @@
:icon="hostIcon"
:to="{ name: '/host/[uuid]/dashboard', params: { uuid: host.uuid } }"
/>
<div class="content">
<VtsCardRowList class="content">
<VtsCardRowKeyValue>
<template #key>{{ t('state') }}</template>
<template #value>
@@ -98,7 +98,7 @@
<VtsCopyButton :value="noMissingPatches ? t('patches-up-to-date') : t('n-missing', nMissingPatches)" />
</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
@@ -110,6 +110,7 @@ import { getHostIcon } from '@/modules/host/utils/host.util.ts'
import { useHostMetricsStore } from '@/stores/xen-api/host-metrics.store.ts'
import { usePoolStore } from '@/stores/xen-api/pool.store.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsEnabledState from '@core/components/enabled-state/VtsEnabledState.vue'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
@@ -146,10 +147,6 @@ const noMissingPatches = computed(() => nMissingPatches.value === 0)
<style lang="postcss" scoped>
.host-infos-card {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
.value {
display: flex;
align-items: center;

View File

@@ -1,7 +1,7 @@
<template>
<UiPanelCard class="host-network-card">
<UiPanelCard>
<UiPanelCardTitle :label="t('network')" size="medium" />
<div class="content">
<VtsCardRowList>
<VtsCardRowKeyValue>
<template #key>{{ t('management-ip') }}</template>
<template #value>{{ host.address }}</template>
@@ -9,37 +9,13 @@
<VtsCopyButton :value="host.address" />
</template>
</VtsCardRowKeyValue>
<template v-if="ipV4Addresses.length > 0">
<VtsCardRowKeyValue v-for="(ip, index) in ipV4Addresses" :key="ip">
<template #key>
<div v-if="index === 0">{{ t('ipv4-addresses') }}</div>
</template>
<template #value>{{ ip }}</template>
<template #addons>
<VtsCopyButton :value="ip" />
<VtsCopyAllMenuItem v-if="index === 0 && ipV4Addresses.length > 1" :values="ipV4Addresses" />
</template>
</VtsCardRowKeyValue>
</template>
<VtsCardRowKeyValue v-else>
<VtsCardRowKeyValue :values="ipV4Addresses">
<template #key>{{ t('ipv4-addresses') }}</template>
</VtsCardRowKeyValue>
<template v-if="ipV6Addresses.length > 0">
<VtsCardRowKeyValue v-for="(ip, index) in ipV6Addresses" :key="ip">
<template #key>
<div v-if="index === 0">{{ t('ipv6-addresses') }}</div>
</template>
<template #value>{{ ip }}</template>
<template #addons>
<VtsCopyButton :value="ip" />
<VtsCopyAllMenuItem v-if="index === 0 && ipV6Addresses.length > 1" :values="ipV6Addresses" />
</template>
</VtsCardRowKeyValue>
</template>
<VtsCardRowKeyValue v-else>
<VtsCardRowKeyValue :values="ipV6Addresses">
<template #key>{{ t('ipv6-addresses') }}</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
@@ -47,7 +23,7 @@
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
import { usePifStore } from '@/stores/xen-api/pif.store.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCopyAllMenuItem from '@core/components/copy-all-menu-item/VtsCopyAllMenuItem.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
@@ -66,13 +42,3 @@ const pifs = computed(() => pifsByHost.value.get(host.$ref))
const ipV4Addresses = computed(() => pifs.value?.map(pif => pif.IP).filter(ip => ip) ?? [])
const ipV6Addresses = computed(() => pifs.value?.flatMap(pif => pif.IPv6.filter(ip => ip)) ?? [])
</script>
<style lang="postcss" scoped>
.host-network-card {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<UiPanelCard class="host-software-card">
<UiPanelCard>
<UiPanelCardTitle size="medium" :label="t('software')" />
<div class="content">
<VtsCardRowList>
<VtsCardRowKeyValue>
<template #key>{{ t('version') }}</template>
<template #value>{{ host.software_version.product_version }}</template>
@@ -9,13 +9,14 @@
<VtsCopyButton :value="host.software_version.product_version" />
</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
<script lang="ts" setup>
import type { XenApiHost } from '@/libs/xen-api/xen-api.types.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
@@ -27,13 +28,3 @@ defineProps<{
const { t } = useI18n()
</script>
<style lang="postcss" scoped>
.host-software-card {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>

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

@@ -1,12 +1,12 @@
<template>
<UiPanelCard class="storage-repository-infos-card">
<UiPanelCard>
<UiPanelCardTitle
:id="sr.uuid"
size="medium"
:label="sr.name_label"
:icon="sr.name_label ? srStatusIcon : undefined"
/>
<div class="content">
<VtsCardRowList>
<VtsCardRowKeyValue>
<template #key>{{ t('status') }}</template>
<template #value>
@@ -56,7 +56,7 @@
<template #key>{{ t('high-availability') }}</template>
<template #value><VtsStatus :status="isHaSr" /></template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
@@ -66,6 +66,7 @@ import { useSrUtils } from '@/modules/storage-repository/composables/sr-utils.co
import { useSrStore } from '@/stores/xen-api/sr.store.ts'
import type { SrScope } from '@core/types/storage-repository.type.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
import VtsTag from '@core/components/tag/VtsTag.vue'
@@ -96,13 +97,3 @@ const allocationStrategy = computed(() => getAllocationStrategy(sr) ?? t('unknow
const isHaSr = computed(() => isHaSrForPool(sr, pool))
</script>
<style scoped lang="postcss">
.storage-repository-infos-card {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>

View File

@@ -5,10 +5,10 @@
{{ t('no-pbd-attached') }}
</VtsStateHero>
<template v-else>
<div class="content">
<VtsCardRowList class="content">
<VtsStatus :status="srConnectionStatus" />
</div>
<div v-if="areSomePbdsDisconnected" class="content">
</VtsCardRowList>
<VtsCardRowList v-if="areSomePbdsDisconnected" class="content">
<template v-for="(pbd, index) in disconnectedPbds" :key="pbd.uuid">
<VtsDivider v-if="index > 0" class="divider" type="stretch" />
<UiPanelCardTitle size="small" :label="t('disconnected-pbd-number', { n: index + 1 })" class="subtitle" />
@@ -31,7 +31,7 @@
accent="info"
/>
</template>
</div>
</VtsCardRowList>
</template>
</UiPanelCard>
</template>
@@ -43,6 +43,7 @@ import { usePbdUtils } from '@/modules/storage-repository/composables/pbd-utils.
import { useSrUtils } from '@/modules/storage-repository/composables/sr-utils.composable.ts'
import type { SrScope } from '@core/types/storage-repository.type.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'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
@@ -70,10 +71,6 @@ const { areSomePbdsDisconnected, disconnectedPbds } = usePbdUtils(pbdsInScope)
<style scoped lang="postcss">
.storage-repository-pbds-card {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
.subtitle {
margin-block-end: 0.4rem;
}

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

@@ -1,7 +1,7 @@
<template>
<UiPanelCard class="card-container">
<UiPanelCardTitle size="medium" :label="t('configuration')" />
<div class="content">
<VtsCardRowList>
<VdiFormatCardItem :format="vdi.sm_config['image-format']" />
<VtsCardRowKeyValue>
<template #key>
@@ -43,7 +43,7 @@
<VtsStatus :status="isBootable" />
</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
@@ -53,6 +53,7 @@ import type { XenApiVbd, XenApiVdi } from '@/libs/xen-api/xen-api.types.ts'
import VdiFormatCardItem from '@/modules/vdi/components/list/panel/card-items/VdiFormatCardItem.vue'
import { useSrStore } from '@/stores/xen-api/sr.store.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
@@ -73,11 +74,6 @@ const isBootable = computed(() => vbd?.bootable ?? false)
<style scoped lang="postcss">
.card-container {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.storage {
display: flex;
gap: 0.8rem;

View File

@@ -1,7 +1,7 @@
<template>
<UiPanelCard class="card-container">
<UiPanelCard>
<UiPanelCardTitle :id="vdi.uuid" size="medium" :label="vdi.name_label" :icon="vdiIcon" />
<div class="content">
<VtsCardRowList>
<VtsCardRowKeyValue truncate align-top>
<template #key>{{ t('description') }}</template>
<template #value>{{ vdi.name_description }}</template>
@@ -36,7 +36,7 @@
<VtsCopyButton :value="vbd.device" />
</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
@@ -45,6 +45,7 @@ import type { XenApiVbd, XenApiVdi } from '@/libs/xen-api/xen-api.types.ts'
import { useVbdsStatus, type VbdAttachmentStatus } from '@/modules/vbd/composables/use-vbds-status.composable.ts'
import type { IconName } from '@core/icons'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
import VtsTag from '@core/components/tag/VtsTag.vue'
@@ -79,13 +80,3 @@ const vbdsStatus = useMapper<VbdAttachmentStatus, (typeof CONNECTION_STATUS)[key
'noneAttached'
)
</script>
<style scoped lang="postcss">
.card-container {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>

View File

@@ -8,7 +8,7 @@
<!-- VIF -->
<UiPanelCard>
<UiPanelCardTitle :id="vif.uuid" size="medium" :label="t('vif')" />
<div class="content">
<VtsCardRowList>
<!-- NETWORK -->
<VtsCardRowKeyValue>
<template #key>
@@ -79,32 +79,15 @@
{{ isTxChecksummingEnabled(vif) }}
</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
<!-- VIF NETWORK INFORMATION -->
<UiPanelCard>
<UiPanelCardTitle size="medium" :label="t('network-information')" />
<div class="content">
<VtsCardRowList>
<!-- IP ADDRESSES -->
<div v-if="ipAddresses.length">
<VtsCardRowKeyValue v-for="(ip, index) in ipAddresses" :key="ip">
<template #key>
<div v-if="index === 0">{{ t('ip-addresses') }}</div>
</template>
<template #value>{{ ip }}</template>
<template #addons>
<VtsCopyButton :value="ip" />
<VtsCopyAllMenuItem v-if="index === 0 && ipAddresses.length > 1" :values="ipAddresses" />
</template>
</VtsCardRowKeyValue>
</div>
<VtsCardRowKeyValue v-else>
<template #key>
{{ t('ip-addresses') }}
</template>
<template #value>
<span class="value" />
</template>
<VtsCardRowKeyValue :values="ipAddresses">
<template #key>{{ t('ip-addresses') }}</template>
</VtsCardRowKeyValue>
<!-- MAC ADDRESSES -->
<VtsCardRowKeyValue>
@@ -118,7 +101,7 @@
<VtsCopyButton :value="vif.MAC" />
</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
</VtsSidePanel>
@@ -133,7 +116,7 @@ import { useNetworkStore } from '@/stores/xen-api/network.store.ts'
import { useVmGuestMetricsStore } from '@/stores/xen-api/vm-guest-metrics.store.ts'
import { useVmStore } from '@/stores/xen-api/vm.store.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCopyAllMenuItem from '@core/components/copy-all-menu-item/VtsCopyAllMenuItem.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsSidePanel from '@core/components/panel/VtsSidePanel.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
@@ -187,12 +170,6 @@ const rateLimit = computed(() => {
<style scoped lang="postcss">
.vm-vifs-side-panel {
.content {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.value:empty::before {
content: '-';
}

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

@@ -317,6 +317,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/story/web-core/card/vts-card-row-list': RouteRecordInfo<
'/story/web-core/card/vts-card-row-list',
'/story/web-core/card/vts-card-row-list',
Record<never, never>,
Record<never, never>,
| never
>,
'/story/web-core/cell-object/vts-cell-object': RouteRecordInfo<
'/story/web-core/cell-object/vts-cell-object',
'/story/web-core/cell-object/vts-cell-object',
@@ -1385,6 +1392,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/stories/web-core/card/vts-card-row-list.story.vue': {
routes:
| '/story/web-core/card/vts-card-row-list'
views:
| never
}
'src/stories/web-core/cell-object/vts-cell-object.story.vue': {
routes:
| '/story/web-core/cell-object/vts-cell-object'

View File

@@ -3,7 +3,7 @@
v-slot="{ properties, settings }"
:params="[
slot('key').help('Meant to receive a title'),
slot('value').help('Meant to receive text, tags or other value'),
slot('value').help('Meant to receive text, tags or other value. Overrides the value rendered from `values`'),
slot('addons').help('Meant to receive icons or other actions buttons '),
prop('truncate')
.type('boolean | number')
@@ -21,6 +21,16 @@
.help('Whether to align the key on the first line in case of multiline value')
.type('boolean')
.widget(),
prop('values')
.help('Values rendered as text with a copy button each, plus a copy all action when there are several')
.type('string[]')
.widget(object())
.preset(['192.168.1.10', '192.168.1.11', '10.0.0.12', 'fe80::a00:27ff:fe4e:66a1']),
prop('maxVisibleValues')
.help('Number of values shown before a see more button')
.type('number')
.widget()
.preset(3),
setting('valueSlotDescription')
.preset(
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Velit facilis pariatur maiores officia fugit odit, doloremque obcaecati quisquam distinctio adipisci fugiat incidunt hic rerum? Ullam, natus. Iste eum porro dolorum!'
@@ -29,12 +39,16 @@
]"
>
<UiCard class="card">
<VtsCardRowKeyValue v-bind="properties">
<VtsCardRowKeyValue :values="properties.values" :max-visible-values="properties.maxVisibleValues">
<template #key>IP addresses</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue v-bind="{ ...properties, values: undefined }">
<template #key>Description</template>
<template #value>{{ settings.valueSlotDescription }}</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue v-bind="properties">
<VtsCardRowKeyValue v-bind="{ ...properties, values: undefined }">
<template #key>Network</template>
<template #value>
<UiObjectLink route="`/vm/test/console`" icon="object:network:connected">
@@ -46,7 +60,7 @@
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue v-bind="properties">
<VtsCardRowKeyValue v-bind="{ ...properties, values: undefined }">
<template #key>Status</template>
<template #value>
<UiInfo accent="success">Connected</UiInfo>
@@ -57,7 +71,7 @@
</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue v-bind="{ ...properties, truncate: false }">
<VtsCardRowKeyValue v-bind="{ ...properties, truncate: false, values: undefined }">
<template #key>Tags</template>
<template #value>
<UiTagsList v-if="tags.length > 0">
@@ -75,7 +89,7 @@
<script lang="ts" setup>
import ComponentStory from '@/components/component-story/ComponentStory.vue'
import { prop, setting, slot } from '@/libs/story/story-param.ts'
import { choice, text } from '@/libs/story/story-widget.ts'
import { choice, object, text } from '@/libs/story/story-widget.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'

View File

@@ -0,0 +1,35 @@
<template>
<ComponentStory :params="[slot().help('Meant to receive VtsCardRowKeyValue components')]">
<UiCard class="card">
<VtsCardRowList>
<VtsCardRowKeyValue :values="['10.0.0.12']">
<template #key>IP address</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue :values="['1500']">
<template #key>MTU</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>Status</template>
<template #value>
<UiInfo accent="success">Connected</UiInfo>
</template>
</VtsCardRowKeyValue>
</VtsCardRowList>
</UiCard>
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from '@/components/component-story/ComponentStory.vue'
import { slot } from '@/libs/story/story-param.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiInfo from '@core/components/ui/info/UiInfo.vue'
</script>
<style lang="postcss" scoped>
.card {
width: 400px;
}
</style>

View File

@@ -1,5 +1,11 @@
<template>
<ComponentStory v-slot="{ properties }" :params="[iconProp().preset('object:host').required()]">
<ComponentStory
v-slot="{ properties }"
:params="[
iconProp().preset('object:host').required(),
prop('size').enum('small', 'medium').preset('medium').widget(),
]"
>
<VtsTreeList>
<VtsTreeLoadingItem v-bind="properties" />
</VtsTreeList>
@@ -8,7 +14,7 @@
<script lang="ts" setup>
import ComponentStory from '@/components/component-story/ComponentStory.vue'
import { iconProp } from '@/libs/story/story-param.ts'
import { iconProp, prop } from '@/libs/story/story-param.ts'
import VtsTreeList from '@core/components/tree/VtsTreeList.vue'
import VtsTreeLoadingItem from '@core/components/tree/VtsTreeLoadingItem.vue'
</script>

View File

@@ -4,7 +4,10 @@
:params="[
prop('accent').enum('info', 'success', 'warning', 'danger', 'muted').required().preset('info').widget(),
prop('wrap').bool().help('Choose if the text should wrap if too long').widget(),
prop('size').enum('small', 'medium').preset('medium').widget(),
prop('size')
.enum('small', 'medium')
.help('Text size only, the icon is always medium. Defaults to small inside a VtsSidePanel, medium elsewhere')
.widget(),
slot(),
setting('defaultSlot').widget(text()).preset('message'),
]"

View File

@@ -9,6 +9,7 @@
.preset({ name: '/pool/[uuid]', params: { uuid: '355ee47d-ff4c-4924-3db2-fd86ae629676' } })
.widget(),
prop('active').bool().widget(),
prop('size').enum('small', 'medium').preset('medium').widget(),
prop('no-indent').bool().help('Remove indentation based on component depth in the tree'),
slot('default'),
slot('icon').help('Meant to receive a VtsIcon or another icon component in case the icon prop is not used'),

View File

@@ -267,7 +267,7 @@ Throw `noSuchObject(id, type)` to get an automatic 404 when the routing doesn't
## Streaming & file-download routes
- Obtain and validate the resource **before** setting any response header, so a `noSuchObject` returns a clean JSON 404 instead of a body labelled as a download
- Obtain and validate the resource **before** setting any response header, so a `noSuchObject` returns a clean JSON 404 instead of a body labeled as a download
- Set `content-disposition: attachment; filename="…"` with a meaningful name built from domain labels (VM / disk / partition). Fold accents to ASCII and collapse anything outside `[\w.-]` so the header and the on-disk filename stay safe (also blocks header injection)
- Tie the stream to the request lifetime (`req.on('close', () => stream.destroy())`) so a client disconnect doesn't leak the export
- Export: return the stream (`Promise<Readable>`) and let the REST API pipe it to the response. Don't pipe it manually (`pipeline(stream, res)` + `Promise<void>`)

View File

@@ -0,0 +1,85 @@
<template>
<div class="card-row-value-list">
<div v-for="(value, index) in visibleValues" :key="index" class="value-row">
<div class="value">{{ value }}</div>
<div class="addons">
<VtsCopyButton :value />
<slot v-if="index === 0" name="first-row-addons" />
</div>
</div>
<UiButton
v-if="hiddenValuesCount > 0"
class="see-more"
size="small"
accent="brand"
variant="tertiary"
@click="toggleExpanded()"
>
{{ isExpanded ? t('action:see-less') : t('action:see-n-more', { n: hiddenValuesCount }) }}
</UiButton>
</div>
</template>
<script lang="ts" setup>
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
import { useToggle } from '@vueuse/shared'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { values, maxVisibleValues } = defineProps<{
values: string[]
maxVisibleValues?: number
}>()
defineSlots<{
'first-row-addons'?(): any
}>()
const { t } = useI18n()
const [isExpanded, toggleExpanded] = useToggle(false)
const hiddenValuesCount = computed(() =>
maxVisibleValues === undefined ? 0 : Math.max(values.length - maxVisibleValues, 0)
)
const visibleValues = computed(() => (isExpanded.value ? values : values.slice(0, maxVisibleValues)))
</script>
<style lang="postcss" scoped>
.card-row-value-list {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 0.8rem;
.value-row {
display: flex;
align-items: flex-start;
gap: 1.6rem;
}
.value {
flex: 1 1 auto;
min-width: 0;
overflow-wrap: anywhere;
color: var(--color-neutral-txt-primary);
&:empty::before {
content: '-';
}
}
.addons {
display: flex;
gap: 0.8rem;
height: 1lh;
align-items: center;
}
.see-more {
align-self: flex-start;
}
}
</style>

View File

@@ -1,48 +1,67 @@
<template>
<div
class="vts-card-row-key-value typo-body-regular-small"
:class="{ truncate: shouldTruncate, 'align-top': alignTop }"
>
<div class="key">
<slot name="key" />
</div>
<div class="value-container">
<div ref="truncatableValueElement" class="value" :class="{ truncated: shouldTruncate && !isExpanded }">
<slot name="value" />
<div class="vts-card-row-key-value typo-body-regular-small" :class="className">
<div ref="keyValue" class="key-value">
<div class="key">
<slot name="key" />
<div v-if="multipleValues && isStacked" class="addons">
<slot name="addons" />
<VtsCopyAllMenuItem :values="multipleValues" />
</div>
</div>
<CardRowValueList v-if="multipleValues" :values="multipleValues" :max-visible-values>
<template v-if="!isStacked" #first-row-addons>
<slot name="addons" />
<VtsCopyAllMenuItem :values="multipleValues" />
</template>
</CardRowValueList>
<div v-else class="value-container">
<div ref="truncatableValueElement" class="value" :class="{ truncated: shouldTruncate && !isExpanded }">
<slot name="value">
<template v-if="singleValue !== undefined">{{ singleValue }}</template>
</slot>
</div>
</div>
<UiButtonIcon
v-if="shouldTruncate"
v-tooltip="isExpanded ? t('action:show-less') : t('action:show-more')"
:icon="isExpanded ? 'fa:chevron-up' : 'fa:chevron-down'"
class="show-more"
size="small"
accent="brand"
@click="toggleExpanded()"
/>
</div>
<div v-if="slots.addons" class="addons">
<slot name="addons" />
<div v-if="!multipleValues && (slots.addons || singleValue !== undefined || shouldTruncate)" class="actions">
<div v-if="slots.addons || singleValue !== undefined" class="addons">
<slot name="addons" />
<VtsCopyButton v-if="singleValue !== undefined" :value="singleValue" />
</div>
<div v-if="shouldTruncate" class="show-more">
<UiButtonIcon
v-tooltip="isExpanded ? t('action:show-less') : t('action:show-more')"
:icon="isExpanded ? 'fa:chevron-up' : 'fa:chevron-down'"
size="small"
accent="brand"
@click="toggleExpanded()"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import CardRowValueList from '@core/components/card/CardRowValueList.vue'
import VtsCopyAllMenuItem from '@core/components/copy-all-menu-item/VtsCopyAllMenuItem.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { calculateLineCount } from '@core/utils/line-height.util.ts'
import { useStyleTag } from '@vueuse/core'
import { useResizeObserver, useStyleTag } from '@vueuse/core'
import { useToggle } from '@vueuse/shared'
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { truncate } = defineProps<{
const { truncate, alignTop, values } = defineProps<{
truncate?: number | boolean
alignTop?: boolean
values?: string[]
maxVisibleValues?: number
}>()
const slots = defineSlots<{
key(): any
value(): any
value?(): any
addons?(): any
}>()
@@ -50,6 +69,24 @@ const DEFAULT_MAX_LINES = 5
const { t } = useI18n()
const multipleValues = computed(() => (values !== undefined && values.length > 1 ? values : undefined))
const singleValue = computed(() => (values?.length === 1 ? values[0] : undefined))
const keyValueRef = useTemplateRef('keyValue')
const isStacked = ref(false)
useResizeObserver(
() => (multipleValues.value !== undefined ? keyValueRef.value : undefined),
() => {
const key = keyValueRef.value?.firstElementChild
const valueList = keyValueRef.value?.lastElementChild
isStacked.value =
key instanceof HTMLElement && valueList instanceof HTMLElement && valueList.offsetTop > key.offsetTop
}
)
const truncatableValueElementRef = useTemplateRef('truncatableValueElement')
const lineCount = ref(0)
@@ -67,6 +104,13 @@ const shouldTruncate = computed(() => isTruncationEnabled.value && lineCount.val
const [isExpanded, toggleExpanded] = useToggle(false)
const className = computed(() => ({
truncate: shouldTruncate.value,
'align-top': alignTop,
'multiple-values': multipleValues.value !== undefined,
stacked: isStacked.value,
}))
const calculateLines = async () => {
await nextTick()
@@ -89,42 +133,36 @@ useStyleTag(style)
onMounted(() => calculateLines())
watch(
() => slots.value?.(),
() => calculateLines()
)
watch([() => slots.value?.(), () => values], () => calculateLines())
</script>
<style lang="postcss" scoped>
.vts-card-row-key-value {
display: flex;
align-items: center;
align-items: flex-start;
column-gap: 1.6rem;
&.truncate {
display: grid;
row-gap: 0.4rem;
grid-template-columns: 1fr auto;
.key {
grid-column: 1 / -1;
grid-row: 1;
}
.addons {
grid-column: 2;
grid-row: 1;
}
.value-container {
grid-column: 1 / -1;
}
.key-value {
display: flex;
flex: 1 1 auto;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 1.6rem;
min-width: 0;
}
&.align-top:not(.truncate) {
&.align-top .key-value,
&.multiple-values .key-value {
align-items: flex-start;
}
&.multiple-values.stacked .key {
display: flex;
flex-grow: 1;
justify-content: space-between;
gap: 0.8rem;
}
.key {
overflow-wrap: break-word;
color: var(--color-neutral-txt-secondary);
@@ -132,15 +170,16 @@ watch(
.value-container {
display: flex;
align-items: end;
gap: 0.8rem;
flex: 1 1 auto;
min-width: 0;
.value {
min-width: 0;
color: var(--color-neutral-txt-primary);
}
.show-more {
flex-shrink: 0;
:slotted(*) {
vertical-align: middle;
}
}
}
@@ -148,12 +187,23 @@ watch(
content: '-';
}
.addons {
.actions {
display: flex;
flex-direction: column;
align-self: stretch;
align-items: flex-end;
}
.addons,
.show-more {
display: flex;
gap: 0.8rem;
margin-left: auto;
font-size: 1.6rem;
height: 1lh;
align-items: center;
}
.show-more {
margin-block-start: auto;
}
}
</style>

View File

@@ -0,0 +1,19 @@
<template>
<div class="vts-card-row-list">
<slot />
</div>
</template>
<script lang="ts" setup>
defineSlots<{
default(): any
}>()
</script>
<style lang="postcss" scoped>
.vts-card-row-list {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
</style>

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

@@ -4,6 +4,7 @@
:class="{
mobile: uiStore.isSmall,
'has-panel-column': hasPanelColumn,
toggling: isToggling,
}"
>
<slot />
@@ -11,30 +12,40 @@
</template>
<script setup lang="ts">
import { usePanelStore } from '@core/stores/panel.store.ts'
import { PANEL_TRANSITION_DURATION, usePanelStore } from '@core/stores/panel.store.ts'
import { useUiStore } from '@core/stores/ui.store.ts'
import { computed } from 'vue'
import { refAutoReset } from '@vueuse/core'
import { computed, watch } from 'vue'
const panelStore = usePanelStore()
const uiStore = useUiStore()
const hasPanelColumn = computed(() => (panelStore.isLocked || panelStore.isExpanded) && !uiStore.isSmall)
const isToggling = refAutoReset(false, PANEL_TRANSITION_DURATION)
watch(hasPanelColumn, () => {
isToggling.value = true
})
const columnTransition = `grid-template-columns ${PANEL_TRANSITION_DURATION}ms`
</script>
<style scoped lang="postcss">
.vts-content-side-panel {
--side-panel-width: 40rem;
&:not(.mobile) {
display: grid;
grid-template-columns: minmax(0, 1fr) 0;
align-items: start;
overflow-x: clip;
min-height: min-content;
transition: grid-template-columns 0.25s;
&.has-panel-column {
grid-template-columns: minmax(0, 1fr) var(--side-panel-width);
grid-template-columns: minmax(0, 1fr) v-bind('panelStore.cssWidth');
}
&.toggling {
transition: v-bind(columnTransition);
}
}
}

View File

@@ -1,11 +1,6 @@
<template>
<div :class="className" class="vts-layout-sidebar">
<div
v-if="!ui.isSmall"
:class="{ active: sidebar.isResizing }"
class="resize-handle"
@mousedown="sidebar.startResize"
/>
<VtsResizeHandle v-if="showResizeHandle" :position="resizeHandlePosition" :resizable="sidebar" />
<div v-if="slots.header || (showLock && !ui.isSmall)" class="header">
<div v-if="slots.header" class="start">
<slot name="header" />
@@ -16,6 +11,7 @@
content: sidebar.isLocked ? t('action:sidebar-unlock') : t('action:sidebar-lock'),
placement: lockTooltipPosition,
}"
:aria-label="sidebar.isLocked ? t('action:sidebar-unlock') : t('action:sidebar-lock')"
accent="brand"
size="small"
:icon="sidebar.isLocked ? 'fa:thumb-tack-slash' : 'fa:thumb-tack'"
@@ -36,6 +32,7 @@
</template>
<script lang="ts" setup>
import VtsResizeHandle from '@core/components/resize-handle/VtsResizeHandle.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { type SidebarSide, useLeftSidebarStore, useRightSidebarStore } from '@core/packages/sidebar'
@@ -66,6 +63,10 @@ const sidebar = computed(() => (side === 'right' ? rightSidebar : leftSidebar))
const lockTooltipPosition = computed(() => (side === 'left' ? 'right' : 'left'))
const showResizeHandle = computed(() => !ui.isSmall && sidebar.value.isExpanded)
const resizeHandlePosition = computed(() => (side === 'left' ? 'end' : 'start'))
const className = computed(() => [
{
locked: sidebar.value.isLocked && !ui.isSmall,
@@ -154,34 +155,5 @@ const className = computed(() => [
flex: 1;
overflow: auto;
}
.resize-handle {
position: absolute;
inset-block: 0;
width: 0.8rem;
background-color: transparent;
cursor: col-resize;
transition: background-color 0.4s;
user-select: none;
z-index: 1;
&:hover,
&.active {
background-color: var(--color-neutral-border);
transition: background-color 0.05s;
}
}
&.side--left {
.resize-handle {
inset-inline-end: 0;
}
}
&.side--right {
.resize-handle {
inset-inline-start: 0;
}
}
}
</style>

View File

@@ -1,6 +1,7 @@
<!-- v5 -->
<template>
<div class="vts-side-panel-sticky" :class="{ mobile: uiStore.isSmall }">
<VtsResizeHandle v-if="showResizeHandle" position="start" :resizable="panelStore" />
<VtsPanel
:closable="hasSelection"
:close-icon="uiStore.isSmall ? 'fa:angle-left' : 'action:close-cancel-clear'"
@@ -42,12 +43,14 @@
<script setup lang="ts">
import VtsPanel from '@core/components/panel/VtsPanel.vue'
import VtsResizeHandle from '@core/components/resize-handle/VtsResizeHandle.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { usePanelStore } from '@core/stores/panel.store.ts'
import { PANEL_TRANSITION_DURATION, usePanelStore } from '@core/stores/panel.store.ts'
import { useUiStore } from '@core/stores/ui.store.ts'
import { watch } from 'vue'
import { IK_INFO_SIZE } from '@core/utils/injection-keys.util.ts'
import { computed, provide, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { hasSelection } = defineProps<{
@@ -70,6 +73,12 @@ const uiStore = useUiStore()
const { t } = useI18n()
const showResizeHandle = computed(() => !uiStore.isSmall && panelStore.isExpanded)
const transformTransition = `transform ${PANEL_TRANSITION_DURATION}ms`
provide(IK_INFO_SIZE, 'small')
watch(
[() => hasSelection, () => panelStore.isLocked, () => uiStore.isSmall],
([hasSelection]) => {
@@ -99,8 +108,8 @@ function handleClose() {
}
.vts-side-panel {
width: var(--side-panel-width, 40rem);
transition: transform 0.25s;
width: v-bind('panelStore.cssWidth');
transition: v-bind(transformTransition);
&:not(.mobile) {
--panel-viewport-height: calc(100dvh - 5.6rem);

View File

@@ -0,0 +1,76 @@
<template>
<div
:class="className"
class="vts-resize-handle"
role="separator"
aria-orientation="vertical"
tabindex="0"
:aria-label="t('action:resize')"
:aria-valuenow="ariaValueNow"
:aria-valuemin="resizable.minWidth"
:aria-valuemax="ariaValueMax"
@mousedown="resizable.startResize"
@dblclick="resizable.resetWidth()"
@keydown.left.prevent="handleArrowKey('left')"
@keydown.right.prevent="handleArrowKey('right')"
@keydown.home.prevent="resizable.setWidth(resizable.minWidth)"
@keydown.end.prevent="resizable.setWidth(resizable.maxWidth)"
@keydown.enter.prevent="resizable.resetWidth()"
/>
</template>
<script setup lang="ts">
import type { ResizableWidth } from '@core/composables/resizable-width.composable.ts'
import { toVariants } from '@core/utils/to-variants.util.ts'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { position, resizable } = defineProps<{
position: 'start' | 'end'
resizable: ResizableWidth
}>()
const RESIZE_STEP = 10
const { t } = useI18n()
const className = computed(() => toVariants({ position, active: resizable.isResizing }))
const ariaValueNow = computed(() => Math.round(resizable.width))
const ariaValueMax = computed(() => Math.round(resizable.maxWidth))
function handleArrowKey(direction: 'left' | 'right') {
const isGrowing = (direction === 'right') === (position === 'end')
resizable.setWidth(resizable.width + (isGrowing ? RESIZE_STEP : -RESIZE_STEP))
}
</script>
<style scoped lang="postcss">
.vts-resize-handle {
position: absolute;
inset-block: 0;
width: 0.8rem;
background-color: transparent;
cursor: col-resize;
transition: background-color 0.4s;
user-select: none;
z-index: 1;
&:hover,
&:focus-visible,
&.active {
background-color: var(--color-neutral-border);
transition: background-color 0.05s;
}
&.position--start {
inset-inline-start: 0;
}
&.position--end {
inset-inline-end: 0;
}
}
</style>

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,19 +1,25 @@
<template>
<div class="vts-tree-line">
<div :class="className" class="vts-tree-line">
<div class="tree-line-vertical" />
<div :class="{ right }" class="tree-line-horizontal" />
</div>
</template>
<script lang="ts" setup>
defineProps<{
import type { TreeItemLabelSize } from '@core/components/ui/tree-item-label/UiTreeItemLabel.vue'
import { toVariants } from '@core/utils/to-variants.util.ts'
import { computed } from 'vue'
const { size = 'medium' } = defineProps<{
right?: boolean
size?: TreeItemLabelSize
}>()
const className = computed(() => toVariants({ size }))
</script>
<style lang="postcss" scoped>
.vts-tree-line {
flex: 0 1 1.5em;
align-self: stretch;
display: flex;
align-items: center;
@@ -22,7 +28,6 @@ defineProps<{
.tree-line-vertical {
width: 0.1rem;
background: var(--color-brand-txt-base);
height: calc(100% + 0.2rem);
}
.tree-line-horizontal {
@@ -34,5 +39,21 @@ defineProps<{
background: var(--color-brand-txt-base);
}
}
&.size--small {
flex: 0 1 1.6rem;
.tree-line-vertical {
height: 100%;
}
}
&.size--medium {
flex: 0 1 2.4rem;
.tree-line-vertical {
height: calc(100% + 0.2rem);
}
}
}
</style>

View File

@@ -1,9 +1,9 @@
<template>
<li class="vts-tree-loading-item">
<li :class="className" class="vts-tree-loading-item">
<div class="label-placeholder">
<div class="link-placeholder">
<template v-if="depth > 1">
<VtsTreeLine v-for="i in depth - 1" :key="i" :right="i === depth - 1" />
<VtsTreeLine v-for="i in depth - 1" :key="i" :right="i === depth - 1" :size="resolvedSize" />
</template>
<VtsIcon :name="icon" size="medium" />
<div class="loader">&nbsp;</div>
@@ -15,22 +15,33 @@
<script lang="ts" setup>
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import VtsTreeLine from '@core/components/tree/VtsTreeLine.vue'
import type { TreeItemLabelSize } from '@core/components/ui/tree-item-label/UiTreeItemLabel.vue'
import type { IconName } from '@core/icons'
import { IK_TREE_LIST_DEPTH } from '@core/utils/injection-keys.util.ts'
import { inject, ref } from 'vue'
import { TREE_ITEM_SMALL_HEIGHT } from '@core/packages/tree/types.ts'
import { IK_TREE_ITEM_LABEL_SIZE, IK_TREE_LIST_DEPTH } from '@core/utils/injection-keys.util.ts'
import { toVariants } from '@core/utils/to-variants.util.ts'
import { computed, inject, ref } from 'vue'
defineProps<{
const { size } = defineProps<{
icon: IconName
size?: TreeItemLabelSize
}>()
const depth = inject(IK_TREE_LIST_DEPTH, ref(0))
const contextSize = inject(IK_TREE_ITEM_LABEL_SIZE, undefined)
const resolvedSize = computed(() => size ?? contextSize ?? 'medium')
const className = computed(() => toVariants({ size: resolvedSize.value }))
const smallHeight = `${TREE_ITEM_SMALL_HEIGHT}px`
</script>
<style lang="postcss" scoped>
.vts-tree-loading-item {
.label-placeholder {
display: flex;
height: 4rem;
background-color: var(--color-neutral-background-primary);
.link-placeholder {
@@ -48,6 +59,14 @@ const depth = inject(IK_TREE_LIST_DEPTH, ref(0))
}
}
&.size--small .label-placeholder {
height: v-bind(smallHeight);
}
&.size--medium .label-placeholder {
height: 4rem;
}
@keyframes pulse {
0% {
opacity: 0.5;

View File

@@ -1,14 +1,16 @@
<!-- v7 -->
<template>
<button :class="classNames" :disabled="busy || isDisabled || lockIcon" class="ui-button" type="button">
<VtsIcon :busy :name="leftIcon" class="icon" size="current" />
<VtsIcon :name="leftIcon" class="icon" size="current" />
<slot />
<UiLoader v-if="busy" class="icon" />
<VtsIcon v-if="lockIcon" name="fa:lock" size="medium" class="icon" />
</button>
</template>
<script lang="ts" setup>
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import UiLoader from '@core/components/ui/loader/UiLoader.vue'
import { useDisabled } from '@core/composables/disabled.composable.ts'
import type { IconName } from '@core/icons'
import { toVariants } from '@core/utils/to-variants.util.ts'

View File

@@ -1,7 +1,7 @@
<!-- v6 -->
<template>
<div class="ui-info">
<VtsIcon class="icon" :name="icon" :size />
<VtsIcon class="icon" :name="icon" size="medium" />
<p v-tooltip="!wrap" class="label" :class="[textSize, { 'text-ellipsis': !wrap }]">
<slot />
</p>
@@ -13,14 +13,16 @@ import VtsIcon from '@core/components/icon/VtsIcon.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import type { IconName } from '@core/icons'
import { useMapper } from '@core/packages/mapper'
import { IK_INFO_SIZE } from '@core/utils/injection-keys.util.ts'
import { inject } from 'vue'
export type InfoAccent = 'info' | 'success' | 'warning' | 'danger' | 'muted'
type Size = 'small' | 'medium'
export type InfoSize = 'small' | 'medium'
const { accent, size = 'medium' } = defineProps<{
const { accent, size } = defineProps<{
accent: InfoAccent
wrap?: boolean
size?: Size
size?: InfoSize
}>()
defineSlots<{
@@ -39,8 +41,10 @@ const icon = useMapper<InfoAccent, IconName>(
'muted'
)
const textSize = useMapper<Size, any>(
() => size,
const contextSize = inject(IK_INFO_SIZE, undefined)
const textSize = useMapper<InfoSize, any>(
() => size ?? contextSize ?? 'medium',
{
small: 'typo-body-regular-small',
medium: 'typo-body-regular',

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 }
}
@@ -61,7 +62,7 @@ const typoClasses = {
medium: 'typo-body-bold',
}
const isLink = computed(() => to !== undefined || href !== undefined)
const isLink = computed(() => to !== undefined || href !== undefined || disabled)
const counterProps = computed(() => {
if (counter === undefined) {

View File

@@ -162,7 +162,6 @@ const progress = computed(() => {
--line-width: 0.1rem;
display: flex;
align-items: center;
padding-left: 1.6rem;
gap: 0.4rem;
.tree-lines {
@@ -195,7 +194,7 @@ const progress = computed(() => {
flex: 1;
min-width: 0;
gap: 1.6rem;
padding: 0.4rem 1.6rem;
padding: 0.4rem 1.6rem 0.4rem 0.8rem;
justify-content: space-between;
align-items: center;
}

View File

@@ -2,17 +2,18 @@
<template>
<RouterLink v-slot="{ isExactActive, isActive, href, navigate }" :to="route" custom>
<div
:class="isExactActive ? 'exact-active' : active || isActive ? 'active' : undefined"
:class="[className, isExactActive ? 'exact-active' : active || isActive ? 'active' : undefined]"
class="ui-tree-item-label"
v-bind="attrs"
>
<template v-if="depth > 1">
<VtsTreeLine v-for="i in depth - 1" :key="i" />
<VtsTreeLine v-for="i in depth - 1" :key="i" :size="resolvedSize" />
</template>
<div class="toggle-wrapper">
<UiButtonIcon
v-if="hasToggle"
v-tooltip="isExpanded ? t('action:close') : t('action:open')"
:aria-label="isExpanded ? t('action:close') : t('action:open')"
class="toggle"
accent="brand"
:icon="isExpanded ? 'fa:angle-down' : 'fa:angle-right'"
@@ -43,23 +44,29 @@ import VtsTreeLine from '@core/components/tree/VtsTreeLine.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import type { IconName } from '@core/icons'
import { TREE_ITEM_SMALL_HEIGHT } from '@core/packages/tree/types.ts'
import {
IK_TREE_ITEM_EXPANDED,
IK_TREE_ITEM_HAS_CHILDREN,
IK_TREE_ITEM_LABEL_SIZE,
IK_TREE_LIST_DEPTH,
} from '@core/utils/injection-keys.util.ts'
import { inject, ref, useAttrs } from 'vue'
import { toVariants } from '@core/utils/to-variants.util.ts'
import { computed, inject, ref, useAttrs } from 'vue'
import { useI18n } from 'vue-i18n'
import type { RouteLocationRaw } from 'vue-router'
export type TreeItemLabelSize = 'small' | 'medium'
defineOptions({
inheritAttrs: false,
})
defineProps<{
const { size } = defineProps<{
route: RouteLocationRaw
icon?: IconName
active?: boolean
size?: TreeItemLabelSize
}>()
const emit = defineEmits<{
@@ -81,6 +88,14 @@ const hasToggle = inject(IK_TREE_ITEM_HAS_CHILDREN, ref(false))
const isExpanded = inject(IK_TREE_ITEM_EXPANDED, ref(true))
const depth = inject(IK_TREE_LIST_DEPTH, ref(0))
const contextSize = inject(IK_TREE_ITEM_LABEL_SIZE, undefined)
const resolvedSize = computed(() => size ?? contextSize ?? 'medium')
const className = computed(() => toVariants({ size: resolvedSize.value }))
const smallHeight = `${TREE_ITEM_SMALL_HEIGHT}px`
</script>
<style lang="postcss" scoped>
@@ -89,13 +104,10 @@ const depth = inject(IK_TREE_LIST_DEPTH, ref(0))
align-items: center;
color: var(--color-neutral-txt-primary);
background-color: var(--color-neutral-background-primary);
border-radius: 0.8rem;
gap: 0.4rem;
padding: 0 0.8rem;
margin-bottom: 0.2rem;
.toggle-wrapper {
width: 2.4rem;
flex-shrink: 0;
display: flex;
align-items: center;
@@ -105,21 +117,16 @@ const depth = inject(IK_TREE_LIST_DEPTH, ref(0))
display: flex;
align-items: center;
flex: 1;
align-self: stretch;
min-width: 0;
padding: 0.8rem 0;
text-decoration: none;
color: inherit;
gap: 1.2rem;
&:hover {
color: var(--color-neutral-txt-primary);
}
}
.text {
padding-inline-end: 0.4rem;
}
.icon {
font-size: 1.6rem;
}
@@ -138,17 +145,49 @@ const depth = inject(IK_TREE_LIST_DEPTH, ref(0))
width: 100%;
}
/* SIZE VARIANTS */
&.size--small {
height: v-bind(smallHeight);
.toggle-wrapper {
width: 1.6rem;
}
.link {
gap: 0.8rem;
}
}
&.size--medium {
border-radius: 0.8rem;
margin-bottom: 0.2rem;
.toggle-wrapper {
width: 2.4rem;
}
.link {
padding: 0.8rem 0;
gap: 1.2rem;
}
.text {
padding-inline-end: 0.4rem;
}
}
/* INTERACTION VARIANTS */
&:is(.exact-active, .active) {
background-color: var(--color-brand-background-selected);
}
&:hover {
&:hover:not(:has(.addons-wrapper :is(button, a):hover)) {
background-color: var(--color-brand-background-hover);
}
&:active {
&:active:not(:has(.addons-wrapper :is(button, a):active)) {
background-color: var(--color-brand-background-active);
}
}

View File

@@ -0,0 +1,93 @@
import { ifElse } from '@core/utils/if-else.utils.ts'
import { useLocalStorage, useRafFn, useStyleTag, useWindowSize } from '@vueuse/core'
import { computed, ref, type UnwrapRef } from 'vue'
const MIN_WIDTH = 280
const MAX_WIDTH_FLOOR = 400
const MAX_WIDTH_RATIO = 1 / 3
const DEFAULT_WIDTH_RATIO = 1 / 5
export type ResizableSide = 'left' | 'right'
export type ResizableWidth = UnwrapRef<ReturnType<typeof useResizableWidth>>
export function useResizableWidth(side: ResizableSide, storageKey: string) {
const customWidth = useLocalStorage<number | null>(storageKey, null, {
serializer: {
read: value => {
const width = Number.parseFloat(value)
return Number.isFinite(width) ? width : null
},
write: value => String(value),
},
})
const isResizing = ref(false)
const { width: windowWidth } = useWindowSize()
const maxWidth = computed(() => Math.max(MAX_WIDTH_FLOOR, windowWidth.value * MAX_WIDTH_RATIO))
const defaultWidth = computed(() => Math.max(MIN_WIDTH, windowWidth.value * DEFAULT_WIDTH_RATIO))
const width = computed(() => Math.min(maxWidth.value, customWidth.value ?? defaultWidth.value))
let initialX: number
let initialWidth: number
let clientX: number
function startResize(event: MouseEvent) {
clientX = event.clientX
initialX = clientX
initialWidth = width.value
isResizing.value = true
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
function setWidth(value: number) {
customWidth.value = Math.min(maxWidth.value, Math.max(MIN_WIDTH, value))
}
function resetWidth() {
customWidth.value = null
}
function handleMouseMove(event: MouseEvent) {
clientX = event.clientX
}
function handleMouseUp() {
isResizing.value = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
const { load, unload } = useStyleTag('body { cursor: col-resize !important; }', { immediate: false })
const resizeDelta = () => (side === 'left' ? initialWidth + clientX - initialX : initialWidth + initialX - clientX)
const { pause, resume } = useRafFn(
() => {
if (clientX === initialX) {
return
}
setWidth(resizeDelta())
},
{ immediate: false }
)
ifElse(isResizing, [load, resume], [pause, unload])
return {
width,
minWidth: MIN_WIDTH,
maxWidth,
startResize,
setWidth,
resetWidth,
isResizing,
}
}

View File

@@ -103,9 +103,11 @@ export const locales: Locales = {
},
}
export const FALLBACK_LOCALE = 'en'
export default createI18n({
locale: localStorage.getItem('lang') ?? 'en',
fallbackLocale: 'en',
fallbackLocale: FALLBACK_LOCALE,
messages,
missing: (locale, key, vm) => {
if (!import.meta.env.DEV) {

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

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "وثائق وحدة تحكم SDN",
"traffic-rules:format-warning": "خطأ في التكوين: قواعد حركة المرور ليست بالتنسيق المتوقع. يرجى {check-doc} حول كيفية ترحيلها.",
"traffic-rules:format-warning:check-doc": "مراجعة أحدث الوثائق",
"traffic-rules:info-message": "ترتيب قواعد حركة المرور قيد التنفيذ. قد يكون السلوك غير مضمون.",
"traffic-rules:no-traffic-rules-detected": "لم يتم اكتشاف أي قواعد حركة مرور",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "يتطلب وضع إضافة XAPI",

View File

@@ -1204,7 +1204,6 @@
"traffic-rules:error-plugin:check-doc": "dokumentace k řadiči SDN sítě",
"traffic-rules:format-warning": "Chyba nastavení: Pravidla pro provoz nejsou v očekávaném formátu. {check-doc} ohledně toho, jak je převést.",
"traffic-rules:format-warning:check-doc": "Podívejte se do neaktuálnější dokumentace",
"traffic-rules:info-message": "Implementování pořadí pravidel pro provoz teprve probíhá. Chování může být nebezpečné.",
"traffic-rules:no-traffic-rules-detected": "Nezjištěna žádná pravidla pro provoz",
"traffic-rules:xapi-plugin-required": "Vyžaduje režim XAPI zásuvného modulu",
"translation-tool": "Nástroj pro překládání",

View File

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "dokumentationen til SDN-controlleren",
"traffic-rules:format-warning": "Konfigurationsfejl: Trafikreglerne er ikke i det forventede format. Du bedes {check-doc} for, hvordan de migreres.",
"traffic-rules:format-warning:check-doc": "tjekke den seneste dokumentation",
"traffic-rules:info-message": "Rækkefølge for trafikregler er under implementering. Adfærden kan være uforudsigelig.",
"traffic-rules:no-traffic-rules-detected": "Ingen trafikregler registreret",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "Kræver XAPI-plugin-tilstand",

View File

@@ -887,7 +887,6 @@
"total-used:": "Gesamt genutzt:",
"traffic-rule": "Traffic Regel",
"traffic-rules": "Traffic Regeln",
"traffic-rules:info-message": "Reihenfolge der Traffic Regeln wird implementiert. Es kann zu unvorgesehenen Verhalten führen.",
"traffic-rules:no-traffic-rules-detected": "Keine Traffic Regeln erkannt",
"translation-tool": "Übersetzungstool",
"trigger": "Trigger",

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",
@@ -138,6 +141,7 @@
"action:reset-all-networks": "Reset all networks",
"action:reset-all-srs": "Reset all SRs",
"action:reset-to-default": "Reset to default",
"action:resize": "Resize",
"action:restart-toolstack": "Restart toolstack",
"action:resume": "Resume",
"action:revert-to-snapshot": "Revert to snapshot",
@@ -283,6 +287,7 @@
"bond-mode": "Bond mode",
"bond-mode-required": "Bond mode is required.",
"bond-status": "Bond status",
"bonded-network": "Bonded network",
"boot-firmware": "Boot firmware",
"bootable": "Bootable",
"br-details": "Backup repository details",
@@ -537,6 +542,7 @@
"interface": "Interface",
"interface-required": "Interface is required.",
"interfaces": "Interfaces",
"internal-network": "Internal network",
"interrupted": "Interrupted",
"invalid-field": "Invalid field",
"invalid-ip-addresses": "One or more invalid IP addresses",
@@ -571,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",
@@ -687,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",
@@ -1026,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",
@@ -1076,6 +1086,7 @@
"pbd-details": "Physical Block Device (PBD) details",
"pending": "Pending",
"physical-interface-status": "Physical interface status",
"physical-network": "Physical network",
"pif": "PIF",
"pif-status": "PIF status",
"pifs": "PIFs",
@@ -1127,6 +1138,8 @@
"power-on-mode": "Power on mode",
"power-on-vm-for-console": "Power on your VM to access its console",
"power-state": "Power state",
"priority": "Priority",
"priority-within": "Priority within {name}",
"pro-support": "{name} pro support",
"professional-support": "Professional support",
"progress": "Progress",
@@ -1364,6 +1377,7 @@
"theme-solarized-description": "Precision colors for reduced eye strain",
"third-parties": "Third party apps",
"this-host": "This host",
"this-rule": "This rule",
"this-vm-cant-be-migrated": "This VM can't be migrated",
"timeout": "Timeout",
"timezone": "Timezone",
@@ -1387,14 +1401,18 @@
"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.",
"traffic-rules:format-warning:check-doc": "check the latest documentation",
"traffic-rules:info-message": "Traffic rules order is being implemented. Behaviour may be hazardous.",
"traffic-rules:no-traffic-rules-detected": "No traffic rules detected",
"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",
@@ -1546,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

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "مستندات کنترلر SDN",
"traffic-rules:format-warning": "خطای پیکربندی: قوانین ترافیک در قالب مورد انتظار نیستند. لطفاً برای نحوه مهاجرت آن‌ها {check-doc}.",
"traffic-rules:format-warning:check-doc": "آخرین مستندات را بررسی کنید",
"traffic-rules:info-message": "ترتیب قوانین ترافیک در حال پیاده‌سازی است. رفتار ممکن است مخاطره‌آمیز باشد.",
"traffic-rules:no-traffic-rules-detected": "هیچ قانون ترافیکی شناسایی نشد",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "نیازمند حالت افزونه XAPI",

View File

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "SDN-ohjaimen dokumentaatio",
"traffic-rules:format-warning": "Määritysvirhe: liikennesäännöt eivät ole odotetussa muodossa. {check-doc}, miten ne siirretään.",
"traffic-rules:format-warning:check-doc": "Tarkista uusimmasta dokumentaatiosta",
"traffic-rules:info-message": "Liikennesääntöjen järjestystä ollaan toteuttamassa. Toiminta voi olla arvaamatonta.",
"traffic-rules:no-traffic-rules-detected": "Liikennesääntöjä ei havaittu",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "Vaatii XAPI-lisäosatilan",

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",
@@ -138,6 +141,7 @@
"action:reset-all-networks": "Réinitialiser tous les réseaux",
"action:reset-all-srs": "Réinitialiser tous les SRs",
"action:reset-to-default": "Rétablir la valeur par défaut",
"action:resize": "Redimensionner",
"action:restart-toolstack": "Redémarrer le toolstack",
"action:resume": "Reprendre",
"action:revert-to-snapshot": "Revenir à l'instantané",
@@ -283,6 +287,7 @@
"bond-mode": "Mode de liaison",
"bond-mode-required": "Le mode de liaison est requis.",
"bond-status": "Statut du bond",
"bonded-network": "Réseau lié",
"boot-firmware": "Firmware de démarrage",
"bootable": "Amorçable",
"br-details": "Détails du BR",
@@ -537,6 +542,7 @@
"interface": "Interface",
"interface-required": "L'interface est requise.",
"interfaces": "Interfaces",
"internal-network": "Réseau interne",
"interrupted": "Interrompu",
"invalid-field": "Champ invalide",
"invalid-ip-addresses": "Une ou plusieurs adresses IPs invalides",
@@ -571,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",
@@ -687,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",
@@ -1026,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",
@@ -1076,6 +1086,7 @@
"pbd-details": "Détails du périphérique de bloc physique (PBD)",
"pending": "En cours",
"physical-interface-status": "Statut de l'interface physique",
"physical-network": "Réseau physique",
"pif": "PIF",
"pif-status": "Statut du PIF",
"pifs": "PIFs",
@@ -1127,6 +1138,8 @@
"power-on-mode": "Mode d'alimentation",
"power-on-vm-for-console": "Allumez votre VM pour accéder à sa console",
"power-state": "État d'alimentation",
"priority": "Priorité",
"priority-within": "Priorité dans {name}",
"pro-support": "Support pro {name}",
"professional-support": "Support professionnel",
"progress": "Progression",
@@ -1364,6 +1377,7 @@
"theme-solarized-description": "Couleurs précises pour réduire la fatigue oculaire",
"third-parties": "Apps tierces",
"this-host": "Cet hôte",
"this-rule": "Cette règle",
"this-vm-cant-be-migrated": "Cette VM ne peut pas être migrée",
"timeout": "Délai d'attente",
"timezone": "Fuseau horaire",
@@ -1387,14 +1401,18 @@
"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.",
"traffic-rules:format-warning:check-doc": "consulter la documentation",
"traffic-rules:info-message": "Les règles de trafic sont en cours d'application. Certains comportements peuvent être hasardeux.",
"traffic-rules:no-traffic-rules-detected": "Aucune règle de trafic détectée",
"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",
@@ -1546,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

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "la documentazione del controller SDN",
"traffic-rules:format-warning": "Errore di configurazione: le regole di traffico non sono nel formato previsto. {check-doc} su come migrarle.",
"traffic-rules:format-warning:check-doc": "Consulta la documentazione più recente",
"traffic-rules:info-message": "L'ordine delle regole di traffico è in fase di implementazione. Il comportamento potrebbe essere imprevedibile.",
"traffic-rules:no-traffic-rules-detected": "Nessuna regola di traffico rilevata",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "Richiede la modalità plugin XAPI",

View File

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "SDN コントローラーのドキュメント",
"traffic-rules:format-warning": "設定エラー: トラフィックルールが想定された形式ではありません。移行方法については{check-doc}。",
"traffic-rules:format-warning:check-doc": "最新のドキュメントを確認してください",
"traffic-rules:info-message": "トラフィックルールの順序は実装中です。動作が不安定な場合があります。",
"traffic-rules:no-traffic-rules-detected": "トラフィックルールは検出されませんでした",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "XAPI プラグインモードが必要です",

View File

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "SDN 컨트롤러 문서",
"traffic-rules:format-warning": "구성 오류: 트래픽 규칙이 예상된 형식이 아닙니다. 마이그레이션 방법은 {check-doc}.",
"traffic-rules:format-warning:check-doc": "최신 문서를 확인하세요",
"traffic-rules:info-message": "트래픽 규칙 순서 기능을 구현 중입니다. 동작이 불안정할 수 있습니다.",
"traffic-rules:no-traffic-rules-detected": "감지된 트래픽 규칙 없음",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "XAPI 플러그인 모드 필요",

View File

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "dokumentasjonen for SDN-kontrolleren",
"traffic-rules:format-warning": "Konfigurasjonsfeil: Trafikkreglene har ikke forventet format. Vennligst {check-doc} om hvordan du migrerer dem.",
"traffic-rules:format-warning:check-doc": "se den nyeste dokumentasjonen",
"traffic-rules:info-message": "Rekkefølge for trafikkregler er under implementering. Oppførselen kan være uforutsigbar.",
"traffic-rules:no-traffic-rules-detected": "Ingen trafikkregler oppdaget",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "Krever XAPI-plugin-modus",

View File

@@ -1171,7 +1171,6 @@
"traffic-rules:error-plugin:check-doc": "de documentatie van de SDN-controller",
"traffic-rules:format-warning": "Configuratiefout: De verkeersregels hebben niet de verwachte indeling. Raadpleeg {check-doc} voor informatie over hoe je ze kunt migreren.",
"traffic-rules:format-warning:check-doc": "raadpleeg de meest recente documentatie",
"traffic-rules:info-message": "De verkeersregels worden gehandhaafd. Het gedrag kan gevaarlijk zijn.",
"traffic-rules:no-traffic-rules-detected": "Er zijn geen verkeersregels gevonden",
"traffic-rules:xapi-plugin-required": "Vereist de XAPI-plug-in modus",
"translation-tool": "Vertaalprogramma",

View File

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "dokumentację kontrolera SDN",
"traffic-rules:format-warning": "Błąd konfiguracji: reguły ruchu nie są w oczekiwanym formacie. {check-doc}, jak je zmigrować.",
"traffic-rules:format-warning:check-doc": "Sprawdź w najnowszej dokumentacji",
"traffic-rules:info-message": "Kolejność reguł ruchu jest w trakcie implementacji. Działanie może być nieprzewidywalne.",
"traffic-rules:no-traffic-rules-detected": "Nie wykryto reguł ruchu",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "Wymaga trybu wtyczki XAPI",

View File

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "a documentação do controlador SDN",
"traffic-rules:format-warning": "Erro de configuração: as regras de tráfego não estão no formato esperado. Por favor, {check-doc} sobre como migrá-las.",
"traffic-rules:format-warning:check-doc": "consulte a documentação mais recente",
"traffic-rules:info-message": "A ordem das regras de tráfego está sendo implementada. O comportamento pode ser imprevisível.",
"traffic-rules:no-traffic-rules-detected": "Nenhuma regra de tráfego detectada",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "Requer o modo de plugin XAPI",

View File

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "документацию SDN-контроллера",
"traffic-rules:format-warning": "Ошибка конфигурации: правила для трафика имеют неожиданный формат. Пожалуйста, {check-doc} о том, как их мигрировать.",
"traffic-rules:format-warning:check-doc": "ознакомьтесь с последней документацией",
"traffic-rules:info-message": "Порядок правил для трафика находится в разработке. Поведение может быть непредсказуемым.",
"traffic-rules:no-traffic-rules-detected": "Правила для трафика не обнаружены",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "Требуется режим плагина XAPI",

View File

@@ -1171,7 +1171,6 @@
"traffic-rules:error-plugin:check-doc": "dokumentácia k radiču SDN siete",
"traffic-rules:format-warning": "Chyba nastavenia: Pravidlá pre prevádzku nie sú v očakávanom formáte. {check-doc} ohľadom toho, ako ich previesť.",
"traffic-rules:format-warning:check-doc": "Pozrite sa do najaktuálnejšej dokumentácie",
"traffic-rules:info-message": "Implementácia poradia pravidiel pre prevádzku ešte prebieha. Chovanie môže byť nebezpečné.",
"traffic-rules:no-traffic-rules-detected": "Nezjištěná žiadna pravidla pre prevádzku",
"traffic-rules:xapi-plugin-required": "Vyžaduje režim XAPI zásuvného modulu",
"translation-tool": "Prekladový nástroj",

View File

@@ -1090,7 +1090,6 @@
"total-used:": "Totalt använt:",
"traffic-rule": "Trafikregel",
"traffic-rules": "Trafikregler",
"traffic-rules:info-message": "Trafikreglernas ordning införs. Beteendet kan vara farligt.",
"traffic-rules:no-traffic-rules-detected": "Inga trafikregler upptäckta",
"translation-tool": "Översättningsverktyg",
"trigger": "Utlösare",

View File

@@ -1161,7 +1161,6 @@
"traffic-rules:error-plugin:check-doc": "SDN denetleyicisi belgelerine",
"traffic-rules:format-warning": "Yapılandırma hatası: Trafik kuralları beklenen biçimde değil. Bunları nasıl taşıyacağınızı öğrenmek için lütfen {check-doc}.",
"traffic-rules:format-warning:check-doc": "en son belgelere bakın",
"traffic-rules:info-message": "Trafik kurallarının sırası henüz uygulanıyor. Davranış riskli olabilir.",
"traffic-rules:no-traffic-rules-detected": "Trafik kuralı bulunamadı",
"traffic-rules:xapi-plugin-required": "XAPI eklenti modu gerekir",
"translation-tool": "Çeviri aracı",

View File

@@ -1241,7 +1241,6 @@
"traffic-rules:error-plugin:check-doc": "документацію контролера SDN",
"traffic-rules:format-warning": "Помилка конфігурації: правила трафіку мають неочікуваний формат. Будь ласка, {check-doc}, щоб дізнатися, як їх мігрувати.",
"traffic-rules:format-warning:check-doc": "перегляньте найновішу документацію",
"traffic-rules:info-message": "Порядок правил трафіку ще впроваджується. Поведінка може бути непередбачуваною.",
"traffic-rules:no-traffic-rules-detected": "Правил трафіку не виявлено",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "Потрібен режим плагіна XAPI",

View File

@@ -1171,7 +1171,6 @@
"traffic-rules:error-plugin:check-doc": "SDN控制器文档",
"traffic-rules:format-warning": "配置错误:流量规则格式异常,请参考{check-doc}完成迁移。",
"traffic-rules:format-warning:check-doc": "最新文档",
"traffic-rules:info-message": "流量规则顺序正在生效,行为可能存在异常。",
"traffic-rules:no-traffic-rules-detected": "未检测到流量规则",
"traffic-rules:xapi-plugin-required": "需要XAPI插件模式",
"translation-tool": "翻译工具",

View File

@@ -1,5 +1,5 @@
import { useResizableWidth } from '@core/composables/resizable-width.composable.ts'
import { SIDEBAR_DEFAULTS, type SidebarSide } from '@core/packages/sidebar/types.ts'
import { useSidebarResize } from '@core/packages/sidebar/use-sidebar-resize.ts'
import { useSidebarResponsiveExpand } from '@core/packages/sidebar/use-sidebar-responsive-expand.ts'
import { useUiStore } from '@core/stores/ui.store.ts'
import { useLocalStorage, useToggle } from '@vueuse/core'
@@ -13,15 +13,14 @@ function createSidebar(side: SidebarSide) {
const isExpanded = useLocalStorage(`${prefix}.expanded`, defaults.isExpanded)
const isLocked = useLocalStorage(`${prefix}.locked`, defaults.isLocked)
const width = useLocalStorage(`${prefix}.width`, defaults.width)
const toggleExpand = useToggle(isExpanded)
const toggleLock = useToggle(isLocked)
const { startResize, isResizing } = useSidebarResize(side, width)
const resizableWidth = useResizableWidth(side, `${prefix}.custom-width`)
useSidebarResponsiveExpand(isExpanded)
const cssWidth = computed(() => (uiStore.isSmall ? '100%' : `${width.value}px`))
const cssWidth = computed(() => (uiStore.isSmall ? '100%' : `${resizableWidth.width.value}px`))
const cssLockedOffset = computed(() => (isExpanded.value ? '0' : `-${cssWidth.value}`))
@@ -36,14 +35,12 @@ function createSidebar(side: SidebarSide) {
return {
isExpanded,
isLocked,
width,
toggleExpand,
toggleLock,
startResize,
isResizing,
cssWidth,
cssLockedOffset,
cssUnlockedOffset,
...resizableWidth,
}
}

View File

@@ -1,21 +1,20 @@
import type { ResizableWidth } from '@core/composables/resizable-width.composable.ts'
export const SIDES = ['left', 'right'] as const
export type SidebarSide = (typeof SIDES)[number]
export type SidebarState = {
export type SidebarState = ResizableWidth & {
isExpanded: boolean
isLocked: boolean
width: number
toggleExpand: (value?: boolean) => boolean
toggleLock: (value?: boolean) => boolean
startResize: (event: MouseEvent) => void
isResizing: boolean
cssWidth: string
cssLockedOffset: string | number
cssUnlockedOffset: string | number
}
export const SIDEBAR_DEFAULTS = {
left: { isExpanded: true, isLocked: true, width: 350 },
right: { isExpanded: false, isLocked: false, width: 350 },
} satisfies Record<SidebarSide, Pick<SidebarState, 'isExpanded' | 'isLocked' | 'width'>>
left: { isExpanded: true, isLocked: true },
right: { isExpanded: false, isLocked: false },
} satisfies Record<SidebarSide, Pick<SidebarState, 'isExpanded' | 'isLocked'>>

View File

@@ -1,47 +0,0 @@
import type { SidebarSide } from '@core/packages/sidebar/types.ts'
import { ifElse } from '@core/utils/if-else.utils.ts'
import { useRafFn, useStyleTag } from '@vueuse/core'
import { ref, type Ref } from 'vue'
export function useSidebarResize(side: SidebarSide, width: Ref<number>) {
const isResizing = ref(false)
let initialX: number
let initialWidth: number
let clientX: number
function startResize(event: MouseEvent) {
clientX = event.clientX
initialX = clientX
initialWidth = width.value
isResizing.value = true
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
function handleMouseMove(event: MouseEvent) {
clientX = event.clientX
}
function handleMouseUp() {
isResizing.value = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
const { load, unload } = useStyleTag('body { cursor: col-resize !important; }', { immediate: false })
const resizeDelta = () => (side === 'left' ? initialWidth + clientX - initialX : initialWidth + initialX - clientX)
const { pause, resume } = useRafFn(
() => (width.value = Math.min(500, window.innerWidth * 0.3, Math.max(200, resizeDelta()))),
{ immediate: false }
)
ifElse(isResizing, [load, resume], [pause, unload])
return {
startResize,
isResizing,
}
}

View File

@@ -12,6 +12,8 @@ export type Identifiable = { id: TreeNodeId }
export type Labeled = { label: string }
export const TREE_ITEM_SMALL_HEIGHT = 32
type AcceptableKeys<TData, TAccepted> = {
[K in keyof TData]: TData[K] extends TAccepted ? K : never
}[keyof TData]

View File

@@ -1,13 +1,20 @@
import { useResizableWidth } from '@core/composables/resizable-width.composable.ts'
import { useUiStore } from '@core/stores/ui.store.ts'
import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, watch } from 'vue'
export const PANEL_TRANSITION_DURATION = 250
export const usePanelStore = defineStore('panel', () => {
const uiStore = useUiStore()
const isExpanded = useLocalStorage('panel.expanded', true)
const isLocked = useLocalStorage('panel.locked', true)
const resizableWidth = useResizableWidth('right', 'panel.custom-width')
const cssWidth = computed(() => `${resizableWidth.width.value}px`)
const cssHorizontalOffset = computed(() => (isExpanded.value ? 0 : '100%'))
/* Panel closes automatically when it has no content (true on small UI or when not locked) */
const syncsOpenStateWithSelection = computed(() => uiStore.isSmall || !isLocked.value)
@@ -59,5 +66,7 @@ export const usePanelStore = defineStore('panel', () => {
toggleLock,
syncWithSelection,
cssHorizontalOffset,
cssWidth,
...resizableWidth,
}
})

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 { useNumberColumn } from '@core/tables/column-definitions/number-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'
@@ -11,7 +13,9 @@ export const useTrafficRulesColumns = defineColumns(() => {
const { t } = useI18n()
return {
order: useNumberColumn(),
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'),
@@ -23,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

@@ -1,5 +1,7 @@
import type { InputWrapperController } from '@core/components/input-wrapper/VtsInputWrapper.vue'
import type { InfoSize } from '@core/components/ui/info/UiInfo.vue'
import type { ModalAccent } from '@core/components/ui/modal/UiModal.vue'
import type { TreeItemLabelSize } from '@core/components/ui/tree-item-label/UiTreeItemLabel.vue'
import type { ValueFormatter } from '@core/types/chart.ts'
import type { ComputedRef, InjectionKey, Ref } from 'vue'
@@ -13,6 +15,8 @@ export const IK_TREE_ITEM_EXPANDED = Symbol('IK_TREE_ITEM_EXPANDED') as Injectio
export const IK_TREE_LIST_DEPTH = Symbol('IK_TREE_LIST_DEPTH') as InjectionKey<Ref<number>>
export const IK_TREE_ITEM_LABEL_SIZE = Symbol('IK_TREE_ITEM_LABEL_SIZE') as InjectionKey<TreeItemLabelSize>
export const IK_MENU_HORIZONTAL = Symbol('IK_MENU_HORIZONTAL') as InjectionKey<ComputedRef<boolean>>
export const IK_CLOSE_MENU = Symbol('IK_CLOSE_MENU') as InjectionKey<() => void>
@@ -24,3 +28,5 @@ export const IK_DISABLED = Symbol('IK_DISABLED') as InjectionKey<ComputedRef<boo
export const IK_INPUT_WRAPPER_CONTROLLER = Symbol('IK_INPUT_WRAPPER_CONTROLLER') as InjectionKey<InputWrapperController>
export const IK_OVERLAY_ACCENT = Symbol('IK_OVERLAY_ACCENT') as InjectionKey<ComputedRef<ModalAccent>>
export const IK_INFO_SIZE = Symbol('IK_INFO_SIZE') as InjectionKey<InfoSize>

View File

@@ -2,7 +2,7 @@
This guide documents the unit-testing conventions used in `@xen-orchestra/web`, as established across the `pool`, `host` and `vm` modules. Follow it when adding tests for a new module, composable, utility or component.
**Start with [Assert behaviour, not implementation](./tests/assert-behaviour.md)** — it is the rule the rest of this guide serves.
**Start with [Assert behavior, not implementation](./tests/assert-behavior.md)** — it is the rule the rest of this guide serves.
## What to test first
@@ -16,12 +16,12 @@ Testing a page is a different job from testing a component, and the component is
## Getting started
- [Assert behaviour, not implementation](./tests/assert-behaviour.md) — the one rule that outranks the others
- [Assert behavior, not implementation](./tests/assert-behavior.md) — the one rule that outranks the others
- [Test runner](./tests/running-tests.md) — Vitest setup and the commands to run a suite
## Writing a test
- [File layout and structure](./tests/file-structure.md) — where a test lives, how it is named, how it is organised
- [File layout and structure](./tests/file-structure.md) — where a test lives, how it is named, how it is organized
- [Setup: prefer helpers over `beforeEach`](./tests/setup-helpers.md) — local helpers, and the shared `use<X>EnhancedData` ones
- [Test data factories](./tests/factories.md) — the `src/test/create*` factories and how to use them

View File

@@ -1,6 +1,6 @@
# Assert behaviour, not implementation
# Assert behavior, not implementation
**This is the rule the rest of the guide serves.** A test earns its place by failing when behaviour breaks, and staying green through any refactor that preserves it.
**This is the rule the rest of the guide serves.** A test earns its place by failing when behavior breaks, and staying green through any refactor that preserves it.
- Assert concrete outputs and contracts (`input → output`). Never assert _how_ the result was reached — which helper ran, in which order, how many times.
- Every test must assert something meaningful. Never re-assert a mock's own return value: `getHostById.mockReturnValue(host)` followed by `expect(…).toBe(host)` tests the mock, not the code.
@@ -8,7 +8,7 @@
```typescript
// ✗ pins a format the code under test does not own — renaming the icon scheme or rewording a
// translation then breaks the test even though the behaviour is unchanged
// translation then breaks the test even though the behavior is unchanged
expect(displayData.vmIcon).toBe('object:vm:running')
expect(wrapper.vm.powerState.text).toBe('Running')

View File

@@ -10,7 +10,7 @@
## Structuring a test file
- **One `describe` block per function or exposed property**, labelled with its exact name (`describe('getHostInfo')`, `describe('powerState')`). A component test has no such members: group with `describe` only to separate genuinely distinct behaviours, otherwise flat `it`s read better.
- **One `describe` block per function or exposed property**, labeled with its exact name (`describe('getHostInfo')`, `describe('powerState')`). A component test has no such members: group with `describe` only to separate genuinely distinct behaviors, otherwise flat `it`s read better.
- Use `it` (not `test`).
- The name completes the sentence the `it` starts: a **present-tense assertion**, lowercase, no trailing period, and **without a redundant prefix** — the `describe` label already carries the function name. `it('formats the core count with its socket count')` reads as one sentence; `it('getHostCoreSocketInfo formats …')` stutters.

View File

@@ -23,7 +23,7 @@ it('shows VGA as enabled when the VM uses the "std" adapter', () => {
})
```
The test file sits next to the component, named `<ComponentName>.unit.test.ts` (no `.vue`). Group with `describe` only when the component has several distinct behaviours to separate; a small component reads better as flat `it`s.
The test file sits next to the component, named `<ComponentName>.unit.test.ts` (no `.vue`). Group with `describe` only when the component has several distinct behaviors to separate; a small component reads better as flat `it`s.
## Keep components thin
@@ -40,7 +40,7 @@ The extraction is a refactor, so the tests come **first** and are what proves it
3. Re-run those tests **unchanged**.
4. Move the ones the extraction made redundant down next to the new util/composable, and delete them from the component test.
Step 3 is the gate. A test that has to be edited to pass is reporting a behaviour change — say so, do not absorb it into the diff. Tests written only _after_ an extraction describe the new code and cannot catch what the extraction broke, which is the whole risk being taken.
Step 3 is the gate. A test that has to be edited to pass is reporting a behavior change — say so, do not absorb it into the diff. Tests written only _after_ an extraction describe the new code and cannot catch what the extraction broke, which is the whole risk being taken.
Step 4 is selective. A test that only exercised the logic now living in the util moves; a test asserting something the component alone does — rendering, conditional rows, empty states, feeding the util's output into the template — stays.
@@ -63,12 +63,13 @@ Prefer this shape: one assertion covering every row a user sees, and it fails lo
`src/test/find-rendered-values.ts` does exactly that reduction, so a card test does not re-roll it:
| Helper | Reads |
| ------------------------ | ---------------------------------------------------------- |
| `findLabelledValues` | every `VtsTabularKeyValueRow` / `VtsKeyValueRow` of a card |
| `findCardLabelledValues` | every `VtsCardRowKeyValue` of a side-panel card |
| `findCopiedValues` | what each copyable key/value row copies, keyed by label |
| `findCardCopiedValues` | what each copyable `VtsCardRowKeyValue` copies, in order |
| Helper | Reads |
| ---------------------------- | ------------------------------------------------------------------ |
| `findLabelledValues` | every `VtsTabularKeyValueRow` / `VtsKeyValueRow` of a card |
| `findCardLabelledValues` | every `VtsCardRowKeyValue` of a side-panel card |
| `findCardLabelledValueLists` | every `VtsCardRowKeyValue`, each value of a multi-value row listed |
| `findCopiedValues` | what each copyable key/value row copies, keyed by label |
| `findCardCopiedValues` | what each copyable `VtsCardRowKeyValue` copies, in order |
```typescript
expect(findLabelledValues(wrapper)).toEqual({ [t('vga')]: t('disabled'), [t('video-ram')]: '8 B' })
@@ -80,13 +81,10 @@ A row offers a copy button through its `copy` prop (copies its `value`) or its `
expect(findCopiedValues(wrapper)).toEqual({ [t('ip-address')]: '10.0.0.1', [t('uuid')]: 'vm-42' })
```
Both collapse rows that share a label into one entry — a list repeating the same label (an address list labelling only its first row) is queried directly instead:
Both read one value per row. A `VtsCardRowKeyValue` given several `values` (an address list, report recipients) renders them all under a single label, so read it with `findCardLabelledValueLists` instead:
```typescript
expect(wrapper.findAll('.vts-card-row-key-value').map(row => row.get('.value').text())).toEqual([
'10.0.0.1',
'10.0.0.2',
])
expect(findCardLabelledValueLists(wrapper)).toEqual({ [t('ip-addresses')]: ['10.0.0.1', '10.0.0.2'] })
```
When a card mixes deterministic values with environment-derived ones (a relative start time, a formatted date), pin the deterministic rows with `toMatchObject` and cover the composition separately by asserting the **list of labels, in order** — the formatted values themselves belong in the test of the composable that derives them.
@@ -109,7 +107,7 @@ expect(findTableRows(wrapper)).toEqual([
### Never spell out a translation
A rendered label or a translated value is wording the component does not own, so [asserting behaviour](./assert-behaviour.md) means naming the **key**, not the English string. `src/test/i18n.ts` exposes the `t` of the very instance `createGlobalTestConfig()` installs:
A rendered label or a translated value is wording the component does not own, so [asserting behavior](./assert-behavior.md) means naming the **key**, not the English string. `src/test/i18n.ts` exposes the `t` of the very instance `createGlobalTestConfig()` installs:
```typescript
// ✗ rewording the translation breaks the test although the component still picks the same key
@@ -230,7 +228,7 @@ Pushing before mounting is what makes the active tab assertable; without a push
There is no layout engine, and Vitest's `css` option is off, so component styles are never loaded. Out of reach:
- `toBeVisible()` and anything depending on real visibility — assert presence/absence instead (`expect(wrapper.find('.foo').exists()).toBe(false)`)
- `getComputedStyle` assertions on design tokens (colour, padding, `flex-direction`)
- `getComputedStyle` assertions on design tokens (color, padding, `flex-direction`)
- real event dispatch — `trigger()` simulates events
- canvas (ECharts), `ResizeObserver`, `IntersectionObserver` — for a chart card, stub the chart (see [Chart cards](#chart-cards))

View File

@@ -17,7 +17,7 @@ expect(wrapper.vm.powerState.text).toBe(wrapper.vm.t('vm:status:running'))
The composable result becomes the component state, so `wrapper.vm` exposes it with refs **already unwrapped** — no `.value`. Read through `wrapper.vm` rather than destructuring it: a destructured value is a snapshot and stops following updates.
Because the real i18n instance is installed, translations resolve to actual strings. Return `t` from the `setup` callback so the assertion can build its expected label with it instead of hard-coding the wording — see [Assert behaviour](./assert-behaviour.md).
Because the real i18n instance is installed, translations resolve to actual strings. Return `t` from the `setup` callback so the assertion can build its expected label with it instead of hard-coding the wording — see [Assert behavior](./assert-behavior.md).
The wrapper is unmounted after the test, so `onUnmounted` and `onScopeDispose` cleanup runs and no watcher of a previous test can re-fire in the next one.
@@ -57,6 +57,6 @@ expect(wrapper.vm.relativeStartTime).toBe(wrapper.vm.expectedRelativeTime)
Note the object literal: `setup()` returns the component **bindings**, so the callback must hand back an object. A lone ref is not component state and would not surface on `wrapper.vm`. Anything the assertion needs — the `t`, a `locale`, an expected value — is returned alongside, which is why the callback body is a block rather than a bare object.
The expected value is **derived the way production derives it** ([Assert behaviour](./assert-behaviour.md)): `getRelativeTime` owns the wording, and what the composable decides is which date to hand it. `expect(typeof …).toBe('string')` would pass for any string, including one built from a start time read as milliseconds instead of seconds — it asserts the type, not the behaviour. Where a value is environment-dependent, name the helper production uses and call it with the same input; never assert merely that the value is non-empty or differs from some other label.
The expected value is **derived the way production derives it** ([Assert behavior](./assert-behavior.md)): `getRelativeTime` owns the wording, and what the composable decides is which date to hand it. `expect(typeof …).toBe('string')` would pass for any string, including one built from a start time read as milliseconds instead of seconds — it asserts the type, not the behavior. Where a value is environment-dependent, name the helper production uses and call it with the same input; never assert merely that the value is non-empty or differs from some other label.
Functions that only touch refs/computed (e.g. `getPowerState`) can be called on the returned object as usual.

View File

@@ -12,7 +12,7 @@
</VtsCardRowKeyValue>
<template v-if="isSmartModeEnabled">
<VtsDivider class="divider" type="stretch" />
<div class="content">
<VtsCardRowList class="content">
<VtsCardRowKeyValue v-if="smartModePowerState !== undefined">
<template #key>
{{ t('power-state') }}
@@ -42,17 +42,13 @@
<BackupJobSmartModeTagsItem :tags="smartModeTags.included" :label="t('vms-tags')" />
<BackupJobSmartModeTagsItem :tags="smartModeTags.excluded" :label="t('excluded-vms-tags')" />
</template>
</div>
</VtsCardRowList>
</template>
<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,7 +63,10 @@ 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'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
@@ -76,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<{
@@ -86,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">
@@ -95,10 +101,6 @@ const { backedUpVms, backedUpVmsCount, smartModePools, smartModePowerState, smar
}
.content {
display: flex;
flex-direction: column;
gap: 1.6rem;
.smart-mode-power-state-value {
display: flex;
align-items: center;

View File

@@ -9,7 +9,7 @@ import type {
import type { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import { createMetadataBackupJob, createVmBackupJob } from '@/test/create-backup-job.ts'
import { createSr } from '@/test/create-sr.ts'
import { findCardLabelledValues } from '@/test/find-rendered-values.ts'
import { findCardLabelledValueLists, findCardLabelledValues } from '@/test/find-rendered-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import VtsCopyAllMenuItem from '@core/components/copy-all-menu-item/VtsCopyAllMenuItem.vue'
@@ -95,7 +95,7 @@ it('lists every type of the backup job, offering to copy them all', () => {
const wrapper = mountInfosCard(backupJob)
const typeLabels = [t('backup:rolling-snapshot'), t('backup:vms-backup'), t('backup:vms-replication')]
expect(findValues(wrapper).slice(0, 3)).toEqual(typeLabels)
expect(findCardLabelledValueLists(wrapper)[t('type')]).toEqual(typeLabels)
expect(wrapper.getComponent(VtsCopyAllMenuItem).props('values')).toEqual(typeLabels)
})

View File

@@ -1,5 +1,5 @@
<template>
<UiPanelCard class="backup-job-infos-card">
<UiPanelCard>
<UiPanelCardTitle
:id="backupJob.id"
size="medium"
@@ -7,16 +7,9 @@
:href="jobOverviewHref"
icon="object:backup-job"
/>
<div class="content">
<VtsCardRowKeyValue v-for="(typeLabel, index) in typeLabels" :key="typeLabel">
<template #key>
<template v-if="index === 0">{{ t('type') }}</template>
</template>
<template #value>{{ typeLabel }}</template>
<template #addons>
<VtsCopyButton :value="typeLabel" />
<VtsCopyAllMenuItem v-if="index === 0 && typeLabels.length > 1" :values="typeLabels" />
</template>
<VtsCardRowList>
<VtsCardRowKeyValue :values="typeLabels">
<template #key>{{ t('type') }}</template>
</VtsCardRowKeyValue>
<VtsCardRowKeyValue>
<template #key>{{ t('mode') }}</template>
@@ -58,7 +51,7 @@
<template #value />
</VtsCardRowKeyValue>
</template>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
@@ -74,7 +67,7 @@ import { useXoSrUtils } from '@/modules/storage-repository/composables/xo-sr-uti
import { useXoSrCollection } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCopyAllMenuItem from '@core/components/copy-all-menu-item/VtsCopyAllMenuItem.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
@@ -111,13 +104,3 @@ const healthCheckSrs = computed(() =>
})
)
</script>
<style scoped lang="postcss">
.backup-job-infos-card {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>

View File

@@ -1,14 +1,14 @@
<template>
<UiPanelCard class="backup-job-logs-card">
<UiPanelCardTitle size="medium" :label="t('last-n-runs', { n: BACKUP_JOB_LAST_RUNS_COUNT })" />
<div class="content">
<VtsCardRowList class="content">
<template v-for="index in BACKUP_JOB_LAST_RUNS_COUNT" :key="index">
<VtsDivider v-if="index > 1" class="divider" type="stretch" />
<UiPanelCardTitle size="small" :label="t('last-run-number', { n: index })" />
<BackupJobRunItem v-if="backupLogs[index - 1]" :backup-run="backupLogs[index - 1]" />
<span v-else class="no-run typo-body-regular-small">{{ t('no-run') }}</span>
</template>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
@@ -16,6 +16,7 @@
import BackupJobRunItem from '@/modules/backup-job/components/list/panel/card-items/BackupJobRunItem.vue'
import { BACKUP_JOB_LAST_RUNS_COUNT } from '@/modules/backup-job/utils/xo-backup-job.util.ts'
import type { FrontXoBackupLog } from '@/modules/backup-log/remote-resources/use-xo-backup-log-collection.ts'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsDivider from '@core/components/divider/VtsDivider.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
@@ -31,10 +32,6 @@ const { t } = useI18n()
<style scoped lang="postcss">
.backup-job-logs-card {
.content {
display: flex;
flex-direction: column;
gap: 0.6rem;
.divider {
margin-block: 1.6rem;
}

View File

@@ -1,7 +1,7 @@
<template>
<UiPanelCard class="backup-job-schedules-card">
<UiPanelCardTitle size="medium" :label="t('schedules')" :counter="schedules.length" />
<div class="content">
<VtsCardRowList class="content">
<template v-for="({ schedule, nextRun }, index) in schedulesWithNextRun" :key="schedule.id">
<VtsDivider v-if="index > 0" class="divider" type="stretch" />
<VtsCardRowKeyValue>
@@ -45,7 +45,7 @@
</template>
</VtsCardRowKeyValue>
</template>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
@@ -54,6 +54,7 @@ import type { FrontXoSchedule } from '@/modules/schedule/remote-resources/use-xo
import { getScheduleNextRun } from '@/modules/schedule/utils/xo-schedule.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'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsDivider from '@core/components/divider/VtsDivider.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
@@ -89,10 +90,6 @@ const schedulesWithNextRun = computed(() =>
<style scoped lang="postcss">
.backup-job-schedules-card {
.content {
display: flex;
flex-direction: column;
gap: 0.6rem;
.divider {
margin-block: 1.6rem;
}

View File

@@ -2,7 +2,7 @@ import BackupJobSettingsCard from '@/modules/backup-job/components/list/panel/ca
import type { FrontAnyXoBackupJob } from '@/modules/backup-job/remote-resources/use-xo-backup-job-collection.ts'
import type { FrontXoProxy, useXoProxyCollection } from '@/modules/proxy/remote-resources/use-xo-proxy-collection.ts'
import { createMetadataBackupJob, createMirrorBackupJob, createVmBackupJob } from '@/test/create-backup-job.ts'
import { findCardLabelledValues } from '@/test/find-rendered-values.ts'
import { findCardLabelledValueLists, findCardLabelledValues } from '@/test/find-rendered-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import VtsCopyAllMenuItem from '@core/components/copy-all-menu-item/VtsCopyAllMenuItem.vue'
@@ -48,16 +48,6 @@ function findLabels(wrapper: ReturnType<typeof mountSettingsCard>) {
return Object.keys(findCardLabelledValues(wrapper))
}
function findRecipients(wrapper: ReturnType<typeof mountSettingsCard>) {
const rows = wrapper.findAll('.vts-card-row-key-value')
const firstRecipientIndex = rows.findIndex(row => row.get('.key').text() === t('report-recipients'))
return rows
.slice(firstRecipientIndex)
.filter((row, index) => index === 0 || row.get('.key').text() === '')
.map(row => row.get('.value').text())
}
describe('rows', () => {
it('shows every setting of a full VM backup job without rolling snapshot', () => {
expect(findLabels(mountSettingsCard(createVmBackupJob({ mode: 'full' })))).toEqual([
@@ -163,7 +153,7 @@ describe('report recipients', () => {
it('shows the first recipients, then all of them on demand', async () => {
const wrapper = mountSettingsCard(createMetadataBackupJob({ settings: { '': { reportRecipients: recipients } } }))
expect(findRecipients(wrapper)).toEqual(recipients.slice(0, 5))
expect(findCardLabelledValueLists(wrapper)[t('report-recipients')]).toEqual(recipients.slice(0, 5))
const seeMoreButton = wrapper.getComponent(UiButton)
@@ -171,7 +161,7 @@ describe('report recipients', () => {
await seeMoreButton.trigger('click')
expect(findRecipients(wrapper)).toEqual(recipients)
expect(findCardLabelledValueLists(wrapper)[t('report-recipients')]).toEqual(recipients)
expect(seeMoreButton.text()).toBe(t('action:see-less'))
})

View File

@@ -1,7 +1,7 @@
<template>
<UiPanelCard class="backup-job-settings-card">
<UiPanelCard>
<UiPanelCardTitle size="medium" :label="t('settings')" />
<div class="content">
<VtsCardRowList>
<VtsCardRowKeyValue>
<template #key>{{ t('proxy') }}</template>
<template #value>{{ proxy?.name }}</template>
@@ -39,35 +39,8 @@
<VtsCopyButton :value="reportWhenValueTranslation" />
</template>
</VtsCardRowKeyValue>
<template v-if="reportRecipients.length > 0">
<VtsCardRowKeyValue v-for="(recipient, index) in visibleReportRecipients" :key="recipient" align-top>
<template #key>
<template v-if="index === 0">{{ t('report-recipients') }}</template>
</template>
<template #value>{{ recipient }}</template>
<template #addons>
<VtsCopyButton :value="recipient" />
<VtsCopyAllMenuItem v-if="index === 0 && reportRecipients.length > 1" :values="reportRecipients" />
</template>
</VtsCardRowKeyValue>
<UiButton
v-if="hiddenReportRecipientsCount > 0"
class="see-more"
size="small"
accent="brand"
variant="tertiary"
@click="toggleReportRecipients()"
>
{{
areReportRecipientsExpanded
? t('action:see-less')
: t('action:see-n-more', { n: hiddenReportRecipientsCount })
}}
</UiButton>
</template>
<VtsCardRowKeyValue v-else>
<VtsCardRowKeyValue :values="reportRecipients" :max-visible-values="VISIBLE_REPORT_RECIPIENTS_COUNT" align-top>
<template #key>{{ t('report-recipients') }}</template>
<template #value />
</VtsCardRowKeyValue>
<VtsCardRowKeyValue v-if="hasTransferSettings">
<template #key>{{ t('concurrency') }}</template>
@@ -98,7 +71,7 @@
<VtsStatus :status="!!settings.mergeBackupsSynchronously" />
</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
@@ -107,14 +80,12 @@ import { useXoBackupJobSettings } from '@/modules/backup-job/composables/use-xo-
import type { FrontAnyXoBackupJob } from '@/modules/backup-job/remote-resources/use-xo-backup-job-collection.ts'
import { hasBackupJobSnapshotRetention } from '@/modules/backup-job/utils/xo-backup-job.util.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCopyAllMenuItem from '@core/components/copy-all-menu-item/VtsCopyAllMenuItem.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
import UiTag from '@core/components/ui/tag/UiTag.vue'
import { useToggle } from '@vueuse/core'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -122,6 +93,8 @@ const { backupJob } = defineProps<{
backupJob: FrontAnyXoBackupJob
}>()
const VISIBLE_REPORT_RECIPIENTS_COUNT = 5
const { t } = useI18n()
const {
@@ -142,33 +115,5 @@ const canSetCompression = computed(() => backupJob.type === 'backup' && backupJo
const canSetOfflineBackup = computed(() => backupJob.type === 'backup' && !hasBackupJobSnapshotRetention(backupJob))
const VISIBLE_REPORT_RECIPIENTS_COUNT = 5
const [areReportRecipientsExpanded, toggleReportRecipients] = useToggle(false)
const reportRecipients = computed(() => settings.reportRecipients ?? [])
const hiddenReportRecipientsCount = computed(() =>
Math.max(reportRecipients.value.length - VISIBLE_REPORT_RECIPIENTS_COUNT, 0)
)
const visibleReportRecipients = computed(() =>
areReportRecipientsExpanded.value
? reportRecipients.value
: reportRecipients.value.slice(0, VISIBLE_REPORT_RECIPIENTS_COUNT)
)
</script>
<style scoped lang="postcss">
.backup-job-settings-card {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
.see-more {
align-self: center;
}
}
}
</style>

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

@@ -1,7 +1,7 @@
<template>
<UiPanelCard class="backup-log-infos-card">
<UiPanelCard>
<UiPanelCardTitle :id="backupLog.id" size="medium" :label="logName" :href="logHref" icon="object:backup-run" />
<div class="content">
<VtsCardRowList>
<VtsCardRowKeyValue>
<template #key>{{ t('backup-date') }}</template>
<template #value>{{ formattedStartDate }}</template>
@@ -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 }}
@@ -85,7 +86,7 @@
<VtsStatus :status="backupLog.status" />
</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
@@ -96,17 +97,18 @@ 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'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
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'
@@ -122,6 +124,8 @@ const { useGetBackupJobById } = useXoBackupJobCollection()
const { getVmById } = useXoVmCollection()
const { getVmIconTooltip } = useXoVmOperationLabel()
const { getSrById } = useXoSrCollection()
const { getSrStatusIcon } = useXoSrUtils()
@@ -144,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) }
})
)
@@ -156,13 +160,3 @@ const srs = computed(() =>
})
)
</script>
<style scoped lang="postcss">
.backup-log-infos-card {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<UiPanelCard class="backup-log-size-and-speed-card">
<UiPanelCard>
<UiPanelCardTitle size="medium" :label="t('size-and-speed')" />
<div class="content">
<VtsCardRowList>
<VtsCardRowKeyValue>
<template #key>{{ t('size') }}</template>
<template #value>{{ size }}</template>
@@ -16,7 +16,7 @@
<VtsCopyButton :value="speed" />
</template>
</VtsCardRowKeyValue>
</div>
</VtsCardRowList>
</UiPanelCard>
</template>
@@ -24,6 +24,7 @@
import { useXoBackupLogsUtils } from '@/modules/backup-log/composables/use-xo-backup-log-utils.composable.ts'
import type { FrontXoBackupLog } from '@/modules/backup-log/remote-resources/use-xo-backup-log-collection.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCardRowList from '@core/components/card/VtsCardRowList.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import UiPanelCard from '@core/components/ui/panel-card/UiPanelCard.vue'
import UiPanelCardTitle from '@core/components/ui/panel-card-title/UiPanelCardTitle.vue'
@@ -52,13 +53,3 @@ const speed = computed(() => {
return transferSpeed !== undefined ? formatSpeed(transferSpeed) : undefined
})
</script>
<style scoped lang="postcss">
.backup-log-size-and-speed-card {
.content {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More