Compare commits

..

2 Commits

Author SHA1 Message Date
Florent BEAUCHAMP
3e1227c710 feat(nbd): implement server export from/to stream 2023-08-08 14:34:03 +02:00
Florent BEAUCHAMP
ddc73fb836 feat(nbd): server implementation working, no encryption 2023-08-08 11:21:39 +02:00
109 changed files with 1594 additions and 1275 deletions

View File

@@ -0,0 +1,32 @@
import NbdClient from "./client.mjs";
async function bench(){
const client = new NbdClient({
address:'localhost',
port: 9000,
exportname: 'bench_export'
})
await client.connect()
console.log('connected', client.exportSize)
for(let chunk_size=16*1024; chunk_size < 16*1024*1024; chunk_size *=2){
let i=0
const start = + new Date()
for await(const block of client.readBlocks(chunk_size) ){
i++
if((i*chunk_size) % (16*1024*1024) ===0){
process.stdout.write('.')
}
if(i*chunk_size > 1024*1024*1024) break
}
console.log(chunk_size,Math.round( (i*chunk_size/1024/1024*1000)/ (new Date() - start)))
}
await client.disconnect()
}
bench()

View File

@@ -74,7 +74,7 @@ export default class NbdClient {
this.#serverSocket = connect({
socket: this.#serverSocket,
rejectUnauthorized: false,
cert: this.#serverCert,
cert: this.#serverCert
})
this.#serverSocket.once('error', reject)
this.#serverSocket.once('secureConnect', () => {
@@ -88,7 +88,11 @@ export default class NbdClient {
async #unsecureConnect() {
this.#serverSocket = new Socket()
return new Promise((resolve, reject) => {
this.#serverSocket.connect(this.#serverPort, this.#serverAddress)
this.#serverSocket.connect({
port:this.#serverPort,
host: this.#serverAddress,
// @todo should test the onRead to limit buffer copy
})
this.#serverSocket.once('error', reject)
this.#serverSocket.once('connect', () => {
this.#serverSocket.removeListener('error', reject)
@@ -232,19 +236,20 @@ export default class NbdClient {
}
try {
this.#waitingForResponse = true
const magic = await this.#readInt32()
const buffer = await this.#read(4+4+8)
const magic = buffer.readUInt32BE()
if (magic !== NBD_REPLY_MAGIC) {
throw new Error(`magic number for block answer is wrong : ${magic} ${NBD_REPLY_MAGIC}`)
}
const error = await this.#readInt32()
const error = buffer.readUInt32BE(4)
if (error !== 0) {
// @todo use error code from constants.mjs
throw new Error(`GOT ERROR CODE : ${error}`)
}
const blockQueryId = await this.#readInt64()
const blockQueryId = buffer.readBigUInt64BE(8)
const query = this.#commandQueryBacklog.get(blockQueryId)
if (!query) {
throw new Error(` no query associated with id ${blockQueryId}`)
@@ -307,11 +312,11 @@ export default class NbdClient {
})
}
async *readBlocks(indexGenerator) {
async *readBlocks(indexGenerator = 2*1024*1024) {
// default : read all blocks
if (indexGenerator === undefined) {
if (typeof indexGenerator === 'number') {
const exportSize = this.#exportSize
const chunkSize = 2 * 1024 * 1024
const chunkSize = indexGenerator
indexGenerator = function* () {
const nbBlocks = Math.ceil(Number(exportSize / BigInt(chunkSize)))
for (let index = 0; BigInt(index) < nbBlocks; index++) {
@@ -319,12 +324,14 @@ export default class NbdClient {
}
}
}
const readAhead = []
const readAheadMaxLength = this.#readAhead
const makeReadBlockPromise = (index, size) => {
const promise = pRetry(() => this.readBlock(index, size), {
tries: this.#readBlockRetries,
onRetry: async err => {
console.error(err)
warn('will retry reading block ', index, err)
await this.reconnect()
},
@@ -336,6 +343,7 @@ export default class NbdClient {
// read all blocks, but try to keep readAheadMaxLength promise waiting ahead
for (const { index, size } of indexGenerator()) {
// stack readAheadMaxLength promises before starting to handle the results
if (readAhead.length === readAheadMaxLength) {
// any error will stop reading blocks
@@ -348,4 +356,4 @@ export default class NbdClient {
yield readAhead.shift()
}
}
}
}

View File

@@ -1,12 +1,40 @@
// https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md
export const INIT_PASSWD = Buffer.from('NBDMAGIC') // "NBDMAGIC" ensure we're connected to a nbd server
export const OPTS_MAGIC = Buffer.from('IHAVEOPT') // "IHAVEOPT" start an option block
export const NBD_OPT_REPLY_MAGIC = 1100100111001001n // magic received during negociation
export const NBD_OPT_EXPORT_NAME = 1
export const NBD_OPT_ABORT = 2
export const NBD_OPT_LIST = 3
export const NBD_OPT_STARTTLS = 5
export const NBD_OPT_INFO = 6
export const NBD_OPT_GO = 7
export const NBD_OPT_STRUCTURED_REPLY = 8
export const NBD_OPT_LIST_META_CONTEXT = 9
export const NBD_OPT_SET_META_CONTEXT = 10
export const NBD_OPT_EXTENDED_HEADERS = 11
export const NBD_REP_ACK =1
export const NBD_REP_SERVER = 2
export const NBD_REP_INFO = 3
export const NBD_REP_META_CONTEXT = 4
export const NBD_REP_ERR_UNSUP = 0x80000001 // 2^32+1
export const NBD_REP_ERR_POLICY = 0x80000002
export const NBD_REP_ERR_INVALID = 0x80000003
export const NBD_REP_ERR_PLATFORM = 0x80000004
export const NBD_REP_ERR_TLS_REQD = 0x80000005
export const NBD_REP_ERR_UNKNOWN = 0x80000006
export const NBD_REP_ERR_SHUTDOWN = 0x80000007
export const NBD_REP_ERR_BLOCK_SIZE_REQD = 0x80000008
export const NBD_REP_ERR_TOO_BIG = 0x80000009
export const NBD_REP_ERR_EXT_HEADER_REQD = 0x8000000a
export const NBD_INFO_EXPORT = 0
export const NBD_INFO_NAME = 1
export const NBD_INFO_DESCRIPTION = 2
export const NBD_INFO_BLOCK_SIZE = 3
export const NBD_FLAG_HAS_FLAGS = 1 << 0
export const NBD_FLAG_READ_ONLY = 1 << 1
@@ -14,6 +42,9 @@ export const NBD_FLAG_SEND_FLUSH = 1 << 2
export const NBD_FLAG_SEND_FUA = 1 << 3
export const NBD_FLAG_ROTATIONAL = 1 << 4
export const NBD_FLAG_SEND_TRIM = 1 << 5
export const NBD_FLAG_SEND_WRITE_ZEROES = 1 << 6
export const NBD_FLAG_SEND_DF = 1 << 7
export const NBD_FLAG_CAN_MULTI_CONN = 1 << 8
export const NBD_FLAG_FIXED_NEWSTYLE = 1 << 0
@@ -36,6 +67,15 @@ export const NBD_CMD_RESIZE = 8
export const NBD_REQUEST_MAGIC = 0x25609513 // magic number to create a new NBD request to send to the server
export const NBD_REPLY_MAGIC = 0x67446698 // magic number received from the server when reading response to a nbd request
export const NBD_REPLY_ACK = 1
export const NBD_SIMPLE_REPLY_MAGIC = 0x67446698
export const NBD_STRUCTURED_REPLY_MAGIC = 0x668e33ef
export const NBD_REPLY_TYPE_NONE = 0
export const NBD_REPLY_TYPE_OFFSET_DATA = 1
export const NBD_REPLY_TYPE_OFFSET_HOLE = 2
export const NBD_REPLY_TYPE_BLOCK_STATUS = 5
export const NBD_REPLY_TYPE_ERROR = 1 << 15 +1
export const NBD_REPLY_TYPE_ERROR_OFFSET = 1 << 15 +2
export const NBD_DEFAULT_PORT = 10809
export const NBD_DEFAULT_BLOCK_SIZE = 64 * 1024

View File

@@ -0,0 +1,292 @@
import assert, { deepEqual, strictEqual, notStrictEqual } from 'node:assert'
import { createServer } from 'node:net'
import { fromCallback } from 'promise-toolbox'
import { readChunkStrict } from '@vates/read-chunk'
import {
INIT_PASSWD,
NBD_CMD_READ,
NBD_DEFAULT_PORT,
NBD_FLAG_FIXED_NEWSTYLE,
NBD_FLAG_HAS_FLAGS,
NBD_OPT_EXPORT_NAME,
NBD_OPT_REPLY_MAGIC,
NBD_REPLY_ACK,
NBD_REQUEST_MAGIC,
OPTS_MAGIC,
NBD_CMD_DISC,
NBD_REP_ERR_UNSUP,
NBD_CMD_WRITE,
NBD_OPT_GO,
NBD_OPT_INFO,
NBD_INFO_EXPORT,
NBD_REP_INFO,
NBD_SIMPLE_REPLY_MAGIC,
NBD_REP_ERR_UNKNOWN,
} from './constants.mjs'
import { PassThrough } from 'node:stream'
export default class NbdServer {
#server
#clients = new Map()
constructor(port = NBD_DEFAULT_PORT) {
this.#server = createServer()
this.#server.listen(port)
this.#server.on('connection', client => this.#handleNewConnection(client))
}
// will wait for a client to connect and upload the file to this server
downloadStream(key) {
strictEqual(this.#clients.has(key), false)
const stream = new PassThrough()
const offset = BigInt(0)
this.#clients.set(key, { length: BigInt(2 * 1024 * 1024 * 1024 * 1024), stream, offset, key })
return stream
}
// will wait for a client to connect and downlaod this stream
uploadStream(key, source, length) {
strictEqual(this.#clients.has(key), false)
notStrictEqual(length, undefined)
const offset = BigInt(0)
this.#clients.set(key, { length: BigInt(length), stream: source, offset, key })
}
#read(socket, length) {
return readChunkStrict(socket, length)
}
async #readInt32(socket) {
const buffer = await this.#read(socket, 4)
return buffer.readUInt32BE()
}
#write(socket, buffer) {
return fromCallback.call(socket, 'write', buffer)
}
async #writeInt16(socket, int16) {
const buffer = Buffer.alloc(2)
buffer.writeUInt16BE(int16)
return this.#write(socket, buffer)
}
async #writeInt32(socket, int32) {
const buffer = Buffer.alloc(4)
buffer.writeUInt32BE(int32)
return this.#write(socket, buffer)
}
async #writeInt64(socket, int64) {
const buffer = Buffer.alloc(8)
buffer.writeBigUInt64BE(int64)
return this.#write(socket, buffer)
}
async #openExport(key) {
if (!this.#clients.has(key)) {
// export does not exists
const err = new Error('Export not found ')
err.code = 'ENOTFOUND'
throw err
}
const { length } = this.#clients.get(key)
return length
}
async #sendOptionResponse(socket, option, response, data = Buffer.alloc(0)) {
await this.#writeInt64(socket, NBD_OPT_REPLY_MAGIC)
await this.#writeInt32(socket, option)
await this.#writeInt32(socket, response)
await this.#writeInt32(socket, data.length)
await this.#write(socket, data)
}
/**
*
* @param {Socket} socket
* @returns true if server is waiting for more options
*/
async #readOption(socket) {
console.log('wait for option')
const magic = await this.#read(socket, 8)
console.log(magic.toString('ascii'), magic.length, OPTS_MAGIC.toString('ascii'))
deepEqual(magic, OPTS_MAGIC)
const option = await this.#readInt32(socket)
const length = await this.#readInt32(socket)
console.log({ option, length })
const data = length > 0 ? await this.#read(socket, length) : undefined
switch (option) {
case NBD_OPT_EXPORT_NAME: {
const exportNameLength = data.readInt32BE()
const key = data.slice(4, exportNameLength + 4).toString()
let exportSize
try {
exportSize = await this.#openExport(key)
} catch (err) {
if (err.code === 'ENOTFOUND') {
this.#sendOptionResponse(socket, option, NBD_REP_ERR_UNKNOWN)
return false
}
throw err
}
socket.key = key
await this.#writeInt64(socket, exportSize)
await this.#writeInt16(socket, NBD_FLAG_HAS_FLAGS /* transmission flag */)
await this.#write(socket, Buffer.alloc(124) /* padding */)
return false
}
/*
case NBD_OPT_STARTTLS:
console.log('starttls')
// @todo not working
return true
*/
case NBD_OPT_GO:
case NBD_OPT_INFO: {
const exportNameLength = data.readInt32BE()
const key = data.slice(4, exportNameLength + 4).toString()
let exportSize
try {
exportSize = await this.#openExport(key)
} catch (err) {
if (err.code === 'ENOTFOUND') {
this.#sendOptionResponse(socket, option, NBD_REP_ERR_UNKNOWN)
// @todo should disconnect
return false
}
throw err
}
socket.key = key
await this.#writeInt64(socket, NBD_OPT_REPLY_MAGIC)
await this.#writeInt32(socket, option)
await this.#writeInt32(socket, NBD_REP_INFO)
await this.#writeInt32(socket, 12)
// the export info
await this.#writeInt16(socket, NBD_INFO_EXPORT)
await this.#writeInt64(socket, exportSize)
await this.#writeInt16(socket, NBD_FLAG_HAS_FLAGS /* transmission flag */)
// an ACK at the end of the infos
await this.#sendOptionResponse(socket, option, NBD_REPLY_ACK) // no additionnal data
return option === NBD_OPT_INFO // we stays in option phase is option is INFO
}
default:
// not supported
console.log('not supported', option, length, data?.toString())
await this.#sendOptionResponse(socket, option, NBD_REP_ERR_UNSUP) // no additionnal data
// wait for next option
return true
}
}
async #readCommand(socket) {
const key = socket.key
// this socket has an export key
notStrictEqual(key, undefined)
// this export key is still valid
strictEqual(this.#clients.has(key), true)
const client = this.#clients.get(key)
const buffer = await this.#read(socket, 28)
const magic = buffer.readInt32BE(0)
strictEqual(magic, NBD_REQUEST_MAGIC)
/* const commandFlags = */ buffer.readInt16BE(4)
const command = buffer.readInt16BE(6)
const cookie = buffer.readBigUInt64BE(8)
const offset = buffer.readBigUInt64BE(16)
const length = buffer.readInt32BE(24)
switch (command) {
case NBD_CMD_DISC:
console.log('gotdisconnect', client.offset)
await client.stream?.destroy()
// @todo : disconnect
return false
case NBD_CMD_READ: {
/** simple replies */
// read length byte from offset in export
// the client is writing in contiguous mode
assert.strictEqual(offset, client.offset)
client.offset += BigInt(length)
const data = await readChunkStrict(client.stream, length)
const reply = Buffer.alloc(16)
reply.writeInt32BE(NBD_SIMPLE_REPLY_MAGIC)
reply.writeInt32BE(0, 4) // no error
reply.writeBigInt64BE(cookie, 8)
await this.#write(socket, reply)
await this.#write(socket, data)
/* if we implement non stream read, we can handle read in parallel
const reply = Buffer.alloc(16+length)
reply.writeInt32BE(NBD_SIMPLE_REPLY_MAGIC)
reply.writeInt32BE(0,4)// no error
reply.writeBigInt64BE(cookie,8)
// read length byte from offset in export directly in the given buffer
// may do multiple read in parallel on the same export
size += length
socket.fd.read(reply, 16, length, Number(offset))
.then(()=>{
return this.#write(socket, reply)
})
.catch(err => console.error('NBD_CMD_READ',err)) */
return true
}
case NBD_CMD_WRITE: {
// the client is writing in contiguous mode
assert.strictEqual(offset, client.offset)
const data = await this.#read(socket, length)
client.offset += BigInt(length)
await new Promise((resolve, reject) => {
if (!client.stream.write(data, 0, length, Number(offset))) {
client.stream.once('drain', err => (err ? reject(err) : resolve()))
} else {
process.nextTick(resolve)
}
})
const reply = Buffer.alloc(16)
reply.writeInt32BE(NBD_SIMPLE_REPLY_MAGIC)
reply.writeInt32BE(0, 4) // no error
reply.writeBigInt64BE(cookie, 8)
await this.#write(socket, reply)
return true
}
default:
console.log('GOT unsupported command ', command)
// fail to handle
return true
}
}
async #handleNewConnection(socket) {
const remoteAddress = socket.remoteAddress + ':' + socket.remotePort
console.log('new client connection from %s', remoteAddress)
socket.on('close', () => {
console.log('client ', remoteAddress, 'is done')
})
socket.on('error', error => {
throw error
})
// handshake
await this.#write(socket, INIT_PASSWD)
await this.#write(socket, OPTS_MAGIC)
// send flags , the bare minimum
await this.#writeInt16(socket, NBD_FLAG_FIXED_NEWSTYLE)
const clientFlag = await this.#readInt32(socket)
assert.strictEqual(clientFlag & NBD_FLAG_FIXED_NEWSTYLE, NBD_FLAG_FIXED_NEWSTYLE) // only FIXED_NEWSTYLE one is supported from the server options
// read client response flags
let waitingForOptions = true
while (waitingForOptions) {
waitingForOptions = await this.#readOption(socket)
}
let waitingForCommand = true
while (waitingForCommand) {
waitingForCommand = await this.#readCommand(socket)
}
}
#handleClientData(client, data) {}
}

View File

@@ -1,4 +1,4 @@
import NbdClient from '../index.mjs'
import NbdClient from '../client.mjs'
import { spawn, exec } from 'node:child_process'
import fs from 'node:fs/promises'
import { test } from 'tap'

View File

@@ -683,17 +683,11 @@ export class RemoteAdapter {
async outputStream(path, input, { checksum = true, validator = noop } = {}) {
const container = watchStreamSize(input)
const handler = this._handler
await handler.outputStream(path, input, {
await this._handler.outputStream(path, input, {
checksum,
dirMode: this._dirMode,
async validator(tmpPath) {
async validator() {
await input.task
// size on file system can be bigger when encrypted ( IV + alignment padding)
const size = await handler.getSize(tmpPath, { exact: false })
if (Math.abs(size - container.size) > handler.getSizeApproximationMargin()) {
return false
}
return validator.apply(this, arguments)
},
})

View File

@@ -21,12 +21,7 @@ export class RestoreMetadataBackup {
})
} else {
const metadata = JSON.parse(await handler.readFile(join(backupId, 'metadata.json')))
const dataFileName = resolve(backupId, metadata.data ?? 'data.json')
const data = await handler.readFile(dataFileName)
// if data is JSON, sent it as a plain string, otherwise, consider the data as binary and encode it
const isJson = dataFileName.endsWith('.json')
return isJson ? data.toString() : { encoding: 'base64', data: data.toString('base64') }
return String(await handler.readFile(resolve(backupId, metadata.data ?? 'data.json')))
}
}
}

View File

@@ -22,13 +22,7 @@ export class XoMetadataBackup {
const dir = `${scheduleDir}/${formatFilenameDate(timestamp)}`
const data = job.xoMetadata
let dataBaseName = './data'
// JSON data is sent as plain string, binary data is sent as an object with `data` and `encoding properties
const isJson = typeof data === 'string'
if (isJson) {
dataBaseName += '.json'
}
const dataBaseName = './data.json'
const metadata = JSON.stringify(
{
@@ -60,7 +54,7 @@ export class XoMetadataBackup {
async () => {
const handler = adapter.handler
const dirMode = this._config.dirMode
await handler.outputFile(dataFileName, isJson ? data : Buffer.from(data.data, data.encoding), { dirMode })
await handler.outputFile(dataFileName, data, { dirMode })
await handler.outputFile(metaDataFileName, metadata, {
dirMode,
})

View File

@@ -227,19 +227,11 @@ export default class RemoteHandlerAbstract {
// when using encryption, the file size is aligned with the encryption block size ( 16 bytes )
// that means that the size will be 1 to 16 bytes more than the content size + the initialized vector length (16 bytes)
async getSize(file, { exact = true } = {}) {
exact && assert.strictEqual(this.isEncrypted, false, `Can't compute size of an encrypted file ${file}`)
async getSize(file) {
assert.strictEqual(this.isEncrypted, false, `Can't compute size of an encrypted file ${file}`)
const size = await timeout.call(this._getSize(typeof file === 'string' ? normalizePath(file) : file), this._timeout)
return size
}
getSizeApproximationMargin() {
if (this.isEncrypted) {
// on block for initialization vector + at most 1 bloc - 1 byte for aligment padding
return this.#encryptor.ivLength * 2 - 1
}
return 0
return size - this.#encryptor.ivLength
}
async __list(dir, { filter, ignoreMissing = false, prependDir = false } = {}) {

View File

@@ -1,144 +0,0 @@
<!-- TOC -->
- [XenApiCollection](#xenapicollection)
- [Get the collection](#get-the-collection)
- [Defer the subscription](#defer-the-subscription)
- [Create a dedicated collection](#create-a-dedicated-collection)
- [Alter the collection](#alter-the-collection)
_ [Example 1: Adding props to records](#example-1-adding-props-to-records)
_ [Example 2: Adding props to the collection](#example-2-adding-props-to-the-collection) \* [Example 3, filtering and sorting the collection](#example-3-filtering-and-sorting-the-collection)
<!-- TOC -->
# XenApiCollection
## Get the collection
To retrieve a collection, invoke `useXenApiCollection("VM")`.
By doing this, the current component will be automatically subscribed to the collection and will be updated when the
collection changes.
When the component is unmounted, the subscription will be automatically stopped.
## Defer the subscription
If you don't want to fetch the data of the collection when the component is mounted, you can pass `{ immediate: false }`
as options: `const { start, isStarted } = useXenApiCollection("VM", { immediate: false })`.
Then you subscribe to the collection by calling `start()`.
## Create a dedicated collection
It is recommended to create a dedicated collection composable for each type of record you want to use.
They are stored in `src/composables/xen-api-collection/*-collection.composable.ts`.
```typescript
// src/composables/xen-api-collection/console-collection.composable.ts
export const useConsoleCollection = () => useXenApiCollection("console");
```
If you want to allow the user to defer the subscription, you can propagate the options to `useXenApiCollection`.
```typescript
// console-collection.composable.ts
export const useConsoleCollection = <
Immediate extends boolean = true,
>(options?: {
immediate?: Immediate;
}) => useXenApiCollection("console", options);
```
```typescript
// MyComponent.vue
const collection = useConsoleCollection({ immediate: false });
setTimeout(() => collection.start(), 10000);
```
## Alter the collection
You can alter the collection by overriding parts of it.
### Example 1: Adding props to records
```typescript
// xen-api.ts
export interface XenApiConsole extends XenApiRecord<"console"> {
// ... existing props
someProp: string;
someOtherProp: number;
}
```
```typescript
// console-collection.composable.ts
export const useConsoleCollection = () => {
const collection = useXenApiCollection("console");
const records = computed(() => {
return collection.records.value.map((console) => ({
...console,
someProp: "Some value",
someOtherProp: 42,
}));
});
return {
...collection,
records,
};
};
```
```typescript
const consoleCollection = useConsoleCollection();
consoleCollection.getByUuid("...").someProp; // "Some value"
```
### Example 2: Adding props to the collection
```typescript
// vm-collection.composable.ts
export const useVmCollection = () => {
const collection = useXenApiCollection("VM");
return {
...collection,
runningVms: computed(() =>
collection.records.value.filter(
(vm) => vm.power_state === POWER_STATE.RUNNING
)
),
};
};
```
### Example 3, filtering and sorting the collection
```typescript
// vm-collection.composable.ts
export const useVmCollection = () => {
const collection = useXenApiCollection("VM");
return {
...collection,
records: computed(() =>
collection.records.value
.filter(
(vm) =>
!vm.is_a_snapshot && !vm.is_a_template && !vm.is_control_domain
)
.sort((vm1, vm2) => vm1.name_label.localeCompare(vm2.name_label))
),
};
};
```

View File

@@ -0,0 +1,144 @@
# Stores for XenApiRecord collections
All collections of `XenApiRecord` are stored inside the `xapiCollectionStore`.
To retrieve a collection, invoke `useXapiCollectionStore().get(type)`.
## Accessing a collection
In order to use a collection, you'll need to subscribe to it.
```typescript
const consoleStore = useXapiCollectionStore().get("console");
const { records, getByUuid /* ... */ } = consoleStore.subscribe();
```
## Deferred subscription
If you wish to initialize the subscription on demand, you can pass `{ immediate: false }` as options to `subscribe()`.
```typescript
const consoleStore = useXapiCollectionStore().get("console");
const { records, start, isStarted /* ... */ } = consoleStore.subscribe({
immediate: false,
});
// Later, you can then use start() to initialize the subscription.
```
## Create a dedicated store for a collection
To create a dedicated store for a specific `XenApiRecord`, simply return the collection from the XAPI Collection Store:
```typescript
export const useConsoleStore = defineStore("console", () =>
useXapiCollectionStore().get("console")
);
```
## Extending the base Subscription
To extend the base Subscription, you'll need to override the `subscribe` method.
For that, you can use the `createSubscribe<RawObjectType, Extensions>((options) => { /* ... */})` helper.
### Define the extensions
Subscription extensions are defined as `(object | [object, RequiredOptions])[]`.
When using a tuple (`[object, RequiredOptions]`), the corresponding `object` type will be added to the subscription if
the `RequiredOptions` for that tuple are present in the options passed to `subscribe`.
```typescript
// Always present extension
type DefaultExtension = {
propA: string;
propB: ComputedRef<number>;
};
// Conditional extension 1
type FirstConditionalExtension = [
{ propC: ComputedRef<string> }, // <- This signature will be added
{ optC: string } // <- if this condition is met
];
// Conditional extension 2
type SecondConditionalExtension = [
{ propD: () => void }, // <- This signature will be added
{ optD: number } // <- if this condition is met
];
// Create the extensions array
type Extensions = [
DefaultExtension,
FirstConditionalExtension,
SecondConditionalExtension
];
```
### Define the subscription
```typescript
export const useConsoleStore = defineStore("console", () => {
const consoleCollection = useXapiCollectionStore().get("console");
const subscribe = createSubscribe<"console", Extensions>((options) => {
const originalSubscription = consoleCollection.subscribe(options);
const extendedSubscription = {
propA: "Some string",
propB: computed(() => 42),
};
const propCSubscription = options?.optC !== undefined && {
propC: computed(() => "Some other string"),
};
const propDSubscription = options?.optD !== undefined && {
propD: () => console.log("Hello"),
};
return {
...originalSubscription,
...extendedSubscription,
...propCSubscription,
...propDSubscription,
};
});
return {
...consoleCollection,
subscribe,
};
});
```
The generated `subscribe` method will then automatically have the following `options` signature:
```typescript
type Options = {
immediate?: false;
optC?: string;
optD?: number;
};
```
### Use the subscription
In each case, all the default properties (`records`, `getByUuid`, etc.) will be present.
```typescript
const store = useConsoleStore();
// No options (propA and propB will be present)
const subscription = store.subscribe();
// optC option (propA, propB and propC will be present)
const subscription = store.subscribe({ optC: "Hello" });
// optD option (propA, propB and propD will be present)
const subscription = store.subscribe({ optD: 12 });
// optC and optD options (propA, propB, propC and propD will be present)
const subscription = store.subscribe({ optC: "Hello", optD: 12 });
```

View File

@@ -23,7 +23,7 @@ import AppNavigation from "@/components/AppNavigation.vue";
import AppTooltips from "@/components/AppTooltips.vue";
import UnreachableHostsModal from "@/components/UnreachableHostsModal.vue";
import { useChartTheme } from "@/composables/chart-theme.composable";
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
import { usePoolStore } from "@/stores/pool.store";
import { useUiStore } from "@/stores/ui.store";
import { useXenApiStore } from "@/stores/xen-api.store";
import { useActiveElement, useMagicKeys, whenever } from "@vueuse/core";
@@ -42,9 +42,7 @@ if (link == null) {
link.href = favicon;
const xenApiStore = useXenApiStore();
const { pool } = usePoolCollection();
const { pool } = usePoolStore().subscribe();
useChartTheme();
const uiStore = useUiStore();

View File

@@ -27,14 +27,14 @@
</template>
<script lang="ts" setup>
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { faServer } from "@fortawesome/free-solid-svg-icons";
import UiModal from "@/components/ui/UiModal.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { computed, ref, watch } from "vue";
import { difference } from "lodash-es";
import { useHostStore } from "@/stores/host.store";
const { records: hosts } = useHostCollection();
const { records: hosts } = useHostStore().subscribe();
const unreachableHostsUrls = ref<Set<string>>(new Set());
const clearUnreachableHostsUrls = () => unreachableHostsUrls.value.clear();
const isSslModalOpen = computed(() => unreachableHostsUrls.value.size > 0);

View File

@@ -2,80 +2,32 @@
<RouterLink :to="{ name: 'story' }">
<UiTitle type="h4">Stories</UiTitle>
</RouterLink>
<StoryMenuTree
:tree="tree"
@toggle-directory="toggleDirectory"
:opened-directories="openedDirectories"
/>
<ul class="links">
<li v-for="route in routes" :key="route.name">
<RouterLink class="link" :to="route">
{{ route.meta.storyTitle }}
</RouterLink>
</li>
</ul>
</template>
<script lang="ts" setup>
import StoryMenuTree from "@/components/component-story/StoryMenuTree.vue";
import { useRouter } from "vue-router";
import UiTitle from "@/components/ui/UiTitle.vue";
import { type RouteRecordNormalized, useRoute, useRouter } from "vue-router";
import { ref } from "vue";
const { getRoutes } = useRouter();
const routes = getRoutes().filter((route) => route.meta.isStory);
export type StoryTree = Map<
string,
{ path: string; directory: string; children: StoryTree }
>;
function createTree(routes: RouteRecordNormalized[]) {
const tree: StoryTree = new Map();
for (const route of routes) {
const parts = route.path.slice(7).split("/");
let currentNode = tree;
let currentPath = "";
for (const part of parts) {
currentPath = currentPath ? `${currentPath}/${part}` : part;
if (!currentNode.has(part)) {
currentNode.set(part, {
children: new Map(),
path: route.path,
directory: currentPath,
});
}
currentNode = currentNode.get(part)!.children;
}
}
return tree;
}
const tree = createTree(routes);
const currentRoute = useRoute();
const getDefaultOpenedDirectories = (): Set<string> => {
if (!currentRoute.meta.isStory) {
return new Set<string>();
}
const openedDirectories = new Set<string>();
const parts = currentRoute.path.split("/");
let currentPath = "";
for (const part of parts) {
currentPath = currentPath ? `${currentPath}/${part}` : part;
openedDirectories.add(currentPath);
}
return openedDirectories;
};
const openedDirectories = ref(getDefaultOpenedDirectories());
const toggleDirectory = (directory: string) => {
if (openedDirectories.value.has(directory)) {
openedDirectories.value.delete(directory);
} else {
openedDirectories.value.add(directory);
}
};
</script>
<style lang="postcss" scoped>
.links {
padding: 1rem;
}
.link {
display: inline-block;
padding: 0.5rem 1rem;
text-decoration: none;
font-size: 1.6rem;
}
</style>

View File

@@ -1,83 +0,0 @@
<template>
<ul class="story-menu-tree">
<li v-for="[key, node] in tree" :key="key">
<span
v-if="node.children.size > 0"
class="directory"
@click="emit('toggle-directory', node.directory)"
>
<UiIcon
:icon="isOpen(node.directory) ? faFolderOpen : faFolderClosed"
/>
{{ formatName(key) }}
</span>
<RouterLink v-else :to="node.path" class="link">
<UiIcon :icon="faFile" />
{{ formatName(key) }}
</RouterLink>
<StoryMenuTree
v-if="isOpen(node.directory)"
:tree="node.children"
@toggle-directory="emit('toggle-directory', $event)"
:opened-directories="openedDirectories"
/>
</li>
</ul>
</template>
<script lang="ts" setup>
import type { StoryTree } from "@/components/component-story/StoryMenu.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import {
faFile,
faFolderClosed,
faFolderOpen,
} from "@fortawesome/free-regular-svg-icons";
const props = defineProps<{
tree: StoryTree;
openedDirectories: Set<string>;
}>();
const emit = defineEmits<{
(event: "toggle-directory", directory: string): void;
}>();
const isOpen = (directory: string) => props.openedDirectories.has(directory);
const formatName = (name: string) => {
const parts = name.split("-");
return parts.map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
};
</script>
<style lang="postcss" scoped>
.story-menu-tree {
padding-left: 1rem;
.story-menu-tree {
padding-left: 2.2rem;
}
}
.directory {
font-weight: 500;
}
.link {
padding: 0.5rem 0;
}
.directory {
padding: 0.5rem 0;
}
.link,
.directory {
cursor: pointer;
text-decoration: none;
font-size: 1.6rem;
display: inline-block;
}
</style>

View File

@@ -28,10 +28,10 @@
import InfraAction from "@/components/infra/InfraAction.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import InfraVmList from "@/components/infra/InfraVmList.vue";
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
import { vTooltip } from "@/directives/tooltip.directive";
import type { XenApiHost } from "@/libs/xen-api";
import { useHostStore } from "@/stores/host.store";
import { usePoolStore } from "@/stores/pool.store";
import { useUiStore } from "@/stores/ui.store";
import {
faAngleDown,
@@ -46,10 +46,11 @@ const props = defineProps<{
hostOpaqueRef: XenApiHost["$ref"];
}>();
const { getByOpaqueRef } = useHostCollection();
const { getByOpaqueRef } = useHostStore().subscribe();
const host = computed(() => getByOpaqueRef(props.hostOpaqueRef));
const { pool } = usePoolCollection();
const { pool } = usePoolStore().subscribe();
const isPoolMaster = computed(() => pool.value?.master === props.hostOpaqueRef);
const uiStore = useUiStore();

View File

@@ -16,9 +16,9 @@
<script lang="ts" setup>
import InfraHostItem from "@/components/infra/InfraHostItem.vue";
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { useHostStore } from "@/stores/host.store";
const { records: hosts, isReady, hasError } = useHostCollection();
const { records: hosts, isReady, hasError } = useHostStore().subscribe();
</script>
<style lang="postcss" scoped>

View File

@@ -28,10 +28,10 @@ import InfraHostList from "@/components/infra/InfraHostList.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue";
import InfraVmList from "@/components/infra/InfraVmList.vue";
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
import { usePoolStore } from "@/stores/pool.store";
import { faBuilding } from "@fortawesome/free-regular-svg-icons";
const { isReady, hasError, pool } = usePoolCollection();
const { isReady, hasError, pool } = usePoolStore().subscribe();
</script>
<style lang="postcss" scoped>

View File

@@ -19,8 +19,8 @@
import InfraAction from "@/components/infra/InfraAction.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import PowerStateIcon from "@/components/PowerStateIcon.vue";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import type { XenApiVm } from "@/libs/xen-api";
import { useVmStore } from "@/stores/vm.store";
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import { useIntersectionObserver } from "@vueuse/core";
import { computed, ref } from "vue";
@@ -29,7 +29,7 @@ const props = defineProps<{
vmOpaqueRef: XenApiVm["$ref"];
}>();
const { getByOpaqueRef } = useVmCollection();
const { getByOpaqueRef } = useVmStore().subscribe();
const vm = computed(() => getByOpaqueRef(props.vmOpaqueRef));
const rootElement = ref();
const isVisible = ref(false);

View File

@@ -11,8 +11,8 @@
<script lang="ts" setup>
import InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue";
import InfraVmItem from "@/components/infra/InfraVmItem.vue";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import type { XenApiHost } from "@/libs/xen-api";
import { useVmStore } from "@/stores/vm.store";
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
@@ -20,7 +20,7 @@ const props = defineProps<{
hostOpaqueRef?: XenApiHost["$ref"];
}>();
const { isReady, recordsByHostRef, hasError } = useVmCollection();
const { isReady, recordsByHostRef, hasError } = useVmStore().subscribe();
const vms = computed(() =>
recordsByHostRef.value.get(

View File

@@ -5,12 +5,12 @@
</template>
<script lang="ts" setup>
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
import { computed } from "vue";
import { faBuilding } from "@fortawesome/free-regular-svg-icons";
import TitleBar from "@/components/TitleBar.vue";
import { usePoolStore } from "@/stores/pool.store";
const { pool } = usePoolCollection();
const { pool } = usePoolStore().subscribe();
const name = computed(() => pool.value?.name_label ?? "...");
</script>

View File

@@ -33,7 +33,7 @@
<script lang="ts" setup>
import RouterTab from "@/components/RouterTab.vue";
import UiTabBar from "@/components/ui/UiTabBar.vue";
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
import { usePoolStore } from "@/stores/pool.store";
const { pool, isReady } = usePoolCollection();
const { pool, isReady } = usePoolStore().subscribe();
</script>

View File

@@ -37,11 +37,12 @@ import UiCard from "@/components/ui/UiCard.vue";
import UiCardFooter from "@/components/ui/UiCardFooter.vue";
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import { useVmMetricsCollection } from "@/composables/xen-api-collection/vm-metrics-collection.composable";
import { percent } from "@/libs/utils";
import { POWER_STATE } from "@/libs/xen-api";
import { useHostMetricsStore } from "@/stores/host-metrics.store";
import { useHostStore } from "@/stores/host.store";
import { useVmMetricsStore } from "@/stores/vm-metrics.store";
import { useVmStore } from "@/stores/vm.store";
import { logicAnd } from "@vueuse/math";
import { computed } from "vue";
@@ -51,16 +52,18 @@ const {
hasError: hostStoreHasError,
isReady: isHostStoreReady,
runningHosts,
} = useHostCollection();
} = useHostStore().subscribe({
hostMetricsSubscription: useHostMetricsStore().subscribe(),
});
const {
hasError: vmStoreHasError,
isReady: isVmStoreReady,
records: vms,
} = useVmCollection();
} = useVmStore().subscribe();
const { getByOpaqueRef: getVmMetrics, isReady: isVmMetricsStoreReady } =
useVmMetricsCollection();
useVmMetricsStore().subscribe();
const nPCpu = computed(() =>
runningHosts.value.reduce(

View File

@@ -11,20 +11,20 @@
</UiCard>
</template>
<script lang="ts" setup>
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import { vTooltip } from "@/directives/tooltip.directive";
import HostsCpuUsage from "@/components/pool/dashboard/cpuUsage/HostsCpuUsage.vue";
import VmsCpuUsage from "@/components/pool/dashboard/cpuUsage/VmsCpuUsage.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { useHostStore } from "@/stores/host.store";
import { useVmStore } from "@/stores/vm.store";
import { computed, inject, type ComputedRef } from "vue";
import type { Stat } from "@/composables/fetch-stats.composable";
import type { HostStats, VmStats } from "@/libs/xapi-stats";
import UiSpinner from "@/components/ui/UiSpinner.vue";
const { hasError: hasVmError } = useVmCollection();
const { hasError: hasHostError } = useHostCollection();
const { hasError: hasVmError } = useVmStore().subscribe();
const { hasError: hasHostError } = useHostStore().subscribe();
const vmStats = inject<ComputedRef<Stat<VmStats>[]>>(
"vmStats",

View File

@@ -12,21 +12,21 @@
</template>
<script lang="ts" setup>
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import { vTooltip } from "@/directives/tooltip.directive";
import HostsRamUsage from "@/components/pool/dashboard/ramUsage/HostsRamUsage.vue";
import VmsRamUsage from "@/components/pool/dashboard/ramUsage/VmsRamUsage.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { useHostStore } from "@/stores/host.store";
import { useVmStore } from "@/stores/vm.store";
import { computed, inject } from "vue";
import type { ComputedRef } from "vue";
import type { HostStats, VmStats } from "@/libs/xapi-stats";
import type { Stat } from "@/composables/fetch-stats.composable";
import UiSpinner from "@/components/ui/UiSpinner.vue";
const { hasError: hasVmError } = useVmCollection();
const { hasError: hasHostError } = useHostCollection();
const { hasError: hasVmError } = useVmStore().subscribe();
const { hasError: hasHostError } = useHostStore().subscribe();
const vmStats = inject<ComputedRef<Stat<VmStats>[]>>(
"vmStats",

View File

@@ -26,21 +26,21 @@ import UiCard from "@/components/ui/UiCard.vue";
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UiSeparator from "@/components/ui/UiSeparator.vue";
import { useHostMetricsCollection } from "@/composables/xen-api-collection/host-metrics-collection.composable";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import { useHostMetricsStore } from "@/stores/host-metrics.store";
import { useVmStore } from "@/stores/vm.store";
import { computed } from "vue";
const {
isReady: isVmReady,
records: vms,
hasError: hasVmError,
} = useVmCollection();
} = useVmStore().subscribe();
const {
isReady: isHostMetricsReady,
records: hostMetrics,
hasError: hasHostMetricsError,
} = useHostMetricsCollection();
} = useHostMetricsStore().subscribe();
const hasError = computed(() => hasVmError.value || hasHostMetricsError.value);

View File

@@ -23,11 +23,11 @@ import SizeStatsSummary from "@/components/ui/SizeStatsSummary.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UsageBar from "@/components/UsageBar.vue";
import { useSrCollection } from "@/composables/xen-api-collection/sr-collection.composable";
import { useSrStore } from "@/stores/storage.store";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import { computed } from "vue";
const { records: srs, isReady, hasError } = useSrCollection();
const { records: srs, isReady, hasError } = useSrStore().subscribe();
const data = computed<{
result: { id: string; label: string; value: number }[];

View File

@@ -9,9 +9,9 @@
import TasksTable from "@/components/tasks/TasksTable.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { useTaskCollection } from "@/composables/xen-api-collection/task-collection.composable";
import { useTaskStore } from "@/stores/task.store";
const { pendingTasks } = useTaskCollection();
const { pendingTasks } = useTaskStore().subscribe();
</script>
<style lang="postcss" scoped></style>

View File

@@ -12,13 +12,13 @@
import NoDataError from "@/components/NoDataError.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UsageBar from "@/components/UsageBar.vue";
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { getAvgCpuUsage } from "@/libs/utils";
import { useHostStore } from "@/stores/host.store";
import { IK_HOST_STATS } from "@/types/injection-keys";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import { computed, type ComputedRef, inject } from "vue";
const { hasError } = useHostCollection();
const { hasError } = useHostStore().subscribe();
const stats = inject(
IK_HOST_STATS,

View File

@@ -12,9 +12,9 @@
</template>
<script lang="ts" setup>
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import type { HostStats } from "@/libs/xapi-stats";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import { useHostStore } from "@/stores/host.store";
import type { LinearChartData, ValueFormatter } from "@/types/chart";
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
import { sumBy } from "lodash-es";
@@ -29,7 +29,7 @@ const { t } = useI18n();
const hostLastWeekStats = inject(IK_HOST_LAST_WEEK_STATS);
const { records: hosts } = useHostCollection();
const { records: hosts } = useHostStore().subscribe();
const customMaxValue = computed(
() => 100 * sumBy(hosts.value, (host) => +host.cpu_info.cpu_count)

View File

@@ -12,13 +12,13 @@
import NoDataError from "@/components/NoDataError.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UsageBar from "@/components/UsageBar.vue";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import { getAvgCpuUsage } from "@/libs/utils";
import { useVmStore } from "@/stores/vm.store";
import { IK_VM_STATS } from "@/types/injection-keys";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import { computed, type ComputedRef, inject } from "vue";
const { hasError } = useVmCollection();
const { hasError } = useVmStore().subscribe();
const stats = inject(
IK_VM_STATS,

View File

@@ -10,15 +10,15 @@
<script lang="ts" setup>
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { IK_HOST_STATS } from "@/types/injection-keys";
import { type ComputedRef, computed, inject } from "vue";
import UsageBar from "@/components/UsageBar.vue";
import { formatSize, parseRamUsage } from "@/libs/utils";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import NoDataError from "@/components/NoDataError.vue";
import { useHostStore } from "@/stores/host.store";
const { hasError } = useHostCollection();
const { hasError } = useHostStore().subscribe();
const stats = inject(
IK_HOST_STATS,

View File

@@ -17,10 +17,10 @@
<script lang="ts" setup>
import SizeStatsSummary from "@/components/ui/SizeStatsSummary.vue";
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { useHostMetricsCollection } from "@/composables/xen-api-collection/host-metrics-collection.composable";
import { formatSize } from "@/libs/utils";
import { formatSize, getHostMemory } from "@/libs/utils";
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
import { useHostMetricsStore } from "@/stores/host-metrics.store";
import { useHostStore } from "@/stores/host.store";
import type { LinearChartData, ValueFormatter } from "@/types/chart";
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
import { sumBy } from "lodash-es";
@@ -31,22 +31,27 @@ const LinearChart = defineAsyncComponent(
() => import("@/components/charts/LinearChart.vue")
);
const { runningHosts } = useHostCollection();
const { getHostMemory } = useHostMetricsCollection();
const hostMetricsSubscription = useHostMetricsStore().subscribe();
const hostStore = useHostStore();
const { runningHosts } = hostStore.subscribe({ hostMetricsSubscription });
const { t } = useI18n();
const hostLastWeekStats = inject(IK_HOST_LAST_WEEK_STATS);
const customMaxValue = computed(() =>
sumBy(runningHosts.value, (host) => getHostMemory(host)?.size ?? 0)
sumBy(
runningHosts.value,
(host) => getHostMemory(host, hostMetricsSubscription)?.size ?? 0
)
);
const currentData = computed(() => {
let size = 0,
usage = 0;
runningHosts.value.forEach((host) => {
const hostMemory = getHostMemory(host);
const hostMemory = getHostMemory(host, hostMetricsSubscription);
size += hostMemory?.size ?? 0;
usage += hostMemory?.usage ?? 0;
});

View File

@@ -12,13 +12,13 @@
import NoDataError from "@/components/NoDataError.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UsageBar from "@/components/UsageBar.vue";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import { formatSize, parseRamUsage } from "@/libs/utils";
import { useVmStore } from "@/stores/vm.store";
import { IK_VM_STATS } from "@/types/injection-keys";
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
import { computed, type ComputedRef, inject } from "vue";
const { hasError } = useVmCollection();
const { hasError } = useVmStore().subscribe();
const stats = inject(
IK_VM_STATS,

View File

@@ -34,9 +34,9 @@
<script lang="ts" setup>
import RelativeTime from "@/components/RelativeTime.vue";
import UiProgressBar from "@/components/ui/progress/UiProgressBar.vue";
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { parseDateTime } from "@/libs/utils";
import type { XenApiTask } from "@/libs/xen-api";
import { useHostStore } from "@/stores/host.store";
import { computed } from "vue";
const props = defineProps<{
@@ -44,7 +44,7 @@ const props = defineProps<{
task: XenApiTask;
}>();
const { getByOpaqueRef: getHost } = useHostCollection();
const { getByOpaqueRef: getHost } = useHostStore().subscribe();
const createdAt = computed(() => parseDateTime(props.task.created));

View File

@@ -40,8 +40,8 @@
import TaskRow from "@/components/tasks/TaskRow.vue";
import UiSpinner from "@/components/ui/UiSpinner.vue";
import UiTable from "@/components/ui/UiTable.vue";
import { useTaskCollection } from "@/composables/xen-api-collection/task-collection.composable";
import type { XenApiTask } from "@/libs/xen-api";
import { useTaskStore } from "@/stores/task.store";
import { computed } from "vue";
const props = defineProps<{
@@ -49,7 +49,7 @@ const props = defineProps<{
finishedTasks?: XenApiTask[];
}>();
const { hasError, isFetching } = useTaskCollection();
const { hasError, isFetching } = useTaskStore().subscribe();
const hasTasks = computed(
() => props.pendingTasks.length > 0 || (props.finishedTasks?.length ?? 0) > 0

View File

@@ -12,9 +12,10 @@
<script lang="ts" setup>
import MenuItem from "@/components/menu/MenuItem.vue";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import { vTooltip } from "@/directives/tooltip.directive";
import { isOperationsPending } from "@/libs/utils";
import { POWER_STATE, VM_OPERATION, type XenApiVm } from "@/libs/xen-api";
import { useVmStore } from "@/stores/vm.store";
import { useXenApiStore } from "@/stores/xen-api.store";
import { faCopy } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
@@ -23,7 +24,7 @@ const props = defineProps<{
selectedRefs: XenApiVm["$ref"][];
}>();
const { getByOpaqueRef, isOperationPending } = useVmCollection();
const { getByOpaqueRef } = useVmStore().subscribe();
const selectedVms = computed(() =>
props.selectedRefs
@@ -38,7 +39,7 @@ const areAllSelectedVmsHalted = computed(() =>
);
const areSomeSelectedVmsCloning = computed(() =>
selectedVms.value.some((vm) => isOperationPending(vm, VM_OPERATION.CLONE))
selectedVms.value.some((vm) => isOperationsPending(vm, VM_OPERATION.CLONE))
);
const handleCopy = async () => {

View File

@@ -35,11 +35,11 @@
<script lang="ts" setup>
import MenuItem from "@/components/menu/MenuItem.vue";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import { POWER_STATE } from "@/libs/xen-api";
import UiButton from "@/components/ui/UiButton.vue";
import UiModal from "@/components/ui/UiModal.vue";
import useModal from "@/composables/modal.composable";
import { useVmStore } from "@/stores/vm.store";
import { useXenApiStore } from "@/stores/xen-api.store";
import { faSatellite, faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
@@ -51,7 +51,7 @@ const props = defineProps<{
}>();
const xenApi = useXenApiStore().getXapi();
const { getByOpaqueRef: getVm } = useVmCollection();
const { getByOpaqueRef: getVm } = useVmStore().subscribe();
const {
open: openDeleteModal,
close: closeDeleteModal,

View File

@@ -27,7 +27,6 @@
</template>
<script lang="ts" setup>
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import { computed } from "vue";
import { exportVmsAsCsvFile, exportVmsAsJsonFile } from "@/libs/vm";
import MenuItem from "@/components/menu/MenuItem.vue";
@@ -37,6 +36,7 @@ import {
faFileCsv,
faFileExport,
} from "@fortawesome/free-solid-svg-icons";
import { useVmStore } from "@/stores/vm.store";
import { vTooltip } from "@/directives/tooltip.directive";
import type { XenApiVm } from "@/libs/xen-api";
@@ -44,7 +44,7 @@ const props = defineProps<{
vmRefs: XenApiVm["$ref"][];
}>();
const { getByOpaqueRef: getVm } = useVmCollection();
const { getByOpaqueRef: getVm } = useVmStore().subscribe();
const vms = computed(() =>
props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined)
);

View File

@@ -95,12 +95,13 @@
import MenuItem from "@/components/menu/MenuItem.vue";
import PowerStateIcon from "@/components/PowerStateIcon.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { useHostMetricsCollection } from "@/composables/xen-api-collection/host-metrics-collection.composable";
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import { isHostRunning, isOperationsPending } from "@/libs/utils";
import type { XenApiHost, XenApiVm } from "@/libs/xen-api";
import { POWER_STATE, VM_OPERATION } from "@/libs/xen-api";
import { useHostMetricsStore } from "@/stores/host-metrics.store";
import { useHostStore } from "@/stores/host.store";
import { usePoolStore } from "@/stores/pool.store";
import { useVmStore } from "@/stores/vm.store";
import { useXenApiStore } from "@/stores/xen-api.store";
import {
faCirclePlay,
@@ -120,12 +121,12 @@ const props = defineProps<{
vmRefs: XenApiVm["$ref"][];
}>();
const { getByOpaqueRef: getVm, isOperationPending } = useVmCollection();
const { records: hosts } = useHostCollection();
const { pool } = usePoolCollection();
const { isHostRunning } = useHostMetricsCollection();
const { getByOpaqueRef: getVm } = useVmStore().subscribe();
const { records: hosts } = useHostStore().subscribe();
const { pool } = usePoolStore().subscribe();
const hostMetricsSubscription = useHostMetricsStore().subscribe();
const vms = computed(() =>
const vms = computed<XenApiVm[]>(() =>
props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined)
);
@@ -149,7 +150,7 @@ const areVmsPaused = computed(() =>
);
const areOperationsPending = (operation: VM_OPERATION | VM_OPERATION[]) =>
vms.value.some((vm) => isOperationPending(vm, operation));
vms.value.some((vm) => isOperationsPending(vm, operation));
const areVmsBusyToStart = computed(() =>
areOperationsPending(VM_OPERATION.START)
@@ -179,7 +180,9 @@ const areVmsBusyToForceShutdown = computed(() =>
areOperationsPending(VM_OPERATION.HARD_SHUTDOWN)
);
const getHostState = (host: XenApiHost) =>
isHostRunning(host) ? POWER_STATE.RUNNING : POWER_STATE.HALTED;
isHostRunning(host, hostMetricsSubscription)
? POWER_STATE.RUNNING
: POWER_STATE.HALTED;
</script>
<style lang="postcss" scoped>

View File

@@ -20,8 +20,8 @@ import AppMenu from "@/components/menu/AppMenu.vue";
import TitleBar from "@/components/TitleBar.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { useVmStore } from "@/stores/vm.store";
import VmActionPowerStateItems from "@/components/vm/VmActionItems/VmActionPowerStateItems.vue";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import type { XenApiVm } from "@/libs/xen-api";
import {
faAngleDown,
@@ -31,7 +31,7 @@ import {
import { computed } from "vue";
import { useRouter } from "vue-router";
const { getByUuid: getVmByUuid } = useVmCollection();
const { getByUuid: getVmByUuid } = useVmStore().subscribe();
const { currentRoute } = useRouter();
const vm = computed(() =>

View File

@@ -17,9 +17,9 @@ export type Stat<T> = {
pausable: Pausable;
};
export type GetStats<
type GetStats<
T extends XenApiHost | XenApiVm,
S extends HostStats | VmStats = T extends XenApiHost ? HostStats : VmStats,
S extends HostStats | VmStats
> = (
uuid: T["uuid"],
granularity: GRANULARITY,
@@ -29,7 +29,7 @@ export type GetStats<
export type FetchedStats<
T extends XenApiHost | XenApiVm,
S extends HostStats | VmStats = T extends XenApiHost ? HostStats : VmStats,
S extends HostStats | VmStats
> = {
register: (object: T) => void;
unregister: (object: T) => void;
@@ -40,7 +40,7 @@ export type FetchedStats<
export default function useFetchStats<
T extends XenApiHost | XenApiVm,
S extends HostStats | VmStats = T extends XenApiHost ? HostStats : VmStats,
S extends HostStats | VmStats
>(getStats: GetStats<T, S>, granularity: GRANULARITY): FetchedStats<T, S> {
const stats = ref<Map<string, Stat<S>>>(new Map());
const timestamp = ref<number[]>([0, 0]);
@@ -108,7 +108,7 @@ export default function useFetchStats<
return {
register,
unregister,
stats: computed(() => Array.from(stats.value.values()) as Stat<S>[]),
stats: computed<Stat<S>[]>(() => Array.from(stats.value.values())),
timestampStart: computed(() => timestamp.value[0]),
timestampEnd: computed(() => timestamp.value[1]),
};

View File

@@ -1,63 +0,0 @@
import type { RawObjectType } from "@/libs/xen-api";
import { getXenApiCollection } from "@/libs/xen-api-collection";
import type {
RawTypeToRecord,
XenApiBaseCollection,
XenApiCollectionManager,
} from "@/types/xen-api-collection";
import { tryOnUnmounted } from "@vueuse/core";
import { computed } from "vue";
export const useXenApiCollection = <
ObjectType extends RawObjectType,
Record extends RawTypeToRecord<ObjectType>,
Immediate extends boolean,
>(
type: ObjectType,
options?: { immediate?: Immediate }
): XenApiBaseCollection<Record, Immediate> => {
const baseCollection = getXenApiCollection(type);
const isDeferred = options?.immediate === false;
const id = Symbol();
const collection = {
records: baseCollection.records,
isFetching: baseCollection.isFetching,
isReloading: baseCollection.isReloading,
isReady: baseCollection.isReady,
hasError: baseCollection.hasError,
hasUuid: baseCollection.hasUuid.bind(baseCollection),
getByUuid: baseCollection.getByUuid.bind(baseCollection),
getByOpaqueRef: baseCollection.getByOpaqueRef.bind(baseCollection),
};
tryOnUnmounted(() => baseCollection.unsubscribe(id));
if (isDeferred) {
return {
...collection,
start: () => baseCollection.subscribe(id),
isStarted: computed(() => baseCollection.hasSubscriptions.value),
} as XenApiBaseCollection<Record, false>;
}
baseCollection.subscribe(id);
return collection as XenApiBaseCollection<Record, Immediate>;
};
export const useXenApiCollectionManager = <
ObjectType extends RawObjectType,
Record extends RawTypeToRecord<ObjectType>,
>(
type: ObjectType
): XenApiCollectionManager<Record> => {
const collection = getXenApiCollection(type);
return {
hasSubscriptions: collection.hasSubscriptions,
add: collection.add.bind(collection),
remove: collection.remove.bind(collection),
update: collection.update.bind(collection),
};
};

View File

@@ -1,3 +0,0 @@
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
export const useConsoleCollection = () => useXenApiCollection("console");

View File

@@ -1,45 +0,0 @@
import type { GetStats } from "@/composables/fetch-stats.composable";
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
import { useHostMetricsCollection } from "@/composables/xen-api-collection/host-metrics-collection.composable";
import type { XenApiHost } from "@/libs/xen-api";
import { useXenApiStore } from "@/stores/xen-api.store";
import { computed } from "vue";
export const useHostCollection = () => {
const collection = useXenApiCollection("host");
const hostMetricsCollection = useHostMetricsCollection();
return {
...collection,
runningHosts: computed(() =>
collection.records.value.filter((host) =>
hostMetricsCollection.isHostRunning(host)
)
),
getStats: ((
hostUuid,
granularity,
ignoreExpired = false,
{ abortSignal }
) => {
const xenApiStore = useXenApiStore();
const host = collection.getByUuid(hostUuid);
if (host === undefined) {
throw new Error(`Host ${hostUuid} could not be found.`);
}
const xapiStats = xenApiStore.isConnected
? xenApiStore.getXapiStats()
: undefined;
return xapiStats?._getAndUpdateStats({
abortSignal,
host,
ignoreExpired,
uuid: host.uuid,
granularity,
});
}) as GetStats<XenApiHost>,
};
};

View File

@@ -1,24 +0,0 @@
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
import type { XenApiHost } from "@/libs/xen-api";
export const useHostMetricsCollection = () => {
const collection = useXenApiCollection("host_metrics");
return {
...collection,
getHostMemory: (host: XenApiHost) => {
const hostMetrics = collection.getByOpaqueRef(host.metrics);
if (hostMetrics !== undefined) {
const total = +hostMetrics.memory_total;
return {
usage: total - +hostMetrics.memory_free,
size: total,
};
}
},
isHostRunning: (host: XenApiHost) => {
return collection.getByOpaqueRef(host.metrics)?.live === true;
},
};
};

View File

@@ -1,14 +0,0 @@
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
import type { XenApiPool } from "@/libs/xen-api";
import { computed } from "vue";
export const usePoolCollection = () => {
const poolCollection = useXenApiCollection("pool");
return {
...poolCollection,
pool: computed<XenApiPool | undefined>(
() => poolCollection.records.value[0]
),
};
};

View File

@@ -1,3 +0,0 @@
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
export const useSrCollection = () => useXenApiCollection("SR");

View File

@@ -1,41 +0,0 @@
import useArrayRemovedItemsHistory from "@/composables/array-removed-items-history.composable";
import useCollectionFilter from "@/composables/collection-filter.composable";
import useCollectionSorter from "@/composables/collection-sorter.composable";
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
import useFilteredCollection from "@/composables/filtered-collection.composable";
import useSortedCollection from "@/composables/sorted-collection.composable";
import type { XenApiTask } from "@/libs/xen-api";
export const useTaskCollection = () => {
const collection = useXenApiCollection("task");
const { compareFn } = useCollectionSorter<XenApiTask>({
initialSorts: ["-created"],
});
const { predicate } = useCollectionFilter({
initialFilters: [
"!name_label:|(SR.scan host.call_plugin)",
"status:pending",
],
});
const sortedTasks = useSortedCollection(collection.records, compareFn);
return {
...collection,
pendingTasks: useFilteredCollection<XenApiTask>(sortedTasks, predicate),
finishedTasks: useArrayRemovedItemsHistory(
sortedTasks,
(task) => task.uuid,
{
limit: 50,
onRemove: (tasks) =>
tasks.map((task) => ({
...task,
finished: new Date().toISOString(),
})),
}
),
};
};

View File

@@ -1,83 +0,0 @@
import type { GetStats } from "@/composables/fetch-stats.composable";
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { sortRecordsByNameLabel } from "@/libs/utils";
import type { VmStats } from "@/libs/xapi-stats";
import {
POWER_STATE,
VM_OPERATION,
type XenApiHost,
type XenApiVm,
} from "@/libs/xen-api";
import { useXenApiStore } from "@/stores/xen-api.store";
import { castArray } from "lodash-es";
import { computed } from "vue";
export const useVmCollection = () => {
const collection = useXenApiCollection("VM");
const hostCollection = useHostCollection();
const xenApiStore = useXenApiStore();
const records = computed(() =>
collection.records.value
.filter(
(vm) => !vm.is_a_snapshot && !vm.is_a_template && !vm.is_control_domain
)
.sort(sortRecordsByNameLabel)
);
return {
...collection,
records,
isOperationPending: (
vm: XenApiVm,
operations: VM_OPERATION[] | VM_OPERATION
) => {
const currentOperations = Object.values(vm.current_operations);
return castArray(operations).some((operation) =>
currentOperations.includes(operation)
);
},
runningVms: computed(() =>
records.value.filter((vm) => vm.power_state === POWER_STATE.RUNNING)
),
recordsByHostRef: computed(() => {
const vmsByHostOpaqueRef = new Map<XenApiHost["$ref"], XenApiVm[]>();
records.value.forEach((vm) => {
if (!vmsByHostOpaqueRef.has(vm.resident_on)) {
vmsByHostOpaqueRef.set(vm.resident_on, []);
}
vmsByHostOpaqueRef.get(vm.resident_on)?.push(vm);
});
return vmsByHostOpaqueRef;
}),
getStats: ((id, granularity, ignoreExpired = false, { abortSignal }) => {
if (!xenApiStore.isConnected) {
return undefined;
}
const vm = collection.getByUuid(id);
if (vm === undefined) {
throw new Error(`VM ${id} could not be found.`);
}
const host = hostCollection.getByOpaqueRef(vm.resident_on);
if (host === undefined) {
throw new Error(`VM ${id} is halted or host could not be found.`);
}
return xenApiStore.getXapiStats()._getAndUpdateStats<VmStats>({
abortSignal,
host,
ignoreExpired,
uuid: vm.uuid,
granularity,
});
}) as GetStats<XenApiVm>,
};
};

View File

@@ -1,3 +0,0 @@
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
export const useVmMetricsCollection = () => useXenApiCollection("VM_metrics");

View File

@@ -1,14 +1,18 @@
import type {
RawXenApiRecord,
XenApiHost,
XenApiRecord,
XenApiVm,
VM_OPERATION,
RawObjectType,
} from "@/libs/xen-api";
import type { Filter } from "@/types/filter";
import type { Subscription } from "@/types/xapi-collection";
import { faSquareCheck } from "@fortawesome/free-regular-svg-icons";
import { faFont, faHashtag, faList } from "@fortawesome/free-solid-svg-icons";
import { utcParse } from "d3-time-format";
import humanFormat from "human-format";
import { find, forEach, round, size, sum } from "lodash-es";
import { castArray, find, forEach, round, size, sum } from "lodash-es";
export function sortRecordsByNameLabel(
record1: { name_label: string },
@@ -17,7 +21,14 @@ export function sortRecordsByNameLabel(
const label1 = record1.name_label.toLocaleLowerCase();
const label2 = record2.name_label.toLocaleLowerCase();
return label1.localeCompare(label2);
switch (true) {
case label1 < label2:
return -1;
case label1 > label2:
return 1;
default:
return 0;
}
}
export function escapeRegExp(string: string) {
@@ -103,6 +114,28 @@ export function getStatsLength(stats?: object | any[]) {
return size(find(stats, (stat) => stat != null));
}
export function isHostRunning(
host: XenApiHost,
hostMetricsSubscription: Subscription<"host_metrics", object>
) {
return hostMetricsSubscription.getByOpaqueRef(host.metrics)?.live === true;
}
export function getHostMemory(
host: XenApiHost,
hostMetricsSubscription: Subscription<"host_metrics", object>
) {
const hostMetrics = hostMetricsSubscription.getByOpaqueRef(host.metrics);
if (hostMetrics !== undefined) {
const total = +hostMetrics.memory_total;
return {
usage: total - +hostMetrics.memory_free,
size: total,
};
}
}
export const buildXoObject = <T extends XenApiRecord<RawObjectType>>(
record: RawXenApiRecord<T>,
params: { opaqueRef: T["$ref"] }
@@ -149,3 +182,13 @@ export function parseRamUsage(
export const getFirst = <T>(value: T | T[]): T | undefined =>
Array.isArray(value) ? value[0] : value;
export const isOperationsPending = (
obj: XenApiVm,
operations: VM_OPERATION[] | VM_OPERATION
) => {
const currentOperations = Object.values(obj.current_operations);
return castArray(operations).some((operation) =>
currentOperations.includes(operation)
);
};

View File

@@ -1,112 +0,0 @@
import type { RawObjectType, XenApiRecord } from "@/libs/xen-api";
import { useXenApiStore } from "@/stores/xen-api.store";
import type { RawTypeToRecord } from "@/types/xen-api-collection";
import { whenever } from "@vueuse/core";
import { computed, reactive } from "vue";
const collections = new Map<RawObjectType, XenApiCollection<any>>();
export const getXenApiCollection = <
ObjectType extends RawObjectType,
Record extends RawTypeToRecord<ObjectType> = RawTypeToRecord<ObjectType>,
>(
type: ObjectType
) => {
if (!collections.has(type)) {
collections.set(type, new XenApiCollection(type));
}
return collections.get(type)! as XenApiCollection<Record>;
};
export class XenApiCollection<Record extends XenApiRecord<any>> {
private state = reactive({
isReady: false,
isFetching: false,
lastError: undefined as string | undefined,
subscriptions: new Set<symbol>(),
recordsByOpaqueRef: new Map<Record["$ref"], Record>(),
recordsByUuid: new Map<Record["uuid"], Record>(),
});
isReady = computed(() => this.state.isReady);
isFetching = computed(() => this.state.isFetching);
isReloading = computed(() => this.state.isReady && this.state.isFetching);
lastError = computed(() => this.state.lastError);
hasError = computed(() => this.state.lastError !== undefined);
hasSubscriptions = computed(() => this.state.subscriptions.size > 0);
records = computed(() => Array.from(this.state.recordsByOpaqueRef.values()));
subscribe(id: symbol) {
this.state.subscriptions.add(id);
}
unsubscribe(id: symbol) {
this.state.subscriptions.delete(id);
}
constructor(private type: RawObjectType) {
const xenApiStore = useXenApiStore();
whenever(
() => xenApiStore.isConnected && this.hasSubscriptions.value,
() => this.fetchAll(xenApiStore)
);
}
getByOpaqueRef(opaqueRef: Record["$ref"]) {
return this.state.recordsByOpaqueRef.get(opaqueRef);
}
getByUuid(uuid: Record["uuid"]) {
return this.state.recordsByUuid.get(uuid);
}
hasUuid(uuid: Record["uuid"]) {
return this.state.recordsByUuid.has(uuid);
}
add(record: Record) {
this.state.recordsByOpaqueRef.set(record.$ref, record);
this.state.recordsByUuid.set(record.uuid, record);
}
update(record: Record) {
this.state.recordsByOpaqueRef.set(record.$ref, record);
this.state.recordsByUuid.set(record.uuid, record);
}
remove(opaqueRef: Record["$ref"]) {
if (!this.state.recordsByOpaqueRef.has(opaqueRef)) {
return;
}
const record = this.state.recordsByOpaqueRef.get(opaqueRef)!;
this.state.recordsByOpaqueRef.delete(opaqueRef);
this.state.recordsByUuid.delete(record.uuid);
}
private async fetchAll(xenApiStore: ReturnType<typeof useXenApiStore>) {
try {
this.state.isFetching = true;
this.state.lastError = undefined;
const records = await xenApiStore
.getXapi()
.loadRecords<any, Record>(this.type);
this.state.recordsByOpaqueRef.clear();
this.state.recordsByUuid.clear();
records.forEach((record) => this.add(record));
this.state.isReady = true;
} catch {
this.state.lastError = `[${this.type}] Failed to fetch records`;
} finally {
this.state.isFetching = false;
}
}
}

View File

@@ -1,5 +1,5 @@
import { buildXoObject, parseDateTime } from "@/libs/utils";
import type { RawTypeToRecord } from "@/types/xen-api-collection";
import type { RawTypeToRecord } from "@/types/xapi-collection";
import { JSONRPCClient } from "json-rpc-2.0";
import { castArray } from "lodash-es";
@@ -295,7 +295,7 @@ export default class XenApi {
async loadRecords<
T extends RawObjectType,
R extends RawTypeToRecord<T> = RawTypeToRecord<T>,
R extends RawTypeToRecord<T> = RawTypeToRecord<T>
>(type: T): Promise<R[]> {
const result = await this.#call<{ [key: string]: R }>(
`${type}.get_all_records`,

View File

@@ -1,21 +1,21 @@
import type { RouteRecordRaw } from "vue-router";
const componentLoaders = import.meta.glob("@/stories/**/*.story.vue");
const docLoaders = import.meta.glob("@/stories/**/*.story.md", { as: "raw" });
const componentLoaders = import.meta.glob("@/stories/*.story.vue");
const docLoaders = import.meta.glob("@/stories/*.story.md", { as: "raw" });
const children: RouteRecordRaw[] = Object.entries(componentLoaders).map(
([path, componentLoader]) => {
const basePath = path.replace(/^\/src\/stories\/(.*)\.story.vue$/, "$1");
const basename = path.replace(/^\/src\/stories\/(.*)\.story.vue$/, "$1");
const docPath = path.replace(/\.vue$/, ".md");
const routeName = `story-${basePath}`;
const routeName = `story-${basename}`;
return {
name: routeName,
path: basePath,
path: basename,
component: componentLoader,
meta: {
isStory: true,
storyTitle: basePathToStoryTitle(basePath),
storyTitle: basenameToStoryTitle(basename),
storyMdLoader: docLoaders[docPath],
},
};
@@ -46,10 +46,8 @@ export default {
* Basename: `my-component`
* Page title: `My Component`
*/
function basePathToStoryTitle(basePath: string) {
return basePath
.split("/")
.pop()!
function basenameToStoryTitle(basename: string) {
return basename
.split("-")
.map((s) => `${s.charAt(0).toUpperCase()}${s.substring(1)}`)
.join(" ");

View File

@@ -0,0 +1,30 @@
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { createSubscribe } from "@/types/xapi-collection";
import { defineStore } from "pinia";
import { computed } from "vue";
export const useAlarmStore = defineStore("alarm", () => {
const messageCollection = useXapiCollectionStore().get("message");
const subscribe = createSubscribe<"message", []>((options) => {
const originalSubscription = messageCollection.subscribe(options);
const extendedSubscription = {
records: computed(() =>
originalSubscription.records.value.filter(
(record) => record.name === "alarm"
)
),
};
return {
...originalSubscription,
...extendedSubscription,
};
});
return {
...messageCollection,
subscribe,
};
});

View File

@@ -0,0 +1,6 @@
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { defineStore } from "pinia";
export const useConsoleStore = defineStore("console", () =>
useXapiCollectionStore().get("console")
);

View File

@@ -0,0 +1,6 @@
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { defineStore } from "pinia";
export const useHostMetricsStore = defineStore("host-metrics", () =>
useXapiCollectionStore().get("host_metrics")
);

View File

@@ -0,0 +1,91 @@
import { isHostRunning, sortRecordsByNameLabel } from "@/libs/utils";
import type {
GRANULARITY,
HostStats,
XapiStatsResponse,
} from "@/libs/xapi-stats";
import type { XenApiHost } from "@/libs/xen-api";
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { useXenApiStore } from "@/stores/xen-api.store";
import type { Subscription } from "@/types/xapi-collection";
import { createSubscribe } from "@/types/xapi-collection";
import { defineStore } from "pinia";
import { computed, type ComputedRef } from "vue";
type GetStats = (
hostUuid: XenApiHost["uuid"],
granularity: GRANULARITY,
ignoreExpired: boolean,
opts: { abortSignal?: AbortSignal }
) => Promise<XapiStatsResponse<HostStats> | undefined> | undefined;
type GetStatsExtension = {
getStats: GetStats;
};
type RunningHostsExtension = [
{ runningHosts: ComputedRef<XenApiHost[]> },
{ hostMetricsSubscription: Subscription<"host_metrics", any> }
];
type Extensions = [GetStatsExtension, RunningHostsExtension];
export const useHostStore = defineStore("host", () => {
const xenApiStore = useXenApiStore();
const hostCollection = useXapiCollectionStore().get("host");
hostCollection.setSort(sortRecordsByNameLabel);
const subscribe = createSubscribe<"host", Extensions>((options) => {
const originalSubscription = hostCollection.subscribe(options);
const getStats: GetStats = (
hostUuid,
granularity,
ignoreExpired = false,
{ abortSignal }
) => {
const host = originalSubscription.getByUuid(hostUuid);
if (host === undefined) {
throw new Error(`Host ${hostUuid} could not be found.`);
}
const xapiStats = xenApiStore.isConnected
? xenApiStore.getXapiStats()
: undefined;
return xapiStats?._getAndUpdateStats<HostStats>({
abortSignal,
host,
ignoreExpired,
uuid: host.uuid,
granularity,
});
};
const extendedSubscription = {
getStats,
};
const hostMetricsSubscription = options?.hostMetricsSubscription;
const runningHostsSubscription = hostMetricsSubscription !== undefined && {
runningHosts: computed(() =>
originalSubscription.records.value.filter((host) =>
isHostRunning(host, hostMetricsSubscription)
)
),
};
return {
...originalSubscription,
...extendedSubscription,
...runningHostsSubscription,
};
});
return {
...hostCollection,
subscribe,
};
});

View File

@@ -0,0 +1,34 @@
import { getFirst } from "@/libs/utils";
import type { XenApiPool } from "@/libs/xen-api";
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { createSubscribe } from "@/types/xapi-collection";
import { defineStore } from "pinia";
import { computed, type ComputedRef } from "vue";
type PoolExtension = {
pool: ComputedRef<XenApiPool | undefined>;
};
type Extensions = [PoolExtension];
export const usePoolStore = defineStore("pool", () => {
const poolCollection = useXapiCollectionStore().get("pool");
const subscribe = createSubscribe<"pool", Extensions>((options) => {
const originalSubscription = poolCollection.subscribe(options);
const extendedSubscription = {
pool: computed(() => getFirst(originalSubscription.records.value)),
};
return {
...originalSubscription,
...extendedSubscription,
};
});
return {
...poolCollection,
subscribe,
};
});

View File

@@ -0,0 +1,6 @@
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { defineStore } from "pinia";
export const useSrStore = defineStore("SR", () =>
useXapiCollectionStore().get("SR")
);

View File

@@ -0,0 +1,64 @@
import useArrayRemovedItemsHistory from "@/composables/array-removed-items-history.composable";
import useCollectionFilter from "@/composables/collection-filter.composable";
import useCollectionSorter from "@/composables/collection-sorter.composable";
import useFilteredCollection from "@/composables/filtered-collection.composable";
import useSortedCollection from "@/composables/sorted-collection.composable";
import type { XenApiTask } from "@/libs/xen-api";
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { createSubscribe } from "@/types/xapi-collection";
import { defineStore } from "pinia";
import type { ComputedRef, Ref } from "vue";
type PendingTasksExtension = {
pendingTasks: ComputedRef<XenApiTask[]>;
};
type FinishedTasksExtension = {
finishedTasks: Ref<XenApiTask[]>;
};
type Extensions = [PendingTasksExtension, FinishedTasksExtension];
export const useTaskStore = defineStore("task", () => {
const tasksCollection = useXapiCollectionStore().get("task");
const subscribe = createSubscribe<"task", Extensions>(() => {
const subscription = tasksCollection.subscribe();
const { compareFn } = useCollectionSorter<XenApiTask>({
initialSorts: ["-created"],
});
const sortedTasks = useSortedCollection(subscription.records, compareFn);
const { predicate } = useCollectionFilter({
initialFilters: [
"!name_label:|(SR.scan host.call_plugin)",
"status:pending",
],
});
const extendedSubscription = {
pendingTasks: useFilteredCollection<XenApiTask>(sortedTasks, predicate),
finishedTasks: useArrayRemovedItemsHistory(
sortedTasks,
(task) => task.uuid,
{
limit: 50,
onRemove: (tasks) =>
tasks.map((task) => ({
...task,
finished: new Date().toISOString(),
})),
}
),
};
return {
...subscription,
...extendedSubscription,
};
});
return { ...tasksCollection, subscribe };
});

View File

@@ -0,0 +1,6 @@
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { defineStore } from "pinia";
export const useVmGuestMetricsStore = defineStore("vm-guest-metrics", () =>
useXapiCollectionStore().get("VM_guest_metrics")
);

View File

@@ -0,0 +1,6 @@
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { defineStore } from "pinia";
export const useVmMetricsStore = defineStore("vm-metrics", () =>
useXapiCollectionStore().get("VM_metrics")
);

View File

@@ -0,0 +1,125 @@
import { sortRecordsByNameLabel } from "@/libs/utils";
import type {
GRANULARITY,
VmStats,
XapiStatsResponse,
} from "@/libs/xapi-stats";
import type { XenApiHost, XenApiVm } from "@/libs/xen-api";
import { POWER_STATE } from "@/libs/xen-api";
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { useXenApiStore } from "@/stores/xen-api.store";
import { createSubscribe, type Subscription } from "@/types/xapi-collection";
import { defineStore } from "pinia";
import { computed, type ComputedRef } from "vue";
type GetStats = (
id: XenApiVm["uuid"],
granularity: GRANULARITY,
ignoreExpired: boolean,
opts: { abortSignal?: AbortSignal }
) => Promise<XapiStatsResponse<VmStats> | undefined> | undefined;
type DefaultExtension = {
recordsByHostRef: ComputedRef<Map<XenApiHost["$ref"], XenApiVm[]>>;
runningVms: ComputedRef<XenApiVm[]>;
};
type GetStatsExtension = [
{
getStats: GetStats;
},
{ hostSubscription: Subscription<"host", object> }
];
type Extensions = [DefaultExtension, GetStatsExtension];
export const useVmStore = defineStore("vm", () => {
const vmCollection = useXapiCollectionStore().get("VM");
vmCollection.setFilter(
(vm) => !vm.is_a_snapshot && !vm.is_a_template && !vm.is_control_domain
);
vmCollection.setSort(sortRecordsByNameLabel);
const subscribe = createSubscribe<"VM", Extensions>((options) => {
const originalSubscription = vmCollection.subscribe(options);
const extendedSubscription = {
recordsByHostRef: computed(() => {
const vmsByHostOpaqueRef = new Map<XenApiHost["$ref"], XenApiVm[]>();
originalSubscription.records.value.forEach((vm) => {
if (!vmsByHostOpaqueRef.has(vm.resident_on)) {
vmsByHostOpaqueRef.set(vm.resident_on, []);
}
vmsByHostOpaqueRef.get(vm.resident_on)?.push(vm);
});
return vmsByHostOpaqueRef;
}),
runningVms: computed(() =>
originalSubscription.records.value.filter(
(vm) => vm.power_state === POWER_STATE.RUNNING
)
),
};
const hostSubscription = options?.hostSubscription;
const getStatsSubscription:
| {
getStats: GetStats;
}
| undefined =
hostSubscription !== undefined
? {
getStats: (
id,
granularity,
ignoreExpired = false,
{ abortSignal }
) => {
const xenApiStore = useXenApiStore();
if (!xenApiStore.isConnected) {
return undefined;
}
const vm = originalSubscription.getByUuid(id);
if (vm === undefined) {
throw new Error(`VM ${id} could not be found.`);
}
const host = hostSubscription.getByOpaqueRef(vm.resident_on);
if (host === undefined) {
throw new Error(
`VM ${id} is halted or host could not be found.`
);
}
return xenApiStore.getXapiStats()._getAndUpdateStats<VmStats>({
abortSignal,
host,
ignoreExpired,
uuid: vm.uuid,
granularity,
});
},
}
: undefined;
return {
...originalSubscription,
...extendedSubscription,
...getStatsSubscription,
};
});
return {
...vmCollection,
subscribe,
};
});

View File

@@ -0,0 +1,156 @@
import type { RawObjectType } from "@/libs/xen-api";
import { useXenApiStore } from "@/stores/xen-api.store";
import type {
RawTypeToRecord,
SubscribeOptions,
Subscription,
} from "@/types/xapi-collection";
import { tryOnUnmounted, whenever } from "@vueuse/core";
import { defineStore } from "pinia";
import { computed, readonly, ref } from "vue";
export const useXapiCollectionStore = defineStore("xapiCollection", () => {
const collections = ref(new Map());
function get<T extends RawObjectType>(
type: T
): ReturnType<typeof createXapiCollection<T, RawTypeToRecord<T>>> {
if (!collections.value.has(type)) {
collections.value.set(type, createXapiCollection(type));
}
return collections.value.get(type)!;
}
return { get };
});
const createXapiCollection = <
T extends RawObjectType,
R extends RawTypeToRecord<T>
>(
type: T
) => {
const isReady = ref(false);
const isFetching = ref(false);
const isReloading = computed(() => isReady.value && isFetching.value);
const lastError = ref<string>();
const hasError = computed(() => lastError.value !== undefined);
const subscriptions = ref(new Set<symbol>());
const recordsByOpaqueRef = ref(new Map<R["$ref"], R>());
const recordsByUuid = ref(new Map<R["uuid"], R>());
const filter = ref<(record: R) => boolean>();
const sort = ref<(record1: R, record2: R) => 1 | 0 | -1>();
const xenApiStore = useXenApiStore();
const setFilter = (newFilter: (record: R) => boolean) =>
(filter.value = newFilter);
const setSort = (newSort: (record1: R, record2: R) => 1 | 0 | -1) =>
(sort.value = newSort);
const records = computed<R[]>(() => {
const records = Array.from(recordsByOpaqueRef.value.values()).sort(
sort.value
);
return filter.value !== undefined ? records.filter(filter.value) : records;
});
const getByOpaqueRef = (opaqueRef: R["$ref"]) =>
recordsByOpaqueRef.value.get(opaqueRef);
const getByUuid = (uuid: R["uuid"]) => recordsByUuid.value.get(uuid);
const hasUuid = (uuid: R["uuid"]) => recordsByUuid.value.has(uuid);
const hasSubscriptions = computed(() => subscriptions.value.size > 0);
const fetchAll = async () => {
try {
isFetching.value = true;
lastError.value = undefined;
const records = await xenApiStore.getXapi().loadRecords<T, R>(type);
recordsByOpaqueRef.value.clear();
recordsByUuid.value.clear();
records.forEach(add);
isReady.value = true;
} catch (e) {
lastError.value = `[${type}] Failed to fetch records`;
} finally {
isFetching.value = false;
}
};
const add = (record: R) => {
recordsByOpaqueRef.value.set(record.$ref, record);
recordsByUuid.value.set(record.uuid, record);
};
const update = (record: R) => {
recordsByOpaqueRef.value.set(record.$ref, record);
recordsByUuid.value.set(record.uuid, record);
};
const remove = (opaqueRef: R["$ref"]) => {
if (!recordsByOpaqueRef.value.has(opaqueRef)) {
return;
}
const record = recordsByOpaqueRef.value.get(opaqueRef)!;
recordsByOpaqueRef.value.delete(opaqueRef);
recordsByUuid.value.delete(record.uuid);
};
whenever(
() => xenApiStore.isConnected && hasSubscriptions.value,
() => fetchAll()
);
function subscribe<O extends SubscribeOptions<any>>(
options?: O
): Subscription<T, O> {
const id = Symbol();
tryOnUnmounted(() => {
unsubscribe(id);
});
const subscription = {
records,
getByOpaqueRef,
getByUuid,
hasUuid,
isReady: readonly(isReady),
isFetching: readonly(isFetching),
isReloading: isReloading,
hasError,
lastError: readonly(lastError),
};
const start = () => subscriptions.value.add(id);
if (options?.immediate !== false) {
start();
return subscription as unknown as Subscription<T, O>;
}
return {
...subscription,
start,
isStarted: computed(() => subscriptions.value.has(id)),
} as unknown as Subscription<T, O>;
}
const unsubscribe = (id: symbol) => subscriptions.value.delete(id);
return {
hasSubscriptions,
subscribe,
unsubscribe,
add,
update,
remove,
setFilter,
setSort,
};
};

View File

@@ -1,7 +1,7 @@
import { useXenApiCollectionManager } from "@/composables/xen-api-collection.composable";
import { buildXoObject } from "@/libs/utils";
import XapiStats from "@/libs/xapi-stats";
import XenApi, { getRawObjectType } from "@/libs/xen-api";
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { useLocalStorage } from "@vueuse/core";
import { defineStore } from "pinia";
import { computed, ref } from "vue";
@@ -31,11 +31,11 @@ export const useXenApiStore = defineStore("xen-api", () => {
xenApi.registerWatchCallBack((results) => {
results.forEach((result) => {
const collectionManager = useXenApiCollectionManager(
const collection = useXapiCollectionStore().get(
getRawObjectType(result.class)
);
if (!collectionManager.hasSubscriptions.value) {
if (!collection.hasSubscriptions) {
return;
}
@@ -44,11 +44,11 @@ export const useXenApiStore = defineStore("xen-api", () => {
switch (result.operation) {
case "add":
return collectionManager.add(buildObject());
return collection.add(buildObject());
case "mod":
return collectionManager.update(buildObject());
return collection.update(buildObject());
case "del":
return collectionManager.remove(result.ref as any);
return collection.remove(result.ref as any);
}
});
});

View File

@@ -18,8 +18,8 @@
import RouterTab from "@/components/RouterTab.vue";
import ComponentStory from "@/components/component-story/ComponentStory.vue";
import UiTabBar from "@/components/ui/UiTabBar.vue";
import { prop, setting, slot } from "@/libs/story/story-param";
import { text } from "@/libs/story/story-widget";
import { prop, setting, slot } from "@/libs/story/story-param.js";
import { text } from "@/libs/story/story-widget.js";
</script>
<style lang="postcss" scoped></style>

View File

@@ -18,7 +18,7 @@
import ComponentStory from "@/components/component-story/ComponentStory.vue";
import UiTab from "@/components/ui/UiTab.vue";
import UiTabBar from "@/components/ui/UiTabBar.vue";
import { prop, slot } from "@/libs/story/story-param";
import { prop, slot } from "@/libs/story/story-param.js";
</script>
<style lang="postcss" scoped></style>

View File

@@ -19,8 +19,8 @@
import ComponentStory from "@/components/component-story/ComponentStory.vue";
import UiTab from "@/components/ui/UiTab.vue";
import UiTabBar from "@/components/ui/UiTabBar.vue";
import { prop, setting, slot } from "@/libs/story/story-param";
import { text } from "@/libs/story/story-widget";
import { prop, setting, slot } from "@/libs/story/story-param.js";
import { text } from "@/libs/story/story-widget.js";
</script>
<style lang="postcss" scoped></style>

View File

@@ -0,0 +1,108 @@
import type {
RawObjectType,
XenApiConsole,
XenApiHost,
XenApiHostMetrics,
XenApiMessage,
XenApiPool,
XenApiSr,
XenApiTask,
XenApiVm,
XenApiVmGuestMetrics,
XenApiVmMetrics,
} from "@/libs/xen-api";
import type { ComputedRef, Ref } from "vue";
type DefaultExtension<
T extends RawObjectType,
R extends RawTypeToRecord<T> = RawTypeToRecord<T>
> = {
records: ComputedRef<R[]>;
getByOpaqueRef: (opaqueRef: R["$ref"]) => R | undefined;
getByUuid: (uuid: R["uuid"]) => R | undefined;
hasUuid: (uuid: R["uuid"]) => boolean;
isReady: Readonly<Ref<boolean>>;
isFetching: Readonly<Ref<boolean>>;
isReloading: ComputedRef<boolean>;
hasError: ComputedRef<boolean>;
lastError: Readonly<Ref<string | undefined>>;
};
type DeferExtension = [
{
start: () => void;
isStarted: ComputedRef<boolean>;
},
{ immediate: false }
];
type DefaultExtensions<T extends RawObjectType> = [
DefaultExtension<T>,
DeferExtension
];
type GenerateSubscribeOptions<Extensions extends any[]> = Extensions extends [
infer FirstExtension,
...infer RestExtension
]
? FirstExtension extends [object, infer FirstCondition]
? FirstCondition & GenerateSubscribeOptions<RestExtension>
: GenerateSubscribeOptions<RestExtension>
: object;
export type SubscribeOptions<Extensions extends any[]> = Partial<
GenerateSubscribeOptions<Extensions> &
GenerateSubscribeOptions<DefaultExtensions<any>>
>;
type GenerateSubscription<
Options extends object,
Extensions extends any[]
> = Extensions extends [infer FirstExtension, ...infer RestExtension]
? FirstExtension extends [infer FirstObject, infer FirstCondition]
? Options extends FirstCondition
? FirstObject & GenerateSubscription<Options, RestExtension>
: GenerateSubscription<Options, RestExtension>
: FirstExtension & GenerateSubscription<Options, RestExtension>
: object;
export type Subscription<
T extends RawObjectType,
Options extends object,
Extensions extends any[] = []
> = GenerateSubscription<Options, Extensions> &
GenerateSubscription<Options, DefaultExtensions<T>>;
export function createSubscribe<
T extends RawObjectType,
Extensions extends any[],
Options extends object = SubscribeOptions<Extensions>
>(builder: (options?: Options) => Subscription<T, Options, Extensions>) {
return function subscribe<O extends Options>(
options?: O
): Subscription<T, O, Extensions> {
return builder(options);
};
}
export type RawTypeToRecord<T extends RawObjectType> = T extends "SR"
? XenApiSr
: T extends "VM"
? XenApiVm
: T extends "VM_guest_metrics"
? XenApiVmGuestMetrics
: T extends "VM_metrics"
? XenApiVmMetrics
: T extends "console"
? XenApiConsole
: T extends "host"
? XenApiHost
: T extends "host_metrics"
? XenApiHostMetrics
: T extends "message"
? XenApiMessage
: T extends "pool"
? XenApiPool
: T extends "task"
? XenApiTask
: never;

View File

@@ -1,68 +0,0 @@
import type {
RawObjectType,
XenApiConsole,
XenApiHost,
XenApiHostMetrics,
XenApiMessage,
XenApiPool,
XenApiRecord,
XenApiSr,
XenApiTask,
XenApiVm,
XenApiVmGuestMetrics,
XenApiVmMetrics,
} from "@/libs/xen-api";
import type { XenApiCollection } from "@/libs/xen-api-collection";
import type { ComputedRef } from "vue";
export type RawTypeToRecord<ObjectType extends RawObjectType> =
ObjectType extends "SR"
? XenApiSr
: ObjectType extends "VM"
? XenApiVm
: ObjectType extends "VM_guest_metrics"
? XenApiVmGuestMetrics
: ObjectType extends "VM_metrics"
? XenApiVmMetrics
: ObjectType extends "console"
? XenApiConsole
: ObjectType extends "host"
? XenApiHost
: ObjectType extends "host_metrics"
? XenApiHostMetrics
: ObjectType extends "message"
? XenApiMessage
: ObjectType extends "pool"
? XenApiPool
: ObjectType extends "task"
? XenApiTask
: never;
type XenApiBaseCollectionProps =
| "isFetching"
| "isReloading"
| "hasError"
| "hasUuid"
| "isReady"
| "getByUuid"
| "getByOpaqueRef"
| "records";
type XenApiCollectionManagerProps =
| "add"
| "remove"
| "update"
| "hasSubscriptions";
export type XenApiBaseCollection<
Record extends XenApiRecord<any>,
Immediate extends boolean,
> = Pick<XenApiCollection<Record>, XenApiBaseCollectionProps> &
(Immediate extends false
? { start: () => void; isStarted: ComputedRef<boolean> }
: object);
export type XenApiCollectionManager<Record extends XenApiRecord<any>> = Pick<
XenApiCollection<Record>,
XenApiCollectionManagerProps
>;

View File

@@ -1,13 +1,12 @@
<template>Chargement en cours...</template>
<script lang="ts" setup>
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
import { usePoolStore } from "@/stores/pool.store";
import { whenever } from "@vueuse/core";
import { useRouter } from "vue-router";
const router = useRouter();
const { pool } = usePoolCollection();
const { pool } = usePoolStore().subscribe();
whenever(
() => pool.value?.uuid,

View File

@@ -6,14 +6,14 @@
<script lang="ts" setup>
import ObjectNotFoundWrapper from "@/components/ObjectNotFoundWrapper.vue";
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import type { XenApiHost } from "@/libs/xen-api";
import { useHostStore } from "@/stores/host.store";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useUiStore } from "@/stores/ui.store";
import { computed, watchEffect } from "vue";
import { useRoute } from "vue-router";
const { hasUuid, isReady, getByUuid } = useHostCollection();
const { hasUuid, isReady, getByUuid } = useHostStore().subscribe();
const route = useRoute();
const uiStore = useUiStore();

View File

@@ -42,13 +42,13 @@ import PoolDashboardStorageUsage from "@/components/pool/dashboard/PoolDashboard
import PoolDashboardRamUsageChart from "@/components/pool/dashboard/ramUsage/PoolRamUsage.vue";
import UiCardComingSoon from "@/components/ui/UiCardComingSoon.vue";
import UiCardGroup from "@/components/ui/UiCardGroup.vue";
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import useFetchStats from "@/composables/fetch-stats.composable";
import { GRANULARITY, type HostStats, type VmStats } from "@/libs/xapi-stats";
import type { XenApiHost, XenApiVm } from "@/libs/xen-api";
import { useHostMetricsStore } from "@/stores/host-metrics.store";
import { useHostStore } from "@/stores/host.store";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useVmStore } from "@/stores/vm.store";
import {
IK_HOST_LAST_WEEK_STATS,
IK_HOST_STATS,
@@ -60,8 +60,15 @@ import { useI18n } from "vue-i18n";
usePageTitleStore().setTitle(useI18n().t("dashboard"));
const { getStats: getHostStats, runningHosts } = useHostCollection();
const { getStats: getVmStats, runningVms } = useVmCollection();
const hostMetricsSubscription = useHostMetricsStore().subscribe();
const hostSubscription = useHostStore().subscribe({ hostMetricsSubscription });
const { runningHosts, getStats: getHostStats } = hostSubscription;
const { runningVms, getStats: getVmStats } = useVmStore().subscribe({
hostSubscription,
});
const {
register: hostRegister,

View File

@@ -10,11 +10,10 @@
<script lang="ts" setup>
import PoolHeader from "@/components/pool/PoolHeader.vue";
import PoolTabBar from "@/components/pool/PoolTabBar.vue";
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
import { usePoolStore } from "@/stores/pool.store";
import { usePageTitleStore } from "@/stores/page-title.store";
const { pool } = usePoolCollection();
const { pool } = usePoolStore().subscribe();
usePageTitleStore().setObject(pool);
</script>

View File

@@ -15,12 +15,12 @@ import UiCard from "@/components/ui/UiCard.vue";
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
import UiCounter from "@/components/ui/UiCounter.vue";
import UiTitle from "@/components/ui/UiTitle.vue";
import { useTaskCollection } from "@/composables/xen-api-collection/task-collection.composable";
import { useTaskStore } from "@/stores/task.store";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useI18n } from "vue-i18n";
const { pendingTasks, finishedTasks, isReady, hasError } = useTaskCollection();
const { pendingTasks, finishedTasks, isReady, hasError } =
useTaskStore().subscribe();
const { t } = useI18n();
const titleStore = usePageTitleStore();

View File

@@ -37,10 +37,10 @@ import PowerStateIcon from "@/components/PowerStateIcon.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import VmsActionsBar from "@/components/vm/VmsActionsBar.vue";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import { POWER_STATE } from "@/libs/xen-api";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useUiStore } from "@/stores/ui.store";
import { useVmStore } from "@/stores/vm.store";
import type { Filters } from "@/types/filter";
import { faPowerOff } from "@fortawesome/free-solid-svg-icons";
import { storeToRefs } from "pinia";
@@ -52,7 +52,7 @@ const { t } = useI18n();
const titleStore = usePageTitleStore();
titleStore.setTitle(t("vms"));
const { records: vms } = useVmCollection();
const { records: vms } = useVmStore().subscribe();
const { isMobile, isDesktop } = storeToRefs(useUiStore());
const filters: Filters = {

View File

@@ -157,8 +157,6 @@
</template>
<script lang="ts" setup>
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
import { usePageTitleStore } from "@/stores/page-title.store";
import { computed } from "vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
@@ -168,6 +166,8 @@ import { useUiStore } from "@/stores/ui.store";
import { storeToRefs } from "pinia";
import { watch } from "vue";
import { useI18n } from "vue-i18n";
import { useHostStore } from "@/stores/host.store";
import { usePoolStore } from "@/stores/pool.store";
import { locales } from "@/i18n";
import {
faEarthAmericas,
@@ -186,9 +186,8 @@ const { t, locale } = useI18n();
usePageTitleStore().setTitle(() => t("settings"));
const { pool } = usePoolCollection();
const { getByOpaqueRef: getHost } = useHostCollection();
const { pool } = usePoolStore().subscribe();
const { getByOpaqueRef: getHost } = useHostStore().subscribe();
const poolMaster = computed(() =>
pool.value ? getHost(pool.value.master) : undefined

View File

@@ -31,11 +31,12 @@
import RemoteConsole from "@/components/RemoteConsole.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiSpinner from "@/components/ui/UiSpinner.vue";
import { useConsoleCollection } from "@/composables/xen-api-collection/console-collection.composable";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import { isOperationsPending } from "@/libs/utils";
import { POWER_STATE, VM_OPERATION, type XenApiVm } from "@/libs/xen-api";
import { useConsoleStore } from "@/stores/console.store";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useUiStore } from "@/stores/ui.store";
import { useVmStore } from "@/stores/vm.store";
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
@@ -60,14 +61,13 @@ const {
isReady: isVmReady,
getByUuid: getVmByUuid,
hasError: hasVmError,
isOperationPending,
} = useVmCollection();
} = useVmStore().subscribe();
const {
isReady: isConsoleReady,
getByOpaqueRef: getConsoleByOpaqueRef,
hasError: hasConsoleError,
} = useConsoleCollection();
} = useConsoleStore().subscribe();
const isReady = computed(() => isVmReady.value && isConsoleReady.value);
@@ -89,8 +89,9 @@ const vmConsole = computed(() => {
return getConsoleByOpaqueRef(consoleOpaqueRef);
});
const isConsoleAvailable = computed(() =>
vm.value !== undefined ? isOperationPending(vm.value, STOP_OPERATIONS) : false
const isConsoleAvailable = computed(
() =>
vm.value !== undefined && !isOperationsPending(vm.value, STOP_OPERATIONS)
);
</script>

View File

@@ -12,16 +12,16 @@
import ObjectNotFoundWrapper from "@/components/ObjectNotFoundWrapper.vue";
import VmHeader from "@/components/vm/VmHeader.vue";
import VmTabBar from "@/components/vm/VmTabBar.vue";
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
import type { XenApiVm } from "@/libs/xen-api";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useUiStore } from "@/stores/ui.store";
import { useVmStore } from "@/stores/vm.store";
import { whenever } from "@vueuse/core";
import { computed } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
const { getByUuid, hasUuid, isReady } = useVmCollection();
const { getByUuid, hasUuid, isReady } = useVmStore().subscribe();
const uiStore = useUiStore();
const vm = computed(() => getByUuid(route.params.uuid as XenApiVm["uuid"]));
whenever(vm, (vm) => (uiStore.currentHostOpaqueRef = vm.resident_on));

View File

@@ -56,13 +56,9 @@ export default class Tasks extends EventEmitter {
},
})
#app
constructor(app) {
super()
this.#app = app
app.hooks
.on('clean', () => this.#gc(app.config.getOptional('tasks.gc.keep') ?? 1e3))
.on('start', async () => {
@@ -135,10 +131,10 @@ export default class Tasks extends EventEmitter {
*
* @returns {Task}
*/
create({ name, objectId, userId = this.#app.apiContext?.user?.id, type }) {
create({ name, objectId, type }) {
const tasks = this.#tasks
const task = new Task({ properties: { name, objectId, userId, type }, onProgress: this.#onProgress })
const task = new Task({ properties: { name, objectId, type }, onProgress: this.#onProgress })
// Use a compact, sortable, string representation of the creation date
//

View File

@@ -32,14 +32,9 @@ class Host {
* @param {string} ref - Opaque reference of the host
*/
async smartReboot($defer, ref) {
const suspendedVms = []
if (await this.getField('host', ref, 'enabled')) {
await this.callAsync('host.disable', ref)
$defer(async () => {
await this.callAsync('host.enable', ref)
// Resuming VMs should occur after host enabling to avoid triggering a 'NO_HOSTS_AVAILABLE' error
return asyncEach(suspendedVms, vmRef => this.callAsync('VM.resume', vmRef, false, false))
})
$defer(() => this.callAsync('host.enable', ref))
}
let currentVmRef
@@ -56,7 +51,7 @@ class Host {
try {
await this.callAsync('VM.suspend', vmRef)
suspendedVms.push(vmRef)
$defer(() => this.callAsync('VM.resume', vmRef, false, false))
} catch (error) {
const { code } = error

View File

@@ -6,7 +6,7 @@ import { decorateClass } from '@vates/decorate-with'
import { strict as assert } from 'node:assert'
import extractOpaqueRef from './_extractOpaqueRef.mjs'
import NbdClient from '@vates/nbd-client'
import NbdClient from '@vates/nbd-client/client.mjs'
import { createNbdRawStream, createNbdVhdStream } from 'vhd-lib/createStreamNbd.js'
import { VDI_FORMAT_RAW, VDI_FORMAT_VHD } from './index.mjs'

View File

@@ -7,21 +7,12 @@
> Users must be able to say: “Nice enhancement, I'm eager to test it”
- [Netbox] Synchronize VM tags [#5899](https://github.com/vatesfr/xen-orchestra/issues/5899) [Forum#6902](https://xcp-ng.org/forum/topic/6902) (PR [#6957](https://github.com/vatesfr/xen-orchestra/pull/6957))
- [REST API] Add support for `filter` and `limit` parameters to `backups/logs` and `restore/logs` collections [Forum#64789](https://xcp-ng.org/forum/post/64789)
- [Pool/Advanced] Ability to set a crash dump SR [#5060](https://github.com/vatesfr/xen-orchestra/issues/5060) (PR [#6973](https://github.com/vatesfr/xen-orchestra/pull/6973))
- [Backup/full] Ensure full xva is written (PR [#7005](https://github.com/vatesfr/xen-orchestra/pull/7005))
### Bug fixes
> Users must be able to say: “I had this issue, happy to know it's fixed”
- [LDAP] Mark the _Id attribute_ setting as required
- [Incremental Replication] Fix `TypeError: Cannot read properties of undefined (reading 'uuid') at #isAlreadyOnHealthCheckSr` [Forum#7492](https://xcp-ng.org/forum/topic/7492) (PR [#6969](https://github.com/vatesfr/xen-orchestra/pull/6969))
- [File Restore] Increase timeout from one to ten minutes when restoring through XO Proxy
- [Home/VMs] Filtering with a UUID will no longer show other VMs on the same host/pool
- [Jobs] Fixes `invalid parameters` when editing [Forum#64668](https://xcp-ng.org/forum/post/64668)
- [Smart reboot] Fix cases where VMs remained in a suspended state (PR [#6980](https://github.com/vatesfr/xen-orchestra/pull/6980))
### Packages to release
@@ -40,12 +31,9 @@
<!--packages-start-->
- @xen-orchestra/backups patch
- @xen-orchestra/mixins minor
- @xen-orchestra/xapi patch
- xen-api patch
- xo-server minor
- xo-server patch
- xo-server-auth-ldap patch
- xo-server-netbox minor
- xo-web minor
- xo-web patch
<!--packages-end-->

View File

@@ -16,16 +16,6 @@ export default function diff(newer, older) {
return newer === older ? undefined : newer
}
// For arrays, they must be exactly the same or we pass the new one entirely
if (Array.isArray(newer)) {
if (newer.length !== older.length || newer.some((value, index) => diff(value, older[index]) !== undefined)) {
return newer
}
return
}
// For objects, we only need to pass the properties that are different
newer = { ...newer }
Object.keys(newer).forEach(key => {
if ((key === 'name' && compareNames(newer[key], older[key])) || diff(newer[key], older?.[key]) === undefined) {

View File

@@ -38,7 +38,7 @@ class Netbox {
#intervalToken
#loaded
#netboxApiVersion
#xoPools
#pools
#removeApiMethods
#syncInterval
#token
@@ -63,7 +63,7 @@ class Netbox {
}
this.#allowUnauthorized = configuration.allowUnauthorized ?? false
this.#token = configuration.token
this.#xoPools = configuration.pools
this.#pools = configuration.pools
this.#syncInterval = configuration.syncInterval && configuration.syncInterval * 60 * 60 * 1e3
// We don't want to start the auto-sync if the plugin isn't loaded
@@ -109,15 +109,15 @@ class Netbox {
description:
"This type has been created by Xen Orchestra's Netbox plugin test. If it hasn't been properly deleted, you may delete it manually.",
})
const nbClusterTypes = await this.#request(`/virtualization/cluster-types/?name=${encodeURIComponent(name)}`)
const clusterTypes = await this.#request(`/virtualization/cluster-types/?name=${encodeURIComponent(name)}`)
await this.#checkCustomFields()
if (nbClusterTypes.length !== 1) {
if (clusterTypes.length !== 1) {
throw new Error('Could not properly write and read Netbox')
}
await this.#request('/virtualization/cluster-types/', 'DELETE', [{ id: nbClusterTypes[0].id }])
await this.#request('/virtualization/cluster-types/', 'DELETE', [{ id: clusterTypes[0].id }])
}
async #request(path, method = 'GET', data) {
@@ -204,10 +204,10 @@ class Netbox {
// ---------------------------------------------------------------------------
async #synchronize(xoPools = this.#xoPools) {
async #synchronize(pools = this.#pools) {
await this.#checkCustomFields()
log.info(`Synchronizing ${xoPools.length} pools with Netbox`, { pools: xoPools })
log.info(`Synchronizing ${pools.length} pools with Netbox`, { pools })
// Cluster type ------------------------------------------------------------
@@ -215,22 +215,20 @@ class Netbox {
// that have been created from XO
// Check if a cluster type called XCP-ng already exists otherwise create it
const nbClusterTypes = await this.#request(
`/virtualization/cluster-types/?name=${encodeURIComponent(CLUSTER_TYPE)}`
)
if (nbClusterTypes.length > 1) {
const clusterTypes = await this.#request(`/virtualization/cluster-types/?name=${encodeURIComponent(CLUSTER_TYPE)}`)
if (clusterTypes.length > 1) {
throw new Error('Found more than 1 "XCP-ng Pool" cluster type')
}
let nbClusterType
if (nbClusterTypes.length === 0) {
let clusterType
if (clusterTypes.length === 0) {
log.info('Creating cluster type')
nbClusterType = await this.#request('/virtualization/cluster-types/', 'POST', {
clusterType = await this.#request('/virtualization/cluster-types/', 'POST', {
name: CLUSTER_TYPE,
slug: slugify(CLUSTER_TYPE),
description: 'Created by Xen Orchestra',
})
} else {
nbClusterType = nbClusterTypes[0]
clusterType = clusterTypes[0]
}
// Clusters ----------------------------------------------------------------
@@ -239,45 +237,45 @@ class Netbox {
log.info('Synchronizing clusters')
const createNbCluster = (xoPool, nbClusterType) => ({
custom_fields: { uuid: xoPool.uuid },
name: xoPool.name_label.slice(0, NAME_MAX_LENGTH),
type: nbClusterType.id,
const createCluster = (pool, clusterType) => ({
custom_fields: { uuid: pool.uuid },
name: pool.name_label.slice(0, NAME_MAX_LENGTH),
type: clusterType.id,
})
// { Pool UUID → cluster }
const allNbClusters = keyBy(
await this.#request(`/virtualization/clusters/?type_id=${nbClusterType.id}`),
const allClusters = keyBy(
await this.#request(`/virtualization/clusters/?type_id=${clusterType.id}`),
'custom_fields.uuid'
)
const nbClusters = pick(allNbClusters, xoPools)
const clusters = pick(allClusters, pools)
if (!isEmpty(allNbClusters[undefined])) {
if (!isEmpty(allClusters[undefined])) {
// FIXME: Should we delete clusters from this cluster type that don't have
// a UUID?
log.warn('Found some clusters with missing UUID custom field', allNbClusters[undefined])
log.warn('Found some clusters with missing UUID custom field', allClusters[undefined])
}
const clustersToCreate = []
const clustersToUpdate = []
for (const xoPoolId of xoPools) {
const xoPool = this.getObject(xoPoolId)
if (xoPool === undefined) {
for (const poolId of pools) {
const pool = this.getObject(poolId)
if (pool === undefined) {
// If we can't find the pool, don't synchronize anything within that pool
log.warn('Synchronizing pools: cannot find pool', { pool: xoPoolId })
delete allNbClusters[xoPoolId]
delete nbClusters[xoPoolId]
log.warn('Synchronizing pools: cannot find pool', { pool: poolId })
delete allClusters[poolId]
delete clusters[poolId]
continue
}
const nbCluster = nbClusters[xoPool.uuid]
const cluster = clusters[pool.uuid]
const updatedCluster = createNbCluster(xoPool, nbClusterType)
const updatedCluster = createCluster(pool, clusterType)
if (nbCluster === undefined) {
if (cluster === undefined) {
clustersToCreate.push(updatedCluster)
} else {
// `type` needs to be flattened so we can compare the 2 objects
const patch = diff(updatedCluster, { ...nbCluster, type: nbCluster.type.id })
const patch = diff(updatedCluster, { ...cluster, type: cluster.type.id })
if (patch !== undefined) {
clustersToUpdate.push(patch)
}
@@ -295,152 +293,124 @@ class Netbox {
log.info(`Creating ${clustersToCreate.length} clusters`)
newClusters.push(...(await this.#request('/virtualization/clusters/', 'POST', clustersToCreate)))
}
Object.assign(nbClusters, keyBy(newClusters, 'custom_fields.uuid'))
Object.assign(allNbClusters, nbClusters)
Object.assign(clusters, keyBy(newClusters, 'custom_fields.uuid'))
Object.assign(allClusters, clusters)
// Only keep pools that were found in XO and up to date in Netbox
xoPools = Object.keys(nbClusters)
pools = Object.keys(clusters)
const clusterFilter = Object.values(nbClusters)
.map(nbCluster => `cluster_id=${nbCluster.id}`)
const clusterFilter = Object.values(clusters)
.map(cluster => `cluster_id=${cluster.id}`)
.join('&')
// VMs ---------------------------------------------------------------------
log.info('Synchronizing VMs')
const createNbVm = async (xoVm, { nbCluster, nbPlatforms, nbTags }) => {
const nbVm = {
custom_fields: { uuid: xoVm.uuid },
name: xoVm.name_label.slice(0, NAME_MAX_LENGTH).trim(),
comments: xoVm.name_description.slice(0, DESCRIPTION_MAX_LENGTH).trim(),
vcpus: xoVm.CPUs.number,
const createNetboxVm = async (vm, { cluster, platforms }) => {
const netboxVm = {
custom_fields: { uuid: vm.uuid },
name: vm.name_label.slice(0, NAME_MAX_LENGTH).trim(),
comments: vm.name_description.slice(0, DESCRIPTION_MAX_LENGTH).trim(),
vcpus: vm.CPUs.number,
disk: Math.floor(
xoVm.$VBDs
vm.$VBDs
.map(vbdId => this.getObject(vbdId))
.filter(vbd => !vbd.is_cd_drive)
.map(vbd => this.getObject(vbd.VDI))
.reduce((total, vdi) => total + vdi.size, 0) / G
),
memory: Math.floor(xoVm.memory.dynamic[1] / M),
cluster: nbCluster.id,
status: xoVm.power_state === 'Running' ? 'active' : 'offline',
memory: Math.floor(vm.memory.dynamic[1] / M),
cluster: cluster.id,
status: vm.power_state === 'Running' ? 'active' : 'offline',
platform: null,
tags: [],
}
const distro = xoVm.os_version?.distro
const distro = vm.os_version?.distro
if (distro != null) {
const slug = slugify(distro)
let nbPlatform = find(nbPlatforms, { slug })
if (nbPlatform === undefined) {
let platform = find(platforms, { slug })
if (platform === undefined) {
// TODO: Should we also delete/update platforms in Netbox?
nbPlatform = await this.#request('/dcim/platforms/', 'POST', {
platform = await this.#request('/dcim/platforms/', 'POST', {
name: distro,
slug,
})
nbPlatforms[nbPlatform.id] = nbPlatform
platforms[platform.id] = platform
}
nbVm.platform = nbPlatform.id
netboxVm.platform = platform.id
}
const nbVmTags = []
for (const tag of xoVm.tags) {
const slug = slugify(tag)
let nbTag = find(nbTags, { slug })
if (nbTag === undefined) {
// TODO: Should we also delete/update tags in Netbox?
nbTag = await this.#request('/extras/tags/', 'POST', {
name: tag,
slug,
color: '2598d9',
description: 'XO tag',
})
nbTags[nbTag.id] = nbTag
}
// Edge case: tags "foo" and "Foo" would have the same slug. It's
// allowed in XO but not in Netbox so in that case, we only add it once
// to Netbox.
if (find(nbVmTags, { id: nbTag.id }) === undefined) {
nbVmTags.push({ id: nbTag.id })
}
}
// Sort them so that they can be compared by diff()
nbVm.tags = nbVmTags.sort(({ id: id1 }, { id: id2 }) => (id1 < id2 ? -1 : 1))
// https://netbox.readthedocs.io/en/stable/release-notes/version-2.7/#api-choice-fields-now-use-string-values-3569
if (
this.#netboxApiVersion !== undefined &&
!semver.satisfies(semver.coerce(this.#netboxApiVersion).version, '>=2.7.0')
) {
nbVm.status = xoVm.power_state === 'Running' ? 1 : 0
netboxVm.status = vm.power_state === 'Running' ? 1 : 0
}
return nbVm
return netboxVm
}
// Some props need to be flattened to satisfy the POST request schema
const flattenNested = nbVm => ({
...nbVm,
cluster: nbVm.cluster?.id ?? null,
status: nbVm.status?.value ?? null,
platform: nbVm.platform?.id ?? null,
// Sort them so that they can be compared by diff()
tags: nbVm.tags.map(nbTag => ({ id: nbTag.id })).sort(({ id: id1 }, { id: id2 }) => (id1 < id2 ? -1 : 1)),
const flattenNested = vm => ({
...vm,
cluster: vm.cluster?.id ?? null,
status: vm.status?.value ?? null,
platform: vm.platform?.id ?? null,
})
const nbPlatforms = keyBy(await this.#request('/dcim/platforms/'), 'id')
const nbTags = keyBy(await this.#request('/extras/tags/'), 'id')
const platforms = keyBy(await this.#request('/dcim/platforms'), 'id')
// Get all the VMs in the cluster type "XCP-ng Pool" even from clusters
// we're not synchronizing right now, so we can "migrate" them back if
// necessary
const allNbVmsList = (await this.#request('/virtualization/virtual-machines/')).filter(
nbVm => Object.values(allNbClusters).find(cluster => cluster.id === nbVm.cluster.id) !== undefined
const allNetboxVmsList = (await this.#request('/virtualization/virtual-machines/')).filter(
netboxVm => Object.values(allClusters).find(cluster => cluster.id === netboxVm.cluster.id) !== undefined
)
// Then get only the ones from the pools we're synchronizing
const nbVmsList = allNbVmsList.filter(
nbVm => Object.values(nbClusters).find(cluster => cluster.id === nbVm.cluster.id) !== undefined
const netboxVmsList = allNetboxVmsList.filter(
netboxVm => Object.values(clusters).find(cluster => cluster.id === netboxVm.cluster.id) !== undefined
)
// Then make them objects to map the Netbox VMs to their XO VMs
// { VM UUID → Netbox VM }
const allNbVms = keyBy(allNbVmsList, 'custom_fields.uuid')
const nbVms = keyBy(nbVmsList, 'custom_fields.uuid')
const allNetboxVms = keyBy(allNetboxVmsList, 'custom_fields.uuid')
const netboxVms = keyBy(netboxVmsList, 'custom_fields.uuid')
const usedNames = [] // Used for name deduplication
// Build the 3 collections of VMs and perform all the API calls at the end
const vmsToDelete = nbVmsList.filter(nbVm => nbVm.custom_fields.uuid == null).map(nbVm => ({ id: nbVm.id }))
const vmsToDelete = netboxVmsList
.filter(netboxVm => netboxVm.custom_fields.uuid == null)
.map(netboxVm => ({ id: netboxVm.id }))
const vmsToUpdate = []
const vmsToCreate = []
for (const xoPoolId of xoPools) {
for (const poolId of pools) {
// Get XO VMs that are on this pool
const xoPoolVms = this.getObjects({ filter: { type: 'VM', $pool: xoPoolId } })
const poolVms = this.getObjects({ filter: { type: 'VM', $pool: poolId } })
const nbCluster = nbClusters[xoPoolId]
const cluster = clusters[poolId]
// Get Netbox VMs that are supposed to be in this pool
const xoPoolNbVms = pickBy(nbVms, nbVm => nbVm.cluster.id === nbCluster.id)
const poolNetboxVms = pickBy(netboxVms, netboxVm => netboxVm.cluster.id === cluster.id)
// For each XO VM of this pool (I)
for (const xoVm of Object.values(xoPoolVms)) {
for (const vm of Object.values(poolVms)) {
// Grab the Netbox VM from the list of all VMs so that if the VM is on
// another cluster, we update the existing object instead of creating a
// new one
const nbVm = allNbVms[xoVm.uuid]
delete xoPoolNbVms[xoVm.uuid]
const netboxVm = allNetboxVms[vm.uuid]
delete poolNetboxVms[vm.uuid]
const updatedVm = await createNbVm(xoVm, { nbCluster, nbPlatforms, nbTags })
const updatedVm = await createNetboxVm(vm, { cluster, platforms })
if (nbVm !== undefined) {
if (netboxVm !== undefined) {
// VM found in Netbox: update VM (I.1)
const patch = diff(updatedVm, flattenNested(nbVm))
const patch = diff(updatedVm, flattenNested(netboxVm))
if (patch !== undefined) {
vmsToUpdate.push(patch)
} else {
// The VM is up to date, just store its name as being used
usedNames.push(nbVm.name)
usedNames.push(netboxVm.name)
}
} else {
// VM not found in Netbox: create VM (I.2)
@@ -449,28 +419,28 @@ class Netbox {
}
// For each REMAINING Netbox VM of this pool (II)
for (const nbVm of Object.values(xoPoolNbVms)) {
const xoVmUuid = nbVm.custom_fields?.uuid
const xoVm = this.getObject(xoVmUuid)
for (const netboxVm of Object.values(poolNetboxVms)) {
const vmUuid = netboxVm.custom_fields?.uuid
const vm = this.getObject(vmUuid)
// We check if the VM was moved to another pool in XO
const xoPool = this.getObject(xoVm?.$pool)
const nbCluster = allNbClusters[xoPool?.uuid]
if (nbCluster !== undefined) {
const pool = this.getObject(vm?.$pool)
const cluster = allClusters[pool?.uuid]
if (cluster !== undefined) {
// If the VM is found in XO: update it if necessary (II.1)
const updatedVm = await createNbVm(xoVm, { nbCluster, nbPlatforms, nbTags })
const patch = diff(updatedVm, flattenNested(nbVm))
const updatedVm = await createNetboxVm(vm, { cluster, platforms })
const patch = diff(updatedVm, flattenNested(netboxVm))
if (patch === undefined) {
// Should never happen since at least the cluster should be different
log.warn('Found a VM that should be on another cluster', { vm: nbVm })
log.warn('Found a VM that should be on another cluster', { vm: netboxVm })
continue
}
vmsToUpdate.push(patch)
} else {
// Otherwise, delete it from Netbox (II.2)
vmsToDelete.push({ id: nbVm.id })
delete nbVms[xoVmUuid]
vmsToDelete.push({ id: netboxVm.id })
delete netboxVms[vmUuid]
}
}
}
@@ -479,12 +449,12 @@ class Netbox {
// Deduplicate vmsToUpdate first to avoid back and forth changes
// Deduplicate even between pools to simplify and avoid back and forth
// changes if the VM is migrated
for (const nbVm of [...vmsToUpdate, ...vmsToCreate]) {
if (nbVm.name === undefined) {
for (const netboxVm of [...vmsToUpdate, ...vmsToCreate]) {
if (netboxVm.name === undefined) {
continue
}
nbVm.name = deduplicateName(nbVm.name, usedNames)
usedNames.push(nbVm.name)
netboxVm.name = deduplicateName(netboxVm.name, usedNames)
usedNames.push(netboxVm.name)
}
// Perform calls to Netbox. "Delete → Update → Create" one at a time to
@@ -502,61 +472,63 @@ class Netbox {
log.info(`Creating ${vmsToCreate.length} VMs`)
newVms.push(...(await this.#request('/virtualization/virtual-machines/', 'POST', vmsToCreate)))
}
Object.assign(nbVms, keyBy(newVms, 'custom_fields.uuid'))
Object.assign(allNbVms, nbVms)
Object.assign(netboxVms, keyBy(newVms, 'custom_fields.uuid'))
Object.assign(allNetboxVms, netboxVms)
// VIFs --------------------------------------------------------------------
log.info('Synchronizing VIFs')
const createNbIf = (xoVif, xoVm) => {
const name = `eth${xoVif.device}`
const nbVm = nbVms[xoVm.uuid]
const createIf = (vif, vm) => {
const name = `eth${vif.device}`
const netboxVm = netboxVms[vm.uuid]
const nbIf = {
custom_fields: { uuid: xoVif.uuid },
const netboxIf = {
custom_fields: { uuid: vif.uuid },
name,
mac_address: xoVif.MAC.toUpperCase(),
mac_address: vif.MAC.toUpperCase(),
}
if (nbVm !== undefined) {
nbIf.virtual_machine = nbVm.id
if (netboxVm !== undefined) {
netboxIf.virtual_machine = netboxVm.id
}
return nbIf
return netboxIf
}
const nbIfsList = await this.#request(`/virtualization/interfaces/?${clusterFilter}`)
const netboxIfsList = await this.#request(`/virtualization/interfaces/?${clusterFilter}`)
// { ID → Interface }
const nbIfs = keyBy(nbIfsList, 'custom_fields.uuid')
const netboxIfs = keyBy(netboxIfsList, 'custom_fields.uuid')
const ifsToDelete = nbIfsList.filter(nbIf => nbIf.custom_fields.uuid == null).map(nbIf => ({ id: nbIf.id }))
const ifsToDelete = netboxIfsList
.filter(netboxIf => netboxIf.custom_fields.uuid == null)
.map(netboxIf => ({ id: netboxIf.id }))
const ifsToUpdate = []
const ifsToCreate = []
for (const nbVm of Object.values(nbVms)) {
const xoVm = this.getObject(nbVm.custom_fields.uuid)
if (xoVm === undefined) {
log.warn('Synchronizing VIFs: cannot find VM from UUID custom field', { vm: nbVm.custom_fields.uuid })
for (const netboxVm of Object.values(netboxVms)) {
const vm = this.getObject(netboxVm.custom_fields.uuid)
if (vm === undefined) {
log.warn('Synchronizing VIFs: cannot find VM from UUID custom field', { vm: netboxVm.custom_fields.uuid })
continue
}
// Start by deleting old interfaces attached to this Netbox VM
Object.entries(nbIfs).forEach(([id, nbIf]) => {
if (nbIf.virtual_machine.id === nbVm.id && !xoVm.VIFs.includes(nbIf.custom_fields.uuid)) {
ifsToDelete.push({ id: nbIf.id })
delete nbIfs[id]
Object.entries(netboxIfs).forEach(([id, netboxIf]) => {
if (netboxIf.virtual_machine.id === netboxVm.id && !vm.VIFs.includes(netboxIf.custom_fields.uuid)) {
ifsToDelete.push({ id: netboxIf.id })
delete netboxIfs[id]
}
})
// For each XO VIF, create or update the Netbox interface
for (const xoVifId of xoVm.VIFs) {
const xoVif = this.getObject(xoVifId)
const nbIf = nbIfs[xoVif.uuid]
const updatedIf = createNbIf(xoVif, xoVm)
if (nbIf === undefined) {
for (const vifId of vm.VIFs) {
const vif = this.getObject(vifId)
const netboxIf = netboxIfs[vif.uuid]
const updatedIf = createIf(vif, vm)
if (netboxIf === undefined) {
ifsToCreate.push(updatedIf)
} else {
// `virtual_machine` needs to be flattened so we can compare the 2 objects
const patch = diff(updatedIf, { ...nbIf, virtual_machine: nbIf.virtual_machine.id })
const patch = diff(updatedIf, { ...netboxIf, virtual_machine: netboxIf.virtual_machine.id })
if (patch !== undefined) {
ifsToUpdate.push(patch)
}
@@ -578,55 +550,55 @@ class Netbox {
log.info(`Creating ${ifsToCreate.length} interfaces`)
newIfs.push(...(await this.#request('/virtualization/interfaces/', 'POST', ifsToCreate)))
}
Object.assign(nbIfs, keyBy(newIfs, 'custom_fields.uuid'))
Object.assign(netboxIfs, keyBy(newIfs, 'custom_fields.uuid'))
// IPs ---------------------------------------------------------------------
log.info('Synchronizing IP addresses')
const createNbIp = (ip, prefix, nbIf) => {
const createIp = (ip, prefix, netboxIf) => {
return {
address: `${ip}/${prefix.split('/')[1]}`,
assigned_object_type: 'virtualization.vminterface',
assigned_object_id: nbIf,
assigned_object_id: netboxIf,
}
}
// In Netbox, a device interface and a VM interface can have the same ID and
// an IP address can be assigned to both types of interface, so we need to
// make sure that we only get IPs that are assigned to a VM interface
const nbIps = keyBy(
const netboxIps = keyBy(
(await this.#request('/ipam/ip-addresses/')).filter(
address => address.assigned_object_type === 'virtualization.vminterface'
),
'id'
)
const nbPrefixes = await this.#request('/ipam/prefixes/')
const netboxPrefixes = await this.#request('/ipam/prefixes/')
const ipsToDelete = []
const ipsToCreate = []
const ignoredIps = [] // IPs for which a valid prefix could not be found in Netbox
// For each VM, for each interface, for each IP: create IP in Netbox
for (const nbVm of Object.values(nbVms)) {
const xoVm = this.getObject(nbVm.custom_fields.uuid)
if (xoVm === undefined) {
log.warn('Synchronizing IPs: cannot find VM from UUID custom field', { vm: nbVm.custom_fields.uuid })
for (const netboxVm of Object.values(netboxVms)) {
const vm = this.getObject(netboxVm.custom_fields.uuid)
if (vm === undefined) {
log.warn('Synchronizing IPs: cannot find VM from UUID custom field', { vm: netboxVm.custom_fields.uuid })
continue
}
// Find the Netbox interface associated with the vif
const nbVmIfs = Object.values(nbIfs).filter(nbIf => nbIf.virtual_machine.id === nbVm.id)
for (const nbIf of nbVmIfs) {
const netboxVmIfs = Object.values(netboxIfs).filter(netboxIf => netboxIf.virtual_machine.id === netboxVm.id)
for (const netboxIf of netboxVmIfs) {
// Store old IPs and remove them one by one. At the end, delete the remaining ones.
const nbIpsToCheck = pickBy(nbIps, nbIp => nbIp.assigned_object_id === nbIf.id)
const netboxIpsToCheck = pickBy(netboxIps, netboxIp => netboxIp.assigned_object_id === netboxIf.id)
const xoVif = this.getObject(nbIf.custom_fields.uuid)
if (xoVif === undefined) {
const vif = this.getObject(netboxIf.custom_fields.uuid)
if (vif === undefined) {
// Cannot create IPs if interface was not found
log.warn('Could not find VIF', { vm: xoVm.uuid, vif: nbIf.custom_fields.uuid })
log.warn('Could not find VIF', { vm: vm.uuid, vif: netboxIf.custom_fields.uuid })
continue
}
const ips = Object.values(pickBy(xoVm.addresses, (_, key) => key.startsWith(xoVif.device + '/')))
const ips = Object.values(pickBy(vm.addresses, (_, key) => key.startsWith(vif.device + '/')))
for (const ip of ips) {
const parsedIp = ipaddr.parse(ip)
const ipKind = parsedIp.kind()
@@ -635,7 +607,7 @@ class Netbox {
// Users must create prefixes themselves
let smallestPrefix
let highestBits = 0
nbPrefixes.forEach(({ prefix }) => {
netboxPrefixes.forEach(({ prefix }) => {
const [range, bits] = prefix.split('/')
const parsedRange = ipaddr.parse(range)
if (parsedRange.kind() === ipKind && parsedIp.match(parsedRange, bits) && bits > highestBits) {
@@ -646,26 +618,26 @@ class Netbox {
if (smallestPrefix === undefined) {
// A valid prefix is required to create an IP in Netbox. If none matches, ignore the IP.
ignoredIps.push({ vm: xoVm.uuid, ip })
ignoredIps.push({ vm: vm.uuid, ip })
continue
}
const compactIp = parsedIp.toString() // use compact notation (e.g. ::1) before ===-comparison
const nbIp = find(nbIpsToCheck, nbIp => {
const [ip, bits] = nbIp.address.split('/')
const netboxIp = find(netboxIpsToCheck, netboxIp => {
const [ip, bits] = netboxIp.address.split('/')
return ipaddr.parse(ip).toString() === compactIp && bits === highestBits
})
if (nbIp !== undefined) {
if (netboxIp !== undefined) {
// IP is up to date, don't do anything with it
delete nbIpsToCheck[nbIp.id]
delete netboxIpsToCheck[netboxIp.id]
} else {
// IP wasn't found in Netbox, create it
ipsToCreate.push(createNbIp(ip, smallestPrefix, nbIf.id))
ipsToCreate.push(createIp(ip, smallestPrefix, netboxIf.id))
}
}
// Delete the remaining IPs found in Netbox for this VM
ipsToDelete.push(...Object.values(nbIpsToCheck).map(nbIp => ({ id: nbIp.id })))
ipsToDelete.push(...Object.values(netboxIpsToCheck).map(netboxIp => ({ id: netboxIp.id })))
}
}
@@ -682,7 +654,7 @@ class Netbox {
}
if (ipsToCreate.length > 0) {
log.info(`Creating ${ipsToCreate.length} IPs`)
Object.assign(nbIps, keyBy(await this.#request('/ipam/ip-addresses/', 'POST', ipsToCreate), 'id'))
Object.assign(netboxIps, keyBy(await this.#request('/ipam/ip-addresses/', 'POST', ipsToCreate), 'id'))
}
// Primary IPs -------------------------------------------------------------
@@ -693,39 +665,41 @@ class Netbox {
log.info("Setting VMs' primary IPs")
const vmsToUpdate2 = []
for (const nbVm of Object.values(nbVms)) {
const xoVm = this.getObject(nbVm.custom_fields.uuid)
if (xoVm === undefined) {
log.warn('Updating primary IPs: cannot find VM from UUID custom field', { vm: nbVm.custom_fields.uuid })
for (const netboxVm of Object.values(netboxVms)) {
const vm = this.getObject(netboxVm.custom_fields.uuid)
if (vm === undefined) {
log.warn('Updating primary IPs: cannot find VM from UUID custom field', { vm: netboxVm.custom_fields.uuid })
continue
}
const patch = { id: nbVm.id }
const patch = { id: netboxVm.id }
const nbVmIps = Object.values(nbIps).filter(nbIp => nbIp.assigned_object?.virtual_machine.id === nbVm.id)
const netboxVmIps = Object.values(netboxIps).filter(
netboxIp => netboxIp.assigned_object?.virtual_machine.id === netboxVm.id
)
const ipv4 = xoVm.addresses['0/ipv4/0']
if (ipv4 === undefined && nbVm.primary_ip4 !== null) {
const ipv4 = vm.addresses['0/ipv4/0']
if (ipv4 === undefined && netboxVm.primary_ip4 !== null) {
patch.primary_ip4 = null
} else if (ipv4 !== undefined) {
const nbIp = nbVmIps.find(nbIp => nbIp.address.split('/')[0] === ipv4)
if (nbIp === undefined && nbVm.primary_ip4 !== null) {
const netboxIp = netboxVmIps.find(netboxIp => netboxIp.address.split('/')[0] === ipv4)
if (netboxIp === undefined && netboxVm.primary_ip4 !== null) {
patch.primary_ip4 = null
} else if (nbIp !== undefined && nbIp.id !== nbVm.primary_ip4?.id) {
patch.primary_ip4 = nbIp.id
} else if (netboxIp !== undefined && netboxIp.id !== netboxVm.primary_ip4?.id) {
patch.primary_ip4 = netboxIp.id
}
}
const _ipv6 = xoVm.addresses['0/ipv6/0']
const _ipv6 = vm.addresses['0/ipv6/0']
// For IPv6, compare with the compact notation
const ipv6 = _ipv6 && ipaddr.parse(_ipv6).toString()
if (ipv6 === undefined && nbVm.primary_ip6 !== null) {
if (ipv6 === undefined && netboxVm.primary_ip6 !== null) {
patch.primary_ip6 = null
} else if (ipv6 !== undefined) {
const nbIp = nbVmIps.find(nbIp => nbIp.address.split('/')[0] === ipv6)
if (nbIp === undefined && nbVm.primary_ip6 !== null) {
const netboxIp = netboxVmIps.find(netboxIp => netboxIp.address.split('/')[0] === ipv6)
if (netboxIp === undefined && netboxVm.primary_ip6 !== null) {
patch.primary_ip6 = null
} else if (nbIp !== undefined && nbIp.id !== nbVm.primary_ip6?.id) {
patch.primary_ip6 = nbIp.id
} else if (netboxIp !== undefined && netboxIp.id !== netboxVm.primary_ip6?.id) {
patch.primary_ip6 = netboxIp.id
}
}
@@ -736,9 +710,9 @@ class Netbox {
if (vmsToUpdate2.length > 0) {
log.info(`Updating primary IPs of ${vmsToUpdate2.length} VMs`)
Object.assign(nbVms, keyBy(await this.#request('/virtualization/virtual-machines/', 'PATCH', vmsToUpdate2)))
Object.assign(netboxVms, keyBy(await this.#request('/virtualization/virtual-machines/', 'PATCH', vmsToUpdate2)))
}
log.info(`Done synchronizing ${xoPools.length} pools with Netbox`, { pools: xoPools })
log.info(`Done synchronizing ${pools.length} pools with Netbox`, { pools })
}
}

View File

@@ -90,7 +90,6 @@ set.params = {
},
optional: true,
},
userId: { type: 'string', optional: true },
},
},
}

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