feat(xo6/web-core): implement tests on frontend (#10012)

This commit is contained in:
OlivierFL
2026-09-09 11:48:41 +02:00
committed by GitHub
parent 11eed70342
commit e428304aea
119 changed files with 7954 additions and 776 deletions

View File

@@ -24,3 +24,7 @@ Each module typically contains:
- A `/composables` subdirectory for Vue composables that encapsulate logic related to that module.
- A `/utils` subdirectory for utility functions that are specific to that module.
- Other directories as needed for that module's functionality.
## Testing
See [tests.md](./tests.md) for the unit-testing conventions used across modules (Vitest setup, factories, mocking, and how to test utilities and composables).

View File

@@ -0,0 +1,33 @@
# Testing
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.
## What to test first
A fixed priority, not a preference:
1. **Components that make up a page** (`modules/<mod>/components/**`) — top priority, **equal with** the next entry
2. **Composables and utilities** (`*.composable.ts`, `*.util.ts`) — top priority
3. **Pages** (`src/pages/**`) — lowest, and deliberately thin: assert which components render, never their values
Testing a page is a different job from testing a component, and the component is the more valuable one. A component should not carry much complex logic — when it does, extract that into a composable or utility and test it there.
## Getting started
- [Assert behaviour, not implementation](./tests/assert-behaviour.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
- [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
## What you are testing
- [Testing pure utilities](./tests/testing-utilities.md) — `*.util.ts`, called directly
- [Testing composables](./tests/testing-composables.md) — `mountComposable`, reactivity, `useI18n`
- [Testing components](./tests/testing-components.md) — `mount`, querying rendered output, and what `happy-dom` cannot do
- [Mocking dependencies](./tests/mocking.md) — the typed `vi.mock` form, and the network stub

View File

@@ -0,0 +1,24 @@
# Assert behaviour, 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.
- 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.
- **When a value is produced by a helper, build the expected value with that same helper** instead of copying its output as a literal. `objectIcon(type, state)` owns the icon-name format and `t(key)` owns the wording of a translation; what the code under test decides is _which_ icon and _which_ key. Assert that decision:
```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
expect(displayData.vmIcon).toBe('object:vm:running')
expect(wrapper.vm.powerState.text).toBe('Running')
// ✓ asserts the decision: the running VM icon, the running status label
expect(displayData.vmIcon).toBe(objectIcon('vm', 'running'))
expect(wrapper.vm.powerState.text).toBe(t('vm:status:running'))
```
- A component test takes that `t` — and the `d` for a locale-formatted date — from `src/test/i18n.ts`, the very instance `createGlobalTestConfig()` installs; a composable test calls `useI18n()` inside the `setup` callback (see [Testing composables](./testing-composables.md)).
- This is not tautological, because the helper is not the code under test: `objectIcon` and the translation files have their own tests. A test _of_ `objectIcon` does assert literals — that is where its format is pinned, once.
- Values that are literals in production stay literals in the test. `powerState.icon` is written as `'status:running-circle'` in the source, so `expect(wrapper.vm.powerState.icon).toBe('status:running-circle')` is the right assertion; there is no helper to defer to.
- Environment-dependent values — a relative timestamp, a locale-formatted date — are derived the way production derives them, and the test says so.
- Every mock couples the test to the mocked surface, which is why the boundary stays tight — see [Mocking dependencies](./mocking.md).

View File

@@ -0,0 +1,41 @@
# Test data factories
Domain objects are created through factories in `src/test/`, one per object type:
| Factory | Builds |
| ------------------ | ------------------- |
| `createVm` | `FrontXoVm` |
| `createHost` | `FrontXoHost` |
| `createServer` | `FrontXoServer` |
| `createPool` | `FrontXoPool` |
| `createSr` | `FrontXoSr` |
| `createVbd` | `FrontXoVbd` |
| `createVdi` | `FrontXoVdi` |
| `createVmSnapshot` | `FrontXoVmSnapshot` |
| `createHostStats` | `XapiHostStats` |
| `createVmStats` | `XapiVmStats` |
| `createPoolStats` | `XapiPoolStats` |
`createPoolStats` composes `createHostStats`, since pool stats are one entry per host: pass a `Partial<XapiHostStats>` per host id, or an `{ error }` entry for a host whose stats the pool could not fetch.
```typescript
const poolStats = createPoolStats({
'host-1': { stats: { cpus: { '0': [10, 20] } } },
'host-2': { error: { code: 'boom' } },
})
```
Shared _helper_ factories live there too — `mount-composable.ts`, `mount-chart-card.ts`, `create-enhanced-data-helpers.ts`, `global-test-config.ts`, `create-test-router.ts`, `find-labelled-values.ts`, `i18n.ts` and `linear-chart-stub.ts`.
Each factory returns a **fully-populated** object of the real front-end type and accepts a `Partial<T>` of overrides, spread last, so a test only states the fields relevant to its case:
```typescript
const runningVm = createVm({ power_state: VM_POWER_STATE.RUNNING })
const host = createHost({ memory: { size: 8 * 1024 ** 3, usage: 3 * 1024 ** 3 } })
```
Rules:
- **Reuse factories, never duplicate** the object shape inline.
- Type fixtures from the real source types so they drift with production. For a shape local to one test, derive it — e.g. `type StorageUsage = NonNullable<NonNullable<XoPoolDashboard['srs']>['topFiveUsage']>[number]` — and write a small local `createStorageUsage` helper.
- Cast branded ids with the honest type (`'host-1' as XoHost['id']`), never `as never`.

View File

@@ -0,0 +1,44 @@
# File layout and structure
## File layout and naming
- A test file lives **next to** the file it covers, named `<source-name>.unit.test.ts`.
- `xo-host.util.ts` → `xo-host.util.unit.test.ts`
- `use-pool-enhanced-data.composable.ts` → `use-pool-enhanced-data.composable.unit.test.ts`
- `VmSystemGraphics.vue` → `VmSystemGraphics.unit.test.ts` (the `.vue` is dropped)
- Shared test data **factories** and helpers live in `src/test/`.
## 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.
- 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.
```typescript
describe('getHostCoreSocketInfo', () => {
it('formats the core count with its socket count', () => {
const host = createHost({ cpus: { cores: 8, sockets: 2 } })
expect(getHostCoreSocketInfo(host)).toBe('8 (2)')
})
it('falls back to zero for missing core and socket counts', () => {
const host = createHost({ cpus: {} })
expect(getHostCoreSocketInfo(host)).toBe('0 (0)')
})
})
```
## Do not import from `vitest`
`globals: true` (in `vite.config.ts`) together with `"types": ["vitest/globals"]` make `describe`, `it`, `expect`, `vi` and `beforeEach` available globally. **Never** add `import { ... } from 'vitest'`.
Those types are declared in `tsconfig.vitest.json`, not in `tsconfig.app.json`, so they are ambient in test files only. A `expect()` left behind in production code is a type error rather than a runtime `ReferenceError` in the browser.
The two projects split on the file name:
| Project | Covers | Test globals |
| ---------------------- | ----------------------------------------------------- | ------------ |
| `tsconfig.app.json` | everything **except** `*.unit.test.ts` and `setup.ts` | no |
| `tsconfig.vitest.json` | everything, tests included | yes |

View File

@@ -0,0 +1,27 @@
# Mocking dependencies
Keep the mock boundary tight: **mock only the remote-resource collections / stores** a composable depends on, and let pure utilities and icon helpers run for real.
Use the **typed** mock form — `vi.mock(import('<module>'), …)` — so a rename breaks type-check instead of silently leaving a stale mock. Create the stub functions with `vi.hoisted` (a plain `const fn = vi.fn()` referenced in the factory throws, because the mock is hoisted above imports), and reset them in `beforeEach`:
```typescript
const { getHostById, isMasterHost } = vi.hoisted(() => ({
getHostById: vi.fn(),
isMasterHost: vi.fn(),
}))
vi.mock(import('@/modules/host/remote-resources/use-xo-host-collection.ts'), () => ({
useXoHostCollection: (() => ({ getHostById, isMasterHost })) as unknown as typeof useXoHostCollection,
}))
beforeEach(() => {
getHostById.mockReset()
isMasterHost.mockReset()
})
```
Note the cast: the typed form checks the factory return against the **whole** module, so a partial mock of a large export needs `as unknown as typeof useX` (with an `import type { useX }`).
## Network
`src/test/setup.ts` stubs the global `fetch` with a never-settling promise, so composables that trigger remote requests on use never hit the network in unit tests. Override it locally with `vi.stubGlobal('fetch', …)` when a test needs a real response.

View File

@@ -0,0 +1,28 @@
# Test runner
Testing uses **Vitest 4** with a single `unit` project (see `vite.config.ts`): every test matches `**/*.unit.test.ts` and runs on `happy-dom`, CLI-only, no browser.
Utilities, composables **and components** all run there — mounting a component needs `@vue/test-utils`, not a browser. Only real-CSS and layout assertions are out of reach; see [Testing components](./testing-components.md).
`test.alias` in that config maps `placement.js` to its ESM build: the package only declares a `module` entry, which the Node-style resolution used by Vitest ignores, so any component pulling in a menu fails to resolve it otherwise.
## Running tests
`yarn test` runs the suite once and exits. A bare `yarn vitest` starts watch mode, which
reruns the affected tests on every save — that is the one to use while developing.
```sh
cd @xen-orchestra/web
# Run the whole suite once
yarn test
# Watch mode
yarn vitest
# Watch a single module
yarn vitest src/modules/vm/
# Browse and re-run tests from the Vitest UI
yarn test:ui
```

View File

@@ -0,0 +1,51 @@
# Setup: prefer helpers over `beforeEach`
Reserve `beforeEach` for **mock resets only**. Repeated setup goes into small local helper/factory functions at the top of the file:
A composable mounted with different fixture overrides across a file gets one helper taking those overrides — never a `mountComposable(...)` call repeated per test:
```typescript
function mountVmUtils(overrides: Partial<FrontXoVm> = {}) {
return mountComposable(() => useXoVmUtils(createVm(overrides))).wrapper.vm
}
it('is true while a state-changing operation is pending', () => {
const result = mountVmUtils({ current_operations: { task1: VM_OPERATIONS.CLEAN_REBOOT } })
expect(result.isChangingState).toBe(true)
})
```
Two cases legitimately stay inline: a **reactivity** test, which needs a `ref` it can reassign, and a test that must call `useI18n()` _inside_ the `setup` callback to build its expected value (see [Testing composables](./testing-composables.md)).
Because mocks are read lazily, calling `getX.mockReturnValue(...)` in the test body _before_ invoking the helper still takes effect.
This applies to **navigation into the result** too, not only to mounting. An expression such as `result.filterableServers[0]`, repeated across a `describe` block, is repeated setup — give it a helper and assert on a named local, rather than re-deriving it in every test.
The exception is a **reactivity** test: it must keep the live `wrapper` so it can re-read through `wrapper.vm` after reassigning the source ref. A helper returning a snapshot of the first item would defeat the test.
## Unmounting
`src/test/setup.ts` calls `enableAutoUnmount(afterEach)`, so every wrapper — `mount` or `mountComposable` — is unmounted after the test that created it. Never unmount by hand, and never mount in `beforeAll`: that wrapper would be torn down after the first test.
## Shared helpers: `use<X>EnhancedData`
When several modules test the _same shape_ of composable, the helpers belong in `src/test/`, not copy-pasted per module. The `use<X>EnhancedData` family (`usePoolEnhancedData`, `useVmEnhancedData`, … each backing a `<X>sTable.vue`) all expose a `filterable<X>s` computed plus a `getDisplayData`, so `src/test/create-enhanced-data-helpers.ts` builds their three helpers once. Only the name of the filterable list varies, so it is passed as a getter:
```typescript
const { mountEnhancedData, mountFirstFilterable, mountFirstDisplayData } = createEnhancedDataHelpers(
usePoolEnhancedData,
result => result.filterableServers,
createServer
)
it('maps poolName from the server pool label', () => {
const filterableServer = mountFirstFilterable([createServer({ poolNameLabel: 'Production Pool' })])
expect(filterableServer.poolName).toBe('Production Pool')
})
```
Types are inferred from the composable itself, so `filterableServer` is a `ServerFilterableData` and `mountFirstDisplayData()` a `ServerDisplayData` — no annotation at the call site. `mountEnhancedData` returns the mounted instance, for the reactivity test.
Adding a new `use<X>EnhancedData` test means three lines of wiring, not three copied helpers.

View File

@@ -0,0 +1,215 @@
# Testing components
Components run in the same `unit` project as everything else: `@vue/test-utils` `mount` on `happy-dom`, **no browser needed**. Mount with `createGlobalTestConfig()` so the real `vue-i18n` instance and a fresh Pinia are installed, and give the repeated mount a helper (see [Setup](./setup-helpers.md)):
```typescript
import VmSystemGraphics from '@/modules/vm/components/system/VmSystemGraphics.vue'
import { createVm } from '@/test/create-vm.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
function mountGraphics(vm = createVm()) {
return mount(VmSystemGraphics, {
props: { vm },
global: createGlobalTestConfig(),
})
}
it('shows VGA as enabled when the VM uses the "std" adapter', () => {
const wrapper = mountGraphics(createVm({ vga: 'std' }))
expect(wrapper.text()).toContain(t('enabled'))
})
```
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.
## Keep components thin
A component should not carry much complex logic. Non-trivial computation (chart series, aggregation, formatting rules) belongs in a `*.util.ts` or a composable, tested directly — that is cheaper and covers more branches than mounting. A component that is awkward to test is telling you to **extract**, not to write a heavier component test, and never to skip the test.
What is left to assert at the component level is what it renders: labels, formatted values, conditional rows, empty states.
### Extracting from a tested component
The extraction is a refactor, so the tests come **first** and are what proves it changed nothing:
1. Write the tests against the component **as it stands**, and run them green.
2. Extract.
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 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.
## Assert what the user reads, not the markup
`wrapper.text()` concatenates the whole subtree **with no separators**, so `'8 B'` and `'Disabled'` run together. It is fine for `toContain` on a single component, but it cannot express "this label has this value". Query instead:
```typescript
// every card title, in order
expect(wrapper.findAll('.ui-title').map(title => title.text())).toEqual([t('graphics-display')])
// a key/value row: the markup is semantic, so label and value are addressable
const rows = wrapper.findAll('.vts-tabular-key-value-row')
const labelledValues = Object.fromEntries(rows.map(row => [row.get('dt.label').text(), row.get('dd.value').text()]))
expect(labelledValues).toEqual({ [t('vga')]: t('disabled'), [t('video-ram')]: '8 B' })
```
Prefer this shape: one assertion covering every row a user sees, and it fails loudly when a value lands under the wrong label. Assert against **class names and semantic elements**, never a positional chain of child indexes.
`src/test/find-labelled-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 |
```typescript
expect(findLabelledValues(wrapper)).toEqual({ [t('vga')]: t('disabled'), [t('video-ram')]: '8 B' })
```
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:
```typescript
expect(wrapper.findAll('.vts-card-row-key-value').map(row => row.get('.value').text())).toEqual([
'10.0.0.1',
'10.0.0.2',
])
```
When a card mixes deterministic values with environment-derived ones (a relative start time, a locale-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.
### 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` and `d` of the very instance `createGlobalTestConfig()` installs:
```typescript
// ✗ rewording the translation breaks the test although the component still picks the same key
expect(findLabelledValues(wrapper)).toEqual({ 'Suspend storage repository': 'None' })
// ✓ asserts the decision: the suspend-SR row, falling back to the "none" wording
expect(findLabelledValues(wrapper)).toEqual({ [t('suspend-storage-repository')]: t('none') })
```
A mistyped key does not pass quietly — `t` returns a `⟨key⟩` marker for a key the locale has no entry for, which matches no rendered text.
Where the line falls:
| Value | Assert as |
| ---------------------------------------------------------- | -------------------------------------------------- |
| a label, or a value that is itself a translation | `t('key')` — including a plural, `t('n-vcpus', 2)` |
| a locale-formatted date | `d(timestamp, { …the component's options })` |
| a string the component composes from several translations | a template literal of those `t(…)` calls |
| data reaching the component (a name, an id, an IP, a tag) | the literal — it is the test's own input |
| a number the component formats (`'8 B'`, `'4 GiB'`, `'2'`) | the literal — deriving it would copy the template |
## Chart cards
A dashboard chart card loads its `VtsLinearChart` asynchronously, and the real chart cannot mount here: ECharts needs a canvas and a `ResizeObserver`. `src/test/linear-chart-stub.ts` stands in for it, with the props declared so they stay readable. `mountChartCard` from `src/test/mount-chart-card.ts` wires that stub in and defaults `loading` to `false`, so a card test only names its component and its stats type:
```typescript
function mountChart(props: ChartCardProps<XapiHostStats>) {
return mountChartCard(HostDashboardLoadAverageChart, props)
}
it('plots the load average of the host', () => {
const wrapper = mountChart({ data: createHostStats({ stats: { load: [1.234, 2.567] } }) })
expect(findLinearChart(wrapper).props('data')).toEqual([
{
label: t('load-average'),
data: [
{ timestamp: 990_000, value: 1.23 },
{ timestamp: 1_000_000, value: 2.57 },
],
},
])
})
```
Stubbing with `true` instead would push the props into attributes, where they arrive stringified — which is why `mountChartCard` owns that wiring rather than each test repeating it.
Assert the plotted series, not the absence of a state hero. Every card guards on the series it built, so an empty series does reach the "no data" hero — but a hero-only test still passes over a series plotting the wrong samples. That is how the pool CPU card came to size its series off `stats.memory` unnoticed.
The `maxValue` is worth its own assertion: it is where the card's own axis choice lives (`{ step: 5, fallback: 10 }` for the host load average, `step: 50` for host network throughput, `headroom: 1.2` for the stacked pool cpu usage), and nothing else covers it.
So is the `valueFormatter`, which decides whether the axis and the tooltips read `1 KiB` or `1024%`. `formatChartValue(wrapper, value)` runs a value through the formatter the card handed over, and returns `undefined` when it handed none — so one assertion covers both the choice of formatter and the wiring:
```typescript
it('formats the plotted values as bytes', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(formatChartValue(wrapper, 1024)).toBe('1 KiB')
})
```
Keep it to one representative value: how the formatter handles the whole range, `null` included, belongs to the formatter's own test — `chart-stats.util.unit.test.ts` for `formatChartBytes`, `chart-percent-formatter.composable.unit.test.ts` for `useChartPercentFormatter`.
## Legend cards
A donut card and a progress-bar card both render their values through `UiLegend`, which keeps the label and the value addressable:
```typescript
function findLegends(wrapper: VueWrapper) {
return wrapper
.findAll('.ui-legend')
.map(legend => [legend.get('.label').text(), legend.get('.value-and-unit').text()])
}
expect(findLegends(wrapper)).toEqual([
[t('vm:status:running', 2), '2'],
[t('vm:status:halted', 2), '1'],
])
```
Three things shape those assertions:
- `VtsProgressBarGroup` sorts **descending by default**, so the expected order is the busiest first, not the order the payload listed.
- A card holding several legend groups — `PoolDashboardStatus` shows one for its hosts and one for its VMs — is queried per group (`wrapper.findAll('.vts-donut-chart-with-legend')`, then the legends inside each). A flat `findAll('.ui-legend')` collapses the groups into one list, and an assertion on it no longer says which group a value landed in.
- `VtsStateHero` renders its own wording ahead of the slot: an `all-done` hero reads `'All good!Patches up to date'`. Assert the part the component owns with `toContain`.
## Routing
`createGlobalTestConfig()` installs a router on **every** mount, built by `src/test/create-test-router.ts` over the **real generated routes** on an in-memory history. Two unrelated things need it:
- A component rendering `RouterLink`. Without a router the link resolves to a plain element and its scoped slot (`isActive`, `href`) is never invoked, so the markup renders **empty instead of failing** — a tab bar silently becomes no tabs, and an assertion on it passes vacuously.
- Anything reaching `useUiStore` (`VtsStateHero`, `VtsKeyValueRow`, `TabItem`…). That store calls `useRouter()`/`useRoute()` in its setup, so a router-less mount logs two `injection "Symbol(router)" not found` warnings, and reading `uiStore.hasUi` would throw on `route.query`.
Because the routes are the real ones, `href` assertions are real paths (`/vm/vm-42/system`) rather than a stub's echo of its own prop. Page components are swapped for an empty one: a test never renders a page, and installing a router runs an initial navigation that would otherwise import the whole page module graph.
A test that needs to navigate builds its own router and pushes before mounting:
```typescript
const router = createTestRouter()
await router.push('/vm/vm-42/system')
const wrapper = mount(VmHeader, { props: { vm }, global: createGlobalTestConfig({ router }) })
expect(
wrapper
.findAll('.tab-item')
.filter(tab => tab.classes('active'))
.map(tab => tab.text())
).toEqual([t('system')])
```
Pushing before mounting is what makes the active tab assertable; without a push the router sits at its start location and nothing is active.
## What `happy-dom` cannot do
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`)
- real event dispatch — `trigger()` simulates events
- canvas (ECharts), `ResizeObserver`, `IntersectionObserver` — for a chart card, stub the chart (see [Chart cards](#chart-cards))
`happy-dom` also **lacks some browser APIs entirely**. `EventSource` is one: mounting a component that reaches a remote-resource collection opens an SSE subscription and throws `ReferenceError: EventSource is not defined`. It surfaces as an _unhandled rejection_, so Vitest **exits non-zero while reporting every test as passed** — do not read a green test list as success. Mock the collection ([Mocking dependencies](./mocking.md)) so the subscription is never opened.
## Pages
Pages (`src/pages/**`) are the **lowest** testing priority, and their tests stay thin. A page is composition: assert which components it renders, and their order. **Never assert a child's values from a page test** — duplicating them there adds no coverage, breaks on every unrelated child edit, and does not say which child broke. Value and formatting assertions live in the test next to the component that renders them.

View File

@@ -0,0 +1,62 @@
# Testing composables
A composable that relies on a Vue context (`useI18n`, `inject`, an effect scope, plugins) must run **inside a mounted component**. Use the `mountComposable` helper from `src/test/mount-composable.ts`, which mounts a throwaway component with the real `vue-i18n` instance and a fresh Pinia:
```typescript
import { mountComposable } from '@/test/mount-composable.ts'
const { wrapper } = mountComposable(() => {
const { t } = useI18n()
return { ...useXoVmUtils(createVm({ power_state: VM_POWER_STATE.RUNNING })), t }
})
expect(wrapper.vm.powerState.icon).toBe('status:running-circle')
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).
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.
## Reactivity
To test that a composable reacts to its source, pass a `ref` and reassign it:
```typescript
const vm = ref(createVm({ power_state: VM_POWER_STATE.RUNNING }))
const { wrapper } = mountComposable(() => useXoVmUtils(vm))
expect(wrapper.vm.powerState.icon).toBe('status:running-circle')
vm.value = createVm({ power_state: VM_POWER_STATE.HALTED })
expect(wrapper.vm.powerState.icon).toBe('status:halted-circle')
```
## Composables whose returned function calls `useI18n` / `inject`
`useI18n()` must be called synchronously during a component `setup()`. If a composable returns a function that _itself_ calls `useI18n` (e.g. `useXoHostUtils(host).getRelativeStartTime`), you cannot call that function from the test body after `mountComposable` has returned — it throws `Must be called at the top of a 'setup' function`. Invoke it **inside** the `mountComposable` callback instead:
```typescript
const startTimeInSeconds = 1660000000
const { wrapper } = mountComposable(() => {
const { locale } = useI18n()
return {
relativeStartTime: useXoHostUtils(createHost()).getRelativeStartTime(startTimeInSeconds),
expectedRelativeTime: getRelativeTime(new Date(startTimeInSeconds * 1000), locale.value),
}
})
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.
Functions that only touch refs/computed (e.g. `getPowerState`) can be called on the returned object as usual.

View File

@@ -0,0 +1,26 @@
# Testing pure utilities
Utility functions (`*.util.ts`) are pure — import and call them directly, and assert `input → output`. No mounting, no mocks.
```typescript
describe('buildCpuUsageSeries', () => {
it('averages the usage across all cpus at each index', () => {
const data = createHostStats({ stats: { cpus: { cpu0: [10, 20], cpu1: [30, 40] } } })
expect(buildCpuUsageSeries(data)).toEqual([
{ timestamp: 990_000, value: 20 },
{ timestamp: 1_000_000, value: 30 },
])
})
it('returns an empty array for null data', () => {
expect(buildCpuUsageSeries(null)).toEqual([])
})
})
```
Cover the meaningful edge cases: empty input, missing samples (`NaN` propagation), `null` data, and boundary rounding.
> **Prefer extracting pure logic into a util.** When a `<script setup>` holds non-trivial logic (chart series, formatting, aggregation), pull it into `modules/<mod>/utils/*.util.ts` and unit-test that — a component should not carry much complex logic. This is how the Pool and Host dashboard chart logic was made testable (`xo-pool-dashboard.util.ts`, `xo-host-dashboard.util.ts`). What remains in the component is then rendering, covered by [Testing components](./testing-components.md).
> **Logic more than one module needs goes to `src/shared/utils/`,** not copied per module. `shared/utils/chart-stats.util.ts` holds what the VM, host and pool dashboards all do — building timestamps, averaging cpu usage, deriving used memory, summing a bidirectional series, rounding an axis maximum — and is tested once, there. A `modules/<mod>/utils/*.util.ts` earns its place only for logic no other module has (`buildHostLoadAverageSeries`: hosts alone report a load average). Never add a module function that just forwards to the shared one: the component calls the shared function itself, passing the stats path it reads and the axis step it wants.

View File

@@ -9,7 +9,9 @@
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build --force",
"prepublishOnly": "yarn run build-only"
"prepublishOnly": "yarn run build-only",
"test": "vitest run",
"test:ui": "vitest --ui"
},
"devDependencies": {
"@csstools/postcss-global-data": "^3.0.0",
@@ -30,6 +32,8 @@
"@types/node": "^18.19.80",
"@vates/types": "^2.0.1",
"@vitejs/plugin-vue": "^6.0.0",
"@vitest/ui": "^4.1.9",
"@vue/test-utils": "^2.4.11",
"@vue/tsconfig": "^0.9.0",
"@vueuse/core": "^14.0.0",
"@vueuse/integrations": "^14.0.0",
@@ -38,6 +42,7 @@
"@xen-orchestra/web-core": "^0.60.0",
"complex-matcher": "^1.1.1",
"d3-time-format": "^4.1.0",
"happy-dom": "^20.0.0",
"human-format": "^1.2.1",
"lodash-es": "^4.18.0",
"ndjson-readablestream": "^1.3.0",
@@ -52,6 +57,7 @@
"unplugin-vue-router": "^0.12.0",
"value-matcher": "^0.2.0",
"vite": "^8.0.0",
"vitest": "^4.1.9",
"vue": "~3.5.13",
"vue-i18n": "^11.1.2",
"vue-router": "5.0.6",

View File

@@ -0,0 +1,164 @@
import HostDashboardCpuProvisioning from '@/modules/host/components/dashboard/HostDashboardCpuProvisioning.vue'
import type { FrontXoHost, useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import type { FrontXoVm, useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { createHost } from '@/test/create-host.ts'
import { createVm } from '@/test/create-vm.ts'
import { findCardNumbers } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { VM_POWER_STATE } from '@vates/types'
import { mount } from '@vue/test-utils'
import { ref } from 'vue'
const { useXoHostCollectionMock, useXoVmCollectionMock } = vi.hoisted(() => ({
useXoHostCollectionMock: vi.fn(),
useXoVmCollectionMock: vi.fn(),
}))
vi.mock(import('@/modules/host/remote-resources/use-xo-host-collection.ts'), () => ({
useXoHostCollection: useXoHostCollectionMock as unknown as typeof useXoHostCollection,
}))
vi.mock(import('@/modules/vm/remote-resources/use-xo-vm-collection.ts'), () => ({
useXoVmCollection: useXoVmCollectionMock as unknown as typeof useXoVmCollection,
}))
const host = createHost({ id: 'host-1' as FrontXoHost['id'], cpus: { cores: 8, sockets: 2 } })
function givenVmsByHost(vmsByHost: Map<FrontXoHost['id'], FrontXoVm[]>) {
useXoVmCollectionMock.mockReturnValue({ vmsByHost: ref(vmsByHost), areVmsReady: ref(true) })
}
function givenHostVms(vms: FrontXoVm[]) {
givenVmsByHost(new Map([[host.id, vms]]))
}
function createRunningVm(id: string, vCpus: number) {
return createVm({
id: id as FrontXoVm['id'],
power_state: VM_POWER_STATE.RUNNING,
CPUs: { max: vCpus, number: vCpus },
})
}
beforeEach(() => {
useXoHostCollectionMock.mockReset()
useXoVmCollectionMock.mockReset()
useXoHostCollectionMock.mockReturnValue({ areHostsReady: ref(true) })
givenHostVms([])
})
function mountProvisioning(mountedHost = host) {
return mount(HostDashboardCpuProvisioning, {
props: { host: mountedHost },
global: createGlobalTestConfig(),
})
}
it('renders the card title', () => {
const wrapper = mountProvisioning()
expect(wrapper.get('.ui-card-title').text()).toBe(t('cpu-provisioning'))
})
it('shows a loader instead of the provisioning while the hosts are still loading', () => {
useXoHostCollectionMock.mockReturnValue({ areHostsReady: ref(false) })
const wrapper = mountProvisioning()
expect(wrapper.find('.ui-loader').exists()).toBe(true)
expect(findCardNumbers(wrapper)).toEqual([])
})
it('shows a loader instead of the provisioning while the VMs are still loading', () => {
useXoVmCollectionMock.mockReturnValue({ vmsByHost: ref(new Map()), areVmsReady: ref(false) })
const wrapper = mountProvisioning()
expect(wrapper.find('.ui-loader').exists()).toBe(true)
expect(findCardNumbers(wrapper)).toEqual([])
})
it('sums the vCPUs of the running VMs against the cores of the host', () => {
givenHostVms([createRunningVm('vm-1', 2), createRunningVm('vm-2', 4)])
const wrapper = mountProvisioning()
expect(findCardNumbers(wrapper)).toEqual([
[t('vcpus-assigned'), '6'],
[t('total-cpus'), '8'],
])
})
it('leaves out the vCPUs of the VMs that are not running', () => {
givenHostVms([
createRunningVm('vm-1', 2),
createVm({ id: 'vm-2' as FrontXoVm['id'], power_state: VM_POWER_STATE.HALTED, CPUs: { max: 4, number: 4 } }),
])
const wrapper = mountProvisioning()
expect(findCardNumbers(wrapper)).toEqual([
[t('vcpus-assigned'), '2'],
[t('total-cpus'), '8'],
])
})
it('leaves out the VMs running on another host', () => {
givenVmsByHost(new Map([['host-2' as FrontXoHost['id'], [createRunningVm('vm-1', 2)]]]))
const wrapper = mountProvisioning()
expect(findCardNumbers(wrapper)).toEqual([
[t('vcpus-assigned'), '0'],
[t('total-cpus'), '8'],
])
})
it('reports no vCPU assigned for a host without VM', () => {
const wrapper = mountProvisioning()
expect(findCardNumbers(wrapper)).toEqual([
[t('vcpus-assigned'), '0'],
[t('total-cpus'), '8'],
])
})
it('fills the progress bar with the share of the cores the running VMs took', () => {
givenHostVms([createRunningVm('vm-1', 2), createRunningVm('vm-2', 4)])
const wrapper = mountProvisioning()
expect(wrapper.get('.ui-progress-bar .fill').attributes('style')).toBe('width: 75%;')
})
it('labels the progress bar with the share of the cores in use', () => {
givenHostVms([createRunningVm('vm-1', 2), createRunningVm('vm-2', 4)])
const wrapper = mountProvisioning()
expect(wrapper.get('.ui-legend').text()).toBe(`${t('vcpus')}75%`)
})
it('overprovisions past the cores of the host', () => {
givenHostVms([createRunningVm('vm-1', 8), createRunningVm('vm-2', 8)])
const wrapper = mountProvisioning()
expect(findCardNumbers(wrapper)).toEqual([
[t('vcpus-assigned'), '16'],
[t('total-cpus'), '8'],
])
})
it('leaves the progress bar empty and the core count unknown when the host does not report its cores', () => {
givenHostVms([createRunningVm('vm-1', 2), createRunningVm('vm-2', 4)])
const wrapper = mountProvisioning(createHost({ id: host.id, cpus: { sockets: 2 } }))
expect(wrapper.get('.ui-progress-bar .fill').attributes('style')).toBe('width: 0%;')
expect(findCardNumbers(wrapper)).toEqual([
[t('vcpus-assigned'), '6'],
[t('total-cpus'), '-'],
])
})

View File

@@ -5,7 +5,7 @@
<template v-else>
<VtsProgressBar
:label="t('vcpus')"
:total="cpusCount ?? vCpusCount"
:total="cpusCount ?? 0"
:thresholds="cpuProgressThresholds(t('cpu-provisioning-warning'))"
:current="vCpusCount"
legend-type="percent"
@@ -47,7 +47,7 @@ const isReady = logicAnd(areHostsReady, areVmsReady)
const hostVms = computed(() => vmsByHost.value.get(host.id) ?? [])
const cpusCount = computed(() => host.cpus.cores ?? 0)
const cpusCount = computed(() => host.cpus.cores)
const runningVms = computed(() => hostVms.value.filter(vm => vm.power_state === VM_POWER_STATE.RUNNING))

View File

@@ -0,0 +1,75 @@
import HostDashboardCpuUsageChart from '@/modules/host/components/dashboard/HostDashboardCpuUsageChart.vue'
import { createHostStats } from '@/test/create-host-stats.ts'
import { n, t } from '@/test/i18n.ts'
import { findLinearChart, formatChartValue } from '@/test/linear-chart-stub.ts'
import { mountChartCard, type ChartCardProps } from '@/test/mount-chart-card.ts'
import type { XapiHostStats } from '@vates/types/common'
function mountChart(props: ChartCardProps<XapiHostStats>) {
return mountChartCard(HostDashboardCpuUsageChart, props)
}
const statsWithSamples = createHostStats({ stats: { cpus: { '0': [10, 20] } } })
it('renders the card title and the period it covers', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(wrapper.get('.ui-card-title').text()).toContain(t('cpu-usage'))
expect(wrapper.get('.ui-card-title').text()).toContain(t('last-week'))
})
it('shows a loader while the stats are loading', () => {
const wrapper = mountChart({ data: null, loading: true })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows a loader while the stats have not arrived yet', () => {
const wrapper = mountChart({ data: null })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows an error message when the stats could not be fetched', () => {
const wrapper = mountChart({ data: statsWithSamples, error: true })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('reports that there is nothing to plot when the host has no cpu sample', () => {
const wrapper = mountChart({ data: createHostStats() })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('reports that there is nothing to plot when the host reports no cpu at all', () => {
const wrapper = mountChart({ data: createHostStats({ stats: { cpus: {} } }) })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('plots the average usage across the vcpus of the host', () => {
const wrapper = mountChart({ data: createHostStats({ stats: { cpus: { '0': [10, 20], '1': [30, 40] } } }) })
expect(findLinearChart(wrapper).props('data')).toEqual([
{
label: t('stacked-cpu-usage'),
data: [
{ timestamp: 990_000, value: 20 },
{ timestamp: 1_000_000, value: 30 },
],
},
])
})
it('rounds the axis up to the next hundred percent', () => {
const wrapper = mountChart({ data: createHostStats({ stats: { cpus: { '0': [10, 120] } } }) })
expect(findLinearChart(wrapper).props('maxValue')).toBe(200)
})
it('formats the plotted values as percentages', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(formatChartValue(wrapper, 4200)).toBe(n(42, 'percent'))
})

View File

@@ -14,7 +14,9 @@
</template>
<script lang="ts" setup>
import type { LinearChartData, ValueFormatter } from '@core/types/chart.ts'
import { useChartPercentFormatter } from '@/shared/composables/chart-percent-formatter.composable.ts'
import { buildCpuUsageSeries, getCpuUsageMaxValue } from '@/shared/utils/chart-stats.util.ts'
import type { LinearChartData } from '@core/types/chart.ts'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
@@ -30,52 +32,24 @@ const { data } = defineProps<{
const VtsLinearChart = defineAsyncComponent(() => import('@core/components/linear-chart/VtsLinearChart.vue'))
const { t, n } = useI18n()
const { t } = useI18n()
const valueFormatter = useChartPercentFormatter()
const cpuUsageSeries = computed(() => buildCpuUsageSeries(data))
const cpuUsage = computed<LinearChartData>(() => {
if (!data?.stats.cpus) {
if (cpuUsageSeries.value.length === 0) {
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] ?? NaN), 0)
result.set(timestamp, {
timestamp,
value: Math.round(cpuUsageSum / cpus.length),
})
}
return [
{
label: t('stacked-cpu-usage'),
data: Array.from(result.values()),
data: cpuUsageSeries.value,
},
]
})
const maxValue = computed(() => {
const values = cpuUsage.value[0]?.data.map(item => item.value || 0) ?? []
if (values.length === 0) {
return 100
}
const maxCpuUsage = Math.max(...values)
return Math.ceil(maxCpuUsage / 100) * 100
})
const valueFormatter: ValueFormatter = value => {
if (value === null) {
return ''
}
return n(value / 100, 'percent')
}
const maxValue = computed(() => getCpuUsageMaxValue(cpuUsageSeries.value))
</script>

View File

@@ -0,0 +1,69 @@
import HostDashboardLoadAverageChart from '@/modules/host/components/dashboard/HostDashboardLoadAverageChart.vue'
import { createHostStats } from '@/test/create-host-stats.ts'
import { t } from '@/test/i18n.ts'
import { findLinearChart } from '@/test/linear-chart-stub.ts'
import { mountChartCard, type ChartCardProps } from '@/test/mount-chart-card.ts'
import type { XapiHostStats } from '@vates/types/common'
function mountChart(props: ChartCardProps<XapiHostStats>) {
return mountChartCard(HostDashboardLoadAverageChart, props)
}
const statsWithSamples = createHostStats({ stats: { load: [1.5, 2.5] } })
it('renders the card title and the period it covers', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(wrapper.get('.ui-card-title').text()).toContain(t('load-average'))
expect(wrapper.get('.ui-card-title').text()).toContain(t('last-week'))
})
it('shows a loader while the stats are loading', () => {
const wrapper = mountChart({ data: null, loading: true })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows a loader while the stats have not arrived yet', () => {
const wrapper = mountChart({ data: null })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows an error message when the stats could not be fetched', () => {
const wrapper = mountChart({ data: statsWithSamples, error: true })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('reports that there is nothing to plot when the host has no load sample', () => {
const wrapper = mountChart({ data: createHostStats() })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('plots the load average of the host', () => {
const wrapper = mountChart({ data: createHostStats({ stats: { load: [1.234, 2.567] } }) })
expect(findLinearChart(wrapper).props('data')).toEqual([
{
label: t('load-average'),
data: [
{ timestamp: 990_000, value: 1.23 },
{ timestamp: 1_000_000, value: 2.57 },
],
},
])
})
it('rounds the axis up to the next multiple of five', () => {
const wrapper = mountChart({ data: createHostStats({ stats: { load: [1.5, 6.5] } }) })
expect(findLinearChart(wrapper).props('maxValue')).toBe(10)
})
it('falls back to a maximum of ten when every sample is zero', () => {
const wrapper = mountChart({ data: createHostStats({ stats: { load: [0, 0] } }) })
expect(findLinearChart(wrapper).props('maxValue')).toBe(10)
})

View File

@@ -14,6 +14,8 @@
</template>
<script lang="ts" setup>
import { buildHostLoadAverageSeries } from '@/modules/host/utils/xo-host-dashboard.util.ts'
import { getChartMaxValue } from '@/shared/utils/chart-stats.util.ts'
import type { LinearChartData } from '@core/types/chart.ts'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
@@ -32,41 +34,20 @@ const VtsLinearChart = defineAsyncComponent(() => import('@core/components/linea
const { t } = useI18n()
const loadAverageSeries = computed(() => buildHostLoadAverageSeries(data))
const loadAverage = computed<LinearChartData>(() => {
if (!data?.stats.load) {
if (loadAverageSeries.value.length === 0) {
return []
}
const loadValues = data.stats.load
const result = new Map<number, { timestamp: number; value: number }>()
const timestampStart = data.endTimestamp - data.interval * (loadValues.length - 1)
for (let hourIndex = 0; hourIndex < loadValues.length; hourIndex++) {
const timestamp = (timestampStart + hourIndex * data.interval) * 1000
const load = loadValues[hourIndex]
result.set(timestamp, {
timestamp,
value: Number(load?.toFixed(2)),
})
}
return [
{
label: t('load-average'),
data: Array.from(result.values()),
data: loadAverageSeries.value,
},
]
})
const maxValue = computed(() => {
const values = loadAverage.value[0]?.data.map(item => item.value || 0) ?? []
if (values.length === 0) {
return 10
}
const maxLoad = Math.max(...values)
return Math.ceil(maxLoad / 5) * 5
})
const maxValue = computed(() => getChartMaxValue(loadAverageSeries.value, { step: 5, fallback: 10 }))
</script>

View File

@@ -0,0 +1,100 @@
import HostDashboardNetworkUsageChart from '@/modules/host/components/dashboard/HostDashboardNetworkUsageChart.vue'
import { createHostStats } from '@/test/create-host-stats.ts'
import { t } from '@/test/i18n.ts'
import { findLinearChart, formatChartValue } from '@/test/linear-chart-stub.ts'
import { mountChartCard, type ChartCardProps } from '@/test/mount-chart-card.ts'
import type { XapiHostStats } from '@vates/types/common'
function mountChart(props: ChartCardProps<XapiHostStats>) {
return mountChartCard(HostDashboardNetworkUsageChart, props)
}
const statsWithSamples = createHostStats({
stats: { pifs: { rx: { eth0: [10, 20] }, tx: { eth0: [30, 40] } } },
})
it('renders the card title and the period it covers', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(wrapper.get('.ui-card-title').text()).toContain(t('network-throughput'))
expect(wrapper.get('.ui-card-title').text()).toContain(t('last-week'))
})
it('shows a loader while the stats are loading', () => {
const wrapper = mountChart({ data: null, loading: true })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows a loader while the stats have not arrived yet', () => {
const wrapper = mountChart({ data: null })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows an error message when the stats could not be fetched', () => {
const wrapper = mountChart({ data: statsWithSamples, error: true })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('reports that there is nothing to plot when the host has no pif sample', () => {
const wrapper = mountChart({ data: createHostStats() })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('plots the upload above the download', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(findLinearChart(wrapper).props('data')).toEqual([
{
label: t('network-upload'),
data: [
{ timestamp: 990_000, value: 30 },
{ timestamp: 1_000_000, value: 40 },
],
},
{
label: t('network-download'),
data: [
{ timestamp: 990_000, value: 10 },
{ timestamp: 1_000_000, value: 20 },
],
},
])
})
it('sums the throughput of every pif of the host', () => {
const wrapper = mountChart({
data: createHostStats({ stats: { pifs: { rx: { eth0: [10, 20], eth1: [1, 2] }, tx: { eth0: [30, 40] } } } }),
})
expect(findLinearChart(wrapper).props('data')[1].data).toEqual([
{ timestamp: 990_000, value: 11 },
{ timestamp: 1_000_000, value: 22 },
])
})
it('rounds the axis up to the next fifty bytes, with headroom above the peak', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(findLinearChart(wrapper).props('maxValue')).toBe(50)
})
it('plots a flat download for a host reporting transmissions but no reception', () => {
const wrapper = mountChart({
data: createHostStats({ stats: { pifs: { rx: {}, tx: { eth0: [30, 40] } } } }),
})
expect(findLinearChart(wrapper).props('data')[1].data).toEqual([
{ timestamp: 990_000, value: 0 },
{ timestamp: 1_000_000, value: 0 },
])
})
it('formats the plotted values as bytes', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(formatChartValue(wrapper, 1024)).toBe('1 KiB')
})

View File

@@ -9,16 +9,16 @@
<VtsStateHero v-else-if="networkUsage.length === 0" format="card" type="no-data" size="medium">
{{ t('no-data-to-calculate') }}
</VtsStateHero>
<VtsLinearChart v-else :data="networkUsage" :max-value :value-formatter="byteFormatter" />
<VtsLinearChart v-else :data="networkUsage" :max-value :value-formatter="formatChartBytes" />
</UiCard>
</template>
<script lang="ts" setup>
import { buildPairedUsageSeries, formatChartBytes, getPairedUsageMaxValue } from '@/shared/utils/chart-stats.util.ts'
import type { LinearChartData } from '@core/types/chart.ts'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.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 type { XapiHostStats } from '@vates/types/common'
import { computed, defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -33,58 +33,26 @@ const VtsLinearChart = defineAsyncComponent(() => import('@core/components/linea
const { t } = useI18n()
const networkUsageSeries = computed(() => buildPairedUsageSeries(data, data?.stats.pifs?.rx, data?.stats.pifs?.tx))
const networkUsage = computed<LinearChartData>(() => {
if (!data?.stats?.pifs) {
const [download, upload] = networkUsageSeries.value
if (download.length === 0 && upload.length === 0) {
return []
}
const timestamps = Array.from(
{ length: data.stats.pifs.rx['0'].length },
(_, i) => data.endTimestamp * 1000 - ((data.stats.pifs?.rx?.['0'].length ?? 0) - 1 - i) * data.interval * 1000
)
const rxSeries = [
{
label: t('network-download'),
data: timestamps.map((timestamp, index) => ({
timestamp,
value: Object.values(data.stats.pifs?.rx ?? {}).reduce((sum, values) => sum + (values[index] ?? NaN), 0),
})),
},
]
const txSeries = [
return [
{
label: t('network-upload'),
data: timestamps.map((timestamp, index) => ({
timestamp,
value: Object.values(data.stats.pifs?.tx ?? {}).reduce((sum, values) => sum + (values[index] ?? NaN), 0),
})),
data: upload,
},
{
label: t('network-download'),
data: download,
},
]
return [...txSeries, ...rxSeries]
})
const maxValue = computed(() => {
const values = networkUsage.value.flatMap(series => series.data.map(item => item.value || 0))
if (values.length === 0) {
return 100
}
const maxUsage = Math.max(...values) * 1.2
return Math.ceil(maxUsage / 50) * 50
})
const byteFormatter = (value: number | null) => {
if (value === null) {
return ''
}
const size = formatSizeRaw(value, 1)
return `${size.value} ${size.prefix}`
}
const maxValue = computed(() => getPairedUsageMaxValue(networkUsageSeries.value, { step: 50 }))
</script>

View File

@@ -0,0 +1,95 @@
import HostDashboardPatches from '@/modules/host/components/dashboard/HostDashboardPatches.vue'
import type { FrontXoHost } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import type {
MissingPatch,
useXoHostMissingPatchesCollection,
} from '@/modules/host/remote-resources/use-xo-host-missing-patches-collection.ts'
import { createHost } from '@/test/create-host.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
import { ref } from 'vue'
const { useXoHostMissingPatchesCollectionMock } = vi.hoisted(() => ({
useXoHostMissingPatchesCollectionMock: vi.fn(),
}))
vi.mock(import('@/modules/host/remote-resources/use-xo-host-missing-patches-collection.ts'), () => ({
useXoHostMissingPatchesCollection:
useXoHostMissingPatchesCollectionMock as unknown as typeof useXoHostMissingPatchesCollection,
}))
const host = createHost({ id: 'host-1' as FrontXoHost['id'] })
function givenMissingPatches(missingPatches: MissingPatch[], areReady = true) {
useXoHostMissingPatchesCollectionMock.mockReturnValue({
hostMissingPatches: ref(missingPatches),
areHostMissingPatchesReady: ref(areReady),
})
}
beforeEach(() => {
useXoHostMissingPatchesCollectionMock.mockReset()
givenMissingPatches([])
})
function mountPatches() {
return mount(HostDashboardPatches, {
props: { host },
global: createGlobalTestConfig(),
})
}
function findPatchRows(wrapper: ReturnType<typeof mountPatches>) {
return wrapper.findAll('tbody tr').map(row => row.text())
}
it('renders the card title', () => {
const wrapper = mountPatches()
expect(wrapper.get('.ui-card-title').text()).toContain(t('patches'))
})
it('counts the missing patches next to the title', () => {
givenMissingPatches([
{ name: 'XSAPATCH-1', version: '1.0' },
{ name: 'XSAPATCH-2', version: '2.0' },
])
const wrapper = mountPatches()
expect(wrapper.get('.missing-patches-info').text()).toBe(t('n-missing', 2))
})
it('leaves out the count when the host misses no patch', () => {
const wrapper = mountPatches()
expect(wrapper.find('.missing-patches-info').exists()).toBe(false)
})
it('lists one row per missing patch', () => {
givenMissingPatches([
{ name: 'XSAPATCH-1', version: '1.0' },
{ name: 'XSAPATCH-2', version: '2.0' },
])
const wrapper = mountPatches()
expect(findPatchRows(wrapper)).toEqual(['XSAPATCH-11.0', 'XSAPATCH-22.0'])
})
it('reports the host as up to date when it misses no patch', () => {
const wrapper = mountPatches()
expect(wrapper.get('.vts-state-hero').text()).toContain(t('patches-up-to-date'))
expect(findPatchRows(wrapper)).toEqual([])
})
it('reports the host as up to date while its patches are still loading', () => {
givenMissingPatches([], false)
const wrapper = mountPatches()
expect(wrapper.get('.vts-state-hero').text()).toContain(t('patches-up-to-date'))
expect(wrapper.find('.ui-loader').exists()).toBe(false)
})

View File

@@ -0,0 +1,148 @@
import HostDashboardQuickInfo from '@/modules/host/components/dashboard/HostDashboardQuickInfo.vue'
import type { FrontXoHost, useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import type { useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { createHost } from '@/test/create-host.ts'
import { findLabelledValues } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { HOST_POWER_STATE } from '@vates/types'
import { mount } from '@vue/test-utils'
import { ref } from 'vue'
const { useXoHostCollectionMock, getMasterHostByPoolId, isMasterHost } = vi.hoisted(() => ({
useXoHostCollectionMock: vi.fn(),
getMasterHostByPoolId: vi.fn(),
isMasterHost: vi.fn(),
}))
vi.mock(import('@/modules/host/remote-resources/use-xo-host-collection.ts'), () => ({
useXoHostCollection: useXoHostCollectionMock as unknown as typeof useXoHostCollection,
}))
vi.mock(import('@/modules/vm/remote-resources/use-xo-vm-collection.ts'), () => ({
useXoVmCollection: (() => ({ vmsByHost: ref(new Map()) })) as unknown as typeof useXoVmCollection,
}))
beforeEach(() => {
useXoHostCollectionMock.mockReset()
getMasterHostByPoolId.mockReset()
isMasterHost.mockReset()
useXoHostCollectionMock.mockReturnValue({ areHostsReady: ref(true), getMasterHostByPoolId, isMasterHost })
getMasterHostByPoolId.mockReturnValue(undefined)
isMasterHost.mockReturnValue(false)
})
function mountQuickInfo(host: FrontXoHost = createHost()) {
return mount(HostDashboardQuickInfo, {
props: { host },
global: createGlobalTestConfig(),
})
}
it('shows a busy state instead of the rows while the hosts are still loading', () => {
useXoHostCollectionMock.mockReturnValue({ areHostsReady: ref(false), getMasterHostByPoolId, isMasterHost })
const wrapper = mountQuickInfo()
expect(findLabelledValues(wrapper)).toEqual({})
})
it('lists every row of the card, in order', () => {
const wrapper = mountQuickInfo()
expect(wrapper.findAll('.vts-key-value-row').map(row => row.get('dt.label').text())).toEqual([
t('state'),
t('ip-address'),
t('started'),
t('master'),
t('uuid'),
t('description'),
t('version'),
t('hardware'),
t('cores-with-sockets'),
t('ram'),
t('tags'),
])
})
it('shows the identity, power state and resources of the host', () => {
const wrapper = mountQuickInfo(
createHost({
id: 'host-42' as FrontXoHost['id'],
power_state: HOST_POWER_STATE.HALTED,
address: '10.0.0.1',
name_description: 'Runs the production VMs',
version: '8.3.0',
bios_strings: { 'system-manufacturer': 'Dell', 'system-product-name': 'PowerEdge R640' },
cpus: { cores: 16, sockets: 2 },
memory: { size: 4294967296, usage: 2147483648 },
})
)
expect(findLabelledValues(wrapper)).toMatchObject({
[t('state')]: t('host:status:halted'),
[t('ip-address')]: '10.0.0.1',
[t('uuid')]: 'host-42',
[t('description')]: 'Runs the production VMs',
[t('version')]: '8.3.0',
[t('hardware')]: 'Dell (PowerEdge R640)',
[t('cores-with-sockets')]: '16 (2)',
[t('ram')]: '4 GiB',
})
})
it('leaves the hardware row empty for a host reporting no BIOS string', () => {
const wrapper = mountQuickInfo(createHost({ bios_strings: {} }))
expect(findLabelledValues(wrapper)).toMatchObject({ [t('hardware')]: '' })
})
it('shows the manufacturer alone when the host reports no product name', () => {
const wrapper = mountQuickInfo(createHost({ bios_strings: { 'system-manufacturer': 'Dell' } }))
expect(findLabelledValues(wrapper)).toMatchObject({ [t('hardware')]: 'Dell' })
})
it('shows the product name alone when the host reports no manufacturer', () => {
const wrapper = mountQuickInfo(createHost({ bios_strings: { 'system-product-name': 'PowerEdge R640' } }))
expect(findLabelledValues(wrapper)).toMatchObject({ [t('hardware')]: '(PowerEdge R640)' })
})
it('counts no core and no socket for a host reporting neither', () => {
const wrapper = mountQuickInfo(createHost({ cpus: {} }))
expect(findLabelledValues(wrapper)).toMatchObject({ [t('cores-with-sockets')]: '0 (0)' })
})
it('leaves the start time out for a host that is not running', () => {
const wrapper = mountQuickInfo(createHost({ power_state: HOST_POWER_STATE.HALTED }))
expect(findLabelledValues(wrapper)).toMatchObject({ [t('started')]: '' })
})
it('names the host itself as the master of its pool', () => {
isMasterHost.mockReturnValue(true)
expect(findLabelledValues(mountQuickInfo())).toMatchObject({ [t('master')]: t('this-host') })
})
it('links to the master of the pool when the host is not it', () => {
getMasterHostByPoolId.mockReturnValue(createHost({ id: 'host-1' as FrontXoHost['id'], name_label: 'Primary Host' }))
const wrapper = mountQuickInfo()
expect(findLabelledValues(wrapper)).toMatchObject({ [t('master')]: 'Primary Host' })
expect(wrapper.get('.ui-link').attributes('href')).toBe('/host/host-1/dashboard')
})
it('leaves the master row empty when the master of the pool is not known', () => {
expect(findLabelledValues(mountQuickInfo())).toMatchObject({ [t('master')]: '' })
})
it('renders one tag per host tag', () => {
const wrapper = mountQuickInfo(createHost({ tags: ['production', 'billing'] }))
expect(wrapper.findAll('.ui-tag').map(tag => tag.text())).toEqual(['production', 'billing'])
})

View File

@@ -35,13 +35,10 @@
<VtsKeyValueRow :label="t('uuid')" :value="host.id" />
<VtsKeyValueRow :label="t('description')" :value="host.name_description" />
<VtsKeyValueRow :label="t('version')" :value="host.version" />
<VtsKeyValueRow
:label="t('hardware')"
:value="`${host.bios_strings['system-manufacturer']} (${host.bios_strings['system-product-name']})`"
/>
<VtsKeyValueRow :label="t('hardware')" :value="manufacturerInfo" />
</VtsKeyValueList>
<VtsKeyValueList>
<VtsKeyValueRow :label="t('cores-with-sockets')" :value="`${host.cpus.cores} (${host.cpus.sockets})`" />
<VtsKeyValueRow :label="t('cores-with-sockets')" :value="coreSocketInfo" />
<VtsKeyValueRow :label="t('ram')" :value="`${ram.value} ${ram.prefix}`" />
<VtsKeyValueRow :label="t('tags')">
<template #value>
@@ -57,6 +54,7 @@
<script lang="ts" setup>
import { useXoHostUtils } from '@/modules/host/composables/xo-host-utils.composable.ts'
import { type FrontXoHost, useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import { getHostCoreSocketInfo, getHostManufacturerInfo } from '@/modules/host/utils/xo-host.util.ts'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import VtsKeyValueList from '@core/components/key-value-list/VtsKeyValueList.vue'
import VtsKeyValueRow from '@core/components/key-value-row/VtsKeyValueRow.vue'
@@ -95,6 +93,10 @@ const isMaster = computed(() => isMasterHost(host.id))
const masterHost = computed(() => getMasterHostByPoolId(host.$pool))
const ram = computed(() => formatSizeRaw(host.memory.size, 1))
const manufacturerInfo = computed(() => getHostManufacturerInfo(host))
const coreSocketInfo = computed(() => getHostCoreSocketInfo(host))
</script>
<style lang="postcss" scoped>

View File

@@ -0,0 +1,64 @@
import HostDashboardRamProvisioning from '@/modules/host/components/dashboard/HostDashboardRamProvisioning.vue'
import type { FrontXoHost, useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import { createHost } from '@/test/create-host.ts'
import { findCardNumbers } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
import { ref } from 'vue'
const { useXoHostCollectionMock } = vi.hoisted(() => ({ useXoHostCollectionMock: vi.fn() }))
vi.mock(import('@/modules/host/remote-resources/use-xo-host-collection.ts'), () => ({
useXoHostCollection: useXoHostCollectionMock as unknown as typeof useXoHostCollection,
}))
beforeEach(() => {
useXoHostCollectionMock.mockReset()
useXoHostCollectionMock.mockReturnValue({ areHostsReady: ref(true) })
})
function mountProvisioning(host: FrontXoHost = createHost({ memory: { size: 4294967296, usage: 1073741824 } })) {
return mount(HostDashboardRamProvisioning, {
props: { host },
global: createGlobalTestConfig(),
})
}
it('renders the card title', () => {
const wrapper = mountProvisioning()
expect(wrapper.get('.ui-card-title').text()).toBe(t('ram-provisioning'))
})
it('shows a loader instead of the provisioning while the hosts are still loading', () => {
useXoHostCollectionMock.mockReturnValue({ areHostsReady: ref(false) })
const wrapper = mountProvisioning()
expect(wrapper.find('.ui-loader').exists()).toBe(true)
expect(findCardNumbers(wrapper)).toEqual([])
})
it('splits the memory of the host between what is assigned and what is left', () => {
const wrapper = mountProvisioning()
expect(findCardNumbers(wrapper)).toEqual([
[t('total-assigned'), '1 GiB'],
[t('total-free'), '3 GiB'],
])
})
it('fills the progress bar with the share of the memory in use', () => {
const wrapper = mountProvisioning()
expect(wrapper.get('.ui-progress-bar .fill').attributes('style')).toBe('width: 25%;')
})
it('labels the progress bar with the name of the host', () => {
const wrapper = mountProvisioning(
createHost({ name_label: 'Primary Host', memory: { size: 4294967296, usage: 1073741824 } })
)
expect(wrapper.get('.ui-legend').text()).toContain('Primary Host')
})

View File

@@ -29,12 +29,12 @@
<script lang="ts" setup>
import { useXoHostCollection, type FrontXoHost } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import { getHostRamProvisioning } from '@/modules/host/utils/xo-host.util.ts'
import VtsProgressBar from '@core/components/progress-bar/VtsProgressBar.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardNumbers from '@core/components/ui/card-numbers/UiCardNumbers.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import { formatSizeRaw } from '@core/utils/size.util.ts'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -46,16 +46,7 @@ const { t } = useI18n()
const { areHostsReady } = useXoHostCollection()
const ramUsage = computed(() => {
const total = host.memory.size
const used = host.memory.usage
return {
total: formatSizeRaw(total, 0),
used: formatSizeRaw(used, 0),
free: formatSizeRaw(total - used, 0),
}
})
const ramUsage = computed(() => getHostRamProvisioning(host))
</script>
<style lang="postcss" scoped>

View File

@@ -0,0 +1,77 @@
import HostDashboardRamUsageChart from '@/modules/host/components/dashboard/HostDashboardRamUsageChart.vue'
import { createHostStats } from '@/test/create-host-stats.ts'
import { t } from '@/test/i18n.ts'
import { findLinearChart, formatChartValue } from '@/test/linear-chart-stub.ts'
import { mountChartCard, type ChartCardProps } from '@/test/mount-chart-card.ts'
import type { XapiHostStats } from '@vates/types/common'
function mountChart(props: ChartCardProps<XapiHostStats>) {
return mountChartCard(HostDashboardRamUsageChart, props)
}
const statsWithSamples = createHostStats({
stats: { memory: [2048, 4096], memoryFree: [1024, 2048] },
})
it('renders the card title and the period it covers', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(wrapper.get('.ui-card-title').text()).toContain(t('ram-usage'))
expect(wrapper.get('.ui-card-title').text()).toContain(t('last-week'))
})
it('shows a loader while the stats are loading', () => {
const wrapper = mountChart({ data: null, loading: true })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows a loader while the stats have not arrived yet', () => {
const wrapper = mountChart({ data: null })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows an error message when the stats could not be fetched', () => {
const wrapper = mountChart({ data: statsWithSamples, error: true })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('reports that there is nothing to plot when the host has no memory sample', () => {
const wrapper = mountChart({ data: createHostStats() })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('reports that there is nothing to plot when the free memory is missing', () => {
const wrapper = mountChart({ data: createHostStats({ stats: { memory: [2048, 4096] } }) })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('plots the memory the host actually uses', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(findLinearChart(wrapper).props('data')).toEqual([
{
label: t('stacked-ram-usage'),
data: [
{ timestamp: 990_000, value: 1024 },
{ timestamp: 1_000_000, value: 2048 },
],
},
])
})
it('scales the axis to the total memory of the host', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(findLinearChart(wrapper).props('maxValue')).toBe(4096)
})
it('formats the plotted values as bytes', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(formatChartValue(wrapper, 1024)).toBe('1 KiB')
})

View File

@@ -9,16 +9,16 @@
<VtsStateHero v-else-if="ramUsage.length === 0" format="card" type="no-data" size="medium">
{{ t('no-data-to-calculate') }}
</VtsStateHero>
<VtsLinearChart v-else :data="ramUsage" :max-value :value-formatter="byteFormatter" />
<VtsLinearChart v-else :data="ramUsage" :max-value :value-formatter="formatChartBytes" />
</UiCard>
</template>
<script lang="ts" setup>
import { buildRamUsageSeries, formatChartBytes, getRamUsageMaxValue } from '@/shared/utils/chart-stats.util.ts'
import type { LinearChartData } from '@core/types/chart.ts'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.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 type { XapiHostStats } from '@vates/types/common'
import { computed, defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -34,50 +34,19 @@ const VtsLinearChart = defineAsyncComponent(() => import('@core/components/linea
const { t } = useI18n()
const ramUsage = computed<LinearChartData>(() => {
if (!data?.stats.memory || !data?.stats.memoryFree) {
const series = buildRamUsageSeries(data)
if (series.length === 0) {
return []
}
const memoryTotalValues = data.stats.memory
const memoryFreeValues = data.stats.memoryFree
const result = new Map<number, { timestamp: number; value: number }>()
const timestampStart = data.endTimestamp - data.interval * (memoryTotalValues.length - 1)
for (let hourIndex = 0; hourIndex < memoryTotalValues.length; hourIndex++) {
const timestamp = (timestampStart + hourIndex * data.interval) * 1000
const memoryTotal = memoryTotalValues[hourIndex]
const memoryFree = memoryFreeValues[hourIndex]
const memoryUsed = (memoryTotal ?? NaN) - (memoryFree ?? NaN)
result.set(timestamp, {
timestamp,
value: memoryUsed,
})
}
return [
{
label: t('stacked-ram-usage'),
data: Array.from(result.values()),
data: series,
},
]
})
const maxValue = computed(() => {
if (!data?.stats.memory?.length) {
return 1024 * 1024 * 1024 // 1 GB as fallback
}
return Math.max(...data.stats.memory.map(memoryValue => memoryValue || 0), 0)
})
const byteFormatter = (value: number | null) => {
if (value === null) {
return ''
}
const size = formatSizeRaw(value, 1)
return `${size.value} ${size.prefix}`
}
const maxValue = computed(() => getRamUsageMaxValue(data))
</script>

View File

@@ -0,0 +1,144 @@
import HostDashboardVmsStatus from '@/modules/host/components/dashboard/HostDashboardVmsStatus.vue'
import type { FrontXoHost, useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import type { FrontXoVm, useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { createHost } from '@/test/create-host.ts'
import { createVm } from '@/test/create-vm.ts'
import { findLegends } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { VM_POWER_STATE } from '@vates/types'
import { mount } from '@vue/test-utils'
import { nextTick, ref } from 'vue'
const { useXoHostCollectionMock, useXoVmCollectionMock } = vi.hoisted(() => ({
useXoHostCollectionMock: vi.fn(),
useXoVmCollectionMock: vi.fn(),
}))
vi.mock(import('@/modules/host/remote-resources/use-xo-host-collection.ts'), () => ({
useXoHostCollection: useXoHostCollectionMock as unknown as typeof useXoHostCollection,
}))
vi.mock(import('@/modules/vm/remote-resources/use-xo-vm-collection.ts'), () => ({
useXoVmCollection: useXoVmCollectionMock as unknown as typeof useXoVmCollection,
}))
const host = createHost({ id: 'host-1' as FrontXoHost['id'] })
const vmsByHost = ref(new Map<FrontXoHost['id'], FrontXoVm[]>())
function givenVmsByHost(vms: Map<FrontXoHost['id'], FrontXoVm[]>) {
vmsByHost.value = vms
useXoVmCollectionMock.mockReturnValue({ vmsByHost, areVmsReady: ref(true) })
}
function givenHostVms(vms: FrontXoVm[]) {
givenVmsByHost(new Map([[host.id, vms]]))
}
function createVmInState(id: string, powerState: FrontXoVm['power_state']) {
return createVm({ id: id as FrontXoVm['id'], power_state: powerState })
}
beforeEach(() => {
useXoHostCollectionMock.mockReset()
useXoVmCollectionMock.mockReset()
useXoHostCollectionMock.mockReturnValue({ areHostsReady: ref(true) })
givenHostVms([])
})
function mountVmsStatus() {
return mount(HostDashboardVmsStatus, {
props: { host },
global: createGlobalTestConfig(),
})
}
it('renders the card title', () => {
const wrapper = mountVmsStatus()
expect(wrapper.get('.ui-card-title').text()).toBe(t('vms-status'))
})
it('shows a loader instead of the statuses while the hosts are still loading', () => {
useXoHostCollectionMock.mockReturnValue({ areHostsReady: ref(false) })
const wrapper = mountVmsStatus()
expect(wrapper.find('.ui-loader').exists()).toBe(true)
expect(findLegends(wrapper)).toEqual([])
})
it('shows a loader instead of the statuses while the VMs are still loading', () => {
useXoVmCollectionMock.mockReturnValue({ vmsByHost: ref(new Map()), areVmsReady: ref(false) })
const wrapper = mountVmsStatus()
expect(wrapper.find('.ui-loader').exists()).toBe(true)
expect(findLegends(wrapper)).toEqual([])
})
it('counts the VMs of the host in each power state', () => {
givenHostVms([
createVmInState('vm-1', VM_POWER_STATE.RUNNING),
createVmInState('vm-2', VM_POWER_STATE.RUNNING),
createVmInState('vm-3', VM_POWER_STATE.PAUSED),
createVmInState('vm-4', VM_POWER_STATE.SUSPENDED),
createVmInState('vm-5', VM_POWER_STATE.HALTED),
])
const wrapper = mountVmsStatus()
expect(findLegends(wrapper)).toEqual([
[t('vm:status:running', 2), '2'],
[t('vm:status:paused', 2), '1'],
[t('vm:status:suspended', 2), '1'],
[t('vm:status:halted', 2), '1'],
])
})
it('totals the VMs of the host', () => {
givenHostVms([createVmInState('vm-1', VM_POWER_STATE.RUNNING), createVmInState('vm-2', VM_POWER_STATE.HALTED)])
const wrapper = mountVmsStatus()
expect(wrapper.get('.ui-card-numbers').text()).toBe(`${t('total')}2`)
})
it('keeps every power state in the legend for a host without VM', () => {
const wrapper = mountVmsStatus()
expect(findLegends(wrapper)).toEqual([
[t('vm:status:running', 2), '0'],
[t('vm:status:paused', 2), '0'],
[t('vm:status:suspended', 2), '0'],
[t('vm:status:halted', 2), '0'],
])
})
it('leaves out the VMs running on another host', () => {
givenVmsByHost(new Map([['host-2' as FrontXoHost['id'], [createVmInState('vm-1', VM_POWER_STATE.RUNNING)]]]))
const wrapper = mountVmsStatus()
expect(wrapper.get('.ui-card-numbers').text()).toBe(`${t('total')}0`)
})
it('follows the VMs of the host as the collection changes', async () => {
givenHostVms([createVmInState('vm-1', VM_POWER_STATE.RUNNING)])
const wrapper = mountVmsStatus()
vmsByHost.value = new Map([
[host.id, [createVmInState('vm-1', VM_POWER_STATE.RUNNING), createVmInState('vm-2', VM_POWER_STATE.HALTED)]],
])
await nextTick()
expect(findLegends(wrapper)).toEqual([
[t('vm:status:running', 2), '1'],
[t('vm:status:paused', 2), '0'],
[t('vm:status:suspended', 2), '0'],
[t('vm:status:halted', 2), '1'],
])
expect(wrapper.get('.ui-card-numbers').text()).toBe(`${t('total')}2`)
})

View File

@@ -0,0 +1,61 @@
import HostHardwareSpecificationsCard from '@/modules/host/components/list/panel/card/HostHardwareSpecificationsCard.vue'
import type { FrontXoHost } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import { createHost } from '@/test/create-host.ts'
import { findCardLabelledValues } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import { mount } from '@vue/test-utils'
function mountCard(host: FrontXoHost = createHost()) {
return mount(HostHardwareSpecificationsCard, {
props: { host },
global: createGlobalTestConfig(),
})
}
it('renders the card title', () => {
const wrapper = mountCard()
expect(wrapper.get('.ui-card-title').text()).toBe(t('hardware-specifications'))
})
it('shows the manufacturer and the core layout of the host', () => {
const wrapper = mountCard(
createHost({
bios_strings: { 'system-manufacturer': 'Dell', 'system-product-name': 'PowerEdge R640' },
cpus: { cores: 16, sockets: 2 },
})
)
expect(findCardLabelledValues(wrapper)).toEqual({
[t('manufacturer-info')]: 'Dell (PowerEdge R640)',
[t('core-socket')]: '16 (2)',
})
})
it('leaves out the product name when the host does not report one', () => {
const wrapper = mountCard(createHost({ bios_strings: { 'system-manufacturer': 'Dell' } }))
expect(findCardLabelledValues(wrapper)).toMatchObject({ [t('manufacturer-info')]: 'Dell' })
})
it('falls back to zero cores and sockets when the host reports neither', () => {
const wrapper = mountCard(createHost({ cpus: {} }))
expect(findCardLabelledValues(wrapper)).toMatchObject({ [t('core-socket')]: '0 (0)' })
})
it('offers to copy each specification as it is shown', () => {
const wrapper = mountCard(
createHost({
bios_strings: { 'system-manufacturer': 'Dell', 'system-product-name': 'PowerEdge R640' },
cpus: { cores: 16, sockets: 2 },
})
)
expect(wrapper.findAllComponents(VtsCopyButton).map(button => button.props('value'))).toEqual([
'Dell (PowerEdge R640)',
'16 (2)',
])
})

View File

@@ -24,6 +24,7 @@
<script lang="ts" setup>
import type { FrontXoHost } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import { getHostCoreSocketInfo, getHostManufacturerInfo } from '@/modules/host/utils/xo-host.util.ts'
import VtsCardRowKeyValue from '@core/components/card/VtsCardRowKeyValue.vue'
import VtsCopyButton from '@core/components/copy-button/VtsCopyButton.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
@@ -37,18 +38,9 @@ const { host } = defineProps<{
const { t } = useI18n()
const manufacturerInfo = computed(
() =>
(host.bios_strings['system-manufacturer'] ?? '') +
(host.bios_strings['system-product-name'] ? ` (${host.bios_strings['system-product-name']})` : '')
)
const manufacturerInfo = computed(() => getHostManufacturerInfo(host))
const coreSocketInfo = computed(() => {
const cores = host.cpus.cores ?? 0
const sockets = host.cpus.sockets ?? 0
return `${cores} (${sockets})`
})
const coreSocketInfo = computed(() => getHostCoreSocketInfo(host))
</script>
<style scoped lang="postcss">

View File

@@ -0,0 +1,198 @@
import { useXoHostUtils } from '@/modules/host/composables/xo-host-utils.composable.ts'
import type { FrontXoHost } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import type { FrontXoVm, useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { createHost } from '@/test/create-host.ts'
import { createVm } from '@/test/create-vm.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { getRelativeTime } from '@core/composables/relative-time.composable.ts'
import { HOST_ALLOWED_OPERATIONS, HOST_POWER_STATE, VM_OPERATIONS } from '@vates/types'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { vmsByHost } = vi.hoisted(() => ({
vmsByHost: { value: new Map<string, unknown[]>() },
}))
vi.mock(import('@/modules/vm/remote-resources/use-xo-vm-collection.ts'), () => ({
useXoVmCollection: (() => ({ vmsByHost })) as unknown as typeof useXoVmCollection,
}))
beforeEach(() => {
vmsByHost.value = new Map()
})
function setResidentVms(hostId: FrontXoHost['id'], vms: FrontXoVm[]) {
vmsByHost.value = new Map([[hostId, vms]])
}
function mountHostUtils(overrides: Partial<FrontXoHost> = {}) {
return mountComposable(() => {
const { t } = useI18n()
return { ...useXoHostUtils(createHost(overrides)), t }
}).wrapper.vm
}
describe('getPowerState', () => {
it('maps the running state to its label and running-circle icon', () => {
const { getPowerState, t } = mountHostUtils()
expect(getPowerState(HOST_POWER_STATE.RUNNING)).toEqual({
text: t('host:status:running'),
icon: 'status:running-circle',
})
})
it('maps the halted state to its label and halted-circle icon', () => {
const { getPowerState, t } = mountHostUtils()
expect(getPowerState(HOST_POWER_STATE.HALTED)).toEqual({
text: t('host:status:halted'),
icon: 'status:halted-circle',
})
})
it('maps the unknown state to its label and no icon', () => {
const { getPowerState, t } = mountHostUtils()
expect(getPowerState(HOST_POWER_STATE.UNKNOWN)).toEqual({ text: t('host:status:unknown'), icon: undefined })
})
it('falls back to the unknown entry for an out-of-range value', () => {
const { getPowerState, t } = mountHostUtils()
const outOfRangeState = 'Suspended' as HOST_POWER_STATE
expect(getPowerState(outOfRangeState)).toEqual({ text: t('host:status:unknown'), icon: undefined })
})
})
describe('getRelativeStartTime', () => {
it('reports the start time relative to now, reading it as seconds', () => {
const startTimeInSeconds = 1660000000
const { wrapper } = mountComposable(() => {
const { locale } = useI18n()
return {
relativeStartTime: useXoHostUtils(createHost()).getRelativeStartTime(startTimeInSeconds),
expectedRelativeTime: getRelativeTime(new Date(startTimeInSeconds * 1000), locale.value),
}
})
expect(wrapper.vm.relativeStartTime).toBe(wrapper.vm.expectedRelativeTime)
})
})
describe('isChangingState', () => {
it('is false when no operation is pending', () => {
const { isChangingState } = mountHostUtils({ current_operations: {} })
expect(isChangingState).toBe(false)
})
it('is true while a state-changing operation is pending on a running host', () => {
const { isChangingState } = mountHostUtils({
power_state: HOST_POWER_STATE.RUNNING,
current_operations: { 'task-1': HOST_ALLOWED_OPERATIONS.SHUTDOWN },
})
expect(isChangingState).toBe(true)
})
it('is false when the pending operation does not change the state of a running host', () => {
const { isChangingState } = mountHostUtils({
power_state: HOST_POWER_STATE.RUNNING,
current_operations: { 'task-1': HOST_ALLOWED_OPERATIONS.POWER_ON },
})
expect(isChangingState).toBe(false)
})
it('is true while a halted host is powering on', () => {
const { isChangingState } = mountHostUtils({
power_state: HOST_POWER_STATE.HALTED,
current_operations: { 'task-1': HOST_ALLOWED_OPERATIONS.POWER_ON },
})
expect(isChangingState).toBe(true)
})
it('is true while a resident VM of a disabled host is suspending', () => {
const host = createHost({ enabled: false, current_operations: {} })
setResidentVms(host.id, [createVm({ current_operations: { 'task-1': VM_OPERATIONS.SUSPEND } })])
const { wrapper } = mountComposable(() => useXoHostUtils(host))
expect(wrapper.vm.isChangingState).toBe(true)
})
it('is false when a resident VM is suspending but the host is still enabled', () => {
const host = createHost({ enabled: true, current_operations: {} })
setResidentVms(host.id, [createVm({ current_operations: { 'task-1': VM_OPERATIONS.SUSPEND } })])
const { wrapper } = mountComposable(() => useXoHostUtils(host))
expect(wrapper.vm.isChangingState).toBe(false)
})
it('reacts to changes of the source host', () => {
const host = ref(createHost({ current_operations: {} }))
const { wrapper } = mountComposable(() => useXoHostUtils(host))
expect(wrapper.vm.isChangingState).toBe(false)
host.value = createHost({ current_operations: { 'task-1': HOST_ALLOWED_OPERATIONS.REBOOT } })
expect(wrapper.vm.isChangingState).toBe(true)
})
})
describe('currentOperation', () => {
it('maps the pending host operation to a translated label', () => {
const { currentOperation, t } = mountHostUtils({
current_operations: { 'task-1': HOST_ALLOWED_OPERATIONS.REBOOT },
})
expect(currentOperation).toBe(t('operation:clean-reboot'))
})
it('maps the power-on operation of a halted host to a translated label', () => {
const { currentOperation, t } = mountHostUtils({
power_state: HOST_POWER_STATE.HALTED,
current_operations: { 'task-1': HOST_ALLOWED_OPERATIONS.POWER_ON },
})
expect(currentOperation).toBe(t('operation:start'))
})
it('maps the smart-reboot VM operation to a translated label', () => {
const host = createHost({ enabled: false, current_operations: {} })
setResidentVms(host.id, [createVm({ current_operations: { 'task-1': VM_OPERATIONS.HARD_SHUTDOWN } })])
const { wrapper } = mountComposable(() => {
const { t } = useI18n()
return { ...useXoHostUtils(host), t }
})
expect(wrapper.vm.currentOperation).toBe(wrapper.vm.t('operation:vm-hard-shutdown'))
})
it('falls back to an empty label when there is no pending operation', () => {
const { currentOperation } = mountHostUtils({ current_operations: {} })
expect(currentOperation).toBe('')
})
it('falls back to an empty label for an unmapped operation', () => {
const { currentOperation } = mountHostUtils({
current_operations: { 'task-1': 'some_unknown_op' as HOST_ALLOWED_OPERATIONS },
})
expect(currentOperation).toBe('')
})
})

View File

@@ -0,0 +1,13 @@
import { buildTimestamps, type ChartPoint } from '@/shared/utils/chart-stats.util.ts'
import type { XapiHostStats } from '@vates/types/common'
export function buildHostLoadAverageSeries(data: XapiHostStats | null): ChartPoint[] {
if (!data?.stats.load) {
return []
}
const load = data.stats.load
const timestamps = buildTimestamps(data, load.length)
return timestamps.map((timestamp, index) => ({ timestamp, value: Number(load[index]?.toFixed(2)) }))
}

View File

@@ -0,0 +1,34 @@
import { buildHostLoadAverageSeries } from '@/modules/host/utils/xo-host-dashboard.util.ts'
import { createHostStats } from '@/test/create-host-stats.ts'
describe('buildHostLoadAverageSeries', () => {
it('rounds each load sample to two decimals', () => {
const data = createHostStats({ stats: { load: [1.234, 2.567] } })
expect(buildHostLoadAverageSeries(data)).toEqual([
{ timestamp: 990_000, value: 1.23 },
{ timestamp: 1_000_000, value: 2.57 },
])
})
it('propagates NaN when a sample is missing', () => {
const data = createHostStats({ stats: { load: [1.5, null] } })
const series = buildHostLoadAverageSeries(data)
expect(series[0].value).toBe(1.5)
expect(series[1].value).toBeNaN()
})
it('returns an empty array when there are no load stats', () => {
expect(buildHostLoadAverageSeries(createHostStats())).toEqual([])
})
it('returns an empty array when the load stats hold no samples', () => {
expect(buildHostLoadAverageSeries(createHostStats({ stats: { load: [] } }))).toEqual([])
})
it('returns an empty array for null data', () => {
expect(buildHostLoadAverageSeries(null)).toEqual([])
})
})

View File

@@ -2,6 +2,7 @@ import type { FrontXoHost } from '@/modules/host/remote-resources/use-xo-host-co
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { getVmsPendingOperation } from '@/modules/vm/utils/xo-vm.util.ts'
import type { VtsLinkCellProps } from '@core/components/table/cells/VtsLinkCell.vue'
import { formatSizeRaw, type SizeInfo } from '@core/utils/size.util.ts'
import { HOST_ALLOWED_OPERATIONS, HOST_POWER_STATE, VM_OPERATIONS } from '@vates/types'
import { castArray } from 'lodash-es'
@@ -57,3 +58,25 @@ export function getHostSmartRebootVmOperation(host: FrontXoHost, residentVms: Fr
export function getHostInfo(host: FrontXoHost | undefined): VtsLinkCellProps & { label: string } {
return host ? { label: host.name_label, to: `/host/${host.id}/dashboard` } : { label: '' }
}
export function getHostCoreSocketInfo(host: FrontXoHost): string {
return `${host.cpus.cores ?? 0} (${host.cpus.sockets ?? 0})`
}
export function getHostManufacturerInfo(host: FrontXoHost): string {
const manufacturer = host.bios_strings['system-manufacturer'] ?? ''
const productName = host.bios_strings['system-product-name']
return manufacturer + (productName ? ` (${productName})` : '')
}
export function getHostRamProvisioning(host: FrontXoHost): { total: SizeInfo; used: SizeInfo; free: SizeInfo } {
const size = host.memory.size
const usage = host.memory.usage
return {
total: formatSizeRaw(size, 0),
used: formatSizeRaw(usage, 0),
free: formatSizeRaw(size - usage, 0),
}
}

View File

@@ -0,0 +1,105 @@
import {
getHostCoreSocketInfo,
getHostInfo,
getHostManufacturerInfo,
getHostRamProvisioning,
isHostOperationPending,
} from '@/modules/host/utils/xo-host.util.ts'
import { createHost } from '@/test/create-host.ts'
import { formatSizeRaw } from '@core/utils/size.util.ts'
import { HOST_ALLOWED_OPERATIONS } from '@vates/types'
describe('isHostOperationPending', () => {
it('matches a single operation', () => {
const host = createHost({ current_operations: { 'task-1': HOST_ALLOWED_OPERATIONS.REBOOT } })
expect(isHostOperationPending(host, HOST_ALLOWED_OPERATIONS.REBOOT)).toBe(true)
})
it('matches when given an array of operations', () => {
const host = createHost({ current_operations: { 'task-1': HOST_ALLOWED_OPERATIONS.SHUTDOWN } })
expect(isHostOperationPending(host, [HOST_ALLOWED_OPERATIONS.REBOOT, HOST_ALLOWED_OPERATIONS.SHUTDOWN])).toBe(true)
})
it('returns false when none of the current operations match', () => {
const host = createHost({ current_operations: { 'task-1': HOST_ALLOWED_OPERATIONS.ENABLE } })
expect(isHostOperationPending(host, [HOST_ALLOWED_OPERATIONS.REBOOT, HOST_ALLOWED_OPERATIONS.SHUTDOWN])).toBe(false)
})
it('returns false when there are no current operations', () => {
const host = createHost({ current_operations: {} })
expect(isHostOperationPending(host, HOST_ALLOWED_OPERATIONS.REBOOT)).toBe(false)
})
})
describe('getHostInfo', () => {
it('returns the host name label and its dashboard link when a host is provided', () => {
const host = createHost({ name_label: 'Primary Host' })
expect(getHostInfo(host)).toEqual({ label: 'Primary Host', to: `/host/${host.id}/dashboard` })
})
it('returns an empty label when no host is provided', () => {
expect(getHostInfo(undefined)).toEqual({ label: '' })
})
})
describe('getHostCoreSocketInfo', () => {
it('formats the core count with its socket count', () => {
const host = createHost({ cpus: { cores: 8, sockets: 2 } })
expect(getHostCoreSocketInfo(host)).toBe('8 (2)')
})
it('falls back to zero for missing core and socket counts', () => {
const host = createHost({ cpus: {} })
expect(getHostCoreSocketInfo(host)).toBe('0 (0)')
})
})
describe('getHostManufacturerInfo', () => {
it('combines the manufacturer with the product name in parentheses', () => {
const host = createHost({
bios_strings: { 'system-manufacturer': 'Dell Inc.', 'system-product-name': 'PowerEdge R740' },
})
expect(getHostManufacturerInfo(host)).toBe('Dell Inc. (PowerEdge R740)')
})
it('returns only the manufacturer when there is no product name', () => {
const host = createHost({ bios_strings: { 'system-manufacturer': 'Dell Inc.' } })
expect(getHostManufacturerInfo(host)).toBe('Dell Inc.')
})
it('returns an empty string when the bios strings are missing', () => {
const host = createHost({ bios_strings: {} })
expect(getHostManufacturerInfo(host)).toBe('')
})
})
describe('getHostRamProvisioning', () => {
it('formats total, used and free memory sizes', () => {
const size = 8 * 1024 ** 3
const usage = 3 * 1024 ** 3
const host = createHost({ memory: { size, usage } })
expect(getHostRamProvisioning(host)).toEqual({
total: formatSizeRaw(size, 0),
used: formatSizeRaw(usage, 0),
free: formatSizeRaw(size - usage, 0),
})
})
it('reports zero free memory when the host is fully used', () => {
const size = 4 * 1024 ** 3
const host = createHost({ memory: { size, usage: size } })
expect(getHostRamProvisioning(host).free).toEqual(formatSizeRaw(0, 0))
})
})

View File

@@ -0,0 +1,74 @@
import PoolDashboardCpuProvisioning from '@/modules/pool/components/dashboard/PoolDashboardCpuProvisioning.vue'
import type { XoPoolDashboard } from '@/modules/pool/types/xo-pool-dashboard.type.ts'
import { findCardNumbers } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
type CpuProvisioning = NonNullable<XoPoolDashboard['cpuProvisioning']>
function withCpuProvisioning(overrides: Partial<CpuProvisioning> = {}): XoPoolDashboard {
return { cpuProvisioning: { assigned: 6, total: 8, percent: 75, ...overrides } }
}
function mountProvisioning(props: { poolDashboard?: XoPoolDashboard; hasError?: boolean } = {}) {
return mount(PoolDashboardCpuProvisioning, {
props: { poolDashboard: withCpuProvisioning(), ...props },
global: createGlobalTestConfig(),
})
}
it('renders the card title', () => {
const wrapper = mountProvisioning()
expect(wrapper.get('.ui-card-title').text()).toBe(t('cpu-provisioning'))
})
it('shows a loader while the dashboard has not arrived yet', () => {
const wrapper = mountProvisioning({ poolDashboard: undefined })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
expect(findCardNumbers(wrapper)).toEqual([])
})
it('shows a loader while the provisioning is missing from the dashboard', () => {
const wrapper = mountProvisioning({ poolDashboard: {} })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows an error message when the dashboard could not be fetched', () => {
const wrapper = mountProvisioning({ hasError: true })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('splits the CPUs of the pool between what is assigned and what it holds', () => {
const wrapper = mountProvisioning()
expect(findCardNumbers(wrapper)).toEqual([
[t('vcpus-assigned'), '6'],
[t('total-cpus'), '8'],
])
})
it('fills the progress bar with the share of the CPUs assigned', () => {
const wrapper = mountProvisioning()
expect(wrapper.get('.ui-progress-bar .fill').attributes('style')).toBe('width: 75%;')
})
it('labels the progress bar with the share of the CPUs assigned', () => {
const wrapper = mountProvisioning()
expect(wrapper.get('.ui-legend').text()).toBe(`${t('vcpus')}75%`)
})
it('reports no CPU for a pool provisioning nothing', () => {
const wrapper = mountProvisioning({ poolDashboard: withCpuProvisioning({ assigned: 0, total: 0, percent: 0 }) })
expect(findCardNumbers(wrapper)).toEqual([
[t('vcpus-assigned'), '0'],
[t('total-cpus'), '0'],
])
})

View File

@@ -0,0 +1,85 @@
import HostsCpuUsage from '@/modules/pool/components/dashboard/cpu-usage/HostsCpuUsage.vue'
import VmsCpuUsage from '@/modules/pool/components/dashboard/cpu-usage/VmsCpuUsage.vue'
import PoolDashboardCpuUsage from '@/modules/pool/components/dashboard/PoolDashboardCpuUsage.vue'
import type { XoPoolDashboard } from '@/modules/pool/types/xo-pool-dashboard.type.ts'
import { findLegends } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
type TopFiveUsage = NonNullable<NonNullable<XoPoolDashboard['hosts']>['topFiveUsage']>
function createPoolDashboard(overrides: Partial<XoPoolDashboard> = {}): XoPoolDashboard {
return {
hosts: { topFiveUsage: { cpu: [], ram: [] } as TopFiveUsage },
vms: { topFiveUsage: { cpu: [], ram: [] } as NonNullable<NonNullable<XoPoolDashboard['vms']>['topFiveUsage']> },
...overrides,
}
}
function mountCpuUsage(props: { poolDashboard?: XoPoolDashboard; hasError?: boolean } = {}) {
return mount(PoolDashboardCpuUsage, {
props: { poolDashboard: createPoolDashboard(), ...props },
global: createGlobalTestConfig(),
})
}
it('renders the card title', () => {
const wrapper = mountCpuUsage()
expect(wrapper.get('.ui-card-title').text()).toBe(t('cpu-usage'))
})
it('splits the card between the hosts and the VMs, each capped to the top five', () => {
const wrapper = mountCpuUsage()
expect(wrapper.findAll('.ui-card-subtitle').map(subtitle => subtitle.text())).toEqual([
`${t('host')} ${t('top-#', 5)}`,
`${t('vms')} ${t('top-#', 5)}`,
])
})
it('shows a loader in place of the hosts while their usage has not arrived yet', () => {
const wrapper = mountCpuUsage({ poolDashboard: createPoolDashboard({ hosts: {} }) })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
expect(wrapper.findComponent(HostsCpuUsage).exists()).toBe(false)
expect(wrapper.findComponent(VmsCpuUsage).exists()).toBe(true)
})
it('shows a loader in place of the VMs while their usage has not arrived yet', () => {
const wrapper = mountCpuUsage({ poolDashboard: createPoolDashboard({ vms: {} }) })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
expect(wrapper.findComponent(HostsCpuUsage).exists()).toBe(true)
expect(wrapper.findComponent(VmsCpuUsage).exists()).toBe(false)
})
it('plots the usage of the hosts and of the VMs in their own section', () => {
const wrapper = mountCpuUsage({
poolDashboard: {
hosts: {
topFiveUsage: {
cpu: [{ id: 'host-1' as never, name_label: 'Host 1', percent: 30 }],
ram: [],
} as unknown as TopFiveUsage,
},
vms: {
topFiveUsage: {
cpu: [{ id: 'vm-1' as never, name_label: 'VM 1', percent: 70 }],
ram: [],
} as unknown as NonNullable<NonNullable<XoPoolDashboard['vms']>['topFiveUsage']>,
},
},
})
expect(findLegends(wrapper.findComponent(HostsCpuUsage))).toEqual([['Host 1', '30%']])
expect(findLegends(wrapper.findComponent(VmsCpuUsage))).toEqual([['VM 1', '70%']])
})
it('passes the fetch error down to both sections', () => {
const wrapper = mountCpuUsage({ hasError: true })
expect(wrapper.findComponent(HostsCpuUsage).get('.vts-state-hero').text()).toBe(t('error-no-data'))
expect(wrapper.findComponent(VmsCpuUsage).get('.vts-state-hero').text()).toBe(t('error-no-data'))
})

View File

@@ -0,0 +1,84 @@
import PoolDashboardHostsPatches from '@/modules/pool/components/dashboard/PoolDashboardHostsPatches.vue'
import type { XoPoolDashboard } from '@/modules/pool/types/xo-pool-dashboard.type.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
type MissingPatches = NonNullable<NonNullable<XoPoolDashboard['hosts']>['missingPatches']>
function withMissingPatches(missingPatches: MissingPatches): XoPoolDashboard {
return { hosts: { missingPatches } }
}
function withAuthorizedPatches(patches: { name: string; version: string }[]): XoPoolDashboard {
return withMissingPatches({
hasAuthorization: true,
missingPatches: patches as unknown as Extract<MissingPatches, { hasAuthorization: true }>['missingPatches'],
})
}
function mountPatches(props: { poolDashboard?: XoPoolDashboard; hasError?: boolean } = {}) {
return mount(PoolDashboardHostsPatches, {
props: { poolDashboard: withAuthorizedPatches([{ name: 'XSAPATCH-1', version: '1.0' }]), ...props },
global: createGlobalTestConfig(),
})
}
function findPatchRows(wrapper: ReturnType<typeof mountPatches>) {
return wrapper.findAll('tbody tr').map(row => row.text())
}
it('renders the card title', () => {
const wrapper = mountPatches()
expect(wrapper.get('.ui-card-title').text()).toContain(t('patches'))
})
it('shows a loader while the dashboard has not arrived yet', () => {
const wrapper = mountPatches({ poolDashboard: undefined })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
expect(findPatchRows(wrapper)).toEqual([])
})
it('shows a loader while the missing patches are missing from the dashboard', () => {
const wrapper = mountPatches({ poolDashboard: { hosts: {} } })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('counts the missing patches next to the title', () => {
const wrapper = mountPatches({
poolDashboard: withAuthorizedPatches([
{ name: 'XSAPATCH-1', version: '1.0' },
{ name: 'XSAPATCH-2', version: '2.0' },
]),
})
expect(wrapper.get('.missing-patches-info').text()).toBe(t('n-missing', 2))
})
it('lists one row per missing patch', () => {
const wrapper = mountPatches({
poolDashboard: withAuthorizedPatches([
{ name: 'XSAPATCH-1', version: '1.0' },
{ name: 'XSAPATCH-2', version: '2.0' },
]),
})
expect(findPatchRows(wrapper)).toEqual(['XSAPATCH-11.0', 'XSAPATCH-22.0'])
})
it('reports the pool as up to date when its hosts miss no patch', () => {
const wrapper = mountPatches({ poolDashboard: withAuthorizedPatches([]) })
expect(wrapper.get('.vts-state-hero').text()).toContain(t('patches-up-to-date'))
expect(wrapper.find('.missing-patches-info').exists()).toBe(false)
})
it('reports the pool as up to date when the patches cannot be listed without a licence', () => {
const wrapper = mountPatches({ poolDashboard: withMissingPatches({ hasAuthorization: false }) })
expect(wrapper.get('.vts-state-hero').text()).toContain(t('patches-up-to-date'))
expect(findPatchRows(wrapper)).toEqual([])
})

View File

@@ -0,0 +1,96 @@
import PoolDashboardRamUsage from '@/modules/pool/components/dashboard/PoolDashboardRamUsage.vue'
import HostsRamUsage from '@/modules/pool/components/dashboard/ram-usage/HostsRamUsage.vue'
import VmsRamUsage from '@/modules/pool/components/dashboard/ram-usage/VmsRamUsage.vue'
import type { XoPoolDashboard } from '@/modules/pool/types/xo-pool-dashboard.type.ts'
import { findLegends } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
type HostsTopFiveUsage = NonNullable<NonNullable<XoPoolDashboard['hosts']>['topFiveUsage']>
type VmsTopFiveUsage = NonNullable<NonNullable<XoPoolDashboard['vms']>['topFiveUsage']>
function withTopFiveUsage(hosts: HostsTopFiveUsage['ram'], vms: VmsTopFiveUsage['ram']): XoPoolDashboard {
return {
hosts: { topFiveUsage: { cpu: [], ram: hosts } },
vms: { topFiveUsage: { cpu: [], ram: vms } },
}
}
function createPoolDashboard(overrides: Partial<XoPoolDashboard> = {}): XoPoolDashboard {
return { ...withTopFiveUsage([], []), ...overrides }
}
function mountRamUsage(props: { poolDashboard?: XoPoolDashboard; hasError?: boolean } = {}) {
return mount(PoolDashboardRamUsage, {
props: { poolDashboard: createPoolDashboard(), ...props },
global: createGlobalTestConfig(),
})
}
it('renders the card title', () => {
const wrapper = mountRamUsage()
expect(wrapper.get('.ui-card-title').text()).toBe(t('ram-usage'))
})
it('splits the card between the hosts and the VMs, each capped to the top five', () => {
const wrapper = mountRamUsage()
expect(wrapper.findAll('.ui-card-subtitle').map(subtitle => subtitle.text())).toEqual([
`${t('host')} ${t('top-#', 5)}`,
`${t('vms')} ${t('top-#', 5)}`,
])
})
it('shows a loader in place of the hosts while their usage has not arrived yet', () => {
const wrapper = mountRamUsage({ poolDashboard: createPoolDashboard({ hosts: {} }) })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
expect(wrapper.findComponent(HostsRamUsage).exists()).toBe(false)
expect(wrapper.findComponent(VmsRamUsage).exists()).toBe(true)
})
it('shows a loader in place of the VMs while their usage has not arrived yet', () => {
const wrapper = mountRamUsage({ poolDashboard: createPoolDashboard({ vms: {} }) })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
expect(wrapper.findComponent(HostsRamUsage).exists()).toBe(true)
expect(wrapper.findComponent(VmsRamUsage).exists()).toBe(false)
})
it('shows the memory of the hosts and of the VMs in their own section', () => {
const wrapper = mountRamUsage({
poolDashboard: withTopFiveUsage(
[
{
id: 'host-1' as HostsTopFiveUsage['ram'][number]['id'],
name_label: 'Host 1',
percent: 25,
size: 4294967296,
usage: 1073741824,
},
],
[
{
id: 'vm-1' as VmsTopFiveUsage['ram'][number]['id'],
name_label: 'VM 1',
percent: 75,
memory: 4294967296,
memoryFree: 1073741824,
},
]
),
})
expect(findLegends(wrapper.findComponent(HostsRamUsage))).toEqual([['Host 1', '1 GiB / 4 GiB']])
expect(findLegends(wrapper.findComponent(VmsRamUsage))).toEqual([['VM 1', '3 GiB / 4 GiB']])
})
it('passes the fetch error down to both sections', () => {
const wrapper = mountRamUsage({ hasError: true })
expect(wrapper.findComponent(HostsRamUsage).get('.vts-state-hero').text()).toBe(t('error-no-data'))
expect(wrapper.findComponent(VmsRamUsage).get('.vts-state-hero').text()).toBe(t('error-no-data'))
})

View File

@@ -0,0 +1,133 @@
import PoolDashboardStatus from '@/modules/pool/components/dashboard/PoolDashboardStatus.vue'
import type { XoPoolDashboard } from '@/modules/pool/types/xo-pool-dashboard.type.ts'
import { findCardNumbers } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
type HostsStatus = NonNullable<NonNullable<XoPoolDashboard['hosts']>['status']>
type VmsStatus = NonNullable<NonNullable<XoPoolDashboard['vms']>['status']>
function createHostsStatus(overrides: Partial<HostsStatus> = {}): HostsStatus {
return { running: 2, disabled: 1, halted: 1, total: 4, ...overrides }
}
function createVmsStatus(overrides: Partial<VmsStatus> = {}): VmsStatus {
return { running: 5, paused: 2, suspended: 1, halted: 2, total: 10, ...overrides }
}
function createPoolDashboard(overrides: Partial<XoPoolDashboard> = {}): XoPoolDashboard {
return {
hosts: { status: createHostsStatus() },
vms: { status: createVmsStatus() },
...overrides,
}
}
function mountStatus(props: { poolDashboard?: XoPoolDashboard; hasError?: boolean } = {}) {
return mount(PoolDashboardStatus, {
props: { poolDashboard: createPoolDashboard(), ...props },
global: createGlobalTestConfig(),
})
}
function findLegendSections(wrapper: ReturnType<typeof mountStatus>) {
return wrapper
.findAll('.vts-donut-chart-with-legend')
.map(section => [
section.get('.ui-legend-title').text(),
section.findAll('.ui-legend').map(legend => [legend.get('.label').text(), legend.get('.value-and-unit').text()]),
])
}
it('renders the card title', () => {
const wrapper = mountStatus()
expect(wrapper.get('.ui-card-title').text()).toBe(t('status'))
})
it('shows a loader while the dashboard has not arrived yet', () => {
const wrapper = mountStatus({ poolDashboard: undefined })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
expect(findLegendSections(wrapper)).toEqual([])
})
it('shows a loader while the status of the hosts is missing from the dashboard', () => {
const wrapper = mountStatus({ poolDashboard: { hosts: {}, vms: { status: createVmsStatus() } } })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows a loader while the status of the VMs is missing from the dashboard', () => {
const wrapper = mountStatus({ poolDashboard: { hosts: { status: createHostsStatus() }, vms: {} } })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows an error message when the dashboard could not be fetched', () => {
const wrapper = mountStatus({ hasError: true })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('breaks down the hosts and the VMs by status', () => {
const wrapper = mountStatus()
expect(findLegendSections(wrapper)).toEqual([
[
t('hosts'),
[
[t('host:status:running', 2), '2'],
[t('disabled', 2), '1'],
[t('host:status:halted', 2), '1'],
],
],
[
t('vms'),
[
[t('vm:status:running', 2), '5'],
[t('vm:status:paused', 2), '2'],
[t('vm:status:suspended', 2), '1'],
[t('vm:status:halted', 2), '2'],
],
],
])
})
it('totals the hosts and the VMs of the pool', () => {
const wrapper = mountStatus()
expect(findCardNumbers(wrapper)).toEqual([
[t('total'), '4'],
[t('total'), '10'],
])
})
it('reports that no VM was detected for a pool without VM, and still breaks down the hosts', () => {
const wrapper = mountStatus({
poolDashboard: createPoolDashboard({
vms: { status: createVmsStatus({ running: 0, paused: 0, suspended: 0, halted: 0, total: 0 }) },
}),
})
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-vm-detected'))
expect(findLegendSections(wrapper).map(([title]) => title)).toEqual([t('hosts')])
expect(findCardNumbers(wrapper)).toEqual([[t('total'), '4']])
})
it('counts a status the pool does not report as zero', () => {
const wrapper = mountStatus({
poolDashboard: { hosts: { status: { running: 2, total: 2 } as HostsStatus }, vms: { status: createVmsStatus() } },
})
expect(findLegendSections(wrapper)[0]).toEqual([
t('hosts'),
[
[t('host:status:running', 2), '2'],
[t('disabled', 2), '0'],
[t('host:status:halted', 2), '0'],
],
])
})

View File

@@ -0,0 +1,86 @@
import PoolDashboardStoragesUsage from '@/modules/pool/components/dashboard/PoolDashboardStoragesUsage.vue'
import type { XoPoolDashboard } from '@/modules/pool/types/xo-pool-dashboard.type.ts'
import { findCardNumbers } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
type StorageUsage = NonNullable<NonNullable<XoPoolDashboard['srs']>['topFiveUsage']>[number]
function createStorageUsage(overrides: Partial<StorageUsage> = {}): StorageUsage {
return {
id: 'sr-1' as StorageUsage['id'],
name_label: 'Local storage',
percent: 25,
physical_usage: 1073741824,
size: 4294967296,
...overrides,
}
}
function mountStoragesUsage(props: { poolDashboard?: XoPoolDashboard; hasError?: boolean } = {}) {
return mount(PoolDashboardStoragesUsage, {
props: { poolDashboard: undefined, ...props },
global: createGlobalTestConfig(),
})
}
function withStorages(topFiveUsage: StorageUsage[]): XoPoolDashboard {
return { srs: { topFiveUsage } }
}
it('renders the card title and the number of storages it covers', () => {
const wrapper = mountStoragesUsage({ poolDashboard: withStorages([createStorageUsage()]) })
expect(wrapper.get('.ui-card-title').text()).toContain(t('storage-usage'))
expect(wrapper.get('.ui-card-title').text()).toContain(t('top-#', 5))
})
it('shows a loader while the dashboard has not arrived yet', () => {
const wrapper = mountStoragesUsage()
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows a loader while the storage usage is missing from the dashboard', () => {
const wrapper = mountStoragesUsage({ poolDashboard: { srs: {} } })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows an error message when the dashboard could not be fetched', () => {
const wrapper = mountStoragesUsage({ poolDashboard: withStorages([createStorageUsage()]), hasError: true })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('reports that there is nothing to show when the pool has no storage', () => {
const wrapper = mountStoragesUsage({ poolDashboard: withStorages([]) })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('shows one progress bar per storage, the fullest first', () => {
const wrapper = mountStoragesUsage({
poolDashboard: withStorages([
createStorageUsage({ id: 'sr-1' as StorageUsage['id'], name_label: 'Local storage', percent: 30 }),
createStorageUsage({ id: 'sr-2' as StorageUsage['id'], name_label: 'Shared storage', percent: 70 }),
]),
})
expect(wrapper.findAll('.ui-legend').map(legend => legend.text())).toEqual(['Shared storage70%', 'Local storage30%'])
})
it('sums the usage and the free space across the storages', () => {
const wrapper = mountStoragesUsage({
poolDashboard: withStorages([
createStorageUsage({ id: 'sr-1' as StorageUsage['id'], physical_usage: 1073741824, size: 4294967296 }),
createStorageUsage({ id: 'sr-2' as StorageUsage['id'], physical_usage: 2147483648, size: 4294967296 }),
]),
})
expect(findCardNumbers(wrapper)).toEqual([
[t('total-used'), '3 GiB'],
[t('total-free'), '5 GiB'],
])
})

View File

@@ -35,15 +35,13 @@
<script lang="ts" setup>
import type { XoPoolDashboard } from '@/modules/pool/types/xo-pool-dashboard.type.ts'
import VtsProgressBarGroup, {
type ProgressBarGroupItem,
} from '@core/components/progress-bar-group/VtsProgressBarGroup.vue'
import { buildPercentProgressItems, getStoragesUsageTotals } from '@/modules/pool/utils/xo-pool-dashboard.util.ts'
import VtsProgressBarGroup from '@core/components/progress-bar-group/VtsProgressBarGroup.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardNumbers from '@core/components/ui/card-numbers/UiCardNumbers.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import { formatSizeRaw } from '@core/utils/size.util.ts'
import { useArrayReduce } from '@vueuse/shared'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -58,23 +56,12 @@ const areStoragesUsageReady = computed(() => poolDashboard?.srs?.topFiveUsage !=
const topFiveUsage = computed(() => poolDashboard?.srs?.topFiveUsage ?? [])
const totalUsage = useArrayReduce(topFiveUsage, (sum, sr) => sum + sr.physical_usage, 0)
const totalSize = useArrayReduce(topFiveUsage, (sum, sr) => sum + sr.size, 0)
const totals = computed(() => getStoragesUsageTotals(topFiveUsage.value))
const formattedTotalUsage = computed(() => formatSizeRaw(totalUsage.value, 0))
const formattedTotalSizeFree = computed(() => formatSizeRaw(totalSize.value - totalUsage.value, 0))
const formattedTotalUsage = computed(() => formatSizeRaw(totals.value.totalUsage, 0))
const formattedTotalSizeFree = computed(() => formatSizeRaw(totals.value.totalSize - totals.value.totalUsage, 0))
const progressBarItems = computed(() =>
topFiveUsage.value.map(
sr =>
({
id: sr.id,
label: sr.name_label,
current: sr.percent,
total: 100,
}) satisfies ProgressBarGroupItem
)
)
const progressBarItems = computed(() => buildPercentProgressItems(topFiveUsage.value))
</script>
<style lang="postcss" scoped>

View File

@@ -0,0 +1,94 @@
import PoolDashboardCpuChart from '@/modules/pool/components/dashboard/chart-usage/PoolDashboardCpuChart.vue'
import { createPoolStats } from '@/test/create-pool-stats.ts'
import { n, t } from '@/test/i18n.ts'
import { findLinearChart, formatChartValue } from '@/test/linear-chart-stub.ts'
import { mountChartCard, type ChartCardProps } from '@/test/mount-chart-card.ts'
import type { XapiPoolStats } from '@vates/types/common'
function mountChart(props: ChartCardProps<XapiPoolStats>) {
return mountChartCard(PoolDashboardCpuChart, props)
}
const statsWithSamples = createPoolStats({
'host-1': { stats: { cpus: { '0': [10, 20] }, memory: [2048, 4096] } },
})
it('renders the card title and the period it covers', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(wrapper.get('.ui-card-title').text()).toContain(t('pool-cpu-usage'))
expect(wrapper.get('.ui-card-title').text()).toContain(t('last-week'))
})
it('shows a loader while the stats are loading', () => {
const wrapper = mountChart({ data: null, loading: true })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows a loader while the stats have not arrived yet', () => {
const wrapper = mountChart({ data: null })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows an error message when the stats could not be fetched', () => {
const wrapper = mountChart({ data: statsWithSamples, error: true })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('reports that there is nothing to plot for a pool without host', () => {
const wrapper = mountChart({ data: createPoolStats() })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('reports that there is nothing to plot when no host reports its cpus', () => {
const wrapper = mountChart({ data: createPoolStats({ 'host-1': { stats: { memory: [2048, 4096] } } }) })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('reports that there is nothing to plot when the cpus of every host hold no sample', () => {
const wrapper = mountChart({ data: createPoolStats({ 'host-1': { stats: { cpus: { '0': [] } } } }) })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('reports that there is nothing to plot when the stats of every host failed', () => {
const wrapper = mountChart({ data: createPoolStats({ 'host-1': { error: { code: 'boom' } } }) })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('plots the usage stacked across every host of the pool', () => {
const wrapper = mountChart({
data: createPoolStats({
'host-1': { stats: { cpus: { '0': [10, 20] } } },
'host-2': { stats: { cpus: { '0': [1, 2], '1': [3, 4] } } },
}),
})
expect(findLinearChart(wrapper).props('data')).toEqual([
{
label: t('stacked-cpu-usage'),
data: [
{ timestamp: 990_000, value: 14 },
{ timestamp: 1_000_000, value: 26 },
],
},
])
})
it('scales the axis above the peak of the stacked usage', () => {
const wrapper = mountChart({ data: createPoolStats({ 'host-1': { stats: { cpus: { '0': [10, 90] } } } }) })
expect(findLinearChart(wrapper).props('maxValue')).toBe(200)
})
it('formats the plotted values as percentages', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(formatChartValue(wrapper, 4200)).toBe(n(42, 'percent'))
})

View File

@@ -11,16 +11,19 @@
<VtsStateHero v-else-if="cpuUsage.length === 0" format="card" type="no-data" size="medium">
{{ t('no-data-to-calculate') }}
</VtsStateHero>
<VtsLinearChart v-else :data="cpuUsage" :max-value="maxValue" :value-formatter="valueFormatter" class="chart" />
<VtsLinearChart v-else :data="cpuUsage" :max-value :value-formatter class="chart" />
</UiCard>
</template>
<script lang="ts" setup>
import type { LinearChartData, ValueFormatter } from '@core/types/chart.ts'
import { buildStackedCpuUsageSeries } from '@/modules/pool/utils/xo-pool-dashboard.util.ts'
import { useChartPercentFormatter } from '@/shared/composables/chart-percent-formatter.composable.ts'
import { getChartMaxValue } from '@/shared/utils/chart-stats.util.ts'
import type { LinearChartData } from '@core/types/chart.ts'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
import type { XapiHostStats, XapiPoolStats } from '@vates/types/common'
import type { XapiPoolStats } from '@vates/types/common'
import { computed, defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -31,66 +34,24 @@ const { data, loading } = defineProps<{
}>()
const VtsLinearChart = defineAsyncComponent(() => import('@core/components/linear-chart/VtsLinearChart.vue'))
const { t, n } = useI18n()
const { t } = useI18n()
const valueFormatter = useChartPercentFormatter()
const cpuUsageSeries = computed(() => buildStackedCpuUsageSeries(data))
const cpuUsage = computed<LinearChartData>(() => {
if (!data) {
if (cpuUsageSeries.value.length === 0) {
return []
}
const hostsStats = Object.values(data).filter((host): host is XapiHostStats => !!(host as XapiHostStats)?.stats?.cpus)
if (hostsStats.length === 0) {
return []
}
const result = new Map<number, { timestamp: number; value: number }>()
const dataLength = hostsStats[0].stats.memory?.length ?? 0
const timestampStart = hostsStats[0].endTimestamp - hostsStats[0].interval * (dataLength - 1)
for (let hourIndex = 0; hourIndex < dataLength; hourIndex++) {
const timestamp = (timestampStart + hourIndex * hostsStats[0].interval) * 1000
const totalUsage = hostsStats.reduce((sumHost, host) => {
const cpus = Object.values(host.stats.cpus ?? {})
const cpuUsageSum = cpus.reduce((total, cpu) => total + (cpu[hourIndex] ?? NaN), 0)
return sumHost + Math.round(cpuUsageSum)
}, 0)
result.set(timestamp, {
timestamp,
value: totalUsage,
})
}
return [
{
label: t('stacked-cpu-usage'),
data: Array.from(result.values()),
data: cpuUsageSeries.value,
},
]
})
const maxValue = computed(() => {
if (!data) {
return 100
}
const values = cpuUsage.value[0]?.data.map(item => item.value || 0) ?? []
if (values.length === 0) {
return 100
}
const maxCpuUsage = Math.max(...values) * 1.2
return Math.ceil(maxCpuUsage / 100) * 100
})
const valueFormatter: ValueFormatter = value => {
if (value === null) {
return ''
}
return n(value / 100, 'percent')
}
const maxValue = computed(() => getChartMaxValue(cpuUsageSeries.value, { step: 100, fallback: 100, headroom: 1.2 }))
</script>

View File

@@ -0,0 +1,101 @@
import PoolDashboardNetworkChart from '@/modules/pool/components/dashboard/chart-usage/PoolDashboardNetworkChart.vue'
import { createPoolStats } from '@/test/create-pool-stats.ts'
import { t } from '@/test/i18n.ts'
import { findLinearChart, formatChartValue } from '@/test/linear-chart-stub.ts'
import { mountChartCard, type ChartCardProps } from '@/test/mount-chart-card.ts'
import type { XapiPoolStats } from '@vates/types/common'
function mountChart(props: ChartCardProps<XapiPoolStats>) {
return mountChartCard(PoolDashboardNetworkChart, props)
}
const statsWithSamples = createPoolStats({
'host-1': { stats: { pifs: { rx: { eth0: [10, 20] }, tx: { eth0: [30, 40] } } } },
})
it('renders the card title and the period it covers', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(wrapper.get('.ui-card-title').text()).toContain(t('network-throughput'))
expect(wrapper.get('.ui-card-title').text()).toContain(t('last-week'))
})
it('shows a loader while the stats are loading', () => {
const wrapper = mountChart({ data: null, loading: true })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows a loader while the stats have not arrived yet', () => {
const wrapper = mountChart({ data: null })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows an error message when the stats could not be fetched', () => {
const wrapper = mountChart({ data: statsWithSamples, error: true })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('reports that there is nothing to plot for a pool without host', () => {
const wrapper = mountChart({ data: createPoolStats() })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('reports that there is nothing to plot when no host reports its pifs', () => {
const wrapper = mountChart({ data: createPoolStats({ 'host-1': { stats: { memory: [2048, 4096] } } }) })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('reports that there is nothing to plot when the pifs of every host hold no sample', () => {
const wrapper = mountChart({ data: createPoolStats({ 'host-1': { stats: { pifs: { rx: {}, tx: {} } } } }) })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('reports that there is nothing to plot when the stats of every host failed', () => {
const wrapper = mountChart({ data: createPoolStats({ 'host-1': { error: { code: 'boom' } } }) })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('plots the upload above the download, stacked across every host of the pool', () => {
const wrapper = mountChart({
data: createPoolStats({
'host-1': { stats: { pifs: { rx: { eth0: [10, 20] }, tx: { eth0: [30, 40] } } } },
'host-2': { stats: { pifs: { rx: { eth0: [1, 2] }, tx: { eth0: [3, 4] } } } },
}),
})
expect(findLinearChart(wrapper).props('data')).toEqual([
{
label: t('network-upload'),
data: [
{ timestamp: 990_000, value: 33 },
{ timestamp: 1_000_000, value: 44 },
],
},
{
label: t('network-download'),
data: [
{ timestamp: 990_000, value: 11 },
{ timestamp: 1_000_000, value: 22 },
],
},
])
})
it('rounds the axis up to the next fifty bytes, with headroom above the peak', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(findLinearChart(wrapper).props('maxValue')).toBe(50)
})
it('formats the plotted values as bytes', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(formatChartValue(wrapper, 1024)).toBe('1 KiB')
})

View File

@@ -11,17 +11,18 @@
<VtsStateHero v-else-if="networkUsage.length === 0" format="card" type="no-data" size="medium">
{{ t('no-data-to-calculate') }}
</VtsStateHero>
<VtsLinearChart v-else :data="networkUsage" :max-value :value-formatter="byteFormatter" />
<VtsLinearChart v-else :data="networkUsage" :max-value :value-formatter="formatChartBytes" />
</UiCard>
</template>
<script lang="ts" setup>
import { buildStackedNetworkUsageSeries } from '@/modules/pool/utils/xo-pool-dashboard.util.ts'
import { formatChartBytes, getPairedUsageMaxValue } from '@/shared/utils/chart-stats.util.ts'
import type { LinearChartData } from '@core/types/chart.ts'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.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 type { XapiHostStats, XapiPoolStats } from '@vates/types/common'
import type { XapiPoolStats } from '@vates/types/common'
import { computed, defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -35,79 +36,26 @@ const VtsLinearChart = defineAsyncComponent(() => import('@core/components/linea
const { t } = useI18n()
const networkUsageSeries = computed(() => buildStackedNetworkUsageSeries(data))
const networkUsage = computed<LinearChartData>(() => {
if (!data) {
const [download, upload] = networkUsageSeries.value
if (download.length === 0 && upload.length === 0) {
return []
}
const hostsStats = Object.values(data).filter(
(hostStats): hostStats is XapiHostStats => !!(hostStats as XapiHostStats)?.stats?.pifs
)
if (hostsStats.length === 0) {
return []
}
const timestamps = Array.from(
{ length: Object.values(hostsStats[0]?.stats?.pifs?.rx ?? {})[0].length },
(_, i) =>
hostsStats[0].endTimestamp * 1000 -
(Object.values(hostsStats[0]?.stats?.pifs?.rx ?? {})[0].length - 1 - i) * hostsStats[0].interval * 1000
)
const txSeries = [
return [
{
label: t('network-upload'),
data: timestamps.map((timestamp, index) => {
const hostsSum = hostsStats.reduce((total, host) => {
const value = Object.values(host.stats?.pifs?.tx ?? {}).reduce(
(sum, values) => sum + (values[index] ?? NaN),
0
)
return total + value
}, 0)
return { timestamp, value: hostsSum }
}),
data: upload,
},
]
const rxSeries = [
{
label: t('network-download'),
data: timestamps.map((timestamp, index) => {
const hostsSum = hostsStats.reduce((total, host) => {
const value = Object.values(host.stats?.pifs?.rx ?? {}).reduce(
(sum, values) => sum + (values[index] ?? NaN),
0
)
return total + value
}, 0)
return { timestamp, value: hostsSum }
}),
data: download,
},
]
return [...txSeries, ...rxSeries]
})
const maxValue = computed(() => {
const values = networkUsage.value.flatMap(series => series.data.map(item => item.value || 0))
if (values.length === 0) {
return 100
}
const maxUsage = Math.max(...values) * 1.2
return Math.ceil(maxUsage / 50) * 50
})
const byteFormatter = (value: number | null) => {
if (value === null) {
return ''
}
const size = formatSizeRaw(value, 1)
return `${size.value} ${size.prefix}`
}
const maxValue = computed(() => getPairedUsageMaxValue(networkUsageSeries.value, { step: 50 }))
</script>

View File

@@ -0,0 +1,99 @@
import PoolDashboardRamChart from '@/modules/pool/components/dashboard/chart-usage/PoolDashboardRamChart.vue'
import { createPoolStats } from '@/test/create-pool-stats.ts'
import { t } from '@/test/i18n.ts'
import { findLinearChart, formatChartValue } from '@/test/linear-chart-stub.ts'
import { mountChartCard, type ChartCardProps } from '@/test/mount-chart-card.ts'
import type { XapiPoolStats } from '@vates/types/common'
function mountChart(props: ChartCardProps<XapiPoolStats>) {
return mountChartCard(PoolDashboardRamChart, props)
}
const statsWithSamples = createPoolStats({
'host-1': { stats: { memory: [2048, 4096], memoryFree: [1024, 2048] } },
})
it('renders the card title and the period it covers', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(wrapper.get('.ui-card-title').text()).toContain(t('pool-ram-usage'))
expect(wrapper.get('.ui-card-title').text()).toContain(t('last-week'))
})
it('shows a loader while the stats are loading', () => {
const wrapper = mountChart({ data: null, loading: true })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows a loader while the stats have not arrived yet', () => {
const wrapper = mountChart({ data: null })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows an error message when the stats could not be fetched', () => {
const wrapper = mountChart({ data: statsWithSamples, error: true })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('reports that there is nothing to plot for a pool without host', () => {
const wrapper = mountChart({ data: createPoolStats() })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('reports that there is nothing to plot when the free memory of every host is missing', () => {
const wrapper = mountChart({ data: createPoolStats({ 'host-1': { stats: { memory: [2048, 4096] } } }) })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('reports that there is nothing to plot when the memory of every host holds no sample', () => {
const wrapper = mountChart({ data: createPoolStats({ 'host-1': { stats: { memory: [], memoryFree: [] } } }) })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('reports that there is nothing to plot when the stats of every host failed', () => {
const wrapper = mountChart({ data: createPoolStats({ 'host-1': { error: { code: 'boom' } } }) })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('plots the memory used, stacked across every host of the pool', () => {
const wrapper = mountChart({
data: createPoolStats({
'host-1': { stats: { memory: [2048, 4096], memoryFree: [1024, 2048] } },
'host-2': { stats: { memory: [1024, 1024], memoryFree: [512, 256] } },
}),
})
expect(findLinearChart(wrapper).props('data')).toEqual([
{
label: t('stacked-ram-usage'),
data: [
{ timestamp: 990_000, value: 1536 },
{ timestamp: 1_000_000, value: 2816 },
],
},
])
})
it('scales the axis to the total memory of the pool', () => {
const wrapper = mountChart({
data: createPoolStats({
'host-1': { stats: { memory: [2048, 4096], memoryFree: [1024, 2048] } },
'host-2': { stats: { memory: [1024, 1024], memoryFree: [512, 256] } },
}),
})
expect(findLinearChart(wrapper).props('maxValue')).toBe(5120)
})
it('formats the plotted values as bytes', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(formatChartValue(wrapper, 1024)).toBe('1 KiB')
})

View File

@@ -11,17 +11,18 @@
<VtsStateHero v-else-if="ramUsage.length === 0" format="card" type="no-data" size="medium">
{{ t('no-data-to-calculate') }}
</VtsStateHero>
<VtsLinearChart v-else :data="ramUsage" :max-value="maxValue" :value-formatter="byteFormatter" />
<VtsLinearChart v-else :data="ramUsage" :max-value :value-formatter="formatChartBytes" />
</UiCard>
</template>
<script lang="ts" setup>
import { buildStackedRamUsageSeries, getStackedRamUsageMaxValue } from '@/modules/pool/utils/xo-pool-dashboard.util.ts'
import { formatChartBytes } from '@/shared/utils/chart-stats.util.ts'
import type { LinearChartData } from '@core/types/chart.ts'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.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 type { XapiHostStats, XapiPoolStats } from '@vates/types/common'
import type { XapiPoolStats } from '@vates/types/common'
import { computed, defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -34,72 +35,20 @@ const { data, loading } = defineProps<{
const VtsLinearChart = defineAsyncComponent(() => import('@core/components/linear-chart/VtsLinearChart.vue'))
const { t } = useI18n()
const ramUsageSeries = computed(() => buildStackedRamUsageSeries(data))
const ramUsage = computed<LinearChartData>(() => {
if (!data) {
if (ramUsageSeries.value.length === 0) {
return []
}
const hostsStats = Object.values(data).filter(
(host): host is XapiHostStats =>
!!(host as XapiHostStats)?.stats?.memory && !!(host as XapiHostStats)?.stats?.memoryFree
)
if (hostsStats.length === 0) {
return []
}
const result = new Map<number, { timestamp: number; value: number }>()
const firstHost = hostsStats[0]
const dataLength = firstHost.stats.memory?.length ?? 0
const timestampStart = firstHost.endTimestamp - firstHost.interval * (dataLength - 1)
for (let index = 0; index < dataLength; index++) {
const timestamp = (timestampStart + index * firstHost.interval) * 1000
const totalUsed = hostsStats.reduce((sum, host) => {
const memoryTotal = host.stats.memory?.[index] ?? NaN
const memoryFree = host.stats.memoryFree?.[index] ?? NaN
const memoryUsed = memoryTotal - memoryFree
return sum + memoryUsed
}, 0)
result.set(timestamp, {
timestamp,
value: totalUsed,
})
}
return [
{
label: t('stacked-ram-usage'),
data: Array.from(result.values()),
data: ramUsageSeries.value,
},
]
})
const maxValue = computed(() => {
if (!data) {
return 1024 * 1024 * 1024 // 1 GB fallback
}
const hostsStats = Object.values(data).filter(
(host): host is XapiHostStats => !!(host as XapiHostStats)?.stats?.memory
)
const totalMemory = hostsStats.reduce((sum, host) => {
const maxMemory = Math.max(...(host.stats.memory?.map(value => value || 0) ?? [0]))
return sum + maxMemory
}, 0)
return totalMemory || 1024 * 1024 * 1024
})
const byteFormatter = (value: number | null) => {
if (value === null) {
return ''
}
const size = formatSizeRaw(value, 1)
return `${size.value} ${size.prefix}`
}
const maxValue = computed(() => getStackedRamUsageMaxValue(data))
</script>

View File

@@ -0,0 +1,63 @@
import HostsCpuUsage from '@/modules/pool/components/dashboard/cpu-usage/HostsCpuUsage.vue'
import type { XoPoolDashboard } from '@/modules/pool/types/xo-pool-dashboard.type.ts'
import { findLegends } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
type HostCpuUsage = NonNullable<NonNullable<XoPoolDashboard['hosts']>['topFiveUsage']>['cpu'][number]
function createHostCpuUsage(overrides: Partial<HostCpuUsage> = {}): HostCpuUsage {
return {
id: 'host-1' as HostCpuUsage['id'],
name_label: 'Host 1',
percent: 25,
...overrides,
}
}
function mountCpuUsage(props: { topFiveCpu?: HostCpuUsage[]; hasError?: boolean } = {}) {
return mount(HostsCpuUsage, {
props: { topFiveCpu: undefined, ...props },
global: createGlobalTestConfig(),
})
}
it('reports that there is nothing to show while the usage has not arrived yet', () => {
const wrapper = mountCpuUsage()
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('shows an error message when the usage could not be fetched', () => {
const wrapper = mountCpuUsage({ topFiveCpu: [createHostCpuUsage()], hasError: true })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('shows one progress bar per host, the busiest first', () => {
const wrapper = mountCpuUsage({
topFiveCpu: [
createHostCpuUsage({ id: 'host-1' as HostCpuUsage['id'], name_label: 'Host 1', percent: 30 }),
createHostCpuUsage({ id: 'host-2' as HostCpuUsage['id'], name_label: 'Host 2', percent: 70 }),
],
})
expect(findLegends(wrapper)).toEqual([
['Host 2', '70%'],
['Host 1', '30%'],
])
})
it('reads each share of usage out of a hundred', () => {
const wrapper = mountCpuUsage({ topFiveCpu: [createHostCpuUsage({ percent: 12.5 })] })
expect(findLegends(wrapper)).toEqual([['Host 1', '12.5%']])
})
it('shows an empty progress bar group for a pool without host', () => {
const wrapper = mountCpuUsage({ topFiveCpu: [] })
expect(wrapper.findAll('.ui-progress-bar')).toEqual([])
expect(wrapper.find('.vts-state-hero').exists()).toBe(false)
})

View File

@@ -12,30 +12,20 @@
<script lang="ts" setup>
import type { XoPoolDashboard } from '@/modules/pool/types/xo-pool-dashboard.type.ts'
import VtsProgressBarGroup, {
type ProgressBarGroupItem,
} from '@core/components/progress-bar-group/VtsProgressBarGroup.vue'
import { buildPercentProgressItems } from '@/modules/pool/utils/xo-pool-dashboard.util.ts'
import VtsProgressBarGroup from '@core/components/progress-bar-group/VtsProgressBarGroup.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import { cpuProgressThresholds } from '@core/utils/progress.util.ts'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { topFiveCpu = [] } = defineProps<{
// TODO: refactor to use the same component as VmsCpuUsage.vue
const { topFiveCpu } = defineProps<{
topFiveCpu: NonNullable<NonNullable<XoPoolDashboard['hosts']>['topFiveUsage']>['cpu'] | undefined
hasError?: boolean
}>()
const progressBarItems = computed(() =>
topFiveCpu.map(
cpu =>
({
id: cpu.id,
label: cpu.name_label,
current: cpu.percent,
total: 100,
}) satisfies ProgressBarGroupItem
)
)
const progressBarItems = computed(() => buildPercentProgressItems(topFiveCpu ?? []))
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,57 @@
import VmsCpuUsage from '@/modules/pool/components/dashboard/cpu-usage/VmsCpuUsage.vue'
import type { XoPoolDashboard } from '@/modules/pool/types/xo-pool-dashboard.type.ts'
import { findLegends } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
type VmCpuUsage = NonNullable<NonNullable<XoPoolDashboard['vms']>['topFiveUsage']>['cpu'][number]
function createVmCpuUsage(overrides: Partial<VmCpuUsage> = {}): VmCpuUsage {
return {
id: 'vm-1' as VmCpuUsage['id'],
name_label: 'VM 1',
percent: 25,
...overrides,
}
}
function mountCpuUsage(props: { topFiveCpu?: VmCpuUsage[]; hasError?: boolean } = {}) {
return mount(VmsCpuUsage, {
props: { topFiveCpu: undefined, ...props },
global: createGlobalTestConfig(),
})
}
it('reports that there is nothing to show while the usage has not arrived yet', () => {
const wrapper = mountCpuUsage()
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('shows an error message when the usage could not be fetched', () => {
const wrapper = mountCpuUsage({ topFiveCpu: [createVmCpuUsage()], hasError: true })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('shows one progress bar per VM, the busiest first', () => {
const wrapper = mountCpuUsage({
topFiveCpu: [
createVmCpuUsage({ id: 'vm-1' as VmCpuUsage['id'], name_label: 'VM 1', percent: 30 }),
createVmCpuUsage({ id: 'vm-2' as VmCpuUsage['id'], name_label: 'VM 2', percent: 70 }),
],
})
expect(findLegends(wrapper)).toEqual([
['VM 2', '70%'],
['VM 1', '30%'],
])
})
it('shows an empty progress bar group for a pool without VM', () => {
const wrapper = mountCpuUsage({ topFiveCpu: [] })
expect(wrapper.findAll('.ui-progress-bar')).toEqual([])
expect(wrapper.find('.vts-state-hero').exists()).toBe(false)
})

View File

@@ -12,30 +12,20 @@
<script lang="ts" setup>
import type { XoPoolDashboard } from '@/modules/pool/types/xo-pool-dashboard.type.ts'
import VtsProgressBarGroup, {
type ProgressBarGroupItem,
} from '@core/components/progress-bar-group/VtsProgressBarGroup.vue'
import { buildPercentProgressItems } from '@/modules/pool/utils/xo-pool-dashboard.util.ts'
import VtsProgressBarGroup from '@core/components/progress-bar-group/VtsProgressBarGroup.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import { cpuProgressThresholds } from '@core/utils/progress.util.ts'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { topFiveCpu = [] } = defineProps<{
// TODO: refactor to use the same component as HostsCpuUsage.vue
const { topFiveCpu } = defineProps<{
topFiveCpu: NonNullable<NonNullable<XoPoolDashboard['vms']>['topFiveUsage']>['cpu'] | undefined
hasError?: boolean
}>()
const { t } = useI18n()
const progressBarItems = computed(() =>
topFiveCpu.map(
cpu =>
({
id: cpu.id,
label: cpu.name_label,
current: cpu.percent,
total: 100,
}) satisfies ProgressBarGroupItem
)
)
const progressBarItems = computed(() => buildPercentProgressItems(topFiveCpu ?? []))
</script>

View File

@@ -0,0 +1,77 @@
import HostsRamUsage from '@/modules/pool/components/dashboard/ram-usage/HostsRamUsage.vue'
import type { XoPoolDashboard } from '@/modules/pool/types/xo-pool-dashboard.type.ts'
import { findLegends } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
type HostRamUsage = NonNullable<NonNullable<XoPoolDashboard['hosts']>['topFiveUsage']>['ram'][number]
function createHostRamUsage(overrides: Partial<HostRamUsage> = {}): HostRamUsage {
return {
id: 'host-1' as HostRamUsage['id'],
name_label: 'Host 1',
percent: 25,
size: 4294967296,
usage: 1073741824,
...overrides,
}
}
function mountRamUsage(props: { topFiveRam?: HostRamUsage[]; hasError?: boolean } = {}) {
return mount(HostsRamUsage, {
props: { topFiveRam: undefined, ...props },
global: createGlobalTestConfig(),
})
}
it('reports that there is nothing to show while the usage has not arrived yet', () => {
const wrapper = mountRamUsage()
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('shows an error message when the usage could not be fetched', () => {
const wrapper = mountRamUsage({ topFiveRam: [createHostRamUsage()], hasError: true })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('shows the memory each host uses out of the memory it holds, the fullest first', () => {
const wrapper = mountRamUsage({
topFiveRam: [
createHostRamUsage({
id: 'host-1' as HostRamUsage['id'],
name_label: 'Host 1',
usage: 1073741824,
size: 4294967296,
}),
createHostRamUsage({
id: 'host-2' as HostRamUsage['id'],
name_label: 'Host 2',
usage: 3221225472,
size: 4294967296,
}),
],
})
expect(findLegends(wrapper)).toEqual([
['Host 2', '3 GiB / 4 GiB'],
['Host 1', '1 GiB / 4 GiB'],
])
})
it('shows an empty progress bar group for a pool without host', () => {
const wrapper = mountRamUsage({ topFiveRam: [] })
expect(wrapper.findAll('.ui-progress-bar')).toEqual([])
expect(wrapper.find('.vts-state-hero').exists()).toBe(false)
})
it('follows the usage as new stats arrive', async () => {
const wrapper = mountRamUsage({ topFiveRam: [createHostRamUsage({ usage: 1073741824, size: 4294967296 })] })
await wrapper.setProps({ topFiveRam: [createHostRamUsage({ usage: 3221225472, size: 4294967296 })] })
expect(findLegends(wrapper)).toEqual([['Host 1', '3 GiB / 4 GiB']])
})

View File

@@ -12,29 +12,19 @@
<script lang="ts" setup>
import type { XoPoolDashboard } from '@/modules/pool/types/xo-pool-dashboard.type.ts'
import VtsProgressBarGroup, {
type ProgressBarGroupItem,
} from '@core/components/progress-bar-group/VtsProgressBarGroup.vue'
import { buildHostsRamProgressItems } from '@/modules/pool/utils/xo-pool-dashboard.util.ts'
import VtsProgressBarGroup from '@core/components/progress-bar-group/VtsProgressBarGroup.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { topFiveRam = [] } = defineProps<{
// TODO: refactor to use the same component as VmsRamUsage.vue
const { topFiveRam } = defineProps<{
topFiveRam: NonNullable<NonNullable<XoPoolDashboard['hosts']>['topFiveUsage']>['ram'] | undefined
hasError?: boolean
}>()
const { t } = useI18n()
const progressBarItems = computed(() =>
topFiveRam.map(
ram =>
({
id: ram.id,
label: ram.name_label,
current: ram.usage,
total: ram.size,
}) satisfies ProgressBarGroupItem
)
)
const progressBarItems = computed(() => buildHostsRamProgressItems(topFiveRam ?? []))
</script>

View File

@@ -0,0 +1,69 @@
import VmsRamUsage from '@/modules/pool/components/dashboard/ram-usage/VmsRamUsage.vue'
import type { XoPoolDashboard } from '@/modules/pool/types/xo-pool-dashboard.type.ts'
import { findLegends } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
type VmRamUsage = NonNullable<NonNullable<XoPoolDashboard['vms']>['topFiveUsage']>['ram'][number]
function createVmRamUsage(overrides: Partial<VmRamUsage> = {}): VmRamUsage {
return {
id: 'vm-1' as VmRamUsage['id'],
name_label: 'VM 1',
percent: 25,
memory: 4294967296,
memoryFree: 3221225472,
...overrides,
}
}
function mountRamUsage(props: { topFiveRam?: VmRamUsage[]; hasError?: boolean } = {}) {
return mount(VmsRamUsage, {
props: { topFiveRam: undefined, ...props },
global: createGlobalTestConfig(),
})
}
it('reports that there is nothing to show while the usage has not arrived yet', () => {
const wrapper = mountRamUsage()
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('shows an error message when the usage could not be fetched', () => {
const wrapper = mountRamUsage({ topFiveRam: [createVmRamUsage()], hasError: true })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('shows the memory each VM uses out of the memory it holds, the fullest first', () => {
const wrapper = mountRamUsage({
topFiveRam: [
createVmRamUsage({
id: 'vm-1' as VmRamUsage['id'],
name_label: 'VM 1',
memory: 4294967296,
memoryFree: 3221225472,
}),
createVmRamUsage({
id: 'vm-2' as VmRamUsage['id'],
name_label: 'VM 2',
memory: 4294967296,
memoryFree: 1073741824,
}),
],
})
expect(findLegends(wrapper)).toEqual([
['VM 2', '3 GiB / 4 GiB'],
['VM 1', '1 GiB / 4 GiB'],
])
})
it('shows an empty progress bar group for a pool without VM', () => {
const wrapper = mountRamUsage({ topFiveRam: [] })
expect(wrapper.findAll('.ui-progress-bar')).toEqual([])
expect(wrapper.find('.vts-state-hero').exists()).toBe(false)
})

View File

@@ -12,29 +12,19 @@
<script lang="ts" setup>
import type { XoPoolDashboard } from '@/modules/pool/types/xo-pool-dashboard.type.ts'
import VtsProgressBarGroup, {
type ProgressBarGroupItem,
} from '@core/components/progress-bar-group/VtsProgressBarGroup.vue'
import { buildVmsRamProgressItems } from '@/modules/pool/utils/xo-pool-dashboard.util.ts'
import VtsProgressBarGroup from '@core/components/progress-bar-group/VtsProgressBarGroup.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { topFiveRam = [] } = defineProps<{
// TODO: refactor to use the same component as HostsRamUsage.vue
const { topFiveRam } = defineProps<{
topFiveRam: NonNullable<NonNullable<XoPoolDashboard['vms']>['topFiveUsage']>['ram'] | undefined
hasError?: boolean
}>()
const { t } = useI18n()
const progressBarItems = computed(() =>
topFiveRam.map(
ram =>
({
id: ram.id,
label: ram.name_label,
current: ram.memory - ram.memoryFree,
total: ram.memory,
}) satisfies ProgressBarGroupItem
)
)
const progressBarItems = computed(() => buildVmsRamProgressItems(topFiveRam ?? []))
</script>

View File

@@ -0,0 +1,140 @@
import type { useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import { usePoolEnhancedData } from '@/modules/pool/composables/use-pool-enhanced-data.composable.ts'
import { createEnhancedDataHelpers } from '@/test/create-enhanced-data-helpers.ts'
import { createHost } from '@/test/create-host.ts'
import { createServer } from '@/test/create-server.ts'
import { objectIcon } from '@core/icons'
import { HOST_POWER_STATE } from '@vates/types'
import { ref } from 'vue'
const { getHostById, isMasterHost } = vi.hoisted(() => ({
getHostById: vi.fn(),
isMasterHost: vi.fn(),
}))
vi.mock(import('@/modules/host/remote-resources/use-xo-host-collection.ts'), () => ({
useXoHostCollection: (() => ({ getHostById, isMasterHost })) as unknown as typeof useXoHostCollection,
}))
beforeEach(() => {
getHostById.mockReset()
isMasterHost.mockReset()
})
const { mountEnhancedData, mountFirstFilterable, mountFirstDisplayData } = createEnhancedDataHelpers(
usePoolEnhancedData,
result => result.filterableServers,
createServer
)
describe('filterableServers', () => {
it('maps poolName from the server pool label', () => {
const filterableServer = mountFirstFilterable([createServer({ poolNameLabel: 'Production Pool' })])
expect(filterableServer.poolName).toBe('Production Pool')
})
it('sets masterHostIp from the server host address', () => {
const filterableServer = mountFirstFilterable([createServer({ host: '10.0.0.42' })])
expect(filterableServer.masterHostIp).toBe('10.0.0.42')
})
it('reports an unreachable pool status when the server has an error', () => {
const filterableServer = mountFirstFilterable([
createServer({ error: { code: 'ECONNREFUSED' }, status: 'connected' }),
])
expect(filterableServer.poolStatus).toBe('unable-to-connect-to-the-pool')
})
it('uses the server status when there is no error', () => {
const filterableServer = mountFirstFilterable([createServer({ error: undefined, status: 'disconnected' })])
expect(filterableServer.poolStatus).toBe('disconnected')
})
it('resolves primaryHostName from the master host name label', () => {
getHostById.mockReturnValue(createHost({ name_label: 'Primary Host' }))
const filterableServer = mountFirstFilterable()
expect(filterableServer.primaryHostName).toBe('Primary Host')
})
it('falls back to an empty primaryHostName when the master host is unknown', () => {
getHostById.mockReturnValue(undefined)
const filterableServer = mountFirstFilterable()
expect(filterableServer.primaryHostName).toBe('')
})
it('recomputes when the source servers change', () => {
const servers = ref([createServer({ error: undefined, status: 'connected' })])
const result = mountEnhancedData(servers)
expect(result.filterableServers[0].poolStatus).toBe('connected')
servers.value = [createServer({ error: { code: 'ECONNREFUSED' } })]
expect(result.filterableServers[0].poolStatus).toBe('unable-to-connect-to-the-pool')
})
})
describe('getDisplayData', () => {
it('derives the host icons from the master host power state', () => {
getHostById.mockReturnValue(createHost({ power_state: HOST_POWER_STATE.RUNNING }))
isMasterHost.mockReturnValue(false)
const displayData = mountFirstDisplayData()
expect(displayData.hostIcon).toBe(objectIcon('host', 'running'))
expect(displayData.primaryHostIcon).toBe(objectIcon('host', 'running'))
})
it('leaves the host icons undefined when the master host is unknown', () => {
getHostById.mockReturnValue(undefined)
const displayData = mountFirstDisplayData()
expect(displayData.hostIcon).toBeUndefined()
expect(displayData.primaryHostIcon).toBeUndefined()
expect(displayData.primaryHostRightIcon).toBeUndefined()
})
it('maps the host icons to the halted state when the master host is halted', () => {
getHostById.mockReturnValue(createHost({ power_state: HOST_POWER_STATE.HALTED }))
isMasterHost.mockReturnValue(false)
const displayData = mountFirstDisplayData()
expect(displayData.hostIcon).toBe(objectIcon('host', 'halted'))
})
it('shows the primary-circle icon when the master host leads its pool', () => {
getHostById.mockReturnValue(createHost())
isMasterHost.mockReturnValue(true)
const displayData = mountFirstDisplayData()
expect(displayData.primaryHostRightIcon).toBe('status:primary-circle')
})
it('omits the primary-circle icon when the master host does not lead its pool', () => {
getHostById.mockReturnValue(createHost())
isMasterHost.mockReturnValue(false)
const displayData = mountFirstDisplayData()
expect(displayData.primaryHostRightIcon).toBeUndefined()
})
it('always sets the pool icon', () => {
getHostById.mockReturnValue(undefined)
const displayData = mountFirstDisplayData()
expect(displayData.poolIcon).toBe('object:pool')
})
})

View File

@@ -0,0 +1,125 @@
import type { XoPoolDashboard } from '@/modules/pool/types/xo-pool-dashboard.type.ts'
import {
buildTimestamps,
countSamples,
getMaxSample,
getUsedMemoryAtIndex,
RAM_CHART_MAX_FALLBACK,
sumAtIndex,
type ChartPoint,
type PairedUsageSeries,
} from '@/shared/utils/chart-stats.util.ts'
import type { ProgressBarGroupItem } from '@core/components/progress-bar-group/VtsProgressBarGroup.vue'
import type { XapiHostStats, XapiPoolStats } from '@vates/types/common'
type StorageUsage = NonNullable<NonNullable<XoPoolDashboard['srs']>['topFiveUsage']>[number]
type PercentUsage = { id: string; name_label: string; percent: number }
type HostRamUsage = { id: string; name_label: string; usage: number; size: number }
type VmRamUsage = { id: string; name_label: string; memory: number; memoryFree: number }
function getHostsStats(
data: XapiPoolStats | null,
hasRequiredStats: (hostStats: XapiHostStats) => boolean
): XapiHostStats[] {
return Object.values(data ?? {}).filter((entry): entry is XapiHostStats => hasRequiredStats(entry as XapiHostStats))
}
function buildStackedTimeSeries(
hostsStats: XapiHostStats[],
dataLength: number,
getHostValueAtIndex: (host: XapiHostStats, index: number) => number
): ChartPoint[] {
if (hostsStats.length === 0) {
return []
}
const timestamps = buildTimestamps(hostsStats[0], dataLength)
return timestamps.map((timestamp, index) => {
const value = hostsStats.reduce((sum, host) => sum + getHostValueAtIndex(host, index), 0)
return { timestamp, value }
})
}
export function buildStackedCpuUsageSeries(data: XapiPoolStats | null): ChartPoint[] {
const hostsStats = getHostsStats(data, host => !!host.stats?.cpus)
const dataLength = countSamples(hostsStats[0]?.stats.cpus)
return buildStackedTimeSeries(hostsStats, dataLength, (host, index) => Math.round(sumAtIndex(host.stats.cpus, index)))
}
export function buildStackedRamUsageSeries(data: XapiPoolStats | null): ChartPoint[] {
const hostsStats = getHostsStats(data, host => !!host.stats?.memory && !!host.stats?.memoryFree)
const dataLength = hostsStats[0]?.stats.memory?.length ?? 0
return buildStackedTimeSeries(hostsStats, dataLength, (host, index) => getUsedMemoryAtIndex(host.stats, index))
}
export function getStackedRamUsageMaxValue(data: XapiPoolStats | null): number {
const hostsStats = getHostsStats(data, host => !!host.stats?.memory)
const totalMemory = hostsStats.reduce((total, host) => total + getMaxSample(host.stats.memory), 0)
return totalMemory || RAM_CHART_MAX_FALLBACK
}
export function buildStackedNetworkUsageSeries(data: XapiPoolStats | null): PairedUsageSeries {
const hostsStats = getHostsStats(data, host => !!host.stats?.pifs)
const pifs = hostsStats[0]?.stats.pifs
const dataLength = Math.max(countSamples(pifs?.rx), countSamples(pifs?.tx))
return [
buildStackedTimeSeries(hostsStats, dataLength, (host, index) => sumAtIndex(host.stats.pifs?.rx, index)),
buildStackedTimeSeries(hostsStats, dataLength, (host, index) => sumAtIndex(host.stats.pifs?.tx, index)),
]
}
export function getStoragesUsageTotals(storagesUsage: StorageUsage[]): { totalUsage: number; totalSize: number } {
return storagesUsage.reduce(
(totals, storage) => ({
totalUsage: totals.totalUsage + storage.physical_usage,
totalSize: totals.totalSize + storage.size,
}),
{ totalUsage: 0, totalSize: 0 }
)
}
export function buildPercentProgressItems(usages: PercentUsage[]): ProgressBarGroupItem[] {
return usages.map(
usage =>
({
id: usage.id,
label: usage.name_label,
current: usage.percent,
total: 100,
}) satisfies ProgressBarGroupItem
)
}
export function buildHostsRamProgressItems(usages: HostRamUsage[]): ProgressBarGroupItem[] {
return usages.map(
usage =>
({
id: usage.id,
label: usage.name_label,
current: usage.usage,
total: usage.size,
}) satisfies ProgressBarGroupItem
)
}
export function buildVmsRamProgressItems(usages: VmRamUsage[]): ProgressBarGroupItem[] {
return usages.map(
usage =>
({
id: usage.id,
label: usage.name_label,
current: usage.memory - usage.memoryFree,
total: usage.memory,
}) satisfies ProgressBarGroupItem
)
}

View File

@@ -0,0 +1,304 @@
import type { XoPoolDashboard } from '@/modules/pool/types/xo-pool-dashboard.type.ts'
import {
buildStackedCpuUsageSeries,
buildStackedNetworkUsageSeries,
buildStackedRamUsageSeries,
buildHostsRamProgressItems,
buildPercentProgressItems,
buildVmsRamProgressItems,
getStackedRamUsageMaxValue,
getStoragesUsageTotals,
} from '@/modules/pool/utils/xo-pool-dashboard.util.ts'
import { ONE_GB } from '@/shared/constants.ts'
import { createPoolStats } from '@/test/create-pool-stats.ts'
type StorageUsage = NonNullable<NonNullable<XoPoolDashboard['srs']>['topFiveUsage']>[number]
function createStorageUsage(overrides: Partial<StorageUsage> = {}): StorageUsage {
return {
id: 'sr-1' as StorageUsage['id'],
name_label: 'Storage',
percent: 0,
physical_usage: 0,
size: 0,
...overrides,
}
}
describe('buildStackedCpuUsageSeries', () => {
it('sums the cpu usage of every host at each index', () => {
const data = createPoolStats({
'host-1': { stats: { cpus: { '0': [10, 20], '1': [30, 40] } } },
'host-2': { stats: { cpus: { '0': [1, 2] } } },
})
expect(buildStackedCpuUsageSeries(data)).toEqual([
{ timestamp: 990_000, value: 41 },
{ timestamp: 1_000_000, value: 62 },
])
})
it('rounds the usage each host contributes to the nearest integer', () => {
const data = createPoolStats({ 'host-1': { stats: { cpus: { '0': [10.4] } } } })
expect(buildStackedCpuUsageSeries(data)).toEqual([{ timestamp: 1_000_000, value: 10 }])
})
it('leaves out the hosts whose stats failed', () => {
const data = createPoolStats({
'host-1': { stats: { cpus: { '0': [10, 20] } } },
'host-2': { error: { code: 'boom' } },
})
expect(buildStackedCpuUsageSeries(data)).toEqual([
{ timestamp: 990_000, value: 10 },
{ timestamp: 1_000_000, value: 20 },
])
})
it('leaves out the hosts not reporting their cpus', () => {
const data = createPoolStats({
'host-1': { stats: { cpus: { '0': [10, 20] } } },
'host-2': { stats: { memory: [2048, 4096] } },
})
expect(buildStackedCpuUsageSeries(data)).toEqual([
{ timestamp: 990_000, value: 10 },
{ timestamp: 1_000_000, value: 20 },
])
})
it('sizes the series off the cpu samples, not off another stat the host reports', () => {
const data = createPoolStats({ 'host-1': { stats: { cpus: { '0': [10, 20, 30] }, memory: [2048] } } })
expect(buildStackedCpuUsageSeries(data)).toHaveLength(3)
})
it('returns an empty array when no host reports its cpus', () => {
expect(buildStackedCpuUsageSeries(createPoolStats({ 'host-1': { stats: { memory: [2048] } } }))).toEqual([])
})
it('returns an empty array when the cpus of every host hold no sample', () => {
expect(buildStackedCpuUsageSeries(createPoolStats({ 'host-1': { stats: { cpus: { '0': [] } } } }))).toEqual([])
})
it('returns an empty array for a pool without host', () => {
expect(buildStackedCpuUsageSeries(createPoolStats())).toEqual([])
})
it('returns an empty array for stats that have not arrived yet', () => {
expect(buildStackedCpuUsageSeries(null)).toEqual([])
})
})
describe('buildStackedRamUsageSeries', () => {
it('sums the memory used by every host at each index', () => {
const data = createPoolStats({
'host-1': { stats: { memory: [2048, 4096], memoryFree: [1024, 2048] } },
'host-2': { stats: { memory: [1024, 1024], memoryFree: [512, 256] } },
})
expect(buildStackedRamUsageSeries(data)).toEqual([
{ timestamp: 990_000, value: 1536 },
{ timestamp: 1_000_000, value: 2816 },
])
})
it('leaves out a host reporting its total memory but not its free memory', () => {
const data = createPoolStats({
'host-1': { stats: { memory: [2048, 4096], memoryFree: [1024, 2048] } },
'host-2': { stats: { memory: [1024, 1024] } },
})
expect(buildStackedRamUsageSeries(data)).toEqual([
{ timestamp: 990_000, value: 1024 },
{ timestamp: 1_000_000, value: 2048 },
])
})
it('returns an empty array when no host reports its free memory', () => {
expect(buildStackedRamUsageSeries(createPoolStats({ 'host-1': { stats: { memory: [2048] } } }))).toEqual([])
})
it('returns an empty array when the memory of every host holds no sample', () => {
const data = createPoolStats({ 'host-1': { stats: { memory: [], memoryFree: [] } } })
expect(buildStackedRamUsageSeries(data)).toEqual([])
})
it('returns an empty array for a pool without host', () => {
expect(buildStackedRamUsageSeries(createPoolStats())).toEqual([])
})
it('returns an empty array for stats that have not arrived yet', () => {
expect(buildStackedRamUsageSeries(null)).toEqual([])
})
})
describe('getStackedRamUsageMaxValue', () => {
it('sums the highest total memory sample of every host', () => {
const data = createPoolStats({
'host-1': { stats: { memory: [2048, 4096] } },
'host-2': { stats: { memory: [1024, 1024] } },
})
expect(getStackedRamUsageMaxValue(data)).toBe(5120)
})
it('counts a host reporting its total memory but not its free memory', () => {
const data = createPoolStats({
'host-1': { stats: { memory: [2048], memoryFree: [1024] } },
'host-2': { stats: { memory: [1024] } },
})
expect(getStackedRamUsageMaxValue(data)).toBe(3072)
})
it('falls back to 1 GiB when every memory sample is zero, which would flatten the axis', () => {
expect(getStackedRamUsageMaxValue(createPoolStats({ 'host-1': { stats: { memory: [0] } } }))).toBe(ONE_GB)
})
it('falls back to 1 GiB when no host reports its memory', () => {
expect(getStackedRamUsageMaxValue(createPoolStats())).toBe(ONE_GB)
})
it('falls back to 1 GiB for stats that have not arrived yet', () => {
expect(getStackedRamUsageMaxValue(null)).toBe(ONE_GB)
})
})
describe('buildStackedNetworkUsageSeries', () => {
it('sums the reception and the transmission of every host at each index', () => {
const data = createPoolStats({
'host-1': { stats: { pifs: { rx: { eth0: [10, 20] }, tx: { eth0: [30, 40] } } } },
'host-2': { stats: { pifs: { rx: { eth0: [1, 2] }, tx: { eth0: [3, 4] } } } },
})
expect(buildStackedNetworkUsageSeries(data)).toEqual([
[
{ timestamp: 990_000, value: 11 },
{ timestamp: 1_000_000, value: 22 },
],
[
{ timestamp: 990_000, value: 33 },
{ timestamp: 1_000_000, value: 44 },
],
])
})
it('reports zeroes for the reception when the hosts only report their transmission', () => {
const data = createPoolStats({ 'host-1': { stats: { pifs: { rx: {}, tx: { eth0: [30, 40] } } } } })
expect(buildStackedNetworkUsageSeries(data)).toEqual([
[
{ timestamp: 990_000, value: 0 },
{ timestamp: 1_000_000, value: 0 },
],
[
{ timestamp: 990_000, value: 30 },
{ timestamp: 1_000_000, value: 40 },
],
])
})
it('takes the sample count from whichever direction reports the most', () => {
const data = createPoolStats({ 'host-1': { stats: { pifs: { rx: { eth0: [10] }, tx: { eth0: [30, 40] } } } } })
const [reception, transmission] = buildStackedNetworkUsageSeries(data)
expect(reception.map(point => point.value)).toEqual([10, NaN])
expect(transmission.map(point => point.value)).toEqual([30, 40])
})
it('returns empty series when no host reports its pifs', () => {
expect(buildStackedNetworkUsageSeries(createPoolStats({ 'host-1': { stats: { memory: [2048] } } }))).toEqual([
[],
[],
])
})
it('returns empty series when the pifs of every host hold no sample', () => {
expect(
buildStackedNetworkUsageSeries(createPoolStats({ 'host-1': { stats: { pifs: { rx: {}, tx: {} } } } }))
).toEqual([[], []])
})
it('returns empty series for stats that have not arrived yet', () => {
expect(buildStackedNetworkUsageSeries(null)).toEqual([[], []])
})
})
describe('getStoragesUsageTotals', () => {
it('sums the physical usage and size across all storages', () => {
const storages = [
createStorageUsage({ physical_usage: 100, size: 1000 }),
createStorageUsage({ physical_usage: 50, size: 500 }),
]
expect(getStoragesUsageTotals(storages)).toEqual({ totalUsage: 150, totalSize: 1500 })
})
it('returns zero totals for an empty array', () => {
expect(getStoragesUsageTotals([])).toEqual({ totalUsage: 0, totalSize: 0 })
})
})
describe('buildPercentProgressItems', () => {
it('maps each usage to a progress bar item out of 100', () => {
const storages = [
createStorageUsage({ id: 'sr-1' as StorageUsage['id'], name_label: 'Local storage', percent: 30 }),
createStorageUsage({ id: 'sr-2' as StorageUsage['id'], name_label: 'Shared storage', percent: 70 }),
]
expect(buildPercentProgressItems(storages)).toEqual([
{ id: 'sr-1', label: 'Local storage', current: 30, total: 100 },
{ id: 'sr-2', label: 'Shared storage', current: 70, total: 100 },
])
})
it('keeps a fractional share as it is reported', () => {
expect(buildPercentProgressItems([{ id: 'host-1', name_label: 'Host 1', percent: 12.5 }])).toEqual([
{ id: 'host-1', label: 'Host 1', current: 12.5, total: 100 },
])
})
it('returns an empty array for an empty input', () => {
expect(buildPercentProgressItems([])).toEqual([])
})
})
describe('buildHostsRamProgressItems', () => {
it('maps each host to the memory it uses out of the memory it holds', () => {
const usages = [
{ id: 'host-1', name_label: 'Host 1', usage: 400, size: 1000 },
{ id: 'host-2', name_label: 'Host 2', usage: 750, size: 1000 },
]
expect(buildHostsRamProgressItems(usages)).toEqual([
{ id: 'host-1', label: 'Host 1', current: 400, total: 1000 },
{ id: 'host-2', label: 'Host 2', current: 750, total: 1000 },
])
})
it('returns an empty array for an empty input', () => {
expect(buildHostsRamProgressItems([])).toEqual([])
})
})
describe('buildVmsRamProgressItems', () => {
it('derives the memory a VM uses by taking its free memory off its total', () => {
const usages = [{ id: 'vm-1', name_label: 'VM 1', memory: 1000, memoryFree: 400 }]
expect(buildVmsRamProgressItems(usages)).toEqual([{ id: 'vm-1', label: 'VM 1', current: 600, total: 1000 }])
})
it('reports no memory used for a VM whose memory is entirely free', () => {
const usages = [{ id: 'vm-1', name_label: 'VM 1', memory: 1000, memoryFree: 1000 }]
expect(buildVmsRamProgressItems(usages)).toEqual([{ id: 'vm-1', label: 'VM 1', current: 0, total: 1000 }])
})
it('returns an empty array for an empty input', () => {
expect(buildVmsRamProgressItems([])).toEqual([])
})
})

View File

@@ -0,0 +1,44 @@
import { getPoolInfo } from '@/modules/pool/utils/xo-pool.util.ts'
import { createServer } from '@/test/create-server.ts'
describe('getPoolInfo', () => {
it('returns the pool name label, dashboard link and pool icon when a name label is set', () => {
const server = createServer({ poolNameLabel: 'Production Pool' })
expect(getPoolInfo(server)).toEqual({
label: 'Production Pool',
to: '/pool/pool-789/dashboard',
icon: 'object:pool',
})
})
it('omits the link when a name label is set but no pool id is available', () => {
const server = createServer({ poolNameLabel: 'Production Pool', poolId: undefined })
expect(getPoolInfo(server)).toEqual({
label: 'Production Pool',
to: undefined,
icon: 'object:pool',
})
})
it('falls back to the pool id as label when there is no name label', () => {
const server = createServer({ poolNameLabel: undefined })
expect(getPoolInfo(server)).toEqual({
label: 'pool-789',
to: '/pool/pool-789/dashboard',
icon: 'object:pool',
})
})
it('returns an empty label with no link nor icon when neither name label nor pool id are set', () => {
const server = createServer({ poolNameLabel: undefined, poolId: undefined })
expect(getPoolInfo(server)).toEqual({
label: '',
to: undefined,
icon: undefined,
})
})
})

View File

@@ -0,0 +1,37 @@
import { getVdiFormat, getVdiIcon } from '@/modules/vdi/utils/xo-vdi.util.ts'
import { createVbd } from '@/test/create-vbd.ts'
import { objectIcon } from '@core/icons'
describe('getVdiFormat', () => {
it('uppercases the given format', () => {
expect(getVdiFormat('raw')).toBe('RAW')
})
it('defaults to VHD when the format is undefined', () => {
expect(getVdiFormat(undefined)).toBe('VHD')
})
})
describe('getVdiIcon', () => {
it('returns the detached icon when there are no VBDs', () => {
expect(getVdiIcon([])).toBe(objectIcon('vdi', 'detached'))
})
it('returns the detached icon when every VBD is detached', () => {
expect(getVdiIcon([createVbd({ attached: false }), createVbd({ attached: false })])).toBe(
objectIcon('vdi', 'detached')
)
})
it('returns the attached icon when every VBD is attached', () => {
expect(getVdiIcon([createVbd({ attached: true }), createVbd({ attached: true })])).toBe(
objectIcon('vdi', 'attached')
)
})
it('returns the warning icon when some VBDs are attached and some are not', () => {
expect(getVdiIcon([createVbd({ attached: true }), createVbd({ attached: false })])).toBe(
objectIcon('vdi', 'warning')
)
})
})

View File

@@ -0,0 +1,57 @@
import VmGuestToolsStatus from '@/modules/vm/components/VmGuestToolsStatus.vue'
import type { GuestToolsDisplay } from '@/modules/vm/composables/xo-vm-utils.composable.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { mount } from '@vue/test-utils'
function mountGuestToolsStatus(guestToolsDisplay: GuestToolsDisplay) {
return mount(VmGuestToolsStatus, {
props: { guestToolsDisplay },
global: createGlobalTestConfig(),
})
}
it('shows a dash without any status icon when guest tools do not apply', () => {
const wrapper = mountGuestToolsStatus({ type: 'not-applicable', value: '-' })
expect(wrapper.text()).toBe('-')
expect(wrapper.find('.vts-icon').exists()).toBe(false)
})
it('links to the guest tools documentation when they are missing', () => {
const wrapper = mountGuestToolsStatus({
type: 'missing',
icon: 'status:halted-circle',
value: 'Install guest tools',
tooltip: 'No Xen tools detected',
})
const link = wrapper.get('a')
expect(link.text()).toBe('Install guest tools')
expect(link.attributes('href')).toBe('https://docs.xcp-ng.org/vms/#guest-tools')
})
it('shows the guest tools version as plain text when they are installed', () => {
const wrapper = mountGuestToolsStatus({
type: 'up-to-date',
icon: 'status:success-circle',
value: '7.5.0',
tooltip: 'Installed',
})
expect(wrapper.text()).toBe('7.5.0')
expect(wrapper.find('a').exists()).toBe(false)
expect(wrapper.find('.vts-icon').exists()).toBe(true)
})
it('shows the version alongside a status icon when the guest tools are out of date', () => {
const wrapper = mountGuestToolsStatus({
type: 'outdated',
icon: 'status:warning-circle',
value: '7.5.0',
tooltip: 'Guest tools out of date',
})
expect(wrapper.text()).toBe('7.5.0')
expect(wrapper.find('.vts-icon').exists()).toBe(true)
})

View File

@@ -0,0 +1,152 @@
import VmHeader from '@/modules/vm/components/VmHeader.vue'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import type { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import { createTestRouter } from '@/test/create-test-router.ts'
import { createVm } from '@/test/create-vm.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import VtsObjectIcon from '@core/components/object-icon/VtsObjectIcon.vue'
import { VM_OPERATIONS, VM_POWER_STATE } from '@vates/types'
import { mount, type DOMWrapper } from '@vue/test-utils'
const { buildXo5Route } = vi.hoisted(() => ({
buildXo5Route: vi.fn(),
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => ({
useXoRoutes: (() => ({ buildXo5Route })) as unknown as typeof useXoRoutes,
}))
beforeEach(() => {
buildXo5Route.mockReset()
buildXo5Route.mockImplementation((path: string) => `https://xo5.example.com/#${path}`)
})
async function mountHeader(vm: FrontXoVm = createVm({ id: 'vm-42' as FrontXoVm['id'] }), initialPath?: string) {
const router = createTestRouter()
if (initialPath !== undefined) {
await router.push(initialPath)
}
return mount(VmHeader, {
props: { vm },
global: createGlobalTestConfig({ router }),
})
}
function findTabs(wrapper: Awaited<ReturnType<typeof mountHeader>>) {
return wrapper.findAll('.tab-item')
}
/**
* An icon renders as bare `<svg>` paths, so the only way to name the one a
* component picked is to compare it with a reference render of the icon it was
* meant to pick — the rendering counterpart of asserting `objectIcon(…)`.
*/
function findIconPaths(wrapper: { findAll: (selector: string) => DOMWrapper<Element>[] }) {
return wrapper.findAll('.icon-path').map(path => path.attributes('d'))
}
function mountVmStateIcon(state: 'running' | 'paused' | 'halted' | 'suspended') {
return mount(VtsObjectIcon, {
props: { type: 'vm' as const, state, size: 'medium' as const },
global: createGlobalTestConfig(),
})
}
it('shows the name of the VM', async () => {
const wrapper = await mountHeader(createVm({ name_label: 'Web server' }))
expect(wrapper.get('.ui-head-bar .label').text()).toBe('Web server')
})
it('lists every tab of the VM, in order', async () => {
const wrapper = await mountHeader()
expect(findTabs(wrapper).map(tab => tab.text())).toEqual([
t('dashboard'),
t('console'),
t('backups'),
t('stats'),
t('system'),
t('network'),
t('vdis'),
t('snapshots'),
t('tasks'),
])
})
it('points every in-app tab at the page of that VM', async () => {
const wrapper = await mountHeader()
const inAppHrefs = findTabs(wrapper)
.filter(tab => tab.element.tagName === 'A')
.map(tab => tab.attributes('href'))
expect(inAppHrefs).toEqual([
'/vm/vm-42/dashboard',
'/vm/vm-42/console',
'/vm/vm-42/backups',
'/vm/vm-42/system',
'/vm/vm-42/networks',
'/vm/vm-42/vdis',
'/vm/vm-42/snapshots',
'/vm/vm-42/tasks',
])
})
it('sends the stats tab to XO 5 rather than to an in-app page', async () => {
const wrapper = await mountHeader()
const statsTab = findTabs(wrapper).find(tab => tab.text() === t('stats'))!
expect(statsTab.element.tagName).not.toBe('A')
expect(statsTab.get('a').attributes('href')).toBe('https://xo5.example.com/#/vms/vm-42/stats')
})
it('marks the tab of the current route as the active one', async () => {
const wrapper = await mountHeader(createVm({ id: 'vm-42' as FrontXoVm['id'] }), '/vm/vm-42/system')
const activeTabs = findTabs(wrapper)
.filter(tab => tab.classes('active'))
.map(tab => tab.text())
expect(activeTabs).toEqual([t('system')])
})
it('marks no tab as active while no VM page is open', async () => {
const wrapper = await mountHeader()
expect(findTabs(wrapper).filter(tab => tab.classes('active'))).toHaveLength(0)
})
it('links to the XO 5 page managing the lifecycle of the VM', async () => {
const wrapper = await mountHeader()
expect(wrapper.get('.ui-head-bar .actions a').attributes('href')).toBe('https://xo5.example.com/#/vms/vm-42/general')
})
it('offers the state-change and more-actions menus', async () => {
const wrapper = await mountHeader()
expect(wrapper.get('.ui-head-bar .actions').text()).toContain(t('action:change-state'))
expect(wrapper.find('.ui-head-bar .actions button.ui-button-icon').exists()).toBe(true)
})
it('shows the icon matching the power state of the VM', async () => {
const wrapper = await mountHeader(createVm({ power_state: VM_POWER_STATE.PAUSED, current_operations: {} }))
expect(findIconPaths(wrapper.get('.ui-head-bar .label-wrapper'))).toEqual(findIconPaths(mountVmStateIcon('paused')))
expect(findIconPaths(wrapper.get('.ui-head-bar .label-wrapper'))).not.toEqual(
findIconPaths(mountVmStateIcon('running'))
)
expect(wrapper.find('.ui-head-bar .label-wrapper .ui-loader').exists()).toBe(false)
})
it('replaces the power state icon with a loader while the VM is changing state', async () => {
const vm = createVm({ current_operations: { 'task-1': VM_OPERATIONS.CLEAN_REBOOT } })
const wrapper = await mountHeader(vm)
expect(wrapper.find('.ui-head-bar .label-wrapper .ui-loader').exists()).toBe(true)
})

View File

@@ -0,0 +1,122 @@
import type { useXoSrUtils } from '@/modules/storage-repository/composables/xo-sr-utils.composable.ts'
import type { useXoSrCollection } from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import VmDashboardBackupReplication from '@/modules/vm/components/dashboard/VmDashboardBackupReplication.vue'
import type { useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import type { XoVmDashboard } from '@/modules/vm/types/vm-dashboard.type.ts'
import { createSr } from '@/test/create-sr.ts'
import { createVm } from '@/test/create-vm.ts'
import { findLabelledValues } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { d, t } from '@/test/i18n.ts'
import { objectIcon } from '@core/icons'
import { mount } from '@vue/test-utils'
import { computed, ref } from 'vue'
const { useXoSrCollectionMock, useXoSrUtilsMock, getSrById, getVmById } = vi.hoisted(() => ({
useXoSrCollectionMock: vi.fn(),
useXoSrUtilsMock: vi.fn(),
getSrById: vi.fn(),
getVmById: vi.fn(),
}))
vi.mock(import('@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'), () => ({
useXoSrCollection: useXoSrCollectionMock as unknown as typeof useXoSrCollection,
}))
vi.mock(import('@/modules/vm/remote-resources/use-xo-vm-collection.ts'), () => ({
useXoVmCollection: (() => ({ getVmById })) as unknown as typeof useXoVmCollection,
}))
vi.mock(import('@/modules/storage-repository/composables/xo-sr-utils.composable.ts'), () => ({
useXoSrUtils: useXoSrUtilsMock as unknown as typeof useXoSrUtils,
}))
beforeEach(() => {
useXoSrCollectionMock.mockReset()
useXoSrUtilsMock.mockReset()
getSrById.mockReset()
getVmById.mockReset()
useXoSrCollectionMock.mockReturnValue({ getSrById, areSrsReady: ref(true), hasSrFetchError: ref(false) })
useXoSrUtilsMock.mockReturnValue({ srStatusIcon: computed(() => objectIcon('sr', 'connected')) })
getSrById.mockReturnValue(undefined)
getVmById.mockReturnValue(undefined)
})
type VmReplication = NonNullable<NonNullable<XoVmDashboard['backupsInfo']>['replication']>
function createReplication(overrides: Partial<VmReplication> = {}): VmReplication {
return { id: 'vm-1' as VmReplication['id'], timestamp: 1660000000000, ...overrides }
}
function createDashboard(replication: VmReplication | undefined): XoVmDashboard {
return { backupsInfo: { replication } } as XoVmDashboard
}
function mountReplication(vmDashboard: XoVmDashboard | undefined, hasError = false) {
return mount(VmDashboardBackupReplication, {
props: { vmDashboard, hasError },
global: createGlobalTestConfig(),
})
}
it('renders the card title', () => {
const wrapper = mountReplication(createDashboard(createReplication()))
expect(wrapper.get('.ui-card-title').text()).toBe(t('backup:last-replication'))
})
it('shows a loader while the dashboard has not reported any replication yet', () => {
const wrapper = mountReplication(createDashboard(undefined))
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows a loader while the storage repositories are still loading', () => {
useXoSrCollectionMock.mockReturnValue({ getSrById, areSrsReady: ref(false), hasSrFetchError: ref(false) })
const wrapper = mountReplication(createDashboard(createReplication()))
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows an error message when the dashboard failed', () => {
const wrapper = mountReplication(createDashboard(createReplication()), true)
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('shows an error message when the storage repositories failed to load', () => {
useXoSrCollectionMock.mockReturnValue({ getSrById, areSrsReady: ref(true), hasSrFetchError: ref(true) })
const wrapper = mountReplication(createDashboard(createReplication()))
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('reports that the VM is not replicated when the replication is empty', () => {
const wrapper = mountReplication(createDashboard({} as VmReplication))
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-replicated-vm'))
})
it('shows the replicated VM, its date and its storage repository', () => {
getVmById.mockReturnValue(createVm({ name_label: 'Web server' }))
getSrById.mockReturnValue(createSr({ name_label: 'Backup SR' }))
const replication = createReplication({ sr: 'sr-1' as VmReplication['sr'] })
const wrapper = mountReplication(createDashboard(replication))
expect(findLabelledValues(wrapper)).toMatchObject({
[t('vm')]: 'Web server',
[t('date')]: d(replication.timestamp, { dateStyle: 'short', timeStyle: 'medium' }),
[t('storage-repository')]: 'Backup SR',
})
})
it('leaves the VM and storage repository rows empty when neither can be resolved', () => {
const wrapper = mountReplication(createDashboard(createReplication()))
expect(findLabelledValues(wrapper)).toMatchObject({ [t('vm')]: '', [t('storage-repository')]: '' })
})

View File

@@ -0,0 +1,75 @@
import VmDashboardCpuUsageChart from '@/modules/vm/components/dashboard/VmDashboardCpuUsageChart.vue'
import { createVmStats } from '@/test/create-vm-stats.ts'
import { n, t } from '@/test/i18n.ts'
import { findLinearChart, formatChartValue } from '@/test/linear-chart-stub.ts'
import { mountChartCard, type ChartCardProps } from '@/test/mount-chart-card.ts'
import type { XapiVmStats } from '@vates/types/common'
function mountChart(props: ChartCardProps<XapiVmStats>) {
return mountChartCard(VmDashboardCpuUsageChart, props)
}
const statsWithSamples = createVmStats({ stats: { cpus: { cpu0: [10, 20] } } })
it('renders the card title and the period it covers', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(wrapper.get('.ui-card-title').text()).toContain(t('cpu-usage'))
expect(wrapper.get('.ui-card-title').text()).toContain(t('last-week'))
})
it('shows a loader while the stats are loading', () => {
const wrapper = mountChart({ data: null, loading: true })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows an error message when the stats could not be fetched', () => {
const wrapper = mountChart({ data: null, error: true })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('prefers the error message over the missing stats', () => {
const wrapper = mountChart({ data: statsWithSamples, error: true })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('reports that there is nothing to plot when the VM has no vcpu sample', () => {
const wrapper = mountChart({ data: createVmStats() })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('reports that there is nothing to plot for null stats', () => {
const wrapper = mountChart({ data: null })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('plots the average usage across the vcpus of the VM', () => {
const wrapper = mountChart({ data: createVmStats({ stats: { cpus: { cpu0: [10, 20], cpu1: [30, 40] } } }) })
expect(findLinearChart(wrapper).props('data')).toEqual([
{
label: t('stacked-cpu-usage'),
data: [
{ timestamp: 990_000, value: 20 },
{ timestamp: 1_000_000, value: 30 },
],
},
])
})
it('rounds the axis up to the next hundred percent', () => {
const wrapper = mountChart({ data: createVmStats({ stats: { cpus: { cpu0: [10, 120] } } }) })
expect(findLinearChart(wrapper).props('maxValue')).toBe(200)
})
it('formats the plotted values as percentages', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(formatChartValue(wrapper, 4200)).toBe(n(42, 'percent'))
})

View File

@@ -16,7 +16,9 @@
</template>
<script lang="ts" setup>
import type { LinearChartData, ValueFormatter } from '@core/types/chart.ts'
import { useChartPercentFormatter } from '@/shared/composables/chart-percent-formatter.composable.ts'
import { buildCpuUsageSeries, getCpuUsageMaxValue } from '@/shared/utils/chart-stats.util.ts'
import type { LinearChartData } from '@core/types/chart.ts'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiCard from '@core/components/ui/card/UiCard.vue'
import UiCardTitle from '@core/components/ui/card-title/UiCardTitle.vue'
@@ -32,54 +34,24 @@ const { data } = defineProps<{
const VtsLinearChart = defineAsyncComponent(() => import('@core/components/linear-chart/VtsLinearChart.vue'))
const { t, n } = useI18n()
const { t } = useI18n()
const valueFormatter = useChartPercentFormatter()
const cpuUsageSeries = computed(() => buildCpuUsageSeries(data))
const cpuUsage = computed<LinearChartData>(() => {
if (!data?.stats.cpus) {
if (cpuUsageSeries.value.length === 0) {
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] ?? NaN), 0)
result.set(timestamp, {
timestamp,
value: Math.round(cpuUsageSum / cpus.length),
})
}
return [
{
label: t('stacked-cpu-usage'),
data: Array.from(result.values()),
data: cpuUsageSeries.value,
},
]
})
const maxValue = computed(() => {
const values = cpuUsage.value[0]?.data.map(item => item.value || 0) ?? []
if (values.length === 0) {
return 100
}
const maxCpuUsage = Math.max(...values)
return Math.ceil(maxCpuUsage / 100) * 100
})
const valueFormatter: ValueFormatter = value => {
if (value === null) {
return ''
}
return n(value / 100, 'percent')
}
const maxValue = computed(() => getCpuUsageMaxValue(cpuUsageSeries.value))
</script>

View File

@@ -0,0 +1,102 @@
import VmDashboardNetworkUsageChart from '@/modules/vm/components/dashboard/VmDashboardNetworkUsageChart.vue'
import { createVmStats } from '@/test/create-vm-stats.ts'
import { t } from '@/test/i18n.ts'
import { findLinearChart, formatChartValue } from '@/test/linear-chart-stub.ts'
import { mountChartCard, type ChartCardProps } from '@/test/mount-chart-card.ts'
import type { XapiVmStats } from '@vates/types/common'
function mountChart(props: ChartCardProps<XapiVmStats>) {
return mountChartCard(VmDashboardNetworkUsageChart, props)
}
const statsWithSamples = createVmStats({ stats: { vifs: { rx: { '0': [10, 20] }, tx: { '0': [30, 40] } } } })
it('renders the card title and the period it covers', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(wrapper.get('.ui-card-title').text()).toContain(t('network-throughput'))
expect(wrapper.get('.ui-card-title').text()).toContain(t('last-week'))
})
it('shows a loader while the stats are loading', () => {
const wrapper = mountChart({ data: null, loading: true })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows an error message when the stats could not be fetched', () => {
const wrapper = mountChart({ data: null, error: true })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('prefers the error message over the missing stats', () => {
const wrapper = mountChart({ data: statsWithSamples, error: true })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('reports that there is nothing to plot when the VM has no vif sample', () => {
const wrapper = mountChart({ data: createVmStats() })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('reports that there is nothing to plot for null stats', () => {
const wrapper = mountChart({ data: null })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('plots the upload above the download', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(findLinearChart(wrapper).props('data')).toEqual([
{
label: t('network-upload'),
data: [
{ timestamp: 990_000, value: 30 },
{ timestamp: 1_000_000, value: 40 },
],
},
{
label: t('network-download'),
data: [
{ timestamp: 990_000, value: 10 },
{ timestamp: 1_000_000, value: 20 },
],
},
])
})
it('sums the throughput of every vif of the VM', () => {
const wrapper = mountChart({
data: createVmStats({ stats: { vifs: { rx: { '0': [10, 20], '1': [1, 2] }, tx: { '0': [30, 40] } } } }),
})
expect(findLinearChart(wrapper).props('data')[1].data).toEqual([
{ timestamp: 990_000, value: 11 },
{ timestamp: 1_000_000, value: 22 },
])
})
it('rounds the axis up to the next hundred bytes, with headroom above the peak', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(findLinearChart(wrapper).props('maxValue')).toBe(100)
})
it('plots a flat download for a VM reporting transmissions but no reception', () => {
const wrapper = mountChart({ data: createVmStats({ stats: { vifs: { rx: {}, tx: { '0': [30, 40] } } } }) })
expect(findLinearChart(wrapper).props('data')[1].data).toEqual([
{ timestamp: 990_000, value: 0 },
{ timestamp: 1_000_000, value: 0 },
])
})
it('formats the plotted values as bytes', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(formatChartValue(wrapper, 1024)).toBe('1 KiB')
})

View File

@@ -11,16 +11,16 @@
<VtsStateHero v-else-if="networkUsage.length === 0" format="card" type="no-data" size="medium">
{{ t('no-data-to-calculate') }}
</VtsStateHero>
<VtsLinearChart v-else :data="networkUsage" :max-value :value-formatter="byteFormatter" />
<VtsLinearChart v-else :data="networkUsage" :max-value :value-formatter="formatChartBytes" />
</UiCard>
</template>
<script lang="ts" setup>
import { buildPairedUsageSeries, formatChartBytes, getPairedUsageMaxValue } from '@/shared/utils/chart-stats.util.ts'
import type { LinearChartData } from '@core/types/chart.ts'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.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 type { XapiVmStats } from '@vates/types/common'
import { computed, defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -35,63 +35,26 @@ const VtsLinearChart = defineAsyncComponent(() => import('@core/components/linea
const { t } = useI18n()
const networkUsageSeries = computed(() => buildPairedUsageSeries(data, data?.stats.vifs?.rx, data?.stats.vifs?.tx))
const networkUsage = computed<LinearChartData>(() => {
if (!data?.stats.vifs) {
const [download, upload] = networkUsageSeries.value
if (download.length === 0 && upload.length === 0) {
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-download'),
data: timestamps.map((timestamp, index) => ({
timestamp,
value: Object.values(data.stats.vifs?.rx ?? {}).reduce((sum, values) => sum + (values[index] ?? NaN), 0),
})),
},
]
const txSeries = [
return [
{
label: t('network-upload'),
data: timestamps.map((timestamp, index) => ({
timestamp,
value: Object.values(data.stats.vifs?.tx ?? {}).reduce((sum, values) => sum + (values[index] ?? NaN), 0),
})),
data: upload,
},
{
label: t('network-download'),
data: download,
},
]
return [...txSeries, ...rxSeries]
})
const maxValue = computed(() => {
const values = networkUsage.value.reduce(
(acc, series) => [...acc, ...series.data.map(item => item.value || 0)],
[] as number[]
)
if (values.length === 0) {
return 100
}
const maxUsage = Math.max(...values) * 1.2
return Math.ceil(maxUsage / 100) * 100
})
const byteFormatter = (value: number | null) => {
if (value === null) {
return ''
}
const size = formatSizeRaw(value, 1)
return `${size.value} ${size.prefix}`
}
const maxValue = computed(() => getPairedUsageMaxValue(networkUsageSeries.value, { step: 100 }))
</script>

View File

@@ -0,0 +1,173 @@
import type { useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import type { useXoPoolCollection } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import type { useXoUserResource } from '@/modules/user/remote-resources/use-xo-user.ts'
import VmDashboardQuickInfo from '@/modules/vm/components/dashboard/VmDashboardQuickInfo.vue'
import type { FrontXoVm, useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import type { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import { createHost } from '@/test/create-host.ts'
import { createPool } from '@/test/create-pool.ts'
import { createVm } from '@/test/create-vm.ts'
import { findLabelledValues } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import type { XoUser } from '@vates/types'
import { VM_POWER_STATE } from '@vates/types'
import { mount } from '@vue/test-utils'
import { computed, ref } from 'vue'
const { useXoVmCollectionMock, useGetPoolById, getVmHost, isMasterHost, buildXo5Route, useXoUserResourceMock } =
vi.hoisted(() => ({
useXoVmCollectionMock: vi.fn(),
useGetPoolById: vi.fn(),
getVmHost: vi.fn(),
isMasterHost: vi.fn(),
buildXo5Route: vi.fn(),
useXoUserResourceMock: vi.fn(),
}))
vi.mock(import('@/modules/vm/remote-resources/use-xo-vm-collection.ts'), () => ({
useXoVmCollection: useXoVmCollectionMock as unknown as typeof useXoVmCollection,
}))
vi.mock(import('@/modules/pool/remote-resources/use-xo-pool-collection.ts'), () => ({
useXoPoolCollection: (() => ({ useGetPoolById })) as unknown as typeof useXoPoolCollection,
}))
vi.mock(import('@/modules/host/remote-resources/use-xo-host-collection.ts'), () => ({
useXoHostCollection: (() => ({ isMasterHost })) as unknown as typeof useXoHostCollection,
}))
vi.mock(import('@/modules/user/remote-resources/use-xo-user.ts'), () => ({
useXoUserResource: useXoUserResourceMock as unknown as typeof useXoUserResource,
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => ({
useXoRoutes: (() => ({ buildXo5Route })) as unknown as typeof useXoRoutes,
}))
beforeEach(() => {
useXoVmCollectionMock.mockReset()
useGetPoolById.mockReset()
getVmHost.mockReset()
isMasterHost.mockReset()
buildXo5Route.mockReset()
useXoUserResourceMock.mockReset()
useXoVmCollectionMock.mockReturnValue({ areVmsReady: ref(true), getVmHost })
useGetPoolById.mockReturnValue(computed(() => undefined))
getVmHost.mockReturnValue(undefined)
isMasterHost.mockReturnValue(false)
buildXo5Route.mockImplementation((path: string) => `https://xo5.example.com/#${path}`)
useXoUserResourceMock.mockReturnValue({ user: computed(() => undefined) })
})
function mountQuickInfo(vm: FrontXoVm = createVm()) {
return mount(VmDashboardQuickInfo, {
props: { vm },
global: createGlobalTestConfig(),
})
}
it('shows a busy state instead of the rows while the VMs are still loading', () => {
useXoVmCollectionMock.mockReturnValue({ areVmsReady: ref(false), getVmHost })
const wrapper = mountQuickInfo()
expect(findLabelledValues(wrapper)).toEqual({})
})
it('lists every row of the card, in order', () => {
const wrapper = mountQuickInfo()
expect(wrapper.findAll('.vts-key-value-row').map(row => row.get('dt.label').text())).toEqual([
t('state'),
t('ip-address'),
t('created-on'),
t('created-by'),
t('started'),
t('uuid'),
t('pool'),
t('host'),
t('description'),
t('os-name'),
t('virtualization-type'),
t('guest-tools'),
t('vcpus'),
t('ram'),
t('tags'),
])
})
it('shows the identity, power state and resources of the VM', () => {
const wrapper = mountQuickInfo(
createVm({
id: 'vm-42' as FrontXoVm['id'],
power_state: VM_POWER_STATE.PAUSED,
mainIpAddress: '10.0.0.1',
name_description: 'Serves the website',
os_version: { name: 'Debian Bookworm' } as FrontXoVm['os_version'],
CPUs: { number: 2, max: 4 },
memory: { size: 4294967296, dynamic: [0, 0], static: [0, 0] },
})
)
expect(findLabelledValues(wrapper)).toMatchObject({
[t('state')]: t('vm:status:paused'),
[t('ip-address')]: '10.0.0.1',
[t('uuid')]: 'vm-42',
[t('description')]: 'Serves the website',
[t('os-name')]: 'Debian Bookworm',
[t('vcpus')]: '2',
[t('ram')]: '4 GiB',
})
})
it('reports an HVM guest running the PV drivers as pvhvm', () => {
const wrapper = mountQuickInfo(createVm({ virtualizationMode: 'hvm', pvDriversDetected: true }))
expect(findLabelledValues(wrapper)).toMatchObject({ [t('virtualization-type')]: 'pvhvm' })
})
it('reports the raw virtualization mode of an HVM guest without PV drivers', () => {
const wrapper = mountQuickInfo(createVm({ virtualizationMode: 'hvm', pvDriversDetected: false }))
expect(findLabelledValues(wrapper)).toMatchObject({ [t('virtualization-type')]: 'hvm' })
})
it('reports the raw virtualization mode of a paravirtualized guest', () => {
const wrapper = mountQuickInfo(createVm({ virtualizationMode: 'pv', pvDriversDetected: true }))
expect(findLabelledValues(wrapper)).toMatchObject({ [t('virtualization-type')]: 'pv' })
})
it('shows the pool and the host of the VM', () => {
useGetPoolById.mockReturnValue(computed(() => createPool({ name_label: 'Production Pool' })))
getVmHost.mockReturnValue(createHost({ name_label: 'Primary Host' }))
expect(findLabelledValues(mountQuickInfo())).toMatchObject({
[t('pool')]: 'Production Pool',
[t('host')]: 'Primary Host',
})
})
it('falls back to "None" when the VM has neither a known pool nor a host', () => {
expect(findLabelledValues(mountQuickInfo())).toMatchObject({ [t('pool')]: t('none'), [t('host')]: t('none') })
})
it('shows the name of the user who created the VM', () => {
useXoUserResourceMock.mockReturnValue({
user: computed(() => ({ id: 'user-1', name: 'alice', email: 'alice@example.com' }) as XoUser),
})
expect(findLabelledValues(mountQuickInfo())).toMatchObject({ [t('created-by')]: 'alice' })
})
it('falls back to "Unknown" when the creator of the VM is not known', () => {
expect(findLabelledValues(mountQuickInfo())).toMatchObject({ [t('created-by')]: t('unknown') })
})
it('renders one tag per VM tag', () => {
const wrapper = mountQuickInfo(createVm({ tags: ['production', 'billing'] }))
expect(wrapper.findAll('.ui-tag').map(tag => tag.text())).toEqual(['production', 'billing'])
})

View File

@@ -0,0 +1,75 @@
import VmDashboardRamUsageChart from '@/modules/vm/components/dashboard/VmDashboardRamUsageChart.vue'
import { createVmStats } from '@/test/create-vm-stats.ts'
import { t } from '@/test/i18n.ts'
import { findLinearChart, formatChartValue } from '@/test/linear-chart-stub.ts'
import { mountChartCard, type ChartCardProps } from '@/test/mount-chart-card.ts'
import type { XapiVmStats } from '@vates/types/common'
function mountChart(props: ChartCardProps<XapiVmStats>) {
return mountChartCard(VmDashboardRamUsageChart, props)
}
const statsWithSamples = createVmStats({ stats: { memory: [1000, 2000], memoryFree: [400, 500] } })
it('renders the card title and the period it covers', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(wrapper.get('.ui-card-title').text()).toContain(t('ram-usage'))
expect(wrapper.get('.ui-card-title').text()).toContain(t('last-week'))
})
it('shows a loader while the stats are loading', () => {
const wrapper = mountChart({ data: null, loading: true })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows an error message when the stats could not be fetched', () => {
const wrapper = mountChart({ data: null, error: true })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('prefers the error message over the missing stats', () => {
const wrapper = mountChart({ data: statsWithSamples, error: true })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('reports that there is nothing to plot when the VM has no memory sample', () => {
const wrapper = mountChart({ data: createVmStats() })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('reports that there is nothing to plot for null stats', () => {
const wrapper = mountChart({ data: null })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('plots the memory the VM actually uses', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(findLinearChart(wrapper).props('data')).toEqual([
{
label: t('stacked-ram-usage'),
data: [
{ timestamp: 990_000, value: 600 },
{ timestamp: 1_000_000, value: 1500 },
],
},
])
})
it('scales the axis to the memory allocated to the VM', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(findLinearChart(wrapper).props('maxValue')).toBe(2000)
})
it('formats the plotted values as bytes', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(formatChartValue(wrapper, 1024)).toBe('1 KiB')
})

View File

@@ -11,16 +11,16 @@
<VtsStateHero v-else-if="ramUsage.length === 0" format="card" type="no-data" size="medium">
{{ t('no-data-to-calculate') }}
</VtsStateHero>
<VtsLinearChart v-else :data="ramUsage" :max-value :value-formatter="byteFormatter" />
<VtsLinearChart v-else :data="ramUsage" :max-value :value-formatter="formatChartBytes" />
</UiCard>
</template>
<script lang="ts" setup>
import { buildRamUsageSeries, formatChartBytes, getRamUsageMaxValue } from '@/shared/utils/chart-stats.util.ts'
import type { LinearChartData } from '@core/types/chart.ts'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.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 type { XapiVmStats } from '@vates/types/common'
import { computed, defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -35,56 +35,20 @@ const VtsLinearChart = defineAsyncComponent(() => import('@core/components/linea
const { t } = useI18n()
const ramUsageSeries = computed(() => buildRamUsageSeries(data))
const ramUsage = computed<LinearChartData>(() => {
if (!data?.stats.memory || !data?.stats.memoryFree) {
if (ramUsageSeries.value.length === 0) {
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 ?? NaN) - (ramFree ?? NaN)
result.set(timestamp, {
timestamp,
value: ramUsed,
})
}
return [
{
label: t('stacked-ram-usage'),
data: Array.from(result.values()),
data: ramUsageSeries.value,
},
]
})
const maxValue = computed(() => {
if (!data?.stats.memory?.length) {
return 1024 * 1024 * 1024 // 1 GB as fallback
}
return Math.max(...data.stats.memory.map(memoryValue => memoryValue || 0), 0)
})
const byteFormatter = (value: number | null) => {
if (value === null) {
return ''
}
const size = formatSizeRaw(value, 1)
return `${size.value} ${size.prefix}`
}
const maxValue = computed(() => getRamUsageMaxValue(data))
</script>

View File

@@ -0,0 +1,102 @@
import VmDashboardVdiUsageChart from '@/modules/vm/components/dashboard/VmDashboardVdiUsageChart.vue'
import { createVmStats } from '@/test/create-vm-stats.ts'
import { t } from '@/test/i18n.ts'
import { findLinearChart, formatChartValue } from '@/test/linear-chart-stub.ts'
import { mountChartCard, type ChartCardProps } from '@/test/mount-chart-card.ts'
import type { XapiVmStats } from '@vates/types/common'
function mountChart(props: ChartCardProps<XapiVmStats>) {
return mountChartCard(VmDashboardVdiUsageChart, props)
}
const statsWithSamples = createVmStats({ stats: { xvds: { r: { xvda: [10, 20] }, w: { xvda: [30, 40] } } } })
it('renders the card title and the period it covers', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(wrapper.get('.ui-card-title').text()).toContain(t('vdi-throughput'))
expect(wrapper.get('.ui-card-title').text()).toContain(t('last-week'))
})
it('shows a loader while the stats are loading', () => {
const wrapper = mountChart({ data: null, loading: true })
expect(wrapper.find('.ui-loader').exists()).toBe(true)
})
it('shows an error message when the stats could not be fetched', () => {
const wrapper = mountChart({ data: null, error: true })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('prefers the error message over the missing stats', () => {
const wrapper = mountChart({ data: statsWithSamples, error: true })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('error-no-data'))
})
it('reports that there is nothing to plot when the VM has no disk sample', () => {
const wrapper = mountChart({ data: createVmStats() })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('reports that there is nothing to plot for null stats', () => {
const wrapper = mountChart({ data: null })
expect(wrapper.get('.vts-state-hero').text()).toBe(t('no-data-to-calculate'))
})
it('plots the reads above the writes', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(findLinearChart(wrapper).props('data')).toEqual([
{
label: t('read'),
data: [
{ timestamp: 990_000, value: 10 },
{ timestamp: 1_000_000, value: 20 },
],
},
{
label: t('write'),
data: [
{ timestamp: 990_000, value: 30 },
{ timestamp: 1_000_000, value: 40 },
],
},
])
})
it('sums the throughput of every disk of the VM', () => {
const wrapper = mountChart({
data: createVmStats({ stats: { xvds: { r: { xvda: [10, 20], xvdb: [1, 2] }, w: { xvda: [30, 40] } } } }),
})
expect(findLinearChart(wrapper).props('data')[0].data).toEqual([
{ timestamp: 990_000, value: 11 },
{ timestamp: 1_000_000, value: 22 },
])
})
it('rounds the axis up to the next hundred bytes, with headroom above the peak', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(findLinearChart(wrapper).props('maxValue')).toBe(100)
})
it('plots a flat read for a VM reporting writes but no read', () => {
const wrapper = mountChart({ data: createVmStats({ stats: { xvds: { w: { xvda: [10, 20] } } } }) })
expect(findLinearChart(wrapper).props('data')[0].data).toEqual([
{ timestamp: 990_000, value: 0 },
{ timestamp: 1_000_000, value: 0 },
])
})
it('formats the plotted values as bytes', () => {
const wrapper = mountChart({ data: statsWithSamples })
expect(formatChartValue(wrapper, 1024)).toBe('1 KiB')
})

View File

@@ -11,16 +11,16 @@
<VtsStateHero v-else-if="vdiUsage.length === 0" format="card" type="no-data" size="medium">
{{ t('no-data-to-calculate') }}
</VtsStateHero>
<VtsLinearChart v-else :data="vdiUsage" :max-value :value-formatter="byteFormatter" />
<VtsLinearChart v-else :data="vdiUsage" :max-value :value-formatter="formatChartBytes" />
</UiCard>
</template>
<script lang="ts" setup>
import { buildPairedUsageSeries, formatChartBytes, getPairedUsageMaxValue } from '@/shared/utils/chart-stats.util.ts'
import type { LinearChartData } from '@core/types/chart.ts'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.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 type { XapiVmStats } from '@vates/types/common'
import { computed, defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -35,63 +35,26 @@ const VtsLinearChart = defineAsyncComponent(() => import('@core/components/linea
const { t } = useI18n()
const vdiUsageSeries = computed(() => buildPairedUsageSeries(data, data?.stats.xvds?.r, data?.stats.xvds?.w))
const vdiUsage = computed<LinearChartData>(() => {
if (!data?.stats.xvds) {
const [read, write] = vdiUsageSeries.value
if (read.length === 0 && write.length === 0) {
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 = [
return [
{
label: t('read'),
data: timestamps.map((timestamp, index) => ({
timestamp,
value: Object.values(data.stats.xvds?.r ?? {}).reduce((sum, values) => sum + (values[index] ?? NaN), 0),
})),
data: read,
},
]
const writeSeries = [
{
label: t('write'),
data: timestamps.map((timestamp, index) => ({
timestamp,
value: Object.values(data.stats.xvds?.w ?? {}).reduce((sum, values) => sum + (values[index] ?? NaN), 0),
})),
data: write,
},
]
return [...readSeries, ...writeSeries]
})
const maxValue = computed(() => {
const values = vdiUsage.value.reduce(
(acc, series) => [...acc, ...series.data.map(item => item.value || 0)],
[] as number[]
)
if (values.length === 0) {
return 100
}
const maxUsage = Math.max(...values) * 1.2
return Math.ceil(maxUsage / 100) * 100
})
const byteFormatter = (value: number | null) => {
if (value === null) {
return ''
}
const size = formatSizeRaw(value, 1)
return `${size.value} ${size.prefix}`
}
const maxValue = computed(() => getPairedUsageMaxValue(vdiUsageSeries.value, { step: 100 }))
</script>

View File

@@ -0,0 +1,203 @@
import type { useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import type { useXoPoolCollection } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import type { useXoUserResource } from '@/modules/user/remote-resources/use-xo-user.ts'
import VmInfoCard from '@/modules/vm/components/list/panel/cards/VmInfoCard.vue'
import type { FrontXoVm, useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import type {
FrontXoVmTemplate,
useXoVmTemplateCollection,
} from '@/modules/vm/remote-resources/use-xo-vm-template-collection.ts'
import type { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import { createHost } from '@/test/create-host.ts'
import { createPool } from '@/test/create-pool.ts'
import { createVm } from '@/test/create-vm.ts'
import { findCardLabelledValues } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import type { XoUser } from '@vates/types'
import { HOST_POWER_STATE, VM_POWER_STATE } from '@vates/types'
import { mount } from '@vue/test-utils'
import { computed } from 'vue'
const { useGetPoolById, getVmHost, isMasterHost, buildXo5Route, useXoUserResourceMock, useXoVmTemplateCollectionMock } =
vi.hoisted(() => ({
useGetPoolById: vi.fn(),
getVmHost: vi.fn(),
isMasterHost: vi.fn(),
buildXo5Route: vi.fn(),
useXoUserResourceMock: vi.fn(),
useXoVmTemplateCollectionMock: vi.fn(),
}))
vi.mock(import('@/modules/pool/remote-resources/use-xo-pool-collection.ts'), () => ({
useXoPoolCollection: (() => ({ useGetPoolById })) as unknown as typeof useXoPoolCollection,
}))
vi.mock(import('@/modules/vm/remote-resources/use-xo-vm-collection.ts'), () => ({
useXoVmCollection: (() => ({ getVmHost })) as unknown as typeof useXoVmCollection,
}))
vi.mock(import('@/modules/host/remote-resources/use-xo-host-collection.ts'), () => ({
useXoHostCollection: (() => ({ isMasterHost })) as unknown as typeof useXoHostCollection,
}))
vi.mock(import('@/modules/user/remote-resources/use-xo-user.ts'), () => ({
useXoUserResource: useXoUserResourceMock as unknown as typeof useXoUserResource,
}))
vi.mock(import('@/modules/vm/remote-resources/use-xo-vm-template-collection.ts'), () => ({
useXoVmTemplateCollection: useXoVmTemplateCollectionMock as unknown as typeof useXoVmTemplateCollection,
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => ({
useXoRoutes: (() => ({ buildXo5Route })) as unknown as typeof useXoRoutes,
}))
beforeEach(() => {
useGetPoolById.mockReset()
getVmHost.mockReset()
isMasterHost.mockReset()
buildXo5Route.mockReset()
useXoUserResourceMock.mockReset()
useXoVmTemplateCollectionMock.mockReset()
useGetPoolById.mockReturnValue(computed(() => undefined))
getVmHost.mockReturnValue(undefined)
isMasterHost.mockReturnValue(false)
buildXo5Route.mockImplementation((path: string) => `https://xo5.example.com/#${path}`)
useXoUserResourceMock.mockReturnValue({ user: computed(() => undefined) })
useXoVmTemplateCollectionMock.mockReturnValue({ templates: computed(() => []) })
})
function createTemplate(overrides: Partial<FrontXoVmTemplate> = {}) {
return {
id: 'template-1',
uuid: 'template-uuid-1',
name_label: 'Debian 12',
$pool: 'pool-789',
...overrides,
} as FrontXoVmTemplate
}
function mountInfoCard(vm: FrontXoVm = createVm()) {
return mount(VmInfoCard, {
props: { vm },
global: createGlobalTestConfig(),
})
}
it('renders the name and the id of the VM as the card title', () => {
const wrapper = mountInfoCard(createVm({ name_label: 'Web server', id: 'vm-42' as FrontXoVm['id'] }))
expect(wrapper.get('.vts-card-object-title').text()).toContain('Web server')
expect(wrapper.get('.vts-card-object-title').text()).toContain('vm-42')
})
it('lists every row of the card, in order', () => {
const wrapper = mountInfoCard()
expect(wrapper.findAll('.vts-card-row-key-value').map(row => row.get('.key').text())).toEqual([
t('state'),
t('description'),
t('tags'),
t('pool'),
t('host'),
t('os-name'),
t('guest-tools'),
t('template'),
t('created-on'),
t('created-by'),
t('started'),
])
})
it('shows the power state, description and OS name of the VM', () => {
const wrapper = mountInfoCard(
createVm({
power_state: VM_POWER_STATE.HALTED,
name_description: 'Serves the website',
os_version: { name: 'Debian Bookworm' } as FrontXoVm['os_version'],
})
)
expect(findCardLabelledValues(wrapper)).toMatchObject({
[t('state')]: t('vm:status:halted'),
[t('description')]: 'Serves the website',
[t('os-name')]: 'Debian Bookworm',
})
})
it('renders one tag per VM tag', () => {
const wrapper = mountInfoCard(createVm({ tags: ['production', 'billing'] }))
expect(wrapper.findAll('.ui-tag').map(tag => tag.text())).toEqual(['production', 'billing'])
})
it('shows the name of the pool hosting the VM', () => {
useGetPoolById.mockReturnValue(computed(() => createPool({ name_label: 'Production Pool' })))
expect(findCardLabelledValues(mountInfoCard())).toMatchObject({ [t('pool')]: 'Production Pool' })
})
it('leaves the pool row empty when the pool is unknown', () => {
expect(findCardLabelledValues(mountInfoCard())).toMatchObject({ [t('pool')]: '' })
})
it('shows the name of the host running the VM', () => {
getVmHost.mockReturnValue(createHost({ name_label: 'Primary Host', power_state: HOST_POWER_STATE.RUNNING }))
expect(findCardLabelledValues(mountInfoCard())).toMatchObject({ [t('host')]: 'Primary Host' })
})
it('leaves the host row empty when the VM runs on no host', () => {
expect(findCardLabelledValues(mountInfoCard())).toMatchObject({ [t('host')]: '' })
})
it('shows the name of the template the VM was created from', () => {
useXoVmTemplateCollectionMock.mockReturnValue({ templates: computed(() => [createTemplate()]) })
const wrapper = mountInfoCard(
createVm({
$pool: 'pool-789' as FrontXoVm['$pool'],
creation: { template: 'template-uuid-1' } as FrontXoVm['creation'],
})
)
expect(findCardLabelledValues(wrapper)).toMatchObject({ [t('template')]: 'Debian 12' })
})
it('ignores a same-uuid template belonging to another pool', () => {
useXoVmTemplateCollectionMock.mockReturnValue({
templates: computed(() => [createTemplate({ $pool: 'pool-other' as FrontXoVmTemplate['$pool'] })]),
})
const wrapper = mountInfoCard(
createVm({
$pool: 'pool-789' as FrontXoVm['$pool'],
creation: { template: 'template-uuid-1' } as FrontXoVm['creation'],
other: { base_template_name: 'Debian 12 (removed)' },
})
)
expect(findCardLabelledValues(wrapper)).toMatchObject({ [t('template')]: 'Debian 12 (removed)' })
})
it('shows the name of the user who created the VM', () => {
useXoUserResourceMock.mockReturnValue({
user: computed(() => ({ id: 'user-1', name: 'alice', email: 'alice@example.com' }) as XoUser),
})
expect(findCardLabelledValues(mountInfoCard())).toMatchObject({ [t('created-by')]: 'alice' })
})
it('falls back to the email of the user who created the VM', () => {
useXoUserResourceMock.mockReturnValue({
user: computed(() => ({ id: 'user-1', name: '', email: 'alice@example.com' }) as XoUser),
})
expect(findCardLabelledValues(mountInfoCard())).toMatchObject({ [t('created-by')]: 'alice@example.com' })
})
it('falls back to "Unknown" when the creator of the VM is not known', () => {
expect(findCardLabelledValues(mountInfoCard())).toMatchObject({ [t('created-by')]: t('unknown') })
})

View File

@@ -0,0 +1,54 @@
import VmNetworkCard from '@/modules/vm/components/list/panel/cards/VmNetworkCard.vue'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { createVm } from '@/test/create-vm.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
function mountNetworkCard(vm: FrontXoVm = createVm()) {
return mount(VmNetworkCard, {
props: { vm },
global: createGlobalTestConfig(),
})
}
function findIpAddresses(wrapper: ReturnType<typeof mountNetworkCard>) {
return wrapper.findAll('.vts-card-row-key-value').map(row => row.get('.value').text())
}
it('renders the card title', () => {
const wrapper = mountNetworkCard(createVm({ addresses: undefined }))
expect(wrapper.get('.ui-card-title .title').text()).toBe(t('networks'))
})
it('lists the IP addresses of the VM sorted alphabetically', () => {
const wrapper = mountNetworkCard(createVm({ addresses: { '0/ipv4/0': '10.0.0.2', '0/ipv4/1': '10.0.0.1' } }))
expect(findIpAddresses(wrapper)).toEqual(['10.0.0.1', '10.0.0.2'])
})
it('labels only the first address row', () => {
const wrapper = mountNetworkCard(createVm({ addresses: { '0/ipv4/0': '10.0.0.1', '0/ipv4/1': '10.0.0.2' } }))
expect(wrapper.findAll('.vts-card-row-key-value').map(row => row.get('.key').text())).toEqual([t('ip-addresses'), ''])
})
it('shows an empty address row when the VM has no IP address', () => {
const wrapper = mountNetworkCard(createVm({ addresses: undefined }))
expect(wrapper.findAll('.vts-card-row-key-value').map(row => row.get('.key').text())).toEqual([t('ip-addresses')])
expect(findIpAddresses(wrapper)).toEqual([''])
})
it('links to the networks page when the VM has at least one IP address', () => {
const wrapper = mountNetworkCard(createVm({ addresses: { '0/ipv4/0': '10.0.0.1' } }))
expect(wrapper.get('.ui-card-title').text()).toContain(t('see-details'))
})
it('hides the networks page link when the VM has no IP address', () => {
const wrapper = mountNetworkCard(createVm({ addresses: undefined }))
expect(wrapper.get('.ui-card-title').text()).not.toContain(t('see-details'))
})

View File

@@ -0,0 +1,94 @@
import type { FrontXoVbd, useXoVbdCollection } from '@/modules/vbd/remote-resources/use-xo-vbd-collection.ts'
import type { useXoVdiCollection } from '@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'
import VmResourcesCard from '@/modules/vm/components/list/panel/cards/VmResourcesCard.vue'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { createVbd } from '@/test/create-vbd.ts'
import { createVdi } from '@/test/create-vdi.ts'
import { createVm } from '@/test/create-vm.ts'
import { findCardLabelledValues } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
const { getVbdsByIds } = vi.hoisted(() => ({
getVbdsByIds: vi.fn(),
}))
const { getVdiById } = vi.hoisted(() => ({
getVdiById: vi.fn(),
}))
vi.mock(import('@/modules/vbd/remote-resources/use-xo-vbd-collection.ts'), () => ({
useXoVbdCollection: (() => ({ getVbdsByIds })) as unknown as typeof useXoVbdCollection,
}))
vi.mock(import('@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'), () => ({
useXoVdiCollection: (() => ({ getVdiById })) as unknown as typeof useXoVdiCollection,
}))
beforeEach(() => {
getVbdsByIds.mockReset()
getVdiById.mockReset()
getVbdsByIds.mockReturnValue([])
getVdiById.mockReturnValue(undefined)
})
function mountResourcesCard(vm: FrontXoVm = createVm()) {
return mount(VmResourcesCard, {
props: { vm },
global: createGlobalTestConfig(),
})
}
function attachVdis(...sizes: number[]) {
const vbds = sizes.map((_, index) => createVbd({ VDI: `vdi-${index}` as FrontXoVbd['VDI'] }))
getVbdsByIds.mockReturnValue(vbds)
getVdiById.mockImplementation((id: string) => createVdi({ size: sizes[Number(id.split('-')[1])] }))
}
it('renders the card title', () => {
const wrapper = mountResourcesCard()
expect(wrapper.get('.ui-card-title .title').text()).toBe(t('resources'))
})
it('shows the vCPU count, the formatted RAM and the summed disk space of the VM', () => {
attachVdis(1073741824, 2147483648)
const wrapper = mountResourcesCard(
createVm({
CPUs: { number: 2, max: 4 },
memory: { size: 4294967296, dynamic: [0, 0], static: [0, 0] },
snapshots: ['snapshot-1'] as FrontXoVm['snapshots'],
})
)
expect(findCardLabelledValues(wrapper)).toEqual({
[t('vcpus')]: '2',
[t('ram')]: '4 GiB',
[t('disk-space')]: '3 GiB',
[t('vdis')]: '2',
[t('snapshots')]: '1',
})
})
it('ignores the VBDs whose VDI cannot be resolved when summing the disk space', () => {
getVbdsByIds.mockReturnValue([
createVbd({ VDI: 'vdi-a' as FrontXoVbd['VDI'] }),
createVbd({ VDI: 'vdi-missing' as FrontXoVbd['VDI'] }),
])
getVdiById.mockImplementation((id: string) => (id === 'vdi-a' ? createVdi({ size: 1073741824 }) : undefined))
expect(findCardLabelledValues(mountResourcesCard())).toMatchObject({ [t('disk-space')]: '1 GiB' })
})
it('leaves the VDI and snapshot counts empty when the VM has none', () => {
const wrapper = mountResourcesCard(createVm({ $VBDs: [], snapshots: [] }))
expect(findCardLabelledValues(wrapper)).toMatchObject({
[t('vdis')]: '',
[t('snapshots')]: '',
[t('disk-space')]: '0 B',
})
})

View File

@@ -0,0 +1,80 @@
import type { FrontXoVmSnapshot } from '@/modules/snapshot/components/remote-resources/use-xo-vm-snapshot-collection.ts'
import type { useXo5VmSnapshotRoute } from '@/modules/snapshot/composables/xo-vm-snapshot-route-xo5.composable.ts'
import VmSnapshotCard from '@/modules/vm/components/snapshot/cards/VmSnapshotCard.vue'
import { createVmSnapshot } from '@/test/create-vm-snapshot.ts'
import { findLabelledValues } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { d, t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
const { buildXo5VmSnapshotRoute } = vi.hoisted(() => ({
buildXo5VmSnapshotRoute: vi.fn(),
}))
vi.mock(import('@/modules/snapshot/composables/xo-vm-snapshot-route-xo5.composable.ts'), () => ({
useXo5VmSnapshotRoute: (() => ({ buildXo5VmSnapshotRoute })) as unknown as typeof useXo5VmSnapshotRoute,
}))
beforeEach(() => {
buildXo5VmSnapshotRoute.mockReset()
buildXo5VmSnapshotRoute.mockImplementation(
(vmId: string, snapshotId: string) => `https://xo5.example.com/#/vms/${vmId}/snapshots?s=1_0_asc-${snapshotId}`
)
})
function mountSnapshotCard(snapshot: FrontXoVmSnapshot | undefined, title = 'Oldest snapshot') {
return mount(VmSnapshotCard, {
props: { snapshot, title },
global: createGlobalTestConfig(),
})
}
it('renders the title it is given', () => {
const wrapper = mountSnapshotCard(createVmSnapshot(), 'Latest snapshot')
expect(wrapper.get('.ui-card-title').text()).toBe('Latest snapshot')
})
it('shows the name and the creation date of the snapshot', () => {
const snapshot = createVmSnapshot({ name_label: 'Before upgrade' })
const labelledValues = findLabelledValues(mountSnapshotCard(snapshot))
expect(labelledValues[t('snapshot')]).toBe('Before upgrade')
expect(labelledValues[t('snapshot-created-on')]).toBe(
d(snapshot.snapshot_time * 1000, { dateStyle: 'short', timeStyle: 'medium' })
)
})
it('links the snapshot to its XO 5 page', () => {
const snapshot = createVmSnapshot({
id: 'snapshot-42' as FrontXoVmSnapshot['id'],
$snapshot_of: 'vm-42' as FrontXoVmSnapshot['$snapshot_of'],
})
const wrapper = mountSnapshotCard(snapshot)
expect(wrapper.get('a').attributes('href')).toBe(
'https://xo5.example.com/#/vms/vm-42/snapshots?s=1_0_asc-snapshot-42'
)
})
it('offers to copy the snapshot name', () => {
const wrapper = mountSnapshotCard(createVmSnapshot({ name_label: 'Before upgrade' }))
expect(wrapper.find('.copy-button').exists()).toBe(true)
})
it('leaves both rows empty and offers no copy when there is no snapshot', () => {
const wrapper = mountSnapshotCard(undefined)
expect(findLabelledValues(wrapper)).toEqual({ [t('snapshot')]: '', [t('snapshot-created-on')]: '' })
expect(wrapper.find('.copy-button').exists()).toBe(false)
})
it('shows no name and no copy button for an unnamed snapshot', () => {
const wrapper = mountSnapshotCard(createVmSnapshot({ name_label: '' }))
expect(findLabelledValues(wrapper)[t('snapshot')]).toBe('')
expect(wrapper.find('.copy-button').exists()).toBe(false)
})

View File

@@ -0,0 +1,74 @@
import VmSystemGeneralInfo from '@/modules/vm/components/system/VmSystemGeneralInfo.vue'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { createVm } from '@/test/create-vm.ts'
import { findLabelledValues } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { VM_POWER_STATE } from '@vates/types'
import { mount } from '@vue/test-utils'
function mountGeneralInfo(vm: FrontXoVm = createVm()) {
return mount(VmSystemGeneralInfo, {
props: { vm },
global: createGlobalTestConfig(),
})
}
it('renders the card title', () => {
const wrapper = mountGeneralInfo()
expect(wrapper.get('.ui-title').text()).toBe(t('general-information'))
})
it('shows the identity, operating system and guest tools of the VM', () => {
const wrapper = mountGeneralInfo(
createVm({
name_label: 'Web server',
id: 'vm-42' as FrontXoVm['id'],
name_description: 'Serves the website',
tags: ['production'],
os_version: { name: 'Debian Bookworm', uname: '6.1.0-18-amd64' } as FrontXoVm['os_version'],
pvDriversVersion: '7.5.0',
power_state: VM_POWER_STATE.RUNNING,
managementAgentDetected: true,
pvDriversDetected: true,
pvDriversUpToDate: true,
})
)
expect(findLabelledValues(wrapper)).toEqual({
[t('name')]: 'Web server',
[t('id')]: 'vm-42',
[t('description')]: 'Serves the website',
[t('tags')]: 'production',
[t('os-name')]: 'Debian Bookworm',
[t('os-kernel')]: '6.1.0-18-amd64',
[t('management-agent-version')]: '7.5.0',
[t('guest-tools')]: '7.5.0',
})
})
it('leaves the operating system rows empty when the VM reports no OS version', () => {
const wrapper = mountGeneralInfo(createVm({ os_version: null }))
expect(findLabelledValues(wrapper)).toMatchObject({ [t('os-name')]: '', [t('os-kernel')]: '' })
})
it('renders one tag per VM tag', () => {
const wrapper = mountGeneralInfo(createVm({ tags: ['production', 'billing'] }))
expect(wrapper.findAll('.ui-tag').map(tag => tag.text())).toEqual(['production', 'billing'])
})
it('renders no tag when the VM has none', () => {
const wrapper = mountGeneralInfo(createVm({ tags: [] }))
expect(wrapper.find('.ui-tag').exists()).toBe(false)
expect(findLabelledValues(wrapper)).toMatchObject({ [t('tags')]: '' })
})
it('shows the guest tools as not applicable when the VM is not running', () => {
const wrapper = mountGeneralInfo(createVm({ power_state: VM_POWER_STATE.HALTED }))
expect(findLabelledValues(wrapper)).toMatchObject({ [t('guest-tools')]: '-' })
})

View File

@@ -0,0 +1,42 @@
import VmSystemGraphics from '@/modules/vm/components/system/VmSystemGraphics.vue'
import { createVm } from '@/test/create-vm.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
function mountGraphics(vm = createVm()) {
return mount(VmSystemGraphics, {
props: { vm },
global: createGlobalTestConfig(),
})
}
it('renders the card title', () => {
const wrapper = mountGraphics()
expect(wrapper.text()).toContain(t('graphics-display'))
})
it('shows VGA as enabled when the VM uses the "std" adapter', () => {
const wrapper = mountGraphics(createVm({ vga: 'std' }))
expect(wrapper.text()).toContain(t('enabled'))
})
it('shows VGA as disabled for any other adapter', () => {
const wrapper = mountGraphics(createVm({ vga: 'qxl' }))
expect(wrapper.text()).toContain(t('disabled'))
})
it('formats the video RAM with its unit', () => {
const wrapper = mountGraphics(createVm({ videoram: 8 }))
expect(wrapper.text()).toContain('8 B')
})
it('omits the video RAM value when the VM has none', () => {
const wrapper = mountGraphics(createVm({ videoram: 0 }))
expect(wrapper.text()).not.toMatch(/\d+\s*(B|KiB|MiB)/)
})

View File

@@ -0,0 +1,125 @@
import type { FrontXoHost, useXoHostCollection } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import VmSystemManagement from '@/modules/vm/components/system/VmSystemManagement.vue'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { createHost } from '@/test/create-host.ts'
import { createVm } from '@/test/create-vm.ts'
import { findLabelledValues } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { VM_OPERATIONS } from '@vates/types'
import { mount } from '@vue/test-utils'
const { getHostById } = vi.hoisted(() => ({
getHostById: vi.fn(),
}))
vi.mock(import('@/modules/host/remote-resources/use-xo-host-collection.ts'), () => ({
useXoHostCollection: (() => ({ getHostById })) as unknown as typeof useXoHostCollection,
}))
beforeEach(() => {
getHostById.mockReset()
getHostById.mockReturnValue(undefined)
})
function mountManagement(vm: FrontXoVm = createVm()) {
return mount(VmSystemManagement, {
props: { vm },
global: createGlobalTestConfig(),
})
}
it('renders the card title', () => {
const wrapper = mountManagement()
expect(wrapper.get('.ui-title').text()).toBe(t('vm-management'))
})
it('shows high availability as enabled when the VM has a restart priority', () => {
const wrapper = mountManagement(createVm({ high_availability: 'restart' }))
expect(findLabelledValues(wrapper)).toMatchObject({ [t('high-availability')]: t('enabled') })
})
it('shows high availability as disabled when the VM has no restart priority', () => {
const wrapper = mountManagement(createVm({ high_availability: '' }))
expect(findLabelledValues(wrapper)).toMatchObject({ [t('high-availability')]: t('disabled') })
})
it('shows the name of the affinity host', () => {
getHostById.mockReturnValue(createHost({ name_label: 'Primary Host' }))
const wrapper = mountManagement(createVm({ affinityHost: 'host-1' as FrontXoHost['id'] }))
expect(findLabelledValues(wrapper)).toMatchObject({ [t('affinity-host')]: 'Primary Host' })
})
it('falls back to "None" when the VM has no affinity host', () => {
const wrapper = mountManagement(createVm({ affinityHost: undefined }))
expect(findLabelledValues(wrapper)).toMatchObject({ [t('affinity-host')]: t('none') })
})
it('shows the VM as protected from accidental deletion when destroy is blocked', () => {
const wrapper = mountManagement(
createVm({ blockedOperations: { [VM_OPERATIONS.DESTROY]: 'true' } as FrontXoVm['blockedOperations'] })
)
expect(findLabelledValues(wrapper)).toMatchObject({
[t('protect-from-accidental-deletion')]: t('enabled'),
[t('protect-from-accidental-shutdown')]: t('disabled'),
})
})
it('shows the VM as protected from accidental shutdown when any shutdown operation is blocked', () => {
const wrapper = mountManagement(
createVm({
blockedOperations: { [VM_OPERATIONS.HARD_SHUTDOWN]: 'true' } as FrontXoVm['blockedOperations'],
})
)
expect(findLabelledValues(wrapper)).toMatchObject({
[t('protect-from-accidental-deletion')]: t('disabled'),
[t('protect-from-accidental-shutdown')]: t('enabled'),
})
})
it('shows both protections as disabled when no operation is blocked', () => {
const wrapper = mountManagement(createVm({ blockedOperations: {} as FrontXoVm['blockedOperations'] }))
expect(findLabelledValues(wrapper)).toMatchObject({
[t('protect-from-accidental-deletion')]: t('disabled'),
[t('protect-from-accidental-shutdown')]: t('disabled'),
})
})
it('shows whether the VM starts automatically with its pool', () => {
const wrapper = mountManagement(createVm({ auto_poweron: true }))
expect(findLabelledValues(wrapper)).toMatchObject({ [t('auto-power')]: t('enabled') })
})
describe('start delay', () => {
function mountStartDelay(startDelay: number) {
return findLabelledValues(mountManagement(createVm({ startDelay })))[t('start-delay')]
}
it('spells out every non-zero unit of the delay', () => {
expect(mountStartDelay(2 * 86_400 + 3 * 3_600 + 4 * 60 + 5)).toBe(
`${t('relative-time:day', 2)} ${t('relative-time:hour', 3)} ${t('relative-time:minute', 4)} ${t('relative-time:second', 5)}`
)
})
it('omits the units the delay does not reach', () => {
expect(mountStartDelay(3_600 + 30)).toBe(`${t('relative-time:hour', 1)} ${t('relative-time:second', 30)}`)
})
it('uses the singular wording for a delay of a single unit', () => {
expect(mountStartDelay(86_400)).toBe(t('relative-time:day', 1))
})
it('falls back to zero seconds when the VM has no start delay', () => {
expect(mountStartDelay(0)).toBe(t('relative-time:second', 0))
})
})

View File

@@ -0,0 +1,32 @@
import VmSystemNetworking from '@/modules/vm/components/system/VmSystemNetworking.vue'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { createVm } from '@/test/create-vm.ts'
import { findLabelledValues } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
function mountNetworking(vm: FrontXoVm = createVm()) {
return mount(VmSystemNetworking, {
props: { vm },
global: createGlobalTestConfig(),
})
}
it('renders the card title', () => {
const wrapper = mountNetworking()
expect(wrapper.get('.ui-title').text()).toBe(t('networking'))
})
it('shows the NIC type of the VM', () => {
const wrapper = mountNetworking(createVm({ nicType: 'e1000' }))
expect(findLabelledValues(wrapper)).toEqual({ [t('nic-type')]: 'e1000' })
})
it('leaves the NIC type empty when the VM has none', () => {
const wrapper = mountNetworking(createVm({ nicType: undefined }))
expect(findLabelledValues(wrapper)).toEqual({ [t('nic-type')]: '' })
})

View File

@@ -0,0 +1,79 @@
import VmSystemResource from '@/modules/vm/components/system/VmSystemResource.vue'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { createVm } from '@/test/create-vm.ts'
import { findLabelledValues } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
function mountResource(vm: FrontXoVm = createVm()) {
return mount(VmSystemResource, {
props: { vm },
global: createGlobalTestConfig(),
})
}
it('renders the card title', () => {
const wrapper = mountResource()
expect(wrapper.get('.ui-title').text()).toBe(t('resource-management'))
})
it('shows every CPU, memory and GPU resource of the VM', () => {
const wrapper = mountResource(
createVm({
cpuCap: 50,
cpuMask: [0, 2],
cpuWeight: 512,
CPUs: { number: 2, max: 4 },
coresPerSocket: 2,
memory: {
size: 4294967296,
static: [1073741824, 4294967296],
dynamic: [2147483648, 3221225472],
},
VGPUs: ['vgpu-1', 'vgpu-2'] as FrontXoVm['VGPUs'],
})
)
expect(findLabelledValues(wrapper)).toEqual({
[t('cpu-cap')]: '50',
[t('cpu-mask')]: '0, 2',
[t('cpu-weight')]: '512',
[t('vcpu-at-startup')]: t('n-vcpus', 2),
[t('maximum-vcpu')]: t('n-vcpus', 4),
[t('vm-limit-topology')]: t('sockets-with-cores-per-socket', { nSockets: 2, nCores: 2 }),
[t('minimum-static-memory')]: '1 GiB',
[t('maximum-static-memory')]: '4 GiB',
[t('minimum-dynamic-memory')]: '2 GiB',
[t('maximum-dynamic-memory')]: '3 GiB',
[t('gpus')]: 'vgpu-1, vgpu-2',
})
})
it('leaves the CPU cap and weight empty when they are not set', () => {
const wrapper = mountResource(createVm({ cpuCap: 0, cpuWeight: 0, cpuMask: [] }))
expect(findLabelledValues(wrapper)).toMatchObject({ [t('cpu-cap')]: '', [t('cpu-weight')]: '', [t('cpu-mask')]: '' })
})
it('falls back to the default behavior when the VM has no cores-per-socket topology', () => {
const wrapper = mountResource(createVm({ coresPerSocket: undefined }))
expect(findLabelledValues(wrapper)).toMatchObject({ [t('vm-limit-topology')]: t('default-behavior') })
})
it('falls back to "None" when the VM has no GPU', () => {
const wrapper = mountResource(createVm({ VGPUs: [] }))
expect(findLabelledValues(wrapper)).toMatchObject({ [t('gpus')]: t('none') })
})
it('uses the singular vCPU wording for a single vCPU', () => {
const wrapper = mountResource(createVm({ CPUs: { number: 1, max: 1 } }))
expect(findLabelledValues(wrapper)).toMatchObject({
[t('vcpu-at-startup')]: t('n-vcpus', 1),
[t('maximum-vcpu')]: t('n-vcpus', 1),
})
})

View File

@@ -0,0 +1,32 @@
import VmSystemStorageConfig from '@/modules/vm/components/system/VmSystemStorageConfig.vue'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { createVm } from '@/test/create-vm.ts'
import { findLabelledValues } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
function mountStorageConfig(vm: FrontXoVm = createVm()) {
return mount(VmSystemStorageConfig, {
props: { vm },
global: createGlobalTestConfig(),
})
}
it('renders the card title', () => {
const wrapper = mountStorageConfig()
expect(wrapper.get('.ui-title').text()).toBe(t('storage-configuration'))
})
it('shows the suspend storage repository of the VM', () => {
const wrapper = mountStorageConfig(createVm({ suspendSr: 'sr-1' as FrontXoVm['suspendSr'] }))
expect(findLabelledValues(wrapper)).toEqual({ [t('suspend-storage-repository')]: 'sr-1' })
})
it('falls back to "None" when the VM has no suspend storage repository', () => {
const wrapper = mountStorageConfig(createVm({ suspendSr: undefined }))
expect(findLabelledValues(wrapper)).toEqual({ [t('suspend-storage-repository')]: t('none') })
})

View File

@@ -0,0 +1,68 @@
import VmSystemVirtualizationAndBoot from '@/modules/vm/components/system/VmSystemVirtualizationAndBoot.vue'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { createVm } from '@/test/create-vm.ts'
import { findLabelledValues } from '@/test/find-labelled-values.ts'
import { createGlobalTestConfig } from '@/test/global-test-config.ts'
import { t } from '@/test/i18n.ts'
import { mount } from '@vue/test-utils'
function mountVirtualizationAndBoot(vm: FrontXoVm = createVm()) {
return mount(VmSystemVirtualizationAndBoot, {
props: { vm },
global: createGlobalTestConfig(),
})
}
it('renders the card title', () => {
const wrapper = mountVirtualizationAndBoot()
expect(wrapper.get('.ui-title').text()).toBe(t('virtualization-boot-settings'))
})
it('shows every enabled virtualization and boot setting', () => {
const wrapper = mountVirtualizationAndBoot(
createVm({
virtualizationMode: 'pv',
secureBoot: true,
boot: { firmware: 'uefi' } as FrontXoVm['boot'],
VTPMs: ['vtpm-1', 'vtpm-2'] as FrontXoVm['VTPMs'],
viridian: true,
hasVendorDevice: true,
isNestedVirtEnabled: true,
})
)
expect(findLabelledValues(wrapper)).toEqual({
[t('virtualization-mode')]: 'pv',
[t('secure-boot')]: t('enabled'),
[t('boot-firmware')]: 'uefi',
[t('virtual-tpm')]: 'vtpm-1, vtpm-2',
[t('viridian')]: t('enabled'),
[t('manage-citrix-pv-drivers-via-windows-update')]: t('enabled'),
[t('nested-virtualization')]: t('enabled'),
})
})
it('shows every disabled setting and falls back for the missing values', () => {
const wrapper = mountVirtualizationAndBoot(
createVm({
virtualizationMode: 'hvm',
secureBoot: false,
boot: {} as FrontXoVm['boot'],
VTPMs: [],
viridian: false,
hasVendorDevice: false,
isNestedVirtEnabled: false,
})
)
expect(findLabelledValues(wrapper)).toEqual({
[t('virtualization-mode')]: 'hvm',
[t('secure-boot')]: t('disabled'),
[t('boot-firmware')]: '',
[t('virtual-tpm')]: t('none'),
[t('viridian')]: t('disabled'),
[t('manage-citrix-pv-drivers-via-windows-update')]: t('disabled'),
[t('nested-virtualization')]: t('disabled'),
})
})

View File

@@ -0,0 +1,123 @@
import type { FrontXoVbd, useXoVbdCollection } from '@/modules/vbd/remote-resources/use-xo-vbd-collection.ts'
import type { useXoVdiCollection } from '@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'
import { useVmEnhancedData } from '@/modules/vm/composables/use-vm-enhanced-data.composable.ts'
import { createEnhancedDataHelpers } from '@/test/create-enhanced-data-helpers.ts'
import { createVbd } from '@/test/create-vbd.ts'
import { createVdi } from '@/test/create-vdi.ts'
import { createVm } from '@/test/create-vm.ts'
import { objectIcon } from '@core/icons'
import { formatSizeRaw } from '@core/utils/size.util.ts'
import { VM_POWER_STATE } from '@vates/types'
import { ref } from 'vue'
const { getVbdsByIds } = vi.hoisted(() => ({
getVbdsByIds: vi.fn(),
}))
const { getVdiById } = vi.hoisted(() => ({
getVdiById: vi.fn(),
}))
vi.mock(import('@/modules/vbd/remote-resources/use-xo-vbd-collection.ts'), () => ({
useXoVbdCollection: (() => ({ getVbdsByIds })) as unknown as typeof useXoVbdCollection,
}))
vi.mock(import('@/modules/vdi/remote-resources/use-xo-vdi-collection.ts'), () => ({
useXoVdiCollection: (() => ({ getVdiById })) as unknown as typeof useXoVdiCollection,
}))
beforeEach(() => {
getVbdsByIds.mockReset()
getVdiById.mockReset()
getVbdsByIds.mockReturnValue([])
getVdiById.mockReturnValue(undefined)
})
const { mountEnhancedData, mountFirstFilterable, mountFirstDisplayData } = createEnhancedDataHelpers(
useVmEnhancedData,
result => result.filterableVms,
createVm
)
describe('filterableVms', () => {
it('exposes the raw RAM size from the VM memory', () => {
const filterableVm = mountFirstFilterable([
createVm({ memory: { size: 2147483648, dynamic: [0, 0], static: [0, 0] } }),
])
expect(filterableVm.ramSize).toBe(2147483648)
})
it('sums the disk space from each VBD-linked VDI', () => {
getVbdsByIds.mockReturnValue([
createVbd({ VDI: 'vdi-a' as FrontXoVbd['VDI'] }),
createVbd({ VDI: 'vdi-b' as FrontXoVbd['VDI'] }),
])
getVdiById.mockImplementation((id: string) => createVdi({ size: id === 'vdi-a' ? 1000 : 2000 }))
const filterableVm = mountFirstFilterable()
expect(filterableVm.diskSpaceSize).toBe(3000)
})
it('ignores VBDs whose VDI cannot be resolved', () => {
getVbdsByIds.mockReturnValue([
createVbd({ VDI: 'vdi-a' as FrontXoVbd['VDI'] }),
createVbd({ VDI: 'vdi-missing' as FrontXoVbd['VDI'] }),
])
getVdiById.mockImplementation((id: string) => (id === 'vdi-a' ? createVdi({ size: 1500 }) : undefined))
const filterableVm = mountFirstFilterable()
expect(filterableVm.diskSpaceSize).toBe(1500)
})
it('exposes the sorted IP addresses of the VM', () => {
const filterableVm = mountFirstFilterable([
createVm({ addresses: { '0/ipv4/0': '10.0.0.2', '0/ipv4/1': '10.0.0.1' } }),
])
expect(filterableVm.ipAddresses).toEqual(['10.0.0.1', '10.0.0.2'])
})
it('recomputes when the source VMs change', () => {
const vms = ref([createVm({ memory: { size: 1073741824, dynamic: [0, 0], static: [0, 0] } })])
const result = mountEnhancedData(vms)
expect(result.filterableVms[0].ramSize).toBe(1073741824)
vms.value = [createVm({ memory: { size: 4294967296, dynamic: [0, 0], static: [0, 0] } })]
expect(result.filterableVms[0].ramSize).toBe(4294967296)
})
})
describe('getDisplayData', () => {
it('formats the raw RAM size for display', () => {
const size = 2147483648
const displayData = mountFirstDisplayData([createVm({ memory: { size, dynamic: [0, 0], static: [0, 0] } })])
expect(displayData.formattedRam).toEqual(formatSizeRaw(size, 1))
})
it('formats the summed disk space for display', () => {
getVbdsByIds.mockReturnValue([createVbd({ VDI: 'vdi-a' as FrontXoVbd['VDI'] })])
getVdiById.mockReturnValue(createVdi({ size: 5000 }))
const displayData = mountFirstDisplayData()
expect(displayData.formattedDiskSpace).toEqual(formatSizeRaw(5000, 1))
})
it('derives the VM icon from the lowercased power state', () => {
const displayData = mountFirstDisplayData([createVm({ power_state: VM_POWER_STATE.RUNNING })])
expect(displayData.vmIcon).toBe(objectIcon('vm', 'running'))
})
it('maps the VM icon to the halted state when the VM is halted', () => {
const displayData = mountFirstDisplayData([createVm({ power_state: VM_POWER_STATE.HALTED })])
expect(displayData.vmIcon).toBe(objectIcon('vm', 'halted'))
})
})

View File

@@ -0,0 +1,254 @@
import { useXoVmUtils } from '@/modules/vm/composables/xo-vm-utils.composable.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import type { useXoRoutes } from '@/shared/remote-resources/use-xo-routes.ts'
import { createVm } from '@/test/create-vm.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { getRelativeTime } from '@core/composables/relative-time.composable.ts'
import { parseDateTime } from '@core/utils/time.util.ts'
import { VM_OPERATIONS, VM_POWER_STATE } from '@vates/types'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { buildXo5Route } = vi.hoisted(() => ({
buildXo5Route: vi.fn(),
}))
vi.mock(import('@/shared/remote-resources/use-xo-routes.ts'), () => ({
useXoRoutes: (() => ({ buildXo5Route })) as unknown as typeof useXoRoutes,
}))
beforeEach(() => {
buildXo5Route.mockReset()
})
function mountVmUtils(overrides: Partial<FrontXoVm> = {}) {
return mountComposable(() => {
const { t } = useI18n()
return { ...useXoVmUtils(createVm(overrides)), t }
}).wrapper.vm
}
describe('powerState', () => {
it('maps the VM power state to an icon and a translated label', () => {
const result = mountVmUtils({ power_state: VM_POWER_STATE.RUNNING })
expect(result.powerState.icon).toBe('status:running-circle')
expect(result.powerState.text).toBe(result.t('vm:status:running'))
})
it('reacts to changes of the source VM', () => {
const vm = ref(createVm({ power_state: VM_POWER_STATE.RUNNING }))
const { wrapper } = mountComposable(() => {
const { t } = useI18n()
return { ...useXoVmUtils(vm), t }
})
expect(wrapper.vm.powerState.icon).toBe('status:running-circle')
vm.value = createVm({ power_state: VM_POWER_STATE.HALTED })
expect(wrapper.vm.powerState.icon).toBe('status:halted-circle')
expect(wrapper.vm.powerState.text).toBe(wrapper.vm.t('vm:status:halted'))
})
})
describe('relativeStartTime', () => {
it('returns the "not running" label when the VM is halted', () => {
const result = mountVmUtils({ power_state: VM_POWER_STATE.HALTED })
expect(result.relativeStartTime).toBe(result.t('not-running'))
})
it('reports the start time relative to now, reading it as seconds', () => {
const startTimeInSeconds = 1660000000
const { wrapper } = mountComposable(() => {
const { locale } = useI18n()
const { relativeStartTime } = useXoVmUtils(
createVm({ power_state: VM_POWER_STATE.RUNNING, startTime: startTimeInSeconds })
)
return {
relativeStartTime,
expectedRelativeTime: getRelativeTime(new Date(startTimeInSeconds * 1000), locale.value),
}
})
expect(wrapper.vm.relativeStartTime).toBe(wrapper.vm.expectedRelativeTime)
})
})
describe('installDateFormatted', () => {
it('returns the "unknown" label when there is no install time', () => {
const result = mountVmUtils({ installTime: undefined })
expect(result.installDateFormatted).toBe(result.t('unknown'))
})
it('formats the install date with the current locale', () => {
const installTime = 1660000000
const { wrapper } = mountComposable(() => {
const { locale, t } = useI18n()
const { installDateFormatted } = useXoVmUtils(createVm({ installTime }))
const expected = new Intl.DateTimeFormat(locale.value, { dateStyle: 'long' }).format(
new Date(parseDateTime(installTime * 1000))
)
return { installDateFormatted, expected, t }
})
expect(wrapper.vm.installDateFormatted).toBe(wrapper.vm.expected)
})
})
describe('hasGuestTools', () => {
it('requires both management agent and PV drivers to be detected', () => {
const result = mountVmUtils()
expect(result.hasGuestTools(createVm({ managementAgentDetected: true, pvDriversDetected: true }))).toBe(true)
expect(result.hasGuestTools(createVm({ managementAgentDetected: false, pvDriversDetected: true }))).toBe(false)
expect(result.hasGuestTools(createVm({ managementAgentDetected: true, pvDriversDetected: false }))).toBe(false)
})
})
describe('guestToolsDisplay', () => {
it('is not applicable when the VM is not running', () => {
const result = mountVmUtils({ power_state: VM_POWER_STATE.HALTED })
expect(result.guestToolsDisplay).toEqual({ type: 'not-applicable', value: '-' })
})
it('offers to install guest tools when the management agent is not detected', () => {
const result = mountVmUtils({
power_state: VM_POWER_STATE.RUNNING,
managementAgentDetected: false,
pvDriversDetected: false,
})
expect(result.guestToolsDisplay).toEqual({
type: 'missing',
icon: 'status:halted-circle',
value: 'Install guest tools',
tooltip: 'Management agent not detected',
})
})
it('offers to install guest tools when only the PV drivers are not detected', () => {
const result = mountVmUtils({
power_state: VM_POWER_STATE.RUNNING,
managementAgentDetected: true,
pvDriversDetected: false,
})
expect(result.guestToolsDisplay).toMatchObject({ type: 'missing', tooltip: result.t('no-xen-tools-detected') })
})
it('reports up-to-date guest tools with their version', () => {
const result = mountVmUtils({
power_state: VM_POWER_STATE.RUNNING,
managementAgentDetected: true,
pvDriversDetected: true,
pvDriversUpToDate: true,
pvDriversVersion: '7.5.0',
})
expect(result.guestToolsDisplay).toEqual({
type: 'up-to-date',
icon: 'status:success-circle',
value: '7.5.0',
tooltip: 'Installed',
})
})
it('reports outdated guest tools', () => {
const result = mountVmUtils({
power_state: VM_POWER_STATE.RUNNING,
managementAgentDetected: true,
pvDriversDetected: true,
pvDriversUpToDate: false,
pvDriversVersion: '7.5.0',
})
expect(result.guestToolsDisplay).toEqual({
type: 'outdated',
icon: 'status:warning-circle',
value: '7.5.0',
tooltip: 'Guest tools out of date',
})
})
it('reports an unknown guest tools status when the PV drivers state is not reported', () => {
const result = mountVmUtils({
power_state: VM_POWER_STATE.RUNNING,
managementAgentDetected: true,
pvDriversDetected: true,
pvDriversUpToDate: undefined,
pvDriversVersion: '7.5.0',
})
expect(result.guestToolsDisplay).toEqual({
type: 'unknown',
icon: 'status:info-circle',
value: '7.5.0',
tooltip: 'Guest tools status unknown',
})
})
it('falls back to the "installed" label when the PV drivers version is unknown', () => {
const result = mountVmUtils({
power_state: VM_POWER_STATE.RUNNING,
managementAgentDetected: true,
pvDriversDetected: true,
pvDriversUpToDate: true,
pvDriversVersion: '',
})
expect(result.guestToolsDisplay).toMatchObject({ type: 'up-to-date', value: result.t('installed') })
})
})
describe('isChangingState', () => {
it('is false when no state-changing operation is pending', () => {
const result = mountVmUtils({ current_operations: {} })
expect(result.isChangingState).toBe(false)
})
it('is true while a state-changing operation is pending', () => {
const result = mountVmUtils({ current_operations: { task1: VM_OPERATIONS.CLEAN_REBOOT } })
expect(result.isChangingState).toBe(true)
})
})
describe('currentOperation', () => {
it('maps the pending operation to a translated label', () => {
const result = mountVmUtils({ current_operations: { task1: VM_OPERATIONS.SNAPSHOT } })
expect(result.currentOperation).toBe(result.t('operation:snapshot'))
})
it('falls back to an empty label for an unknown operation', () => {
const result = mountVmUtils({ current_operations: { task1: 'some_unknown_op' as VM_OPERATIONS } })
expect(result.currentOperation).toBe('')
})
it('falls back to an empty label when there is no pending operation', () => {
const result = mountVmUtils({ current_operations: {} })
expect(result.currentOperation).toBe('')
})
})
describe('xo5VmAdvancedHref', () => {
it('builds the XO5 advanced route for the VM id', () => {
buildXo5Route.mockImplementation((path: string) => `https://xo5.example.com/#${path}`)
const result = mountVmUtils({ id: 'vm-42' as FrontXoVm['id'] })
expect(result.xo5VmAdvancedHref).toBe('https://xo5.example.com/#/vms/vm-42/advanced')
})
})

View File

@@ -0,0 +1,45 @@
import type { useXoVbdCollection } from '@/modules/vbd/remote-resources/use-xo-vbd-collection.ts'
import { useXoVmVbdsUtils } from '@/modules/vm/composables/xo-vm-vbd-utils.composable.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { createVbd } from '@/test/create-vbd.ts'
import { createVm } from '@/test/create-vm.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { ref } from 'vue'
const { getVbdsByIds } = vi.hoisted(() => ({
getVbdsByIds: vi.fn(),
}))
vi.mock(import('@/modules/vbd/remote-resources/use-xo-vbd-collection.ts'), () => ({
useXoVbdCollection: (() => ({ getVbdsByIds })) as unknown as typeof useXoVbdCollection,
}))
beforeEach(() => {
getVbdsByIds.mockReset()
})
describe('notCdDriveVbds', () => {
it('filters out the CD-drive VBDs', () => {
getVbdsByIds.mockReturnValue([createVbd({ is_cd_drive: false }), createVbd({ is_cd_drive: true })])
const { wrapper } = mountComposable(() => useXoVmVbdsUtils(createVm()))
expect(wrapper.vm.notCdDriveVbds).toHaveLength(1)
expect(wrapper.vm.notCdDriveVbds[0].is_cd_drive).toBe(false)
})
it('reacts to changes of the source VM', () => {
getVbdsByIds.mockImplementation((ids: string[]) =>
ids.includes('vbd-cd') ? [createVbd({ is_cd_drive: true })] : [createVbd({ is_cd_drive: false })]
)
const vm = ref(createVm({ $VBDs: ['vbd-disk'] as FrontXoVm['$VBDs'] }))
const { wrapper } = mountComposable(() => useXoVmVbdsUtils(vm))
expect(wrapper.vm.notCdDriveVbds).toHaveLength(1)
vm.value = createVm({ $VBDs: ['vbd-cd'] as FrontXoVm['$VBDs'] })
expect(wrapper.vm.notCdDriveVbds).toHaveLength(0)
})
})

View File

@@ -0,0 +1,189 @@
import type { useXoPbdCollection } from '@/modules/pbd/remote-resources/use-xo-pbd-collection.ts'
import type { useXoSrUtils } from '@/modules/storage-repository/composables/xo-sr-utils.composable.ts'
import type {
FrontXoSr,
useXoSrCollection,
} from '@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'
import { useDuplicateVmForm } from '@/modules/vm/form/use-duplicate-vm-form.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { createSr } from '@/test/create-sr.ts'
import { createVm } from '@/test/create-vm.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { VM_POWER_STATE } from '@vates/types'
import { computed, nextTick, ref } from 'vue'
const { useXoSrCollectionMock, useXoPbdCollectionMock, getSrLocation } = vi.hoisted(() => ({
useXoSrCollectionMock: vi.fn(),
useXoPbdCollectionMock: vi.fn(),
getSrLocation: vi.fn(),
}))
vi.mock(import('@/modules/storage-repository/remote-resources/use-xo-sr-collection.ts'), () => ({
useXoSrCollection: useXoSrCollectionMock as unknown as typeof useXoSrCollection,
}))
vi.mock(import('@/modules/pbd/remote-resources/use-xo-pbd-collection.ts'), () => ({
useXoPbdCollection: useXoPbdCollectionMock as unknown as typeof useXoPbdCollection,
}))
vi.mock(import('@/modules/storage-repository/composables/xo-sr-utils.composable.ts'), () => ({
useXoSrUtils: (() => ({ getSrLocation })) as unknown as typeof useXoSrUtils,
}))
beforeEach(() => {
useXoSrCollectionMock.mockReset()
useXoPbdCollectionMock.mockReset()
getSrLocation.mockReset()
useXoSrCollectionMock.mockReturnValue({ srs: computed(() => []) })
useXoPbdCollectionMock.mockReturnValue({ pbdsBySr: computed(() => new Map()) })
getSrLocation.mockReturnValue('Local')
})
function mountDuplicateVmForm(vm: FrontXoVm = createVm()) {
return mountComposable(() => useDuplicateVmForm(vm)).wrapper.vm
}
describe('formData', () => {
it('suggests a copy name derived from the VM name', () => {
const result = mountDuplicateVmForm(createVm({ name_label: 'Web server' }))
expect(result.formData.name).toBe('Web server_COPY')
})
it('defaults to a fast clone for a halted VM', () => {
const result = mountDuplicateVmForm(createVm({ power_state: VM_POWER_STATE.HALTED }))
expect(result.formData.copyMode).toBe('fastClone')
})
it('defaults to a full copy for a running VM', () => {
const result = mountDuplicateVmForm(createVm({ power_state: VM_POWER_STATE.RUNNING }))
expect(result.formData.copyMode).toBe('fullCopy')
})
it('defaults to no compression', () => {
const result = mountDuplicateVmForm()
expect(result.formData.compressionMode).toBe('disabled')
})
it('switches a fast clone back to a full copy when the VM stops being halted', async () => {
const vm = ref(createVm({ power_state: VM_POWER_STATE.HALTED }))
const { wrapper } = mountComposable(() => useDuplicateVmForm(vm))
expect(wrapper.vm.formData.copyMode).toBe('fastClone')
vm.value = createVm({ power_state: VM_POWER_STATE.RUNNING })
await nextTick()
expect(wrapper.vm.formData.copyMode).toBe('fullCopy')
})
})
describe('copyModeOptions', () => {
it('allows a fast clone only for a halted VM', () => {
const result = mountDuplicateVmForm(createVm({ power_state: VM_POWER_STATE.HALTED }))
expect(result.copyModeOptions).toEqual([
{ label: 'Fast clone', value: 'fastClone', disabled: false },
{ label: 'Full copy', value: 'fullCopy' },
])
})
it('disables the fast clone for a running VM', () => {
const result = mountDuplicateVmForm(createVm({ power_state: VM_POWER_STATE.RUNNING }))
expect(result.copyModeOptions).toMatchObject([{ value: 'fastClone', disabled: true }, { value: 'fullCopy' }])
})
})
describe('compressionModeOptions', () => {
it('offers no compression, GZIP and ZSTD', () => {
const result = mountDuplicateVmForm()
expect(result.compressionModeOptions).toEqual([
{ label: 'Disabled', value: 'disabled' },
{ label: 'GZIP', value: 'gzip' },
{ label: 'ZSTD', value: 'zstd' },
])
})
})
describe('isCrossPool', () => {
it('is true when the selected SR belongs to another pool than the VM', () => {
const result = mountDuplicateVmForm(createVm({ $pool: 'pool-789' as FrontXoVm['$pool'] }))
result.formData.sr = createSr({ $pool: 'pool-other' as FrontXoSr['$pool'] })
expect(result.isCrossPool).toBe(true)
})
it('is false when the selected SR belongs to the pool of the VM', () => {
const result = mountDuplicateVmForm(createVm({ $pool: 'pool-789' as FrontXoVm['$pool'] }))
result.formData.sr = createSr({ $pool: 'pool-789' as FrontXoSr['$pool'] })
expect(result.isCrossPool).toBe(false)
})
it('is false while no SR is selected', () => {
const result = mountDuplicateVmForm()
expect(result.isCrossPool).toBe(false)
})
})
describe('validateAndBuildPayload', () => {
it('builds a fast-clone payload from the form name', async () => {
const result = mountDuplicateVmForm(createVm({ power_state: VM_POWER_STATE.HALTED }))
result.formData.name = 'Web server copy'
await expect(result.validateAndBuildPayload()).resolves.toEqual({ name_label: 'Web server copy', fast: true })
})
it('builds a full-copy payload targeting the selected SR', async () => {
const result = mountDuplicateVmForm()
result.formData.name = 'Web server copy'
result.formData.sr = createSr({ id: 'sr-42' as FrontXoSr['id'] })
await expect(result.validateAndBuildPayload()).resolves.toEqual({
name_label: 'Web server copy',
srId: 'sr-42',
})
})
it('adds the compression to a full-copy payload when one is selected', async () => {
const result = mountDuplicateVmForm()
result.formData.name = 'Web server copy'
result.formData.sr = createSr({ id: 'sr-42' as FrontXoSr['id'] })
result.formData.compressionMode = 'zstd'
await expect(result.validateAndBuildPayload()).resolves.toEqual({
name_label: 'Web server copy',
srId: 'sr-42',
compress: 'zstd',
})
})
it('builds no payload when the name is empty', async () => {
const result = mountDuplicateVmForm(createVm({ power_state: VM_POWER_STATE.HALTED }))
result.formData.name = ''
await expect(result.validateAndBuildPayload()).resolves.toBeUndefined()
})
it('builds no payload when a full copy has no target SR', async () => {
const result = mountDuplicateVmForm()
result.formData.name = 'Web server copy'
result.formData.sr = undefined
await expect(result.validateAndBuildPayload()).resolves.toBeUndefined()
})
})

View File

@@ -0,0 +1,151 @@
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import {
areAllVmsHavingPowerState,
areVmsOperationPending,
extractVmHostId,
getVmIpAddresses,
getVmsPendingOperation,
isVmOperationPending,
notAllVmsHavingPowerState,
} from '@/modules/vm/utils/xo-vm.util.ts'
import { createVm } from '@/test/create-vm.ts'
import { VM_OPERATIONS, VM_POWER_STATE } from '@vates/types'
describe('isVmOperationPending', () => {
it('matches a single operation', () => {
const vm = createVm({ current_operations: { 'task-1': VM_OPERATIONS.START } })
expect(isVmOperationPending(vm, VM_OPERATIONS.START)).toBe(true)
})
it('matches when given an array of operations', () => {
const vm = createVm({ current_operations: { 'task-1': VM_OPERATIONS.SNAPSHOT } })
expect(isVmOperationPending(vm, [VM_OPERATIONS.START, VM_OPERATIONS.SNAPSHOT])).toBe(true)
})
it('returns false when none of the current operations match', () => {
const vm = createVm({ current_operations: { 'task-1': VM_OPERATIONS.CLONE } })
expect(isVmOperationPending(vm, [VM_OPERATIONS.START, VM_OPERATIONS.SNAPSHOT])).toBe(false)
})
it('returns false when there are no current operations', () => {
const vm = createVm({ current_operations: {} })
expect(isVmOperationPending(vm, VM_OPERATIONS.START)).toBe(false)
})
})
describe('areVmsOperationPending', () => {
it('returns true when at least one VM has a pending operation', () => {
const idleVm = createVm({ current_operations: {} })
const snapshottingVm = createVm({ current_operations: { 'task-1': VM_OPERATIONS.SNAPSHOT } })
expect(areVmsOperationPending([idleVm, snapshottingVm], VM_OPERATIONS.SNAPSHOT)).toBe(true)
})
it('returns false when no VM has a pending operation', () => {
const idleVm = createVm({ current_operations: {} })
const otherVm = createVm({ current_operations: { 'task-1': VM_OPERATIONS.CLONE } })
expect(areVmsOperationPending([idleVm, otherVm], VM_OPERATIONS.SNAPSHOT)).toBe(false)
})
})
describe('getVmsPendingOperation', () => {
it('returns the first requested operation found among the VMs', () => {
const idleVm = createVm({ current_operations: {} })
const snapshottingVm = createVm({ current_operations: { 'task-1': VM_OPERATIONS.SNAPSHOT } })
expect(getVmsPendingOperation([idleVm, snapshottingVm], [VM_OPERATIONS.START, VM_OPERATIONS.SNAPSHOT])).toBe(
VM_OPERATIONS.SNAPSHOT
)
})
it('follows the requested order when a VM has several pending operations', () => {
const busyVm = createVm({ current_operations: { 'task-1': VM_OPERATIONS.SNAPSHOT, 'task-2': VM_OPERATIONS.CLONE } })
expect(getVmsPendingOperation([busyVm], [VM_OPERATIONS.CLONE, VM_OPERATIONS.SNAPSHOT])).toBe(VM_OPERATIONS.CLONE)
})
it('stops at the first VM having a pending operation', () => {
const cloningVm = createVm({ current_operations: { 'task-1': VM_OPERATIONS.CLONE } })
const snapshottingVm = createVm({ current_operations: { 'task-2': VM_OPERATIONS.SNAPSHOT } })
expect(getVmsPendingOperation([cloningVm, snapshottingVm], [VM_OPERATIONS.SNAPSHOT, VM_OPERATIONS.CLONE])).toBe(
VM_OPERATIONS.CLONE
)
})
it('returns undefined when no VM has any of the requested operations pending', () => {
const idleVm = createVm({ current_operations: {} })
expect(getVmsPendingOperation([idleVm], VM_OPERATIONS.SNAPSHOT)).toBeUndefined()
})
})
describe('areAllVmsHavingPowerState', () => {
it('returns true when every VM matches one of the power states', () => {
const runningVm = createVm({ power_state: VM_POWER_STATE.RUNNING })
const pausedVm = createVm({ power_state: VM_POWER_STATE.PAUSED })
expect(areAllVmsHavingPowerState([runningVm, pausedVm], [VM_POWER_STATE.RUNNING, VM_POWER_STATE.PAUSED])).toBe(true)
})
it('returns false when at least one VM does not match', () => {
const runningVm = createVm({ power_state: VM_POWER_STATE.RUNNING })
const haltedVm = createVm({ power_state: VM_POWER_STATE.HALTED })
expect(areAllVmsHavingPowerState([runningVm, haltedVm], [VM_POWER_STATE.RUNNING])).toBe(false)
})
})
describe('notAllVmsHavingPowerState', () => {
it('returns false when every VM matches one of the power states', () => {
const runningVm = createVm({ power_state: VM_POWER_STATE.RUNNING })
expect(notAllVmsHavingPowerState([runningVm], [VM_POWER_STATE.RUNNING])).toBe(false)
})
it('returns true when at least one VM does not match', () => {
const runningVm = createVm({ power_state: VM_POWER_STATE.RUNNING })
const haltedVm = createVm({ power_state: VM_POWER_STATE.HALTED })
expect(notAllVmsHavingPowerState([runningVm, haltedVm], [VM_POWER_STATE.RUNNING])).toBe(true)
})
})
describe('getVmIpAddresses', () => {
it('returns the addresses sorted alphabetically', () => {
const vm = createVm({ addresses: { '0/ipv4/0': '10.0.0.2', '0/ipv4/1': '10.0.0.1' } })
expect(getVmIpAddresses(vm)).toEqual(['10.0.0.1', '10.0.0.2'])
})
it('returns an empty array when the VM has no addresses', () => {
const vm = createVm({ addresses: undefined })
expect(getVmIpAddresses(vm)).toEqual([])
})
})
describe('extractVmHostId', () => {
it('returns the container id when the VM runs on a host', () => {
const vm = createVm({
$container: 'host-1' as FrontXoVm['$container'],
$pool: 'pool-1' as FrontXoVm['$pool'],
})
expect(extractVmHostId(vm)).toBe('host-1')
})
it('returns undefined when the container is the pool', () => {
const vm = createVm({
$container: 'pool-789' as FrontXoVm['$container'],
$pool: 'pool-789' as FrontXoVm['$pool'],
})
expect(extractVmHostId(vm)).toBeUndefined()
})
})

View File

@@ -0,0 +1,14 @@
import type { ValueFormatter } from '@core/types/chart.ts'
import { useI18n } from 'vue-i18n'
export function useChartPercentFormatter(): ValueFormatter {
const { n } = useI18n()
return value => {
if (value === null) {
return ''
}
return n(value / 100, 'percent')
}
}

View File

@@ -0,0 +1,13 @@
import { useChartPercentFormatter } from '@/shared/composables/chart-percent-formatter.composable.ts'
import { n } from '@/test/i18n.ts'
import { mountComposable } from '@/test/mount-composable.ts'
const mountFormatter = () => mountComposable(() => ({ format: useChartPercentFormatter() })).wrapper.vm.format
it('returns an empty string for a null value', () => {
expect(mountFormatter()(null)).toBe('')
})
it('formats a hundredth-of-a-percent value as a percentage', () => {
expect(mountFormatter()(4200)).toBe(n(42, 'percent'))
})

View File

@@ -0,0 +1,131 @@
import { ONE_GB } from '@/shared/constants.ts'
import { formatSizeRaw } from '@core/utils/size.util.ts'
export type ChartPoint = { timestamp: number; value: number }
export type PairedUsageSeries = [ChartPoint[], ChartPoint[]]
type StatValues = (number | null)[]
type StatValuesRecord = Record<string, StatValues>
type StatsWindow = { endTimestamp: number; interval: number }
type CpuStats = StatsWindow & { stats: { cpus?: StatValuesRecord } }
type MemorySamples = { memory?: StatValues; memoryFree?: StatValues }
type MemoryStats = StatsWindow & { stats: MemorySamples }
type ChartMaxOptions = { step: number; fallback: number; headroom?: number }
export const RAM_CHART_MAX_FALLBACK = ONE_GB
export function sumAtIndex(records: StatValuesRecord | undefined, index: number): number {
return Object.values(records ?? {}).reduce((sum, values) => sum + (values[index] ?? NaN), 0)
}
export function countSamples(records: StatValuesRecord | undefined): number {
return Object.values(records ?? {})[0]?.length ?? 0
}
export function getMaxSample(values: StatValues | undefined): number {
return Math.max(...(values ?? []).map(value => value || 0), 0)
}
export function getUsedMemoryAtIndex(stats: MemorySamples, index: number): number {
return (stats.memory?.[index] ?? NaN) - (stats.memoryFree?.[index] ?? NaN)
}
export function buildTimestamps(stats: StatsWindow, dataLength: number): number[] {
const timestampStart = stats.endTimestamp - stats.interval * (dataLength - 1)
return Array.from({ length: dataLength }, (_, index) => (timestampStart + index * stats.interval) * 1000)
}
export function buildCpuUsageSeries(data: CpuStats | null): ChartPoint[] {
const cpus = data?.stats.cpus
if (!cpus) {
return []
}
const cpuCount = Object.keys(cpus).length
const timestamps = buildTimestamps(data, countSamples(cpus))
return timestamps.map((timestamp, index) => ({
timestamp,
value: Math.round(sumAtIndex(cpus, index) / cpuCount),
}))
}
export function buildRamUsageSeries(data: MemoryStats | null): ChartPoint[] {
if (!data?.stats.memory || !data.stats.memoryFree) {
return []
}
const timestamps = buildTimestamps(data, data.stats.memory.length)
return timestamps.map((timestamp, index) => ({
timestamp,
value: getUsedMemoryAtIndex(data.stats, index),
}))
}
export function buildPairedUsageSeries(
stats: StatsWindow | null,
firstRecords: StatValuesRecord | undefined,
secondRecords: StatValuesRecord | undefined
): PairedUsageSeries {
if (stats === null) {
return [[], []]
}
const sampleCount = Math.max(countSamples(firstRecords), countSamples(secondRecords))
const timestamps = buildTimestamps(stats, sampleCount)
return [
timestamps.map((timestamp, index) => ({ timestamp, value: sumAtIndex(firstRecords, index) })),
timestamps.map((timestamp, index) => ({ timestamp, value: sumAtIndex(secondRecords, index) })),
]
}
export function roundUpChartMax(values: number[], { step, fallback, headroom = 1 }: ChartMaxOptions): number {
if (values.length === 0) {
return fallback
}
return Math.ceil((Math.max(...values) * headroom) / step) * step || fallback
}
export function getChartMaxValue(series: ChartPoint[], options: ChartMaxOptions): number {
return roundUpChartMax(
series.map(point => point.value || 0),
options
)
}
export function getCpuUsageMaxValue(series: ChartPoint[]): number {
return getChartMaxValue(series, { step: 100, fallback: 100 })
}
export function getRamUsageMaxValue(data: MemoryStats | null): number {
return getMaxSample(data?.stats.memory) || RAM_CHART_MAX_FALLBACK
}
export function getPairedUsageMaxValue(
[firstSeries, secondSeries]: PairedUsageSeries,
{ step }: { step: number }
): number {
return getChartMaxValue([...firstSeries, ...secondSeries], { step, fallback: 100, headroom: 1.2 })
}
export function formatChartBytes(value: number | null): string {
if (value === null) {
return ''
}
const size = formatSizeRaw(value, 1)
return `${size.value} ${size.prefix}`
}

View File

@@ -0,0 +1,380 @@
import { ONE_GB } from '@/shared/constants.ts'
import {
buildCpuUsageSeries,
buildPairedUsageSeries,
buildRamUsageSeries,
buildTimestamps,
countSamples,
formatChartBytes,
getChartMaxValue,
getCpuUsageMaxValue,
getMaxSample,
getPairedUsageMaxValue,
getRamUsageMaxValue,
getUsedMemoryAtIndex,
roundUpChartMax,
sumAtIndex,
type PairedUsageSeries,
} from '@/shared/utils/chart-stats.util.ts'
import { createHostStats } from '@/test/create-host-stats.ts'
describe('sumAtIndex', () => {
it('sums the sample every record holds at that index', () => {
expect(sumAtIndex({ '0': [10, 20], '1': [30, 40] }, 1)).toBe(60)
})
it('reports the sample itself for a single record', () => {
expect(sumAtIndex({ '0': [10, 20] }, 0)).toBe(10)
})
it('reports NaN when a record is missing the sample', () => {
expect(sumAtIndex({ '0': [10, 20], '1': [30] }, 1)).toBeNaN()
})
it('reports NaN for a null sample, which the host sends for a gap in its window', () => {
expect(sumAtIndex({ '0': [10, null] }, 1)).toBeNaN()
})
it('reports NaN past the end of the window', () => {
expect(sumAtIndex({ '0': [10, 20] }, 5)).toBeNaN()
})
it('reports zero for an empty record set', () => {
expect(sumAtIndex({}, 0)).toBe(0)
})
it('reports zero for records the host did not send', () => {
expect(sumAtIndex(undefined, 0)).toBe(0)
})
})
describe('countSamples', () => {
it('reports how many samples the records hold', () => {
expect(countSamples({ '0': [10, 20, 30] })).toBe(3)
})
it('reads the count off the first record, as every record covers the same window', () => {
expect(countSamples({ '0': [10, 20, 30], '1': [40, 50, 60] })).toBe(3)
})
it('reports no sample for a record set holding none', () => {
expect(countSamples({ '0': [] })).toBe(0)
})
it('reports no sample for an empty record set', () => {
expect(countSamples({})).toBe(0)
})
it('reports no sample for records the host did not send', () => {
expect(countSamples(undefined)).toBe(0)
})
})
describe('getMaxSample', () => {
it('reports the highest sample of the window', () => {
expect(getMaxSample([1000, 3000, 2000])).toBe(3000)
})
it('treats a null sample, which the host sends for a gap in its window, as zero', () => {
expect(getMaxSample([null, 500])).toBe(500)
})
it('reports zero when every sample is zero', () => {
expect(getMaxSample([0, 0])).toBe(0)
})
it('reports zero for a window holding no sample', () => {
expect(getMaxSample([])).toBe(0)
})
it('reports zero for samples the host did not send', () => {
expect(getMaxSample(undefined)).toBe(0)
})
})
describe('getUsedMemoryAtIndex', () => {
it('subtracts the free memory from the total memory at that index', () => {
expect(getUsedMemoryAtIndex({ memory: [1000, 2000], memoryFree: [400, 500] }, 1)).toBe(1500)
})
it('reports NaN when the total memory is missing the sample', () => {
expect(getUsedMemoryAtIndex({ memory: [1000], memoryFree: [400, 500] }, 1)).toBeNaN()
})
it('reports NaN when the free memory is missing the sample', () => {
expect(getUsedMemoryAtIndex({ memory: [1000, 2000], memoryFree: [400] }, 1)).toBeNaN()
})
it('reports NaN for a null sample, which the host sends for a gap in its window', () => {
expect(getUsedMemoryAtIndex({ memory: [1000, null], memoryFree: [400, 500] }, 1)).toBeNaN()
})
it('reports NaN for memory the host did not send', () => {
expect(getUsedMemoryAtIndex({}, 0)).toBeNaN()
})
})
describe('buildTimestamps', () => {
it('returns evenly spaced millisecond timestamps ending at the end timestamp', () => {
const stats = createHostStats({ endTimestamp: 1000, interval: 10 })
expect(buildTimestamps(stats, 3)).toEqual([980_000, 990_000, 1_000_000])
})
it('returns a single timestamp at the end timestamp when there is only one sample', () => {
const stats = createHostStats({ endTimestamp: 1000, interval: 10 })
expect(buildTimestamps(stats, 1)).toEqual([1_000_000])
})
it('returns an empty array when there is no sample', () => {
expect(buildTimestamps(createHostStats(), 0)).toEqual([])
})
})
describe('buildCpuUsageSeries', () => {
it('averages the usage across all cpus at each index', () => {
const data = createHostStats({ stats: { cpus: { cpu0: [10, 20], cpu1: [30, 40] } } })
expect(buildCpuUsageSeries(data)).toEqual([
{ timestamp: 990_000, value: 20 },
{ timestamp: 1_000_000, value: 30 },
])
})
it('rounds the averaged usage to the nearest integer', () => {
const data = createHostStats({ stats: { cpus: { cpu0: [1], cpu1: [2] } } })
expect(buildCpuUsageSeries(data)).toEqual([{ timestamp: 1_000_000, value: 2 }])
})
it('propagates NaN when a sample is missing', () => {
const series = buildCpuUsageSeries(createHostStats({ stats: { cpus: { cpu0: [10, null] } } }))
expect(series[0].value).toBe(10)
expect(series[1].value).toBeNaN()
})
it('returns an empty array when there are no cpu stats', () => {
expect(buildCpuUsageSeries(createHostStats())).toEqual([])
})
it('returns an empty array when the cpu stats hold no sample', () => {
expect(buildCpuUsageSeries(createHostStats({ stats: { cpus: { cpu0: [] } } }))).toEqual([])
})
it('returns an empty array when no cpu is reported at all', () => {
expect(buildCpuUsageSeries(createHostStats({ stats: { cpus: {} } }))).toEqual([])
})
it('returns an empty array for null data', () => {
expect(buildCpuUsageSeries(null)).toEqual([])
})
})
describe('buildRamUsageSeries', () => {
it('subtracts the free memory from the total memory at each index', () => {
const data = createHostStats({ stats: { memory: [1000, 2000], memoryFree: [400, 500] } })
expect(buildRamUsageSeries(data)).toEqual([
{ timestamp: 990_000, value: 600 },
{ timestamp: 1_000_000, value: 1500 },
])
})
it('propagates NaN when a sample is missing', () => {
const series = buildRamUsageSeries(createHostStats({ stats: { memory: [1000, 2000], memoryFree: [400] } }))
expect(series[0].value).toBe(600)
expect(series[1].value).toBeNaN()
})
it('returns an empty array when the total memory is not reported', () => {
expect(buildRamUsageSeries(createHostStats({ stats: { memoryFree: [400] } }))).toEqual([])
})
it('returns an empty array when the free memory is not reported', () => {
expect(buildRamUsageSeries(createHostStats({ stats: { memory: [1000] } }))).toEqual([])
})
it('returns an empty array when the memory stats hold no sample', () => {
expect(buildRamUsageSeries(createHostStats({ stats: { memory: [], memoryFree: [] } }))).toEqual([])
})
it('returns an empty array for null data', () => {
expect(buildRamUsageSeries(null)).toEqual([])
})
})
describe('buildPairedUsageSeries', () => {
it('sums each half across every device at each index', () => {
const stats = createHostStats()
expect(buildPairedUsageSeries(stats, { '0': [10, 20], '1': [1, 2] }, { '0': [30, 40] })).toEqual([
[
{ timestamp: 990_000, value: 11 },
{ timestamp: 1_000_000, value: 22 },
],
[
{ timestamp: 990_000, value: 30 },
{ timestamp: 1_000_000, value: 40 },
],
])
})
it('propagates NaN when a sample is missing', () => {
const [firstSeries] = buildPairedUsageSeries(createHostStats(), { '0': [null] }, { '0': [10] })
expect(firstSeries[0].value).toBeNaN()
})
it('reports zeroes for the second half when its records are missing', () => {
const [, secondSeries] = buildPairedUsageSeries(createHostStats(), { '0': [10, 20] }, undefined)
expect(secondSeries).toEqual([
{ timestamp: 990_000, value: 0 },
{ timestamp: 1_000_000, value: 0 },
])
})
it('reports zeroes for the first half when its records are missing', () => {
expect(buildPairedUsageSeries(createHostStats(), undefined, { '0': [10, 20] })).toEqual([
[
{ timestamp: 990_000, value: 0 },
{ timestamp: 1_000_000, value: 0 },
],
[
{ timestamp: 990_000, value: 10 },
{ timestamp: 1_000_000, value: 20 },
],
])
})
it('takes the sample count from whichever half reports the most', () => {
const [firstSeries, secondSeries] = buildPairedUsageSeries(createHostStats(), { '0': [10] }, { '0': [30, 40] })
expect(firstSeries.map(point => point.value)).toEqual([10, NaN])
expect(secondSeries.map(point => point.value)).toEqual([30, 40])
})
it('returns empty series when the records hold no sample', () => {
expect(buildPairedUsageSeries(createHostStats(), { '0': [] }, { '0': [] })).toEqual([[], []])
})
it('returns empty series for null stats', () => {
expect(buildPairedUsageSeries(null, { '0': [10] }, { '0': [20] })).toEqual([[], []])
})
})
describe('roundUpChartMax', () => {
it('returns the fallback when there are no values', () => {
expect(roundUpChartMax([], { step: 100, fallback: 42 })).toBe(42)
})
it('returns the fallback when all values are zero', () => {
expect(roundUpChartMax([0, 0, 0], { step: 100, fallback: 42 })).toBe(42)
})
it('rounds the maximum up to the next step without headroom by default', () => {
expect(roundUpChartMax([150], { step: 100, fallback: 100 })).toBe(200)
})
it('keeps a value already on a step boundary', () => {
expect(roundUpChartMax([50], { step: 100, fallback: 100 })).toBe(100)
})
it('applies the headroom multiplier before rounding', () => {
expect(roundUpChartMax([50], { step: 1, fallback: 0, headroom: 1.2 })).toBe(60)
})
it('picks the maximum across multiple values', () => {
expect(roundUpChartMax([10, 50, 30], { step: 1, fallback: 0, headroom: 1.2 })).toBe(60)
})
})
describe('getChartMaxValue', () => {
it('rounds up the highest value plotted by the series', () => {
const series = [
{ timestamp: 0, value: 10 },
{ timestamp: 1, value: 150 },
]
expect(getChartMaxValue(series, { step: 100, fallback: 100 })).toBe(200)
})
it('returns the fallback for an empty series', () => {
expect(getChartMaxValue([], { step: 100, fallback: 42 })).toBe(42)
})
})
describe('getCpuUsageMaxValue', () => {
it('returns 100 for an empty series', () => {
expect(getCpuUsageMaxValue([])).toBe(100)
})
it('rounds the maximum up to the next hundred', () => {
expect(getCpuUsageMaxValue([{ timestamp: 0, value: 150 }])).toBe(200)
})
it('rounds a maximum below a hundred up to a hundred', () => {
expect(getCpuUsageMaxValue([{ timestamp: 0, value: 50 }])).toBe(100)
})
it('keeps a maximum already on a hundred boundary', () => {
expect(getCpuUsageMaxValue([{ timestamp: 0, value: 200 }])).toBe(200)
})
})
describe('getRamUsageMaxValue', () => {
it('returns the highest total memory sample', () => {
expect(getRamUsageMaxValue(createHostStats({ stats: { memory: [1000, 3000, 2000] } }))).toBe(3000)
})
it('treats a missing sample as zero', () => {
expect(getRamUsageMaxValue(createHostStats({ stats: { memory: [null, 500] } }))).toBe(500)
})
it('falls back to 1 GiB when every memory sample is zero, which would flatten the axis', () => {
expect(getRamUsageMaxValue(createHostStats({ stats: { memory: [0, 0] } }))).toBe(ONE_GB)
})
it('falls back to 1 GiB when the memory holds no sample', () => {
expect(getRamUsageMaxValue(createHostStats({ stats: { memory: [] } }))).toBe(ONE_GB)
})
it('falls back to 1 GiB for null data', () => {
expect(getRamUsageMaxValue(null)).toBe(ONE_GB)
})
})
describe('getPairedUsageMaxValue', () => {
it('returns 100 when both series are empty', () => {
expect(getPairedUsageMaxValue([[], []], { step: 100 })).toBe(100)
})
it('adds a fifth of headroom above the highest value of either half', () => {
const series: PairedUsageSeries = [[{ timestamp: 0, value: 100 }], [{ timestamp: 0, value: 300 }]]
expect(getPairedUsageMaxValue(series, { step: 100 })).toBe(400)
})
it('rounds up to the given step', () => {
const series: PairedUsageSeries = [[{ timestamp: 0, value: 10 }], [{ timestamp: 0, value: 20 }]]
expect(getPairedUsageMaxValue(series, { step: 50 })).toBe(50)
})
})
describe('formatChartBytes', () => {
it('returns an empty string for a null value', () => {
expect(formatChartBytes(null)).toBe('')
})
it('formats a byte value with its unit', () => {
expect(formatChartBytes(1024)).toBe('1 KiB')
})
it('formats a sub-kibibyte value in bytes', () => {
expect(formatChartBytes(512)).toBe('512 B')
})
})

View File

@@ -0,0 +1,11 @@
import { isIpv6, type IpAddress } from '@/shared/utils/ip.utils.ts'
describe('isIpv6', () => {
it('returns true for an IPv6 address', () => {
expect(isIpv6('::1' as IpAddress)).toBe(true)
})
it('returns false for an IPv4 address', () => {
expect(isIpv6('192.168.1.1' as IpAddress)).toBe(false)
})
})

View File

@@ -0,0 +1,32 @@
import { mountComposable } from '@/test/mount-composable.ts'
import type { MaybeRefOrGetter, ShallowUnwrapRef } from 'vue'
interface EnhancedDataResult {
getDisplayData: (item: never) => unknown
}
type Filterable<TResult extends EnhancedDataResult> = Parameters<TResult['getDisplayData']>[0]
type Display<TResult extends EnhancedDataResult> = ReturnType<TResult['getDisplayData']>
export function createEnhancedDataHelpers<TSource, TResult extends EnhancedDataResult & Record<string, unknown>>(
useEnhancedData: (sources: MaybeRefOrGetter<TSource[]>) => TResult,
getFilterables: (result: ShallowUnwrapRef<TResult>) => Filterable<TResult>[],
createSource: () => TSource
) {
function mountEnhancedData(sources: MaybeRefOrGetter<TSource[]> = [createSource()]) {
return mountComposable(() => useEnhancedData(sources)).wrapper.vm
}
function mountFirstFilterable(sources: TSource[] = [createSource()]): Filterable<TResult> {
return getFilterables(mountEnhancedData(sources))[0]
}
function mountFirstDisplayData(sources: TSource[] = [createSource()]): Display<TResult> {
const result = mountEnhancedData(sources)
return result.getDisplayData(getFilterables(result)[0]) as Display<TResult>
}
return { mountEnhancedData, mountFirstFilterable, mountFirstDisplayData }
}

View File

@@ -0,0 +1,14 @@
import type { XapiHostStats } from '@vates/types/common'
/**
* Builds a minimal `XapiHostStats` for use in tests. Pass `overrides` to tweak
* only the fields relevant to the case under test (usually `stats`).
*/
export function createHostStats(overrides: Partial<XapiHostStats> = {}): XapiHostStats {
return {
endTimestamp: 1000,
interval: 10,
stats: {},
...overrides,
}
}

View File

@@ -0,0 +1,38 @@
import type { FrontXoHost } from '@/modules/host/remote-resources/use-xo-host-collection.ts'
import { HOST_POWER_STATE } from '@vates/types'
/**
* Builds a fully-populated `FrontXoHost` for use in tests. Pass `overrides` to
* tweak only the fields relevant to the case under test.
*/
export function createHost(overrides: Partial<FrontXoHost> = {}): FrontXoHost {
return {
id: 'host-456' as FrontXoHost['id'],
name_label: 'Test Host',
name_description: 'A test host',
power_state: HOST_POWER_STATE.RUNNING,
controlDomain: 'vm-controller-123' as FrontXoHost['controlDomain'],
residentVms: [],
$pool: 'pool-789' as FrontXoHost['$pool'],
current_operations: {},
address: '192.168.1.1',
startTime: 1660000000000,
version: '8.2.0',
bios_strings: {},
cpus: { cores: 8, sockets: 2 },
CPUs: {},
memory: { size: 4294967296, usage: 2147483648 },
tags: [],
iscsiIqn: '',
powerOnMode: '',
build: 'release/yangtze/master/1',
otherConfig: {},
multipathing: false,
logging: {},
enabled: true,
agentStartTime: 1660000000000,
PGPUs: [],
type: 'host',
...overrides,
}
}

View File

@@ -0,0 +1,19 @@
import { createHostStats } from '@/test/create-host-stats.ts'
import type { XoHost } from '@vates/types'
import type { XapiHostStats, XapiPoolStats } from '@vates/types/common'
type PoolStatsEntry = Partial<XapiHostStats> | { error: Record<string, unknown> }
/**
* Builds an `XapiPoolStats` from one entry per host. A `Partial<XapiHostStats>`
* entry goes through {@link createHostStats}; an `{ error }` entry models a host
* whose stats the pool could not fetch, which the API reports in place of them.
*/
export function createPoolStats(hosts: Record<string, PoolStatsEntry> = {}): XapiPoolStats {
return Object.fromEntries(
Object.entries(hosts).map(([hostId, entry]) => [
hostId as XoHost['id'],
'error' in entry ? entry : createHostStats(entry),
])
)
}

View File

@@ -0,0 +1,27 @@
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
/**
* Builds a fully-populated `FrontXoPool` for use in tests. Pass `overrides` to
* tweak only the fields relevant to the case under test.
*/
export function createPool(overrides: Partial<FrontXoPool> = {}): FrontXoPool {
return {
id: 'pool-789' as FrontXoPool['id'],
name_label: 'Test Pool',
name_description: 'A test pool',
master: 'host-456' as FrontXoPool['master'],
current_operations: {},
default_SR: 'sr-123' as FrontXoPool['default_SR'],
tags: [],
otherConfig: {},
auto_poweron: false,
HA_enabled: false,
migrationCompression: false,
suspendSr: undefined,
crashDumpSr: undefined,
haSrs: [],
type: 'pool',
haRebootVmOnInternalShutdown: false,
...overrides,
}
}

View File

@@ -0,0 +1,24 @@
import type { FrontXoServer } from '@/modules/server/remote-resources/use-xo-server-collection.ts'
/**
* Builds a fully-populated `FrontXoServer` for use in tests. Pass `overrides` to
* tweak only the fields relevant to the case under test.
*/
export function createServer(overrides: Partial<FrontXoServer> = {}): FrontXoServer {
return {
id: 'server-123' as FrontXoServer['id'],
label: 'Test Server',
poolId: 'pool-789' as FrontXoServer['poolId'],
poolNameLabel: 'Test Pool',
poolNameDescription: 'A test pool',
master: 'host-456' as FrontXoServer['master'],
host: '192.168.1.1',
httpProxy: undefined,
username: 'admin',
readOnly: false,
allowUnauthorized: false,
status: 'connected',
error: undefined,
...overrides,
}
}

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