Compare commits
16 Commits
feat_nbd_s
...
fix_stats_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f51e25ac28 | ||
|
|
3baa37846e | ||
|
|
999fba2030 | ||
|
|
785a5857ef | ||
|
|
067f4ac882 | ||
|
|
8a26e08102 | ||
|
|
42aa202f7a | ||
|
|
403d2c8e7b | ||
|
|
ad46bde302 | ||
|
|
1b6ec2c545 | ||
|
|
56388557cb | ||
|
|
1ddbe87d0f | ||
|
|
3081810450 | ||
|
|
155be7fd95 | ||
|
|
ef960e94d3 | ||
|
|
bfd99a48fe |
@@ -1,32 +0,0 @@
|
||||
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()
|
||||
@@ -1,40 +1,12 @@
|
||||
// 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
|
||||
@@ -42,9 +14,6 @@ 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
|
||||
|
||||
@@ -67,15 +36,6 @@ 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
|
||||
|
||||
@@ -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,11 +88,7 @@ export default class NbdClient {
|
||||
async #unsecureConnect() {
|
||||
this.#serverSocket = new Socket()
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#serverSocket.connect({
|
||||
port:this.#serverPort,
|
||||
host: this.#serverAddress,
|
||||
// @todo should test the onRead to limit buffer copy
|
||||
})
|
||||
this.#serverSocket.connect(this.#serverPort, this.#serverAddress)
|
||||
this.#serverSocket.once('error', reject)
|
||||
this.#serverSocket.once('connect', () => {
|
||||
this.#serverSocket.removeListener('error', reject)
|
||||
@@ -236,20 +232,19 @@ export default class NbdClient {
|
||||
}
|
||||
try {
|
||||
this.#waitingForResponse = true
|
||||
const buffer = await this.#read(4+4+8)
|
||||
const magic = buffer.readUInt32BE()
|
||||
const magic = await this.#readInt32()
|
||||
|
||||
if (magic !== NBD_REPLY_MAGIC) {
|
||||
throw new Error(`magic number for block answer is wrong : ${magic} ${NBD_REPLY_MAGIC}`)
|
||||
}
|
||||
|
||||
const error = buffer.readUInt32BE(4)
|
||||
const error = await this.#readInt32()
|
||||
if (error !== 0) {
|
||||
// @todo use error code from constants.mjs
|
||||
throw new Error(`GOT ERROR CODE : ${error}`)
|
||||
}
|
||||
|
||||
const blockQueryId = buffer.readBigUInt64BE(8)
|
||||
const blockQueryId = await this.#readInt64()
|
||||
const query = this.#commandQueryBacklog.get(blockQueryId)
|
||||
if (!query) {
|
||||
throw new Error(` no query associated with id ${blockQueryId}`)
|
||||
@@ -312,11 +307,11 @@ export default class NbdClient {
|
||||
})
|
||||
}
|
||||
|
||||
async *readBlocks(indexGenerator = 2*1024*1024) {
|
||||
async *readBlocks(indexGenerator) {
|
||||
// default : read all blocks
|
||||
if (typeof indexGenerator === 'number') {
|
||||
if (indexGenerator === undefined) {
|
||||
const exportSize = this.#exportSize
|
||||
const chunkSize = indexGenerator
|
||||
const chunkSize = 2 * 1024 * 1024
|
||||
indexGenerator = function* () {
|
||||
const nbBlocks = Math.ceil(Number(exportSize / BigInt(chunkSize)))
|
||||
for (let index = 0; BigInt(index) < nbBlocks; index++) {
|
||||
@@ -324,14 +319,12 @@ 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()
|
||||
},
|
||||
@@ -343,7 +336,6 @@ 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
|
||||
@@ -356,4 +348,4 @@ export default class NbdClient {
|
||||
yield readAhead.shift()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
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) {}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import NbdClient from '../client.mjs'
|
||||
import NbdClient from '../index.mjs'
|
||||
import { spawn, exec } from 'node:child_process'
|
||||
import fs from 'node:fs/promises'
|
||||
import { test } from 'tap'
|
||||
|
||||
@@ -21,7 +21,12 @@ export class RestoreMetadataBackup {
|
||||
})
|
||||
} else {
|
||||
const metadata = JSON.parse(await handler.readFile(join(backupId, 'metadata.json')))
|
||||
return String(await handler.readFile(resolve(backupId, metadata.data ?? 'data.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') }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,13 @@ export class XoMetadataBackup {
|
||||
const dir = `${scheduleDir}/${formatFilenameDate(timestamp)}`
|
||||
|
||||
const data = job.xoMetadata
|
||||
const dataBaseName = './data.json'
|
||||
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 metadata = JSON.stringify(
|
||||
{
|
||||
@@ -54,7 +60,7 @@ export class XoMetadataBackup {
|
||||
async () => {
|
||||
const handler = adapter.handler
|
||||
const dirMode = this._config.dirMode
|
||||
await handler.outputFile(dataFileName, data, { dirMode })
|
||||
await handler.outputFile(dataFileName, isJson ? data : Buffer.from(data.data, data.encoding), { dirMode })
|
||||
await handler.outputFile(metaDataFileName, metadata, {
|
||||
dirMode,
|
||||
})
|
||||
|
||||
144
@xen-orchestra/lite/docs/xen-api-collections.md
Normal file
144
@xen-orchestra/lite/docs/xen-api-collections.md
Normal file
@@ -0,0 +1,144 @@
|
||||
<!-- 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))
|
||||
),
|
||||
};
|
||||
};
|
||||
```
|
||||
@@ -1,144 +0,0 @@
|
||||
# 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 });
|
||||
```
|
||||
@@ -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 { usePoolStore } from "@/stores/pool.store";
|
||||
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
|
||||
import { useUiStore } from "@/stores/ui.store";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
import { useActiveElement, useMagicKeys, whenever } from "@vueuse/core";
|
||||
@@ -42,7 +42,9 @@ if (link == null) {
|
||||
link.href = favicon;
|
||||
|
||||
const xenApiStore = useXenApiStore();
|
||||
const { pool } = usePoolStore().subscribe();
|
||||
|
||||
const { pool } = usePoolCollection();
|
||||
|
||||
useChartTheme();
|
||||
const uiStore = useUiStore();
|
||||
|
||||
|
||||
@@ -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 } = useHostStore().subscribe();
|
||||
const { records: hosts } = useHostCollection();
|
||||
const unreachableHostsUrls = ref<Set<string>>(new Set());
|
||||
const clearUnreachableHostsUrls = () => unreachableHostsUrls.value.clear();
|
||||
const isSslModalOpen = computed(() => unreachableHostsUrls.value.size > 0);
|
||||
|
||||
@@ -2,32 +2,80 @@
|
||||
<RouterLink :to="{ name: 'story' }">
|
||||
<UiTitle type="h4">Stories</UiTitle>
|
||||
</RouterLink>
|
||||
<ul class="links">
|
||||
<li v-for="route in routes" :key="route.name">
|
||||
<RouterLink class="link" :to="route">
|
||||
{{ route.meta.storyTitle }}
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
<StoryMenuTree
|
||||
:tree="tree"
|
||||
@toggle-directory="toggleDirectory"
|
||||
:opened-directories="openedDirectories"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRouter } from "vue-router";
|
||||
import StoryMenuTree from "@/components/component-story/StoryMenuTree.vue";
|
||||
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);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.links {
|
||||
padding: 1rem;
|
||||
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;
|
||||
}
|
||||
.link {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
text-decoration: none;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<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>
|
||||
@@ -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,11 +46,10 @@ const props = defineProps<{
|
||||
hostOpaqueRef: XenApiHost["$ref"];
|
||||
}>();
|
||||
|
||||
const { getByOpaqueRef } = useHostStore().subscribe();
|
||||
const { getByOpaqueRef } = useHostCollection();
|
||||
const host = computed(() => getByOpaqueRef(props.hostOpaqueRef));
|
||||
|
||||
const { pool } = usePoolStore().subscribe();
|
||||
|
||||
const { pool } = usePoolCollection();
|
||||
const isPoolMaster = computed(() => pool.value?.master === props.hostOpaqueRef);
|
||||
|
||||
const uiStore = useUiStore();
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import InfraHostItem from "@/components/infra/InfraHostItem.vue";
|
||||
import { useHostStore } from "@/stores/host.store";
|
||||
import { useHostCollection } from "@/composables/xen-api-collection/host-collection.composable";
|
||||
|
||||
const { records: hosts, isReady, hasError } = useHostStore().subscribe();
|
||||
const { records: hosts, isReady, hasError } = useHostCollection();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
@@ -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 { usePoolStore } from "@/stores/pool.store";
|
||||
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
|
||||
import { faBuilding } from "@fortawesome/free-regular-svg-icons";
|
||||
|
||||
const { isReady, hasError, pool } = usePoolStore().subscribe();
|
||||
const { isReady, hasError, pool } = usePoolCollection();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
@@ -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 } = useVmStore().subscribe();
|
||||
const { getByOpaqueRef } = useVmCollection();
|
||||
const vm = computed(() => getByOpaqueRef(props.vmOpaqueRef));
|
||||
const rootElement = ref();
|
||||
const isVisible = ref(false);
|
||||
|
||||
@@ -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 } = useVmStore().subscribe();
|
||||
const { isReady, recordsByHostRef, hasError } = useVmCollection();
|
||||
|
||||
const vms = computed(() =>
|
||||
recordsByHostRef.value.get(
|
||||
|
||||
@@ -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 } = usePoolStore().subscribe();
|
||||
const { pool } = usePoolCollection();
|
||||
|
||||
const name = computed(() => pool.value?.name_label ?? "...");
|
||||
</script>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<script lang="ts" setup>
|
||||
import RouterTab from "@/components/RouterTab.vue";
|
||||
import UiTabBar from "@/components/ui/UiTabBar.vue";
|
||||
import { usePoolStore } from "@/stores/pool.store";
|
||||
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
|
||||
|
||||
const { pool, isReady } = usePoolStore().subscribe();
|
||||
const { pool, isReady } = usePoolCollection();
|
||||
</script>
|
||||
|
||||
@@ -37,12 +37,11 @@ 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";
|
||||
|
||||
@@ -52,18 +51,16 @@ const {
|
||||
hasError: hostStoreHasError,
|
||||
isReady: isHostStoreReady,
|
||||
runningHosts,
|
||||
} = useHostStore().subscribe({
|
||||
hostMetricsSubscription: useHostMetricsStore().subscribe(),
|
||||
});
|
||||
} = useHostCollection();
|
||||
|
||||
const {
|
||||
hasError: vmStoreHasError,
|
||||
isReady: isVmStoreReady,
|
||||
records: vms,
|
||||
} = useVmStore().subscribe();
|
||||
} = useVmCollection();
|
||||
|
||||
const { getByOpaqueRef: getVmMetrics, isReady: isVmMetricsStoreReady } =
|
||||
useVmMetricsStore().subscribe();
|
||||
useVmMetricsCollection();
|
||||
|
||||
const nPCpu = computed(() =>
|
||||
runningHosts.value.reduce(
|
||||
|
||||
@@ -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 } = useVmStore().subscribe();
|
||||
const { hasError: hasHostError } = useHostStore().subscribe();
|
||||
const { hasError: hasVmError } = useVmCollection();
|
||||
const { hasError: hasHostError } = useHostCollection();
|
||||
|
||||
const vmStats = inject<ComputedRef<Stat<VmStats>[]>>(
|
||||
"vmStats",
|
||||
|
||||
@@ -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 } = useVmStore().subscribe();
|
||||
const { hasError: hasHostError } = useHostStore().subscribe();
|
||||
const { hasError: hasVmError } = useVmCollection();
|
||||
const { hasError: hasHostError } = useHostCollection();
|
||||
|
||||
const vmStats = inject<ComputedRef<Stat<VmStats>[]>>(
|
||||
"vmStats",
|
||||
|
||||
@@ -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 { useHostMetricsStore } from "@/stores/host-metrics.store";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
import { useHostMetricsCollection } from "@/composables/xen-api-collection/host-metrics-collection.composable";
|
||||
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
|
||||
import { computed } from "vue";
|
||||
|
||||
const {
|
||||
isReady: isVmReady,
|
||||
records: vms,
|
||||
hasError: hasVmError,
|
||||
} = useVmStore().subscribe();
|
||||
} = useVmCollection();
|
||||
|
||||
const {
|
||||
isReady: isHostMetricsReady,
|
||||
records: hostMetrics,
|
||||
hasError: hasHostMetricsError,
|
||||
} = useHostMetricsStore().subscribe();
|
||||
} = useHostMetricsCollection();
|
||||
|
||||
const hasError = computed(() => hasVmError.value || hasHostMetricsError.value);
|
||||
|
||||
|
||||
@@ -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 { useSrStore } from "@/stores/storage.store";
|
||||
import { useSrCollection } from "@/composables/xen-api-collection/sr-collection.composable";
|
||||
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
|
||||
import { computed } from "vue";
|
||||
|
||||
const { records: srs, isReady, hasError } = useSrStore().subscribe();
|
||||
const { records: srs, isReady, hasError } = useSrCollection();
|
||||
|
||||
const data = computed<{
|
||||
result: { id: string; label: string; value: number }[];
|
||||
|
||||
@@ -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 { useTaskStore } from "@/stores/task.store";
|
||||
import { useTaskCollection } from "@/composables/xen-api-collection/task-collection.composable";
|
||||
|
||||
const { pendingTasks } = useTaskStore().subscribe();
|
||||
const { pendingTasks } = useTaskCollection();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped></style>
|
||||
|
||||
@@ -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 } = useHostStore().subscribe();
|
||||
const { hasError } = useHostCollection();
|
||||
|
||||
const stats = inject(
|
||||
IK_HOST_STATS,
|
||||
|
||||
@@ -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 } = useHostStore().subscribe();
|
||||
const { records: hosts } = useHostCollection();
|
||||
|
||||
const customMaxValue = computed(
|
||||
() => 100 * sumBy(hosts.value, (host) => +host.cpu_info.cpu_count)
|
||||
|
||||
@@ -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 } = useVmStore().subscribe();
|
||||
const { hasError } = useVmCollection();
|
||||
|
||||
const stats = inject(
|
||||
IK_VM_STATS,
|
||||
|
||||
@@ -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 } = useHostStore().subscribe();
|
||||
const { hasError } = useHostCollection();
|
||||
|
||||
const stats = inject(
|
||||
IK_HOST_STATS,
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import SizeStatsSummary from "@/components/ui/SizeStatsSummary.vue";
|
||||
import { formatSize, getHostMemory } from "@/libs/utils";
|
||||
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 { 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,27 +31,22 @@ const LinearChart = defineAsyncComponent(
|
||||
() => import("@/components/charts/LinearChart.vue")
|
||||
);
|
||||
|
||||
const hostMetricsSubscription = useHostMetricsStore().subscribe();
|
||||
|
||||
const hostStore = useHostStore();
|
||||
const { runningHosts } = hostStore.subscribe({ hostMetricsSubscription });
|
||||
const { runningHosts } = useHostCollection();
|
||||
const { getHostMemory } = useHostMetricsCollection();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const hostLastWeekStats = inject(IK_HOST_LAST_WEEK_STATS);
|
||||
|
||||
const customMaxValue = computed(() =>
|
||||
sumBy(
|
||||
runningHosts.value,
|
||||
(host) => getHostMemory(host, hostMetricsSubscription)?.size ?? 0
|
||||
)
|
||||
sumBy(runningHosts.value, (host) => getHostMemory(host)?.size ?? 0)
|
||||
);
|
||||
|
||||
const currentData = computed(() => {
|
||||
let size = 0,
|
||||
usage = 0;
|
||||
runningHosts.value.forEach((host) => {
|
||||
const hostMemory = getHostMemory(host, hostMetricsSubscription);
|
||||
const hostMemory = getHostMemory(host);
|
||||
size += hostMemory?.size ?? 0;
|
||||
usage += hostMemory?.usage ?? 0;
|
||||
});
|
||||
|
||||
@@ -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 } = useVmStore().subscribe();
|
||||
const { hasError } = useVmCollection();
|
||||
|
||||
const stats = inject(
|
||||
IK_VM_STATS,
|
||||
|
||||
@@ -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 } = useHostStore().subscribe();
|
||||
const { getByOpaqueRef: getHost } = useHostCollection();
|
||||
|
||||
const createdAt = computed(() => parseDateTime(props.task.created));
|
||||
|
||||
|
||||
@@ -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 } = useTaskStore().subscribe();
|
||||
const { hasError, isFetching } = useTaskCollection();
|
||||
|
||||
const hasTasks = computed(
|
||||
() => props.pendingTasks.length > 0 || (props.finishedTasks?.length ?? 0) > 0
|
||||
|
||||
@@ -12,10 +12,9 @@
|
||||
|
||||
<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";
|
||||
@@ -24,7 +23,7 @@ const props = defineProps<{
|
||||
selectedRefs: XenApiVm["$ref"][];
|
||||
}>();
|
||||
|
||||
const { getByOpaqueRef } = useVmStore().subscribe();
|
||||
const { getByOpaqueRef, isOperationPending } = useVmCollection();
|
||||
|
||||
const selectedVms = computed(() =>
|
||||
props.selectedRefs
|
||||
@@ -39,7 +38,7 @@ const areAllSelectedVmsHalted = computed(() =>
|
||||
);
|
||||
|
||||
const areSomeSelectedVmsCloning = computed(() =>
|
||||
selectedVms.value.some((vm) => isOperationsPending(vm, VM_OPERATION.CLONE))
|
||||
selectedVms.value.some((vm) => isOperationPending(vm, VM_OPERATION.CLONE))
|
||||
);
|
||||
|
||||
const handleCopy = async () => {
|
||||
|
||||
@@ -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 } = useVmStore().subscribe();
|
||||
const { getByOpaqueRef: getVm } = useVmCollection();
|
||||
const {
|
||||
open: openDeleteModal,
|
||||
close: closeDeleteModal,
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
</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";
|
||||
@@ -36,7 +37,6 @@ 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 } = useVmStore().subscribe();
|
||||
const { getByOpaqueRef: getVm } = useVmCollection();
|
||||
const vms = computed(() =>
|
||||
props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined)
|
||||
);
|
||||
|
||||
@@ -95,13 +95,12 @@
|
||||
import MenuItem from "@/components/menu/MenuItem.vue";
|
||||
import PowerStateIcon from "@/components/PowerStateIcon.vue";
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import { isHostRunning, isOperationsPending } from "@/libs/utils";
|
||||
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 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,
|
||||
@@ -121,12 +120,12 @@ const props = defineProps<{
|
||||
vmRefs: XenApiVm["$ref"][];
|
||||
}>();
|
||||
|
||||
const { getByOpaqueRef: getVm } = useVmStore().subscribe();
|
||||
const { records: hosts } = useHostStore().subscribe();
|
||||
const { pool } = usePoolStore().subscribe();
|
||||
const hostMetricsSubscription = useHostMetricsStore().subscribe();
|
||||
const { getByOpaqueRef: getVm, isOperationPending } = useVmCollection();
|
||||
const { records: hosts } = useHostCollection();
|
||||
const { pool } = usePoolCollection();
|
||||
const { isHostRunning } = useHostMetricsCollection();
|
||||
|
||||
const vms = computed<XenApiVm[]>(() =>
|
||||
const vms = computed(() =>
|
||||
props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined)
|
||||
);
|
||||
|
||||
@@ -150,7 +149,7 @@ const areVmsPaused = computed(() =>
|
||||
);
|
||||
|
||||
const areOperationsPending = (operation: VM_OPERATION | VM_OPERATION[]) =>
|
||||
vms.value.some((vm) => isOperationsPending(vm, operation));
|
||||
vms.value.some((vm) => isOperationPending(vm, operation));
|
||||
|
||||
const areVmsBusyToStart = computed(() =>
|
||||
areOperationsPending(VM_OPERATION.START)
|
||||
@@ -180,9 +179,7 @@ const areVmsBusyToForceShutdown = computed(() =>
|
||||
areOperationsPending(VM_OPERATION.HARD_SHUTDOWN)
|
||||
);
|
||||
const getHostState = (host: XenApiHost) =>
|
||||
isHostRunning(host, hostMetricsSubscription)
|
||||
? POWER_STATE.RUNNING
|
||||
: POWER_STATE.HALTED;
|
||||
isHostRunning(host) ? POWER_STATE.RUNNING : POWER_STATE.HALTED;
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
@@ -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 } = useVmStore().subscribe();
|
||||
const { getByUuid: getVmByUuid } = useVmCollection();
|
||||
const { currentRoute } = useRouter();
|
||||
|
||||
const vm = computed(() =>
|
||||
|
||||
@@ -17,9 +17,9 @@ export type Stat<T> = {
|
||||
pausable: Pausable;
|
||||
};
|
||||
|
||||
type GetStats<
|
||||
export type GetStats<
|
||||
T extends XenApiHost | XenApiVm,
|
||||
S extends HostStats | VmStats
|
||||
S extends HostStats | VmStats = T extends XenApiHost ? HostStats : VmStats,
|
||||
> = (
|
||||
uuid: T["uuid"],
|
||||
granularity: GRANULARITY,
|
||||
@@ -29,7 +29,7 @@ type GetStats<
|
||||
|
||||
export type FetchedStats<
|
||||
T extends XenApiHost | XenApiVm,
|
||||
S extends HostStats | VmStats
|
||||
S extends HostStats | VmStats = T extends XenApiHost ? 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
|
||||
S extends HostStats | VmStats = T extends XenApiHost ? 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<Stat<S>[]>(() => Array.from(stats.value.values())),
|
||||
stats: computed(() => Array.from(stats.value.values()) as Stat<S>[]),
|
||||
timestampStart: computed(() => timestamp.value[0]),
|
||||
timestampEnd: computed(() => timestamp.value[1]),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
|
||||
|
||||
export const useConsoleCollection = () => useXenApiCollection("console");
|
||||
@@ -0,0 +1,45 @@
|
||||
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>,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
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]
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
|
||||
|
||||
export const useSrCollection = () => useXenApiCollection("SR");
|
||||
@@ -0,0 +1,41 @@
|
||||
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(),
|
||||
})),
|
||||
}
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
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>,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { useXenApiCollection } from "@/composables/xen-api-collection.composable";
|
||||
|
||||
export const useVmMetricsCollection = () => useXenApiCollection("VM_metrics");
|
||||
@@ -1,18 +1,14 @@
|
||||
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 { castArray, find, forEach, round, size, sum } from "lodash-es";
|
||||
import { find, forEach, round, size, sum } from "lodash-es";
|
||||
|
||||
export function sortRecordsByNameLabel(
|
||||
record1: { name_label: string },
|
||||
@@ -21,14 +17,7 @@ export function sortRecordsByNameLabel(
|
||||
const label1 = record1.name_label.toLocaleLowerCase();
|
||||
const label2 = record2.name_label.toLocaleLowerCase();
|
||||
|
||||
switch (true) {
|
||||
case label1 < label2:
|
||||
return -1;
|
||||
case label1 > label2:
|
||||
return 1;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
return label1.localeCompare(label2);
|
||||
}
|
||||
|
||||
export function escapeRegExp(string: string) {
|
||||
@@ -114,28 +103,6 @@ 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"] }
|
||||
@@ -182,13 +149,3 @@ 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)
|
||||
);
|
||||
};
|
||||
|
||||
112
@xen-orchestra/lite/src/libs/xen-api-collection.ts
Normal file
112
@xen-orchestra/lite/src/libs/xen-api-collection.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { buildXoObject, parseDateTime } from "@/libs/utils";
|
||||
import type { RawTypeToRecord } from "@/types/xapi-collection";
|
||||
import type { RawTypeToRecord } from "@/types/xen-api-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`,
|
||||
|
||||
@@ -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 basename = path.replace(/^\/src\/stories\/(.*)\.story.vue$/, "$1");
|
||||
const basePath = path.replace(/^\/src\/stories\/(.*)\.story.vue$/, "$1");
|
||||
const docPath = path.replace(/\.vue$/, ".md");
|
||||
const routeName = `story-${basename}`;
|
||||
const routeName = `story-${basePath}`;
|
||||
|
||||
return {
|
||||
name: routeName,
|
||||
path: basename,
|
||||
path: basePath,
|
||||
component: componentLoader,
|
||||
meta: {
|
||||
isStory: true,
|
||||
storyTitle: basenameToStoryTitle(basename),
|
||||
storyTitle: basePathToStoryTitle(basePath),
|
||||
storyMdLoader: docLoaders[docPath],
|
||||
},
|
||||
};
|
||||
@@ -46,8 +46,10 @@ export default {
|
||||
* Basename: `my-component`
|
||||
* Page title: `My Component`
|
||||
*/
|
||||
function basenameToStoryTitle(basename: string) {
|
||||
return basename
|
||||
function basePathToStoryTitle(basePath: string) {
|
||||
return basePath
|
||||
.split("/")
|
||||
.pop()!
|
||||
.split("-")
|
||||
.map((s) => `${s.charAt(0).toUpperCase()}${s.substring(1)}`)
|
||||
.join(" ");
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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,
|
||||
};
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useConsoleStore = defineStore("console", () =>
|
||||
useXapiCollectionStore().get("console")
|
||||
);
|
||||
@@ -1,6 +0,0 @@
|
||||
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useHostMetricsStore = defineStore("host-metrics", () =>
|
||||
useXapiCollectionStore().get("host_metrics")
|
||||
);
|
||||
@@ -1,91 +0,0 @@
|
||||
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,
|
||||
};
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
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,
|
||||
};
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useSrStore = defineStore("SR", () =>
|
||||
useXapiCollectionStore().get("SR")
|
||||
);
|
||||
@@ -1,64 +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 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 };
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useVmGuestMetricsStore = defineStore("vm-guest-metrics", () =>
|
||||
useXapiCollectionStore().get("VM_guest_metrics")
|
||||
);
|
||||
@@ -1,6 +0,0 @@
|
||||
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useVmMetricsStore = defineStore("vm-metrics", () =>
|
||||
useXapiCollectionStore().get("VM_metrics")
|
||||
);
|
||||
@@ -1,125 +0,0 @@
|
||||
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,
|
||||
};
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -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 collection = useXapiCollectionStore().get(
|
||||
const collectionManager = useXenApiCollectionManager(
|
||||
getRawObjectType(result.class)
|
||||
);
|
||||
|
||||
if (!collection.hasSubscriptions) {
|
||||
if (!collectionManager.hasSubscriptions.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -44,11 +44,11 @@ export const useXenApiStore = defineStore("xen-api", () => {
|
||||
|
||||
switch (result.operation) {
|
||||
case "add":
|
||||
return collection.add(buildObject());
|
||||
return collectionManager.add(buildObject());
|
||||
case "mod":
|
||||
return collection.update(buildObject());
|
||||
return collectionManager.update(buildObject());
|
||||
case "del":
|
||||
return collection.remove(result.ref as any);
|
||||
return collectionManager.remove(result.ref as any);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.js";
|
||||
import { text } from "@/libs/story/story-widget.js";
|
||||
import { prop, setting, slot } from "@/libs/story/story-param";
|
||||
import { text } from "@/libs/story/story-widget";
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped></style>
|
||||
@@ -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.js";
|
||||
import { prop, slot } from "@/libs/story/story-param";
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped></style>
|
||||
@@ -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.js";
|
||||
import { text } from "@/libs/story/story-widget.js";
|
||||
import { prop, setting, slot } from "@/libs/story/story-param";
|
||||
import { text } from "@/libs/story/story-widget";
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped></style>
|
||||
@@ -1,108 +0,0 @@
|
||||
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;
|
||||
68
@xen-orchestra/lite/src/types/xen-api-collection.ts
Normal file
68
@xen-orchestra/lite/src/types/xen-api-collection.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
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
|
||||
>;
|
||||
@@ -1,12 +1,13 @@
|
||||
<template>Chargement en cours...</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { usePoolStore } from "@/stores/pool.store";
|
||||
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
|
||||
import { whenever } from "@vueuse/core";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
const router = useRouter();
|
||||
const { pool } = usePoolStore().subscribe();
|
||||
|
||||
const { pool } = usePoolCollection();
|
||||
|
||||
whenever(
|
||||
() => pool.value?.uuid,
|
||||
|
||||
@@ -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 } = useHostStore().subscribe();
|
||||
const { hasUuid, isReady, getByUuid } = useHostCollection();
|
||||
const route = useRoute();
|
||||
const uiStore = useUiStore();
|
||||
|
||||
|
||||
@@ -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,15 +60,8 @@ import { useI18n } from "vue-i18n";
|
||||
|
||||
usePageTitleStore().setTitle(useI18n().t("dashboard"));
|
||||
|
||||
const hostMetricsSubscription = useHostMetricsStore().subscribe();
|
||||
|
||||
const hostSubscription = useHostStore().subscribe({ hostMetricsSubscription });
|
||||
|
||||
const { runningHosts, getStats: getHostStats } = hostSubscription;
|
||||
|
||||
const { runningVms, getStats: getVmStats } = useVmStore().subscribe({
|
||||
hostSubscription,
|
||||
});
|
||||
const { getStats: getHostStats, runningHosts } = useHostCollection();
|
||||
const { getStats: getVmStats, runningVms } = useVmCollection();
|
||||
|
||||
const {
|
||||
register: hostRegister,
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
<script lang="ts" setup>
|
||||
import PoolHeader from "@/components/pool/PoolHeader.vue";
|
||||
import PoolTabBar from "@/components/pool/PoolTabBar.vue";
|
||||
import { usePoolStore } from "@/stores/pool.store";
|
||||
import { usePoolCollection } from "@/composables/xen-api-collection/pool-collection.composable";
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
|
||||
const { pool } = usePoolStore().subscribe();
|
||||
const { pool } = usePoolCollection();
|
||||
|
||||
usePageTitleStore().setObject(pool);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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 { useTaskStore } from "@/stores/task.store";
|
||||
import { useTaskCollection } from "@/composables/xen-api-collection/task-collection.composable";
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { pendingTasks, finishedTasks, isReady, hasError } =
|
||||
useTaskStore().subscribe();
|
||||
const { pendingTasks, finishedTasks, isReady, hasError } = useTaskCollection();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const titleStore = usePageTitleStore();
|
||||
|
||||
@@ -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 } = useVmStore().subscribe();
|
||||
const { records: vms } = useVmCollection();
|
||||
const { isMobile, isDesktop } = storeToRefs(useUiStore());
|
||||
|
||||
const filters: Filters = {
|
||||
|
||||
@@ -157,6 +157,8 @@
|
||||
</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";
|
||||
@@ -166,8 +168,6 @@ 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,8 +186,9 @@ const { t, locale } = useI18n();
|
||||
|
||||
usePageTitleStore().setTitle(() => t("settings"));
|
||||
|
||||
const { pool } = usePoolStore().subscribe();
|
||||
const { getByOpaqueRef: getHost } = useHostStore().subscribe();
|
||||
const { pool } = usePoolCollection();
|
||||
|
||||
const { getByOpaqueRef: getHost } = useHostCollection();
|
||||
|
||||
const poolMaster = computed(() =>
|
||||
pool.value ? getHost(pool.value.master) : undefined
|
||||
|
||||
@@ -31,12 +31,11 @@
|
||||
import RemoteConsole from "@/components/RemoteConsole.vue";
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
||||
import { isOperationsPending } from "@/libs/utils";
|
||||
import { useConsoleCollection } from "@/composables/xen-api-collection/console-collection.composable";
|
||||
import { useVmCollection } from "@/composables/xen-api-collection/vm-collection.composable";
|
||||
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";
|
||||
@@ -61,13 +60,14 @@ const {
|
||||
isReady: isVmReady,
|
||||
getByUuid: getVmByUuid,
|
||||
hasError: hasVmError,
|
||||
} = useVmStore().subscribe();
|
||||
isOperationPending,
|
||||
} = useVmCollection();
|
||||
|
||||
const {
|
||||
isReady: isConsoleReady,
|
||||
getByOpaqueRef: getConsoleByOpaqueRef,
|
||||
hasError: hasConsoleError,
|
||||
} = useConsoleStore().subscribe();
|
||||
} = useConsoleCollection();
|
||||
|
||||
const isReady = computed(() => isVmReady.value && isConsoleReady.value);
|
||||
|
||||
@@ -89,9 +89,8 @@ const vmConsole = computed(() => {
|
||||
return getConsoleByOpaqueRef(consoleOpaqueRef);
|
||||
});
|
||||
|
||||
const isConsoleAvailable = computed(
|
||||
() =>
|
||||
vm.value !== undefined && !isOperationsPending(vm.value, STOP_OPERATIONS)
|
||||
const isConsoleAvailable = computed(() =>
|
||||
vm.value !== undefined ? isOperationPending(vm.value, STOP_OPERATIONS) : false
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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 } = useVmStore().subscribe();
|
||||
const { getByUuid, hasUuid, isReady } = useVmCollection();
|
||||
const uiStore = useUiStore();
|
||||
const vm = computed(() => getByUuid(route.params.uuid as XenApiVm["uuid"]));
|
||||
whenever(vm, (vm) => (uiStore.currentHostOpaqueRef = vm.resident_on));
|
||||
|
||||
@@ -56,9 +56,13 @@ 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 () => {
|
||||
@@ -131,10 +135,10 @@ export default class Tasks extends EventEmitter {
|
||||
*
|
||||
* @returns {Task}
|
||||
*/
|
||||
create({ name, objectId, type }) {
|
||||
create({ name, objectId, userId = this.#app.apiContext?.user?.id, type }) {
|
||||
const tasks = this.#tasks
|
||||
|
||||
const task = new Task({ properties: { name, objectId, type }, onProgress: this.#onProgress })
|
||||
const task = new Task({ properties: { name, objectId, userId, type }, onProgress: this.#onProgress })
|
||||
|
||||
// Use a compact, sortable, string representation of the creation date
|
||||
//
|
||||
|
||||
@@ -32,9 +32,14 @@ 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(() => this.callAsync('host.enable', 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))
|
||||
})
|
||||
}
|
||||
|
||||
let currentVmRef
|
||||
@@ -51,7 +56,7 @@ class Host {
|
||||
|
||||
try {
|
||||
await this.callAsync('VM.suspend', vmRef)
|
||||
$defer(() => this.callAsync('VM.resume', vmRef, false, false))
|
||||
suspendedVms.push(vmRef)
|
||||
} catch (error) {
|
||||
const { code } = error
|
||||
|
||||
|
||||
@@ -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/client.mjs'
|
||||
import NbdClient from '@vates/nbd-client'
|
||||
import { createNbdRawStream, createNbdVhdStream } from 'vhd-lib/createStreamNbd.js'
|
||||
import { VDI_FORMAT_RAW, VDI_FORMAT_VHD } from './index.mjs'
|
||||
|
||||
|
||||
@@ -7,12 +7,21 @@
|
||||
|
||||
> 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))
|
||||
|
||||
### 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))
|
||||
- [XenApi/stats] Fix `Cannot read properties of undefined (reading 'statusCode')` (PR [#7004](https://github.com/vatesfr/xen-orchestra/pull/7004))
|
||||
|
||||
### Packages to release
|
||||
|
||||
@@ -31,9 +40,12 @@
|
||||
<!--packages-start-->
|
||||
|
||||
- @xen-orchestra/backups patch
|
||||
- @xen-orchestra/mixins minor
|
||||
- @xen-orchestra/xapi patch
|
||||
- xen-api patch
|
||||
- xo-server patch
|
||||
- xo-server minor
|
||||
- xo-server-auth-ldap patch
|
||||
- xo-web patch
|
||||
- xo-server-netbox minor
|
||||
- xo-web minor
|
||||
|
||||
<!--packages-end-->
|
||||
|
||||
@@ -419,7 +419,7 @@ export class Xapi extends EventEmitter {
|
||||
signal: $cancelToken,
|
||||
}),
|
||||
{
|
||||
when: error => error.response !== undefined && error.response.statusCode === 302,
|
||||
when: error => error.response !== undefined && error.response?.statusCode === 302,
|
||||
onRetry: async error => {
|
||||
const response = error.response
|
||||
if (response === undefined) {
|
||||
|
||||
@@ -16,6 +16,16 @@ 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) {
|
||||
|
||||
@@ -38,7 +38,7 @@ class Netbox {
|
||||
#intervalToken
|
||||
#loaded
|
||||
#netboxApiVersion
|
||||
#pools
|
||||
#xoPools
|
||||
#removeApiMethods
|
||||
#syncInterval
|
||||
#token
|
||||
@@ -63,7 +63,7 @@ class Netbox {
|
||||
}
|
||||
this.#allowUnauthorized = configuration.allowUnauthorized ?? false
|
||||
this.#token = configuration.token
|
||||
this.#pools = configuration.pools
|
||||
this.#xoPools = 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 clusterTypes = await this.#request(`/virtualization/cluster-types/?name=${encodeURIComponent(name)}`)
|
||||
const nbClusterTypes = await this.#request(`/virtualization/cluster-types/?name=${encodeURIComponent(name)}`)
|
||||
|
||||
await this.#checkCustomFields()
|
||||
|
||||
if (clusterTypes.length !== 1) {
|
||||
if (nbClusterTypes.length !== 1) {
|
||||
throw new Error('Could not properly write and read Netbox')
|
||||
}
|
||||
|
||||
await this.#request('/virtualization/cluster-types/', 'DELETE', [{ id: clusterTypes[0].id }])
|
||||
await this.#request('/virtualization/cluster-types/', 'DELETE', [{ id: nbClusterTypes[0].id }])
|
||||
}
|
||||
|
||||
async #request(path, method = 'GET', data) {
|
||||
@@ -204,10 +204,10 @@ class Netbox {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async #synchronize(pools = this.#pools) {
|
||||
async #synchronize(xoPools = this.#xoPools) {
|
||||
await this.#checkCustomFields()
|
||||
|
||||
log.info(`Synchronizing ${pools.length} pools with Netbox`, { pools })
|
||||
log.info(`Synchronizing ${xoPools.length} pools with Netbox`, { pools: xoPools })
|
||||
|
||||
// Cluster type ------------------------------------------------------------
|
||||
|
||||
@@ -215,20 +215,22 @@ class Netbox {
|
||||
// that have been created from XO
|
||||
|
||||
// Check if a cluster type called XCP-ng already exists otherwise create it
|
||||
const clusterTypes = await this.#request(`/virtualization/cluster-types/?name=${encodeURIComponent(CLUSTER_TYPE)}`)
|
||||
if (clusterTypes.length > 1) {
|
||||
const nbClusterTypes = await this.#request(
|
||||
`/virtualization/cluster-types/?name=${encodeURIComponent(CLUSTER_TYPE)}`
|
||||
)
|
||||
if (nbClusterTypes.length > 1) {
|
||||
throw new Error('Found more than 1 "XCP-ng Pool" cluster type')
|
||||
}
|
||||
let clusterType
|
||||
if (clusterTypes.length === 0) {
|
||||
let nbClusterType
|
||||
if (nbClusterTypes.length === 0) {
|
||||
log.info('Creating cluster type')
|
||||
clusterType = await this.#request('/virtualization/cluster-types/', 'POST', {
|
||||
nbClusterType = await this.#request('/virtualization/cluster-types/', 'POST', {
|
||||
name: CLUSTER_TYPE,
|
||||
slug: slugify(CLUSTER_TYPE),
|
||||
description: 'Created by Xen Orchestra',
|
||||
})
|
||||
} else {
|
||||
clusterType = clusterTypes[0]
|
||||
nbClusterType = nbClusterTypes[0]
|
||||
}
|
||||
|
||||
// Clusters ----------------------------------------------------------------
|
||||
@@ -237,45 +239,45 @@ class Netbox {
|
||||
|
||||
log.info('Synchronizing clusters')
|
||||
|
||||
const createCluster = (pool, clusterType) => ({
|
||||
custom_fields: { uuid: pool.uuid },
|
||||
name: pool.name_label.slice(0, NAME_MAX_LENGTH),
|
||||
type: clusterType.id,
|
||||
const createNbCluster = (xoPool, nbClusterType) => ({
|
||||
custom_fields: { uuid: xoPool.uuid },
|
||||
name: xoPool.name_label.slice(0, NAME_MAX_LENGTH),
|
||||
type: nbClusterType.id,
|
||||
})
|
||||
|
||||
// { Pool UUID → cluster }
|
||||
const allClusters = keyBy(
|
||||
await this.#request(`/virtualization/clusters/?type_id=${clusterType.id}`),
|
||||
const allNbClusters = keyBy(
|
||||
await this.#request(`/virtualization/clusters/?type_id=${nbClusterType.id}`),
|
||||
'custom_fields.uuid'
|
||||
)
|
||||
const clusters = pick(allClusters, pools)
|
||||
const nbClusters = pick(allNbClusters, xoPools)
|
||||
|
||||
if (!isEmpty(allClusters[undefined])) {
|
||||
if (!isEmpty(allNbClusters[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', allClusters[undefined])
|
||||
log.warn('Found some clusters with missing UUID custom field', allNbClusters[undefined])
|
||||
}
|
||||
|
||||
const clustersToCreate = []
|
||||
const clustersToUpdate = []
|
||||
for (const poolId of pools) {
|
||||
const pool = this.getObject(poolId)
|
||||
if (pool === undefined) {
|
||||
for (const xoPoolId of xoPools) {
|
||||
const xoPool = this.getObject(xoPoolId)
|
||||
if (xoPool === undefined) {
|
||||
// If we can't find the pool, don't synchronize anything within that pool
|
||||
log.warn('Synchronizing pools: cannot find pool', { pool: poolId })
|
||||
delete allClusters[poolId]
|
||||
delete clusters[poolId]
|
||||
log.warn('Synchronizing pools: cannot find pool', { pool: xoPoolId })
|
||||
delete allNbClusters[xoPoolId]
|
||||
delete nbClusters[xoPoolId]
|
||||
continue
|
||||
}
|
||||
const cluster = clusters[pool.uuid]
|
||||
const nbCluster = nbClusters[xoPool.uuid]
|
||||
|
||||
const updatedCluster = createCluster(pool, clusterType)
|
||||
const updatedCluster = createNbCluster(xoPool, nbClusterType)
|
||||
|
||||
if (cluster === undefined) {
|
||||
if (nbCluster === undefined) {
|
||||
clustersToCreate.push(updatedCluster)
|
||||
} else {
|
||||
// `type` needs to be flattened so we can compare the 2 objects
|
||||
const patch = diff(updatedCluster, { ...cluster, type: cluster.type.id })
|
||||
const patch = diff(updatedCluster, { ...nbCluster, type: nbCluster.type.id })
|
||||
if (patch !== undefined) {
|
||||
clustersToUpdate.push(patch)
|
||||
}
|
||||
@@ -293,124 +295,152 @@ class Netbox {
|
||||
log.info(`Creating ${clustersToCreate.length} clusters`)
|
||||
newClusters.push(...(await this.#request('/virtualization/clusters/', 'POST', clustersToCreate)))
|
||||
}
|
||||
Object.assign(clusters, keyBy(newClusters, 'custom_fields.uuid'))
|
||||
Object.assign(allClusters, clusters)
|
||||
Object.assign(nbClusters, keyBy(newClusters, 'custom_fields.uuid'))
|
||||
Object.assign(allNbClusters, nbClusters)
|
||||
// Only keep pools that were found in XO and up to date in Netbox
|
||||
pools = Object.keys(clusters)
|
||||
xoPools = Object.keys(nbClusters)
|
||||
|
||||
const clusterFilter = Object.values(clusters)
|
||||
.map(cluster => `cluster_id=${cluster.id}`)
|
||||
const clusterFilter = Object.values(nbClusters)
|
||||
.map(nbCluster => `cluster_id=${nbCluster.id}`)
|
||||
.join('&')
|
||||
|
||||
// VMs ---------------------------------------------------------------------
|
||||
|
||||
log.info('Synchronizing VMs')
|
||||
|
||||
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,
|
||||
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,
|
||||
disk: Math.floor(
|
||||
vm.$VBDs
|
||||
xoVm.$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(vm.memory.dynamic[1] / M),
|
||||
cluster: cluster.id,
|
||||
status: vm.power_state === 'Running' ? 'active' : 'offline',
|
||||
memory: Math.floor(xoVm.memory.dynamic[1] / M),
|
||||
cluster: nbCluster.id,
|
||||
status: xoVm.power_state === 'Running' ? 'active' : 'offline',
|
||||
platform: null,
|
||||
tags: [],
|
||||
}
|
||||
|
||||
const distro = vm.os_version?.distro
|
||||
const distro = xoVm.os_version?.distro
|
||||
if (distro != null) {
|
||||
const slug = slugify(distro)
|
||||
let platform = find(platforms, { slug })
|
||||
if (platform === undefined) {
|
||||
let nbPlatform = find(nbPlatforms, { slug })
|
||||
if (nbPlatform === undefined) {
|
||||
// TODO: Should we also delete/update platforms in Netbox?
|
||||
platform = await this.#request('/dcim/platforms/', 'POST', {
|
||||
nbPlatform = await this.#request('/dcim/platforms/', 'POST', {
|
||||
name: distro,
|
||||
slug,
|
||||
})
|
||||
platforms[platform.id] = platform
|
||||
nbPlatforms[nbPlatform.id] = nbPlatform
|
||||
}
|
||||
|
||||
netboxVm.platform = platform.id
|
||||
nbVm.platform = nbPlatform.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')
|
||||
) {
|
||||
netboxVm.status = vm.power_state === 'Running' ? 1 : 0
|
||||
nbVm.status = xoVm.power_state === 'Running' ? 1 : 0
|
||||
}
|
||||
|
||||
return netboxVm
|
||||
return nbVm
|
||||
}
|
||||
|
||||
// Some props need to be flattened to satisfy the POST request schema
|
||||
const flattenNested = vm => ({
|
||||
...vm,
|
||||
cluster: vm.cluster?.id ?? null,
|
||||
status: vm.status?.value ?? null,
|
||||
platform: vm.platform?.id ?? null,
|
||||
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 platforms = keyBy(await this.#request('/dcim/platforms'), 'id')
|
||||
const nbPlatforms = keyBy(await this.#request('/dcim/platforms/'), 'id')
|
||||
const nbTags = keyBy(await this.#request('/extras/tags/'), '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 allNetboxVmsList = (await this.#request('/virtualization/virtual-machines/')).filter(
|
||||
netboxVm => Object.values(allClusters).find(cluster => cluster.id === netboxVm.cluster.id) !== undefined
|
||||
const allNbVmsList = (await this.#request('/virtualization/virtual-machines/')).filter(
|
||||
nbVm => Object.values(allNbClusters).find(cluster => cluster.id === nbVm.cluster.id) !== undefined
|
||||
)
|
||||
// Then get only the ones from the pools we're synchronizing
|
||||
const netboxVmsList = allNetboxVmsList.filter(
|
||||
netboxVm => Object.values(clusters).find(cluster => cluster.id === netboxVm.cluster.id) !== undefined
|
||||
const nbVmsList = allNbVmsList.filter(
|
||||
nbVm => Object.values(nbClusters).find(cluster => cluster.id === nbVm.cluster.id) !== undefined
|
||||
)
|
||||
// Then make them objects to map the Netbox VMs to their XO VMs
|
||||
// { VM UUID → Netbox VM }
|
||||
const allNetboxVms = keyBy(allNetboxVmsList, 'custom_fields.uuid')
|
||||
const netboxVms = keyBy(netboxVmsList, 'custom_fields.uuid')
|
||||
const allNbVms = keyBy(allNbVmsList, 'custom_fields.uuid')
|
||||
const nbVms = keyBy(nbVmsList, '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 = netboxVmsList
|
||||
.filter(netboxVm => netboxVm.custom_fields.uuid == null)
|
||||
.map(netboxVm => ({ id: netboxVm.id }))
|
||||
const vmsToDelete = nbVmsList.filter(nbVm => nbVm.custom_fields.uuid == null).map(nbVm => ({ id: nbVm.id }))
|
||||
const vmsToUpdate = []
|
||||
const vmsToCreate = []
|
||||
for (const poolId of pools) {
|
||||
for (const xoPoolId of xoPools) {
|
||||
// Get XO VMs that are on this pool
|
||||
const poolVms = this.getObjects({ filter: { type: 'VM', $pool: poolId } })
|
||||
const xoPoolVms = this.getObjects({ filter: { type: 'VM', $pool: xoPoolId } })
|
||||
|
||||
const cluster = clusters[poolId]
|
||||
const nbCluster = nbClusters[xoPoolId]
|
||||
|
||||
// Get Netbox VMs that are supposed to be in this pool
|
||||
const poolNetboxVms = pickBy(netboxVms, netboxVm => netboxVm.cluster.id === cluster.id)
|
||||
const xoPoolNbVms = pickBy(nbVms, nbVm => nbVm.cluster.id === nbCluster.id)
|
||||
|
||||
// For each XO VM of this pool (I)
|
||||
for (const vm of Object.values(poolVms)) {
|
||||
for (const xoVm of Object.values(xoPoolVms)) {
|
||||
// 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 netboxVm = allNetboxVms[vm.uuid]
|
||||
delete poolNetboxVms[vm.uuid]
|
||||
const nbVm = allNbVms[xoVm.uuid]
|
||||
delete xoPoolNbVms[xoVm.uuid]
|
||||
|
||||
const updatedVm = await createNetboxVm(vm, { cluster, platforms })
|
||||
const updatedVm = await createNbVm(xoVm, { nbCluster, nbPlatforms, nbTags })
|
||||
|
||||
if (netboxVm !== undefined) {
|
||||
if (nbVm !== undefined) {
|
||||
// VM found in Netbox: update VM (I.1)
|
||||
const patch = diff(updatedVm, flattenNested(netboxVm))
|
||||
const patch = diff(updatedVm, flattenNested(nbVm))
|
||||
if (patch !== undefined) {
|
||||
vmsToUpdate.push(patch)
|
||||
} else {
|
||||
// The VM is up to date, just store its name as being used
|
||||
usedNames.push(netboxVm.name)
|
||||
usedNames.push(nbVm.name)
|
||||
}
|
||||
} else {
|
||||
// VM not found in Netbox: create VM (I.2)
|
||||
@@ -419,28 +449,28 @@ class Netbox {
|
||||
}
|
||||
|
||||
// For each REMAINING Netbox VM of this pool (II)
|
||||
for (const netboxVm of Object.values(poolNetboxVms)) {
|
||||
const vmUuid = netboxVm.custom_fields?.uuid
|
||||
const vm = this.getObject(vmUuid)
|
||||
for (const nbVm of Object.values(xoPoolNbVms)) {
|
||||
const xoVmUuid = nbVm.custom_fields?.uuid
|
||||
const xoVm = this.getObject(xoVmUuid)
|
||||
// We check if the VM was moved to another pool in XO
|
||||
const pool = this.getObject(vm?.$pool)
|
||||
const cluster = allClusters[pool?.uuid]
|
||||
if (cluster !== undefined) {
|
||||
const xoPool = this.getObject(xoVm?.$pool)
|
||||
const nbCluster = allNbClusters[xoPool?.uuid]
|
||||
if (nbCluster !== undefined) {
|
||||
// If the VM is found in XO: update it if necessary (II.1)
|
||||
const updatedVm = await createNetboxVm(vm, { cluster, platforms })
|
||||
const patch = diff(updatedVm, flattenNested(netboxVm))
|
||||
const updatedVm = await createNbVm(xoVm, { nbCluster, nbPlatforms, nbTags })
|
||||
const patch = diff(updatedVm, flattenNested(nbVm))
|
||||
|
||||
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: netboxVm })
|
||||
log.warn('Found a VM that should be on another cluster', { vm: nbVm })
|
||||
continue
|
||||
}
|
||||
|
||||
vmsToUpdate.push(patch)
|
||||
} else {
|
||||
// Otherwise, delete it from Netbox (II.2)
|
||||
vmsToDelete.push({ id: netboxVm.id })
|
||||
delete netboxVms[vmUuid]
|
||||
vmsToDelete.push({ id: nbVm.id })
|
||||
delete nbVms[xoVmUuid]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -449,12 +479,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 netboxVm of [...vmsToUpdate, ...vmsToCreate]) {
|
||||
if (netboxVm.name === undefined) {
|
||||
for (const nbVm of [...vmsToUpdate, ...vmsToCreate]) {
|
||||
if (nbVm.name === undefined) {
|
||||
continue
|
||||
}
|
||||
netboxVm.name = deduplicateName(netboxVm.name, usedNames)
|
||||
usedNames.push(netboxVm.name)
|
||||
nbVm.name = deduplicateName(nbVm.name, usedNames)
|
||||
usedNames.push(nbVm.name)
|
||||
}
|
||||
|
||||
// Perform calls to Netbox. "Delete → Update → Create" one at a time to
|
||||
@@ -472,63 +502,61 @@ class Netbox {
|
||||
log.info(`Creating ${vmsToCreate.length} VMs`)
|
||||
newVms.push(...(await this.#request('/virtualization/virtual-machines/', 'POST', vmsToCreate)))
|
||||
}
|
||||
Object.assign(netboxVms, keyBy(newVms, 'custom_fields.uuid'))
|
||||
Object.assign(allNetboxVms, netboxVms)
|
||||
Object.assign(nbVms, keyBy(newVms, 'custom_fields.uuid'))
|
||||
Object.assign(allNbVms, nbVms)
|
||||
|
||||
// VIFs --------------------------------------------------------------------
|
||||
|
||||
log.info('Synchronizing VIFs')
|
||||
|
||||
const createIf = (vif, vm) => {
|
||||
const name = `eth${vif.device}`
|
||||
const netboxVm = netboxVms[vm.uuid]
|
||||
const createNbIf = (xoVif, xoVm) => {
|
||||
const name = `eth${xoVif.device}`
|
||||
const nbVm = nbVms[xoVm.uuid]
|
||||
|
||||
const netboxIf = {
|
||||
custom_fields: { uuid: vif.uuid },
|
||||
const nbIf = {
|
||||
custom_fields: { uuid: xoVif.uuid },
|
||||
name,
|
||||
mac_address: vif.MAC.toUpperCase(),
|
||||
mac_address: xoVif.MAC.toUpperCase(),
|
||||
}
|
||||
|
||||
if (netboxVm !== undefined) {
|
||||
netboxIf.virtual_machine = netboxVm.id
|
||||
if (nbVm !== undefined) {
|
||||
nbIf.virtual_machine = nbVm.id
|
||||
}
|
||||
|
||||
return netboxIf
|
||||
return nbIf
|
||||
}
|
||||
|
||||
const netboxIfsList = await this.#request(`/virtualization/interfaces/?${clusterFilter}`)
|
||||
const nbIfsList = await this.#request(`/virtualization/interfaces/?${clusterFilter}`)
|
||||
// { ID → Interface }
|
||||
const netboxIfs = keyBy(netboxIfsList, 'custom_fields.uuid')
|
||||
const nbIfs = keyBy(nbIfsList, 'custom_fields.uuid')
|
||||
|
||||
const ifsToDelete = netboxIfsList
|
||||
.filter(netboxIf => netboxIf.custom_fields.uuid == null)
|
||||
.map(netboxIf => ({ id: netboxIf.id }))
|
||||
const ifsToDelete = nbIfsList.filter(nbIf => nbIf.custom_fields.uuid == null).map(nbIf => ({ id: nbIf.id }))
|
||||
const ifsToUpdate = []
|
||||
const ifsToCreate = []
|
||||
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 })
|
||||
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 })
|
||||
continue
|
||||
}
|
||||
// Start by deleting old interfaces attached to this Netbox VM
|
||||
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]
|
||||
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]
|
||||
}
|
||||
})
|
||||
|
||||
// For each XO VIF, create or update the Netbox interface
|
||||
for (const vifId of vm.VIFs) {
|
||||
const vif = this.getObject(vifId)
|
||||
const netboxIf = netboxIfs[vif.uuid]
|
||||
const updatedIf = createIf(vif, vm)
|
||||
if (netboxIf === undefined) {
|
||||
for (const xoVifId of xoVm.VIFs) {
|
||||
const xoVif = this.getObject(xoVifId)
|
||||
const nbIf = nbIfs[xoVif.uuid]
|
||||
const updatedIf = createNbIf(xoVif, xoVm)
|
||||
if (nbIf === undefined) {
|
||||
ifsToCreate.push(updatedIf)
|
||||
} else {
|
||||
// `virtual_machine` needs to be flattened so we can compare the 2 objects
|
||||
const patch = diff(updatedIf, { ...netboxIf, virtual_machine: netboxIf.virtual_machine.id })
|
||||
const patch = diff(updatedIf, { ...nbIf, virtual_machine: nbIf.virtual_machine.id })
|
||||
if (patch !== undefined) {
|
||||
ifsToUpdate.push(patch)
|
||||
}
|
||||
@@ -550,55 +578,55 @@ class Netbox {
|
||||
log.info(`Creating ${ifsToCreate.length} interfaces`)
|
||||
newIfs.push(...(await this.#request('/virtualization/interfaces/', 'POST', ifsToCreate)))
|
||||
}
|
||||
Object.assign(netboxIfs, keyBy(newIfs, 'custom_fields.uuid'))
|
||||
Object.assign(nbIfs, keyBy(newIfs, 'custom_fields.uuid'))
|
||||
|
||||
// IPs ---------------------------------------------------------------------
|
||||
|
||||
log.info('Synchronizing IP addresses')
|
||||
|
||||
const createIp = (ip, prefix, netboxIf) => {
|
||||
const createNbIp = (ip, prefix, nbIf) => {
|
||||
return {
|
||||
address: `${ip}/${prefix.split('/')[1]}`,
|
||||
assigned_object_type: 'virtualization.vminterface',
|
||||
assigned_object_id: netboxIf,
|
||||
assigned_object_id: nbIf,
|
||||
}
|
||||
}
|
||||
|
||||
// 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 netboxIps = keyBy(
|
||||
const nbIps = keyBy(
|
||||
(await this.#request('/ipam/ip-addresses/')).filter(
|
||||
address => address.assigned_object_type === 'virtualization.vminterface'
|
||||
),
|
||||
'id'
|
||||
)
|
||||
const netboxPrefixes = await this.#request('/ipam/prefixes/')
|
||||
const nbPrefixes = 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 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 })
|
||||
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 })
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the Netbox interface associated with the vif
|
||||
const netboxVmIfs = Object.values(netboxIfs).filter(netboxIf => netboxIf.virtual_machine.id === netboxVm.id)
|
||||
for (const netboxIf of netboxVmIfs) {
|
||||
const nbVmIfs = Object.values(nbIfs).filter(nbIf => nbIf.virtual_machine.id === nbVm.id)
|
||||
for (const nbIf of nbVmIfs) {
|
||||
// Store old IPs and remove them one by one. At the end, delete the remaining ones.
|
||||
const netboxIpsToCheck = pickBy(netboxIps, netboxIp => netboxIp.assigned_object_id === netboxIf.id)
|
||||
const nbIpsToCheck = pickBy(nbIps, nbIp => nbIp.assigned_object_id === nbIf.id)
|
||||
|
||||
const vif = this.getObject(netboxIf.custom_fields.uuid)
|
||||
if (vif === undefined) {
|
||||
const xoVif = this.getObject(nbIf.custom_fields.uuid)
|
||||
if (xoVif === undefined) {
|
||||
// Cannot create IPs if interface was not found
|
||||
log.warn('Could not find VIF', { vm: vm.uuid, vif: netboxIf.custom_fields.uuid })
|
||||
log.warn('Could not find VIF', { vm: xoVm.uuid, vif: nbIf.custom_fields.uuid })
|
||||
continue
|
||||
}
|
||||
const ips = Object.values(pickBy(vm.addresses, (_, key) => key.startsWith(vif.device + '/')))
|
||||
const ips = Object.values(pickBy(xoVm.addresses, (_, key) => key.startsWith(xoVif.device + '/')))
|
||||
for (const ip of ips) {
|
||||
const parsedIp = ipaddr.parse(ip)
|
||||
const ipKind = parsedIp.kind()
|
||||
@@ -607,7 +635,7 @@ class Netbox {
|
||||
// Users must create prefixes themselves
|
||||
let smallestPrefix
|
||||
let highestBits = 0
|
||||
netboxPrefixes.forEach(({ prefix }) => {
|
||||
nbPrefixes.forEach(({ prefix }) => {
|
||||
const [range, bits] = prefix.split('/')
|
||||
const parsedRange = ipaddr.parse(range)
|
||||
if (parsedRange.kind() === ipKind && parsedIp.match(parsedRange, bits) && bits > highestBits) {
|
||||
@@ -618,26 +646,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: vm.uuid, ip })
|
||||
ignoredIps.push({ vm: xoVm.uuid, ip })
|
||||
continue
|
||||
}
|
||||
|
||||
const compactIp = parsedIp.toString() // use compact notation (e.g. ::1) before ===-comparison
|
||||
const netboxIp = find(netboxIpsToCheck, netboxIp => {
|
||||
const [ip, bits] = netboxIp.address.split('/')
|
||||
const nbIp = find(nbIpsToCheck, nbIp => {
|
||||
const [ip, bits] = nbIp.address.split('/')
|
||||
return ipaddr.parse(ip).toString() === compactIp && bits === highestBits
|
||||
})
|
||||
if (netboxIp !== undefined) {
|
||||
if (nbIp !== undefined) {
|
||||
// IP is up to date, don't do anything with it
|
||||
delete netboxIpsToCheck[netboxIp.id]
|
||||
delete nbIpsToCheck[nbIp.id]
|
||||
} else {
|
||||
// IP wasn't found in Netbox, create it
|
||||
ipsToCreate.push(createIp(ip, smallestPrefix, netboxIf.id))
|
||||
ipsToCreate.push(createNbIp(ip, smallestPrefix, nbIf.id))
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the remaining IPs found in Netbox for this VM
|
||||
ipsToDelete.push(...Object.values(netboxIpsToCheck).map(netboxIp => ({ id: netboxIp.id })))
|
||||
ipsToDelete.push(...Object.values(nbIpsToCheck).map(nbIp => ({ id: nbIp.id })))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -654,7 +682,7 @@ class Netbox {
|
||||
}
|
||||
if (ipsToCreate.length > 0) {
|
||||
log.info(`Creating ${ipsToCreate.length} IPs`)
|
||||
Object.assign(netboxIps, keyBy(await this.#request('/ipam/ip-addresses/', 'POST', ipsToCreate), 'id'))
|
||||
Object.assign(nbIps, keyBy(await this.#request('/ipam/ip-addresses/', 'POST', ipsToCreate), 'id'))
|
||||
}
|
||||
|
||||
// Primary IPs -------------------------------------------------------------
|
||||
@@ -665,41 +693,39 @@ class Netbox {
|
||||
log.info("Setting VMs' primary IPs")
|
||||
|
||||
const vmsToUpdate2 = []
|
||||
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 })
|
||||
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 })
|
||||
continue
|
||||
}
|
||||
const patch = { id: netboxVm.id }
|
||||
const patch = { id: nbVm.id }
|
||||
|
||||
const netboxVmIps = Object.values(netboxIps).filter(
|
||||
netboxIp => netboxIp.assigned_object?.virtual_machine.id === netboxVm.id
|
||||
)
|
||||
const nbVmIps = Object.values(nbIps).filter(nbIp => nbIp.assigned_object?.virtual_machine.id === nbVm.id)
|
||||
|
||||
const ipv4 = vm.addresses['0/ipv4/0']
|
||||
if (ipv4 === undefined && netboxVm.primary_ip4 !== null) {
|
||||
const ipv4 = xoVm.addresses['0/ipv4/0']
|
||||
if (ipv4 === undefined && nbVm.primary_ip4 !== null) {
|
||||
patch.primary_ip4 = null
|
||||
} else if (ipv4 !== undefined) {
|
||||
const netboxIp = netboxVmIps.find(netboxIp => netboxIp.address.split('/')[0] === ipv4)
|
||||
if (netboxIp === undefined && netboxVm.primary_ip4 !== null) {
|
||||
const nbIp = nbVmIps.find(nbIp => nbIp.address.split('/')[0] === ipv4)
|
||||
if (nbIp === undefined && nbVm.primary_ip4 !== null) {
|
||||
patch.primary_ip4 = null
|
||||
} else if (netboxIp !== undefined && netboxIp.id !== netboxVm.primary_ip4?.id) {
|
||||
patch.primary_ip4 = netboxIp.id
|
||||
} else if (nbIp !== undefined && nbIp.id !== nbVm.primary_ip4?.id) {
|
||||
patch.primary_ip4 = nbIp.id
|
||||
}
|
||||
}
|
||||
|
||||
const _ipv6 = vm.addresses['0/ipv6/0']
|
||||
const _ipv6 = xoVm.addresses['0/ipv6/0']
|
||||
// For IPv6, compare with the compact notation
|
||||
const ipv6 = _ipv6 && ipaddr.parse(_ipv6).toString()
|
||||
if (ipv6 === undefined && netboxVm.primary_ip6 !== null) {
|
||||
if (ipv6 === undefined && nbVm.primary_ip6 !== null) {
|
||||
patch.primary_ip6 = null
|
||||
} else if (ipv6 !== undefined) {
|
||||
const netboxIp = netboxVmIps.find(netboxIp => netboxIp.address.split('/')[0] === ipv6)
|
||||
if (netboxIp === undefined && netboxVm.primary_ip6 !== null) {
|
||||
const nbIp = nbVmIps.find(nbIp => nbIp.address.split('/')[0] === ipv6)
|
||||
if (nbIp === undefined && nbVm.primary_ip6 !== null) {
|
||||
patch.primary_ip6 = null
|
||||
} else if (netboxIp !== undefined && netboxIp.id !== netboxVm.primary_ip6?.id) {
|
||||
patch.primary_ip6 = netboxIp.id
|
||||
} else if (nbIp !== undefined && nbIp.id !== nbVm.primary_ip6?.id) {
|
||||
patch.primary_ip6 = nbIp.id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,9 +736,9 @@ class Netbox {
|
||||
|
||||
if (vmsToUpdate2.length > 0) {
|
||||
log.info(`Updating primary IPs of ${vmsToUpdate2.length} VMs`)
|
||||
Object.assign(netboxVms, keyBy(await this.#request('/virtualization/virtual-machines/', 'PATCH', vmsToUpdate2)))
|
||||
Object.assign(nbVms, keyBy(await this.#request('/virtualization/virtual-machines/', 'PATCH', vmsToUpdate2)))
|
||||
}
|
||||
|
||||
log.info(`Done synchronizing ${pools.length} pools with Netbox`, { pools })
|
||||
log.info(`Done synchronizing ${xoPools.length} pools with Netbox`, { pools: xoPools })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ set.params = {
|
||||
},
|
||||
optional: true,
|
||||
},
|
||||
userId: { type: 'string', optional: true },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export async function set({
|
||||
backupNetwork,
|
||||
migrationNetwork,
|
||||
suspendSr,
|
||||
crashDumpSr,
|
||||
}) {
|
||||
pool = this.getXapiObject(pool)
|
||||
|
||||
@@ -29,6 +30,8 @@ export async function set({
|
||||
migrationNetwork !== undefined && pool.update_other_config('xo:migrationNetwork', migrationNetwork),
|
||||
backupNetwork !== undefined && pool.update_other_config('xo:backupNetwork', backupNetwork),
|
||||
suspendSr !== undefined && pool.$call('set_suspend_image_SR', suspendSr === null ? Ref.EMPTY : suspendSr._xapiRef),
|
||||
crashDumpSr !== undefined &&
|
||||
pool.$call('set_crash_dump_SR', crashDumpSr === null ? Ref.EMPTY : crashDumpSr._xapiRef),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -57,11 +60,16 @@ set.params = {
|
||||
type: ['string', 'null'],
|
||||
optional: true,
|
||||
},
|
||||
crashDumpSr: {
|
||||
type: ['string', 'null'],
|
||||
optional: true,
|
||||
},
|
||||
}
|
||||
|
||||
set.resolve = {
|
||||
pool: ['id', 'pool', 'administrate'],
|
||||
suspendSr: ['suspendSr', 'SR', 'administrate'],
|
||||
crashDumpSr: ['crashDumpSr', 'SR', 'administrate'],
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user