Compare commits
74 Commits
feat_dedup
...
feat_nbd_s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e1227c710 | ||
|
|
ddc73fb836 | ||
|
|
a13fda5fe9 | ||
|
|
66bee59774 | ||
|
|
685400bbf8 | ||
|
|
5bef8fc411 | ||
|
|
aa7ff1449a | ||
|
|
3dca7f2a71 | ||
|
|
3dc2f649f6 | ||
|
|
9eb537c2f9 | ||
|
|
dfd5f6882f | ||
|
|
7214016338 | ||
|
|
606e3c4ce5 | ||
|
|
fb04d3d25d | ||
|
|
db8c042131 | ||
|
|
fd9005fba8 | ||
|
|
2d25413b8d | ||
|
|
035679800a | ||
|
|
abd0a3035a | ||
|
|
d307730c68 | ||
|
|
1b44de4958 | ||
|
|
ec78a1ce8b | ||
|
|
19c82ab30d | ||
|
|
9986f3fb18 | ||
|
|
d24e9c093d | ||
|
|
70c8b24fac | ||
|
|
9c9c11104b | ||
|
|
cba90b27f4 | ||
|
|
46cbced570 | ||
|
|
52cf2d1514 | ||
|
|
e51351be8d | ||
|
|
2a42e0ff94 | ||
|
|
3a824a2bfc | ||
|
|
fc1c809a18 | ||
|
|
221cd40199 | ||
|
|
aca19d9a81 | ||
|
|
0601bbe18d | ||
|
|
2d52aee952 | ||
|
|
99605bf185 | ||
|
|
91b19d9bc4 | ||
|
|
562401ebe4 | ||
|
|
6fd2f2610d | ||
|
|
6ae19b0640 | ||
|
|
6b936d8a8c | ||
|
|
8f2cfaae00 | ||
|
|
5c215e1a8a | ||
|
|
e3cb98124f | ||
|
|
90c3319880 | ||
|
|
348db876d2 | ||
|
|
408fd7ec03 | ||
|
|
1fd84836b1 | ||
|
|
522204795f | ||
|
|
e29c422ac9 | ||
|
|
152cf09b7e | ||
|
|
ff728099dc | ||
|
|
706d94221d | ||
|
|
340e9af7f4 | ||
|
|
40e536ba61 | ||
|
|
fd4c56c8c2 | ||
|
|
20d04ba956 | ||
|
|
3b1bcc67ae | ||
|
|
1add3fbf9d | ||
|
|
97f0759de0 | ||
|
|
005ab47d9b | ||
|
|
14a0caa4c6 | ||
|
|
1c23bd5ff7 | ||
|
|
49c161b17a | ||
|
|
18dce3fce6 | ||
|
|
d6fc86b6bc | ||
|
|
61d960d4b1 | ||
|
|
02d3465832 | ||
|
|
4bbadc9515 | ||
|
|
78586291ca | ||
|
|
945dec94bf |
@@ -1,8 +1,11 @@
|
||||
'use strict'
|
||||
|
||||
module.exports = {
|
||||
arrowParens: 'avoid',
|
||||
jsxSingleQuote: true,
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
trailingComma: 'es5',
|
||||
|
||||
// 2020-11-24: Requested by nraynaud and approved by the rest of the team
|
||||
//
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vates/fuse-vhd",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"license": "ISC",
|
||||
"private": false,
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/fuse-vhd",
|
||||
|
||||
32
@vates/nbd-client/bench.mjs
Normal file
32
@vates/nbd-client/bench.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
import NbdClient from "./client.mjs";
|
||||
|
||||
|
||||
|
||||
async function bench(){
|
||||
const client = new NbdClient({
|
||||
address:'localhost',
|
||||
port: 9000,
|
||||
exportname: 'bench_export'
|
||||
})
|
||||
await client.connect()
|
||||
console.log('connected', client.exportSize)
|
||||
|
||||
for(let chunk_size=16*1024; chunk_size < 16*1024*1024; chunk_size *=2){
|
||||
|
||||
|
||||
let i=0
|
||||
const start = + new Date()
|
||||
for await(const block of client.readBlocks(chunk_size) ){
|
||||
i++
|
||||
if((i*chunk_size) % (16*1024*1024) ===0){
|
||||
process.stdout.write('.')
|
||||
}
|
||||
if(i*chunk_size > 1024*1024*1024) break
|
||||
}
|
||||
console.log(chunk_size,Math.round( (i*chunk_size/1024/1024*1000)/ (new Date() - start)))
|
||||
|
||||
}
|
||||
await client.disconnect()
|
||||
}
|
||||
|
||||
bench()
|
||||
@@ -74,7 +74,7 @@ export default class NbdClient {
|
||||
this.#serverSocket = connect({
|
||||
socket: this.#serverSocket,
|
||||
rejectUnauthorized: false,
|
||||
cert: this.#serverCert,
|
||||
cert: this.#serverCert
|
||||
})
|
||||
this.#serverSocket.once('error', reject)
|
||||
this.#serverSocket.once('secureConnect', () => {
|
||||
@@ -88,7 +88,11 @@ export default class NbdClient {
|
||||
async #unsecureConnect() {
|
||||
this.#serverSocket = new Socket()
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#serverSocket.connect(this.#serverPort, this.#serverAddress)
|
||||
this.#serverSocket.connect({
|
||||
port:this.#serverPort,
|
||||
host: this.#serverAddress,
|
||||
// @todo should test the onRead to limit buffer copy
|
||||
})
|
||||
this.#serverSocket.once('error', reject)
|
||||
this.#serverSocket.once('connect', () => {
|
||||
this.#serverSocket.removeListener('error', reject)
|
||||
@@ -232,19 +236,20 @@ export default class NbdClient {
|
||||
}
|
||||
try {
|
||||
this.#waitingForResponse = true
|
||||
const magic = await this.#readInt32()
|
||||
const buffer = await this.#read(4+4+8)
|
||||
const magic = buffer.readUInt32BE()
|
||||
|
||||
if (magic !== NBD_REPLY_MAGIC) {
|
||||
throw new Error(`magic number for block answer is wrong : ${magic} ${NBD_REPLY_MAGIC}`)
|
||||
}
|
||||
|
||||
const error = await this.#readInt32()
|
||||
const error = buffer.readUInt32BE(4)
|
||||
if (error !== 0) {
|
||||
// @todo use error code from constants.mjs
|
||||
throw new Error(`GOT ERROR CODE : ${error}`)
|
||||
}
|
||||
|
||||
const blockQueryId = await this.#readInt64()
|
||||
const blockQueryId = buffer.readBigUInt64BE(8)
|
||||
const query = this.#commandQueryBacklog.get(blockQueryId)
|
||||
if (!query) {
|
||||
throw new Error(` no query associated with id ${blockQueryId}`)
|
||||
@@ -307,11 +312,11 @@ export default class NbdClient {
|
||||
})
|
||||
}
|
||||
|
||||
async *readBlocks(indexGenerator) {
|
||||
async *readBlocks(indexGenerator = 2*1024*1024) {
|
||||
// default : read all blocks
|
||||
if (indexGenerator === undefined) {
|
||||
if (typeof indexGenerator === 'number') {
|
||||
const exportSize = this.#exportSize
|
||||
const chunkSize = 2 * 1024 * 1024
|
||||
const chunkSize = indexGenerator
|
||||
indexGenerator = function* () {
|
||||
const nbBlocks = Math.ceil(Number(exportSize / BigInt(chunkSize)))
|
||||
for (let index = 0; BigInt(index) < nbBlocks; index++) {
|
||||
@@ -319,12 +324,14 @@ export default class NbdClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const readAhead = []
|
||||
const readAheadMaxLength = this.#readAhead
|
||||
const makeReadBlockPromise = (index, size) => {
|
||||
const promise = pRetry(() => this.readBlock(index, size), {
|
||||
tries: this.#readBlockRetries,
|
||||
onRetry: async err => {
|
||||
console.error(err)
|
||||
warn('will retry reading block ', index, err)
|
||||
await this.reconnect()
|
||||
},
|
||||
@@ -336,6 +343,7 @@ export default class NbdClient {
|
||||
|
||||
// read all blocks, but try to keep readAheadMaxLength promise waiting ahead
|
||||
for (const { index, size } of indexGenerator()) {
|
||||
|
||||
// stack readAheadMaxLength promises before starting to handle the results
|
||||
if (readAhead.length === readAheadMaxLength) {
|
||||
// any error will stop reading blocks
|
||||
@@ -348,4 +356,4 @@ export default class NbdClient {
|
||||
yield readAhead.shift()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,40 @@
|
||||
// https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md
|
||||
|
||||
export const INIT_PASSWD = Buffer.from('NBDMAGIC') // "NBDMAGIC" ensure we're connected to a nbd server
|
||||
export const OPTS_MAGIC = Buffer.from('IHAVEOPT') // "IHAVEOPT" start an option block
|
||||
export const NBD_OPT_REPLY_MAGIC = 1100100111001001n // magic received during negociation
|
||||
|
||||
export const NBD_OPT_EXPORT_NAME = 1
|
||||
export const NBD_OPT_ABORT = 2
|
||||
export const NBD_OPT_LIST = 3
|
||||
export const NBD_OPT_STARTTLS = 5
|
||||
export const NBD_OPT_INFO = 6
|
||||
export const NBD_OPT_GO = 7
|
||||
export const NBD_OPT_STRUCTURED_REPLY = 8
|
||||
export const NBD_OPT_LIST_META_CONTEXT = 9
|
||||
export const NBD_OPT_SET_META_CONTEXT = 10
|
||||
export const NBD_OPT_EXTENDED_HEADERS = 11
|
||||
|
||||
export const NBD_REP_ACK =1
|
||||
export const NBD_REP_SERVER = 2
|
||||
export const NBD_REP_INFO = 3
|
||||
export const NBD_REP_META_CONTEXT = 4
|
||||
export const NBD_REP_ERR_UNSUP = 0x80000001 // 2^32+1
|
||||
export const NBD_REP_ERR_POLICY = 0x80000002
|
||||
export const NBD_REP_ERR_INVALID = 0x80000003
|
||||
export const NBD_REP_ERR_PLATFORM = 0x80000004
|
||||
export const NBD_REP_ERR_TLS_REQD = 0x80000005
|
||||
export const NBD_REP_ERR_UNKNOWN = 0x80000006
|
||||
export const NBD_REP_ERR_SHUTDOWN = 0x80000007
|
||||
export const NBD_REP_ERR_BLOCK_SIZE_REQD = 0x80000008
|
||||
export const NBD_REP_ERR_TOO_BIG = 0x80000009
|
||||
export const NBD_REP_ERR_EXT_HEADER_REQD = 0x8000000a
|
||||
|
||||
export const NBD_INFO_EXPORT = 0
|
||||
export const NBD_INFO_NAME = 1
|
||||
export const NBD_INFO_DESCRIPTION = 2
|
||||
export const NBD_INFO_BLOCK_SIZE = 3
|
||||
|
||||
|
||||
export const NBD_FLAG_HAS_FLAGS = 1 << 0
|
||||
export const NBD_FLAG_READ_ONLY = 1 << 1
|
||||
@@ -14,6 +42,9 @@ export const NBD_FLAG_SEND_FLUSH = 1 << 2
|
||||
export const NBD_FLAG_SEND_FUA = 1 << 3
|
||||
export const NBD_FLAG_ROTATIONAL = 1 << 4
|
||||
export const NBD_FLAG_SEND_TRIM = 1 << 5
|
||||
export const NBD_FLAG_SEND_WRITE_ZEROES = 1 << 6
|
||||
export const NBD_FLAG_SEND_DF = 1 << 7
|
||||
export const NBD_FLAG_CAN_MULTI_CONN = 1 << 8
|
||||
|
||||
export const NBD_FLAG_FIXED_NEWSTYLE = 1 << 0
|
||||
|
||||
@@ -36,6 +67,15 @@ export const NBD_CMD_RESIZE = 8
|
||||
export const NBD_REQUEST_MAGIC = 0x25609513 // magic number to create a new NBD request to send to the server
|
||||
export const NBD_REPLY_MAGIC = 0x67446698 // magic number received from the server when reading response to a nbd request
|
||||
export const NBD_REPLY_ACK = 1
|
||||
export const NBD_SIMPLE_REPLY_MAGIC = 0x67446698
|
||||
export const NBD_STRUCTURED_REPLY_MAGIC = 0x668e33ef
|
||||
export const NBD_REPLY_TYPE_NONE = 0
|
||||
export const NBD_REPLY_TYPE_OFFSET_DATA = 1
|
||||
export const NBD_REPLY_TYPE_OFFSET_HOLE = 2
|
||||
export const NBD_REPLY_TYPE_BLOCK_STATUS = 5
|
||||
export const NBD_REPLY_TYPE_ERROR = 1 << 15 +1
|
||||
export const NBD_REPLY_TYPE_ERROR_OFFSET = 1 << 15 +2
|
||||
|
||||
|
||||
export const NBD_DEFAULT_PORT = 10809
|
||||
export const NBD_DEFAULT_BLOCK_SIZE = 64 * 1024
|
||||
|
||||
@@ -13,18 +13,18 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "1.2.1",
|
||||
"version": "2.0.0",
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
},
|
||||
"main": "./index.mjs",
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.0",
|
||||
"@vates/read-chunk": "^1.1.1",
|
||||
"@vates/read-chunk": "^1.2.0",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"xen-api": "^1.3.3"
|
||||
"xen-api": "^1.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tap": "^16.3.0",
|
||||
|
||||
292
@vates/nbd-client/server.mjs
Normal file
292
@vates/nbd-client/server.mjs
Normal file
@@ -0,0 +1,292 @@
|
||||
import assert, { deepEqual, strictEqual, notStrictEqual } from 'node:assert'
|
||||
import { createServer } from 'node:net'
|
||||
import { fromCallback } from 'promise-toolbox'
|
||||
import { readChunkStrict } from '@vates/read-chunk'
|
||||
import {
|
||||
INIT_PASSWD,
|
||||
NBD_CMD_READ,
|
||||
NBD_DEFAULT_PORT,
|
||||
NBD_FLAG_FIXED_NEWSTYLE,
|
||||
NBD_FLAG_HAS_FLAGS,
|
||||
NBD_OPT_EXPORT_NAME,
|
||||
NBD_OPT_REPLY_MAGIC,
|
||||
NBD_REPLY_ACK,
|
||||
NBD_REQUEST_MAGIC,
|
||||
OPTS_MAGIC,
|
||||
NBD_CMD_DISC,
|
||||
NBD_REP_ERR_UNSUP,
|
||||
NBD_CMD_WRITE,
|
||||
NBD_OPT_GO,
|
||||
NBD_OPT_INFO,
|
||||
NBD_INFO_EXPORT,
|
||||
NBD_REP_INFO,
|
||||
NBD_SIMPLE_REPLY_MAGIC,
|
||||
NBD_REP_ERR_UNKNOWN,
|
||||
} from './constants.mjs'
|
||||
import { PassThrough } from 'node:stream'
|
||||
|
||||
export default class NbdServer {
|
||||
#server
|
||||
#clients = new Map()
|
||||
constructor(port = NBD_DEFAULT_PORT) {
|
||||
this.#server = createServer()
|
||||
this.#server.listen(port)
|
||||
this.#server.on('connection', client => this.#handleNewConnection(client))
|
||||
}
|
||||
|
||||
// will wait for a client to connect and upload the file to this server
|
||||
downloadStream(key) {
|
||||
strictEqual(this.#clients.has(key), false)
|
||||
const stream = new PassThrough()
|
||||
const offset = BigInt(0)
|
||||
this.#clients.set(key, { length: BigInt(2 * 1024 * 1024 * 1024 * 1024), stream, offset, key })
|
||||
return stream
|
||||
}
|
||||
|
||||
// will wait for a client to connect and downlaod this stream
|
||||
uploadStream(key, source, length) {
|
||||
strictEqual(this.#clients.has(key), false)
|
||||
notStrictEqual(length, undefined)
|
||||
const offset = BigInt(0)
|
||||
this.#clients.set(key, { length: BigInt(length), stream: source, offset, key })
|
||||
}
|
||||
|
||||
#read(socket, length) {
|
||||
return readChunkStrict(socket, length)
|
||||
}
|
||||
async #readInt32(socket) {
|
||||
const buffer = await this.#read(socket, 4)
|
||||
return buffer.readUInt32BE()
|
||||
}
|
||||
|
||||
#write(socket, buffer) {
|
||||
return fromCallback.call(socket, 'write', buffer)
|
||||
}
|
||||
async #writeInt16(socket, int16) {
|
||||
const buffer = Buffer.alloc(2)
|
||||
buffer.writeUInt16BE(int16)
|
||||
return this.#write(socket, buffer)
|
||||
}
|
||||
async #writeInt32(socket, int32) {
|
||||
const buffer = Buffer.alloc(4)
|
||||
buffer.writeUInt32BE(int32)
|
||||
return this.#write(socket, buffer)
|
||||
}
|
||||
async #writeInt64(socket, int64) {
|
||||
const buffer = Buffer.alloc(8)
|
||||
buffer.writeBigUInt64BE(int64)
|
||||
return this.#write(socket, buffer)
|
||||
}
|
||||
|
||||
async #openExport(key) {
|
||||
if (!this.#clients.has(key)) {
|
||||
// export does not exists
|
||||
const err = new Error('Export not found ')
|
||||
err.code = 'ENOTFOUND'
|
||||
throw err
|
||||
}
|
||||
const { length } = this.#clients.get(key)
|
||||
return length
|
||||
}
|
||||
|
||||
async #sendOptionResponse(socket, option, response, data = Buffer.alloc(0)) {
|
||||
await this.#writeInt64(socket, NBD_OPT_REPLY_MAGIC)
|
||||
await this.#writeInt32(socket, option)
|
||||
await this.#writeInt32(socket, response)
|
||||
await this.#writeInt32(socket, data.length)
|
||||
await this.#write(socket, data)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Socket} socket
|
||||
* @returns true if server is waiting for more options
|
||||
*/
|
||||
async #readOption(socket) {
|
||||
console.log('wait for option')
|
||||
const magic = await this.#read(socket, 8)
|
||||
console.log(magic.toString('ascii'), magic.length, OPTS_MAGIC.toString('ascii'))
|
||||
deepEqual(magic, OPTS_MAGIC)
|
||||
const option = await this.#readInt32(socket)
|
||||
const length = await this.#readInt32(socket)
|
||||
console.log({ option, length })
|
||||
const data = length > 0 ? await this.#read(socket, length) : undefined
|
||||
switch (option) {
|
||||
case NBD_OPT_EXPORT_NAME: {
|
||||
const exportNameLength = data.readInt32BE()
|
||||
const key = data.slice(4, exportNameLength + 4).toString()
|
||||
let exportSize
|
||||
try {
|
||||
exportSize = await this.#openExport(key)
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOTFOUND') {
|
||||
this.#sendOptionResponse(socket, option, NBD_REP_ERR_UNKNOWN)
|
||||
return false
|
||||
}
|
||||
throw err
|
||||
}
|
||||
socket.key = key
|
||||
await this.#writeInt64(socket, exportSize)
|
||||
await this.#writeInt16(socket, NBD_FLAG_HAS_FLAGS /* transmission flag */)
|
||||
await this.#write(socket, Buffer.alloc(124) /* padding */)
|
||||
|
||||
return false
|
||||
}
|
||||
/*
|
||||
case NBD_OPT_STARTTLS:
|
||||
console.log('starttls')
|
||||
// @todo not working
|
||||
return true
|
||||
*/
|
||||
|
||||
case NBD_OPT_GO:
|
||||
case NBD_OPT_INFO: {
|
||||
const exportNameLength = data.readInt32BE()
|
||||
const key = data.slice(4, exportNameLength + 4).toString()
|
||||
let exportSize
|
||||
try {
|
||||
exportSize = await this.#openExport(key)
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOTFOUND') {
|
||||
this.#sendOptionResponse(socket, option, NBD_REP_ERR_UNKNOWN)
|
||||
// @todo should disconnect
|
||||
return false
|
||||
}
|
||||
throw err
|
||||
}
|
||||
socket.key = key
|
||||
await this.#writeInt64(socket, NBD_OPT_REPLY_MAGIC)
|
||||
await this.#writeInt32(socket, option)
|
||||
await this.#writeInt32(socket, NBD_REP_INFO)
|
||||
await this.#writeInt32(socket, 12)
|
||||
// the export info
|
||||
await this.#writeInt16(socket, NBD_INFO_EXPORT)
|
||||
await this.#writeInt64(socket, exportSize)
|
||||
await this.#writeInt16(socket, NBD_FLAG_HAS_FLAGS /* transmission flag */)
|
||||
|
||||
// an ACK at the end of the infos
|
||||
await this.#sendOptionResponse(socket, option, NBD_REPLY_ACK) // no additionnal data
|
||||
return option === NBD_OPT_INFO // we stays in option phase is option is INFO
|
||||
}
|
||||
default:
|
||||
// not supported
|
||||
console.log('not supported', option, length, data?.toString())
|
||||
await this.#sendOptionResponse(socket, option, NBD_REP_ERR_UNSUP) // no additionnal data
|
||||
// wait for next option
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
async #readCommand(socket) {
|
||||
const key = socket.key
|
||||
// this socket has an export key
|
||||
notStrictEqual(key, undefined)
|
||||
// this export key is still valid
|
||||
strictEqual(this.#clients.has(key), true)
|
||||
const client = this.#clients.get(key)
|
||||
|
||||
const buffer = await this.#read(socket, 28)
|
||||
const magic = buffer.readInt32BE(0)
|
||||
strictEqual(magic, NBD_REQUEST_MAGIC)
|
||||
/* const commandFlags = */ buffer.readInt16BE(4)
|
||||
const command = buffer.readInt16BE(6)
|
||||
const cookie = buffer.readBigUInt64BE(8)
|
||||
const offset = buffer.readBigUInt64BE(16)
|
||||
const length = buffer.readInt32BE(24)
|
||||
switch (command) {
|
||||
case NBD_CMD_DISC:
|
||||
console.log('gotdisconnect', client.offset)
|
||||
await client.stream?.destroy()
|
||||
// @todo : disconnect
|
||||
return false
|
||||
case NBD_CMD_READ: {
|
||||
/** simple replies */
|
||||
|
||||
// read length byte from offset in export
|
||||
|
||||
// the client is writing in contiguous mode
|
||||
assert.strictEqual(offset, client.offset)
|
||||
client.offset += BigInt(length)
|
||||
const data = await readChunkStrict(client.stream, length)
|
||||
const reply = Buffer.alloc(16)
|
||||
reply.writeInt32BE(NBD_SIMPLE_REPLY_MAGIC)
|
||||
reply.writeInt32BE(0, 4) // no error
|
||||
reply.writeBigInt64BE(cookie, 8)
|
||||
await this.#write(socket, reply)
|
||||
await this.#write(socket, data)
|
||||
/* if we implement non stream read, we can handle read in parallel
|
||||
const reply = Buffer.alloc(16+length)
|
||||
reply.writeInt32BE(NBD_SIMPLE_REPLY_MAGIC)
|
||||
reply.writeInt32BE(0,4)// no error
|
||||
reply.writeBigInt64BE(cookie,8)
|
||||
|
||||
// read length byte from offset in export directly in the given buffer
|
||||
// may do multiple read in parallel on the same export
|
||||
size += length
|
||||
socket.fd.read(reply, 16, length, Number(offset))
|
||||
.then(()=>{
|
||||
return this.#write(socket, reply)
|
||||
})
|
||||
.catch(err => console.error('NBD_CMD_READ',err)) */
|
||||
return true
|
||||
}
|
||||
case NBD_CMD_WRITE: {
|
||||
// the client is writing in contiguous mode
|
||||
assert.strictEqual(offset, client.offset)
|
||||
|
||||
const data = await this.#read(socket, length)
|
||||
client.offset += BigInt(length)
|
||||
await new Promise((resolve, reject) => {
|
||||
if (!client.stream.write(data, 0, length, Number(offset))) {
|
||||
client.stream.once('drain', err => (err ? reject(err) : resolve()))
|
||||
} else {
|
||||
process.nextTick(resolve)
|
||||
}
|
||||
})
|
||||
const reply = Buffer.alloc(16)
|
||||
reply.writeInt32BE(NBD_SIMPLE_REPLY_MAGIC)
|
||||
reply.writeInt32BE(0, 4) // no error
|
||||
reply.writeBigInt64BE(cookie, 8)
|
||||
await this.#write(socket, reply)
|
||||
return true
|
||||
}
|
||||
default:
|
||||
console.log('GOT unsupported command ', command)
|
||||
// fail to handle
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
async #handleNewConnection(socket) {
|
||||
const remoteAddress = socket.remoteAddress + ':' + socket.remotePort
|
||||
console.log('new client connection from %s', remoteAddress)
|
||||
|
||||
socket.on('close', () => {
|
||||
console.log('client ', remoteAddress, 'is done')
|
||||
})
|
||||
socket.on('error', error => {
|
||||
throw error
|
||||
})
|
||||
// handshake
|
||||
await this.#write(socket, INIT_PASSWD)
|
||||
await this.#write(socket, OPTS_MAGIC)
|
||||
|
||||
// send flags , the bare minimum
|
||||
await this.#writeInt16(socket, NBD_FLAG_FIXED_NEWSTYLE)
|
||||
const clientFlag = await this.#readInt32(socket)
|
||||
assert.strictEqual(clientFlag & NBD_FLAG_FIXED_NEWSTYLE, NBD_FLAG_FIXED_NEWSTYLE) // only FIXED_NEWSTYLE one is supported from the server options
|
||||
|
||||
// read client response flags
|
||||
let waitingForOptions = true
|
||||
while (waitingForOptions) {
|
||||
waitingForOptions = await this.#readOption(socket)
|
||||
}
|
||||
|
||||
let waitingForCommand = true
|
||||
while (waitingForCommand) {
|
||||
waitingForCommand = await this.#readCommand(socket)
|
||||
}
|
||||
}
|
||||
|
||||
#handleClientData(client, data) {}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import NbdClient from '../index.mjs'
|
||||
import NbdClient from '../client.mjs'
|
||||
import { spawn, exec } from 'node:child_process'
|
||||
import fs from 'node:fs/promises'
|
||||
import { test } from 'tap'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vates/node-vsphere-soap",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"description": "interface to vSphere SOAP/WSDL from node for interfacing with vCenter or ESXi, forked from node-vsphere-soap",
|
||||
"main": "lib/client.mjs",
|
||||
"author": "reedog117",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('assert')
|
||||
const isUtf8 = require('isutf8')
|
||||
|
||||
/**
|
||||
* Read a chunk of data from a stream.
|
||||
@@ -81,6 +82,13 @@ exports.readChunkStrict = async function readChunkStrict(stream, size) {
|
||||
|
||||
if (size !== undefined && chunk.length !== size) {
|
||||
const error = new Error(`stream has ended with not enough data (actual: ${chunk.length}, expected: ${size})`)
|
||||
|
||||
// Buffer.isUtf8 is too recent for now
|
||||
// @todo : replace external package by Buffer.isUtf8 when the supported version of node reach 18
|
||||
|
||||
if (chunk.length < 1024 && isUtf8(chunk)) {
|
||||
error.text = chunk.toString('utf8')
|
||||
}
|
||||
Object.defineProperties(error, {
|
||||
chunk: {
|
||||
value: chunk,
|
||||
|
||||
@@ -102,12 +102,37 @@ describe('readChunkStrict', function () {
|
||||
assert.strictEqual(error.chunk, undefined)
|
||||
})
|
||||
|
||||
it('throws if stream ends with not enough data', async () => {
|
||||
it('throws if stream ends with not enough data, utf8', async () => {
|
||||
const error = await rejectionOf(readChunkStrict(makeStream(['foo', 'bar']), 10))
|
||||
assert(error instanceof Error)
|
||||
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 6, expected: 10)')
|
||||
assert.strictEqual(error.text, 'foobar')
|
||||
assert.deepEqual(error.chunk, Buffer.from('foobar'))
|
||||
})
|
||||
|
||||
it('throws if stream ends with not enough data, non utf8 ', async () => {
|
||||
const source = [Buffer.alloc(10, 128), Buffer.alloc(10, 128)]
|
||||
const error = await rejectionOf(readChunkStrict(makeStream(source), 30))
|
||||
assert(error instanceof Error)
|
||||
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 20, expected: 30)')
|
||||
assert.strictEqual(error.text, undefined)
|
||||
assert.deepEqual(error.chunk, Buffer.concat(source))
|
||||
})
|
||||
|
||||
it('throws if stream ends with not enough data, utf8 , long data', async () => {
|
||||
const source = Buffer.from('a'.repeat(1500))
|
||||
const error = await rejectionOf(readChunkStrict(makeStream([source]), 2000))
|
||||
assert(error instanceof Error)
|
||||
assert.strictEqual(error.message, `stream has ended with not enough data (actual: 1500, expected: 2000)`)
|
||||
assert.strictEqual(error.text, undefined)
|
||||
assert.deepEqual(error.chunk, source)
|
||||
})
|
||||
|
||||
it('succeed', async () => {
|
||||
const source = Buffer.from('a'.repeat(20))
|
||||
const chunk = await readChunkStrict(makeStream([source]), 10)
|
||||
assert.deepEqual(source.subarray(10), chunk)
|
||||
})
|
||||
})
|
||||
|
||||
describe('skip', function () {
|
||||
@@ -134,6 +159,16 @@ describe('skip', function () {
|
||||
it('returns less size if stream ends', async () => {
|
||||
assert.deepEqual(await skip(makeStream('foo bar'), 10), 7)
|
||||
})
|
||||
|
||||
it('put back if it read too much', async () => {
|
||||
let source = makeStream(['foo', 'bar'])
|
||||
await skip(source, 1) // read part of data chunk
|
||||
const chunk = (await readChunkStrict(source, 2)).toString('utf-8')
|
||||
assert.strictEqual(chunk, 'oo')
|
||||
|
||||
source = makeStream(['foo', 'bar'])
|
||||
assert.strictEqual(await skip(source, 3), 3) // read aligned with data chunk
|
||||
})
|
||||
})
|
||||
|
||||
describe('skipStrict', function () {
|
||||
@@ -144,4 +179,9 @@ describe('skipStrict', function () {
|
||||
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 7, expected: 10)')
|
||||
assert.deepEqual(error.bytesSkipped, 7)
|
||||
})
|
||||
it('succeed', async () => {
|
||||
const source = makeStream(['foo', 'bar', 'baz'])
|
||||
const res = await skipStrict(source, 4)
|
||||
assert.strictEqual(res, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.0",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
},
|
||||
@@ -33,5 +33,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"isutf8": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.39.0",
|
||||
"@xen-orchestra/backups": "^0.40.0",
|
||||
"@xen-orchestra/fs": "^4.0.1",
|
||||
"filenamify": "^4.1.0",
|
||||
"filenamify": "^6.0.0",
|
||||
"getopts": "^2.2.5",
|
||||
"lodash": "^4.17.15",
|
||||
"promise-toolbox": "^0.21.0"
|
||||
@@ -27,7 +27,7 @@
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
},
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.10",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createLogger } from '@xen-orchestra/log'
|
||||
import { createVhdDirectoryFromStream, openVhd, VhdAbstract, VhdDirectory, VhdSynthetic } from 'vhd-lib'
|
||||
import { decorateMethodsWith } from '@vates/decorate-with'
|
||||
import { deduped } from '@vates/disposable/deduped.js'
|
||||
import { dirname, join, normalize, resolve } from 'node:path'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { execFile } from 'child_process'
|
||||
import { mount } from '@vates/fuse-vhd'
|
||||
import { readdir, lstat } from 'node:fs/promises'
|
||||
@@ -18,6 +18,7 @@ import fromEvent from 'promise-toolbox/fromEvent'
|
||||
import groupBy from 'lodash/groupBy.js'
|
||||
import pDefer from 'promise-toolbox/defer'
|
||||
import pickBy from 'lodash/pickBy.js'
|
||||
import tar from 'tar'
|
||||
import zlib from 'zlib'
|
||||
|
||||
import { BACKUP_DIR } from './_getVmBackupDir.mjs'
|
||||
@@ -41,20 +42,23 @@ const compareTimestamp = (a, b) => a.timestamp - b.timestamp
|
||||
const noop = Function.prototype
|
||||
|
||||
const resolveRelativeFromFile = (file, path) => resolve('/', dirname(file), path).slice(1)
|
||||
const makeRelative = path => resolve('/', path).slice(1)
|
||||
const resolveSubpath = (root, path) => resolve(root, makeRelative(path))
|
||||
|
||||
const resolveSubpath = (root, path) => resolve(root, `.${resolve('/', path)}`)
|
||||
async function addZipEntries(zip, realBasePath, virtualBasePath, relativePaths) {
|
||||
for (const relativePath of relativePaths) {
|
||||
const realPath = join(realBasePath, relativePath)
|
||||
const virtualPath = join(virtualBasePath, relativePath)
|
||||
|
||||
async function addDirectory(files, realPath, metadataPath) {
|
||||
const stats = await lstat(realPath)
|
||||
if (stats.isDirectory()) {
|
||||
await asyncMap(await readdir(realPath), file =>
|
||||
addDirectory(files, realPath + '/' + file, metadataPath + '/' + file)
|
||||
)
|
||||
} else if (stats.isFile()) {
|
||||
files.push({
|
||||
realPath,
|
||||
metadataPath,
|
||||
})
|
||||
const stats = await lstat(realPath)
|
||||
const { mode, mtime } = stats
|
||||
const opts = { mode, mtime }
|
||||
if (stats.isDirectory()) {
|
||||
zip.addEmptyDirectory(virtualPath, opts)
|
||||
await addZipEntries(zip, realPath, virtualPath, await readdir(realPath))
|
||||
} else if (stats.isFile()) {
|
||||
zip.addFile(realPath, virtualPath, opts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,17 +186,6 @@ export class RemoteAdapter {
|
||||
})
|
||||
}
|
||||
|
||||
async *_usePartitionFiles(diskId, partitionId, paths) {
|
||||
const path = yield this.getPartition(diskId, partitionId)
|
||||
|
||||
const files = []
|
||||
await asyncMap(paths, file =>
|
||||
addDirectory(files, resolveSubpath(path, file), normalize('./' + file).replace(/\/+$/, ''))
|
||||
)
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// check if we will be allowed to merge a a vhd created in this adapter
|
||||
// with the vhd at path `path`
|
||||
async isMergeableParent(packedParentUid, path) {
|
||||
@@ -209,15 +202,24 @@ export class RemoteAdapter {
|
||||
})
|
||||
}
|
||||
|
||||
fetchPartitionFiles(diskId, partitionId, paths) {
|
||||
fetchPartitionFiles(diskId, partitionId, paths, format) {
|
||||
const { promise, reject, resolve } = pDefer()
|
||||
Disposable.use(
|
||||
async function* () {
|
||||
const files = yield this._usePartitionFiles(diskId, partitionId, paths)
|
||||
const zip = new ZipFile()
|
||||
files.forEach(({ realPath, metadataPath }) => zip.addFile(realPath, metadataPath))
|
||||
zip.end()
|
||||
const { outputStream } = zip
|
||||
const path = yield this.getPartition(diskId, partitionId)
|
||||
let outputStream
|
||||
|
||||
if (format === 'tgz') {
|
||||
outputStream = tar.c({ cwd: path, gzip: true }, paths.map(makeRelative))
|
||||
} else if (format === 'zip') {
|
||||
const zip = new ZipFile()
|
||||
await addZipEntries(zip, path, '', paths.map(makeRelative))
|
||||
zip.end()
|
||||
;({ outputStream } = zip)
|
||||
} else {
|
||||
throw new Error('unsupported format ' + format)
|
||||
}
|
||||
|
||||
resolve(outputStream)
|
||||
await fromEvent(outputStream, 'end')
|
||||
}.bind(this)
|
||||
@@ -824,8 +826,6 @@ decorateMethodsWith(RemoteAdapter, {
|
||||
debounceResourceFactory,
|
||||
]),
|
||||
|
||||
_usePartitionFiles: Disposable.factory,
|
||||
|
||||
getDisk: compose([Disposable.factory, [deduped, diskId => [diskId]], debounceResourceFactory]),
|
||||
|
||||
getPartition: Disposable.factory,
|
||||
|
||||
@@ -16,6 +16,8 @@ export const TAG_BASE_DELTA = 'xo:base_delta'
|
||||
|
||||
export const TAG_COPY_SRC = 'xo:copy_of'
|
||||
|
||||
const TAG_BACKUP_SR = 'xo:backup:sr'
|
||||
|
||||
const ensureArray = value => (value === undefined ? [] : Array.isArray(value) ? value : [value])
|
||||
const resolveUuid = async (xapi, cache, uuid, type) => {
|
||||
if (uuid == null) {
|
||||
@@ -157,7 +159,10 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
||||
if (detectBase) {
|
||||
const remoteBaseVmUuid = vmRecord.other_config[TAG_BASE_DELTA]
|
||||
if (remoteBaseVmUuid) {
|
||||
baseVm = find(xapi.objects.all, obj => (obj = obj.other_config) && obj[TAG_COPY_SRC] === remoteBaseVmUuid)
|
||||
baseVm = find(
|
||||
xapi.objects.all,
|
||||
obj => (obj = obj.other_config) && obj[TAG_COPY_SRC] === remoteBaseVmUuid && obj[TAG_BACKUP_SR] === sr.$id
|
||||
)
|
||||
|
||||
if (!baseVm) {
|
||||
throw new Error(`could not find the base VM (copy of ${remoteBaseVmUuid})`)
|
||||
|
||||
@@ -18,7 +18,7 @@ export const MixinXapiWriter = (BaseClass = Object) =>
|
||||
const vdiRefs = await xapi.VM_getDisks(baseVm.$ref)
|
||||
for (const vdiRef of vdiRefs) {
|
||||
const vdi = xapi.getObject(vdiRef)
|
||||
if (vdi.$SR.uuid !== this._heathCheckSr.uuid) {
|
||||
if (vdi.$SR.uuid !== this._healthCheckSr.uuid) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "0.39.0",
|
||||
"version": "0.40.0",
|
||||
"engines": {
|
||||
"node": ">=14.18"
|
||||
},
|
||||
@@ -23,15 +23,15 @@
|
||||
"@vates/compose": "^2.1.0",
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@vates/disposable": "^0.1.4",
|
||||
"@vates/fuse-vhd": "^1.0.0",
|
||||
"@vates/nbd-client": "^1.2.1",
|
||||
"@vates/fuse-vhd": "^2.0.0",
|
||||
"@vates/nbd-client": "^2.0.0",
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/fs": "^4.0.1",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"@xen-orchestra/template": "^0.1.0",
|
||||
"compare-versions": "^5.0.1",
|
||||
"d3-time-format": "^3.0.0",
|
||||
"compare-versions": "^6.0.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"decorator-synchronized": "^0.6.0",
|
||||
"golike-defer": "^0.5.1",
|
||||
"limit-concurrency-decorator": "^0.5.0",
|
||||
@@ -40,9 +40,10 @@
|
||||
"parse-pairs": "^2.0.0",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"tar": "^6.1.15",
|
||||
"uuid": "^9.0.0",
|
||||
"vhd-lib": "^4.5.0",
|
||||
"xen-api": "^1.3.3",
|
||||
"xen-api": "^1.3.4",
|
||||
"yazl": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -53,7 +54,7 @@
|
||||
"tmp": "^0.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xen-orchestra/xapi": "^2.2.1"
|
||||
"@xen-orchestra/xapi": "^3.0.0"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"preferGlobal": true,
|
||||
"dependencies": {
|
||||
"golike-defer": "^0.5.1",
|
||||
"xen-api": "^1.3.3"
|
||||
"xen-api": "^1.3.4"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish"
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@vates/async-each": "^1.0.0",
|
||||
"@vates/coalesce-calls": "^0.1.0",
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@vates/read-chunk": "^1.1.1",
|
||||
"@vates/read-chunk": "^1.2.0",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"bind-property-descriptor": "^2.0.0",
|
||||
"decorator-synchronized": "^0.6.0",
|
||||
|
||||
@@ -209,7 +209,7 @@ describe('encryption', () => {
|
||||
// encrypt with a non default algorithm
|
||||
const encryptor = _getEncryptor('aes-256-cbc', '73c1838d7d8a6088ca2317fb5f29cd91')
|
||||
|
||||
await fs.writeFile(`${dir}/encryption.json`, `{"algorithm": "aes-256-gmc"}`)
|
||||
await fs.writeFile(`${dir}/encryption.json`, `{"algorithm": "aes-256-gcm"}`)
|
||||
await fs.writeFile(`${dir}/metadata.json`, encryptor.encryptData(`{"random": "NOTSORANDOM"}`))
|
||||
|
||||
// remote is now non empty : can't modify key anymore
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
// Keeping this file to prevent applying the global monorepo config for now
|
||||
module.exports = {};
|
||||
module.exports = {
|
||||
trailingComma: "es5",
|
||||
};
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
## **next**
|
||||
|
||||
## **0.1.2** (2023-07-28)
|
||||
|
||||
- Ability to export selected VMs as CSV file (PR [#6915](https://github.com/vatesfr/xen-orchestra/pull/6915))
|
||||
- [Pool/VMs] Ability to export selected VMs as JSON file (PR [#6911](https://github.com/vatesfr/xen-orchestra/pull/6911))
|
||||
- Add Tasks to Pool Dashboard (PR [#6713](https://github.com/vatesfr/xen-orchestra/pull/6713))
|
||||
|
||||
## **0.1.1** (2023-07-03)
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export const useConsoleStore = defineStore("console", () =>
|
||||
|
||||
To extend the base Subscription, you'll need to override the `subscribe` method.
|
||||
|
||||
For that, you can use the `createSubscribe<XenApiRecord, Extensions>((options) => { /* ... */})` helper.
|
||||
For that, you can use the `createSubscribe<RawObjectType, Extensions>((options) => { /* ... */})` helper.
|
||||
|
||||
### Define the extensions
|
||||
|
||||
@@ -82,7 +82,7 @@ type Extensions = [
|
||||
export const useConsoleStore = defineStore("console", () => {
|
||||
const consoleCollection = useXapiCollectionStore().get("console");
|
||||
|
||||
const subscribe = createSubscribe<XenApiConsole, Extensions>((options) => {
|
||||
const subscribe = createSubscribe<"console", Extensions>((options) => {
|
||||
const originalSubscription = consoleCollection.subscribe(options);
|
||||
|
||||
const extendedSubscription = {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite App</title>
|
||||
<title>XO Lite</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xen-orchestra/lite",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"scripts": {
|
||||
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
|
||||
"build": "run-p type-check build-only",
|
||||
@@ -22,7 +22,7 @@
|
||||
"@types/marked": "^4.0.8",
|
||||
"@vueuse/core": "^10.1.2",
|
||||
"@vueuse/math": "^10.1.2",
|
||||
"complex-matcher": "^0.7.0",
|
||||
"complex-matcher": "^0.7.1",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"decorator-synchronized": "^0.6.0",
|
||||
"echarts": "^5.3.3",
|
||||
@@ -49,7 +49,7 @@
|
||||
"@rushstack/eslint-patch": "^1.1.0",
|
||||
"@types/node": "^16.11.41",
|
||||
"@vitejs/plugin-vue": "^4.2.3",
|
||||
"@vue/eslint-config-prettier": "^7.0.0",
|
||||
"@vue/eslint-config-prettier": "^8.0.0",
|
||||
"@vue/eslint-config-typescript": "^11.0.0",
|
||||
"@vue/tsconfig": "^0.1.3",
|
||||
"eslint-plugin-vue": "^9.0.0",
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
<AppLogin />
|
||||
</div>
|
||||
<div v-else>
|
||||
<AppHeader />
|
||||
<AppHeader v-if="uiStore.hasUi" />
|
||||
<div style="display: flex">
|
||||
<AppNavigation />
|
||||
<main class="main">
|
||||
<AppNavigation v-if="uiStore.hasUi" />
|
||||
<main class="main" :class="{ 'no-ui': !uiStore.hasUi }">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
@@ -41,8 +41,6 @@ if (link == null) {
|
||||
}
|
||||
link.href = favicon;
|
||||
|
||||
document.title = "XO Lite";
|
||||
|
||||
const xenApiStore = useXenApiStore();
|
||||
const { pool } = usePoolStore().subscribe();
|
||||
useChartTheme();
|
||||
@@ -92,5 +90,9 @@ whenever(
|
||||
flex: 1;
|
||||
height: calc(100vh - 8rem);
|
||||
background-color: var(--background-color-secondary);
|
||||
|
||||
&.no-ui {
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
93
@xen-orchestra/lite/src/assets/monitor.svg
Normal file
93
@xen-orchestra/lite/src/assets/monitor.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 63 KiB |
@@ -24,6 +24,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
@@ -33,6 +34,7 @@ import UiButton from "@/components/ui/UiButton.vue";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
|
||||
const { t } = useI18n();
|
||||
usePageTitleStore().setTitle(t("login"));
|
||||
const xenApiStore = useXenApiStore();
|
||||
const { isConnecting } = storeToRefs(xenApiStore);
|
||||
const login = ref("root");
|
||||
@@ -62,7 +64,7 @@ async function handleSubmit() {
|
||||
isInvalidPassword.value = true;
|
||||
error.value = t("password-invalid");
|
||||
} else {
|
||||
error.value = t("error-occured");
|
||||
error.value = t("error-occurred");
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
</template>
|
||||
|
||||
<script
|
||||
generic="T extends XenApiRecord<string>, I extends T['uuid']"
|
||||
generic="T extends XenApiRecord<RawObjectType>, I extends T['uuid']"
|
||||
lang="ts"
|
||||
setup
|
||||
>
|
||||
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
||||
import type { XenApiRecord } from "@/libs/xen-api";
|
||||
import type { RawObjectType, XenApiRecord } from "@/libs/xen-api";
|
||||
import ObjectNotFoundView from "@/views/ObjectNotFoundView.vue";
|
||||
import { computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
import VncClient from "@novnc/novnc/core/rfb";
|
||||
import { promiseTimeout } from "@vueuse/shared";
|
||||
import { fibonacci } from "iterable-backoff";
|
||||
import { computed, onBeforeUnmount, ref, watchEffect } from "vue";
|
||||
import VncClient from "@novnc/novnc/core/rfb";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
import { promiseTimeout } from "@vueuse/shared";
|
||||
|
||||
const N_TOTAL_TRIES = 8;
|
||||
const FIBONACCI_MS_ARRAY: number[] = Array.from(
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</AppMenu>
|
||||
</UiTabBar>
|
||||
|
||||
<div class="tabs">
|
||||
<div :class="{ 'full-width': fullWidthComponent }" class="tabs">
|
||||
<UiCard v-if="selectedTab === TAB.NONE" class="tab-content">
|
||||
<i>No configuration defined</i>
|
||||
</UiCard>
|
||||
@@ -102,11 +102,11 @@ import StorySettingParams from "@/components/component-story/StorySettingParams.
|
||||
import StorySlotParams from "@/components/component-story/StorySlotParams.vue";
|
||||
import AppMenu from "@/components/menu/AppMenu.vue";
|
||||
import MenuItem from "@/components/menu/MenuItem.vue";
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import UiCounter from "@/components/ui/UiCounter.vue";
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import UiTab from "@/components/ui/UiTab.vue";
|
||||
import UiTabBar from "@/components/ui/UiTabBar.vue";
|
||||
import {
|
||||
@@ -140,6 +140,7 @@ const props = defineProps<{
|
||||
settings?: Record<string, any>;
|
||||
}
|
||||
>;
|
||||
fullWidthComponent?: boolean;
|
||||
}>();
|
||||
|
||||
enum TAB {
|
||||
@@ -329,6 +330,10 @@ const applyPreset = (preset: {
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
|
||||
&.full-width {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
|
||||
@@ -34,7 +34,6 @@ const {
|
||||
isReady: isVmReady,
|
||||
records: vms,
|
||||
hasError: hasVmError,
|
||||
runningVms,
|
||||
} = useVmStore().subscribe();
|
||||
|
||||
const {
|
||||
@@ -55,5 +54,7 @@ const activeHostsCount = computed(
|
||||
|
||||
const totalVmsCount = computed(() => vms.value.length);
|
||||
|
||||
const activeVmsCount = computed(() => runningVms.value.length);
|
||||
const activeVmsCount = computed(
|
||||
() => vms.value.filter((vm) => vm.power_state === "Running").length
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<UiCard>
|
||||
<UiCardTitle :count="pendingTasks.length">{{ $t("tasks") }}</UiCardTitle>
|
||||
<TasksTable :pending-tasks="pendingTasks" />
|
||||
</UiCard>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
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";
|
||||
|
||||
const { pendingTasks } = useTaskStore().subscribe();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped></style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<UiTable class="tasks-table" :color="hasError ? 'error' : undefined">
|
||||
<UiTable :color="hasError ? 'error' : undefined" class="tasks-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("name") }}</th>
|
||||
@@ -20,6 +20,9 @@
|
||||
<UiSpinner class="loader" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="!hasTasks">
|
||||
<td class="no-tasks" colspan="5">{{ $t("no-tasks") }}</td>
|
||||
</tr>
|
||||
<template v-else>
|
||||
<TaskRow
|
||||
v-for="task in pendingTasks"
|
||||
@@ -35,20 +38,35 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import TaskRow from "@/components/tasks/TaskRow.vue";
|
||||
import UiTable from "@/components/ui/UiTable.vue";
|
||||
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
||||
import { useTaskStore } from "@/stores/task.store";
|
||||
import UiTable from "@/components/ui/UiTable.vue";
|
||||
import type { XenApiTask } from "@/libs/xen-api";
|
||||
import { useTaskStore } from "@/stores/task.store";
|
||||
import { computed } from "vue";
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
pendingTasks: XenApiTask[];
|
||||
finishedTasks: XenApiTask[];
|
||||
finishedTasks?: XenApiTask[];
|
||||
}>();
|
||||
|
||||
const { hasError, isFetching } = useTaskStore().subscribe();
|
||||
|
||||
const hasTasks = computed(
|
||||
() => props.pendingTasks.length > 0 || (props.finishedTasks?.length ?? 0) > 0
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.tasks-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.no-tasks {
|
||||
text-align: center;
|
||||
color: var(--color-blue-scale-300);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
td[colspan="5"] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
class="left"
|
||||
>
|
||||
<slot>{{ left }}</slot>
|
||||
<UiCounter class="count" v-if="count > 0" :value="count" color="info" />
|
||||
</component>
|
||||
<component
|
||||
:is="subtitle ? 'h6' : 'h5'"
|
||||
@@ -18,11 +19,17 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
subtitle?: boolean;
|
||||
left?: string;
|
||||
right?: string;
|
||||
}>();
|
||||
import UiCounter from "@/components/ui/UiCounter.vue";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
subtitle?: boolean;
|
||||
left?: string;
|
||||
right?: string;
|
||||
count?: number;
|
||||
}>(),
|
||||
{ count: 0 }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
@@ -55,6 +62,9 @@ defineProps<{
|
||||
font-size: var(--section-title-left-size);
|
||||
font-weight: var(--section-title-left-weight);
|
||||
color: var(--section-title-left-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.right {
|
||||
@@ -62,4 +72,8 @@ defineProps<{
|
||||
font-weight: var(--section-title-right-weight);
|
||||
color: var(--section-title-right-color);
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<li class="ui-resource">
|
||||
<UiIcon :icon="icon" class="icon" />
|
||||
<div class="separator" />
|
||||
<div class="label">{{ label }}</div>
|
||||
<div class="count">{{ count }}</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
|
||||
defineProps<{
|
||||
icon: IconDefinition;
|
||||
label: string;
|
||||
count: string | number;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-resource {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--color-extra-blue-base);
|
||||
font-size: 3.2rem;
|
||||
}
|
||||
|
||||
.separator {
|
||||
height: 4.5rem;
|
||||
width: 0;
|
||||
border-left: 0.1rem solid var(--color-extra-blue-base);
|
||||
background-color: var(--color-extra-blue-base);
|
||||
margin: 0 1.5rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 400;
|
||||
margin-left: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<ul class="ui-resources">
|
||||
<slot />
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-resources {
|
||||
display: flex;
|
||||
gap: 1rem 5.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,10 @@
|
||||
import type {
|
||||
RawXenApiRecord,
|
||||
XenApiHost,
|
||||
XenApiHostMetrics,
|
||||
XenApiRecord,
|
||||
XenApiVm,
|
||||
VM_OPERATION,
|
||||
RawObjectType,
|
||||
} from "@/libs/xen-api";
|
||||
import type { Filter } from "@/types/filter";
|
||||
import type { Subscription } from "@/types/xapi-collection";
|
||||
@@ -116,14 +116,14 @@ export function getStatsLength(stats?: object | any[]) {
|
||||
|
||||
export function isHostRunning(
|
||||
host: XenApiHost,
|
||||
hostMetricsSubscription: Subscription<XenApiHostMetrics, object>
|
||||
hostMetricsSubscription: Subscription<"host_metrics", object>
|
||||
) {
|
||||
return hostMetricsSubscription.getByOpaqueRef(host.metrics)?.live === true;
|
||||
}
|
||||
|
||||
export function getHostMemory(
|
||||
host: XenApiHost,
|
||||
hostMetricsSubscription: Subscription<XenApiHostMetrics, object>
|
||||
hostMetricsSubscription: Subscription<"host_metrics", object>
|
||||
) {
|
||||
const hostMetrics = hostMetricsSubscription.getByOpaqueRef(host.metrics);
|
||||
|
||||
@@ -136,7 +136,7 @@ export function getHostMemory(
|
||||
}
|
||||
}
|
||||
|
||||
export const buildXoObject = <T extends XenApiRecord<string>>(
|
||||
export const buildXoObject = <T extends XenApiRecord<RawObjectType>>(
|
||||
record: RawXenApiRecord<T>,
|
||||
params: { opaqueRef: T["$ref"] }
|
||||
) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { buildXoObject, parseDateTime } from "@/libs/utils";
|
||||
import type { RawTypeToRecord } from "@/types/xapi-collection";
|
||||
import { JSONRPCClient } from "json-rpc-2.0";
|
||||
import { castArray } from "lodash-es";
|
||||
|
||||
@@ -90,14 +91,17 @@ export enum VM_OPERATION {
|
||||
|
||||
declare const __brand: unique symbol;
|
||||
|
||||
export interface XenApiRecord<Name extends string> {
|
||||
export interface XenApiRecord<Name extends RawObjectType> {
|
||||
$ref: string & { [__brand]: `${Name}Ref` };
|
||||
uuid: string & { [__brand]: `${Name}Uuid` };
|
||||
}
|
||||
|
||||
export type RawXenApiRecord<T extends XenApiRecord<string>> = Omit<T, "$ref">;
|
||||
export type RawXenApiRecord<T extends XenApiRecord<RawObjectType>> = Omit<
|
||||
T,
|
||||
"$ref"
|
||||
>;
|
||||
|
||||
export interface XenApiPool extends XenApiRecord<"Pool"> {
|
||||
export interface XenApiPool extends XenApiRecord<"pool"> {
|
||||
cpu_info: {
|
||||
cpu_count: string;
|
||||
};
|
||||
@@ -105,7 +109,7 @@ export interface XenApiPool extends XenApiRecord<"Pool"> {
|
||||
name_label: string;
|
||||
}
|
||||
|
||||
export interface XenApiHost extends XenApiRecord<"Host"> {
|
||||
export interface XenApiHost extends XenApiRecord<"host"> {
|
||||
address: string;
|
||||
name_label: string;
|
||||
metrics: XenApiHostMetrics["$ref"];
|
||||
@@ -114,13 +118,13 @@ export interface XenApiHost extends XenApiRecord<"Host"> {
|
||||
software_version: { product_version: string };
|
||||
}
|
||||
|
||||
export interface XenApiSr extends XenApiRecord<"Sr"> {
|
||||
export interface XenApiSr extends XenApiRecord<"SR"> {
|
||||
name_label: string;
|
||||
physical_size: number;
|
||||
physical_utilisation: number;
|
||||
}
|
||||
|
||||
export interface XenApiVm extends XenApiRecord<"Vm"> {
|
||||
export interface XenApiVm extends XenApiRecord<"VM"> {
|
||||
current_operations: Record<string, VM_OPERATION>;
|
||||
guest_metrics: string;
|
||||
metrics: XenApiVmMetrics["$ref"];
|
||||
@@ -135,24 +139,24 @@ export interface XenApiVm extends XenApiRecord<"Vm"> {
|
||||
VCPUs_at_startup: number;
|
||||
}
|
||||
|
||||
export interface XenApiConsole extends XenApiRecord<"Console"> {
|
||||
export interface XenApiConsole extends XenApiRecord<"console"> {
|
||||
protocol: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export interface XenApiHostMetrics extends XenApiRecord<"HostMetrics"> {
|
||||
export interface XenApiHostMetrics extends XenApiRecord<"host_metrics"> {
|
||||
live: boolean;
|
||||
memory_free: number;
|
||||
memory_total: number;
|
||||
}
|
||||
|
||||
export interface XenApiVmMetrics extends XenApiRecord<"VmMetrics"> {
|
||||
export interface XenApiVmMetrics extends XenApiRecord<"VM_metrics"> {
|
||||
VCPUs_number: number;
|
||||
}
|
||||
|
||||
export type XenApiVmGuestMetrics = XenApiRecord<"VmGuestMetrics">;
|
||||
export type XenApiVmGuestMetrics = XenApiRecord<"VM_guest_metrics">;
|
||||
|
||||
export interface XenApiTask extends XenApiRecord<"Task"> {
|
||||
export interface XenApiTask extends XenApiRecord<"task"> {
|
||||
name_label: string;
|
||||
resident_on: XenApiHost["$ref"];
|
||||
created: string;
|
||||
@@ -161,17 +165,22 @@ export interface XenApiTask extends XenApiRecord<"Task"> {
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface XenApiMessage extends XenApiRecord<"Message"> {
|
||||
export interface XenApiMessage<T extends RawObjectType = RawObjectType>
|
||||
extends XenApiRecord<"message"> {
|
||||
body: string;
|
||||
cls: T;
|
||||
name: string;
|
||||
cls: RawObjectType;
|
||||
obj_uuid: RawTypeToRecord<T>["uuid"];
|
||||
priority: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
type WatchCallbackResult = {
|
||||
id: string;
|
||||
class: ObjectType;
|
||||
operation: "add" | "mod" | "del";
|
||||
ref: XenApiRecord<string>["$ref"];
|
||||
snapshot: RawXenApiRecord<XenApiRecord<string>>;
|
||||
ref: XenApiRecord<RawObjectType>["$ref"];
|
||||
snapshot: RawXenApiRecord<XenApiRecord<RawObjectType>>;
|
||||
};
|
||||
|
||||
type WatchCallback = (results: WatchCallbackResult[]) => void;
|
||||
@@ -284,16 +293,17 @@ export default class XenApi {
|
||||
return fetch(url, { signal: abortSignal });
|
||||
}
|
||||
|
||||
async loadRecords<T extends XenApiRecord<string>>(
|
||||
type: RawObjectType
|
||||
): Promise<T[]> {
|
||||
const result = await this.#call<{ [key: string]: RawXenApiRecord<T> }>(
|
||||
async loadRecords<
|
||||
T extends RawObjectType,
|
||||
R extends RawTypeToRecord<T> = RawTypeToRecord<T>
|
||||
>(type: T): Promise<R[]> {
|
||||
const result = await this.#call<{ [key: string]: R }>(
|
||||
`${type}.get_all_records`,
|
||||
[this.sessionId]
|
||||
);
|
||||
|
||||
return Object.entries(result).map(([opaqueRef, record]) =>
|
||||
buildXoObject(record, { opaqueRef: opaqueRef as T["$ref"] })
|
||||
buildXoObject(record, { opaqueRef: opaqueRef as R["$ref"] })
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"community": "Community",
|
||||
"community-name": "{name} community",
|
||||
"console": "Console",
|
||||
"console-unavailable": "Console unavailable",
|
||||
"copy": "Copy",
|
||||
"cpu-provisioning": "CPU provisioning",
|
||||
"cpu-usage": "CPU usage",
|
||||
@@ -32,7 +33,7 @@
|
||||
"do-you-have-needs": "You have needs and/or expectations? Let us know",
|
||||
"edit-config": "Edit config",
|
||||
"error-no-data": "Error, can't collect data.",
|
||||
"error-occured": "An error has occurred",
|
||||
"error-occurred": "An error has occurred",
|
||||
"export": "Export",
|
||||
"export-table-to": "Export table to {type}",
|
||||
"export-vms": "Export VMs",
|
||||
@@ -77,8 +78,11 @@
|
||||
"news": "News",
|
||||
"news-name": "{name} news",
|
||||
"new-features-are-coming": "New features are coming soon!",
|
||||
"no-tasks": "No tasks",
|
||||
"not-found": "Not found",
|
||||
"object": "Object",
|
||||
"object-not-found": "Object {id} can't be found…",
|
||||
"open-in-new-window": "Open in new window",
|
||||
"or": "Or",
|
||||
"page-not-found": "This page is not to be found…",
|
||||
"password": "Password",
|
||||
@@ -87,6 +91,7 @@
|
||||
"please-confirm": "Please confirm",
|
||||
"pool-cpu-usage": "Pool CPU Usage",
|
||||
"pool-ram-usage": "Pool RAM Usage",
|
||||
"power-on-for-console": "Power on your VM to access its console",
|
||||
"power-state": "Power state",
|
||||
"property": "Property",
|
||||
"ram-usage": "RAM usage",
|
||||
@@ -110,7 +115,6 @@
|
||||
"settings": "Settings",
|
||||
"shutdown": "Shutdown",
|
||||
"snapshot": "Snapshot",
|
||||
"selected-vms-in-execution": "Some selected VMs are running",
|
||||
"sort-by": "Sort by",
|
||||
"stacked-cpu-usage": "Stacked CPU usage",
|
||||
"stacked-ram-usage": "Stacked RAM usage",
|
||||
@@ -127,7 +131,6 @@
|
||||
"system": "System",
|
||||
"task": {
|
||||
"estimated-end": "Estimated end",
|
||||
"page-title": "Tasks | (1) Tasks | ({n}) Tasks",
|
||||
"progress": "Progress",
|
||||
"started": "Started"
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"community": "Communauté",
|
||||
"community-name": "Communauté {name}",
|
||||
"console": "Console",
|
||||
"console-unavailable": "Console indisponible",
|
||||
"copy": "Copier",
|
||||
"cpu-provisioning": "Provisionnement CPU",
|
||||
"cpu-usage": "Utilisation CPU",
|
||||
@@ -32,7 +33,7 @@
|
||||
"do-you-have-needs": "Vous avez des besoins et/ou des attentes ? Faites le nous savoir",
|
||||
"edit-config": "Modifier config",
|
||||
"error-no-data": "Erreur, impossible de collecter les données.",
|
||||
"error-occured": "Une erreur est survenue",
|
||||
"error-occurred": "Une erreur est survenue",
|
||||
"export": "Exporter",
|
||||
"export-table-to": "Exporter le tableau en {type}",
|
||||
"export-vms": "Exporter les VMs",
|
||||
@@ -77,8 +78,11 @@
|
||||
"news": "Actualités",
|
||||
"news-name": "Actualités {name}",
|
||||
"new-features-are-coming": "De nouvelles fonctionnalités arrivent bientôt !",
|
||||
"no-tasks": "Aucune tâche",
|
||||
"not-found": "Non trouvé",
|
||||
"object": "Objet",
|
||||
"object-not-found": "L'objet {id} est introuvable…",
|
||||
"open-in-new-window": "Ouvrir dans une nouvelle fenêtre",
|
||||
"or": "Ou",
|
||||
"page-not-found": "Cette page est introuvable…",
|
||||
"password": "Mot de passe",
|
||||
@@ -87,6 +91,7 @@
|
||||
"please-confirm": "Veuillez confirmer",
|
||||
"pool-cpu-usage": "Utilisation CPU du Pool",
|
||||
"pool-ram-usage": "Utilisation RAM du Pool",
|
||||
"power-on-for-console": "Allumez votre VM pour accéder à sa console",
|
||||
"power-state": "État d'alimentation",
|
||||
"property": "Propriété",
|
||||
"ram-usage": "Utilisation de la RAM",
|
||||
@@ -110,7 +115,6 @@
|
||||
"settings": "Paramètres",
|
||||
"shutdown": "Arrêter",
|
||||
"snapshot": "Instantané",
|
||||
"selected-vms-in-execution": "Certaines VMs sélectionnées sont en cours d'exécution",
|
||||
"sort-by": "Trier par",
|
||||
"stacked-cpu-usage": "Utilisation CPU empilée",
|
||||
"stacked-ram-usage": "Utilisation RAM empilée",
|
||||
@@ -127,7 +131,6 @@
|
||||
"system": "Système",
|
||||
"task": {
|
||||
"estimated-end": "Fin estimée",
|
||||
"page-title": "Tâches | (1) Tâches | ({n}) Tâches",
|
||||
"progress": "Progression",
|
||||
"started": "Démarré"
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
name: "notFound",
|
||||
name: "not-found",
|
||||
component: () => import("@/views/PageNotFoundView.vue"),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { XenApiMessage } from "@/libs/xen-api";
|
||||
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
|
||||
import { createSubscribe } from "@/types/xapi-collection";
|
||||
import { defineStore } from "pinia";
|
||||
@@ -7,7 +6,7 @@ import { computed } from "vue";
|
||||
export const useAlarmStore = defineStore("alarm", () => {
|
||||
const messageCollection = useXapiCollectionStore().get("message");
|
||||
|
||||
const subscribe = createSubscribe<XenApiMessage, []>((options) => {
|
||||
const subscribe = createSubscribe<"message", []>((options) => {
|
||||
const originalSubscription = messageCollection.subscribe(options);
|
||||
|
||||
const extendedSubscription = {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
HostStats,
|
||||
XapiStatsResponse,
|
||||
} from "@/libs/xapi-stats";
|
||||
import type { XenApiHost, XenApiHostMetrics } from "@/libs/xen-api";
|
||||
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";
|
||||
@@ -25,7 +25,7 @@ type GetStatsExtension = {
|
||||
|
||||
type RunningHostsExtension = [
|
||||
{ runningHosts: ComputedRef<XenApiHost[]> },
|
||||
{ hostMetricsSubscription: Subscription<XenApiHostMetrics, any> }
|
||||
{ hostMetricsSubscription: Subscription<"host_metrics", any> }
|
||||
];
|
||||
|
||||
type Extensions = [GetStatsExtension, RunningHostsExtension];
|
||||
@@ -36,7 +36,7 @@ export const useHostStore = defineStore("host", () => {
|
||||
|
||||
hostCollection.setSort(sortRecordsByNameLabel);
|
||||
|
||||
const subscribe = createSubscribe<XenApiHost, Extensions>((options) => {
|
||||
const subscribe = createSubscribe<"host", Extensions>((options) => {
|
||||
const originalSubscription = hostCollection.subscribe(options);
|
||||
|
||||
const getStats: GetStats = (
|
||||
|
||||
92
@xen-orchestra/lite/src/stores/page-title.store.ts
Normal file
92
@xen-orchestra/lite/src/stores/page-title.store.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useTitle } from "@vueuse/core";
|
||||
import { defineStore } from "pinia";
|
||||
import {
|
||||
computed,
|
||||
type MaybeRefOrGetter,
|
||||
onBeforeUnmount,
|
||||
reactive,
|
||||
toRef,
|
||||
watch,
|
||||
} from "vue";
|
||||
|
||||
const PAGE_TITLE_SUFFIX = "XO Lite";
|
||||
|
||||
interface PageTitleConfig {
|
||||
object: { name_label: string } | undefined;
|
||||
title: string | undefined;
|
||||
count: number | undefined;
|
||||
}
|
||||
|
||||
export const usePageTitleStore = defineStore("page-title", () => {
|
||||
const pageTitleConfig = reactive<PageTitleConfig>({
|
||||
count: undefined,
|
||||
title: undefined,
|
||||
object: undefined,
|
||||
});
|
||||
|
||||
const generatedPageTitle = computed(() => {
|
||||
const { object, title, count } = pageTitleConfig;
|
||||
const parts = [];
|
||||
|
||||
if (count !== undefined && count > 0) {
|
||||
parts.push(`(${count})`);
|
||||
}
|
||||
|
||||
if (title !== undefined && object !== undefined) {
|
||||
parts.push(`${title} - ${object.name_label}`);
|
||||
} else if (title !== undefined) {
|
||||
parts.push(title);
|
||||
} else if (object !== undefined) {
|
||||
parts.push(object.name_label);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
});
|
||||
|
||||
useTitle(generatedPageTitle, {
|
||||
titleTemplate: computed(() =>
|
||||
generatedPageTitle.value === undefined
|
||||
? PAGE_TITLE_SUFFIX
|
||||
: `%s - ${PAGE_TITLE_SUFFIX}`
|
||||
),
|
||||
});
|
||||
|
||||
const setPageTitleConfig = <T extends keyof PageTitleConfig>(
|
||||
configKey: T,
|
||||
value: MaybeRefOrGetter<PageTitleConfig[T]>
|
||||
) => {
|
||||
const stop = watch(
|
||||
toRef(value),
|
||||
(newValue) =>
|
||||
(pageTitleConfig[configKey] = newValue as PageTitleConfig[T]),
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stop();
|
||||
pageTitleConfig[configKey] = undefined;
|
||||
});
|
||||
};
|
||||
|
||||
const setObject = (
|
||||
object: MaybeRefOrGetter<{ name_label: string } | undefined>
|
||||
) => setPageTitleConfig("object", object);
|
||||
|
||||
const setTitle = (title: MaybeRefOrGetter<string | undefined>) =>
|
||||
setPageTitleConfig("title", title);
|
||||
|
||||
const setCount = (count: MaybeRefOrGetter<number | undefined>) =>
|
||||
setPageTitleConfig("count", count);
|
||||
|
||||
return {
|
||||
setObject,
|
||||
setTitle,
|
||||
setCount,
|
||||
};
|
||||
});
|
||||
@@ -14,7 +14,7 @@ type Extensions = [PoolExtension];
|
||||
export const usePoolStore = defineStore("pool", () => {
|
||||
const poolCollection = useXapiCollectionStore().get("pool");
|
||||
|
||||
const subscribe = createSubscribe<XenApiPool, Extensions>((options) => {
|
||||
const subscribe = createSubscribe<"pool", Extensions>((options) => {
|
||||
const originalSubscription = poolCollection.subscribe(options);
|
||||
|
||||
const extendedSubscription = {
|
||||
|
||||
@@ -1,6 +1,64 @@
|
||||
import useArrayRemovedItemsHistory from "@/composables/array-removed-items-history.composable";
|
||||
import useCollectionFilter from "@/composables/collection-filter.composable";
|
||||
import useCollectionSorter from "@/composables/collection-sorter.composable";
|
||||
import useFilteredCollection from "@/composables/filtered-collection.composable";
|
||||
import useSortedCollection from "@/composables/sorted-collection.composable";
|
||||
import type { XenApiTask } from "@/libs/xen-api";
|
||||
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
|
||||
import { createSubscribe } from "@/types/xapi-collection";
|
||||
import { defineStore } from "pinia";
|
||||
import type { ComputedRef, Ref } from "vue";
|
||||
|
||||
export const useTaskStore = defineStore("task", () =>
|
||||
useXapiCollectionStore().get("task")
|
||||
);
|
||||
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 +1,7 @@
|
||||
import { useBreakpoints, useColorMode } from "@vueuse/core";
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
export const useUiStore = defineStore("ui", () => {
|
||||
const currentHostOpaqueRef = ref();
|
||||
@@ -13,10 +14,14 @@ export const useUiStore = defineStore("ui", () => {
|
||||
|
||||
const isMobile = computed(() => !isDesktop.value);
|
||||
|
||||
const route = useRoute();
|
||||
const hasUi = computed(() => route.query.ui !== "0");
|
||||
|
||||
return {
|
||||
colorMode,
|
||||
currentHostOpaqueRef,
|
||||
isDesktop,
|
||||
isMobile,
|
||||
hasUi,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ type GetStatsExtension = [
|
||||
{
|
||||
getStats: GetStats;
|
||||
},
|
||||
{ hostSubscription: Subscription<XenApiHost, object> }
|
||||
{ hostSubscription: Subscription<"host", object> }
|
||||
];
|
||||
|
||||
type Extensions = [DefaultExtension, GetStatsExtension];
|
||||
@@ -41,7 +41,7 @@ export const useVmStore = defineStore("vm", () => {
|
||||
|
||||
vmCollection.setSort(sortRecordsByNameLabel);
|
||||
|
||||
const subscribe = createSubscribe<XenApiVm, Extensions>((options) => {
|
||||
const subscribe = createSubscribe<"VM", Extensions>((options) => {
|
||||
const originalSubscription = vmCollection.subscribe(options);
|
||||
|
||||
const extendedSubscription = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { RawObjectType, XenApiRecord } from "@/libs/xen-api";
|
||||
import type { RawObjectType } from "@/libs/xen-api";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
import type {
|
||||
RawTypeToObject,
|
||||
RawTypeToRecord,
|
||||
SubscribeOptions,
|
||||
Subscription,
|
||||
} from "@/types/xapi-collection";
|
||||
@@ -10,16 +10,13 @@ import { defineStore } from "pinia";
|
||||
import { computed, readonly, ref } from "vue";
|
||||
|
||||
export const useXapiCollectionStore = defineStore("xapiCollection", () => {
|
||||
const collections = ref(
|
||||
new Map<RawObjectType, ReturnType<typeof createXapiCollection<any>>>()
|
||||
);
|
||||
const collections = ref(new Map());
|
||||
|
||||
function get<
|
||||
T extends RawObjectType,
|
||||
S extends XenApiRecord<string> = RawTypeToObject[T]
|
||||
>(type: T): ReturnType<typeof createXapiCollection<S>> {
|
||||
function get<T extends RawObjectType>(
|
||||
type: T
|
||||
): ReturnType<typeof createXapiCollection<T, RawTypeToRecord<T>>> {
|
||||
if (!collections.value.has(type)) {
|
||||
collections.value.set(type, createXapiCollection<S>(type));
|
||||
collections.value.set(type, createXapiCollection(type));
|
||||
}
|
||||
|
||||
return collections.value.get(type)!;
|
||||
@@ -28,8 +25,11 @@ export const useXapiCollectionStore = defineStore("xapiCollection", () => {
|
||||
return { get };
|
||||
});
|
||||
|
||||
const createXapiCollection = <T extends XenApiRecord<string>>(
|
||||
type: RawObjectType
|
||||
const createXapiCollection = <
|
||||
T extends RawObjectType,
|
||||
R extends RawTypeToRecord<T>
|
||||
>(
|
||||
type: T
|
||||
) => {
|
||||
const isReady = ref(false);
|
||||
const isFetching = ref(false);
|
||||
@@ -37,31 +37,31 @@ const createXapiCollection = <T extends XenApiRecord<string>>(
|
||||
const lastError = ref<string>();
|
||||
const hasError = computed(() => lastError.value !== undefined);
|
||||
const subscriptions = ref(new Set<symbol>());
|
||||
const recordsByOpaqueRef = ref(new Map<T["$ref"], T>());
|
||||
const recordsByUuid = ref(new Map<T["uuid"], T>());
|
||||
const filter = ref<(record: T) => boolean>();
|
||||
const sort = ref<(record1: T, record2: T) => 1 | 0 | -1>();
|
||||
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: T) => boolean) =>
|
||||
const setFilter = (newFilter: (record: R) => boolean) =>
|
||||
(filter.value = newFilter);
|
||||
|
||||
const setSort = (newSort: (record1: T, record2: T) => 1 | 0 | -1) =>
|
||||
const setSort = (newSort: (record1: R, record2: R) => 1 | 0 | -1) =>
|
||||
(sort.value = newSort);
|
||||
|
||||
const records = computed<T[]>(() => {
|
||||
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: T["$ref"]) =>
|
||||
const getByOpaqueRef = (opaqueRef: R["$ref"]) =>
|
||||
recordsByOpaqueRef.value.get(opaqueRef);
|
||||
|
||||
const getByUuid = (uuid: T["uuid"]) => recordsByUuid.value.get(uuid);
|
||||
const getByUuid = (uuid: R["uuid"]) => recordsByUuid.value.get(uuid);
|
||||
|
||||
const hasUuid = (uuid: T["uuid"]) => recordsByUuid.value.has(uuid);
|
||||
const hasUuid = (uuid: R["uuid"]) => recordsByUuid.value.has(uuid);
|
||||
|
||||
const hasSubscriptions = computed(() => subscriptions.value.size > 0);
|
||||
|
||||
@@ -69,7 +69,7 @@ const createXapiCollection = <T extends XenApiRecord<string>>(
|
||||
try {
|
||||
isFetching.value = true;
|
||||
lastError.value = undefined;
|
||||
const records = await xenApiStore.getXapi().loadRecords<T>(type);
|
||||
const records = await xenApiStore.getXapi().loadRecords<T, R>(type);
|
||||
recordsByOpaqueRef.value.clear();
|
||||
recordsByUuid.value.clear();
|
||||
records.forEach(add);
|
||||
@@ -81,17 +81,17 @@ const createXapiCollection = <T extends XenApiRecord<string>>(
|
||||
}
|
||||
};
|
||||
|
||||
const add = (record: T) => {
|
||||
const add = (record: R) => {
|
||||
recordsByOpaqueRef.value.set(record.$ref, record);
|
||||
recordsByUuid.value.set(record.uuid, record);
|
||||
};
|
||||
|
||||
const update = (record: T) => {
|
||||
const update = (record: R) => {
|
||||
recordsByOpaqueRef.value.set(record.$ref, record);
|
||||
recordsByUuid.value.set(record.uuid, record);
|
||||
};
|
||||
|
||||
const remove = (opaqueRef: T["$ref"]) => {
|
||||
const remove = (opaqueRef: R["$ref"]) => {
|
||||
if (!recordsByOpaqueRef.value.has(opaqueRef)) {
|
||||
return;
|
||||
}
|
||||
|
||||
70
@xen-orchestra/lite/src/stories/ui-resource.story.vue
Normal file
70
@xen-orchestra/lite/src/stories/ui-resource.story.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<ComponentStory
|
||||
v-slot="{ properties }"
|
||||
:params="[
|
||||
iconProp().preset(faRocket),
|
||||
prop('label').required().str().widget().preset('Rockets'),
|
||||
prop('count')
|
||||
.required()
|
||||
.type('string | number')
|
||||
.widget(text())
|
||||
.preset('175'),
|
||||
]"
|
||||
:presets="presets"
|
||||
>
|
||||
<UiResource v-bind="properties" />
|
||||
</ComponentStory>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ComponentStory from "@/components/component-story/ComponentStory.vue";
|
||||
import UiResource from "@/components/ui/resources/UiResource.vue";
|
||||
import { iconProp, prop } from "@/libs/story/story-param";
|
||||
import { text } from "@/libs/story/story-widget";
|
||||
import {
|
||||
faDatabase,
|
||||
faDisplay,
|
||||
faMemory,
|
||||
faMicrochip,
|
||||
faNetworkWired,
|
||||
faRocket,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const presets = {
|
||||
VMs: {
|
||||
props: {
|
||||
icon: faDisplay,
|
||||
count: 1,
|
||||
label: "VMs",
|
||||
},
|
||||
},
|
||||
vCPUs: {
|
||||
props: {
|
||||
icon: faMicrochip,
|
||||
count: 4,
|
||||
label: "vCPUs",
|
||||
},
|
||||
},
|
||||
RAM: {
|
||||
props: {
|
||||
icon: faMemory,
|
||||
count: 2,
|
||||
label: "RAM",
|
||||
},
|
||||
},
|
||||
SR: {
|
||||
props: {
|
||||
icon: faDatabase,
|
||||
count: 1,
|
||||
label: "SR",
|
||||
},
|
||||
},
|
||||
Interfaces: {
|
||||
props: {
|
||||
icon: faNetworkWired,
|
||||
count: 2,
|
||||
label: "Interfaces",
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
11
@xen-orchestra/lite/src/stories/ui-resources.story.md
Normal file
11
@xen-orchestra/lite/src/stories/ui-resources.story.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Example
|
||||
|
||||
```vue-template
|
||||
<UiResources>
|
||||
<UiResource :icon="faDisplay" count="1" label="VMs" />
|
||||
<UiResource :icon="faMicrochip" count="4" label="vCPUs" />
|
||||
<UiResource :icon="faMemory" count="2" label="RAM" />
|
||||
<UiResource :icon="faDatabase" count="1" label="SR" />
|
||||
<UiResource :icon="faNetworkWired" count="2" label="Interfaces" />
|
||||
</UiResources>
|
||||
```
|
||||
28
@xen-orchestra/lite/src/stories/ui-resources.story.vue
Normal file
28
@xen-orchestra/lite/src/stories/ui-resources.story.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<ComponentStory
|
||||
:params="[slot().help('One or multiple `UiResource`')]"
|
||||
full-width-component
|
||||
>
|
||||
<UiResources>
|
||||
<UiResource :icon="faDisplay" count="1" label="VMs" />
|
||||
<UiResource :icon="faMicrochip" count="4" label="vCPUs" />
|
||||
<UiResource :icon="faMemory" count="2" label="RAM" />
|
||||
<UiResource :icon="faDatabase" count="1" label="SR" />
|
||||
<UiResource :icon="faNetworkWired" count="2" label="Interfaces" />
|
||||
</UiResources>
|
||||
</ComponentStory>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ComponentStory from "@/components/component-story/ComponentStory.vue";
|
||||
import UiResource from "@/components/ui/resources/UiResource.vue";
|
||||
import UiResources from "@/components/ui/resources/UiResources.vue";
|
||||
import { slot } from "@/libs/story/story-param";
|
||||
import {
|
||||
faDatabase,
|
||||
faDisplay,
|
||||
faMemory,
|
||||
faMicrochip,
|
||||
faNetworkWired,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
</script>
|
||||
@@ -1,10 +1,10 @@
|
||||
import type {
|
||||
RawObjectType,
|
||||
XenApiConsole,
|
||||
XenApiHost,
|
||||
XenApiHostMetrics,
|
||||
XenApiMessage,
|
||||
XenApiPool,
|
||||
XenApiRecord,
|
||||
XenApiSr,
|
||||
XenApiTask,
|
||||
XenApiVm,
|
||||
@@ -13,11 +13,14 @@ import type {
|
||||
} from "@/libs/xen-api";
|
||||
import type { ComputedRef, Ref } from "vue";
|
||||
|
||||
type DefaultExtension<T extends XenApiRecord<string>> = {
|
||||
records: ComputedRef<T[]>;
|
||||
getByOpaqueRef: (opaqueRef: T["$ref"]) => T | undefined;
|
||||
getByUuid: (uuid: T["uuid"]) => T | undefined;
|
||||
hasUuid: (uuid: T["uuid"]) => boolean;
|
||||
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>;
|
||||
@@ -33,7 +36,7 @@ type DeferExtension = [
|
||||
{ immediate: false }
|
||||
];
|
||||
|
||||
type DefaultExtensions<T extends XenApiRecord<string>> = [
|
||||
type DefaultExtensions<T extends RawObjectType> = [
|
||||
DefaultExtension<T>,
|
||||
DeferExtension
|
||||
];
|
||||
@@ -64,14 +67,14 @@ type GenerateSubscription<
|
||||
: object;
|
||||
|
||||
export type Subscription<
|
||||
T extends XenApiRecord<string>,
|
||||
T extends RawObjectType,
|
||||
Options extends object,
|
||||
Extensions extends any[] = []
|
||||
> = GenerateSubscription<Options, Extensions> &
|
||||
GenerateSubscription<Options, DefaultExtensions<T>>;
|
||||
|
||||
export function createSubscribe<
|
||||
T extends XenApiRecord<string>,
|
||||
T extends RawObjectType,
|
||||
Extensions extends any[],
|
||||
Options extends object = SubscribeOptions<Extensions>
|
||||
>(builder: (options?: Options) => Subscription<T, Options, Extensions>) {
|
||||
@@ -82,59 +85,24 @@ export function createSubscribe<
|
||||
};
|
||||
}
|
||||
|
||||
export type RawTypeToObject = {
|
||||
Bond: never;
|
||||
Certificate: never;
|
||||
Cluster: never;
|
||||
Cluster_host: never;
|
||||
DR_task: never;
|
||||
Feature: never;
|
||||
GPU_group: never;
|
||||
PBD: never;
|
||||
PCI: never;
|
||||
PGPU: never;
|
||||
PIF: never;
|
||||
PIF_metrics: never;
|
||||
PUSB: never;
|
||||
PVS_cache_storage: never;
|
||||
PVS_proxy: never;
|
||||
PVS_server: never;
|
||||
PVS_site: never;
|
||||
SDN_controller: never;
|
||||
SM: never;
|
||||
SR: XenApiSr;
|
||||
USB_group: never;
|
||||
VBD: never;
|
||||
VBD_metrics: never;
|
||||
VDI: never;
|
||||
VGPU: never;
|
||||
VGPU_type: never;
|
||||
VIF: never;
|
||||
VIF_metrics: never;
|
||||
VLAN: never;
|
||||
VM: XenApiVm;
|
||||
VMPP: never;
|
||||
VMSS: never;
|
||||
VM_guest_metrics: XenApiVmGuestMetrics;
|
||||
VM_metrics: XenApiVmMetrics;
|
||||
VUSB: never;
|
||||
blob: never;
|
||||
console: XenApiConsole;
|
||||
crashdump: never;
|
||||
host: XenApiHost;
|
||||
host_cpu: never;
|
||||
host_crashdump: never;
|
||||
host_metrics: XenApiHostMetrics;
|
||||
host_patch: never;
|
||||
message: XenApiMessage;
|
||||
network: never;
|
||||
network_sriov: never;
|
||||
pool: XenApiPool;
|
||||
pool_patch: never;
|
||||
pool_update: never;
|
||||
role: never;
|
||||
secret: never;
|
||||
subject: never;
|
||||
task: XenApiTask;
|
||||
tunnel: never;
|
||||
};
|
||||
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;
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
|
||||
@@ -16,6 +18,8 @@ defineProps<{
|
||||
id: string;
|
||||
}>();
|
||||
|
||||
usePageTitleStore().setTitle(useI18n().t("not-found"));
|
||||
|
||||
const router = useRouter();
|
||||
</script>
|
||||
|
||||
|
||||
@@ -10,10 +10,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from "vue-router";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
const router = useRouter();
|
||||
usePageTitleStore().setTitle(useI18n().t("not-found"));
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { faBook } from "@fortawesome/free-solid-svg-icons";
|
||||
@@ -20,6 +21,8 @@ const title = computed(() => {
|
||||
|
||||
return `${currentRoute.value.meta.storyTitle} Story`;
|
||||
});
|
||||
|
||||
usePageTitleStore().setTitle(title);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped></style>
|
||||
|
||||
@@ -4,4 +4,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
usePageTitleStore().setTitle(useI18n().t("dashboard"));
|
||||
</script>
|
||||
|
||||
@@ -8,17 +8,22 @@
|
||||
import ObjectNotFoundWrapper from "@/components/ObjectNotFoundWrapper.vue";
|
||||
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 { watchEffect } from "vue";
|
||||
import { computed, watchEffect } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const { hasUuid, isReady, getByUuid } = useHostStore().subscribe();
|
||||
const route = useRoute();
|
||||
const uiStore = useUiStore();
|
||||
|
||||
const currentHost = computed(() =>
|
||||
getByUuid(route.params.uuid as XenApiHost["uuid"])
|
||||
);
|
||||
|
||||
watchEffect(() => {
|
||||
uiStore.currentHostOpaqueRef = getByUuid(
|
||||
route.params.uuid as XenApiHost["uuid"]
|
||||
)?.$ref;
|
||||
uiStore.currentHostOpaqueRef = currentHost.value?.$ref;
|
||||
});
|
||||
|
||||
usePageTitleStore().setObject(currentHost);
|
||||
</script>
|
||||
|
||||
@@ -4,4 +4,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
usePageTitleStore().setTitle(useI18n().t("alarms"));
|
||||
</script>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</UiCardGroup>
|
||||
</UiCardGroup>
|
||||
<UiCardGroup>
|
||||
<UiCardComingSoon class="tasks" title="Tasks" />
|
||||
<PoolDashboardTasks class="tasks" />
|
||||
</UiCardGroup>
|
||||
</div>
|
||||
</template>
|
||||
@@ -31,8 +31,24 @@ export const N_ITEMS = 5;
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PoolDashboardTasks from "@/components/pool/dashboard/PoolDashboardTasks.vue";
|
||||
import PoolCpuUsageChart from "@/components/pool/dashboard/cpuUsage/PoolCpuUsageChart.vue";
|
||||
import PoolDashboardCpuProvisioning from "@/components/pool/dashboard/PoolDashboardCpuProvisioning.vue";
|
||||
import PoolDashboardCpuUsage from "@/components/pool/dashboard/PoolDashboardCpuUsage.vue";
|
||||
import PoolDashboardNetworkChart from "@/components/pool/dashboard/PoolDashboardNetworkChart.vue";
|
||||
import PoolDashboardRamUsage from "@/components/pool/dashboard/PoolDashboardRamUsage.vue";
|
||||
import PoolDashboardStatus from "@/components/pool/dashboard/PoolDashboardStatus.vue";
|
||||
import PoolDashboardStorageUsage from "@/components/pool/dashboard/PoolDashboardStorageUsage.vue";
|
||||
import PoolDashboardRamUsageChart from "@/components/pool/dashboard/ramUsage/PoolRamUsage.vue";
|
||||
import UiCardComingSoon from "@/components/ui/UiCardComingSoon.vue";
|
||||
import UiCardGroup from "@/components/ui/UiCardGroup.vue";
|
||||
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,
|
||||
@@ -40,20 +56,9 @@ import {
|
||||
} from "@/types/injection-keys";
|
||||
import { differenceBy } from "lodash-es";
|
||||
import { provide, watch } from "vue";
|
||||
import UiCardComingSoon from "@/components/ui/UiCardComingSoon.vue";
|
||||
import PoolCpuUsageChart from "@/components/pool/dashboard/cpuUsage/PoolCpuUsageChart.vue";
|
||||
import PoolDashboardCpuUsage from "@/components/pool/dashboard/PoolDashboardCpuUsage.vue";
|
||||
import PoolDashboardNetworkChart from "@/components/pool/dashboard/PoolDashboardNetworkChart.vue";
|
||||
import PoolDashboardCpuProvisioning from "@/components/pool/dashboard/PoolDashboardCpuProvisioning.vue";
|
||||
import PoolDashboardRamUsage from "@/components/pool/dashboard/PoolDashboardRamUsage.vue";
|
||||
import PoolDashboardRamUsageChart from "@/components/pool/dashboard/ramUsage/PoolRamUsage.vue";
|
||||
import PoolDashboardStatus from "@/components/pool/dashboard/PoolDashboardStatus.vue";
|
||||
import PoolDashboardStorageUsage from "@/components/pool/dashboard/PoolDashboardStorageUsage.vue";
|
||||
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 { useHostStore } from "@/stores/host.store";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
usePageTitleStore().setTitle(useI18n().t("dashboard"));
|
||||
|
||||
const hostMetricsSubscription = useHostMetricsStore().subscribe();
|
||||
|
||||
@@ -124,6 +129,18 @@ runningVms.value.forEach((vm) => vmRegister(vm));
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.pool-dashboard-view {
|
||||
column-count: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1500px) {
|
||||
.pool-dashboard-view {
|
||||
column-count: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.alarms,
|
||||
.tasks {
|
||||
flex: 1;
|
||||
|
||||
@@ -4,4 +4,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
usePageTitleStore().setTitle(useI18n().t("hosts"));
|
||||
</script>
|
||||
|
||||
@@ -4,4 +4,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
usePageTitleStore().setTitle(useI18n().t("network"));
|
||||
</script>
|
||||
|
||||
@@ -10,6 +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 { usePageTitleStore } from "@/stores/page-title.store";
|
||||
|
||||
const { pool } = usePoolStore().subscribe();
|
||||
usePageTitleStore().setObject(pool);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped></style>
|
||||
|
||||
@@ -4,4 +4,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
usePageTitleStore().setTitle(useI18n().t("stats"));
|
||||
</script>
|
||||
|
||||
@@ -4,4 +4,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
usePageTitleStore().setTitle(useI18n().t("storage"));
|
||||
</script>
|
||||
|
||||
@@ -4,4 +4,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
usePageTitleStore().setTitle(useI18n().t("system"));
|
||||
</script>
|
||||
|
||||
@@ -4,58 +4,28 @@
|
||||
{{ $t("tasks") }}
|
||||
<UiCounter :value="pendingTasks.length" color="info" />
|
||||
</UiTitle>
|
||||
|
||||
<TasksTable :finished-tasks="finishedTasks" :pending-tasks="pendingTasks" />
|
||||
<UiCardSpinner v-if="!isReady" />
|
||||
</UiCard>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import TasksTable from "@/components/tasks/TasksTable.vue";
|
||||
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 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 { useTaskStore } from "@/stores/task.store";
|
||||
import { useTitle } from "@vueuse/core";
|
||||
import { computed } from "vue";
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { records, hasError } = useTaskStore().subscribe();
|
||||
const { pendingTasks, finishedTasks, isReady, hasError } =
|
||||
useTaskStore().subscribe();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { compareFn } = useCollectionSorter<XenApiTask>({
|
||||
initialSorts: ["-created"],
|
||||
});
|
||||
|
||||
const allTasks = useSortedCollection(records, compareFn);
|
||||
|
||||
const { predicate } = useCollectionFilter({
|
||||
initialFilters: ["!name_label:|(SR.scan host.call_plugin)", "status:pending"],
|
||||
});
|
||||
|
||||
const pendingTasks = useFilteredCollection<XenApiTask>(allTasks, predicate);
|
||||
|
||||
const finishedTasks = useArrayRemovedItemsHistory(
|
||||
allTasks,
|
||||
(task) => task.uuid,
|
||||
{
|
||||
limit: 50,
|
||||
onRemove: (tasks) =>
|
||||
tasks.map((task) => ({
|
||||
...task,
|
||||
finished: new Date().toISOString(),
|
||||
})),
|
||||
}
|
||||
);
|
||||
|
||||
useTitle(
|
||||
computed(() => t("task.page-title", { n: pendingTasks.value.length }))
|
||||
);
|
||||
const titleStore = usePageTitleStore();
|
||||
titleStore.setTitle(t("tasks"));
|
||||
titleStore.setCount(() => pendingTasks.value.length);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
@@ -38,6 +38,7 @@ import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import VmsActionsBar from "@/components/vm/VmsActionsBar.vue";
|
||||
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";
|
||||
@@ -46,9 +47,13 @@ import { storeToRefs } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const titleStore = usePageTitleStore();
|
||||
titleStore.setTitle(t("vms"));
|
||||
|
||||
const { records: vms } = useVmStore().subscribe();
|
||||
const { isMobile, isDesktop } = storeToRefs(useUiStore());
|
||||
const { t } = useI18n();
|
||||
|
||||
const filters: Filters = {
|
||||
name_label: { label: t("name"), type: "string" },
|
||||
@@ -62,6 +67,8 @@ const filters: Filters = {
|
||||
};
|
||||
|
||||
const selectedVmsRefs = ref([]);
|
||||
|
||||
titleStore.setCount(() => selectedVmsRefs.value.length);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
@@ -157,6 +157,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { computed } from "vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
@@ -181,7 +182,9 @@ import UiKeyValueRow from "@/components/ui/UiKeyValueRow.vue";
|
||||
|
||||
const xoLiteVersion = XO_LITE_VERSION;
|
||||
const xoLiteGitHead = XO_LITE_GIT_HEAD;
|
||||
const { locale } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
usePageTitleStore().setTitle(() => t("settings"));
|
||||
|
||||
const { pool } = usePoolStore().subscribe();
|
||||
const { getByOpaqueRef: getHost } = useHostStore().subscribe();
|
||||
|
||||
@@ -4,4 +4,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
usePageTitleStore().setTitle(useI18n().t("alarms"));
|
||||
</script>
|
||||
|
||||
@@ -1,21 +1,46 @@
|
||||
<template>
|
||||
<div v-if="!isReady">Loading...</div>
|
||||
<div v-else-if="!isVmRunning">Console is only available for running VMs.</div>
|
||||
<RemoteConsole
|
||||
v-else-if="vm && vmConsole"
|
||||
:location="vmConsole.location"
|
||||
:is-console-available="!isOperationsPending(vm, STOP_OPERATIONS)"
|
||||
/>
|
||||
<div :class="{ 'no-ui': !uiStore.hasUi }" class="vm-console-view">
|
||||
<div v-if="hasError">{{ $t("error-occurred") }}</div>
|
||||
<UiSpinner v-else-if="!isReady" class="spinner" />
|
||||
<div v-else-if="!isVmRunning" class="not-running">
|
||||
<div><img alt="" src="@/assets/monitor.svg" /></div>
|
||||
{{ $t("power-on-for-console") }}
|
||||
</div>
|
||||
<template v-else-if="vm && vmConsole">
|
||||
<RemoteConsole
|
||||
:is-console-available="isConsoleAvailable"
|
||||
:location="vmConsole.location"
|
||||
class="remote-console"
|
||||
/>
|
||||
<div class="open-in-new-window">
|
||||
<RouterLink
|
||||
v-if="uiStore.hasUi"
|
||||
:to="{ query: { ui: '0' } }"
|
||||
class="link"
|
||||
target="_blank"
|
||||
>
|
||||
<UiIcon :icon="faArrowUpRightFromSquare" />
|
||||
{{ $t("open-in-new-window") }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { POWER_STATE, VM_OPERATION, type XenApiVm } from "@/libs/xen-api";
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import RemoteConsole from "@/components/RemoteConsole.vue";
|
||||
import { useConsoleStore } from "@/stores/console.store";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
||||
import { isOperationsPending } from "@/libs/utils";
|
||||
import { POWER_STATE, VM_OPERATION, type XenApiVm } from "@/libs/xen-api";
|
||||
import { useConsoleStore } from "@/stores/console.store";
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { useUiStore } from "@/stores/ui.store";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const STOP_OPERATIONS = [
|
||||
VM_OPERATION.SHUTDOWN,
|
||||
@@ -27,15 +52,27 @@ const STOP_OPERATIONS = [
|
||||
VM_OPERATION.SUSPEND,
|
||||
];
|
||||
|
||||
usePageTitleStore().setTitle(useI18n().t("console"));
|
||||
|
||||
const route = useRoute();
|
||||
const uiStore = useUiStore();
|
||||
|
||||
const { isReady: isVmReady, getByUuid: getVmByUuid } = useVmStore().subscribe();
|
||||
const {
|
||||
isReady: isVmReady,
|
||||
getByUuid: getVmByUuid,
|
||||
hasError: hasVmError,
|
||||
} = useVmStore().subscribe();
|
||||
|
||||
const { isReady: isConsoleReady, getByOpaqueRef: getConsoleByOpaqueRef } =
|
||||
useConsoleStore().subscribe();
|
||||
const {
|
||||
isReady: isConsoleReady,
|
||||
getByOpaqueRef: getConsoleByOpaqueRef,
|
||||
hasError: hasConsoleError,
|
||||
} = useConsoleStore().subscribe();
|
||||
|
||||
const isReady = computed(() => isVmReady.value && isConsoleReady.value);
|
||||
|
||||
const hasError = computed(() => hasVmError.value || hasConsoleError.value);
|
||||
|
||||
const vm = computed(() => getVmByUuid(route.params.uuid as XenApiVm["uuid"]));
|
||||
|
||||
const isVmRunning = computed(
|
||||
@@ -51,4 +88,74 @@ const vmConsole = computed(() => {
|
||||
|
||||
return getConsoleByOpaqueRef(consoleOpaqueRef);
|
||||
});
|
||||
|
||||
const isConsoleAvailable = computed(
|
||||
() =>
|
||||
vm.value !== undefined && !isOperationsPending(vm.value, STOP_OPERATIONS)
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.vm-console-view {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: calc(100% - 14.5rem);
|
||||
|
||||
&.no-ui {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
color: var(--color-extra-blue-base);
|
||||
display: flex;
|
||||
margin: auto;
|
||||
width: 10rem;
|
||||
height: 10rem;
|
||||
}
|
||||
|
||||
.remote-console {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.not-running,
|
||||
.not-available {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 4rem;
|
||||
color: var(--color-extra-blue-base);
|
||||
font-size: 3.6rem;
|
||||
}
|
||||
|
||||
.open-in-new-window {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
overflow: hidden;
|
||||
|
||||
& > .link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background-color: var(--color-extra-blue-base);
|
||||
color: var(--color-blue-scale-500);
|
||||
text-decoration: none;
|
||||
padding: 1.5rem;
|
||||
font-size: 1.6rem;
|
||||
border-radius: 0 0 0 0.8rem;
|
||||
white-space: nowrap;
|
||||
transform: translateX(calc(100% - 4.5rem));
|
||||
transition: transform 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,4 +4,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
usePageTitleStore().setTitle(useI18n().t("dashboard"));
|
||||
</script>
|
||||
|
||||
@@ -4,4 +4,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
usePageTitleStore().setTitle(useI18n().t("network"));
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<template>
|
||||
<ObjectNotFoundWrapper :is-ready="isReady" :uuid-checker="hasUuid">
|
||||
<VmHeader />
|
||||
<VmTabBar :uuid="vm!.uuid" />
|
||||
<template v-if="uiStore.hasUi">
|
||||
<VmHeader />
|
||||
<VmTabBar :uuid="vm!.uuid" />
|
||||
</template>
|
||||
<RouterView />
|
||||
</ObjectNotFoundWrapper>
|
||||
</template>
|
||||
@@ -11,6 +13,7 @@ import ObjectNotFoundWrapper from "@/components/ObjectNotFoundWrapper.vue";
|
||||
import VmHeader from "@/components/vm/VmHeader.vue";
|
||||
import VmTabBar from "@/components/vm/VmTabBar.vue";
|
||||
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";
|
||||
@@ -22,4 +25,5 @@ const { getByUuid, hasUuid, isReady } = useVmStore().subscribe();
|
||||
const uiStore = useUiStore();
|
||||
const vm = computed(() => getByUuid(route.params.uuid as XenApiVm["uuid"]));
|
||||
whenever(vm, (vm) => (uiStore.currentHostOpaqueRef = vm.resident_on));
|
||||
usePageTitleStore().setObject(vm);
|
||||
</script>
|
||||
|
||||
@@ -4,4 +4,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
usePageTitleStore().setTitle(useI18n().t("stats"));
|
||||
</script>
|
||||
|
||||
@@ -4,4 +4,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
usePageTitleStore().setTitle(useI18n().t("storage"));
|
||||
</script>
|
||||
|
||||
@@ -4,4 +4,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
usePageTitleStore().setTitle(useI18n().t("system"));
|
||||
</script>
|
||||
|
||||
@@ -4,4 +4,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
|
||||
import { usePageTitleStore } from "@/stores/page-title.store";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
usePageTitleStore().setTitle(useI18n().t("tasks"));
|
||||
</script>
|
||||
|
||||
@@ -11,10 +11,18 @@ const runHook = async (emitter, hook, onResult = noop) => {
|
||||
const listeners = emitter.listeners(hook)
|
||||
await Promise.all(
|
||||
listeners.map(async listener => {
|
||||
const handle = setInterval(() => {
|
||||
warn(
|
||||
`${hook} ${listener.name || 'anonymous'} listener is still running`,
|
||||
listener.name ? undefined : { source: listener.toString() }
|
||||
)
|
||||
}, 5e3)
|
||||
try {
|
||||
onResult(await listener.call(emitter))
|
||||
} catch (error) {
|
||||
warn(`${hook} failure`, { error })
|
||||
} finally {
|
||||
clearInterval(handle)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "0.10.2",
|
||||
"version": "0.11.0",
|
||||
"engines": {
|
||||
"node": ">=15.6"
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"rimraf": "^5.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/read-chunk": "^1.1.1"
|
||||
"@vates/read-chunk": "^1.2.0"
|
||||
},
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^2.2.0",
|
||||
"@vates/read-chunk": "^1.1.1",
|
||||
"@vates/read-chunk": "^1.2.0",
|
||||
"ansi-colors": "^4.1.1",
|
||||
"app-conf": "^2.3.0",
|
||||
"content-type": "^1.0.4",
|
||||
|
||||
@@ -58,7 +58,7 @@ const ndJsonStream = asyncIteratorToStream(async function* (responseId, iterable
|
||||
|
||||
export default class Api {
|
||||
constructor(app, { appVersion, httpServer }) {
|
||||
this._ajv = new Ajv({ allErrors: true })
|
||||
this._ajv = new Ajv({ allErrors: true, useDefaults: true })
|
||||
this._methods = { __proto__: null }
|
||||
const PREFIX = '/api/v1'
|
||||
const router = new Router({ prefix: PREFIX }).post('/', async ctx => {
|
||||
|
||||
@@ -174,12 +174,15 @@ export default class Backups {
|
||||
},
|
||||
],
|
||||
fetchPartitionFiles: [
|
||||
({ disk: diskId, remote, partition: partitionId, paths }) =>
|
||||
Disposable.use(this.getAdapter(remote), adapter => adapter.fetchPartitionFiles(diskId, partitionId, paths)),
|
||||
({ disk: diskId, format, remote, partition: partitionId, paths }) =>
|
||||
Disposable.use(this.getAdapter(remote), adapter =>
|
||||
adapter.fetchPartitionFiles(diskId, partitionId, paths, format)
|
||||
),
|
||||
{
|
||||
description: 'fetch files from partition',
|
||||
params: {
|
||||
disk: { type: 'string' },
|
||||
format: { type: 'string', default: 'zip' },
|
||||
partition: { type: 'string', optional: true },
|
||||
paths: { type: 'array', items: { type: 'string' } },
|
||||
remote: { type: 'object' },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "@xen-orchestra/proxy",
|
||||
"version": "0.26.29",
|
||||
"version": "0.26.30",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "XO Proxy used to remotely execute backup jobs",
|
||||
"keywords": [
|
||||
@@ -32,18 +32,18 @@
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@vates/disposable": "^0.1.4",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.39.0",
|
||||
"@xen-orchestra/backups": "^0.40.0",
|
||||
"@xen-orchestra/fs": "^4.0.1",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"@xen-orchestra/mixin": "^0.1.0",
|
||||
"@xen-orchestra/mixins": "^0.10.2",
|
||||
"@xen-orchestra/mixins": "^0.11.0",
|
||||
"@xen-orchestra/self-signed": "^0.1.3",
|
||||
"@xen-orchestra/xapi": "^2.2.1",
|
||||
"@xen-orchestra/xapi": "^3.0.0",
|
||||
"ajv": "^8.0.3",
|
||||
"app-conf": "^2.3.0",
|
||||
"async-iterator-to-stream": "^1.1.0",
|
||||
"fs-extra": "^11.1.0",
|
||||
"get-stream": "^6.0.0",
|
||||
"get-stream": "^7.0.1",
|
||||
"getopts": "^2.2.3",
|
||||
"golike-defer": "^0.5.1",
|
||||
"http-server-plus": "^1.0.0",
|
||||
@@ -60,7 +60,7 @@
|
||||
"source-map-support": "^0.5.16",
|
||||
"stoppable": "^1.0.6",
|
||||
"xdg-basedir": "^5.1.0",
|
||||
"xen-api": "^1.3.3",
|
||||
"xen-api": "^1.3.4",
|
||||
"xo-common": "^0.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -48,7 +48,7 @@ export default class VhdEsxiCowd extends VhdAbstract {
|
||||
|
||||
// depending on the paramters we also look into the parent data
|
||||
return (
|
||||
this.#grainDirectory.readInt32LE(blockId * 4) !== 0 ||
|
||||
this.#grainDirectory.readUInt32LE(blockId * 4) !== 0 ||
|
||||
(this.#lookMissingBlockInParent && this.#parentVhd.containsBlock(blockId))
|
||||
)
|
||||
}
|
||||
@@ -61,14 +61,14 @@ export default class VhdEsxiCowd extends VhdAbstract {
|
||||
const buffer = await this.#read(0, 2048)
|
||||
|
||||
strictEqual(buffer.slice(0, 4).toString('ascii'), 'COWD')
|
||||
strictEqual(buffer.readInt32LE(4), 1) // version
|
||||
strictEqual(buffer.readInt32LE(8), 3) // flags
|
||||
const numSectors = buffer.readInt32LE(12)
|
||||
const grainSize = buffer.readInt32LE(16)
|
||||
strictEqual(buffer.readUInt32LE(4), 1) // version
|
||||
strictEqual(buffer.readUInt32LE(8), 3) // flags
|
||||
const numSectors = buffer.readUInt32LE(12)
|
||||
const grainSize = buffer.readUInt32LE(16)
|
||||
strictEqual(grainSize, 1) // 1 grain should be 1 sector long
|
||||
strictEqual(buffer.readInt32LE(20), 4) // grain directory position in sectors
|
||||
strictEqual(buffer.readUInt32LE(20), 4) // grain directory position in sectors
|
||||
|
||||
const nbGrainDirectoryEntries = buffer.readInt32LE(24)
|
||||
const nbGrainDirectoryEntries = buffer.readUInt32LE(24)
|
||||
strictEqual(nbGrainDirectoryEntries, Math.ceil(numSectors / 4096))
|
||||
const size = numSectors * 512
|
||||
// a grain directory entry contains the address of a grain table
|
||||
@@ -90,7 +90,7 @@ export default class VhdEsxiCowd extends VhdAbstract {
|
||||
// we're lucky : a grain address can address exacty a full block
|
||||
async readBlock(blockId) {
|
||||
notEqual(this.#grainDirectory, undefined, 'grainDirectory is not loaded')
|
||||
const sectorOffset = this.#grainDirectory.readInt32LE(blockId * 4)
|
||||
const sectorOffset = this.#grainDirectory.readUInt32LE(blockId * 4)
|
||||
|
||||
const buffer = (await this.#parentVhd.readBlock(blockId)).buffer
|
||||
|
||||
@@ -137,7 +137,7 @@ export default class VhdEsxiCowd extends VhdAbstract {
|
||||
}
|
||||
|
||||
for (let i = 0; i < graintable.length / 4; i++) {
|
||||
const grainOffset = graintable.readInt32LE(i * 4)
|
||||
const grainOffset = graintable.readUInt32LE(i * 4)
|
||||
if (grainOffset === 0) {
|
||||
// the content from parent : it is already in buffer
|
||||
await changeRange()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import _computeGeometryForSize from 'vhd-lib/_computeGeometryForSize.js'
|
||||
import { createFooter, createHeader } from 'vhd-lib/_createFooterHeader.js'
|
||||
import { DISK_TYPES, FOOTER_SIZE } from 'vhd-lib/_constants.js'
|
||||
import { readChunk } from '@vates/read-chunk'
|
||||
import { readChunkStrict, skipStrict } from '@vates/read-chunk'
|
||||
import { Task } from '@vates/task'
|
||||
import { unpackFooter, unpackHeader } from 'vhd-lib/Vhd/_utils.js'
|
||||
import { VhdAbstract } from 'vhd-lib'
|
||||
@@ -21,6 +21,10 @@ export default class VhdEsxiRaw extends VhdAbstract {
|
||||
#header
|
||||
#footer
|
||||
|
||||
#streamOffset = 0
|
||||
#stream
|
||||
#reading = false
|
||||
|
||||
static async open(esxi, datastore, path, opts) {
|
||||
const vhd = new VhdEsxiRaw(esxi, datastore, path, opts)
|
||||
await vhd.readHeaderAndFooter()
|
||||
@@ -49,10 +53,10 @@ export default class VhdEsxiRaw extends VhdAbstract {
|
||||
|
||||
this.#header = unpackHeader(createHeader(length / VHD_BLOCK_LENGTH))
|
||||
const geometry = _computeGeometryForSize(length)
|
||||
const actualSize = geometry.actualSize
|
||||
|
||||
this.#footer = unpackFooter(
|
||||
createFooter(actualSize, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DYNAMIC)
|
||||
// length can be smaller than disk capacity due to alignment to head/cylinder/sector
|
||||
createFooter(length, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DYNAMIC)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,12 +68,65 @@ export default class VhdEsxiRaw extends VhdAbstract {
|
||||
return this.#bat.has(blockId)
|
||||
}
|
||||
|
||||
async readBlock(blockId) {
|
||||
async #readChunk(start, length) {
|
||||
if (this.#reading) {
|
||||
throw new Error('reading must be done sequentially')
|
||||
}
|
||||
try {
|
||||
this.#reading = true
|
||||
if (this.#stream !== undefined) {
|
||||
// stream is too far ahead or to far behind
|
||||
if (this.#streamOffset > start || this.#streamOffset + VHD_BLOCK_LENGTH < start) {
|
||||
this.#stream.destroy()
|
||||
this.#stream = undefined
|
||||
this.#streamOffset = 0
|
||||
}
|
||||
}
|
||||
// no stream
|
||||
if (this.#stream === undefined) {
|
||||
const end = this.footer.currentSize - 1
|
||||
const res = await this.#esxi.download(this.#datastore, this.#path, `${start}-${end}`)
|
||||
this.#stream = res.body
|
||||
this.#streamOffset = start
|
||||
}
|
||||
|
||||
// stream a little behind
|
||||
if (this.#streamOffset < start) {
|
||||
await skipStrict(this.#stream, start - this.#streamOffset)
|
||||
this.#streamOffset = start
|
||||
}
|
||||
|
||||
// really read data
|
||||
this.#streamOffset += length
|
||||
const data = await readChunkStrict(this.#stream, length)
|
||||
return data
|
||||
} catch (error) {
|
||||
error.start = start
|
||||
error.length = length
|
||||
error.streamLength = this.footer.currentSize
|
||||
this.#stream?.destroy()
|
||||
this.#stream = undefined
|
||||
this.#streamOffset = 0
|
||||
throw error
|
||||
} finally {
|
||||
this.#reading = false
|
||||
}
|
||||
}
|
||||
|
||||
async #readBlock(blockId) {
|
||||
const start = blockId * VHD_BLOCK_LENGTH
|
||||
const end = (blockId + 1) * VHD_BLOCK_LENGTH - 1
|
||||
let length = VHD_BLOCK_LENGTH
|
||||
let partial = false
|
||||
if (start + length > this.footer.currentSize) {
|
||||
length = this.footer.currentSize - start
|
||||
partial = true
|
||||
}
|
||||
|
||||
const data = await (await this.#esxi.download(this.#datastore, this.#path, `${start}-${end}`)).buffer()
|
||||
let data = await this.#readChunk(start, length)
|
||||
|
||||
if (partial) {
|
||||
data = Buffer.concat([data, Buffer.alloc(VHD_BLOCK_LENGTH - data.length)])
|
||||
}
|
||||
const bitmap = Buffer.alloc(512, 255)
|
||||
return {
|
||||
id: blockId,
|
||||
@@ -79,28 +136,44 @@ export default class VhdEsxiRaw extends VhdAbstract {
|
||||
}
|
||||
}
|
||||
|
||||
async readBlock(blockId) {
|
||||
let tries = 5
|
||||
let lastError
|
||||
while (tries > 0) {
|
||||
try {
|
||||
const res = await this.#readBlock(blockId)
|
||||
return res
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
lastError.blockId = blockId
|
||||
console.warn('got error , will retry in 2seconds', lastError)
|
||||
}
|
||||
await new Promise(resolve => setTimeout(() => resolve(), 2000))
|
||||
tries--
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
// this will read all the disk once to check which block contains data, it can take a long time to execute depending on the network speed
|
||||
async readBlockAllocationTable() {
|
||||
if (!this.#thin) {
|
||||
// fast path : is we do not use thin mode, the BAT is full
|
||||
return
|
||||
}
|
||||
const res = await this.#esxi.download(this.#datastore, this.#path)
|
||||
const length = res.headers.get('content-length')
|
||||
const stream = res.body
|
||||
const empty = Buffer.alloc(VHD_BLOCK_LENGTH, 0)
|
||||
let pos = 0
|
||||
this.#bat = new Set()
|
||||
let nextChunkLength = Math.min(VHD_BLOCK_LENGTH, length)
|
||||
Task.set('total', length / VHD_BLOCK_LENGTH)
|
||||
let nextChunkLength = Math.min(VHD_BLOCK_LENGTH, this.footer.currentSize)
|
||||
Task.set('total', this.footer.currentSize / VHD_BLOCK_LENGTH)
|
||||
const progress = setInterval(() => {
|
||||
Task.set('progress', Math.round((pos * 100) / length))
|
||||
console.log('reading blocks', pos / VHD_BLOCK_LENGTH, '/', length / VHD_BLOCK_LENGTH)
|
||||
Task.set('progress', Math.round((pos * 100) / this.footer.currentSize))
|
||||
console.log('reading blocks', pos / VHD_BLOCK_LENGTH, '/', this.footer.currentSize / VHD_BLOCK_LENGTH)
|
||||
}, 30 * 1000)
|
||||
|
||||
while (nextChunkLength > 0) {
|
||||
try {
|
||||
const chunk = await readChunk(stream, nextChunkLength)
|
||||
const chunk = await this.#readChunk(pos, nextChunkLength)
|
||||
let isEmpty
|
||||
if (nextChunkLength === VHD_BLOCK_LENGTH) {
|
||||
isEmpty = empty.equals(chunk)
|
||||
@@ -112,15 +185,28 @@ export default class VhdEsxiRaw extends VhdAbstract {
|
||||
this.#bat.add(pos / VHD_BLOCK_LENGTH)
|
||||
}
|
||||
pos += VHD_BLOCK_LENGTH
|
||||
nextChunkLength = Math.min(VHD_BLOCK_LENGTH, length - pos)
|
||||
nextChunkLength = Math.min(VHD_BLOCK_LENGTH, this.footer.currentSize - pos)
|
||||
} catch (error) {
|
||||
clearInterval(progress)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
console.log('BAT reading done, remaining ', this.#bat.size, '/', Math.ceil(length / VHD_BLOCK_LENGTH))
|
||||
console.log(
|
||||
'BAT reading done, remaining ',
|
||||
this.#bat.size,
|
||||
'/',
|
||||
Math.ceil(this.footer.currentSize / VHD_BLOCK_LENGTH)
|
||||
)
|
||||
clearInterval(progress)
|
||||
}
|
||||
|
||||
rawContent() {
|
||||
return this.#esxi.download(this.#datastore, this.#path).then(res => {
|
||||
const stream = res.body
|
||||
stream.length = this.footer.currentSize
|
||||
return stream
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* eslint-enable no-console */
|
||||
|
||||
@@ -1,18 +1,54 @@
|
||||
import _computeGeometryForSize from 'vhd-lib/_computeGeometryForSize.js'
|
||||
import { createFooter, createHeader } from 'vhd-lib/_createFooterHeader.js'
|
||||
import { FOOTER_SIZE } from 'vhd-lib/_constants.js'
|
||||
import { DISK_TYPES, FOOTER_SIZE } from 'vhd-lib/_constants.js'
|
||||
import { notEqual, strictEqual } from 'node:assert'
|
||||
import { unpackFooter, unpackHeader } from 'vhd-lib/Vhd/_utils.js'
|
||||
import { VhdAbstract } from 'vhd-lib'
|
||||
|
||||
// from https://github.com/qemu/qemu/commit/98eb9733f4cf2eeab6d12db7e758665d2fd5367b#
|
||||
// one big difference with the other versions of VMDK is that the grain tables are actually sparse, they are pre-allocated but not used in grain order,
|
||||
// so we have to read the grain directory to know where to find the grain tables
|
||||
|
||||
function readInt64(buffer, index) {
|
||||
const n = buffer.readBigInt64LE(index * 8 /* size of an int64 in bytes */)
|
||||
if (n > Number.MAX_SAFE_INTEGER) {
|
||||
const SE_SPARSE_DIR_NON_ALLOCATED = 0
|
||||
const SE_SPARSE_DIR_ALLOCATED = 1
|
||||
|
||||
const SE_SPARSE_GRAIN_NON_ALLOCATED = 0 // check in parent
|
||||
const SE_SPARSE_GRAIN_UNMAPPED = 1 // grain has been unmapped, but index of previous grain still readable for reclamation
|
||||
const SE_SPARSE_GRAIN_ZERO = 2
|
||||
const SE_SPARSE_GRAIN_ALLOCATED = 3
|
||||
|
||||
const VHD_BLOCK_SIZE_BYTES = 2 * 1024 * 1024
|
||||
const GRAIN_SIZE_BYTES = 4 * 1024
|
||||
const GRAIN_TABLE_COUNT = 4 * 1024
|
||||
|
||||
const ones = n => (1n << BigInt(n)) - 1n
|
||||
|
||||
function asNumber(n) {
|
||||
if (n > Number.MAX_SAFE_INTEGER)
|
||||
throw new Error(`can't handle ${n} ${Number.MAX_SAFE_INTEGER} ${n & 0x00000000ffffffffn}`)
|
||||
}
|
||||
return +n
|
||||
return Number(n)
|
||||
}
|
||||
|
||||
const readInt64 = (buffer, index) => asNumber(buffer.readBigInt64LE(index * 8))
|
||||
|
||||
/**
|
||||
* @returns {{topNibble: number, low60: bigint}} topNibble is the first 4 bits of the 64 bits entry, indexPart is the remaining 60 bits
|
||||
*/
|
||||
function readTaggedEntry(buffer, index) {
|
||||
const entry = buffer.readBigInt64LE(index * 8)
|
||||
return { topNibble: Number(entry >> 60n), low60: entry & ones(60) }
|
||||
}
|
||||
|
||||
function readSeSparseDir(buffer, index) {
|
||||
const { topNibble, low60 } = readTaggedEntry(buffer, index)
|
||||
return { type: topNibble, tableIndex: asNumber(low60) }
|
||||
}
|
||||
|
||||
function readSeSparseTable(buffer, index) {
|
||||
const { topNibble, low60 } = readTaggedEntry(buffer, index)
|
||||
// https://lists.gnu.org/archive/html/qemu-block/2019-06/msg00934.html
|
||||
const topIndexPart = low60 >> 48n // bring the top 12 bits down
|
||||
const bottomIndexPart = (low60 & ones(48)) << 12n // bring the bottom 48 bits up
|
||||
return { type: topNibble, grainIndex: asNumber(bottomIndexPart | topIndexPart) }
|
||||
}
|
||||
|
||||
export default class VhdEsxiSeSparse extends VhdAbstract {
|
||||
@@ -25,27 +61,22 @@ export default class VhdEsxiSeSparse extends VhdAbstract {
|
||||
#header
|
||||
#footer
|
||||
|
||||
#grainDirectory
|
||||
// as we will read all grain with data with load everything in memory
|
||||
// in theory , that can be 512MB of data for a 2TB fully allocated
|
||||
// but our use case is to transfer a relatively small diff
|
||||
// and random access is expensive in HTTP, and migration is a one time cors
|
||||
// so let's go with naive approach, and future me will have to handle a more
|
||||
// clever approach if necessary
|
||||
// grain at zero won't be stored
|
||||
#grainIndex // Map blockId => []
|
||||
|
||||
#grainMap = new Map()
|
||||
|
||||
#grainSize
|
||||
#grainTableSize
|
||||
#grainTableOffset
|
||||
#grainOffset
|
||||
#grainDirOffsetBytes
|
||||
#grainDirSizeBytes
|
||||
#grainTableOffsetBytes
|
||||
#grainOffsetBytes
|
||||
|
||||
static async open(esxi, datastore, path, parentVhd, opts) {
|
||||
const vhd = new VhdEsxiSeSparse(esxi, datastore, path, parentVhd, opts)
|
||||
await vhd.readHeaderAndFooter()
|
||||
return vhd
|
||||
}
|
||||
|
||||
get path() {
|
||||
return this.#path
|
||||
}
|
||||
constructor(esxi, datastore, path, parentVhd, { lookMissingBlockInParent = true } = {}) {
|
||||
super()
|
||||
this.#esxi = esxi
|
||||
@@ -63,156 +94,149 @@ export default class VhdEsxiSeSparse extends VhdAbstract {
|
||||
return this.#footer
|
||||
}
|
||||
|
||||
async #readGrain(start, length = 4 * 1024) {
|
||||
return (await this.#esxi.download(this.#datastore, this.#path, `${start}-${start + length - 1}`)).buffer()
|
||||
}
|
||||
|
||||
containsBlock(blockId) {
|
||||
notEqual(this.#grainDirectory, undefined, "bat must be loaded to use contain blocks'")
|
||||
|
||||
// a grain table is 4096 entries of 4KB
|
||||
// a grain table cover 8 vhd blocks
|
||||
// grain table always exists in sespars
|
||||
|
||||
// depending on the paramters we also look into the parent data
|
||||
notEqual(this.#grainIndex, undefined, "bat must be loaded to use contain blocks'")
|
||||
return (
|
||||
this.#grainDirectory.readInt32LE(blockId * 4) !== 0 ||
|
||||
this.#grainIndex.get(blockId) !== undefined ||
|
||||
(this.#lookMissingBlockInParent && this.#parentVhd.containsBlock(blockId))
|
||||
)
|
||||
}
|
||||
|
||||
async #read(start, end) {
|
||||
return (await this.#esxi.download(this.#datastore, this.#path, `${start}-${end}`)).buffer()
|
||||
async #read(start, length) {
|
||||
const buffer = await (
|
||||
await this.#esxi.download(this.#datastore, this.#path, `${start}-${start + length - 1}`)
|
||||
).buffer()
|
||||
strictEqual(buffer.length, length)
|
||||
return buffer
|
||||
}
|
||||
|
||||
async readHeaderAndFooter() {
|
||||
const buffer = await this.#read(0, 2048)
|
||||
strictEqual(buffer.readBigInt64LE(0), 0xcafebaben)
|
||||
const vmdkHeaderBuffer = await this.#read(0, 2048)
|
||||
|
||||
strictEqual(readInt64(buffer, 1), 0x200000001) // version 2.1
|
||||
strictEqual(vmdkHeaderBuffer.readBigInt64LE(0), 0xcafebaben)
|
||||
strictEqual(readInt64(vmdkHeaderBuffer, 1), 0x200000001) // version 2.1
|
||||
|
||||
const capacity = readInt64(buffer, 2)
|
||||
const grain_size = readInt64(buffer, 3)
|
||||
this.#grainDirOffsetBytes = readInt64(vmdkHeaderBuffer, 16) * 512
|
||||
// console.log('grainDirOffsetBytes', this.#grainDirOffsetBytes)
|
||||
this.#grainDirSizeBytes = readInt64(vmdkHeaderBuffer, 17) * 512
|
||||
// console.log('grainDirSizeBytes', this.#grainDirSizeBytes)
|
||||
|
||||
const grain_tables_offset = readInt64(buffer, 18)
|
||||
const grain_tables_size = readInt64(buffer, 19)
|
||||
this.#grainOffset = readInt64(buffer, 24)
|
||||
const grainSizeSectors = readInt64(vmdkHeaderBuffer, 3)
|
||||
const grainSizeBytes = grainSizeSectors * 512 // 8 sectors = 4KB default
|
||||
strictEqual(grainSizeBytes, GRAIN_SIZE_BYTES) // we only support default grain size
|
||||
|
||||
this.#grainSize = grain_size * 512 // 8 sectors / 4KB default
|
||||
this.#grainTableOffset = grain_tables_offset * 512
|
||||
this.#grainTableSize = grain_tables_size * 512
|
||||
this.#grainTableOffsetBytes = readInt64(vmdkHeaderBuffer, 18) * 512
|
||||
// console.log('grainTableOffsetBytes', this.#grainTableOffsetBytes)
|
||||
|
||||
const size = capacity * grain_size * 512
|
||||
this.#header = unpackHeader(createHeader(Math.ceil(size / (4096 * 512))))
|
||||
const geometry = _computeGeometryForSize(size)
|
||||
const actualSize = geometry.actualSize
|
||||
const grainTableCount = (readInt64(vmdkHeaderBuffer, 4) * 512) / 8 // count is the number of 64b entries in each tables
|
||||
// console.log('grainTableCount', grainTableCount)
|
||||
strictEqual(grainTableCount, GRAIN_TABLE_COUNT) // we only support tables of 4096 entries (default)
|
||||
|
||||
this.#grainOffsetBytes = readInt64(vmdkHeaderBuffer, 24) * 512
|
||||
// console.log('grainOffsetBytes', this.#grainOffsetBytes)
|
||||
|
||||
const sizeBytes = readInt64(vmdkHeaderBuffer, 2) * 512
|
||||
// console.log('sizeBytes', sizeBytes)
|
||||
|
||||
const nbBlocks = Math.ceil(sizeBytes / VHD_BLOCK_SIZE_BYTES)
|
||||
this.#header = unpackHeader(createHeader(nbBlocks))
|
||||
const geometry = _computeGeometryForSize(sizeBytes)
|
||||
this.#footer = unpackFooter(
|
||||
createFooter(actualSize, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, this.#parentVhd.footer.diskType)
|
||||
createFooter(sizeBytes, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DYNAMIC)
|
||||
)
|
||||
}
|
||||
|
||||
async readBlockAllocationTable() {
|
||||
const CHUNK_SIZE = 64 * 512
|
||||
this.#grainIndex = new Map()
|
||||
|
||||
strictEqual(this.#grainTableSize % CHUNK_SIZE, 0)
|
||||
|
||||
for (let chunkIndex = 0, grainIndex = 0; chunkIndex < this.#grainTableSize / CHUNK_SIZE; chunkIndex++) {
|
||||
process.stdin.write('.')
|
||||
const start = chunkIndex * CHUNK_SIZE + this.#grainTableOffset
|
||||
const end = start + 4096 * 8 - 1
|
||||
const buffer = await this.#read(start, end)
|
||||
for (let indexInChunk = 0; indexInChunk < 4096; indexInChunk++) {
|
||||
const entry = buffer.readBigInt64LE(indexInChunk * 8)
|
||||
switch (entry) {
|
||||
case 0n: // not allocated, go to parent
|
||||
break
|
||||
case 1n: // unmapped
|
||||
break
|
||||
}
|
||||
if (entry > 3n) {
|
||||
this.#grainMap.set(grainIndex)
|
||||
grainIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// read grain directory and the grain tables
|
||||
const nbBlocks = this.header.maxTableEntries
|
||||
this.#grainDirectory = await this.#read(2048 /* header length */, 2048 + nbBlocks * 4 - 1)
|
||||
}
|
||||
|
||||
// we're lucky : a grain address can address exacty a full block
|
||||
async readBlock(blockId) {
|
||||
notEqual(this.#grainDirectory, undefined, 'grainDirectory is not loaded')
|
||||
const sectorOffset = this.#grainDirectory.readInt32LE(blockId * 4)
|
||||
|
||||
const buffer = (await this.#parentVhd.readBlock(blockId)).buffer
|
||||
|
||||
if (sectorOffset === 0) {
|
||||
strictEqual(this.#lookMissingBlockInParent, true, "shouldn't have empty block in a delta alone")
|
||||
return {
|
||||
id: blockId,
|
||||
bitmap: buffer.slice(0, 512),
|
||||
data: buffer.slice(512),
|
||||
buffer,
|
||||
}
|
||||
}
|
||||
const offset = sectorOffset * 512
|
||||
|
||||
const graintable = await this.#read(offset, offset + 4096 * 4 /* grain table length */ - 1)
|
||||
|
||||
strictEqual(graintable.length, 4096 * 4)
|
||||
// we have no guaranty that data are order or contiguous
|
||||
// let's construct ranges to limit the number of queries
|
||||
let rangeStart, offsetStart, offsetEnd
|
||||
|
||||
const changeRange = async (index, offset) => {
|
||||
if (offsetStart !== undefined) {
|
||||
// if there was a
|
||||
if (offset === offsetEnd) {
|
||||
offsetEnd++
|
||||
return
|
||||
}
|
||||
const grains = await this.#read(offsetStart * 512, offsetEnd * 512 - 1)
|
||||
grains.copy(buffer, (rangeStart + 1) /* block bitmap */ * 512)
|
||||
}
|
||||
if (offset) {
|
||||
// we're at the beginning of a range present in the file
|
||||
rangeStart = index
|
||||
offsetStart = offset
|
||||
offsetEnd = offset + 1
|
||||
} else {
|
||||
// we're at the beginning of a range from the parent or empty
|
||||
rangeStart = undefined
|
||||
offsetStart = undefined
|
||||
offsetEnd = undefined
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < graintable.length / 4; i++) {
|
||||
const grainOffset = graintable.readInt32LE(i * 4)
|
||||
if (grainOffset === 0) {
|
||||
await changeRange()
|
||||
// from parent
|
||||
const tableSizeBytes = GRAIN_TABLE_COUNT * 8
|
||||
const grainDirBuffer = await this.#read(this.#grainDirOffsetBytes, this.#grainDirSizeBytes)
|
||||
// read the grain dir ( first level )
|
||||
for (let grainDirIndex = 0; grainDirIndex < grainDirBuffer.length / 8; grainDirIndex++) {
|
||||
const { type: grainDirType, tableIndex } = readSeSparseDir(grainDirBuffer, grainDirIndex)
|
||||
if (grainDirType === SE_SPARSE_DIR_NON_ALLOCATED) {
|
||||
// no grain table allocated at all in this grain dir
|
||||
continue
|
||||
}
|
||||
if (grainOffset === 1) {
|
||||
await changeRange()
|
||||
// this is a emptied grain, no data, don't look into parent
|
||||
buffer.fill(0, (i + 1) /* block bitmap */ * 512)
|
||||
strictEqual(grainDirType, SE_SPARSE_DIR_ALLOCATED)
|
||||
// read the corresponding grain table ( second level )
|
||||
const grainTableBuffer = await this.#read(
|
||||
this.#grainTableOffsetBytes + tableIndex * tableSizeBytes,
|
||||
tableSizeBytes
|
||||
)
|
||||
// offset in bytes if >0, grainType if <=0
|
||||
let grainOffsets = []
|
||||
let blockId = grainDirIndex * 8
|
||||
|
||||
const addGrain = val => {
|
||||
grainOffsets.push(val)
|
||||
// 4096 block of 4Kb per dir entry =>16MB/grain dir
|
||||
// 1 block = 2MB
|
||||
// 512 grain => 1 block
|
||||
// 8 block per dir entry
|
||||
if (grainOffsets.length === 512) {
|
||||
this.#grainIndex.set(blockId, grainOffsets)
|
||||
grainOffsets = []
|
||||
blockId++
|
||||
}
|
||||
}
|
||||
|
||||
if (grainOffset > 1) {
|
||||
// non empty grain
|
||||
await changeRange(i, grainOffset)
|
||||
for (let grainTableIndex = 0; grainTableIndex < grainTableBuffer.length / 8; grainTableIndex++) {
|
||||
const { type: grainType, grainIndex } = readSeSparseTable(grainTableBuffer, grainTableIndex)
|
||||
if (grainType === SE_SPARSE_GRAIN_ALLOCATED) {
|
||||
// this is ok in 32 bits int with VMDK smaller than 2TB
|
||||
const offsetByte = grainIndex * GRAIN_SIZE_BYTES + this.#grainOffsetBytes
|
||||
addGrain(offsetByte)
|
||||
} else {
|
||||
// multiply by -1 to differenciate type and offset
|
||||
// no offset can be zero
|
||||
addGrain(-grainType)
|
||||
}
|
||||
}
|
||||
}
|
||||
await changeRange()
|
||||
return {
|
||||
id: blockId,
|
||||
bitmap: buffer.slice(0, 512),
|
||||
data: buffer.slice(512),
|
||||
buffer,
|
||||
strictEqual(grainOffsets.length, 0)
|
||||
}
|
||||
}
|
||||
|
||||
async readBlock(blockId) {
|
||||
let changed = false
|
||||
const parentBlock = await this.#parentVhd.readBlock(blockId)
|
||||
const parentBuffer = parentBlock.buffer
|
||||
const grainOffsets = this.#grainIndex.get(blockId) // may be undefined if the child contains block and lookMissingBlockInParent=true
|
||||
const EMPTY_GRAIN = Buffer.alloc(GRAIN_SIZE_BYTES, 0)
|
||||
for (const index in grainOffsets) {
|
||||
const value = grainOffsets[index]
|
||||
let data
|
||||
if (value > 0) {
|
||||
// it's the offset in byte of a grain type SE_SPARSE_GRAIN_ALLOCATED
|
||||
data = await this.#read(value, GRAIN_SIZE_BYTES)
|
||||
} else {
|
||||
// back to the real grain type
|
||||
const type = value * -1
|
||||
switch (type) {
|
||||
case SE_SPARSE_GRAIN_ZERO:
|
||||
case SE_SPARSE_GRAIN_UNMAPPED:
|
||||
data = EMPTY_GRAIN
|
||||
break
|
||||
case SE_SPARSE_GRAIN_NON_ALLOCATED:
|
||||
/* from parent */
|
||||
break
|
||||
default:
|
||||
throw new Error(`can't handle grain type ${type}`)
|
||||
}
|
||||
}
|
||||
if (data) {
|
||||
changed = true
|
||||
data.copy(parentBuffer, index * GRAIN_SIZE_BYTES + 512 /* block bitmap */)
|
||||
}
|
||||
}
|
||||
// no need to copy if data all come from parent
|
||||
return changed
|
||||
? {
|
||||
id: blockId,
|
||||
bitmap: parentBuffer.slice(0, 512),
|
||||
data: parentBuffer.slice(512),
|
||||
buffer: parentBuffer,
|
||||
}
|
||||
: parentBlock
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Client } from '@vates/node-vsphere-soap'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { dirname } from 'node:path'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { strictEqual, notStrictEqual } from 'node:assert'
|
||||
@@ -9,6 +10,8 @@ import parseVmdk from './parsers/vmdk.mjs'
|
||||
import parseVmsd from './parsers/vmsd.mjs'
|
||||
import parseVmx from './parsers/vmx.mjs'
|
||||
|
||||
const { warn } = createLogger('xo:vmware-explorer:esxi')
|
||||
|
||||
export default class Esxi extends EventEmitter {
|
||||
#client
|
||||
#cookies
|
||||
@@ -64,7 +67,7 @@ export default class Esxi extends EventEmitter {
|
||||
})
|
||||
}
|
||||
|
||||
async download(dataStore, path, range) {
|
||||
async #download(dataStore, path, range) {
|
||||
strictEqual(this.#ready, true)
|
||||
notStrictEqual(this.#dcPath, undefined)
|
||||
const url = new URL('https://localhost')
|
||||
@@ -102,6 +105,24 @@ export default class Esxi extends EventEmitter {
|
||||
return res
|
||||
}
|
||||
|
||||
async download(dataStore, path, range) {
|
||||
let tries = 5
|
||||
let lastError
|
||||
while (tries > 0) {
|
||||
try {
|
||||
const res = await this.#download(dataStore, path, range)
|
||||
return res
|
||||
} catch (error) {
|
||||
warn('got error , will retry in 2 seconds', { error })
|
||||
lastError = error
|
||||
}
|
||||
await new Promise(resolve => setTimeout(() => resolve(), 2000))
|
||||
tries--
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
// inspired from https://github.com/reedog117/node-vsphere-soap/blob/master/test/vsphere-soap.test.js#L95
|
||||
async search(type, properties) {
|
||||
// get property collector
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import VHDEsxiSeSparse from './VhdEsxiSeSparse.mjs'
|
||||
import VhdEsxiCowd from './VhdEsxiCowd.mjs'
|
||||
// import VhdEsxiSeSparse from "./VhdEsxiSeSparse.mjs";
|
||||
|
||||
export default async function openDeltaVmdkasVhd(esxi, datastore, path, parentVhd, opts) {
|
||||
let vhd
|
||||
if (path.endsWith('-sesparse.vmdk')) {
|
||||
throw new Error(
|
||||
`sesparse VMDK reading is not functional yet ${path}. For now, this VM can only be migrated if it doesn't have any snapshots and if it is halted.`
|
||||
)
|
||||
// vhd = new VhdEsxiSeSparse(esxi, datastore, path, parentVhd, opts)
|
||||
vhd = new VHDEsxiSeSparse(esxi, datastore, path, parentVhd, opts)
|
||||
} else {
|
||||
if (path.endsWith('-delta.vmdk')) {
|
||||
vhd = new VhdEsxiCowd(esxi, datastore, path, parentVhd, opts)
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"license": "ISC",
|
||||
"private": false,
|
||||
"version": "0.2.3",
|
||||
"version": "0.3.0",
|
||||
"name": "@xen-orchestra/vmware-explorer",
|
||||
"dependencies": {
|
||||
"@vates/node-vsphere-soap": "^2.0.0",
|
||||
"@vates/read-chunk": "^1.2.0",
|
||||
"@vates/task": "^0.2.0",
|
||||
"@vates/read-chunk": "^1.1.1",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"lodash": "^4.17.21",
|
||||
"node-fetch": "^3.3.0",
|
||||
"@vates/node-vsphere-soap": "^1.0.0",
|
||||
"vhd-lib": "^4.5.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xen-orchestra/xapi",
|
||||
"version": "2.2.1",
|
||||
"version": "3.0.0",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/xapi",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"main": "./index.mjs",
|
||||
"peerDependencies": {
|
||||
"xen-api": "^1.3.3"
|
||||
"xen-api": "^1.3.4"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
@@ -25,10 +25,10 @@
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.0",
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@vates/nbd-client": "^1.2.1",
|
||||
"@vates/nbd-client": "^2.0.0",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"d3-time-format": "^3.0.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"golike-defer": "^0.5.1",
|
||||
"http-request-plus": "^1.0.0",
|
||||
"json-rpc-protocol": "^0.13.2",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { decorateClass } from '@vates/decorate-with'
|
||||
import { strict as assert } from 'node:assert'
|
||||
|
||||
import extractOpaqueRef from './_extractOpaqueRef.mjs'
|
||||
import NbdClient from '@vates/nbd-client'
|
||||
import NbdClient from '@vates/nbd-client/client.mjs'
|
||||
import { createNbdRawStream, createNbdVhdStream } from 'vhd-lib/createStreamNbd.js'
|
||||
import { VDI_FORMAT_RAW, VDI_FORMAT_VHD } from './index.mjs'
|
||||
|
||||
|
||||
63
CHANGELOG.md
63
CHANGELOG.md
@@ -1,8 +1,67 @@
|
||||
# ChangeLog
|
||||
|
||||
## **5.85.0** (2023-07-31)
|
||||
|
||||
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
|
||||
|
||||
### Highlights
|
||||
|
||||
- [Import/From VMWare] Support ESXi 6.5+ with snapshot (PR [#6909](https://github.com/vatesfr/xen-orchestra/pull/6909))
|
||||
- [Netbox] New major version. BREAKING: in order for this new version to work, you need to assign the type `virtualization > vminterface` to the custom field `UUID` in your Netbox instance. [See documentation](https://xen-orchestra.com/docs/advanced.html#netbox). [#6038](https://github.com/vatesfr/xen-orchestra/issues/6038) [#6135](https://github.com/vatesfr/xen-orchestra/issues/6135) [#6024](https://github.com/vatesfr/xen-orchestra/issues/6024) [#6036](https://github.com/vatesfr/xen-orchestra/issues/6036) [Forum#6070](https://xcp-ng.org/forum/topic/6070) [Forum#6149](https://xcp-ng.org/forum/topic/6149) [Forum#6332](https://xcp-ng.org/forum/topic/6332) [Forum#6902](https://xcp-ng.org/forum/topic/6902) (PR [#6950](https://github.com/vatesfr/xen-orchestra/pull/6950))
|
||||
- Synchronize VM description
|
||||
- Synchronize VM platform
|
||||
- Fix duplicated VMs in Netbox after disconnecting one pool
|
||||
- Migrating a VM from one pool to another keeps VM data added manually
|
||||
- Fix largest IP prefix being picked instead of smallest
|
||||
- Fix synchronization not working if some pools are unavailable
|
||||
- Better error messages
|
||||
- [Backup/File restore] Faster and more robust ZIP export
|
||||
- [Backup/File restore] Add faster tar+gzip (`.tgz`) export
|
||||
|
||||
### Enhancements
|
||||
|
||||
- [Backup/Restore] Button to open the raw log in the REST API (PR [#6936](https://github.com/vatesfr/xen-orchestra/pull/6936))
|
||||
- [RPU] Avoid migration of VMs on hosts without missing patches (PR [#6943](https://github.com/vatesfr/xen-orchestra/pull/6943))
|
||||
- [Settings/Users] Show users authentication methods (PR [#6962](https://github.com/vatesfr/xen-orchestra/pull/6962))
|
||||
- [Settings/Users] User external authentication methods can be manually removed (PR [#6962](https://github.com/vatesfr/xen-orchestra/pull/6962))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [Incremental Backup & Replication] Attempt to work around HVM multiplier issues when creating VMs on older XAPIs (PR [#6866](https://github.com/vatesfr/xen-orchestra/pull/6866))
|
||||
- [REST API] Fix VDI export when NBD is enabled
|
||||
- [XO Config Cloud Backup] Improve wording about passphrase (PR [#6938](https://github.com/vatesfr/xen-orchestra/pull/6938))
|
||||
- [Pool] Fix IPv6 handling when adding hosts
|
||||
- [New SR] Send provided NFS version to XAPI when probing a share
|
||||
- [Backup/exports] Show more information on error ` stream has ended with not enough data (actual: xxx, expected: 512)` (PR [#6940](https://github.com/vatesfr/xen-orchestra/pull/6940))
|
||||
- [Backup] Fix incremental replication with multiple SRs (PR [#6811](https://github.com/vatesfr/xen-orchestra/pull/6811))
|
||||
- [New VM] Order interfaces by device as done on a VM Network tab (PR [#6944](https://github.com/vatesfr/xen-orchestra/pull/6944))
|
||||
- Users can no longer sign in using their XO password if they are using other authentication providers (PR [#6962](https://github.com/vatesfr/xen-orchestra/pull/6962))
|
||||
|
||||
### Released packages
|
||||
|
||||
- @vates/read-chunk 1.2.0
|
||||
- @vates/fuse-vhd 2.0.0
|
||||
- xen-api 1.3.4
|
||||
- @vates/nbd-client 2.0.0
|
||||
- @vates/node-vsphere-soap 2.0.0
|
||||
- @xen-orchestra/xapi 3.0.0
|
||||
- @xen-orchestra/backups 0.40.0
|
||||
- @xen-orchestra/backups-cli 1.0.10
|
||||
- complex-matcher 0.7.1
|
||||
- @xen-orchestra/mixins 0.11.0
|
||||
- @xen-orchestra/proxy 0.26.30
|
||||
- @xen-orchestra/vmware-explorer 0.3.0
|
||||
- xo-server-audit 0.10.4
|
||||
- xo-server-netbox 1.0.0
|
||||
- xo-server-transport-xmpp 0.1.2
|
||||
- xo-server-auth-github 0.3.0
|
||||
- xo-server-auth-google 0.3.0
|
||||
- xo-web 5.122.2
|
||||
- xo-server 5.120.2
|
||||
|
||||
## **5.84.0** (2023-06-30)
|
||||
|
||||
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
|
||||
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
|
||||
|
||||
### Highlights
|
||||
|
||||
@@ -51,8 +110,6 @@
|
||||
|
||||
## **5.83.3** (2023-06-23)
|
||||
|
||||
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [Settings/Servers] Fix connecting using an explicit IPv6 address
|
||||
|
||||
@@ -7,16 +7,12 @@
|
||||
|
||||
> Users must be able to say: “Nice enhancement, I'm eager to test it”
|
||||
|
||||
- [Backup/Restore] Button to open the raw log in the REST API (PR [#6936](https://github.com/vatesfr/xen-orchestra/pull/6936))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
> Users must be able to say: “I had this issue, happy to know it's fixed”
|
||||
|
||||
- [Incremental Backup & Replication] Attempt to work around HVM multiplier issues when creating VMs on older XAPIs (PR [#6866](https://github.com/vatesfr/xen-orchestra/pull/6866))
|
||||
- [REST API] Fix VDI export when NBD is enabled
|
||||
- [XO Config Cloud Backup] Improve wording about passphrase (PR [#6938](https://github.com/vatesfr/xen-orchestra/pull/6938))
|
||||
- [Pool] Fix IPv6 handling when adding hosts
|
||||
- [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))
|
||||
|
||||
### Packages to release
|
||||
|
||||
@@ -34,15 +30,10 @@
|
||||
|
||||
<!--packages-start-->
|
||||
|
||||
- @vates/fuse-vhd major
|
||||
- @vates/nbd-client major
|
||||
- @vates/node-vsphere-soap major
|
||||
- @xen-orchestra/backups minor
|
||||
- @xen-orchestra/xapi major
|
||||
- complex-matcher patch
|
||||
- @xen-orchestra/backups patch
|
||||
- xen-api patch
|
||||
- xo-server patch
|
||||
- xo-server-audit patch
|
||||
- xo-web minor
|
||||
- xo-server-auth-ldap patch
|
||||
- xo-web patch
|
||||
|
||||
<!--packages-end-->
|
||||
|
||||
@@ -354,7 +354,7 @@ XO will try to find the right prefix for each IP address. If it can't find a pre
|
||||
- Add a UUID custom field:
|
||||
- Go to Other > Custom fields > Add
|
||||
- Create a custom field called "uuid" (lower case!)
|
||||
- Assign it to object types `virtualization > cluster` and `virtualization > virtual machine`
|
||||
- Assign it to object types `virtualization > cluster`, `virtualization > virtual machine` and `virtualization > vminterface`
|
||||
|
||||

|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |
@@ -25,7 +25,7 @@
|
||||
"jest": "^29.0.3",
|
||||
"lint-staged": "^13.0.3",
|
||||
"lodash": "^4.17.4",
|
||||
"prettier": "^2.0.5",
|
||||
"prettier": "^3.0.1",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"semver": "^7.3.6",
|
||||
"sorted-object": "^2.0.1",
|
||||
@@ -98,7 +98,7 @@
|
||||
"prettify": "prettier --ignore-path .gitignore --ignore-unknown --write .",
|
||||
"test": "npm run test-lint && npm run test-unit",
|
||||
"test-integration": "jest \".integ\\.spec\\.js$\" && scripts/run-script.js --parallel test-integration",
|
||||
"test-lint": "eslint --ignore-path .gitignore --ignore-pattern packages/xo-web .",
|
||||
"test-lint": "eslint --ignore-path .gitignore --ignore-pattern @xen-orchestra/lite --ignore-pattern packages/xo-web .",
|
||||
"test-unit": "jest \"^(?!.*\\.integ\\.spec\\.js$)\" && scripts/run-script.js --bail test"
|
||||
},
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "complex-matcher",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"license": "ISC",
|
||||
"description": "Advanced search syntax used in XO",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/complex-matcher",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user