feat(xo-6/vm): add dashboard view (#8585)

This commit is contained in:
Joris K
2025-06-16 09:24:43 +02:00
committed by GitHub
parent 7be702ef0d
commit 36e594a3ca
26 changed files with 717 additions and 26 deletions

View File

@@ -75,7 +75,7 @@ type BaseXoVm = BaseXapiXo & {
isNestedVirtEnabled: boolean
hasVendorDevice: boolean
high_availability: string
installTime?: number
installTime?: number | null
isFirmwareSupported: boolean
memory: {
dynamic: number[]
@@ -101,7 +101,7 @@ type BaseXoVm = BaseXapiXo & {
secureBoot: boolean
snapshots: XoVmSnapshot['id'][]
startDelay: number
startTime?: number
startTime?: number | null
suspendSr?: XoSr['id']
tags: string[]
vga?: string

View File

@@ -0,0 +1,53 @@
import { useTimestamp } from '@vueuse/core'
import { computed, type MaybeRefOrGetter, toValue } from 'vue'
import { useI18n } from 'vue-i18n'
export enum SECONDS {
MINUTE = 60,
HOUR = SECONDS.MINUTE * 60,
DAY = SECONDS.HOUR * 24,
WEEK = SECONDS.DAY * 7,
MONTH = SECONDS.DAY * 30,
YEAR = SECONDS.DAY * 365,
}
export type TimeThreshold = [number, Intl.RelativeTimeFormatUnit]
const timeThresholds: TimeThreshold[] = [
[SECONDS.MINUTE, 'second'],
[SECONDS.HOUR, 'minute'],
[SECONDS.DAY, 'hour'],
[SECONDS.WEEK, 'day'],
[SECONDS.MONTH, 'week'],
[SECONDS.YEAR, 'month'],
[Infinity, 'year'],
]
export function useTimeAgo(referenceDate: MaybeRefOrGetter<Date | number | string>) {
const { locale } = useI18n()
const formatter = computed(() => new Intl.RelativeTimeFormat(locale.value, { numeric: 'auto' }))
const now = useTimestamp({ interval: 1000 })
const referenceTime = computed(() => new Date(toValue(referenceDate)).getTime())
const distance = computed(() => {
const diff = Math.trunc((referenceTime.value - now.value) / 1000)
if (Math.abs(diff) < 60) {
return Math.trunc(diff / 10) * 10 || 0 // Avoid recomputing when the value changes from 0 to -0
}
return timeThresholds.reduce(
(acc, [threshold]) => (Math.abs(acc) < threshold ? acc : Math.trunc(acc / threshold) * threshold),
diff
)
})
return computed(() => {
const index = timeThresholds.findIndex(([threshold]) => Math.abs(distance.value) < threshold)
const divisor = index > 0 ? timeThresholds[index - 1][0] : 1
return formatter.value.format(Math.trunc(distance.value / divisor), timeThresholds[index][1])
})
}

View File

@@ -130,6 +130,7 @@
"cpus": "CPUs",
"crash-dump-storage-repository": "Crash dump storage repository",
"create": "Create",
"created-on": "Created on",
"custom-config": "Custom config",
"dark-mode.auto": "Auto dark mode",
"dark-mode.disable": "Disable dark mode",
@@ -210,8 +211,8 @@
"graphics-display": "Graphics & Display",
"gzip": "gzip",
"hardware": "Hardware",
"heartbeat-storage-repository": "Heartbeat storage repository",
"hardware-specifications": "Hardware specifications",
"heartbeat-storage-repository": "Heartbeat storage repository",
"here": "Here",
"high-availability": "High availability (HA)",
"host": "Host",
@@ -369,6 +370,7 @@
"no-vif-detected": "No VIF detected",
"none": "None",
"not-found": "Not found",
"not-running": "Not running",
"not-yet-available": "Information is not yet available",
"object": "Object",
"object-not-found": "Object {id} can't be found…",
@@ -421,6 +423,7 @@
"ram": "RAM",
"ram-provisioning": "RAM provisioning",
"ram-usage": "RAM usage",
"read": "Read",
"read-only": "Read-only",
"reboot": "Reboot",
"receive": "Receive",
@@ -540,6 +543,7 @@
"vcpus": "vCPU | vCPU | vCPUs",
"vcpus-assigned": "vCPUs assigned",
"vcpus-used": "vCPUs used",
"vdi-throughput": "VDI throughput",
"vdis": "VDI | VDI | VDIs",
"version": "Version",
"vga": "VGA",
@@ -552,12 +556,17 @@
"virtual-tpm": "Virtual TPM (VTPM)",
"virtualization-boot-settings": "Virtualization & boot settings",
"virtualization-mode": "Virtualization mode",
"virtualization-type": "Virtualization type",
"vlan": "VLAN",
"vm": "VM",
"vm-description": "VM description",
"vm-is-running": "The VM is running",
"vm-limit-topology": "VM limit topology",
"vm-management": "VM management",
"vm-status.halted": "Halted",
"vm-status.paused": "Paused",
"vm-status.running": "Running",
"vm-status.suspended": "Suspended",
"vm.active": "Active",
"vm.inactive": "Inactive",
"vms": "VM | VMs",
@@ -570,6 +579,7 @@
"vms-status.suspended": "Suspended",
"vms-status.unknown": "Unknown",
"vms-status.unknown.tooltip": "For which XO has lost connection to the pool",
"write": "Write",
"xo-backups": "XO backups",
"xo-lite-under-construction": "XOLite is under construction",
"xo-replications": "XO replications",

View File

@@ -130,6 +130,7 @@
"cpus": "CPUs",
"crash-dump-storage-repository": "Dépôt de stockage des log de crash",
"create": "Créer",
"created-on": "Créé le",
"custom-config": "Configuration personnalisée",
"dark-mode.auto": "Mode sombre automatique",
"dark-mode.disable": "Désactiver le mode sombre",
@@ -210,8 +211,8 @@
"graphics-display": "Graphique & Affichage",
"gzip": "gzip",
"hardware": "Matériel",
"heartbeat-storage-repository": "Dépôt de stockage pour le heartbeat de la haute disponibilité",
"hardware-specifications": "Spécifications matérielles",
"heartbeat-storage-repository": "Dépôt de stockage pour le heartbeat de la haute disponibilité",
"here": "Ici",
"high-availability": "Haute Disponibilité (HA)",
"host": "Hôte",
@@ -369,6 +370,7 @@
"no-vif-detected": "Aucun VIF détecté",
"none": "Aucun",
"not-found": "Non trouvé",
"not-running": "Pas en cours d'exécution",
"not-yet-available": "Information pas encore disponible",
"object": "Objet",
"object-not-found": "L'objet {id} est introuvable…",
@@ -421,6 +423,7 @@
"ram": "RAM",
"ram-provisioning": "Provisionnement RAM",
"ram-usage": "Utilisation de la RAM",
"read": "Lecture",
"read-only": "Lecture seule",
"reboot": "Redémarrer",
"receive": "Recevoir",
@@ -540,6 +543,7 @@
"vcpus": "vCPU | vCPU | vCPUs",
"vcpus-assigned": "vCPUs assignés",
"vcpus-used": "vCPUs utilisés",
"vdi-throughput": "Débit du VDI",
"vdis": "VDI | VDI | VDIs",
"version": "Version",
"vga": "VGA",
@@ -552,12 +556,17 @@
"virtual-tpm": "TPM virtuel (VTPM)",
"virtualization-boot-settings": "Virtualisation & paramètres de démarrage",
"virtualization-mode": "Mode de virtualisation",
"virtualization-type": "Type de virtualisation",
"vlan": "VLAN",
"vm": "VM",
"vm-description": "Description de la VM",
"vm-is-running": "La VM est en cours d'exécution",
"vm-limit-topology": "Limite de topologie physique des VMs",
"vm-management": "Gestion de la VM",
"vm-status.halted": "Éteinte",
"vm-status.paused": "En pause",
"vm-status.running": "Démarrée",
"vm-status.suspended": "Suspendue",
"vm.active": "Active | Active | Actives",
"vm.inactive": "Inactive | Inactive | Inactives",
"vms": "VM | VMs",
@@ -570,6 +579,7 @@
"vms-status.suspended": "Suspendues",
"vms-status.unknown": "Inconnu",
"vms-status.unknown.tooltip": "Dont XO a perdu la connexion avec le pool",
"write": "Écriture",
"xo-backups": "Sauvegardes XO",
"xo-lite-under-construction": "XOLite est en construction",
"xo-replications": "Réplications XO",

View File

@@ -1,6 +1,6 @@
import { parse, Scale, raw } from 'human-format'
import { parse, raw, Scale } from 'human-format'
const scale = Scale.create(['', 'KiB', 'MiB', 'GiB', 'TiB'], 1024)
const scale = Scale.create(['B', 'KiB', 'MiB', 'GiB', 'TiB'], 1024)
export const formatSizeRaw = (bytes: number | undefined, decimals: number) => {
if (bytes === undefined) {

View File

@@ -1,6 +1,6 @@
<template>
<VtsTreeItem expanded>
<UiTreeItemLabel :route="`/vm/${leaf.data.id}/`" no-indent>
<UiTreeItemLabel :route="`/vm/${leaf.data.id}`" no-indent>
{{ leaf.data.name_label }}
<template #icon>
<UiObjectIcon

View File

@@ -6,7 +6,11 @@
</template>
</UiHeadBar>
<TabList>
<TabItem disabled>{{ t('dashboard') }}</TabItem>
<RouterLink v-slot="{ isActive, href }" :to="`/vm/${vm.id}/dashboard`" custom>
<TabItem :active="isActive" :href tag="a">
{{ t('dashboard') }}
</TabItem>
</RouterLink>
<RouterLink v-slot="{ isActive, href }" :to="`/vm/${vm.id}/console`" custom>
<TabItem :active="isActive" :href tag="a">
{{ t('console') }}

View File

@@ -0,0 +1,77 @@
<template>
<UiCard>
<UiCardTitle>
{{ t('cpu-usage') }}
<template #description>{{ t('last-week') }}</template>
</UiCardTitle>
<VtsLoadingHero v-if="loading" type="card" />
<VtsErrorNoDataHero v-else-if="error" type="card" />
<VtsNoDataHero v-else-if="cpuUsage.length === 0" type="card" />
<VtsLinearChart v-else :data="cpuUsage" :max-value :value-formatter />
</UiCard>
</template>
<script lang="ts" setup>
import type { XoVmStats } from '@/types/xo/vm-stats.type.ts'
import type { LinearChartData, ValueFormatter } from '@core/types/chart.ts'
import VtsErrorNoDataHero from '@core/components/state-hero/VtsErrorNoDataHero.vue'
import VtsLoadingHero from '@core/components/state-hero/VtsLoadingHero.vue'
import VtsNoDataHero from '@core/components/state-hero/VtsNoDataHero.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import { computed, defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
const { data } = defineProps<{
data: XoVmStats | null
loading: boolean
error?: string
}>()
const VtsLinearChart = defineAsyncComponent(() => import('@core/components/linear-chart/VtsLinearChart.vue'))
const { t, n } = useI18n()
const cpuUsage = computed<LinearChartData>(() => {
if (!data?.stats.cpus) {
return []
}
const cpus = Object.values(data.stats.cpus)
const result = new Map<number, { timestamp: number; value: number }>()
const timestampStart = data.endTimestamp - data.interval * (cpus[0].length - 1)
for (let hourIndex = 0; hourIndex < cpus[0].length; hourIndex++) {
const timestamp = (timestampStart + hourIndex * data.interval) * 1000
const cpuUsageSum = cpus.reduce((total, cpu) => total + cpu[hourIndex], 0)
result.set(timestamp, {
timestamp,
value: Math.round(cpuUsageSum / cpus.length),
})
}
return [
{
label: t('stacked-cpu-usage'),
data: Array.from(result.values()),
},
]
})
const maxValue = computed(() => {
const values = cpuUsage.value[0]?.data.map(item => item.value) ?? []
if (values.length === 0) {
return 100
}
const maxCpuUsage = Math.max(...values)
return Math.ceil(maxCpuUsage / 100) * 100
})
const valueFormatter: ValueFormatter = value => n(value / 100, 'percent')
</script>

View File

@@ -0,0 +1,88 @@
<template>
<UiCard>
<UiCardTitle>
{{ t('network-throughput') }}
<template #description>{{ t('last-week') }}</template>
</UiCardTitle>
<VtsLoadingHero v-if="loading" type="card" />
<VtsErrorNoDataHero v-else-if="error" type="card" />
<VtsNoDataHero v-else-if="networkUsage.length === 0" type="card" />
<VtsLinearChart v-else :data="networkUsage" :max-value :value-formatter="byteFormatter" />
</UiCard>
</template>
<script lang="ts" setup>
import type { XoVmStats } from '@/types/xo/vm-stats.type.ts'
import type { LinearChartData } from '@core/types/chart.ts'
import VtsErrorNoDataHero from '@core/components/state-hero/VtsErrorNoDataHero.vue'
import VtsLoadingHero from '@core/components/state-hero/VtsLoadingHero.vue'
import VtsNoDataHero from '@core/components/state-hero/VtsNoDataHero.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import { formatSizeRaw } from '@core/utils/size.util.ts'
import { computed, defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
const { data } = defineProps<{
data: XoVmStats | null
loading: boolean
error?: string
}>()
const VtsLinearChart = defineAsyncComponent(() => import('@core/components/linear-chart/VtsLinearChart.vue'))
const { t } = useI18n()
const networkUsage = computed<LinearChartData>(() => {
if (!data?.stats.vifs) {
return []
}
const rxArrays = Object.values(data.stats.vifs.rx)
const timestamps = Array.from(
{ length: rxArrays[0].length },
(_, i) => data.endTimestamp * 1000 - (rxArrays[0].length - 1 - i) * data.interval * 1000
)
const rxSeries = [
{
label: t('network-upload'),
data: timestamps.map((timestamp, index) => ({
timestamp,
value: Object.values(data.stats.vifs.rx).reduce((sum, values) => sum + values[index], 0),
})),
},
]
const txSeries = [
{
label: t('network-download'),
data: timestamps.map((timestamp, index) => ({
timestamp,
value: Object.values(data.stats.vifs.tx).reduce((sum, values) => sum + values[index], 0),
})),
},
]
return [...rxSeries, ...txSeries]
})
const maxValue = computed(() => {
const values = networkUsage.value.flatMap(series => series.data.map(item => item.value))
if (values.length === 0) {
return 100
}
const maxUsage = Math.max(...values)
return Math.ceil(maxUsage / 50) * 50
})
const byteFormatter = (value: number) => {
const result = formatSizeRaw(value, 1)
return `${result?.value}${result?.prefix}`
}
</script>

View File

@@ -0,0 +1,135 @@
<template>
<VtsQuickInfoCard class="vm-dashboard-quick-info" :loading="!isReady">
<VtsQuickInfoColumn>
<VtsQuickInfoRow :label="t('state')">
<template #value>
<span class="power-state">
<VtsIcon :accent="powerState.accent" :icon="powerState.icon" />
{{ powerState.text }}
</span>
</template>
</VtsQuickInfoRow>
<VtsQuickInfoRow :label="t('ip-address')" :value="vm.mainIpAddress" />
<VtsQuickInfoRow :label="t('created-on')" :value="installDateFormatted" />
<VtsQuickInfoRow :label="t('started')" :value="relativeStartTime" />
<VtsQuickInfoRow :label="t('host')">
<template #value>
<span v-if="host" class="host-name">
<UiLink :icon="faServer" :to="`/host/${host.id}`" size="medium">
{{ host.name_label }}
</UiLink>
</span>
<span v-else>
{{ t('none') }}
</span>
</template>
</VtsQuickInfoRow>
</VtsQuickInfoColumn>
<VtsQuickInfoColumn>
<VtsQuickInfoRow :label="t('uuid')" :value="vm.id" />
<VtsQuickInfoRow :label="t('description')" :value="vm.name_description" />
<VtsQuickInfoRow :label="t('os-name')" :value="vm.os_version?.name" />
<VtsQuickInfoRow :label="t('virtualization-type')" :value="virtualizationType" />
</VtsQuickInfoColumn>
<VtsQuickInfoColumn>
<VtsQuickInfoRow :label="t('vcpus')" :value="String(vm.CPUs.number)" />
<VtsQuickInfoRow :label="t('ram')" :value="`${ram?.value} ${ram?.prefix}`" />
<VtsQuickInfoRow :label="t('tags')">
<template #value>
<UiTagsList v-if="vm.tags.length > 0">
<UiTag v-for="tag in vm.tags" :key="tag" accent="info" variant="secondary">
{{ tag }}
</UiTag>
</UiTagsList>
</template>
</VtsQuickInfoRow>
</VtsQuickInfoColumn>
</VtsQuickInfoCard>
</template>
<script lang="ts" setup>
import { useVmStore } from '@/stores/xo-rest-api/vm.store.ts'
import { VM_POWER_STATE, type XoVm } from '@/types/xo/vm.type.ts'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import VtsQuickInfoCard from '@core/components/quick-info-card/VtsQuickInfoCard.vue'
import VtsQuickInfoColumn from '@core/components/quick-info-column/VtsQuickInfoColumn.vue'
import VtsQuickInfoRow from '@core/components/quick-info-row/VtsQuickInfoRow.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import UiTag from '@core/components/ui/tag/UiTag.vue'
import UiTagsList from '@core/components/ui/tag/UiTagsList.vue'
import { useTimeAgo } from '@core/composables/local-time-ago.composable.ts'
import { useMapper } from '@core/packages/mapper'
import { formatSizeRaw } from '@core/utils/size.util.ts'
import { parseDateTime } from '@core/utils/time.util.ts'
import { faMoon, faPause, faPlay, faServer, faStop } from '@fortawesome/free-solid-svg-icons'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { vm } = defineProps<{
vm: XoVm
}>()
const { t, locale } = useI18n()
const { isReady, getVmHost } = useVmStore().subscribe()
const host = computed(() => getVmHost(vm))
// TODO add this to icon when new component is available
// const hostPowerState = computed(() => {
// if (host.value === undefined) {
// return
// }
//
// return host.value.power_state === 'Running' ? 'running' : 'halted'
// })
// TODO as above, add this to icon when new component is available
const powerState = useMapper(
() => vm.power_state,
{
[VM_POWER_STATE.RUNNING]: { icon: faPlay, accent: 'success', text: t('vm-status.running') },
[VM_POWER_STATE.HALTED]: { icon: faStop, accent: 'danger', text: t('vm-status.halted') },
[VM_POWER_STATE.PAUSED]: { icon: faPause, accent: 'brand', text: t('vm-status.paused') },
[VM_POWER_STATE.SUSPENDED]: { icon: faMoon, accent: 'info', text: t('vm-status.suspended') },
},
VM_POWER_STATE.RUNNING
)
const ram = computed(() => formatSizeRaw(vm.memory.size, 1))
const vmTimeAgo = useTimeAgo(() => new Date(parseDateTime((vm.startTime ?? 0) * 1000)))
const relativeStartTime = computed(() => {
if (!vm.startTime || vm.power_state === VM_POWER_STATE.HALTED) {
return t('not-running')
}
return vmTimeAgo.value
})
const installDateFormatted = computed(() => {
if (!vm.installTime) {
return t('unknown')
}
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'long' }).format(
new Date(parseDateTime(vm.installTime * 1000))
)
})
const virtualizationType = computed(() =>
vm.virtualizationMode === 'hvm' && vm.pvDriversDetected ? 'pvhvm' : vm.virtualizationMode
)
</script>
<style lang="postcss" scoped>
.vm-dashboard-quick-info {
.power-state,
.host-name {
display: flex;
align-items: center;
gap: 1rem;
}
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<UiCard>
<UiCardTitle>
{{ t('ram-usage') }}
<template #description>{{ t('last-week') }}</template>
</UiCardTitle>
<VtsLoadingHero v-if="loading" type="card" />
<VtsErrorNoDataHero v-else-if="error" type="card" />
<VtsNoDataHero v-else-if="ramUsage.length === 0" type="card" />
<VtsLinearChart v-else :data="ramUsage" :max-value :value-formatter="byteFormatter" />
</UiCard>
</template>
<script lang="ts" setup>
import type { XoVmStats } from '@/types/xo/vm-stats.type.ts'
import type { LinearChartData } from '@core/types/chart.ts'
import VtsErrorNoDataHero from '@core/components/state-hero/VtsErrorNoDataHero.vue'
import VtsLoadingHero from '@core/components/state-hero/VtsLoadingHero.vue'
import VtsNoDataHero from '@core/components/state-hero/VtsNoDataHero.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import { formatSizeRaw } from '@core/utils/size.util.ts'
import { computed, defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
const { data } = defineProps<{
data: XoVmStats | null
loading: boolean
error?: string
}>()
const VtsLinearChart = defineAsyncComponent(() => import('@core/components/linear-chart/VtsLinearChart.vue'))
const { t } = useI18n()
const ramUsage = computed<LinearChartData>(() => {
if (!data?.stats.memory || !data?.stats.memoryFree) {
return []
}
const ramTotalValues = data.stats.memory
const ramFreeValues = data.stats.memoryFree
const result = new Map<number, { timestamp: number; value: number }>()
const timestampStart = data.endTimestamp - data.interval * (ramTotalValues.length - 1)
for (let hourIndex = 0; hourIndex < ramTotalValues.length; hourIndex++) {
const timestamp = (timestampStart + hourIndex * data.interval) * 1000
const ramTotal = ramTotalValues[hourIndex]
const ramFree = ramFreeValues[hourIndex]
const ramUsed = ramTotal - ramFree
result.set(timestamp, {
timestamp,
value: ramUsed,
})
}
return [
{
label: t('stacked-ram-usage'),
data: Array.from(result.values()),
},
]
})
const maxValue = computed(() => {
if (!data?.stats.memory?.length) {
return 1024 * 1024 * 1024 // 1 GB as fallback
}
return Math.max(...data.stats.memory, 0)
})
const byteFormatter = (value: number) => {
const result = formatSizeRaw(value, 1)
return `${result?.value}${result?.prefix}`
}
</script>

View File

@@ -0,0 +1,87 @@
<template>
<UiCard>
<UiCardTitle>
{{ t('vdi-throughput') }}
<template #description>{{ t('last-week') }}</template>
</UiCardTitle>
<VtsLoadingHero v-if="loading" type="card" />
<VtsErrorNoDataHero v-else-if="error" type="card" />
<VtsNoDataHero v-else-if="vdiUsage.length === 0" type="card" />
<VtsLinearChart v-else :data="vdiUsage" :max-value :value-formatter="byteFormatter" />
</UiCard>
</template>
<script lang="ts" setup>
import type { XoVmStats } from '@/types/xo/vm-stats.type.ts'
import type { LinearChartData } from '@core/types/chart.ts'
import VtsErrorNoDataHero from '@core/components/state-hero/VtsErrorNoDataHero.vue'
import VtsLoadingHero from '@core/components/state-hero/VtsLoadingHero.vue'
import VtsNoDataHero from '@core/components/state-hero/VtsNoDataHero.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import { formatSizeRaw } from '@core/utils/size.util.ts'
import { computed, defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
const { data } = defineProps<{
data: XoVmStats | null
loading: boolean
error?: string
}>()
const VtsLinearChart = defineAsyncComponent(() => import('@core/components/linear-chart/VtsLinearChart.vue'))
const { t } = useI18n()
const vdiUsage = computed<LinearChartData>(() => {
if (!data?.stats.xvds) {
return []
}
const readArrays = Object.values(data.stats.xvds.r)
const timestamps = Array.from(
{ length: readArrays[0].length },
(_, i) => data.endTimestamp * 1000 - (readArrays[0].length - 1 - i) * data.interval * 1000
)
const readSeries = [
{
label: t('read'),
data: timestamps.map((timestamp, index) => ({
timestamp,
value: Object.values(data.stats.xvds.r).reduce((sum, values) => sum + values[index], 0),
})),
},
]
const writeSeries = [
{
label: t('write'),
data: timestamps.map((timestamp, index) => ({
timestamp,
value: Object.values(data.stats.xvds.w).reduce((sum, values) => sum + values[index], 0),
})),
},
]
return [...readSeries, ...writeSeries]
})
const maxValue = computed(() => {
const values = vdiUsage.value.flatMap(series => series.data.map(item => item.value))
if (values.length === 0) {
return 100
}
const maxUsage = Math.max(...values)
return Math.ceil(maxUsage / 50) * 50
})
const byteFormatter = (value: number) => {
const result = formatSizeRaw(value, 1)
return `${result?.value}${result?.prefix}`
}
</script>

View File

@@ -166,8 +166,8 @@
<script setup lang="ts">
import { useNetworkStore } from '@/stores/xo-rest-api/network.store'
import { useVmStore } from '@/stores/xo-rest-api/vm.store.ts'
import type { XoVif } from '@/types/xo/vif.type.ts'
import { useVmStore } from '@/stores/xo-rest-api/vm.store'
import type { XoVif } from '@/types/xo/vif.type'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsConnectionStatus from '@core/components/connection-status/VtsConnectionStatus.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'

View File

@@ -130,9 +130,9 @@
<script setup lang="ts">
import { useNetworkStore } from '@/stores/xo-rest-api/network.store'
import { useVifStore } from '@/stores/xo-rest-api/vif.store.ts'
import { useVmStore } from '@/stores/xo-rest-api/vm.store.ts'
import type { XoVif } from '@/types/xo/vif.type.ts'
import { useVifStore } from '@/stores/xo-rest-api/vif.store'
import { useVmStore } from '@/stores/xo-rest-api/vm.store'
import type { XoVif } from '@/types/xo/vif.type'
import VtsConnectionStatus from '@core/components/connection-status/VtsConnectionStatus.vue'
import VtsDataTable from '@core/components/data-table/VtsDataTable.vue'
import VtsIcon from '@core/components/icon/VtsIcon.vue'

View File

@@ -1,11 +1,17 @@
import type { XoHost } from '@/types/xo/host.type.ts'
import { GRANULARITY, RRD_STEP_FROM_STRING } from '@/utils/rest-api-stats.ts'
import type { XoPool } from '@/types/xo/pool.type.ts'
import type { XoVm } from '@/types/xo/vm.type.ts'
import { type GRANULARITY, RRD_STEP_FROM_STRING } from '@/utils/rest-api-stats.ts'
import { useFetch, useIntervalFn } from '@vueuse/core'
import type { MaybeRefOrGetter } from '@vueuse/shared'
import { toValue } from 'vue'
export function useFetchStats(hostId: MaybeRefOrGetter<XoHost['id']>, granularity: MaybeRefOrGetter<GRANULARITY>) {
const result = useFetch(() => `/rest/v0/hosts/${toValue(hostId)}/stats?granularity=${toValue(granularity)}`, {
export function useFetchStats(
objectType: 'host' | 'vm' | 'pool',
id: MaybeRefOrGetter<XoVm['id'] | XoHost['id'] | XoPool['id']>,
granularity: MaybeRefOrGetter<GRANULARITY>
) {
const result = useFetch(() => `/rest/v0/${objectType}s/${toValue(id)}/stats?granularity=${toValue(granularity)}`, {
immediate: false,
refetch: true,
beforeFetch({ options }) {
@@ -17,9 +23,11 @@ export function useFetchStats(hostId: MaybeRefOrGetter<XoHost['id']>, granularit
.get()
.json()
useIntervalFn(() => result.execute(), RRD_STEP_FROM_STRING[toValue(granularity)] * 1000, {
immediateCallback: true,
})
useIntervalFn(
() => result.execute(),
() => RRD_STEP_FROM_STRING[toValue(granularity)] * 1000,
{ immediateCallback: true }
)
return {
data: result.data,

View File

@@ -29,7 +29,7 @@ const { host } = defineProps<{
host: XoHost
}>()
const { data, isFetching, error } = useFetchStats(() => host.id, GRANULARITY.Hours)
const { data, isFetching, error } = useFetchStats('host', () => host.id, GRANULARITY.Hours)
const uiStore = useUiStore()
</script>

View File

@@ -0,0 +1,85 @@
<template>
<div class="dashboard" :class="{ mobile: uiStore.isMobile }">
<VmDashboardQuickInfo class="quick-info" :vm />
<div v-if="!data" class="offline-hero-container">
<VtsOfflineHero type="page" />
</div>
<template v-else>
<VmDashboardCpuUsageChart class="cpu-usage-chart" :data :error :loading="isFetching" />
<VmDashboardRamUsageChart class="ram-usage-chart" :data :error :loading="isFetching" />
<VmDashboardNetworkUsageChart class="network-usage-chart" :data :error :loading="isFetching" />
<VmDashboardVdiUsageChart class="vdi-usage-chart" :data :error :loading="isFetching" />
</template>
</div>
</template>
<script lang="ts" setup>
import VmDashboardCpuUsageChart from '@/components/vm/dashboard/VmDashboardCpuUsageChart.vue'
import VmDashboardNetworkUsageChart from '@/components/vm/dashboard/VmDashboardNetworkUsageChart.vue'
import VmDashboardQuickInfo from '@/components/vm/dashboard/VmDashboardQuickInfo.vue'
import VmDashboardRamUsageChart from '@/components/vm/dashboard/VmDashboardRamUsageChart.vue'
import VmDashboardVdiUsageChart from '@/components/vm/dashboard/VmDashboardVdiUsageChart.vue'
import { useFetchStats } from '@/composables/fetch-stats.composable.ts'
import { type XoVm } from '@/types/xo/vm.type'
import { GRANULARITY } from '@/utils/rest-api-stats.ts'
import VtsOfflineHero from '@core/components/state-hero/VtsOfflineHero.vue'
import { useUiStore } from '@core/stores/ui.store.ts'
const { vm } = defineProps<{
vm: XoVm
}>()
const { data, isFetching, error } = useFetchStats('vm', () => vm.id, GRANULARITY.Hours)
const uiStore = useUiStore()
</script>
<style lang="postcss" scoped>
.dashboard {
display: grid;
margin: 0.8rem;
gap: 0.8rem;
grid-template-columns: repeat(8, 1fr);
grid-template-areas:
'quick-info quick-info quick-info quick-info quick-info quick-info quick-info quick-info'
'cpu-usage-chart cpu-usage-chart ram-usage-chart ram-usage-chart network-usage-chart network-usage-chart vdi-usage-chart vdi-usage-chart'
'offline-hero-container offline-hero-container offline-hero-container offline-hero-container offline-hero-container offline-hero-container offline-hero-container offline-hero-container';
&.mobile {
grid-template-columns: 1fr;
grid-template-areas:
'quick-info'
'cpu-usage-chart'
'ram-usage-chart'
'network-usage-chart'
'vdi-usage-chart'
'offline-hero-container';
}
.quick-info {
grid-area: quick-info;
}
.offline-hero-container {
grid-area: offline-hero-container;
width: 50rem;
margin: 0 auto;
}
.cpu-usage-chart {
grid-area: cpu-usage-chart;
}
.ram-usage-chart {
grid-area: ram-usage-chart;
}
.network-usage-chart {
grid-area: network-usage-chart;
}
.vdi-usage-chart {
grid-area: vdi-usage-chart;
}
}
</style>

View File

@@ -4,6 +4,6 @@
// The `definePage` macro is removed during the build, leaving an empty script tag that causes the build to fail.
definePage({
name: '/vm/:id',
redirect: (to: any) => `/vm/${to.params.id}/console`,
redirect: (to: any) => `/vm/${to.params.id}/dashboard`,
})
</script>

View File

@@ -11,8 +11,8 @@
</template>
<script setup lang="ts">
import VmVifSidePanel from '@/components/vm/VmVifSidePanel.vue'
import VmVifsTable from '@/components/vm/VmVifsTable.vue'
import VmVifSidePanel from '@/components/vm/network/VmVifSidePanel.vue'
import VmVifsTable from '@/components/vm/network/VmVifsTable.vue'
import { useVifStore } from '@/stores/xo-rest-api/vif.store.ts'
import type { XoVif } from '@/types/xo/vif.type.ts'
import VtsNoSelectionHero from '@core/components/state-hero/VtsNoSelectionHero.vue'

View File

@@ -1,3 +1,4 @@
import { useHostStore } from '@/stores/xo-rest-api/host.store.ts'
import type { XoHost } from '@/types/xo/host.type'
import type { XoPool } from '@/types/xo/pool.type'
import type { XoVm } from '@/types/xo/vm.type'
@@ -10,6 +11,12 @@ import { defineStore } from 'pinia'
import { computed } from 'vue'
export const useVmStore = defineStore('vm', () => {
const deps = {
hostStore: useHostStore(),
}
const hostContext = deps.hostStore.getContext()
const { context: baseContext, ...configRest } = createXoStoreConfig('vm', {
sortBy: sortByNameLabel,
})
@@ -26,10 +33,21 @@ export const useVmStore = defineStore('vm', () => {
return castArray(operations).some(operation => currentOperations.includes(operation))
}
const getVmHost = (vm: XoVm): XoHost | undefined => {
const vmHostId = extractVmHostId(vm)
if (vmHostId === undefined) {
return undefined
}
return hostContext.get(vmHostId)
}
const context = {
...baseContext,
runningVms,
vmsByHost,
getVmHost,
hostLessVmsByPool,
isVmOperatingPending,
}
@@ -58,3 +76,7 @@ function createVmsMap<THostLess extends boolean>(vms: XoVm[], hostLess: THostLes
return vmsMap
}
function extractVmHostId(vm: XoVm) {
return vm.$container === vm.$pool ? undefined : (vm.$container as XoHost['id'])
}

View File

@@ -1,4 +1,4 @@
type XoStats = Record<string, number[]>
import type { XoStats } from '@/types/xo/xo-stats.type.ts'
export type XoHostStats = {
endTimestamp: number

View File

@@ -0,0 +1,23 @@
import type { XoStats } from '@/types/xo/xo-stats.type.ts'
export type XoVmStats = {
endTimestamp: number
interval: number
stats: {
cpus: XoStats
memory: number[]
iops: {
r: XoStats
w: XoStats
}
xvds: {
r: XoStats
w: XoStats
}
vifs: {
rx: XoStats
tx: XoStats
}
memoryFree: number[]
}
}

View File

@@ -33,7 +33,7 @@ export type XoVm = {
name_description: string
power_state: VM_POWER_STATE
addresses: Record<string, string>
mainIpAddress: string
mainIpAddress?: string
other: { disable_pv_vnc: string }
CPUs: {
max: number
@@ -55,6 +55,9 @@ export type XoVm = {
high_availability: 'best-effort' | 'restart' | ''
auto_poweron: boolean
startDelay: number
startTime?: number | null
installTime?: number | null
pvDriversDetected?: boolean
vga: 'std' | 'cirrus'
videoram?: number
pvDriversVersion?: string

View File

@@ -0,0 +1 @@
export type XoStats = Record<string, number[]>

View File

@@ -33,7 +33,7 @@ export const xoApiDefinition = {
type: 'collection',
path: 'vms',
fields:
'id,name_label,name_description,power_state,$container,$pool,other,current_operations,CPUs,addresses,tags,os_version,virtualizationMode,secureBoot,VTPMs,viridian,isNestedVirtEnabled,memory,VGPUs,high_availability,auto_poweron,startDelay,vga,videoram,pvDriversVersion,cpuWeight,cpuCap,cpuMask,coresPerSocket,nicType,affinityHost,suspendSr,blockedOperations,hasVendorDevice',
'id,name_label,name_description,power_state,$container,$pool,other,current_operations,CPUs,addresses,tags,os_version,virtualizationMode,secureBoot,VTPMs,viridian,isNestedVirtEnabled,memory,VGPUs,high_availability,auto_poweron,startDelay,vga,videoram,pvDriversVersion,cpuWeight,cpuCap,cpuMask,coresPerSocket,mainIpAddress,nicType,affinityHost,suspendSr,blockedOperations,hasVendorDevice,startTime,installTime,pvDriversDetected',
handler: (record: XoVm) => record,
},
sr: {

View File

@@ -39,6 +39,7 @@ declare module 'vue-router/auto-routes' {
'/vm/[id]': RouteRecordInfo<'/vm/[id]', '/vm/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'/vm/:id': RouteRecordInfo<'/vm/:id', '/vm/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'/vm/[id]/console': RouteRecordInfo<'/vm/[id]/console', '/vm/:id/console', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'/vm/[id]/dashboard': RouteRecordInfo<'/vm/[id]/dashboard', '/vm/:id/dashboard', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'/vm/[id]/networks': RouteRecordInfo<'/vm/[id]/networks', '/vm/:id/networks', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'/vm/[id]/system': RouteRecordInfo<'/vm/[id]/system', '/vm/:id/system', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'/vm/new': RouteRecordInfo<'/vm/new', '/vm/new', Record<never, never>, Record<never, never>>,