Compare commits

..

1 Commits

Author SHA1 Message Date
Gabriel Gunullu
2cb986b1a3 feat(xapi/VDI_importContent): add SR name_label to task name_label (#6979) 2023-10-10 10:43:08 +02:00
283 changed files with 4428 additions and 9663 deletions

View File

@@ -33,7 +33,7 @@
"test": "node--test"
},
"devDependencies": {
"sinon": "^17.0.1",
"sinon": "^16.0.0",
"tap": "^16.3.0",
"test": "^3.2.1"
}

View File

@@ -29,7 +29,7 @@
"ensure-array": "^1.0.0"
},
"devDependencies": {
"sinon": "^17.0.1",
"sinon": "^16.0.0",
"test": "^3.2.1"
}
}

View File

@@ -42,8 +42,8 @@ function get(node, i, keys) {
? node.value
: node
: node instanceof Node
? get(node.children.get(keys[i]), i + 1, keys)
: undefined
? get(node.children.get(keys[i]), i + 1, keys)
: undefined
}
function set(node, i, keys, value) {

View File

@@ -21,7 +21,6 @@ import {
OPTS_MAGIC,
NBD_CMD_DISC,
} from './constants.mjs'
import { Readable } from 'node:stream'
const { warn } = createLogger('vates:nbd-client')
@@ -41,7 +40,6 @@ export default class NbdClient {
#readBlockRetries
#reconnectRetry
#connectTimeout
#messageTimeout
// AFAIK, there is no guaranty the server answers in the same order as the queries
// so we handle a backlog of command waiting for response and handle concurrency manually
@@ -54,14 +52,7 @@ export default class NbdClient {
#reconnectingPromise
constructor(
{ address, port = NBD_DEFAULT_PORT, exportname, cert },
{
connectTimeout = 6e4,
messageTimeout = 6e4,
waitBeforeReconnect = 1e3,
readAhead = 10,
readBlockRetries = 5,
reconnectRetry = 5,
} = {}
{ connectTimeout = 6e4, waitBeforeReconnect = 1e3, readAhead = 10, readBlockRetries = 5, reconnectRetry = 5 } = {}
) {
this.#serverAddress = address
this.#serverPort = port
@@ -72,7 +63,6 @@ export default class NbdClient {
this.#readBlockRetries = readBlockRetries
this.#reconnectRetry = reconnectRetry
this.#connectTimeout = connectTimeout
this.#messageTimeout = messageTimeout
}
get exportSize() {
@@ -122,30 +112,22 @@ export default class NbdClient {
}
async disconnect() {
warn('will try to disconnect', { serverAddress: this.#serverAddress })
if (!this.#connected) {
warn('was already disconnected', { serverAddress: this.#serverAddress })
return
}
const queryId = this.#nextCommandQueryId
this.#nextCommandQueryId++
warn('will really disconnect', { serverAddress: this.#serverAddress })
const buffer = Buffer.alloc(28)
buffer.writeInt32BE(NBD_REQUEST_MAGIC, 0) // it is a nbd request
buffer.writeInt16BE(0, 4) // no command flags for a disconnect
buffer.writeInt16BE(NBD_CMD_DISC, 6) // we want to disconnect from nbd server
buffer.writeBigUInt64BE(queryId, 8)
buffer.writeBigUInt64BE(0n, 16)
buffer.writeInt32BE(0, 24)
const promise = pFromCallback(cb => {
this.#serverSocket.end(buffer, 'utf8', cb)
})
try {
await pTimeout.call(promise, this.#messageTimeout)
} catch (error) {
this.#serverSocket.destroy()
}
warn('will send end buffer', { serverAddress: this.#serverAddress })
this.#connected = false // optimistically mark as disconnected to ensure we don' send another disconnection while handling this one
await pFromCallback(cb => this.#serverSocket.end(buffer, cb))
warn('end buffer sent', { serverAddress: this.#serverAddress })
this.#serverSocket = undefined
this.#connected = false
}
#clearReconnectPromise = () => {
@@ -217,13 +199,11 @@ export default class NbdClient {
}
#read(length) {
const promise = readChunkStrict(this.#serverSocket, length)
return pTimeout.call(promise, this.#messageTimeout)
return readChunkStrict(this.#serverSocket, length)
}
#write(buffer) {
const promise = fromCallback.call(this.#serverSocket, 'write', buffer)
return pTimeout.call(promise, this.#messageTimeout)
return fromCallback.call(this.#serverSocket, 'write', buffer)
}
async #readInt32() {
@@ -256,20 +236,19 @@ export default class NbdClient {
}
try {
this.#waitingForResponse = true
const buffer = await this.#read(16)
const magic = buffer.readInt32BE(0)
const magic = await this.#readInt32()
if (magic !== NBD_REPLY_MAGIC) {
throw new Error(`magic number for block answer is wrong : ${magic} ${NBD_REPLY_MAGIC}`)
}
const error = buffer.readInt32BE(4)
const error = await this.#readInt32()
if (error !== 0) {
// @todo use error code from constants.mjs
throw new Error(`GOT ERROR CODE : ${error}`)
}
const blockQueryId = buffer.readBigUInt64BE(8)
const blockQueryId = await this.#readInt64()
const query = this.#commandQueryBacklog.get(blockQueryId)
if (!query) {
throw new Error(` no query associated with id ${blockQueryId}`)
@@ -306,13 +285,7 @@ export default class NbdClient {
buffer.writeInt16BE(NBD_CMD_READ, 6) // we want to read a data block
buffer.writeBigUInt64BE(queryId, 8)
// byte offset in the raw disk
const offset = BigInt(index) * BigInt(size)
const remaining = this.#exportSize - offset
if (remaining < BigInt(size)) {
size = Number(remaining)
}
buffer.writeBigUInt64BE(offset, 16)
buffer.writeBigUInt64BE(BigInt(index) * BigInt(size), 16)
buffer.writeInt32BE(size, 24)
return new Promise((resolve, reject) => {
@@ -338,15 +311,14 @@ export default class NbdClient {
})
}
async *readBlocks(indexGenerator = 2 * 1024 * 1024) {
async *readBlocks(indexGenerator) {
// default : read all blocks
if (typeof indexGenerator === 'number') {
const exportSize = Number(this.#exportSize)
const chunkSize = indexGenerator
if (indexGenerator === undefined) {
const exportSize = this.#exportSize
const chunkSize = 2 * 1024 * 1024
indexGenerator = function* () {
const nbBlocks = Math.ceil(exportSize / chunkSize)
for (let index = 0; index < nbBlocks; index++) {
const nbBlocks = Math.ceil(Number(exportSize / BigInt(chunkSize)))
for (let index = 0; BigInt(index) < nbBlocks; index++) {
yield { index, size: chunkSize }
}
}
@@ -380,15 +352,4 @@ export default class NbdClient {
yield readAhead.shift()
}
}
stream(chunkSize) {
async function* iterator() {
for await (const chunk of this.readBlocks(chunkSize)) {
yield chunk
}
}
// create a readable stream instead of returning the iterator
// since iterators don't like unshift and partial reading
return Readable.from(iterator())
}
}

View File

@@ -22,41 +22,41 @@ const readChunk = (stream, size) =>
stream.errored != null
? Promise.reject(stream.errored)
: stream.closed || stream.readableEnded
? Promise.resolve(null)
: new Promise((resolve, reject) => {
if (size !== undefined) {
assert(size > 0)
? Promise.resolve(null)
: new Promise((resolve, reject) => {
if (size !== undefined) {
assert(size > 0)
// per Node documentation:
// > The size argument must be less than or equal to 1 GiB.
assert(size < 1073741824)
}
// per Node documentation:
// > The size argument must be less than or equal to 1 GiB.
assert(size < 1073741824)
}
function onEnd() {
resolve(null)
function onEnd() {
resolve(null)
removeListeners()
}
function onError(error) {
reject(error)
removeListeners()
}
function onReadable() {
const data = stream.read(size)
if (data !== null) {
resolve(data)
removeListeners()
}
function onError(error) {
reject(error)
removeListeners()
}
function onReadable() {
const data = stream.read(size)
if (data !== null) {
resolve(data)
removeListeners()
}
}
function removeListeners() {
stream.removeListener('end', onEnd)
stream.removeListener('error', onError)
stream.removeListener('readable', onReadable)
}
stream.on('end', onEnd)
stream.on('error', onError)
stream.on('readable', onReadable)
onReadable()
})
}
function removeListeners() {
stream.removeListener('end', onEnd)
stream.removeListener('error', onError)
stream.removeListener('readable', onReadable)
}
stream.on('end', onEnd)
stream.on('error', onError)
stream.on('readable', onReadable)
onReadable()
})
exports.readChunk = readChunk
/**
@@ -111,42 +111,42 @@ async function skip(stream, size) {
return stream.errored != null
? Promise.reject(stream.errored)
: size === 0 || stream.closed || stream.readableEnded
? Promise.resolve(0)
: new Promise((resolve, reject) => {
let left = size
function onEnd() {
resolve(size - left)
removeListeners()
}
function onError(error) {
reject(error)
removeListeners()
}
function onReadable() {
const data = stream.read()
left -= data === null ? 0 : data.length
if (left > 0) {
// continue to read
} else {
// if more than wanted has been read, push back the rest
if (left < 0) {
stream.unshift(data.slice(left))
}
resolve(size)
removeListeners()
? Promise.resolve(0)
: new Promise((resolve, reject) => {
let left = size
function onEnd() {
resolve(size - left)
removeListeners()
}
function onError(error) {
reject(error)
removeListeners()
}
function onReadable() {
const data = stream.read()
left -= data === null ? 0 : data.length
if (left > 0) {
// continue to read
} else {
// if more than wanted has been read, push back the rest
if (left < 0) {
stream.unshift(data.slice(left))
}
resolve(size)
removeListeners()
}
function removeListeners() {
stream.removeListener('end', onEnd)
stream.removeListener('error', onError)
stream.removeListener('readable', onReadable)
}
stream.on('end', onEnd)
stream.on('error', onError)
stream.on('readable', onReadable)
onReadable()
})
}
function removeListeners() {
stream.removeListener('end', onEnd)
stream.removeListener('error', onError)
stream.removeListener('readable', onReadable)
}
stream.on('end', onEnd)
stream.on('error', onError)
stream.on('readable', onReadable)
onReadable()
})
}
exports.skip = skip

View File

@@ -111,7 +111,7 @@ const onProgress = makeOnProgress({
// current status of the task as described in the previous section
taskLog.status
// undefined or a dictionary of properties attached to the task
// undefined or a dictionnary of properties attached to the task
taskLog.properties
// timestamp at which the abortion was requested, undefined otherwise

View File

@@ -35,7 +35,7 @@
"test": "node--test"
},
"devDependencies": {
"sinon": "^17.0.1",
"sinon": "^16.0.0",
"test": "^3.2.1"
}
}

View File

@@ -7,8 +7,8 @@
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.43.2",
"@xen-orchestra/fs": "^4.1.2",
"@xen-orchestra/backups": "^0.43.0",
"@xen-orchestra/fs": "^4.1.0",
"filenamify": "^6.0.0",
"getopts": "^2.2.5",
"lodash": "^4.17.15",

View File

@@ -5,16 +5,6 @@ import { importIncrementalVm } from './_incrementalVm.mjs'
import { Task } from './Task.mjs'
import { watchStreamSize } from './_watchStreamSize.mjs'
async function resolveUuid(xapi, cache, uuid, type) {
if (uuid == null) {
return uuid
}
const ref = cache.get(uuid)
if (ref === undefined) {
cache.set(uuid, xapi.call(`${type}.get_by_uuid`, uuid))
}
return cache.get(uuid)
}
export class ImportVmBackup {
constructor({ adapter, metadata, srUuid, xapi, settings: { newMacAddresses, mapVdisSrs = {} } = {} }) {
this._adapter = adapter
@@ -24,29 +14,13 @@ export class ImportVmBackup {
this._xapi = xapi
}
async #decorateIncrementalVmMetadata(backup) {
const { mapVdisSrs } = this._importIncrementalVmSettings
const xapi = this._xapi
const cache = new Map()
const mapVdisSrRefs = {}
for (const [vdiUuid, srUuid] of Object.entries(mapVdisSrs)) {
mapVdisSrRefs[vdiUuid] = await resolveUuid(xapi, cache, srUuid, 'SR')
}
const sr = await resolveUuid(xapi, cache, this._srUuid, 'SR')
Object.values(backup.vdis).forEach(vdi => {
vdi.SR = mapVdisSrRefs[vdi.uuid] ?? sr.$ref
})
return backup
}
async run() {
const adapter = this._adapter
const metadata = this._metadata
const isFull = metadata.mode === 'full'
const sizeContainer = { size: 0 }
const { mapVdisSrs, newMacAddresses } = this._importIncrementalVmSettings
let backup
if (isFull) {
backup = await adapter.readFullVmBackup(metadata)
@@ -55,11 +29,11 @@ export class ImportVmBackup {
assert.strictEqual(metadata.mode, 'delta')
const ignoredVdis = new Set(
Object.entries(mapVdisSrs)
Object.entries(this._importIncrementalVmSettings.mapVdisSrs)
.filter(([_, srUuid]) => srUuid === null)
.map(([vdiUuid]) => vdiUuid)
)
backup = await this.#decorateIncrementalVmMetadata(await adapter.readIncrementalVmBackup(metadata, ignoredVdis))
backup = await adapter.readIncrementalVmBackup(metadata, ignoredVdis)
Object.values(backup.streams).forEach(stream => watchStreamSize(stream, sizeContainer))
}
@@ -74,7 +48,8 @@ export class ImportVmBackup {
const vmRef = isFull
? await xapi.VM_import(backup, srRef)
: await importIncrementalVm(backup, await xapi.getRecord('SR', srRef), {
newMacAddresses,
...this._importIncrementalVmSettings,
detectBase: false,
})
await Promise.all([
@@ -84,13 +59,6 @@ export class ImportVmBackup {
vmRef,
`${metadata.vm.name_label} (${formatFilenameDate(metadata.timestamp)})`
),
xapi.call(
'VM.set_name_description',
vmRef,
`Restored on ${formatFilenameDate(+new Date())} from ${adapter._handler._remote.name} -
${metadata.vm.name_description}
`
),
])
return {

View File

@@ -681,13 +681,11 @@ export class RemoteAdapter {
}
}
async outputStream(path, input, { checksum = true, maxStreamLength, streamLength, validator = noop } = {}) {
async outputStream(path, input, { checksum = true, validator = noop } = {}) {
const container = watchStreamSize(input)
await this._handler.outputStream(path, input, {
checksum,
dirMode: this._dirMode,
maxStreamLength,
streamLength,
async validator() {
await input.task
return validator.apply(this, arguments)

View File

@@ -1,3 +1,4 @@
import find from 'lodash/find.js'
import groupBy from 'lodash/groupBy.js'
import ignoreErrors from 'promise-toolbox/ignoreErrors'
import omit from 'lodash/omit.js'
@@ -11,18 +12,24 @@ import { cancelableMap } from './_cancelableMap.mjs'
import { Task } from './Task.mjs'
import pick from 'lodash/pick.js'
// in `other_config` of an incrementally replicated VM, contains the UUID of the source VM
export const TAG_BASE_DELTA = 'xo:base_delta'
// in `other_config` of an incrementally replicated VM, contains the UUID of the target SR used for replication
//
// added after the complete replication
export const TAG_BACKUP_SR = 'xo:backup:sr'
// in other_config of VDIs of an incrementally replicated VM, contains the UUID of the source VDI
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) {
return uuid
}
let ref = cache.get(uuid)
if (ref === undefined) {
ref = await xapi.call(`${type}.get_by_uuid`, uuid)
cache.set(uuid, ref)
}
return ref
}
export async function exportIncrementalVm(
vm,
@@ -140,7 +147,7 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
$defer,
incrementalVm,
sr,
{ cancelToken = CancelToken.none, newMacAddresses = false } = {}
{ cancelToken = CancelToken.none, detectBase = true, mapVdisSrs = {}, newMacAddresses = false } = {}
) {
const { version } = incrementalVm
if (compareVersions(version, '1.0.0') < 0) {
@@ -150,6 +157,35 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
const vmRecord = incrementalVm.vm
const xapi = sr.$xapi
let baseVm
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 && obj[TAG_BACKUP_SR] === sr.$id
)
if (!baseVm) {
throw new Error(`could not find the base VM (copy of ${remoteBaseVmUuid})`)
}
}
}
const cache = new Map()
const mapVdisSrRefs = {}
for (const [vdiUuid, srUuid] of Object.entries(mapVdisSrs)) {
mapVdisSrRefs[vdiUuid] = await resolveUuid(xapi, cache, srUuid, 'SR')
}
const baseVdis = {}
baseVm &&
baseVm.$VBDs.forEach(vbd => {
const vdi = vbd.$VDI
if (vdi !== undefined) {
baseVdis[vbd.VDI] = vbd.$VDI
}
})
const vdiRecords = incrementalVm.vdis
// 0. Create suspend_VDI
@@ -161,7 +197,18 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
vm: pick(vmRecord, 'uuid', 'name_label', 'suspend_VDI'),
})
} else {
suspendVdi = await xapi.getRecord('VDI', await xapi.VDI_create(vdi))
suspendVdi = await xapi.getRecord(
'VDI',
await xapi.VDI_create({
...vdi,
other_config: {
...vdi.other_config,
[TAG_BASE_DELTA]: undefined,
[TAG_COPY_SRC]: vdi.uuid,
},
sr: mapVdisSrRefs[vdi.uuid] ?? sr.$ref,
})
)
$defer.onFailure(() => suspendVdi.$destroy())
}
}
@@ -179,6 +226,10 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
ha_always_run: false,
is_a_template: false,
name_label: '[Importing…] ' + vmRecord.name_label,
other_config: {
...vmRecord.other_config,
[TAG_COPY_SRC]: vmRecord.uuid,
},
},
{
bios_strings: vmRecord.bios_strings,
@@ -199,8 +250,14 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
const vdi = vdiRecords[vdiRef]
let newVdi
if (vdi.baseVdi !== undefined) {
newVdi = await xapi.getRecord('VDI', await vdi.baseVdi.$clone())
const remoteBaseVdiUuid = detectBase && vdi.other_config[TAG_BASE_DELTA]
if (remoteBaseVdiUuid) {
const baseVdi = find(baseVdis, vdi => vdi.other_config[TAG_COPY_SRC] === remoteBaseVdiUuid)
if (!baseVdi) {
throw new Error(`missing base VDI (copy of ${remoteBaseVdiUuid})`)
}
newVdi = await xapi.getRecord('VDI', await baseVdi.$clone())
$defer.onFailure(() => newVdi.$destroy())
await newVdi.update_other_config(TAG_COPY_SRC, vdi.uuid)
@@ -211,7 +268,18 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
// suspendVDI has already created
newVdi = suspendVdi
} else {
newVdi = await xapi.getRecord('VDI', await xapi.VDI_create(vdi))
newVdi = await xapi.getRecord(
'VDI',
await xapi.VDI_create({
...vdi,
other_config: {
...vdi.other_config,
[TAG_BASE_DELTA]: undefined,
[TAG_COPY_SRC]: vdi.uuid,
},
SR: mapVdisSrRefs[vdi.uuid] ?? sr.$ref,
})
)
$defer.onFailure(() => newVdi.$destroy())
}
@@ -256,9 +324,7 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
if (stream.length === undefined) {
stream = await createVhdStreamWithLength(stream)
}
await xapi.setField('VDI', vdi.$ref, 'name_label', `[Importing] ${vdiRecords[id].name_label}`)
await vdi.$importContent(stream, { cancelToken, format: 'vhd' })
await xapi.setField('VDI', vdi.$ref, 'name_label', vdiRecords[id].name_label)
}
}),

View File

@@ -29,8 +29,6 @@ export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote
writer =>
writer.run({
stream: forkStreamUnpipe(stream),
// stream will be forked and transformed, it's not safe to attach additionnal properties to it
streamLength: stream.length,
timestamp: metadata.timestamp,
vm: metadata.vm,
vmSnapshot: metadata.vmSnapshot,

View File

@@ -35,25 +35,13 @@ export const FullXapi = class FullXapiVmBackupRunner extends AbstractXapi {
useSnapshot: false,
})
)
const vdis = await exportedVm.$getDisks()
let maxStreamLength = 1024 * 1024 // Ovf file and tar headers are a few KB, let's stay safe
for (const vdiRef of vdis) {
const vdi = await this._xapi.getRecord('VDI', vdiRef)
// the size a of fully allocated vdi will be virtual_size exaclty, it's a gross over evaluation
// of the real stream size in general, since a disk is never completly full
// vdi.physical_size seems to underevaluate a lot the real disk usage of a VDI, as of 2023-10-30
maxStreamLength += vdi.virtual_size
}
const sizeContainer = watchStreamSize(stream)
const timestamp = Date.now()
await this._callWriters(
writer =>
writer.run({
maxStreamLength,
sizeContainer,
stream: forkStreamUnpipe(stream),
timestamp,

View File

@@ -1,11 +1,11 @@
import cloneDeep from 'lodash/cloneDeep.js'
import mapValues from 'lodash/mapValues.js'
import { forkStreamUnpipe } from '../_forkStreamUnpipe.mjs'
export function forkDeltaExport(deltaExport) {
const { streams, ...rest } = deltaExport
const newMetadata = cloneDeep(rest)
newMetadata.streams = mapValues(streams, forkStreamUnpipe)
return newMetadata
return Object.create(deltaExport, {
streams: {
value: mapValues(deltaExport.streams, forkStreamUnpipe),
},
})
}

View File

@@ -24,7 +24,7 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
)
}
async _run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
async _run({ timestamp, sizeContainer, stream, vm, vmSnapshot }) {
const settings = this._settings
const job = this._job
const scheduleId = this._scheduleId
@@ -65,8 +65,6 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
await Task.run({ name: 'transfer' }, async () => {
await adapter.outputStream(dataFilename, stream, {
maxStreamLength,
streamLength,
validator: tmpPath => adapter.isValidXva(tmpPath),
})
return { size: sizeContainer.size }

View File

@@ -11,7 +11,6 @@ import { dirname } from 'node:path'
import { formatFilenameDate } from '../../_filenameDate.mjs'
import { getOldEntries } from '../../_getOldEntries.mjs'
import { TAG_BASE_DELTA } from '../../_incrementalVm.mjs'
import { Task } from '../../Task.mjs'
import { MixinRemoteWriter } from './_MixinRemoteWriter.mjs'
@@ -196,7 +195,7 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
assert.notStrictEqual(
parentPath,
undefined,
`missing parent of ${id} in ${dirname(path)}, looking for ${vdi.other_config[TAG_BASE_DELTA]}`
`missing parent of ${id} in ${dirname(path)}, looking for ${vdi.other_config['xo:base_delta']}`
)
parentPath = parentPath.slice(1) // remove leading slash

View File

@@ -4,13 +4,12 @@ import { formatDateTime } from '@xen-orchestra/xapi'
import { formatFilenameDate } from '../../_filenameDate.mjs'
import { getOldEntries } from '../../_getOldEntries.mjs'
import { importIncrementalVm, TAG_BACKUP_SR, TAG_BASE_DELTA, TAG_COPY_SRC } from '../../_incrementalVm.mjs'
import { importIncrementalVm, TAG_COPY_SRC } from '../../_incrementalVm.mjs'
import { Task } from '../../Task.mjs'
import { AbstractIncrementalWriter } from './_AbstractIncrementalWriter.mjs'
import { MixinXapiWriter } from './_MixinXapiWriter.mjs'
import { listReplicatedVms } from './_listReplicatedVms.mjs'
import find from 'lodash/find.js'
export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWriter) {
async checkBaseVdis(baseUuidToSrcVdi, baseVm) {
@@ -82,54 +81,6 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
return asyncMapSettled(this._oldEntries, vm => vm.$destroy())
}
#decorateVmMetadata(backup) {
const { _warmMigration } = this._settings
const sr = this._sr
const xapi = sr.$xapi
const vm = backup.vm
vm.other_config[TAG_COPY_SRC] = vm.uuid
const remoteBaseVmUuid = vm.other_config[TAG_BASE_DELTA]
let baseVm
if (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})`)
}
}
const baseVdis = {}
baseVm?.$VBDs.forEach(vbd => {
const vdi = vbd.$VDI
if (vdi !== undefined) {
baseVdis[vbd.VDI] = vbd.$VDI
}
})
vm.other_config[TAG_COPY_SRC] = vm.uuid
if (!_warmMigration) {
vm.tags.push('Continuous Replication')
}
Object.values(backup.vdis).forEach(vdi => {
vdi.other_config[TAG_COPY_SRC] = vdi.uuid
vdi.SR = sr.$ref
// vdi.other_config[TAG_BASE_DELTA] is never defined on a suspend vdi
if (vdi.other_config[TAG_BASE_DELTA]) {
const remoteBaseVdiUuid = vdi.other_config[TAG_BASE_DELTA]
const baseVdi = find(baseVdis, vdi => vdi.other_config[TAG_COPY_SRC] === remoteBaseVdiUuid)
if (!baseVdi) {
throw new Error(`missing base VDI (copy of ${remoteBaseVdiUuid})`)
}
vdi.baseVdi = baseVdi
}
})
return backup
}
async _transfer({ timestamp, deltaExport, sizeContainers, vm }) {
const { _warmMigration } = this._settings
const sr = this._sr
@@ -140,7 +91,16 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
let targetVmRef
await Task.run({ name: 'transfer' }, async () => {
targetVmRef = await importIncrementalVm(this.#decorateVmMetadata(deltaExport), sr)
targetVmRef = await importIncrementalVm(
{
__proto__: deltaExport,
vm: {
...deltaExport.vm,
tags: _warmMigration ? deltaExport.vm.tags : [...deltaExport.vm.tags, 'Continuous Replication'],
},
},
sr
)
return {
size: Object.values(sizeContainers).reduce((sum, { size }) => sum + size, 0),
}
@@ -161,13 +121,13 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
)
),
targetVm.update_other_config({
[TAG_BACKUP_SR]: srUuid,
'xo:backup:sr': srUuid,
// these entries need to be added in case of offline backup
'xo:backup:datetime': formatDateTime(timestamp),
'xo:backup:job': job.id,
'xo:backup:schedule': scheduleId,
[TAG_BASE_DELTA]: vm.uuid,
'xo:backup:vm': vm.uuid,
}),
])
}

View File

@@ -1,9 +1,9 @@
import { AbstractWriter } from './_AbstractWriter.mjs'
export class AbstractFullWriter extends AbstractWriter {
async run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
async run({ timestamp, sizeContainer, stream, vm, vmSnapshot }) {
try {
return await this._run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot })
return await this._run({ timestamp, sizeContainer, stream, vm, vmSnapshot })
} finally {
// ensure stream is properly closed
stream.destroy()

View File

@@ -8,7 +8,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.43.2",
"version": "0.43.0",
"engines": {
"node": ">=14.18"
},
@@ -28,7 +28,7 @@
"@vates/nbd-client": "^2.0.0",
"@vates/parse-duration": "^0.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/fs": "^4.1.2",
"@xen-orchestra/fs": "^4.1.0",
"@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/template": "^0.1.0",
"app-conf": "^2.3.0",
@@ -51,12 +51,12 @@
"devDependencies": {
"fs-extra": "^11.1.0",
"rimraf": "^5.0.1",
"sinon": "^17.0.1",
"sinon": "^16.0.0",
"test": "^3.2.1",
"tmp": "^0.2.1"
},
"peerDependencies": {
"@xen-orchestra/xapi": "^3.3.0"
"@xen-orchestra/xapi": "^3.2.0"
},
"license": "AGPL-3.0-or-later",
"author": {

View File

@@ -1,10 +1,11 @@
#!/usr/bin/env node
import { defer } from 'golike-defer'
import { readFileSync } from 'fs'
import { Ref, Xapi } from 'xen-api'
'use strict'
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url)))
const { Ref, Xapi } = require('xen-api')
const { defer } = require('golike-defer')
const pkg = require('./package.json')
Xapi.prototype.getVmDisks = async function (vm) {
const disks = { __proto__: null }

View File

@@ -10,10 +10,10 @@
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"engines": {
"node": ">=10"
"node": ">=8"
},
"bin": {
"xo-cr-seed": "./index.mjs"
"xo-cr-seed": "./index.js"
},
"preferGlobal": true,
"dependencies": {

View File

@@ -42,7 +42,7 @@
"test": "node--test"
},
"devDependencies": {
"sinon": "^17.0.1",
"sinon": "^16.0.0",
"test": "^3.2.1"
}
}

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/fs",
"version": "4.1.2",
"version": "4.1.0",
"license": "AGPL-3.0-or-later",
"description": "The File System for Xen Orchestra backups.",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/fs",
@@ -53,7 +53,7 @@
"cross-env": "^7.0.2",
"dotenv": "^16.0.0",
"rimraf": "^5.0.1",
"sinon": "^17.0.1",
"sinon": "^16.0.0",
"test": "^3.3.0",
"tmp": "^0.2.1"
},

View File

@@ -189,7 +189,7 @@ export default class RemoteHandlerAbstract {
* @param {number} [options.dirMode]
* @param {(this: RemoteHandlerAbstract, path: string) => Promise<undefined>} [options.validator] Function that will be called before the data is commited to the remote, if it fails, file should not exist
*/
async outputStream(path, input, { checksum = true, dirMode, maxStreamLength, streamLength, validator } = {}) {
async outputStream(path, input, { checksum = true, dirMode, validator } = {}) {
path = normalizePath(path)
let checksumStream
@@ -201,8 +201,6 @@ export default class RemoteHandlerAbstract {
}
await this._outputStream(path, input, {
dirMode,
maxStreamLength,
streamLength,
validator,
})
if (checksum) {
@@ -635,7 +633,7 @@ export default class RemoteHandlerAbstract {
}
throw error
},
// real unlink concurrency will be 2**max directory depth
// real unlink concurrency will be 2**max directory depth
{ concurrency: 2 }
)
)

View File

@@ -5,7 +5,6 @@ import {
CreateMultipartUploadCommand,
DeleteObjectCommand,
GetObjectCommand,
GetObjectLockConfigurationCommand,
HeadObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
@@ -18,7 +17,7 @@ import { getApplyMd5BodyChecksumPlugin } from '@aws-sdk/middleware-apply-body-ch
import { Agent as HttpAgent } from 'http'
import { Agent as HttpsAgent } from 'https'
import { createLogger } from '@xen-orchestra/log'
import { PassThrough, Transform, pipeline } from 'stream'
import { PassThrough, pipeline } from 'stream'
import { parse } from 'xo-remote-parser'
import copyStreamToBuffer from './_copyStreamToBuffer.js'
import guessAwsRegion from './_guessAwsRegion.js'
@@ -31,8 +30,6 @@ import { pRetry } from 'promise-toolbox'
// limits: https://docs.aws.amazon.com/AmazonS3/latest/dev/qfacts.html
const MAX_PART_SIZE = 1024 * 1024 * 1024 * 5 // 5GB
const MAX_PART_NUMBER = 10000
const MIN_PART_SIZE = 5 * 1024 * 1024
const { warn } = createLogger('xo:fs:s3')
export default class S3Handler extends RemoteHandlerAbstract {
@@ -74,6 +71,9 @@ export default class S3Handler extends RemoteHandlerAbstract {
}),
})
// Workaround for https://github.com/aws/aws-sdk-js-v3/issues/2673
this.#s3.middlewareStack.use(getApplyMd5BodyChecksumPlugin(this.#s3.config))
const parts = split(path)
this.#bucket = parts.shift()
this.#dir = join(...parts)
@@ -223,35 +223,11 @@ export default class S3Handler extends RemoteHandlerAbstract {
}
}
async _outputStream(path, input, { streamLength, maxStreamLength = streamLength, validator }) {
// S3 storage is limited to 10K part, each part is limited to 5GB. And the total upload must be smaller than 5TB
// a bigger partSize increase the memory consumption of aws/lib-storage exponentially
let partSize
if (maxStreamLength === undefined) {
warn(`Writing ${path} to a S3 remote without a max size set will cut it to 50GB`, { path })
partSize = MIN_PART_SIZE // min size for S3
} else {
partSize = Math.min(Math.max(Math.ceil(maxStreamLength / MAX_PART_NUMBER), MIN_PART_SIZE), MAX_PART_SIZE)
}
// ensure we don't try to upload a stream to big for this partSize
let readCounter = 0
const MAX_SIZE = MAX_PART_NUMBER * partSize
const streamCutter = new Transform({
transform(chunk, encoding, callback) {
readCounter += chunk.length
if (readCounter > MAX_SIZE) {
callback(new Error(`read ${readCounter} bytes, maximum size allowed is ${MAX_SIZE} `))
} else {
callback(null, chunk)
}
},
})
async _outputStream(path, input, { validator }) {
// Workaround for "ReferenceError: ReadableStream is not defined"
// https://github.com/aws/aws-sdk-js-v3/issues/2522
const Body = new PassThrough()
pipeline(input, streamCutter, Body, () => {})
pipeline(input, Body, () => {})
const upload = new Upload({
client: this.#s3,
@@ -259,8 +235,6 @@ export default class S3Handler extends RemoteHandlerAbstract {
...this.#createParams(path),
Body,
},
partSize,
leavePartsOnError: false,
})
await upload.done()
@@ -444,24 +418,6 @@ export default class S3Handler extends RemoteHandlerAbstract {
async _closeFile(fd) {}
async _sync() {
await super._sync()
try {
// if Object Lock is enabled, each upload must come with a contentMD5 header
// the computation of this md5 is memory-intensive, especially when uploading a stream
const res = await this.#s3.send(new GetObjectLockConfigurationCommand({ Bucket: this.#bucket }))
if (res.ObjectLockConfiguration?.ObjectLockEnabled === 'Enabled') {
// Workaround for https://github.com/aws/aws-sdk-js-v3/issues/2673
// will automatically add the contentMD5 header to any upload to S3
this.#s3.middlewareStack.use(getApplyMd5BodyChecksumPlugin(this.#s3.config))
}
} catch (error) {
if (error.Code !== 'ObjectLockConfigurationNotFoundError' && error.$metadata.httpStatusCode !== 501) {
throw error
}
}
}
useVhdDirectory() {
return true
}

View File

@@ -2,18 +2,6 @@
## **next**
- Explicit error if users attempt to connect from a slave host (PR [#7110](https://github.com/vatesfr/xen-orchestra/pull/7110))
- More compact UI (PR [#7159](https://github.com/vatesfr/xen-orchestra/pull/7159))
- Fix dashboard host patches list (PR [#7169](https://github.com/vatesfr/xen-orchestra/pull/7169))
- Ability to export selected VMs (PR [#7174](https://github.com/vatesfr/xen-orchestra/pull/7174))
## **0.1.5** (2023-11-07)
- Ability to snapshot/copy a VM from its view (PR [#7087](https://github.com/vatesfr/xen-orchestra/pull/7087))
- [Header] Replace logo with "XO LITE" (PR [#7118](https://github.com/vatesfr/xen-orchestra/pull/7118))
- New VM console toolbar + Ability to send Ctrl+Alt+Del (PR [#7088](https://github.com/vatesfr/xen-orchestra/pull/7088))
- Total overhaul of the modal system (PR [#7134](https://github.com/vatesfr/xen-orchestra/pull/7134))
## **0.1.4** (2023-10-03)
- Ability to migrate selected VMs to another host (PR [#7040](https://github.com/vatesfr/xen-orchestra/pull/7040))

View File

@@ -1,111 +0,0 @@
# Modal System Documentation
## Opening a modal
To open a modal, call `useModal(loader, props?)`.
- `loader`: The modal component loader (e.g. `() => import("path/to/MyModal.vue")`)
- `props`: The optional props to pass to the modal component
This will return an object with the following methods:
- `onApprove(cb)`:
- A function to register a callback to be called when the modal is approved.
- The callback will receive the modal payload as first argument, if any.
- The callback can return a Promise, in which case the modal will wait for it to resolve before closing.
- `onDecline(cb)`:
- A function to register a callback to be called when the modal is declined.
- The callback can return a Promise, in which case the modal will wait for it to resolve before closing.
### Static modal
```ts
useModal(MyModal);
```
### Modal with props
```ts
useModal(MyModal, { message: "Hello world!" });
```
### Handle modal approval
```ts
const { onApprove } = useModal(MyModal, { message: "Hello world!" });
onApprove(() => console.log("Modal approved"));
```
### Handle modal approval with payload
```ts
const { onApprove } = useModal(MyModal, { message: "Hello world!" });
onApprove((payload) => console.log("Modal approved with payload", payload));
```
### Handle modal decline
```ts
const { onDecline } = useModal(MyModal, { message: "Hello world!" });
onDecline(() => console.log("Modal declined"));
```
### Handle modal close
```ts
const { onClose } = useModal(MyModal, { message: "Hello world!" });
onClose(() => console.log("Modal closed"));
```
## Modal controller
Inside the modal component, you can inject the modal controller with `inject(IK_MODAL)!`.
```ts
const modal = inject(IK_MODAL)!;
```
You can then use the following properties and methods on the `modal` object:
- `isBusy`: Whether the modal is currently doing something (e.g. waiting for a promise to resolve).
- `approve(payload?: any | Promise<any>)`: Approve the modal with an optional payload.
- Set `isBusy` to `true`.
- Wait for the `payload` to resolve (if any).
- Wait for all callbacks registered with `onApprove` to resolve (if any).
- Close the modal in case of success.
- `decline()`: Decline the modal.
- Set `isBusy` to `true`.
- Wait for all callbacks registered with `onDecline` to resolve (if any).
- Close the modal in case of success.
## Components
Some components are available for quick modal creation.
### `UiModal`
The root component of the modal which will display the backdrop.
A click on the backdrop will execute `modal.decline()`.
It accepts `color` and `disabled` props which will update the `ColorContext` and `DisabledContext`.
`DisabledContext` will also be set to `true` when `modal.isBusy` is `true`.
The component itself is a `form` and is meant to be used with `<UiModal @submit.prevent="modal.approve()">`.
### `ModalApproveButton`
A wrapper around `UiButton` with `type="submit"` and with the `busy` prop set to `modal.isBusy`.
### `ModalDeclineButton`
A wrapper around `UiButton` with an `outline` prop and with the `busy` prop set to `modal.isBusy`.
This button will call `modal.decline()` on click.
Default text is `$t("cancel")`.

View File

@@ -1,6 +1,6 @@
{
"name": "@xen-orchestra/lite",
"version": "0.1.5",
"version": "0.1.4",
"scripts": {
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
"build": "run-p type-check build-only",
@@ -11,7 +11,6 @@
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@fontsource/poppins": "^5.0.8",
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.2.0",
"@fortawesome/free-solid-svg-icons": "^6.2.0",

View File

@@ -1,4 +1,5 @@
<template>
<UnreachableHostsModal />
<div v-if="!$route.meta.hasStoryNav && !xenApiStore.isConnected">
<AppLogin />
</div>
@@ -12,7 +13,6 @@
</div>
<AppTooltips />
</div>
<ModalList />
</template>
<script lang="ts" setup>
@@ -21,9 +21,8 @@ import AppHeader from "@/components/AppHeader.vue";
import AppLogin from "@/components/AppLogin.vue";
import AppNavigation from "@/components/AppNavigation.vue";
import AppTooltips from "@/components/AppTooltips.vue";
import ModalList from "@/components/ui/modals/ModalList.vue";
import UnreachableHostsModal from "@/components/UnreachableHostsModal.vue";
import { useChartTheme } from "@/composables/chart-theme.composable";
import { useUnreachableHosts } from "@/composables/unreachable-hosts.composable";
import { useUiStore } from "@/stores/ui.store";
import { usePoolCollection } from "@/stores/xen-api/pool.store";
import { useXenApiStore } from "@/stores/xen-api.store";
@@ -79,8 +78,6 @@ whenever(
xenApiStore.getXapi().startWatching(poolRef);
}
);
useUnreachableHosts();
</script>
<style lang="postcss">
@@ -91,7 +88,7 @@ useUnreachableHosts();
.main {
overflow: auto;
flex: 1;
height: calc(100vh - 5.5rem);
height: calc(100vh - 8rem);
background-color: var(--background-color-secondary);
&.no-ui {

View File

@@ -1,11 +1,7 @@
@import "reset.css";
@import "theme.css";
@import "@fontsource/poppins/400.css";
@import "@fontsource/poppins/500.css";
@import "@fontsource/poppins/600.css";
@import "@fontsource/poppins/700.css";
@import "@fontsource/poppins/900.css";
@import "@fontsource/poppins/400-italic.css";
/* TODO Serve fonts locally */
@import url("https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;0,900;1,400;1,500;1,600;1,700;1,900&display=swap");
body {
min-height: 100vh;

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,6 +1,4 @@
:root {
--color-logo: #282467;
--color-blue-scale-000: #000000;
--color-blue-scale-100: #1a1b38;
--color-blue-scale-200: #595a6f;
@@ -61,10 +59,6 @@
}
:root.dark {
color-scheme: dark;
--color-logo: #e5e5e7;
--color-blue-scale-000: #ffffff;
--color-blue-scale-100: #e5e5e7;
--color-blue-scale-200: #9899a5;

View File

@@ -7,8 +7,7 @@
class="toggle-navigation"
/>
<RouterLink :to="{ name: 'home' }">
<img v-if="isMobile" alt="XO Lite" src="../assets/logo.svg" />
<TextLogo v-else />
<img alt="XO Lite" src="../assets/logo.svg" />
</RouterLink>
<slot />
<div class="right">
@@ -19,7 +18,6 @@
<script lang="ts" setup>
import AccountButton from "@/components/AccountButton.vue";
import TextLogo from "@/components/TextLogo.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { useNavigationStore } from "@/stores/navigation.store";
import { useUiStore } from "@/stores/ui.store";
@@ -38,7 +36,7 @@ const { trigger: navigationTrigger } = storeToRefs(navigationStore);
display: flex;
align-items: center;
justify-content: space-between;
height: 5.5rem;
min-height: 8rem;
padding: 1rem;
border-bottom: 0.1rem solid var(--color-blue-scale-400);
background-color: var(--background-color-secondary);
@@ -46,11 +44,6 @@ const { trigger: navigationTrigger } = storeToRefs(navigationStore);
img {
width: 4rem;
}
.text-logo {
margin-left: 1rem;
vertical-align: middle;
}
}
.right {

View File

@@ -2,34 +2,27 @@
<div class="app-login form-container">
<form @submit.prevent="handleSubmit">
<img alt="XO Lite" src="../assets/logo-title.svg" />
<p v-if="isHostIsSlaveErr(error)" class="error">
<UiIcon :icon="faExclamationCircle" />
{{ $t("login-only-on-master") }}
<a :href="masterUrl.href">{{ masterUrl.hostname }}</a>
</p>
<template v-else>
<FormInputWrapper>
<FormInput v-model="login" name="login" readonly type="text" />
</FormInputWrapper>
<FormInputWrapper>
<FormInput v-model="login" name="login" readonly type="text" />
</FormInputWrapper>
<FormInputWrapper :error="error">
<FormInput
name="password"
ref="passwordRef"
type="password"
v-model="password"
:class="{ error: isInvalidPassword }"
:placeholder="$t('password')"
:readonly="isConnecting"
required
/>
<LoginError :error="error" />
<label class="remember-me-label">
<FormCheckbox v-model="rememberMe" />
{{ $t("keep-me-logged") }}
</label>
<UiButton type="submit" :busy="isConnecting">
{{ $t("login") }}
</UiButton>
</template>
</FormInputWrapper>
<label class="remember-me-label">
<FormCheckbox v-model="rememberMe" />
<p>{{ $t("keep-me-logged") }}</p>
</label>
<UiButton type="submit" :busy="isConnecting">
{{ $t("login") }}
</UiButton>
</form>
</div>
</template>
@@ -39,16 +32,12 @@ import { usePageTitleStore } from "@/stores/page-title.store";
import { storeToRefs } from "pinia";
import { onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useLocalStorage, whenever } from "@vueuse/core";
import { useLocalStorage } from "@vueuse/core";
import FormCheckbox from "@/components/form/FormCheckbox.vue";
import FormInput from "@/components/form/FormInput.vue";
import FormInputWrapper from "@/components/form/FormInputWrapper.vue";
import LoginError from "@/components/LoginError.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import type { XenApiError } from "@/libs/xen-api/xen-api.types";
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons";
import { useXenApiStore } from "@/stores/xen-api.store";
const { t } = useI18n();
@@ -57,15 +46,12 @@ const xenApiStore = useXenApiStore();
const { isConnecting } = storeToRefs(xenApiStore);
const login = ref("root");
const password = ref("");
const error = ref<XenApiError>();
const error = ref<string>();
const passwordRef = ref<InstanceType<typeof FormInput>>();
const isInvalidPassword = ref(false);
const masterUrl = ref(new URL(window.origin));
const rememberMe = useLocalStorage("rememberMe", false);
const focusPasswordInput = () => passwordRef.value?.focus();
const isHostIsSlaveErr = (err: XenApiError | undefined) =>
err?.message === "HOST_IS_SLAVE";
onMounted(() => {
if (rememberMe.value) {
@@ -80,23 +66,18 @@ watch(password, () => {
error.value = undefined;
});
whenever(
() => isHostIsSlaveErr(error.value),
() => (masterUrl.value.hostname = error.value!.data)
);
async function handleSubmit() {
try {
await xenApiStore.connect(login.value, password.value);
} catch (err: any) {
if (err.message === "SESSION_AUTHENTICATION_FAILED") {
} catch (err) {
if ((err as Error).message === "SESSION_AUTHENTICATION_FAILED") {
focusPasswordInput();
isInvalidPassword.value = true;
error.value = t("password-invalid");
} else {
console.error(error);
error.value = t("error-occurred");
console.error(err);
}
error.value = err;
}
}
</script>
@@ -104,11 +85,14 @@ async function handleSubmit() {
<style lang="postcss" scoped>
.remember-me-label {
cursor: pointer;
display: flex;
margin: 1rem;
width: fit-content;
& .form-checkbox {
margin: auto 1rem auto auto;
margin: 1rem 1rem 1rem 0;
vertical-align: middle;
}
& p {
display: inline;
vertical-align: middle;
}
}
@@ -132,10 +116,6 @@ form {
margin: 0 auto;
padding: 8.5rem;
background-color: var(--background-color-secondary);
.error {
color: var(--color-red-vates-base);
}
}
h1 {

View File

@@ -48,7 +48,7 @@ whenever(isOpen, () => {
overflow: auto;
width: 37rem;
max-width: 37rem;
height: calc(100vh - 5.5rem);
height: calc(100vh - 8rem);
padding: 0.5rem;
border-right: 1px solid var(--color-blue-scale-400);
background-color: var(--background-color-primary);

View File

@@ -3,27 +3,79 @@
<UiFilter
v-for="filter in activeFilters"
:key="filter"
@edit="openModal(filter)"
@edit="editFilter(filter)"
@remove="emit('removeFilter', filter)"
>
{{ filter }}
</UiFilter>
<UiActionButton :icon="faPlus" class="add-filter" @click="openModal()">
<UiActionButton :icon="faPlus" class="add-filter" @click="open">
{{ $t("add-filter") }}
</UiActionButton>
</UiFilterGroup>
<UiModal v-model="isOpen">
<ConfirmModalLayout @submit.prevent="handleSubmit">
<template #default>
<div class="rows">
<CollectionFilterRow
v-for="(newFilter, index) in newFilters"
:key="newFilter.id"
v-model="newFilters[index]"
:available-filters="availableFilters"
@remove="removeNewFilter"
/>
</div>
<div
v-if="newFilters.some((filter) => filter.isAdvanced)"
class="available-properties"
>
{{ $t("available-properties-for-advanced-filter") }}
<div class="properties">
<UiBadge
v-for="(filter, property) in availableFilters"
:key="property"
:icon="getFilterIcon(filter)"
>
{{ property }}
</UiBadge>
</div>
</div>
</template>
<template #buttons>
<UiButton transparent @click="addNewFilter">
{{ $t("add-or") }}
</UiButton>
<UiButton :disabled="!isFilterValid" type="submit">
{{ $t(editedFilter ? "update" : "add") }}
</UiButton>
<UiButton outlined @click="close">
{{ $t("cancel") }}
</UiButton>
</template>
</ConfirmModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import CollectionFilterRow from "@/components/CollectionFilterRow.vue";
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import UiActionButton from "@/components/ui/UiActionButton.vue";
import UiBadge from "@/components/ui/UiBadge.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiFilter from "@/components/ui/UiFilter.vue";
import UiFilterGroup from "@/components/ui/UiFilterGroup.vue";
import { useModal } from "@/composables/modal.composable";
import type { Filters } from "@/types/filter";
import useModal from "@/composables/modal.composable";
import { getFilterIcon } from "@/libs/utils";
import type { Filters, NewFilter } from "@/types/filter";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { Or, parse } from "complex-matcher";
import { computed, ref } from "vue";
const props = defineProps<{
defineProps<{
activeFilters: string[];
availableFilters: Filters;
}>();
@@ -33,19 +85,110 @@ const emit = defineEmits<{
(event: "removeFilter", filter: string): void;
}>();
const openModal = (editedFilter?: string) => {
const { onApprove } = useModal<string>(
() => import("@/components/modals/CollectionFilterModal.vue"),
{
availableFilters: props.availableFilters,
editedFilter,
}
const { isOpen, open, close } = useModal({ onClose: () => reset() });
const newFilters = ref<NewFilter[]>([]);
let newFilterId = 0;
const addNewFilter = () =>
newFilters.value.push({
id: newFilterId++,
content: "",
isAdvanced: false,
builder: { property: "", comparison: "", value: "", negate: false },
});
const removeNewFilter = (id: number) => {
const index = newFilters.value.findIndex((newFilter) => newFilter.id === id);
if (index >= 0) {
newFilters.value.splice(index, 1);
}
};
addNewFilter();
const generatedFilter = computed(() => {
const filters = newFilters.value.filter(
(newFilter) => newFilter.content !== ""
);
if (editedFilter !== undefined) {
onApprove(() => emit("removeFilter", editedFilter));
if (filters.length === 0) {
return "";
}
onApprove((newFilter) => emit("addFilter", newFilter));
if (filters.length === 1) {
return filters[0].content;
}
return `|(${filters.map((filter) => filter.content).join(" ")})`;
});
const isFilterValid = computed(() => generatedFilter.value !== "");
const editedFilter = ref();
const editFilter = (filter: string) => {
const parsedFilter = parse(filter);
const nodes =
parsedFilter instanceof Or ? parsedFilter.children : [parsedFilter];
newFilters.value = nodes.map((node) => ({
id: newFilterId++,
content: node.toString(),
isAdvanced: true,
builder: { property: "", comparison: "", value: "", negate: false },
}));
editedFilter.value = filter;
open();
};
const reset = () => {
editedFilter.value = "";
newFilters.value = [];
addNewFilter();
};
const handleSubmit = () => {
if (editedFilter.value) {
emit("removeFilter", editedFilter.value);
}
emit("addFilter", generatedFilter.value);
reset();
close();
};
</script>
<style lang="postcss" scoped>
.properties {
font-size: 1.6rem;
margin-top: 1rem;
ul {
margin-left: 1rem;
}
li {
cursor: pointer;
&:hover {
opacity: 0.7;
}
}
}
.available-properties {
margin-top: 1rem;
}
.properties {
display: flex;
margin-top: 0.6rem;
gap: 0.5rem;
}
.rows {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>

View File

@@ -12,27 +12,65 @@
</span>
</UiFilter>
<UiActionButton :icon="faPlus" class="add-sort" @click="openModal()">
<UiActionButton :icon="faPlus" class="add-sort" @click="open">
{{ $t("add-sort") }}
</UiActionButton>
</UiFilterGroup>
<UiModal v-model="isOpen">
<ConfirmModalLayout @submit.prevent="handleSubmit">
<template #default>
<div class="form-widgets">
<FormWidget :label="$t('sort-by')">
<select v-model="newSortProperty">
<option v-if="!newSortProperty"></option>
<option
v-for="(sort, property) in availableSorts"
:key="property"
:value="property"
>
{{ sort.label ?? property }}
</option>
</select>
</FormWidget>
<FormWidget>
<select v-model="newSortIsAscending">
<option :value="true">{{ $t("ascending") }}</option>
<option :value="false">{{ $t("descending") }}</option>
</select>
</FormWidget>
</div>
</template>
<template #buttons>
<UiButton type="submit">{{ $t("add") }}</UiButton>
<UiButton outlined @click="close">
{{ $t("cancel") }}
</UiButton>
</template>
</ConfirmModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import FormWidget from "@/components/FormWidget.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import UiActionButton from "@/components/ui/UiActionButton.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiFilter from "@/components/ui/UiFilter.vue";
import UiFilterGroup from "@/components/ui/UiFilterGroup.vue";
import { useModal } from "@/composables/modal.composable";
import type { ActiveSorts, NewSort, Sorts } from "@/types/sort";
import useModal from "@/composables/modal.composable";
import type { ActiveSorts, Sorts } from "@/types/sort";
import {
faCaretDown,
faCaretUp,
faPlus,
} from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
import { ref } from "vue";
const props = defineProps<{
defineProps<{
availableSorts: Sorts;
activeSorts: ActiveSorts<Record<string, any>>;
}>();
@@ -43,19 +81,29 @@ const emit = defineEmits<{
(event: "removeSort", property: string): void;
}>();
const openModal = () => {
const { onApprove } = useModal<NewSort>(
() => import("@/components/modals/CollectionSorterModal.vue"),
{ availableSorts: computed(() => props.availableSorts) }
);
const { isOpen, open, close } = useModal({ onClose: () => reset() });
onApprove(({ property, isAscending }) =>
emit("addSort", property, isAscending)
);
const newSortProperty = ref();
const newSortIsAscending = ref<boolean>(true);
const reset = () => {
newSortProperty.value = undefined;
newSortIsAscending.value = true;
};
const handleSubmit = () => {
emit("addSort", newSortProperty.value, newSortIsAscending.value);
reset();
close();
};
</script>
<style lang="postcss" scoped>
.form-widgets {
display: flex;
gap: 1rem;
}
.property {
display: inline-flex;
align-items: center;

View File

@@ -2,25 +2,26 @@
<UiCardSpinner v-if="!areSomeLoaded" />
<UiTable v-else class="hosts-patches-table" :class="{ desktop: isDesktop }">
<tr v-for="patch in sortedPatches" :key="patch.$id">
<th>
<span v-tooltip="{ placement: 'left', content: patch.version }">
{{ patch.name }}
</span>
</th>
<td v-if="hasMultipleHosts">
<UiSpinner v-if="!areAllLoaded" />
<UiCounter
v-else
v-tooltip="{
placement: 'left',
content: $t('n-hosts-awaiting-patch', {
n: patch.$hostRefs.size,
}),
}"
:value="patch.$hostRefs.size"
class="counter"
color="error"
/>
<th>{{ patch.name }}</th>
<td>
<div class="version">
{{ patch.version }}
<template v-if="hasMultipleHosts">
<UiSpinner v-if="!areAllLoaded" />
<UiCounter
v-else
v-tooltip="{
placement: 'left',
content: $t('n-hosts-awaiting-patch', {
n: patch.$hostRefs.size,
}),
}"
:value="patch.$hostRefs.size"
class="counter"
color="error"
/>
</template>
</div>
</td>
</tr>
</UiTable>
@@ -44,15 +45,9 @@ const props = defineProps<{
}>();
const sortedPatches = computed(() =>
[...props.patches].sort((patch1, patch2) => {
if (patch1.changelog == null) {
return 1;
} else if (patch2.changelog == null) {
return -1;
}
return patch1.changelog.date - patch2.changelog.date;
})
[...props.patches].sort(
(patch1, patch2) => patch1.changelog.date - patch2.changelog.date
)
);
const { isDesktop } = useUiStore();
@@ -63,6 +58,13 @@ const { isDesktop } = useUiStore();
max-width: 45rem;
}
.version {
display: flex;
gap: 1rem;
justify-content: flex-end;
align-items: center;
}
.counter {
font-size: 1rem;
}

View File

@@ -1,34 +0,0 @@
<template>
<div class="error" v-if="error !== undefined">
<UiIcon :icon="faExclamationCircle" />
<span v-if="error.message === 'SESSION_AUTHENTICATION_FAILED'">
{{ $t("password-invalid") }}
</span>
<span v-else>
{{ $t("error-occurred") }}
</span>
</div>
</template>
<script lang="ts" setup>
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import type { XenApiError } from "@/libs/xen-api/xen-api.types";
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons";
defineProps<{
error: XenApiError | undefined;
}>();
</script>
<style lang="postcss" scoped>
.error {
font-size: 1.3rem;
line-height: 150%;
margin: 0.5rem 0;
color: var(--color-red-vates-base);
& svg {
margin-right: 0.5rem;
}
}
</style>

View File

@@ -105,10 +105,6 @@ watchEffect(() => {
onBeforeUnmount(() => {
clearVncClient();
});
defineExpose({
sendCtrlAltDel: () => vncClient?.sendCtrlAltDel(),
});
</script>
<style lang="postcss" scoped>

View File

@@ -1,37 +0,0 @@
<template>
<svg
class="text-logo"
viewBox="300.85 622.73 318.32 63.27"
xmlns="http://www.w3.org/2000/svg"
width="100"
height="22"
>
<g>
<polygon
points="355.94 684.92 341.54 684.92 327.84 664.14 315.68 684.92 301.81 684.92 317.59 659.25 338.96 659.25 355.94 684.92"
/>
<path
d="M406.2,627.17c4.62,2.64,8.27,6.33,10.94,11.07,2.67,4.74,4.01,10.1,4.01,16.07s-1.34,11.35-4.01,16.12c-2.67,4.77-6.32,8.48-10.94,11.12-4.63,2.64-9.78,3.97-15.47,3.97s-10.85-1.32-15.47-3.97c-4.63-2.64-8.27-6.35-10.95-11.12-2.67-4.77-4.01-10.14-4.01-16.12s1.34-11.33,4.01-16.07c2.67-4.74,6.32-8.43,10.95-11.07,4.62-2.64,9.78-3.97,15.47-3.97s10.84,1.32,15.47,3.97Zm-24.86,9.65c-2.7,1.61-4.81,3.92-6.33,6.94-1.52,3.02-2.28,6.54-2.28,10.56s.76,7.54,2.28,10.56c1.52,3.02,3.63,5.33,6.33,6.94,2.7,1.61,5.83,2.41,9.39,2.41s6.69-.8,9.39-2.41c2.7-1.61,4.81-3.92,6.33-6.94,1.52-3.02,2.28-6.53,2.28-10.56s-.76-7.54-2.28-10.56-3.63-5.33-6.33-6.94c-2.7-1.61-5.83-2.41-9.39-2.41s-6.69,.8-9.39,2.41Z"
/>
<polygon
points="354.99 624.06 339.53 649.22 317.49 649.22 300.86 624.06 315.26 624.06 328.96 644.84 341.12 624.06 354.99 624.06"
/>
<g>
<path d="M476.32,675.94h20.81v10.04h-33.47v-63.14h12.66v53.1Z" />
<path d="M517.84,622.84v63.14h-12.66v-63.14h12.66Z" />
<path
d="M573.29,622.84v10.22h-16.82v52.92h-12.66v-52.92h-16.83v-10.22h46.31Z"
/>
<path
d="M595.18,633.06v15.83h21.26v10.04h-21.26v16.73h23.97v10.31h-36.64v-63.23h36.64v10.31h-23.97Z"
/>
</g>
</g>
</svg>
</template>
<style lang="postcss" scoped>
.text-logo {
fill: var(--color-logo);
}
</style>

View File

@@ -27,20 +27,20 @@ defineProps<{
.title-bar {
display: flex;
align-items: center;
height: 6rem;
padding: 0 1.5rem;
height: 8rem;
padding: 0 2rem;
border-bottom: 1px solid var(--color-blue-scale-400);
background-color: var(--background-color-primary);
gap: 0.8rem;
gap: 1.5rem;
}
.icon {
font-size: 2.5rem;
font-size: 3.8rem;
color: var(--color-extra-blue-base);
}
.title {
font-size: 2.5rem;
font-size: 3rem;
color: var(--color-blue-scale-100);
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<UiModal v-model="isSslModalOpen" color="error">
<ConfirmModalLayout :icon="faServer">
<template #title>{{ $t("unreachable-hosts") }}</template>
<template #default>
<div class="description">
<p>{{ $t("following-hosts-unreachable") }}</p>
<p>{{ $t("allow-self-signed-ssl") }}</p>
<ul>
<li v-for="url in unreachableHostsUrls" :key="url">
<a :href="url" class="link" rel="noopener" target="_blank">{{
url
}}</a>
</li>
</ul>
</div>
</template>
<template #buttons>
<UiButton color="success" @click="reload">
{{ $t("unreachable-hosts-reload-page") }}
</UiButton>
<UiButton @click="closeSslModal">{{ $t("cancel") }}</UiButton>
</template>
</ConfirmModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import UiButton from "@/components/ui/UiButton.vue";
import useModal from "@/composables/modal.composable";
import { useHostCollection } from "@/stores/xen-api/host.store";
import { faServer } from "@fortawesome/free-solid-svg-icons";
import { difference } from "lodash-es";
import { ref, watch } from "vue";
const { records: hosts } = useHostCollection();
const unreachableHostsUrls = ref<Set<string>>(new Set());
const reload = () => window.location.reload();
const { isOpen: isSslModalOpen, close: closeSslModal } = useModal({
onClose: () => unreachableHostsUrls.value.clear(),
});
watch(
() => unreachableHostsUrls.value.size,
(size) => {
isSslModalOpen.value = size > 0;
},
{ immediate: true }
);
watch(hosts, (nextHosts, previousHosts) => {
difference(nextHosts, previousHosts).forEach((host) => {
const url = new URL("http://localhost");
url.protocol = window.location.protocol;
url.hostname = host.address;
fetch(url, { mode: "no-cors" }).catch(() =>
unreachableHostsUrls.value.add(url.toString())
);
});
});
</script>
<style lang="postcss" scoped>
.description {
text-align: center;
p {
margin: 1rem 0;
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div class="usage-bar">
<div>
<template v-if="data !== undefined">
<div
v-for="item in computedData.sortedArray"
@@ -67,12 +67,6 @@ const computedData = computed(() => {
</script>
<style lang="postcss" scoped>
.usage-bar {
display: flex;
flex-direction: column;
gap: 1rem;
}
.progress-item:nth-child(1) {
--progress-bar-color: var(--color-extra-blue-d60);
}

View File

@@ -1,4 +1,9 @@
<template>
<UiModal v-model="isRawValueModalOpen">
<BasicModalLayout>
<CodeHighlight :code="rawValueModalPayload" />
</BasicModalLayout>
</UiModal>
<StoryParamsTable>
<thead>
<tr>
@@ -96,7 +101,9 @@ import CodeHighlight from "@/components/CodeHighlight.vue";
import StoryParamsTable from "@/components/component-story/StoryParamsTable.vue";
import StoryWidget from "@/components/component-story/StoryWidget.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { useModal } from "@/composables/modal.composable";
import BasicModalLayout from "@/components/ui/modals/layouts/BasicModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import useModal from "@/composables/modal.composable";
import useSortedCollection from "@/composables/sorted-collection.composable";
import { vTooltip } from "@/directives/tooltip.directive";
import type { PropParam } from "@/libs/story/story-param";
@@ -124,10 +131,11 @@ const emit = defineEmits<{
const model = useVModel(props, "modelValue", emit);
const openRawValueModal = (code: string) =>
useModal(() => import("@/components/modals/CodeHighlightModal.vue"), {
code,
});
const {
open: openRawValueModal,
isOpen: isRawValueModalOpen,
payload: rawValueModalPayload,
} = useModal<string>();
</script>
<style lang="postcss" scoped>

View File

@@ -1,80 +0,0 @@
<template>
<FormInputGroup>
<FormNumber v-model="sizeInput" :max-decimals="3" />
<FormSelect v-model="prefixInput">
<option
v-for="currentPrefix in availablePrefixes"
:key="currentPrefix"
:value="currentPrefix"
>
{{ currentPrefix }}B
</option>
</FormSelect>
</FormInputGroup>
</template>
<script lang="ts" setup>
import FormInputGroup from "@/components/form/FormInputGroup.vue";
import FormNumber from "@/components/form/FormNumber.vue";
import FormSelect from "@/components/form/FormSelect.vue";
import { useVModel } from "@vueuse/core";
import humanFormat, { type Prefix } from "human-format";
import { ref, watch } from "vue";
const props = defineProps<{
modelValue: number | undefined;
}>();
const emit = defineEmits<{
(event: "update:modelValue", value: number): number;
}>();
const availablePrefixes: Prefix<"binary">[] = ["Ki", "Mi", "Gi"];
const model = useVModel(props, "modelValue", emit, {
shouldEmit: (value) => value !== props.modelValue,
});
const sizeInput = ref();
const prefixInput = ref();
const scale = humanFormat.Scale.create(availablePrefixes, 1024, 1);
watch([sizeInput, prefixInput], ([newSize, newPrefix]) => {
if (newSize === "" || newSize === undefined) {
return;
}
model.value = humanFormat.parse(`${newSize || 0} ${newPrefix || "Ki"}`, {
scale,
});
});
watch(
() => props.modelValue,
(newValue) => {
if (newValue === undefined) {
sizeInput.value = undefined;
if (prefixInput.value === undefined) {
prefixInput.value = availablePrefixes[0];
}
return;
}
const { value, prefix } = humanFormat.raw(newValue, {
scale,
prefix: prefixInput.value,
});
console.log(value);
sizeInput.value = value;
if (value !== 0) {
prefixInput.value = prefix;
}
},
{ immediate: true }
);
</script>

View File

@@ -273,7 +273,6 @@ defineExpose({
.textarea {
height: auto;
min-height: 2em;
overflow: hidden;
}
.input {

View File

@@ -1,18 +1,46 @@
<template>
<UiModal
v-model="isCodeModalOpen"
:color="isJsonValid ? 'success' : 'error'"
closable
>
<FormModalLayout @submit.prevent="saveJson" :icon="faCode">
<template #default>
<FormTextarea class="modal-textarea" v-model="editedJson" />
</template>
<template #buttons>
<UiButton transparent @click="formatJson">
{{ $t("reformat") }}
</UiButton>
<UiButton outlined @click="closeCodeModal">
{{ $t("cancel") }}
</UiButton>
<UiButton :disabled="!isJsonValid" type="submit">
{{ $t("save") }}
</UiButton>
</template>
</FormModalLayout>
</UiModal>
<FormInput
:before="faCode"
@click="openCodeModal"
:model-value="jsonValue"
:before="faCode"
readonly
@click="openModal()"
/>
</template>
<script lang="ts" setup>
import FormInput from "@/components/form/FormInput.vue";
import { useModal } from "@/composables/modal.composable";
import FormTextarea from "@/components/form/FormTextarea.vue";
import FormModalLayout from "@/components/ui/modals/layouts/FormModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import UiButton from "@/components/ui/UiButton.vue";
import useModal from "@/composables/modal.composable";
import { faCode } from "@fortawesome/free-solid-svg-icons";
import { useVModel } from "@vueuse/core";
import { computed } from "vue";
import { useVModel, whenever } from "@vueuse/core";
import { computed, ref } from "vue";
const props = defineProps<{
modelValue: any;
@@ -24,14 +52,51 @@ const emit = defineEmits<{
const model = useVModel(props, "modelValue", emit);
const {
open: openCodeModal,
close: closeCodeModal,
isOpen: isCodeModalOpen,
} = useModal();
const jsonValue = computed(() => JSON.stringify(model.value, undefined, 2));
const openModal = () => {
const { onApprove } = useModal<string>(
() => import("@/components/modals/JsonEditorModal.vue"),
{ initialValue: jsonValue.value }
);
const isJsonValid = computed(() => {
try {
JSON.parse(editedJson.value);
return true;
} catch {
return false;
}
});
onApprove((newValue) => (model.value = JSON.parse(newValue)));
const formatJson = () => {
if (!isJsonValid.value) {
return;
}
editedJson.value = JSON.stringify(JSON.parse(editedJson.value), undefined, 2);
};
const saveJson = () => {
if (!isJsonValid.value) {
return;
}
formatJson();
model.value = JSON.parse(editedJson.value);
closeCodeModal();
};
whenever(isCodeModalOpen, () => (editedJson.value = jsonValue.value));
const editedJson = ref();
</script>
<style lang="postcss" scoped>
:deep(.modal-textarea) {
min-width: 50rem;
min-height: 20rem;
}
</style>

View File

@@ -1,77 +0,0 @@
<template>
<FormInput v-model="localValue" inputmode="decimal" />
</template>
<script lang="ts" setup>
import FormInput from "@/components/form/FormInput.vue";
import { computed, ref, watch } from "vue";
const props = defineProps<{
modelValue: number | undefined;
maxDecimals?: number;
}>();
const emit = defineEmits<{
(event: "update:modelValue", value: number | undefined): void;
}>();
const localValue = ref("");
const hasTrailingDot = ref(false);
const cleaningRegex = computed(() => {
if (props.maxDecimals === undefined) {
// Any number with optional decimal part
return /(\d*\.?\d*)/;
}
if (props.maxDecimals > 0) {
// Numbers with up to `props.maxDecimals` decimal places
return new RegExp(`(\\d*\\.?\\d{0,${props.maxDecimals}})`);
}
// Integer numbers only
return /(\d*)/;
});
watch(
localValue,
(newLocalValue) => {
const cleanValue =
localValue.value
.replace(",", ".")
.replace(/[^0-9.]/g, "")
.match(cleaningRegex.value)?.[0] ?? "";
hasTrailingDot.value = cleanValue.endsWith(".");
if (cleanValue !== newLocalValue) {
localValue.value = cleanValue;
return;
}
if (newLocalValue === "") {
emit("update:modelValue", undefined);
return;
}
const parsedValue = parseFloat(cleanValue);
emit(
"update:modelValue",
Number.isNaN(parsedValue) ? undefined : parsedValue
);
},
{ flush: "post" }
);
watch(
() => props.modelValue,
(newModelValue) => {
localValue.value = `${newModelValue?.toString() ?? ""}${
hasTrailingDot.value ? "." : ""
}`;
},
{ immediate: true }
);
</script>

View File

@@ -63,12 +63,12 @@ const [isExpanded, toggle] = useToggle(true);
<style lang="postcss" scoped>
.infra-host-item:deep(.link),
.infra-host-item:deep(.link-placeholder) {
padding-left: 2rem;
padding-left: 3rem;
}
.infra-vm-list:deep(.link),
.infra-vm-list:deep(.link-placeholder) {
padding-left: 3rem;
padding-left: 4.5rem;
}
.master-icon {

View File

@@ -72,7 +72,7 @@ const hasTooltip = computed(() => hasEllipsis(textElement.value));
align-items: center;
flex: 1;
min-width: 0;
padding: 1rem;
padding: 1.5rem;
text-decoration: none;
color: inherit;
gap: 1rem;

View File

@@ -42,7 +42,7 @@ const { isReady, hasError, pool } = usePoolCollection();
.infra-vm-list:deep(.link),
.infra-vm-list:deep(.link-placeholder) {
padding-left: 2rem;
padding-left: 3rem;
}
.text-error {

View File

@@ -43,6 +43,10 @@ const { stop } = useIntersectionObserver(rootElement, ([entry]) => {
</script>
<style lang="postcss" scoped>
.infra-vm-item {
height: 6rem;
}
.infra-action {
color: var(--color-extra-blue-d60);

View File

@@ -25,11 +25,10 @@ defineProps<{
align-items: center;
height: 4.4rem;
padding-right: 1.5rem;
padding-left: 1.5rem;
padding-left: 1rem;
white-space: nowrap;
border-radius: 0.8rem;
gap: 1rem;
background-color: var(--color-blue-scale-500);
&.disabled {
color: var(--color-blue-scale-400);

View File

@@ -1,17 +0,0 @@
<template>
<UiModal>
<BasicModalLayout>
<CodeHighlight :code="code" />
</BasicModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import CodeHighlight from "@/components/CodeHighlight.vue";
import BasicModalLayout from "@/components/ui/modals/layouts/BasicModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
defineProps<{
code: any;
}>();
</script>

View File

@@ -1,154 +0,0 @@
<template>
<UiModal @submit.prevent="modal.approve(generatedFilter)">
<ConfirmModalLayout>
<template #default>
<div class="rows">
<CollectionFilterRow
v-for="(newFilter, index) in newFilters"
:key="newFilter.id"
v-model="newFilters[index]"
:available-filters="availableFilters"
@remove="removeNewFilter($event)"
/>
</div>
<div
v-if="newFilters.some((filter) => filter.isAdvanced)"
class="available-properties"
>
{{ $t("available-properties-for-advanced-filter") }}
<div class="properties">
<UiBadge
v-for="(filter, property) in availableFilters"
:key="property"
:icon="getFilterIcon(filter)"
>
{{ property }}
</UiBadge>
</div>
</div>
</template>
<template #buttons>
<UiButton transparent @click="addNewFilter()">
{{ $t("add-or") }}
</UiButton>
<ModalDeclineButton />
<ModalApproveButton :disabled="!isFilterValid">
{{ $t(editedFilter ? "update" : "add") }}
</ModalApproveButton>
</template>
</ConfirmModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import CollectionFilterRow from "@/components/CollectionFilterRow.vue";
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import ModalApproveButton from "@/components/ui/modals/ModalApproveButton.vue";
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import UiBadge from "@/components/ui/UiBadge.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { getFilterIcon } from "@/libs/utils";
import type { Filters, NewFilter } from "@/types/filter";
import { IK_MODAL } from "@/types/injection-keys";
import { Or, parse } from "complex-matcher";
import { computed, inject, onMounted, ref } from "vue";
const props = defineProps<{
availableFilters: Filters;
editedFilter?: string;
}>();
const modal = inject(IK_MODAL)!;
const newFilters = ref<NewFilter[]>([]);
let newFilterId = 0;
const addNewFilter = () =>
newFilters.value.push({
id: newFilterId++,
content: "",
isAdvanced: false,
builder: { property: "", comparison: "", value: "", negate: false },
});
const removeNewFilter = (id: number) => {
const index = newFilters.value.findIndex((newFilter) => newFilter.id === id);
if (index >= 0) {
newFilters.value.splice(index, 1);
}
};
const generatedFilter = computed(() => {
const filters = newFilters.value.filter(
(newFilter) => newFilter.content !== ""
);
if (filters.length === 0) {
return "";
}
if (filters.length === 1) {
return filters[0].content;
}
return `|(${filters.map((filter) => filter.content).join(" ")})`;
});
const isFilterValid = computed(() => generatedFilter.value !== "");
onMounted(() => {
if (props.editedFilter === undefined) {
addNewFilter();
return;
}
const parsedFilter = parse(props.editedFilter);
const nodes =
parsedFilter instanceof Or ? parsedFilter.children : [parsedFilter];
newFilters.value = nodes.map((node) => ({
id: newFilterId++,
content: node.toString(),
isAdvanced: true,
builder: { property: "", comparison: "", value: "", negate: false },
}));
});
</script>
<style lang="postcss" scoped>
.properties {
font-size: 1.6rem;
margin-top: 1rem;
ul {
margin-left: 1rem;
}
li {
cursor: pointer;
&:hover {
opacity: 0.7;
}
}
}
.available-properties {
margin-top: 1rem;
}
.properties {
display: flex;
margin-top: 0.6rem;
gap: 0.5rem;
}
.rows {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>

View File

@@ -1,67 +0,0 @@
<template>
<UiModal @submit.prevent="handleSubmit">
<ConfirmModalLayout>
<template #default>
<div class="form-widgets">
<FormWidget :label="$t('sort-by')">
<select v-model="newSortProperty">
<option v-if="!newSortProperty"></option>
<option
v-for="(sort, property) in availableSorts"
:key="property"
:value="property"
>
{{ sort.label ?? property }}
</option>
</select>
</FormWidget>
<FormWidget>
<select v-model="newSortIsAscending">
<option :value="true">{{ $t("ascending") }}</option>
<option :value="false">{{ $t("descending") }}</option>
</select>
</FormWidget>
</div>
</template>
<template #buttons>
<ModalDeclineButton />
<ModalApproveButton>{{ $t("add") }}</ModalApproveButton>
</template>
</ConfirmModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import FormWidget from "@/components/FormWidget.vue";
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import ModalApproveButton from "@/components/ui/modals/ModalApproveButton.vue";
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import { IK_MODAL } from "@/types/injection-keys";
import type { NewSort, Sorts } from "@/types/sort";
import { inject, ref } from "vue";
defineProps<{
availableSorts: Sorts;
}>();
const newSortProperty = ref();
const newSortIsAscending = ref<boolean>(true);
const modal = inject(IK_MODAL)!;
const handleSubmit = () => {
modal.approve<NewSort>({
property: newSortProperty.value,
isAscending: newSortIsAscending.value,
});
};
</script>
<style lang="postcss" scoped>
.form-widgets {
display: flex;
gap: 1rem;
}
</style>

View File

@@ -1,75 +0,0 @@
<template>
<UiModal
:color="isJsonValid ? 'success' : 'error'"
@submit.prevent="handleSubmit()"
>
<FormModalLayout :icon="faCode" class="layout">
<template #default>
<FormTextarea v-model="editedJson" class="modal-textarea" />
</template>
<template #buttons>
<UiButton transparent @click="formatJson()">
{{ $t("reformat") }}
</UiButton>
<ModalDeclineButton />
<ModalApproveButton :disabled="!isJsonValid">
{{ $t("save") }}
</ModalApproveButton>
</template>
</FormModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import FormTextarea from "@/components/form/FormTextarea.vue";
import FormModalLayout from "@/components/ui/modals/layouts/FormModalLayout.vue";
import ModalApproveButton from "@/components/ui/modals/ModalApproveButton.vue";
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { IK_MODAL } from "@/types/injection-keys";
import { faCode } from "@fortawesome/free-solid-svg-icons";
import { computed, inject, ref } from "vue";
const props = defineProps<{
initialValue?: string;
}>();
const editedJson = ref<string>(props.initialValue ?? "");
const modal = inject(IK_MODAL)!;
const isJsonValid = computed(() => {
try {
JSON.parse(editedJson.value);
return true;
} catch {
return false;
}
});
const formatJson = () => {
if (!isJsonValid.value) {
return;
}
editedJson.value = JSON.stringify(JSON.parse(editedJson.value), undefined, 2);
};
const handleSubmit = () => {
if (!isJsonValid.value) {
return;
}
formatJson();
modal.approve(editedJson.value);
};
</script>
<style lang="postcss" scoped>
.layout:deep(.modal-textarea) {
min-width: 50rem;
min-height: 20rem;
}
</style>

View File

@@ -1,54 +0,0 @@
<template>
<UiModal color="error" @submit="modal.approve()">
<ConfirmModalLayout :icon="faServer">
<template #title>{{ $t("unreachable-hosts") }}</template>
<template #default>
<div class="description">
<p>{{ $t("following-hosts-unreachable") }}</p>
<p>{{ $t("allow-self-signed-ssl") }}</p>
<ul>
<li v-for="url in urls" :key="url">
<a :href="url" class="link" rel="noopener" target="_blank">
{{ url }}
</a>
</li>
</ul>
</div>
</template>
<template #buttons>
<ModalDeclineButton />
<ModalApproveButton>
{{ $t("unreachable-hosts-reload-page") }}
</ModalApproveButton>
</template>
</ConfirmModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import ModalApproveButton from "@/components/ui/modals/ModalApproveButton.vue";
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import { IK_MODAL } from "@/types/injection-keys";
import { faServer } from "@fortawesome/free-solid-svg-icons";
import { inject } from "vue";
defineProps<{
urls: string[];
}>();
const modal = inject(IK_MODAL)!;
</script>
<style lang="postcss" scoped>
.description {
text-align: center;
p {
margin: 1rem 0;
}
}
</style>

View File

@@ -1,53 +0,0 @@
<template>
<UiModal @submit.prevent="handleSubmit()">
<ConfirmModalLayout :icon="faSatellite">
<template #title>
<i18n-t keypath="confirm-delete" scope="global" tag="div">
<span :class="textClass">
{{ $t("n-vms", { n: vmRefs.length }) }}
</span>
</i18n-t>
</template>
<template #subtitle>
{{ $t("please-confirm") }}
</template>
<template #buttons>
<ModalDeclineButton>
{{ $t("go-back") }}
</ModalDeclineButton>
<ModalApproveButton>
{{ $t("delete-vms", { n: vmRefs.length }) }}
</ModalApproveButton>
</template>
</ConfirmModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import ModalApproveButton from "@/components/ui/modals/ModalApproveButton.vue";
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import { useContext } from "@/composables/context.composable";
import { ColorContext } from "@/context";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
import { useXenApiStore } from "@/stores/xen-api.store";
import { IK_MODAL } from "@/types/injection-keys";
import { faSatellite } from "@fortawesome/free-solid-svg-icons";
import { inject } from "vue";
const props = defineProps<{
vmRefs: XenApiVm["$ref"][];
}>();
const modal = inject(IK_MODAL)!;
const { textClass } = useContext(ColorContext);
const handleSubmit = () => {
const xenApi = useXenApiStore().getXapi();
modal.approve(xenApi.vm.delete(props.vmRefs));
};
</script>

View File

@@ -1,56 +0,0 @@
<template>
<UiModal>
<FormModalLayout :icon="faDisplay">
<template #title>
{{ $t("export-n-vms-manually", { n: labelWithUrl.length }) }}
</template>
<p>
{{ $t("export-vms-manually-information") }}
</p>
<ul class="list">
<li v-for="({ url, label }, index) in labelWithUrl" :key="index">
<a :href="url.href" target="_blank">
{{ label }}
</a>
</li>
</ul>
<template #buttons>
<ModalDeclineButton />
</template>
</FormModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import FormModalLayout from "@/components/ui/modals/layouts/FormModalLayout.vue";
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
const props = defineProps<{
blockedUrls: URL[];
}>();
const { getByOpaqueRef } = useVmCollection();
const labelWithUrl = computed(() =>
props.blockedUrls.map((url) => {
const ref = url.searchParams.get("ref") as XenApiVm["$ref"];
return {
url: url,
label: getByOpaqueRef(ref)?.name_label ?? ref,
};
})
);
</script>
<style lang="postcss" scoped>
.list {
margin-top: 2rem;
}
</style>

View File

@@ -1,65 +0,0 @@
<template>
<UiModal @submit.prevent="handleSubmit">
<FormModalLayout :icon="faDisplay">
<template #title>
{{ $t("export-n-vms", { n: vmRefs.length }) }}
</template>
<FormInputWrapper
light
learn-more-url="https://xcp-ng.org/blog/2018/12/19/zstd-compression-for-xcp-ng/"
:label="$t('select-compression')"
>
<FormSelect v-model="compressionType">
<option
v-for="key in Object.keys(VM_COMPRESSION_TYPE)"
:key="key"
:value="
VM_COMPRESSION_TYPE[key as keyof typeof VM_COMPRESSION_TYPE]
"
>
{{ $t(key.toLowerCase()) }}
</option>
</FormSelect>
</FormInputWrapper>
<template #buttons>
<ModalDeclineButton />
<ModalApproveButton>
{{ $t("export-n-vms", { n: vmRefs.length }) }}
</ModalApproveButton>
</template>
</FormModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import { inject, ref } from "vue";
import FormInputWrapper from "@/components/form/FormInputWrapper.vue";
import FormSelect from "@/components/form/FormSelect.vue";
import FormModalLayout from "@/components/ui/modals/layouts/FormModalLayout.vue";
import ModalApproveButton from "@/components/ui/modals/ModalApproveButton.vue";
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import { IK_MODAL } from "@/types/injection-keys";
import { useXenApiStore } from "@/stores/xen-api.store";
import { VM_COMPRESSION_TYPE } from "@/libs/xen-api/xen-api.enums";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
const props = defineProps<{
vmRefs: XenApiVm["$ref"][];
}>();
const modal = inject(IK_MODAL)!;
const compressionType = ref(VM_COMPRESSION_TYPE.DISABLED);
const handleSubmit = () => {
const xenApi = useXenApiStore().getXapi();
xenApi.vm.export(props.vmRefs, compressionType.value);
modal.approve();
};
</script>

View File

@@ -1,64 +0,0 @@
<template>
<UiModal @submit.prevent="handleSubmit()">
<FormModalLayout>
<template #title>
{{ $t("migrate-n-vms", { n: vmRefs.length }) }}
</template>
<div>
<FormInputWrapper :label="$t('select-destination-host')" light>
<FormSelect v-model="selectedHost">
<option :value="undefined">
{{ $t("select-destination-host") }}
</option>
<option
v-for="host in availableHosts"
:key="host.$ref"
:value="host"
>
{{ host.name_label }}
</option>
</FormSelect>
</FormInputWrapper>
</div>
<template #buttons>
<ModalDeclineButton />
<ModalApproveButton>
{{ $t("migrate-n-vms", { n: vmRefs.length }) }}
</ModalApproveButton>
</template>
</FormModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import FormInputWrapper from "@/components/form/FormInputWrapper.vue";
import FormSelect from "@/components/form/FormSelect.vue";
import FormModalLayout from "@/components/ui/modals/layouts/FormModalLayout.vue";
import ModalApproveButton from "@/components/ui/modals/ModalApproveButton.vue";
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import { useVmMigration } from "@/composables/vm-migration.composable";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
import { IK_MODAL } from "@/types/injection-keys";
import { inject } from "vue";
const props = defineProps<{
vmRefs: XenApiVm["$ref"][];
}>();
const modal = inject(IK_MODAL)!;
const { selectedHost, availableHosts, isValid, migrate } = useVmMigration(
() => props.vmRefs
);
const handleSubmit = () => {
if (!isValid.value) {
return;
}
modal.approve(migrate());
};
</script>

View File

@@ -82,7 +82,7 @@ const {
}
}
.table-container {
max-height: 25rem;
max-height: 24rem;
overflow: auto;
}
</style>

View File

@@ -12,7 +12,7 @@
<UiProgressBar :max-value="maxValue" :value="value" color="custom" />
<UiProgressScale :max-value="maxValue" :steps="1" unit="%" />
<UiProgressLegend :label="$t('vcpus')" :value="`${value}%`" />
<UiCardFooter class="ui-card-footer">
<UiCardFooter>
<template #left>
<p>{{ $t("vcpus-used") }}</p>
<p class="footer-value">{{ nVCpuInUse }}</p>
@@ -113,8 +113,4 @@ const hasError = computed(
color: var(--footer-value-color);
}
}
.ui-card-footer {
margin-top: 2rem;
}
</style>

View File

@@ -2,7 +2,7 @@
<UiCard>
<UiCardTitle class="patches-title">
{{ $t("patches") }}
<template v-if="areAllLoaded && count > 0" #right>
<template v-if="areAllLoaded" #right>
{{ $t("n-missing", { n: count }) }}
</template>
</UiCardTitle>
@@ -35,7 +35,7 @@ const { count, patches, areSomeLoaded, areAllLoaded } = useHostPatches(hosts);
}
.table-container {
max-height: 25rem;
max-height: 40rem;
overflow: auto;
}
</style>

View File

@@ -43,7 +43,6 @@ const isDisplayed = computed(
font-weight: 700;
font-size: 14px;
color: var(--color-blue-scale-300);
margin-top: 2rem;
}
.summary-card {

View File

@@ -26,11 +26,11 @@ const isTabBarDisabled = useContext(DisabledContext, () => props.disabled);
<style lang="postcss" scoped>
.ui-tab {
font-size: 1.6rem;
font-size: 1.8rem;
font-weight: 600;
display: flex;
align-items: center;
padding: 0 1.5rem;
padding: 0 1.2em;
text-decoration: none;
text-transform: uppercase;
color: var(--color-blue-scale-100);

View File

@@ -22,7 +22,7 @@ useContext(DisabledContext, () => props.disabled);
.ui-tab-bar {
display: flex;
align-items: stretch;
height: 5rem;
height: 6.5rem;
border-bottom: 1px solid var(--color-blue-scale-400);
background-color: var(--background-color-primary);
max-width: 100%;

View File

@@ -20,7 +20,7 @@ defineProps<{
border-spacing: 0;
background-color: var(--background-color-primary);
font-weight: 400;
font-size: 1.4rem;
font-size: 1.6rem;
line-height: 2.4rem;
color: var(--color-blue-scale-200);

View File

@@ -1,13 +0,0 @@
<template>
<UiButton :busy="modal.isBusy" type="submit">
<slot />
</UiButton>
</template>
<script lang="ts" setup>
import UiButton from "@/components/ui/UiButton.vue";
import { IK_MODAL } from "@/types/injection-keys";
import { inject } from "vue";
const modal = inject(IK_MODAL)!;
</script>

View File

@@ -3,7 +3,7 @@
:class="textClass"
:icon="faXmark"
class="modal-close-icon"
@click="modal?.decline()"
@click="close"
/>
</template>
@@ -11,13 +11,13 @@
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { useContext } from "@/composables/context.composable";
import { ColorContext } from "@/context";
import { IK_MODAL } from "@/types/injection-keys";
import { IK_MODAL_CLOSE } from "@/types/injection-keys";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { inject } from "vue";
const { textClass } = useContext(ColorContext);
const modal = inject(IK_MODAL, undefined);
const close = inject(IK_MODAL_CLOSE, undefined);
</script>
<style lang="postcss" scoped>

View File

@@ -1,5 +1,9 @@
<template>
<div :class="[backgroundClass, { nested: isNested }]" class="modal-container">
<component
:is="tag"
:class="[backgroundClass, { nested: isNested }]"
class="modal-container"
>
<header v-if="$slots.header" class="modal-header">
<slot name="header" />
</header>
@@ -9,7 +13,7 @@
<footer v-if="$slots.footer" class="modal-footer">
<slot name="footer" />
</footer>
</div>
</component>
</template>
<script lang="ts" setup>
@@ -19,9 +23,13 @@ import type { Color } from "@/types";
import { IK_MODAL_NESTED } from "@/types/injection-keys";
import { inject, provide } from "vue";
const props = defineProps<{
color?: Color;
}>();
const props = withDefaults(
defineProps<{
tag?: string;
color?: Color;
}>(),
{ tag: "div" }
);
defineSlots<{
header: () => any;

View File

@@ -1,13 +0,0 @@
<template>
<UiButton outlined :busy="modal.isBusy" @click="modal.decline()">
<slot>{{ $t("cancel") }}</slot>
</UiButton>
</template>
<script lang="ts" setup>
import UiButton from "@/components/ui/UiButton.vue";
import { IK_MODAL } from "@/types/injection-keys";
import { inject } from "vue";
const modal = inject(IK_MODAL)!;
</script>

View File

@@ -1,14 +0,0 @@
<template>
<ModalListItem
v-for="modal in modalStore.modals"
:key="modal.id"
:modal="modal"
/>
</template>
<script lang="ts" setup>
import ModalListItem from "@/components/ui/modals/ModalListItem.vue";
import { useModalStore } from "@/stores/modal.store";
const modalStore = useModalStore();
</script>

View File

@@ -1,15 +0,0 @@
<template>
<component :is="modal.component" v-bind="modal.props" />
</template>
<script lang="ts" setup>
import type { ModalController } from "@/types";
import { IK_MODAL } from "@/types/injection-keys";
import { provide } from "vue";
const props = defineProps<{
modal: ModalController;
}>();
provide(IK_MODAL, props.modal);
</script>

View File

@@ -1,36 +1,39 @@
<template>
<Teleport to="body">
<form class="ui-modal" v-bind="$attrs" @click.self="modal.decline()">
<div v-if="isOpen" class="ui-modal" @click.self="close">
<slot />
</form>
</div>
</Teleport>
</template>
<script lang="ts" setup>
import { useContext } from "@/composables/context.composable";
import { ColorContext, DisabledContext } from "@/context";
import { ColorContext } from "@/context";
import type { Color } from "@/types";
import { IK_MODAL } from "@/types/injection-keys";
import { useMagicKeys, whenever } from "@vueuse/core/index";
import { inject } from "vue";
import { IK_MODAL_CLOSE } from "@/types/injection-keys";
import { useMagicKeys, useVModel, whenever } from "@vueuse/core/index";
import { provide } from "vue";
const props = defineProps<{
modelValue: boolean;
color?: Color;
disabled?: boolean;
}>();
defineOptions({
inheritAttrs: false,
});
const emit = defineEmits<{
(event: "update:modelValue", value: boolean): void;
}>();
const modal = inject(IK_MODAL)!;
const isOpen = useVModel(props, "modelValue", emit);
const close = () => (isOpen.value = false);
provide(IK_MODAL_CLOSE, close);
useContext(ColorContext, () => props.color);
useContext(DisabledContext, () => props.disabled || modal.isBusy);
const { escape } = useMagicKeys();
whenever(escape, () => modal.decline());
whenever(escape, () => close());
</script>
<style lang="postcss" scoped>

View File

@@ -1,5 +1,5 @@
<template>
<ModalContainer>
<ModalContainer tag="form">
<template #header>
<div class="close-bar">
<ModalCloseIcon />

View File

@@ -26,12 +26,16 @@ import ModalCloseIcon from "@/components/ui/modals/ModalCloseIcon.vue";
import ModalContainer from "@/components/ui/modals/ModalContainer.vue";
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
import { useContext } from "@/composables/context.composable";
import { ColorContext } from "@/context";
import { ColorContext, DisabledContext } from "@/context";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
defineProps<{
icon?: IconDefinition;
}>();
const props = withDefaults(
defineProps<{
icon?: IconDefinition;
disabled?: boolean;
}>(),
{ disabled: undefined }
);
defineSlots<{
title: () => void;
@@ -40,6 +44,8 @@ defineSlots<{
}>();
const { textClass, borderClass } = useContext(ColorContext);
useContext(DisabledContext, () => props.disabled);
</script>
<style lang="postcss" scoped>

View File

@@ -51,6 +51,7 @@ const isTooltipEnabled = computed(() =>
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
margin: 1.6em 0;
}
.label-container {

View File

@@ -1,9 +1,6 @@
<template>
<MenuItem
v-tooltip="
!areAllSelectedVmsHalted &&
$t(isSingleAction ? 'vm-is-running' : 'selected-vms-in-execution')
"
v-tooltip="!areAllSelectedVmsHalted && $t('selected-vms-in-execution')"
:busy="areSomeSelectedVmsCloning"
:disabled="isDisabled"
:icon="faCopy"
@@ -25,7 +22,6 @@ import { computed } from "vue";
const props = defineProps<{
selectedRefs: XenApiVm["$ref"][];
isSingleAction?: boolean;
}>();
const { getByOpaqueRef, isOperationPending } = useVmCollection();

View File

@@ -7,23 +7,59 @@
>
{{ $t("delete") }}
</MenuItem>
<UiModal v-model="isDeleteModalOpen">
<ConfirmModalLayout :icon="faSatellite">
<template #title>
<i18n-t keypath="confirm-delete" scope="global" tag="div">
<span :class="textClass">
{{ $t("n-vms", { n: vmRefs.length }) }}
</span>
</i18n-t>
</template>
<template #subtitle>
{{ $t("please-confirm") }}
</template>
<template #buttons>
<UiButton outlined @click="closeDeleteModal">
{{ $t("go-back") }}
</UiButton>
<UiButton @click="deleteVms">
{{ $t("delete-vms", { n: vmRefs.length }) }}
</UiButton>
</template>
</ConfirmModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import MenuItem from "@/components/menu/MenuItem.vue";
import { useModal } from "@/composables/modal.composable";
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { useContext } from "@/composables/context.composable";
import useModal from "@/composables/modal.composable";
import { ColorContext } from "@/context";
import { vTooltip } from "@/directives/tooltip.directive";
import { VM_POWER_STATE } from "@/libs/xen-api/xen-api.enums";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
import { VM_POWER_STATE } from "@/libs/xen-api/xen-api.enums";
import { useXenApiStore } from "@/stores/xen-api.store";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { faSatellite, faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
const props = defineProps<{
vmRefs: XenApiVm["$ref"][];
}>();
const xenApi = useXenApiStore().getXapi();
const { getByOpaqueRef: getVm } = useVmCollection();
const {
open: openDeleteModal,
close: closeDeleteModal,
isOpen: isDeleteModalOpen,
} = useModal();
const vms = computed<XenApiVm[]>(() =>
props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined)
@@ -37,8 +73,10 @@ const isDisabled = computed(
() => vms.value.length === 0 || areSomeVmsInExecution.value
);
const openDeleteModal = () =>
useModal(() => import("@/components/modals/VmDeleteModal.vue"), {
vmRefs: props.vmRefs,
});
const deleteVms = async () => {
await xenApi.vm.delete(props.vmRefs);
closeDeleteModal();
};
const { textClass } = useContext(ColorContext);
</script>

View File

@@ -1,50 +1,51 @@
<template>
<MenuItem
v-tooltip="
vmRefs.length > 0 &&
!isSomeExportable &&
$t('no-selected-vm-can-be-exported')
"
:icon="faDisplay"
:disabled="isDisabled"
@click="openModal"
>
{{ $t("export-vms") }}
<MenuItem :icon="faFileExport">
{{ $t("export") }}
<template #submenu>
<MenuItem
v-tooltip="{ content: $t('coming-soon'), placement: 'left' }"
:icon="faDisplay"
>
{{ $t("export-vms") }}
</MenuItem>
<MenuItem
:icon="faCode"
@click="
exportVmsAsJsonFile(vms, `vms_${new Date().toISOString()}.json`)
"
>
{{ $t("export-table-to", { type: ".json" }) }}
</MenuItem>
<MenuItem
:icon="faFileCsv"
@click="exportVmsAsCsvFile(vms, `vms_${new Date().toISOString()}.csv`)"
>
{{ $t("export-table-to", { type: ".csv" }) }}
</MenuItem>
</template>
</MenuItem>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
import MenuItem from "@/components/menu/MenuItem.vue";
import { DisabledContext } from "@/context";
import { useContext } from "@/composables/context.composable";
import { useModal } from "@/composables/modal.composable";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { VM_OPERATION } from "@/libs/xen-api/xen-api.enums";
import { computed } from "vue";
import { exportVmsAsCsvFile, exportVmsAsJsonFile } from "@/libs/vm";
import MenuItem from "@/components/menu/MenuItem.vue";
import {
faCode,
faDisplay,
faFileCsv,
faFileExport,
} from "@fortawesome/free-solid-svg-icons";
import { vTooltip } from "@/directives/tooltip.directive";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
const props = defineProps<{ vmRefs: XenApiVm["$ref"][] }>();
const props = defineProps<{
vmRefs: XenApiVm["$ref"][];
}>();
const { getByOpaqueRefs, areSomeOperationAllowed } = useVmCollection();
const isParentDisabled = useContext(DisabledContext);
const isSomeExportable = computed(() =>
getByOpaqueRefs(props.vmRefs).some((vm) =>
areSomeOperationAllowed(vm, VM_OPERATION.EXPORT)
)
const { getByOpaqueRef: getVm } = useVmCollection();
const vms = computed(() =>
props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined)
);
const isDisabled = computed(
() => isParentDisabled.value || !isSomeExportable.value
);
const openModal = () => {
useModal(() => import("@/components/modals/VmExportModal.vue"), {
vmRefs: props.vmRefs,
});
};
</script>

View File

@@ -1,45 +0,0 @@
<template>
<MenuItem :icon="faFileExport">
{{ $t("export") }}
<template #submenu>
<VmActionExportItem :vmRefs="vmRefs" />
<MenuItem
:icon="faCode"
@click="
exportVmsAsJsonFile(vms, `vms_${new Date().toISOString()}.json`)
"
>
{{ $t("export-table-to", { type: ".json" }) }}
</MenuItem>
<MenuItem
:icon="faFileCsv"
@click="exportVmsAsCsvFile(vms, `vms_${new Date().toISOString()}.csv`)"
>
{{ $t("export-table-to", { type: ".csv" }) }}
</MenuItem>
</template>
</MenuItem>
</template>
<script lang="ts" setup>
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { computed } from "vue";
import { exportVmsAsCsvFile, exportVmsAsJsonFile } from "@/libs/vm";
import MenuItem from "@/components/menu/MenuItem.vue";
import VmActionExportItem from "@/components/vm/VmActionItems/VmActionExportItem.vue";
import {
faCode,
faFileCsv,
faFileExport,
} from "@fortawesome/free-solid-svg-icons";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
const props = defineProps<{
vmRefs: XenApiVm["$ref"][];
}>();
const { getByOpaqueRef: getVm } = useVmCollection();
const vms = computed(() =>
props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined)
);
</script>

View File

@@ -1,60 +1,95 @@
<template>
<MenuItem
v-tooltip="
selectedRefs.length > 0 &&
!isMigratable &&
$t('no-selected-vm-can-be-migrated')
!areAllVmsMigratable && $t('some-selected-vms-can-not-be-migrated')
"
:busy="isMigrating"
:disabled="isParentDisabled || !isMigratable"
:disabled="isParentDisabled || !areAllVmsMigratable"
:icon="faRoute"
@click="openModal()"
@click="openModal"
>
{{ $t("migrate") }}
</MenuItem>
<UiModal v-model="isModalOpen">
<FormModalLayout :disabled="isMigrating" @submit.prevent="handleMigrate">
<template #title>
{{ $t("migrate-n-vms", { n: selectedRefs.length }) }}
</template>
<div>
<FormInputWrapper :label="$t('select-destination-host')" light>
<FormSelect v-model="selectedHost">
<option :value="undefined">
{{ $t("select-destination-host") }}
</option>
<option
v-for="host in availableHosts"
:key="host.$ref"
:value="host"
>
{{ host.name_label }}
</option>
</FormSelect>
</FormInputWrapper>
</div>
<template #buttons>
<UiButton outlined @click="closeModal">
{{ isMigrating ? $t("close") : $t("cancel") }}
</UiButton>
<UiButton :busy="isMigrating" :disabled="!isValid" type="submit">
{{ $t("migrate-n-vms", { n: selectedRefs.length }) }}
</UiButton>
</template>
</FormModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import FormInputWrapper from "@/components/form/FormInputWrapper.vue";
import FormSelect from "@/components/form/FormSelect.vue";
import MenuItem from "@/components/menu/MenuItem.vue";
import FormModalLayout from "@/components/ui/modals/layouts/FormModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { useContext } from "@/composables/context.composable";
import { useModal } from "@/composables/modal.composable";
import useModal from "@/composables/modal.composable";
import { useVmMigration } from "@/composables/vm-migration.composable";
import { DisabledContext } from "@/context";
import { vTooltip } from "@/directives/tooltip.directive";
import { VM_OPERATION } from "@/libs/xen-api/xen-api.enums";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { faRoute } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
const props = defineProps<{
selectedRefs: XenApiVm["$ref"][];
}>();
const { getByOpaqueRefs, isOperationPending, areSomeOperationAllowed } =
useVmCollection();
const isParentDisabled = useContext(DisabledContext);
const isMigratable = computed(() =>
getByOpaqueRefs(props.selectedRefs).some((vm) =>
areSomeOperationAllowed(vm, [
VM_OPERATION.POOL_MIGRATE,
VM_OPERATION.MIGRATE_SEND,
])
)
);
const {
open: openModal,
isOpen: isModalOpen,
close: closeModal,
} = useModal({
onClose: () => (selectedHost.value = undefined),
});
const isMigrating = computed(() =>
getByOpaqueRefs(props.selectedRefs).some((vm) =>
isOperationPending(vm, [
VM_OPERATION.POOL_MIGRATE,
VM_OPERATION.MIGRATE_SEND,
])
)
);
const {
selectedHost,
availableHosts,
isValid,
migrate,
isMigrating,
areAllVmsMigratable,
} = useVmMigration(() => props.selectedRefs);
const openModal = () =>
useModal(() => import("@/components/modals/VmMigrateModal.vue"), {
vmRefs: props.selectedRefs,
});
const handleMigrate = async () => {
try {
await migrate();
closeModal();
} catch (e) {
console.error("Error while migrating", e);
}
};
</script>

View File

@@ -11,23 +11,6 @@
</template>
<VmActionPowerStateItems :vm-refs="[vm.$ref]" />
</AppMenu>
<AppMenu v-if="vm !== undefined" placement="bottom-end" shadow>
<template #trigger="{ open, isOpen }">
<UiButton
:active="isOpen"
:icon="faEllipsisVertical"
@click="open"
transparent
class="more-actions-button"
v-tooltip="{
placement: 'left',
content: $t('more-actions'),
}"
/>
</template>
<VmActionCopyItem :selected-refs="[vm.$ref]" is-single-action />
<VmActionSnapshotItem :vm-refs="[vm.$ref]" />
</AppMenu>
</template>
</TitleBar>
</template>
@@ -38,15 +21,11 @@ import TitleBar from "@/components/TitleBar.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiButton from "@/components/ui/UiButton.vue";
import VmActionPowerStateItems from "@/components/vm/VmActionItems/VmActionPowerStateItems.vue";
import VmActionSnapshotItem from "@/components/vm/VmActionItems/VmActionSnapshotItem.vue";
import VmActionCopyItem from "@/components/vm/VmActionItems/VmActionCopyItem.vue";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { vTooltip } from "@/directives/tooltip.directive";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
import {
faAngleDown,
faDisplay,
faEllipsisVertical,
faPowerOff,
} from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
@@ -61,9 +40,3 @@ const vm = computed(() =>
const name = computed(() => vm.value?.name_label);
</script>
<style lang="postcss">
.more-actions-button {
font-size: 1.2em;
}
</style>

View File

@@ -21,7 +21,7 @@
{{ $t("edit-config") }}
</MenuItem>
<VmActionSnapshotItem :vm-refs="selectedRefs" />
<VmActionExportItems :vm-refs="selectedRefs" />
<VmActionExportItem :vm-refs="selectedRefs" />
<VmActionDeleteItem :vm-refs="selectedRefs" />
</AppMenu>
</template>
@@ -32,7 +32,7 @@ import MenuItem from "@/components/menu/MenuItem.vue";
import UiButton from "@/components/ui/UiButton.vue";
import VmActionCopyItem from "@/components/vm/VmActionItems/VmActionCopyItem.vue";
import VmActionDeleteItem from "@/components/vm/VmActionItems/VmActionDeleteItem.vue";
import VmActionExportItems from "@/components/vm/VmActionItems/VmActionExportItems.vue";
import VmActionExportItem from "@/components/vm/VmActionItems/VmActionExportItem.vue";
import VmActionMigrateItem from "@/components/vm/VmActionItems/VmActionMigrateItem.vue";
import VmActionPowerStateItems from "@/components/vm/VmActionItems/VmActionPowerStateItems.vue";
import VmActionSnapshotItem from "@/components/vm/VmActionItems/VmActionSnapshotItem.vue";

View File

@@ -0,0 +1,77 @@
# useModal composable
### Usage
#### API
`useModal<T>(options: ModalOptions)`
Type parameter:
- `T`: The type for the modal's payload.
Parameters:
- `options`: An optional object of type `ModalOptions`.
Returns an object with:
- `payload: ReadOnly<Ref<T | undefined>>`: The payload data of the modal. Mainly used if a single modal is used for
multiple items (typically with `v-for`)
- `isOpen: WritableComputedRef<boolean>`: A writable computed indicating if the modal is open or not.
- `open(currentPayload?: T)`: A function to open the modal and optionally set its payload.
- `close(force = false)`: A function to close the modal. If force is set to `true`, the modal will be closed without
calling the `confirmClose` callback.
#### Types
`ModalOptions`
An object type that accepts:
- `confirmClose?: () => boolean`: An optional callback that is called before the modal is closed. If this function
returns `false`, the modal will not be closed.
- `onClose?: () => void`: An optional callback that is called after the modal is closed.
### Example
```vue
<template>
<div v-for="item in items">
{{ item.name }}
<button @click="openRemoveModal(item)">Delete</button>
</div>
<UiModal v-model="isRemoveModalOpen">
<ModalContainer>
<template #header>
Are you sure you want to delete {{ removeModalPayload.name }}?
</template>
<template #footer>
<button @click="handleRemove">Yes</button>
<button @click="closeRemoveModal">No</button>
</template>
</ModalContainer>
</UiModal>
</template>
<script lang="ts" setup>
import useModal from "@/composables/modal.composable";
const {
payload: removeModalPayload,
isOpen: isRemoveModalOpen,
open: openRemoveModal,
close: closeRemoveModal,
} = useModal({
confirmClose: () =>
window.confirm("Are you sure you want to close this modal?"),
onClose: () => console.log("Modal closed"),
});
async function handleRemove() {
await removeItem(removeModalPayload.id);
closeRemoveModal();
}
</script>
```

View File

@@ -1,5 +1,48 @@
import { useModalStore } from "@/stores/modal.store";
import type { AsyncComponentLoader } from "vue";
import { computed, readonly, ref } from "vue";
export const useModal = <T>(loader: AsyncComponentLoader, props: object = {}) =>
useModalStore().open<T>(loader, props);
type ModalOptions = {
confirmClose?: () => boolean;
onClose?: () => void;
};
export default function useModal<T>(options: ModalOptions = {}) {
const $payload = ref<T>();
const $isOpen = ref(false);
const open = (payload?: T) => {
$isOpen.value = true;
$payload.value = payload;
};
const close = (force = false) => {
if (!force && options.confirmClose?.() === false) {
return;
}
if (options.onClose) {
options.onClose();
}
$isOpen.value = false;
$payload.value = undefined;
};
const isOpen = computed({
get() {
return $isOpen.value;
},
set(value) {
if (value) {
open();
} else {
close();
}
},
});
return {
payload: readonly($payload),
isOpen,
open,
close,
};
}

View File

@@ -1,37 +0,0 @@
import { useModal } from "@/composables/modal.composable";
import { useHostCollection } from "@/stores/xen-api/host.store";
import { whenever } from "@vueuse/core";
import { difference } from "lodash-es";
import { computed, ref, watch } from "vue";
export const useUnreachableHosts = () => {
const { records: hosts } = useHostCollection();
const unreachableHostsUrls = ref<Set<string>>(new Set());
watch(hosts, (nextHosts, previousHosts) => {
difference(nextHosts, previousHosts).forEach((host) => {
const url = new URL("http://localhost");
url.protocol = window.location.protocol;
url.hostname = host.address;
fetch(url, { mode: "no-cors" }).catch(() =>
unreachableHostsUrls.value.add(url.toString())
);
});
});
whenever(
() => unreachableHostsUrls.value.size > 0,
() => {
const { onApprove, onDecline } = useModal(
() => import("@/components/modals/UnreachableHostsModal.vue"),
{
urls: computed(() => Array.from(unreachableHostsUrls.value.values())),
}
);
onApprove(() => window.location.reload());
onDecline(() => unreachableHostsUrls.value.clear());
},
{ immediate: true }
);
};

View File

@@ -39,6 +39,12 @@ export const useVmMigration = (
.sort(sortRecordsByNameLabel);
});
const areAllVmsMigratable = computed(() =>
vms.value.every((vm) =>
vm.allowed_operations.includes(VM_OPERATION.POOL_MIGRATE)
)
);
const isValid = computed(
() =>
!isMigrating.value &&
@@ -69,6 +75,7 @@ export const useVmMigration = (
isMigrating,
availableHosts,
selectedHost,
areAllVmsMigratable,
isValid,
migrate,
};

View File

@@ -18,12 +18,6 @@ export const useXenApiStoreBaseContext = <
return recordsByOpaqueRef.get(opaqueRef);
};
const getByOpaqueRefs = (opaqueRefs: XRecord["$ref"][]) => {
return opaqueRefs
.map(getByOpaqueRef)
.filter((record) => record !== undefined) as XRecord[];
};
const getByUuid = (uuid: XRecord["uuid"]) => {
return recordsByUuid.get(uuid);
};
@@ -55,7 +49,6 @@ export const useXenApiStoreBaseContext = <
lastError,
records,
getByOpaqueRef,
getByOpaqueRefs,
getByUuid,
hasUuid,
add,

View File

@@ -82,8 +82,8 @@ const testMetric = (
typeof test === "string"
? test === type
: typeof test === "function"
? test(type)
: test.exec(type);
? test(type)
: test.exec(type);
const findMetric = (metrics: any, metricType: string) => {
let testResult;

View File

@@ -491,9 +491,3 @@ export enum CERTIFICATE_TYPE {
HOST = "host",
HOST_INTERNAL = "host_internal",
}
export enum VM_COMPRESSION_TYPE {
DISABLED = "false",
GZIP = "true",
ZSTD = "zstd",
}

View File

@@ -18,8 +18,6 @@ import type {
import { buildXoObject, typeToRawType } from "@/libs/xen-api/xen-api.utils";
import { JSONRPCClient } from "json-rpc-2.0";
import { castArray } from "lodash-es";
import type { VM_COMPRESSION_TYPE } from "@/libs/xen-api/xen-api.enums";
import { useModal } from "@/composables/modal.composable";
export default class XenApi {
private client: JSONRPCClient;
@@ -29,12 +27,10 @@ export default class XenApi {
Set<(...args: any[]) => void>
>();
private fromToken: string | undefined;
private hostUrl: string;
constructor(hostUrl: string) {
this.hostUrl = hostUrl;
this.client = new JSONRPCClient(async (request) => {
const response = await fetch(`${this.hostUrl}/jsonrpc`, {
const response = await fetch(`${hostUrl}/jsonrpc`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
@@ -384,36 +380,6 @@ export default class XenApi {
)
);
},
export: (vmRefs: VmRefs, compression: VM_COMPRESSION_TYPE) => {
const blockedUrls: URL[] = [];
castArray(vmRefs).forEach((vmRef) => {
const url = new URL(this.hostUrl);
url.pathname = "/export/";
url.search = new URLSearchParams({
session_id: this.sessionId!,
ref: vmRef,
use_compression: compression,
}).toString();
const _window = window.open(url.href, "_blank");
if (_window === null) {
blockedUrls.push(url);
} else {
URL.revokeObjectURL(url.toString());
}
});
if (blockedUrls.length > 0) {
const { onClose } = useModal(
() => import("@/components/modals/VmExportBlockedUrlsModal.vue"),
{ blockedUrls }
);
onClose(() =>
blockedUrls.forEach((url) => URL.revokeObjectURL(url.toString()))
);
}
},
};
}
}

View File

@@ -629,7 +629,3 @@ export type XenApiEvent<
ref: XRecord["$ref"];
snapshot: RawXenApiRecord<XRecord>;
};
export interface XenApiError extends Error {
data?: any;
}

View File

@@ -43,7 +43,6 @@
"delete-vms": "Delete 1 VM | Delete {n} VMs",
"descending": "descending",
"description": "Description",
"disabled": "Disabled",
"display": "Display",
"do-you-have-needs": "You have needs and/or expectations? Let us know",
"documentation": "Documentation",
@@ -52,11 +51,8 @@
"error-no-data": "Error, can't collect data.",
"error-occurred": "An error has occurred",
"export": "Export",
"export-n-vms": "Export 1 VM | Export {n} VMs",
"export-n-vms-manually": "Export 1 VM manually | Export {n} VMs manually",
"export-table-to": "Export table to {type}",
"export-vms": "Export VMs",
"export-vms-manually-information": "Some VM exports were not able to start automatically, probably due to your browser settings. To export them, you should click on each one. (Alternatively, copy the link as well.)",
"fetching-fresh-data": "Fetching fresh data",
"filter": {
"comparison": {
@@ -79,10 +75,7 @@
"following-hosts-unreachable": "The following hosts are unreachable",
"force-reboot": "Force reboot",
"force-shutdown": "Force shutdown",
"fullscreen": "Fullscreen",
"fullscreen-leave": "Leave fullscreen",
"go-back": "Go back",
"gzip": "gzip",
"here": "Here",
"hosts": "Hosts",
"keep-me-logged": "Keep me logged in",
@@ -93,8 +86,6 @@
"loading-hosts": "Loading hosts…",
"log-out": "Log out",
"login": "Login",
"login-only-on-master": "Login is only possible on the master host",
"more-actions": "More actions",
"migrate": "Migrate",
"migrate-n-vms": "Migrate 1 VM | Migrate {n} VMs",
"n-hosts-awaiting-patch": "{n} host is awaiting this patch | {n} hosts are awaiting this patch",
@@ -109,14 +100,12 @@
"news": "News",
"news-name": "{name} news",
"no-alarm-triggered": "No alarm triggered",
"no-selected-vm-can-be-exported": "No selected VM can be exported",
"no-selected-vm-can-be-migrated": "No selected VM can be migrated",
"no-tasks": "No tasks",
"not-found": "Not found",
"object": "Object",
"object-not-found": "Object {id} can't be found…",
"on-object": "on {object}",
"open-console-in-new-tab": "Open console in new tab",
"open-in-new-window": "Open in new window",
"or": "Or",
"page-not-found": "This page is not to be found…",
"password": "Password",
@@ -145,14 +134,13 @@
},
"resume": "Resume",
"save": "Save",
"select-compression": "Select a compression",
"select-destination-host": "Select a destination host",
"selected-vms-in-execution": "Some selected VMs are running",
"send-ctrl-alt-del": "Send Ctrl+Alt+Del",
"send-us-feedback": "Send us feedback",
"settings": "Settings",
"shutdown": "Shutdown",
"snapshot": "Snapshot",
"some-selected-vms-can-not-be-migrated": "Some selected VMs can't be migrated",
"sort-by": "Sort by",
"stacked-cpu-usage": "Stacked CPU usage",
"stacked-ram-usage": "Stacked RAM usage",
@@ -185,8 +173,6 @@
"vcpus": "vCPUs",
"vcpus-used": "vCPUs used",
"version": "Version",
"vm-is-running": "The VM is running",
"vms": "VMs",
"xo-lite-under-construction": "XOLite is under construction",
"zstd": "zstd"
"xo-lite-under-construction": "XOLite is under construction"
}

View File

@@ -43,7 +43,6 @@
"delete-vms": "Supprimer 1 VM | Supprimer {n} VMs",
"descending": "descendant",
"description": "Description",
"disabled": "Désactivé",
"display": "Affichage",
"do-you-have-needs": "Vous avez des besoins et/ou des attentes ? Faites le nous savoir",
"documentation": "Documentation",
@@ -52,11 +51,8 @@
"error-no-data": "Erreur, impossible de collecter les données.",
"error-occurred": "Une erreur est survenue",
"export": "Exporter",
"export-n-vms": "Exporter 1 VM | Exporter {n} VMs",
"export-n-vms-manually": "Exporter 1 VM manuellement | Exporter {n} VMs manuellement",
"export-table-to": "Exporter le tableau en {type}",
"export-vms": "Exporter les VMs",
"export-vms-manually-information": "Certaines exportations de VMs n'ont pas pu démarrer automatiquement, peut-être en raison des paramètres du navigateur. Pour les exporter, vous devrez cliquer sur chacune d'entre elles. (Ou copier le lien.)",
"fetching-fresh-data": "Récupération de données à jour",
"filter": {
"comparison": {
@@ -79,10 +75,7 @@
"following-hosts-unreachable": "Les hôtes suivants sont inaccessibles",
"force-reboot": "Forcer le redémarrage",
"force-shutdown": "Forcer l'arrêt",
"fullscreen": "Plein écran",
"fullscreen-leave": "Quitter plein écran",
"go-back": "Revenir en arrière",
"gzip": "gzip",
"here": "Ici",
"hosts": "Hôtes",
"keep-me-logged": "Rester connecté",
@@ -93,8 +86,6 @@
"loading-hosts": "Chargement des hôtes…",
"log-out": "Se déconnecter",
"login": "Connexion",
"login-only-on-master": "La connexion n'est possible que sur l'hôte primaire",
"more-actions": "Plus d'actions",
"migrate": "Migrer",
"migrate-n-vms": "Migrer 1 VM | Migrer {n} VMs",
"n-hosts-awaiting-patch": "{n} hôte attend ce patch | {n} hôtes attendent ce patch",
@@ -109,14 +100,12 @@
"news": "Actualités",
"news-name": "Actualités {name}",
"no-alarm-triggered": "Aucune alarme déclenchée",
"no-selected-vm-can-be-exported": "Aucune VM sélectionnée ne peut être exportée",
"no-selected-vm-can-be-migrated": "Aucune VM sélectionnée ne peut être migrée",
"no-tasks": "Aucune tâche",
"not-found": "Non trouvé",
"object": "Objet",
"object-not-found": "L'objet {id} est introuvable…",
"on-object": "sur {object}",
"open-console-in-new-tab": "Ouvrir la console dans un nouvel onglet",
"open-in-new-window": "Ouvrir dans une nouvelle fenêtre",
"or": "Ou",
"page-not-found": "Cette page est introuvable…",
"password": "Mot de passe",
@@ -145,14 +134,13 @@
},
"resume": "Reprendre",
"save": "Enregistrer",
"select-compression": "Sélectionnez une compression",
"select-destination-host": "Sélectionnez un hôte de destination",
"selected-vms-in-execution": "Certaines VMs sélectionnées sont en cours d'exécution",
"send-ctrl-alt-del": "Envoyer Ctrl+Alt+Suppr",
"send-us-feedback": "Envoyez-nous vos commentaires",
"settings": "Paramètres",
"shutdown": "Arrêter",
"snapshot": "Instantané",
"some-selected-vms-can-not-be-migrated": "Certaines VMs sélectionnées ne peuvent pas être migrées",
"sort-by": "Trier par",
"stacked-cpu-usage": "Utilisation CPU empilée",
"stacked-ram-usage": "Utilisation RAM empilée",
@@ -185,8 +173,6 @@
"vcpus": "vCPUs",
"vcpus-used": "vCPUs utilisés",
"version": "Version",
"vm-is-running": "La VM est en cours d'exécution",
"vms": "VMs",
"xo-lite-under-construction": "XOLite est en construction",
"zstd": "zstd"
"xo-lite-under-construction": "XOLite est en construction"
}

View File

@@ -1,75 +0,0 @@
import type { ModalController } from "@/types";
import { createEventHook } from "@vueuse/core";
import { defineStore } from "pinia";
import {
type AsyncComponentLoader,
computed,
defineAsyncComponent,
markRaw,
reactive,
ref,
} from "vue";
export const useModalStore = defineStore("modal", () => {
const modals = ref(new Map<symbol, ModalController>());
const open = <T>(loader: AsyncComponentLoader, props: object) => {
const id = Symbol();
const isBusy = ref(false);
const component = defineAsyncComponent(loader);
const approveEvent = createEventHook<T>();
const declineEvent = createEventHook();
const closeEvent = createEventHook();
const close = async () => {
await closeEvent.trigger(undefined);
modals.value.delete(id);
};
const approve = async (payload: any) => {
try {
isBusy.value = true;
const result = await payload;
await approveEvent.trigger(result);
void close();
} finally {
isBusy.value = false;
}
};
const decline = async () => {
try {
isBusy.value = true;
await declineEvent.trigger(undefined);
void close();
} finally {
isBusy.value = false;
}
};
modals.value.set(
id,
reactive({
id,
component: markRaw(component),
props,
approve,
decline,
isBusy,
})
);
return {
onApprove: approveEvent.on,
onDecline: declineEvent.on,
onClose: closeEvent.on,
id,
};
};
return {
open,
modals: computed(() => modals.value.values()),
};
});

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