Compare commits
10 Commits
refactor-r
...
feat_ssh_t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5853645bc | ||
|
|
b80a3449e5 | ||
|
|
f5a2cfebf1 | ||
|
|
b195e1e854 | ||
|
|
3c8bc08681 | ||
|
|
d201c26a68 | ||
|
|
5008a9c822 | ||
|
|
75dcea9e86 | ||
|
|
767ac4e738 | ||
|
|
1066cdea32 |
@@ -33,7 +33,7 @@
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^17.0.1",
|
||||
"sinon": "^16.0.0",
|
||||
"tap": "^16.3.0",
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
|
||||
@@ -13,15 +13,12 @@ describe('decorateWith', () => {
|
||||
const expectedFn = Function.prototype
|
||||
const newFn = () => {}
|
||||
|
||||
const decorator = decorateWith(
|
||||
function wrapper(fn, ...args) {
|
||||
assert.deepStrictEqual(fn, expectedFn)
|
||||
assert.deepStrictEqual(args, expectedArgs)
|
||||
const decorator = decorateWith(function wrapper(fn, ...args) {
|
||||
assert.deepStrictEqual(fn, expectedFn)
|
||||
assert.deepStrictEqual(args, expectedArgs)
|
||||
|
||||
return newFn
|
||||
},
|
||||
...expectedArgs
|
||||
)
|
||||
return newFn
|
||||
}, ...expectedArgs)
|
||||
|
||||
const descriptor = {
|
||||
configurable: true,
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"ensure-array": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^17.0.1",
|
||||
"sinon": "^16.0.0",
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"fuse-native": "^2.2.6",
|
||||
"lru-cache": "^7.14.0",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"vhd-lib": "^4.6.1"
|
||||
"vhd-lib": "^4.5.0"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^17.0.1",
|
||||
"sinon": "^16.0.0",
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.42.0",
|
||||
"@xen-orchestra/fs": "^4.1.0",
|
||||
"filenamify": "^6.0.0",
|
||||
"getopts": "^2.2.5",
|
||||
"lodash": "^4.17.15",
|
||||
@@ -27,7 +27,7 @@
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
},
|
||||
"version": "1.0.13",
|
||||
"version": "1.0.12",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -206,7 +206,7 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
||||
[TAG_BASE_DELTA]: undefined,
|
||||
[TAG_COPY_SRC]: vdi.uuid,
|
||||
},
|
||||
SR: mapVdisSrRefs[vdi.uuid] ?? sr.$ref,
|
||||
sr: mapVdisSrRefs[vdi.uuid] ?? sr.$ref,
|
||||
})
|
||||
)
|
||||
$defer.onFailure(() => suspendVdi.$destroy())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -31,11 +31,6 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
||||
throw new Error('cannot backup a VM created by this very job')
|
||||
}
|
||||
|
||||
const currentOperations = Object.values(vm.current_operations)
|
||||
if (currentOperations.some(_ => _ === 'migrate_send' || _ === 'pool_migrate')) {
|
||||
throw new Error('cannot backup a VM currently being migrated')
|
||||
}
|
||||
|
||||
this.config = config
|
||||
this.job = job
|
||||
this.remoteAdapters = remoteAdapters
|
||||
@@ -261,15 +256,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
||||
}
|
||||
|
||||
if (this._writers.size !== 0) {
|
||||
const { pool_migrate = null, migrate_send = null } = this._exportedVm.blocked_operations
|
||||
|
||||
const reason = 'VM migration is blocked during backup'
|
||||
await this._exportedVm.update_blocked_operations({ pool_migrate: reason, migrate_send: reason })
|
||||
try {
|
||||
await this._copy()
|
||||
} finally {
|
||||
await this._exportedVm.update_blocked_operations({ pool_migrate, migrate_send })
|
||||
}
|
||||
await this._copy()
|
||||
}
|
||||
} finally {
|
||||
if (startAfter) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "0.43.2",
|
||||
"version": "0.42.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",
|
||||
@@ -44,19 +44,19 @@
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"tar": "^6.1.15",
|
||||
"uuid": "^9.0.0",
|
||||
"vhd-lib": "^4.6.1",
|
||||
"vhd-lib": "^4.5.0",
|
||||
"xen-api": "^1.3.6",
|
||||
"yazl": "^2.5.1"
|
||||
},
|
||||
"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.1.0"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^17.0.1",
|
||||
"sinon": "^16.0.0",
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -98,6 +98,7 @@ export default class RemoteHandlerAbstract {
|
||||
const sharedLimit = limitConcurrency(options.maxParallelOperations ?? DEFAULT_MAX_PARALLEL_OPERATIONS)
|
||||
this.closeFile = sharedLimit(this.closeFile)
|
||||
this.copy = sharedLimit(this.copy)
|
||||
this.exists = sharedLimit(this.exists)
|
||||
this.getInfo = sharedLimit(this.getInfo)
|
||||
this.getSize = sharedLimit(this.getSize)
|
||||
this.list = sharedLimit(this.list)
|
||||
@@ -189,7 +190,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 +202,6 @@ export default class RemoteHandlerAbstract {
|
||||
}
|
||||
await this._outputStream(path, input, {
|
||||
dirMode,
|
||||
maxStreamLength,
|
||||
streamLength,
|
||||
validator,
|
||||
})
|
||||
if (checksum) {
|
||||
@@ -325,6 +324,14 @@ export default class RemoteHandlerAbstract {
|
||||
await this._rmtree(normalizePath(dir))
|
||||
}
|
||||
|
||||
async _exists(file){
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
async exists(file){
|
||||
return this._exists(normalizePath(file))
|
||||
|
||||
}
|
||||
|
||||
// Asks the handler to sync the state of the effective remote with its'
|
||||
// metadata
|
||||
//
|
||||
@@ -626,18 +633,14 @@ export default class RemoteHandlerAbstract {
|
||||
|
||||
const files = await this._list(dir)
|
||||
await asyncEach(files, file =>
|
||||
this._unlink(`${dir}/${file}`).catch(
|
||||
error => {
|
||||
// Unlink dir behavior is not consistent across platforms
|
||||
// https://github.com/nodejs/node-v0.x-archive/issues/5791
|
||||
if (error.code === 'EISDIR' || error.code === 'EPERM') {
|
||||
return this._rmtree(`${dir}/${file}`)
|
||||
}
|
||||
throw error
|
||||
},
|
||||
// real unlink concurrency will be 2**max directory depth
|
||||
{ concurrency: 2 }
|
||||
)
|
||||
this._unlink(`${dir}/${file}`).catch(error => {
|
||||
// Unlink dir behavior is not consistent across platforms
|
||||
// https://github.com/nodejs/node-v0.x-archive/issues/5791
|
||||
if (error.code === 'EISDIR' || error.code === 'EPERM') {
|
||||
return this._rmtree(`${dir}/${file}`)
|
||||
}
|
||||
throw error
|
||||
})
|
||||
)
|
||||
return this._rmtree(dir)
|
||||
}
|
||||
|
||||
@@ -206,4 +206,9 @@ export default class LocalHandler extends RemoteHandlerAbstract {
|
||||
_writeFile(file, data, { flags }) {
|
||||
return this.#addSyncStackTrace(fs.writeFile, this.getFilePath(file), data, { flag: flags })
|
||||
}
|
||||
|
||||
async _exists(file){
|
||||
const exists = await fs.pathExists(this._getFilePath(file))
|
||||
return exists
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,25 +418,20 @@ 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
|
||||
}
|
||||
|
||||
async _exists(file){
|
||||
try{
|
||||
await this._s3.send(new HeadObjectCommand(this._createParams(file)))
|
||||
return true
|
||||
}catch(error){
|
||||
// normalize this error code
|
||||
if (error.name === 'NoSuchKey') {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,23 +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))
|
||||
|
||||
## **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))
|
||||
- Ability to snapshot selected VMs (PR [#7021](https://github.com/vatesfr/xen-orchestra/pull/7021))
|
||||
- Add Patches to Pool Dashboard (PR [#6709](https://github.com/vatesfr/xen-orchestra/pull/6709))
|
||||
- Add remember me checkbox on the login page (PR [#7030](https://github.com/vatesfr/xen-orchestra/pull/7030))
|
||||
|
||||
## **0.1.3** (2023-09-01)
|
||||
|
||||
- Add Alarms to Pool Dashboard (PR [#6976](https://github.com/vatesfr/xen-orchestra/pull/6976))
|
||||
|
||||
@@ -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")`.
|
||||
@@ -1,4 +1,4 @@
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xen-orchestra/lite",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.3",
|
||||
"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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,34 +2,23 @@
|
||||
<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>
|
||||
<UiButton type="submit" :busy="isConnecting">
|
||||
{{ $t("login") }}
|
||||
</UiButton>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -39,16 +28,9 @@ 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 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,22 +39,15 @@ 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) {
|
||||
xenApiStore.reconnect();
|
||||
} else {
|
||||
focusPasswordInput();
|
||||
}
|
||||
xenApiStore.reconnect();
|
||||
focusPasswordInput();
|
||||
});
|
||||
|
||||
watch(password, () => {
|
||||
@@ -80,38 +55,23 @@ 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>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.remember-me-label {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
margin: 1rem;
|
||||
width: fit-content;
|
||||
& .form-checkbox {
|
||||
margin: auto 1rem auto auto;
|
||||
}
|
||||
}
|
||||
|
||||
.form-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -127,15 +87,12 @@ form {
|
||||
font-size: 2rem;
|
||||
min-width: 30em;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
padding: 8.5rem;
|
||||
background-color: var(--background-color-secondary);
|
||||
|
||||
.error {
|
||||
color: var(--color-red-vates-base);
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
@@ -147,7 +104,7 @@ h1 {
|
||||
|
||||
img {
|
||||
width: 40rem;
|
||||
margin: auto auto 5rem auto;
|
||||
margin-bottom: 5rem;
|
||||
}
|
||||
|
||||
input {
|
||||
@@ -161,6 +118,6 @@ input {
|
||||
}
|
||||
|
||||
button {
|
||||
margin: 2rem auto;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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-if="isOpen"
|
||||
:icon="faFilter"
|
||||
@submit.prevent="handleSubmit"
|
||||
@close="handleCancel"
|
||||
>
|
||||
<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 #buttons>
|
||||
<UiButton transparent @click="addNewFilter">
|
||||
{{ $t("add-or") }}
|
||||
</UiButton>
|
||||
<UiButton :disabled="!isFilterValid" type="submit">
|
||||
{{ $t(editedFilter ? "update" : "add") }}
|
||||
</UiButton>
|
||||
<UiButton outlined @click="handleCancel">
|
||||
{{ $t("cancel") }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Or, parse } from "complex-matcher";
|
||||
import { computed, ref } from "vue";
|
||||
import type { Filters, NewFilter } from "@/types/filter";
|
||||
import { faFilter, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import CollectionFilterRow from "@/components/CollectionFilterRow.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 { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import UiModal from "@/components/ui/UiModal.vue";
|
||||
import useModal from "@/composables/modal.composable";
|
||||
import { getFilterIcon } from "@/libs/utils";
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
activeFilters: string[];
|
||||
availableFilters: Filters;
|
||||
}>();
|
||||
@@ -33,19 +85,109 @@ 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();
|
||||
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();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -219,6 +219,7 @@ const valueInputAfter = computed(() =>
|
||||
.collection-filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid var(--background-color-secondary);
|
||||
gap: 1rem;
|
||||
|
||||
@@ -241,8 +242,4 @@ const valueInputAfter = computed(() =>
|
||||
.form-widget-advanced {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ui-action-button:first-of-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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-if="isOpen"
|
||||
:icon="faSort"
|
||||
@submit.prevent="handleSubmit"
|
||||
@close="handleCancel"
|
||||
>
|
||||
<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 #buttons>
|
||||
<UiButton type="submit">{{ $t("add") }}</UiButton>
|
||||
<UiButton outlined @click="handleCancel">
|
||||
{{ $t("cancel") }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import FormWidget from "@/components/FormWidget.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 UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import UiModal from "@/components/ui/UiModal.vue";
|
||||
import useModal from "@/composables/modal.composable";
|
||||
import type { ActiveSorts, Sorts } from "@/types/sort";
|
||||
import {
|
||||
faCaretDown,
|
||||
faCaretUp,
|
||||
faPlus,
|
||||
faSort,
|
||||
} 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,34 @@ 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();
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
reset();
|
||||
close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.form-widgets {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.property {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -28,9 +28,13 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in filteredAndSortedCollection" :key="item.$ref">
|
||||
<tr v-for="item in filteredAndSortedCollection" :key="item[idProperty]">
|
||||
<td v-if="isSelectable">
|
||||
<input v-model="selected" :value="item.$ref" type="checkbox" />
|
||||
<input
|
||||
v-model="selected"
|
||||
:value="item[props.idProperty]"
|
||||
type="checkbox"
|
||||
/>
|
||||
</td>
|
||||
<slot :item="item" name="body-row" />
|
||||
</tr>
|
||||
@@ -38,7 +42,10 @@
|
||||
</UiTable>
|
||||
</template>
|
||||
|
||||
<script generic="T extends XenApiRecord<any>" lang="ts" setup>
|
||||
<script lang="ts" setup>
|
||||
import { computed, toRef, watch } from "vue";
|
||||
import type { Filters } from "@/types/filter";
|
||||
import type { Sorts } from "@/types/sort";
|
||||
import CollectionFilter from "@/components/CollectionFilter.vue";
|
||||
import CollectionSorter from "@/components/CollectionSorter.vue";
|
||||
import UiTable from "@/components/ui/UiTable.vue";
|
||||
@@ -47,20 +54,17 @@ import useCollectionSorter from "@/composables/collection-sorter.composable";
|
||||
import useFilteredCollection from "@/composables/filtered-collection.composable";
|
||||
import useMultiSelect from "@/composables/multi-select.composable";
|
||||
import useSortedCollection from "@/composables/sorted-collection.composable";
|
||||
import type { XenApiRecord } from "@/libs/xen-api/xen-api.types";
|
||||
import type { Filters } from "@/types/filter";
|
||||
import type { Sorts } from "@/types/sort";
|
||||
import { computed, toRef, watch } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: T["$ref"][];
|
||||
modelValue?: string[];
|
||||
availableFilters?: Filters;
|
||||
availableSorts?: Sorts;
|
||||
collection: T[];
|
||||
collection: Record<string, any>[];
|
||||
idProperty: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", selectedRefs: T["$ref"][]): void;
|
||||
(event: "update:modelValue", selectedRefs: string[]): void;
|
||||
}>();
|
||||
|
||||
const isSelectable = computed(() => props.modelValue !== undefined);
|
||||
@@ -81,10 +85,12 @@ const filteredAndSortedCollection = useSortedCollection(
|
||||
compareFn
|
||||
);
|
||||
|
||||
const usableRefs = computed(() => props.collection.map((item) => item["$ref"]));
|
||||
const usableRefs = computed(() =>
|
||||
props.collection.map((item) => item[props.idProperty])
|
||||
);
|
||||
|
||||
const selectableRefs = computed(() =>
|
||||
filteredAndSortedCollection.value.map((item) => item["$ref"])
|
||||
filteredAndSortedCollection.value.map((item) => item[props.idProperty])
|
||||
);
|
||||
|
||||
const { selected, areAllSelected } = useMultiSelect(usableRefs, selectableRefs);
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<template>
|
||||
<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"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</UiTable>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
|
||||
import UiCounter from "@/components/ui/UiCounter.vue";
|
||||
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
||||
import UiTable from "@/components/ui/UiTable.vue";
|
||||
import type { XenApiPatchWithHostRefs } from "@/composables/host-patches.composable";
|
||||
import { vTooltip } from "@/directives/tooltip.directive";
|
||||
import { useUiStore } from "@/stores/ui.store";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
patches: XenApiPatchWithHostRefs[];
|
||||
hasMultipleHosts: boolean;
|
||||
areAllLoaded: boolean;
|
||||
areSomeLoaded: boolean;
|
||||
}>();
|
||||
|
||||
const sortedPatches = computed(() =>
|
||||
[...props.patches].sort(
|
||||
(patch1, patch2) => patch1.changelog.date - patch2.changelog.date
|
||||
)
|
||||
);
|
||||
|
||||
const { isDesktop } = useUiStore();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.hosts-patches-table.desktop {
|
||||
max-width: 45rem;
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -66,8 +66,8 @@ onUnmounted(() => {
|
||||
store.value?.unsubscribe(subscriptionId);
|
||||
});
|
||||
|
||||
const record = computed<ObjectTypeToRecord<HandledTypes> | undefined>(
|
||||
() => store.value?.getByUuid(props.uuid as any)
|
||||
const record = computed<ObjectTypeToRecord<HandledTypes> | undefined>(() =>
|
||||
store.value?.getByUuid(props.uuid as any)
|
||||
);
|
||||
|
||||
const isReady = computed(() => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import { VM_POWER_STATE } from "@/libs/xen-api/xen-api.enums";
|
||||
import { POWER_STATE } from "@/libs/xen-api/xen-api.utils";
|
||||
import {
|
||||
faMoon,
|
||||
faPause,
|
||||
@@ -15,14 +15,14 @@ import {
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
state: VM_POWER_STATE;
|
||||
state: POWER_STATE;
|
||||
}>();
|
||||
|
||||
const icons = {
|
||||
[VM_POWER_STATE.RUNNING]: faPlay,
|
||||
[VM_POWER_STATE.PAUSED]: faPause,
|
||||
[VM_POWER_STATE.SUSPENDED]: faMoon,
|
||||
[VM_POWER_STATE.HALTED]: faStop,
|
||||
[POWER_STATE.RUNNING]: faPlay,
|
||||
[POWER_STATE.PAUSED]: faPause,
|
||||
[POWER_STATE.SUSPENDED]: faMoon,
|
||||
[POWER_STATE.HALTED]: faStop,
|
||||
};
|
||||
|
||||
const icon = computed(() => icons[props.state] ?? faQuestion);
|
||||
|
||||
@@ -105,10 +105,6 @@ watchEffect(() => {
|
||||
onBeforeUnmount(() => {
|
||||
clearVncClient();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
sendCtrlAltDel: () => vncClient?.sendCtrlAltDel(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
59
@xen-orchestra/lite/src/components/UnreachableHostsModal.vue
Normal file
59
@xen-orchestra/lite/src/components/UnreachableHostsModal.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<UiModal
|
||||
v-if="isSslModalOpen"
|
||||
:icon="faServer"
|
||||
color="error"
|
||||
@close="clearUnreachableHostsUrls"
|
||||
>
|
||||
<template #title>{{ $t("unreachable-hosts") }}</template>
|
||||
<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 #buttons>
|
||||
<UiButton color="success" @click="reload">
|
||||
{{ $t("unreachable-hosts-reload-page") }}
|
||||
</UiButton>
|
||||
<UiButton @click="clearUnreachableHostsUrls">{{ $t("cancel") }}</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useHostCollection } from "@/stores/xen-api/host.store";
|
||||
import { faServer } from "@fortawesome/free-solid-svg-icons";
|
||||
import UiModal from "@/components/ui/UiModal.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { difference } from "lodash-es";
|
||||
|
||||
const { records: hosts } = useHostCollection();
|
||||
const unreachableHostsUrls = ref<Set<string>>(new Set());
|
||||
const clearUnreachableHostsUrls = () => unreachableHostsUrls.value.clear();
|
||||
const isSslModalOpen = computed(() => unreachableHostsUrls.value.size > 0);
|
||||
const reload = () => window.location.reload();
|
||||
|
||||
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 p {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<LinearChart :data="data" :value-formatter="customValueFormatter" />
|
||||
<LinearChart
|
||||
title="Chart title"
|
||||
subtitle="Chart subtitle"
|
||||
:data="data"
|
||||
:value-formatter="customValueFormatter"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<template>
|
||||
<VueCharts :option="option" autoresize class="chart" />
|
||||
<UiCard class="linear-chart">
|
||||
<VueCharts :option="option" autoresize class="chart" />
|
||||
<slot name="summary" />
|
||||
</UiCard>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import type { LinearChartData, ValueFormatter } from "@/types/chart";
|
||||
import { IK_CHART_VALUE_FORMATTER } from "@/types/injection-keys";
|
||||
import { utcFormat } from "d3-time-format";
|
||||
@@ -11,6 +15,7 @@ import { LineChart } from "echarts/charts";
|
||||
import {
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
} from "echarts/components";
|
||||
import { use } from "echarts/core";
|
||||
@@ -21,6 +26,8 @@ import VueCharts from "vue-echarts";
|
||||
const Y_AXIS_MAX_VALUE = 200;
|
||||
|
||||
const props = defineProps<{
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
data: LinearChartData;
|
||||
valueFormatter?: ValueFormatter;
|
||||
maxValue?: number;
|
||||
@@ -45,10 +52,15 @@ use([
|
||||
LineChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
TitleComponent,
|
||||
LegendComponent,
|
||||
]);
|
||||
|
||||
const option = computed<EChartsOption>(() => ({
|
||||
title: {
|
||||
text: props.title,
|
||||
subtext: props.subtitle,
|
||||
},
|
||||
legend: {
|
||||
data: props.data.map((series) => series.label),
|
||||
},
|
||||
|
||||
@@ -58,7 +58,7 @@ const getDefaultOpenedDirectories = (): Set<string> => {
|
||||
}
|
||||
|
||||
const openedDirectories = new Set<string>();
|
||||
const parts = currentRoute.path.split("/").slice(2);
|
||||
const parts = currentRoute.path.split("/");
|
||||
let currentPath = "";
|
||||
|
||||
for (const part of parts) {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<template>
|
||||
<UiModal v-if="isRawValueModalOpen" @close="closeRawValueModal">
|
||||
<CodeHighlight :code="rawValueModalPayload" />
|
||||
</UiModal>
|
||||
<StoryParamsTable>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -96,7 +99,8 @@ 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 UiModal from "@/components/ui/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,8 +128,12 @@ const emit = defineEmits<{
|
||||
|
||||
const model = useVModel(props, "modelValue", emit);
|
||||
|
||||
const openRawValueModal = (code: string) =>
|
||||
useModal(() => import("@/components/CodeHighlight.vue"), { code });
|
||||
const {
|
||||
open: openRawValueModal,
|
||||
close: closeRawValueModal,
|
||||
isOpen: isRawValueModalOpen,
|
||||
payload: rawValueModalPayload,
|
||||
} = useModal<string>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
@@ -273,7 +273,6 @@ defineExpose({
|
||||
.textarea {
|
||||
height: auto;
|
||||
min-height: 2em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.input {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
v-if="label !== undefined || learnMoreUrl !== undefined"
|
||||
class="label-container"
|
||||
>
|
||||
<label :class="{ light }" :for="id" class="label">
|
||||
<label :for="id" class="label">
|
||||
<UiIcon :icon="icon" />
|
||||
{{ label }}
|
||||
</label>
|
||||
@@ -58,7 +58,6 @@ const props = withDefaults(
|
||||
error?: string;
|
||||
help?: string;
|
||||
disabled?: boolean;
|
||||
light?: boolean;
|
||||
}>(),
|
||||
{ disabled: undefined }
|
||||
);
|
||||
@@ -96,24 +95,14 @@ useContext(DisabledContext, () => props.disabled);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
color: var(--color-blue-scale-100);
|
||||
font-size: 1.4rem;
|
||||
padding: 1rem 0;
|
||||
|
||||
&.light {
|
||||
font-size: 1.6rem;
|
||||
color: var(--color-blue-scale-300);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&:not(.light) {
|
||||
font-size: 1.4rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
color: var(--color-blue-scale-100);
|
||||
}
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
<template>
|
||||
<UiModal
|
||||
@submit.prevent="saveJson"
|
||||
:color="isJsonValid ? 'success' : 'error'"
|
||||
v-if="isCodeModalOpen"
|
||||
:icon="faCode"
|
||||
@close="closeCodeModal"
|
||||
>
|
||||
<FormTextarea class="modal-textarea" v-model="editedJson" />
|
||||
<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>
|
||||
</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 UiButton from "@/components/ui/UiButton.vue";
|
||||
import UiModal from "@/components/ui/UiModal.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 +43,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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: string;
|
||||
}>();
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -82,7 +82,7 @@ const {
|
||||
}
|
||||
}
|
||||
.table-container {
|
||||
max-height: 25rem;
|
||||
max-height: 24rem;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
@@ -41,11 +41,11 @@ import { useHostCollection } from "@/stores/xen-api/host.store";
|
||||
import { useVmCollection } from "@/stores/xen-api/vm.store";
|
||||
import { useVmMetricsCollection } from "@/stores/xen-api/vm-metrics.store";
|
||||
import { percent } from "@/libs/utils";
|
||||
import { VM_POWER_STATE } from "@/libs/xen-api/xen-api.enums";
|
||||
import { POWER_STATE } from "@/libs/xen-api/xen-api.utils";
|
||||
import { logicAnd } from "@vueuse/math";
|
||||
import { computed } from "vue";
|
||||
|
||||
const ACTIVE_STATES = new Set([VM_POWER_STATE.RUNNING, VM_POWER_STATE.PAUSED]);
|
||||
const ACTIVE_STATES = new Set([POWER_STATE.RUNNING, POWER_STATE.PAUSED]);
|
||||
|
||||
const {
|
||||
hasError: hostStoreHasError,
|
||||
@@ -113,8 +113,4 @@ const hasError = computed(
|
||||
color: var(--footer-value-color);
|
||||
}
|
||||
}
|
||||
|
||||
.ui-card-footer {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<template>
|
||||
<UiCard>
|
||||
<UiCardTitle class="patches-title">
|
||||
{{ $t("patches") }}
|
||||
<template v-if="areAllLoaded && count > 0" #right>
|
||||
{{ $t("n-missing", { n: count }) }}
|
||||
</template>
|
||||
</UiCardTitle>
|
||||
<div class="table-container">
|
||||
<HostPatches
|
||||
:are-all-loaded="areAllLoaded"
|
||||
:are-some-loaded="areSomeLoaded"
|
||||
:has-multiple-hosts="hosts.length > 1"
|
||||
:patches="patches"
|
||||
/>
|
||||
</div>
|
||||
</UiCard>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import HostPatches from "@/components/HostPatchesTable.vue";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import { useHostPatches } from "@/composables/host-patches.composable";
|
||||
import { useHostCollection } from "@/stores/xen-api/host.store";
|
||||
|
||||
const { records: hosts } = useHostCollection();
|
||||
|
||||
const { count, patches, areSomeLoaded, areAllLoaded } = useHostPatches(hosts);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.patches-title {
|
||||
--section-title-right-color: var(--color-red-vates-base);
|
||||
}
|
||||
|
||||
.table-container {
|
||||
max-height: 25rem;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,44 +1,33 @@
|
||||
<template>
|
||||
<UiCard class="linear-chart" :color="hasError ? 'error' : undefined">
|
||||
<UiCardTitle>{{ $t("network-throughput") }}</UiCardTitle>
|
||||
<UiCardTitle :level="UiCardTitleLevel.Subtitle">
|
||||
{{ $t("last-week") }}
|
||||
</UiCardTitle>
|
||||
<NoDataError v-if="hasError" />
|
||||
<UiCardSpinner v-else-if="isLoading" />
|
||||
<LinearChart
|
||||
v-else
|
||||
:data="data"
|
||||
:max-value="customMaxValue"
|
||||
:value-formatter="customValueFormatter"
|
||||
/>
|
||||
</UiCard>
|
||||
<!-- TODO: add a loader when data is not fully loaded or undefined -->
|
||||
<!-- TODO: add small loader with tooltips when stats can be expired -->
|
||||
<!-- TODO: display the NoData component in case of a data recovery error -->
|
||||
<LinearChart
|
||||
:data="data"
|
||||
:max-value="customMaxValue"
|
||||
:subtitle="$t('last-week')"
|
||||
:title="$t('network-throughput')"
|
||||
:value-formatter="customValueFormatter"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
|
||||
import { computed, defineAsyncComponent, inject } from "vue";
|
||||
import { map } from "lodash-es";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { formatSize } from "@/libs/utils";
|
||||
import type { HostStats } from "@/libs/xapi-stats";
|
||||
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
|
||||
import type { LinearChartData } from "@/types/chart";
|
||||
import { map } from "lodash-es";
|
||||
import NoDataError from "@/components/NoDataError.vue";
|
||||
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
|
||||
import { UiCardTitleLevel } from "@/types/enums";
|
||||
import { useHostCollection } from "@/stores/xen-api/host.store";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const LinearChart = defineAsyncComponent(
|
||||
() => import("@/components/charts/LinearChart.vue")
|
||||
);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const hostLastWeekStats = inject(IK_HOST_LAST_WEEK_STATS);
|
||||
const { hasError, isFetching } = useHostCollection();
|
||||
|
||||
const data = computed<LinearChartData>(() => {
|
||||
const stats = hostLastWeekStats?.stats?.value;
|
||||
@@ -93,25 +82,6 @@ const data = computed<LinearChartData>(() => {
|
||||
];
|
||||
});
|
||||
|
||||
const isStatFetched = computed(() => {
|
||||
const stats = hostLastWeekStats?.stats?.value;
|
||||
if (stats === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return stats.every((host) => {
|
||||
const hostStats = host.stats;
|
||||
return (
|
||||
hostStats != null &&
|
||||
Object.values(hostStats.pifs["rx"])[0].length +
|
||||
Object.values(hostStats.pifs["tx"])[0].length ===
|
||||
data.value[0].data.length + data.value[1].data.length
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const isLoading = computed(() => isFetching.value || !isStatFetched.value);
|
||||
|
||||
// TODO: improve the way to get the max value of graph
|
||||
// See: https://github.com/vatesfr/xen-orchestra/pull/6610/files#r1072237279
|
||||
const customMaxValue = computed(
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
<template>
|
||||
<UiCardTitle
|
||||
:level="UiCardTitleLevel.SubtitleWithUnderline"
|
||||
:left="$t('hosts')"
|
||||
:right="$t('top-#', { n: N_ITEMS })"
|
||||
subtitle
|
||||
/>
|
||||
<NoDataError v-if="hasError" />
|
||||
<UsageBar v-else :data="statFetched ? data : undefined" :n-items="N_ITEMS" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, type ComputedRef } from "vue";
|
||||
import NoDataError from "@/components/NoDataError.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import UsageBar from "@/components/UsageBar.vue";
|
||||
import { useHostCollection } from "@/stores/xen-api/host.store";
|
||||
import { getAvgCpuUsage } from "@/libs/utils";
|
||||
import { IK_HOST_STATS } from "@/types/injection-keys";
|
||||
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
|
||||
import NoDataError from "@/components/NoDataError.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import { UiCardTitleLevel } from "@/types/enums";
|
||||
import UsageBar from "@/components/UsageBar.vue";
|
||||
import { useHostCollection } from "@/stores/xen-api/host.store";
|
||||
import { computed, type ComputedRef, inject } from "vue";
|
||||
|
||||
const { hasError } = useHostCollection();
|
||||
|
||||
|
||||
@@ -1,33 +1,24 @@
|
||||
<template>
|
||||
<UiCard class="linear-chart" :color="hasError ? 'error' : undefined">
|
||||
<UiCardTitle>{{ $t("pool-cpu-usage") }}</UiCardTitle>
|
||||
<UiCardTitle :level="UiCardTitleLevel.Subtitle">
|
||||
{{ $t("last-week") }}
|
||||
</UiCardTitle>
|
||||
<NoDataError v-if="hasError" />
|
||||
<UiCardSpinner v-else-if="isLoading" />
|
||||
<LinearChart
|
||||
v-else
|
||||
:data="data"
|
||||
:max-value="customMaxValue"
|
||||
:value-formatter="customValueFormatter"
|
||||
/>
|
||||
</UiCard>
|
||||
<!-- TODO: add a loader when data is not fully loaded or undefined -->
|
||||
<!-- TODO: add small loader with tooltips when stats can be expired -->
|
||||
<!-- TODO: Display the NoDataError component in case of a data recovery error -->
|
||||
<LinearChart
|
||||
:data="data"
|
||||
:max-value="customMaxValue"
|
||||
:subtitle="$t('last-week')"
|
||||
:title="$t('pool-cpu-usage')"
|
||||
:value-formatter="customValueFormatter"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, inject } from "vue";
|
||||
import type { HostStats } from "@/libs/xapi-stats";
|
||||
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
|
||||
import type { LinearChartData, ValueFormatter } from "@/types/chart";
|
||||
import NoDataError from "@/components/NoDataError.vue";
|
||||
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
|
||||
import { sumBy } from "lodash-es";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
|
||||
import { UiCardTitleLevel } from "@/types/enums";
|
||||
import { useHostCollection } from "@/stores/xen-api/host.store";
|
||||
import type { HostStats } from "@/libs/xapi-stats";
|
||||
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
|
||||
import type { LinearChartData, ValueFormatter } from "@/types/chart";
|
||||
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
|
||||
import { sumBy } from "lodash-es";
|
||||
import { computed, defineAsyncComponent, inject } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const LinearChart = defineAsyncComponent(
|
||||
@@ -38,7 +29,8 @@ const { t } = useI18n();
|
||||
|
||||
const hostLastWeekStats = inject(IK_HOST_LAST_WEEK_STATS);
|
||||
|
||||
const { records: hosts, isFetching, hasError } = useHostCollection();
|
||||
const { records: hosts } = useHostCollection();
|
||||
|
||||
const customMaxValue = computed(
|
||||
() => 100 * sumBy(hosts.value, (host) => +host.cpu_info.cpu_count)
|
||||
);
|
||||
@@ -87,22 +79,6 @@ const data = computed<LinearChartData>(() => {
|
||||
},
|
||||
];
|
||||
});
|
||||
const isStatFetched = computed(() => {
|
||||
const stats = hostLastWeekStats?.stats?.value;
|
||||
if (stats === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return stats.every((host) => {
|
||||
const hostStats = host.stats;
|
||||
return (
|
||||
hostStats != null &&
|
||||
Object.values(hostStats.cpus)[0].length === data.value[0].data.length
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const isLoading = computed(() => isFetching.value || !isStatFetched.value);
|
||||
|
||||
const customValueFormatter: ValueFormatter = (value) => `${value}%`;
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<UiCardTitle
|
||||
:level="UiCardTitleLevel.SubtitleWithUnderline"
|
||||
subtitle
|
||||
:left="$t('vms')"
|
||||
:right="$t('top-#', { n: N_ITEMS })"
|
||||
/>
|
||||
@@ -9,7 +9,6 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type ComputedRef, computed, inject } from "vue";
|
||||
import NoDataError from "@/components/NoDataError.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import UsageBar from "@/components/UsageBar.vue";
|
||||
@@ -17,7 +16,7 @@ import { useVmCollection } from "@/stores/xen-api/vm.store";
|
||||
import { getAvgCpuUsage } from "@/libs/utils";
|
||||
import { IK_VM_STATS } from "@/types/injection-keys";
|
||||
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
|
||||
import { UiCardTitleLevel } from "@/types/enums";
|
||||
import { computed, type ComputedRef, inject } from "vue";
|
||||
|
||||
const { hasError } = useVmCollection();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<UiCardTitle
|
||||
:level="UiCardTitleLevel.SubtitleWithUnderline"
|
||||
subtitle
|
||||
:left="$t('hosts')"
|
||||
:right="$t('top-#', { n: N_ITEMS })"
|
||||
/>
|
||||
@@ -13,7 +13,6 @@ import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import { useHostCollection } from "@/stores/xen-api/host.store";
|
||||
import { IK_HOST_STATS } from "@/types/injection-keys";
|
||||
import { type ComputedRef, computed, inject } from "vue";
|
||||
import { UiCardTitleLevel } from "@/types/enums";
|
||||
import UsageBar from "@/components/UsageBar.vue";
|
||||
import { formatSize, parseRamUsage } from "@/libs/utils";
|
||||
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
|
||||
|
||||
@@ -1,43 +1,37 @@
|
||||
<template>
|
||||
<UiCard class="linear-chart" :color="hasError ? 'error' : undefined">
|
||||
<UiCardTitle>{{ $t("pool-ram-usage") }}</UiCardTitle>
|
||||
<UiCardTitle :level="UiCardTitleLevel.Subtitle">
|
||||
{{ $t("last-week") }}
|
||||
</UiCardTitle>
|
||||
<NoDataError v-if="hasError" />
|
||||
<UiCardSpinner v-else-if="isLoading" />
|
||||
<LinearChart
|
||||
v-else
|
||||
:data="data"
|
||||
:max-value="customMaxValue"
|
||||
:value-formatter="customValueFormatter"
|
||||
/>
|
||||
<SizeStatsSummary :size="currentData.size" :usage="currentData.usage" />
|
||||
</UiCard>
|
||||
<!-- TODO: add a loader when data is not fully loaded or undefined -->
|
||||
<!-- TODO: add small loader with tooltips when stats can be expired -->
|
||||
<!-- TODO: display the NoDataError component in case of a data recovery error -->
|
||||
<LinearChart
|
||||
:data="data"
|
||||
:max-value="customMaxValue"
|
||||
:subtitle="$t('last-week')"
|
||||
:title="$t('pool-ram-usage')"
|
||||
:value-formatter="customValueFormatter"
|
||||
>
|
||||
<template #summary>
|
||||
<SizeStatsSummary :size="currentData.size" :usage="currentData.usage" />
|
||||
</template>
|
||||
</LinearChart>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, inject } from "vue";
|
||||
import { formatSize } from "@/libs/utils";
|
||||
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
|
||||
import type { LinearChartData } from "@/types/chart";
|
||||
import NoDataError from "@/components/NoDataError.vue";
|
||||
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
|
||||
import SizeStatsSummary from "@/components/ui/SizeStatsSummary.vue";
|
||||
import { sumBy } from "lodash-es";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
|
||||
import { UiCardTitleLevel } from "@/types/enums";
|
||||
import { useHostCollection } from "@/stores/xen-api/host.store";
|
||||
import { useHostMetricsCollection } from "@/stores/xen-api/host-metrics.store";
|
||||
import { formatSize } from "@/libs/utils";
|
||||
import { RRD_STEP_FROM_STRING } from "@/libs/xapi-stats";
|
||||
import type { LinearChartData, ValueFormatter } from "@/types/chart";
|
||||
import { IK_HOST_LAST_WEEK_STATS } from "@/types/injection-keys";
|
||||
import { sumBy } from "lodash-es";
|
||||
import { computed, defineAsyncComponent, inject } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const LinearChart = defineAsyncComponent(
|
||||
() => import("@/components/charts/LinearChart.vue")
|
||||
);
|
||||
|
||||
const { runningHosts, isFetching, hasError } = useHostCollection();
|
||||
const { runningHosts } = useHostCollection();
|
||||
const { getHostMemory } = useHostMetricsCollection();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -98,23 +92,6 @@ const data = computed<LinearChartData>(() => {
|
||||
];
|
||||
});
|
||||
|
||||
const isStatFetched = computed(() => {
|
||||
const stats = hostLastWeekStats?.stats?.value;
|
||||
if (stats === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return stats.every((host) => {
|
||||
const hostStats = host.stats;
|
||||
return (
|
||||
hostStats != null && hostStats.memory.length === data.value[0].data.length
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const isLoading = computed(
|
||||
() => (isFetching.value && !hasError.value) || !isStatFetched.value
|
||||
);
|
||||
|
||||
const customValueFormatter = (value: number) => String(formatSize(value));
|
||||
const customValueFormatter: ValueFormatter = (value) =>
|
||||
String(formatSize(value));
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<UiCardTitle
|
||||
:level="UiCardTitleLevel.SubtitleWithUnderline"
|
||||
subtitle
|
||||
:left="$t('vms')"
|
||||
:right="$t('top-#', { n: N_ITEMS })"
|
||||
/>
|
||||
@@ -9,15 +9,14 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, type ComputedRef } from "vue";
|
||||
import NoDataError from "@/components/NoDataError.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import UsageBar from "@/components/UsageBar.vue";
|
||||
import { useVmCollection } from "@/stores/xen-api/vm.store";
|
||||
import { formatSize, parseRamUsage } from "@/libs/utils";
|
||||
import { IK_VM_STATS } from "@/types/injection-keys";
|
||||
import { N_ITEMS } from "@/views/pool/PoolDashboardView.vue";
|
||||
import NoDataError from "@/components/NoDataError.vue";
|
||||
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
|
||||
import { UiCardTitleLevel } from "@/types/enums";
|
||||
import UsageBar from "@/components/UsageBar.vue";
|
||||
import { useVmCollection } from "@/stores/xen-api/vm.store";
|
||||
import { computed, type ComputedRef, inject } from "vue";
|
||||
|
||||
const { hasError } = useVmCollection();
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ const isDisplayed = computed(
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: var(--color-blue-scale-300);
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
|
||||
@@ -1,40 +1,35 @@
|
||||
<template>
|
||||
<div :class="['ui-section-title', tags.left]">
|
||||
<component :is="tags.left" v-if="$slots.default || left" class="left">
|
||||
<div :class="{ subtitle }" class="ui-section-title">
|
||||
<component
|
||||
:is="subtitle ? 'h5' : 'h4'"
|
||||
v-if="$slots.default || left"
|
||||
class="left"
|
||||
>
|
||||
<slot>{{ left }}</slot>
|
||||
<UiCounter class="count" v-if="count > 0" :value="count" color="info" />
|
||||
</component>
|
||||
<component :is="tags.right" v-if="$slots.right || right" class="right">
|
||||
<component
|
||||
:is="subtitle ? 'h6' : 'h5'"
|
||||
v-if="$slots.right || right"
|
||||
class="right"
|
||||
>
|
||||
<slot name="right">{{ right }}</slot>
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import UiCounter from "@/components/ui/UiCounter.vue";
|
||||
import { UiCardTitleLevel } from "@/types/enums";
|
||||
|
||||
const props = withDefaults(
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
count?: number;
|
||||
level?: UiCardTitleLevel;
|
||||
subtitle?: boolean;
|
||||
left?: string;
|
||||
right?: string;
|
||||
count?: number;
|
||||
}>(),
|
||||
{ count: 0, level: UiCardTitleLevel.Title }
|
||||
{ count: 0 }
|
||||
);
|
||||
|
||||
const tags = computed(() => {
|
||||
switch (props.level) {
|
||||
case UiCardTitleLevel.Subtitle:
|
||||
return { left: "h6", right: "h6" };
|
||||
case UiCardTitleLevel.SubtitleWithUnderline:
|
||||
return { left: "h5", right: "h6" };
|
||||
default:
|
||||
return { left: "h4", right: "h5" };
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
@@ -42,6 +37,7 @@ const tags = computed(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
--section-title-left-size: 2rem;
|
||||
--section-title-left-color: var(--color-blue-scale-100);
|
||||
@@ -50,17 +46,9 @@ const tags = computed(() => {
|
||||
--section-title-right-color: var(--color-extra-blue-base);
|
||||
--section-title-right-weight: 700;
|
||||
|
||||
&.h6 {
|
||||
margin-bottom: 1rem;
|
||||
--section-title-left-size: 1.5rem;
|
||||
--section-title-left-color: var(--color-blue-scale-300);
|
||||
--section-title-left-weight: 400;
|
||||
}
|
||||
|
||||
&.h5 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
&.subtitle {
|
||||
border-bottom: 1px solid var(--color-extra-blue-base);
|
||||
|
||||
--section-title-left-size: 1.6rem;
|
||||
--section-title-left-color: var(--color-extra-blue-base);
|
||||
--section-title-left-weight: 700;
|
||||
|
||||
157
@xen-orchestra/lite/src/components/ui/UiModal.vue
Normal file
157
@xen-orchestra/lite/src/components/ui/UiModal.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<form
|
||||
:class="className"
|
||||
class="ui-modal"
|
||||
v-bind="$attrs"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<div class="container">
|
||||
<span v-if="onClose" class="close-icon" @click="emit('close')">
|
||||
<UiIcon :icon="faXmark" />
|
||||
</span>
|
||||
<div v-if="icon || $slots.icon" class="modal-icon">
|
||||
<slot name="icon">
|
||||
<UiIcon :icon="icon" />
|
||||
</slot>
|
||||
</div>
|
||||
<UiTitle v-if="$slots.title" type="h4">
|
||||
<slot name="title" />
|
||||
</UiTitle>
|
||||
<div v-if="$slots.subtitle" class="subtitle">
|
||||
<slot name="subtitle" />
|
||||
</div>
|
||||
<div v-if="$slots.default" class="content">
|
||||
<slot />
|
||||
</div>
|
||||
<UiButtonGroup :color="color">
|
||||
<slot name="buttons" />
|
||||
</UiButtonGroup>
|
||||
</div>
|
||||
</form>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import UiTitle from "@/components/ui/UiTitle.vue";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useMagicKeys, whenever } from "@vueuse/core";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
icon?: IconDefinition;
|
||||
color?: "info" | "warning" | "error" | "success";
|
||||
onClose?: () => void;
|
||||
}>(),
|
||||
{ color: "info" }
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "close"): void;
|
||||
}>();
|
||||
|
||||
const { escape } = useMagicKeys();
|
||||
whenever(escape, () => emit("close"));
|
||||
|
||||
const className = computed(() => {
|
||||
return [`color-${props.color}`, { "has-icon": props.icon !== undefined }];
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-modal {
|
||||
position: fixed;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #00000080;
|
||||
}
|
||||
|
||||
.color-success {
|
||||
--modal-color: var(--color-green-infra-base);
|
||||
--modal-background-color: var(--background-color-green-infra);
|
||||
}
|
||||
|
||||
.color-info {
|
||||
--modal-color: var(--color-extra-blue-base);
|
||||
--modal-background-color: var(--background-color-extra-blue);
|
||||
}
|
||||
|
||||
.color-warning {
|
||||
--modal-color: var(--color-orange-world-base);
|
||||
--modal-background-color: var(--background-color-orange-world);
|
||||
}
|
||||
|
||||
.color-error {
|
||||
--modal-color: var(--color-red-vates-base);
|
||||
--modal-background-color: var(--background-color-red-vates);
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 40rem;
|
||||
padding: 4.2rem;
|
||||
text-align: center;
|
||||
border-radius: 1rem;
|
||||
background-color: var(--modal-background-color);
|
||||
box-shadow: var(--shadow-400);
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
font-size: 2rem;
|
||||
position: absolute;
|
||||
top: 1.5rem;
|
||||
right: 2rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--modal-color);
|
||||
}
|
||||
|
||||
.container :deep(.accent) {
|
||||
color: var(--modal-color);
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
font-size: 4.8rem;
|
||||
margin: 2rem 0;
|
||||
color: var(--modal-color);
|
||||
}
|
||||
|
||||
.ui-title {
|
||||
margin-top: 4rem;
|
||||
|
||||
.has-icon & {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-blue-scale-200);
|
||||
}
|
||||
|
||||
.content {
|
||||
overflow: auto;
|
||||
font-size: 1.6rem;
|
||||
max-height: calc(100vh - 40rem);
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.ui-button-group {
|
||||
margin-top: 4rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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);
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -1,28 +0,0 @@
|
||||
<template>
|
||||
<UiIcon
|
||||
:class="textClass"
|
||||
:icon="faXmark"
|
||||
class="modal-close-icon"
|
||||
@click="modal?.decline()"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
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 { faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { inject } from "vue";
|
||||
|
||||
const { textClass } = useContext(ColorContext);
|
||||
|
||||
const modal = inject(IK_MODAL, undefined);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.modal-close-icon {
|
||||
font-size: 2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,75 +0,0 @@
|
||||
<template>
|
||||
<div :class="[backgroundClass, { nested: isNested }]" class="modal-container">
|
||||
<header v-if="$slots.header" class="modal-header">
|
||||
<slot name="header" />
|
||||
</header>
|
||||
<main v-if="$slots.default" class="modal-content">
|
||||
<slot name="default" />
|
||||
</main>
|
||||
<footer v-if="$slots.footer" class="modal-footer">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useContext } from "@/composables/context.composable";
|
||||
import { ColorContext } from "@/context";
|
||||
import type { Color } from "@/types";
|
||||
import { IK_MODAL_NESTED } from "@/types/injection-keys";
|
||||
import { inject, provide } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
color?: Color;
|
||||
}>();
|
||||
|
||||
defineSlots<{
|
||||
header: () => any;
|
||||
default: () => any;
|
||||
footer: () => any;
|
||||
}>();
|
||||
|
||||
const { backgroundClass } = useContext(ColorContext, () => props.color);
|
||||
|
||||
const isNested = inject(IK_MODAL_NESTED, false);
|
||||
provide(IK_MODAL_NESTED, true);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.modal-container {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto 1fr;
|
||||
max-width: calc(100vw - 2rem);
|
||||
max-height: calc(100vh - 20rem);
|
||||
padding: 2rem;
|
||||
gap: 1rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 1.6rem;
|
||||
|
||||
&:not(.nested) {
|
||||
min-width: 40rem;
|
||||
box-shadow: var(--shadow-400);
|
||||
}
|
||||
|
||||
&.nested {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
text-align: center;
|
||||
grid-row: 2;
|
||||
padding: 2rem;
|
||||
max-height: 75vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
grid-row: 3;
|
||||
align-self: end;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,54 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<form class="ui-modal" v-bind="$attrs" @click.self="modal.decline()">
|
||||
<slot />
|
||||
</form>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useContext } from "@/composables/context.composable";
|
||||
import { ColorContext, DisabledContext } 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";
|
||||
|
||||
const props = defineProps<{
|
||||
color?: Color;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const modal = inject(IK_MODAL)!;
|
||||
|
||||
useContext(ColorContext, () => props.color);
|
||||
useContext(DisabledContext, () => props.disabled || modal.isBusy);
|
||||
|
||||
const { escape } = useMagicKeys();
|
||||
|
||||
whenever(escape, () => modal.decline());
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-modal {
|
||||
position: fixed;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(26, 27, 56, 0.25);
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
@@ -1,26 +0,0 @@
|
||||
<template>
|
||||
<ModalContainer>
|
||||
<template #header>
|
||||
<ModalCloseIcon class="close-icon" />
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<slot />
|
||||
</template>
|
||||
</ModalContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ModalCloseIcon from "@/components/ui/modals/ModalCloseIcon.vue";
|
||||
import ModalContainer from "@/components/ui/modals/ModalContainer.vue";
|
||||
|
||||
defineSlots<{
|
||||
default: () => void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.close-icon {
|
||||
float: right;
|
||||
}
|
||||
</style>
|
||||
@@ -1,77 +0,0 @@
|
||||
<template>
|
||||
<ModalContainer>
|
||||
<template #header>
|
||||
<div class="close-bar">
|
||||
<ModalCloseIcon />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<UiIcon :class="textClass" :icon="icon" class="main-icon" />
|
||||
<div v-if="$slots.title || $slots.subtitle" class="titles">
|
||||
<UiTitle v-if="$slots.title" type="h4">
|
||||
<slot name="title" />
|
||||
</UiTitle>
|
||||
<div v-if="$slots.subtitle" class="subtitle">
|
||||
<slot name="subtitle" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.default">
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<UiButtonGroup>
|
||||
<slot name="buttons" />
|
||||
</UiButtonGroup>
|
||||
</template>
|
||||
</ModalContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import ModalCloseIcon from "@/components/ui/modals/ModalCloseIcon.vue";
|
||||
import ModalContainer from "@/components/ui/modals/ModalContainer.vue";
|
||||
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
|
||||
import UiTitle from "@/components/ui/UiTitle.vue";
|
||||
import { useContext } from "@/composables/context.composable";
|
||||
import { ColorContext } from "@/context";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
|
||||
defineProps<{
|
||||
icon?: IconDefinition;
|
||||
}>();
|
||||
|
||||
const { textClass } = useContext(ColorContext);
|
||||
|
||||
defineSlots<{
|
||||
title: () => void;
|
||||
subtitle: () => void;
|
||||
default: () => void;
|
||||
buttons: () => void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.close-bar {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.main-icon {
|
||||
font-size: 4.8rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-blue-scale-200);
|
||||
}
|
||||
</style>
|
||||
@@ -1,65 +0,0 @@
|
||||
<template>
|
||||
<ModalContainer tag="form">
|
||||
<template #header>
|
||||
<div :class="borderClass" class="title-bar">
|
||||
<UiIcon :class="textClass" :icon="icon" />
|
||||
<slot name="title" />
|
||||
<ModalCloseIcon class="close-icon" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<slot />
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<UiButtonGroup class="footer-buttons">
|
||||
<slot name="buttons" />
|
||||
</UiButtonGroup>
|
||||
</template>
|
||||
</ModalContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
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 type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
|
||||
defineProps<{
|
||||
icon?: IconDefinition;
|
||||
}>();
|
||||
|
||||
defineSlots<{
|
||||
title: () => void;
|
||||
default: () => void;
|
||||
buttons: () => void;
|
||||
}>();
|
||||
|
||||
const { textClass, borderClass } = useContext(ColorContext);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.title-bar {
|
||||
display: flex;
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-style: solid;
|
||||
font-size: 2.4rem;
|
||||
gap: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
font-weight: 500;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
margin-left: auto;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.footer-buttons {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -51,6 +51,7 @@ const isTooltipEnabled = computed(() =>
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin: 1.6em 0;
|
||||
}
|
||||
|
||||
.label-container {
|
||||
|
||||
@@ -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"
|
||||
@@ -17,7 +14,7 @@
|
||||
import MenuItem from "@/components/menu/MenuItem.vue";
|
||||
import { useVmCollection } from "@/stores/xen-api/vm.store";
|
||||
import { vTooltip } from "@/directives/tooltip.directive";
|
||||
import { VM_POWER_STATE, VM_OPERATION } from "@/libs/xen-api/xen-api.enums";
|
||||
import { POWER_STATE, VM_OPERATION } from "@/libs/xen-api/xen-api.utils";
|
||||
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
import { faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||
@@ -25,7 +22,6 @@ import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
selectedRefs: XenApiVm["$ref"][];
|
||||
isSingleAction?: boolean;
|
||||
}>();
|
||||
|
||||
const { getByOpaqueRef, isOperationPending } = useVmCollection();
|
||||
@@ -40,7 +36,7 @@ const areAllSelectedVmsHalted = computed(
|
||||
() =>
|
||||
selectedVms.value.length > 0 &&
|
||||
selectedVms.value.every(
|
||||
(selectedVm) => selectedVm.power_state === VM_POWER_STATE.HALTED
|
||||
(selectedVm) => selectedVm.power_state === POWER_STATE.HALTED
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -1,44 +1,77 @@
|
||||
<template>
|
||||
<MenuItem
|
||||
v-tooltip="areSomeVmsInExecution && $t('selected-vms-in-execution')"
|
||||
:disabled="isDisabled"
|
||||
:disabled="areSomeVmsInExecution"
|
||||
:icon="faTrashCan"
|
||||
@click="openDeleteModal"
|
||||
>
|
||||
{{ $t("delete") }}
|
||||
</MenuItem>
|
||||
<UiModal
|
||||
v-if="isDeleteModalOpen"
|
||||
:icon="faSatellite"
|
||||
@close="closeDeleteModal"
|
||||
>
|
||||
<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>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MenuItem from "@/components/menu/MenuItem.vue";
|
||||
import { useModal } from "@/composables/modal.composable";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import UiModal from "@/components/ui/UiModal.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 { POWER_STATE } from "@/libs/xen-api/xen-api.utils";
|
||||
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)
|
||||
);
|
||||
|
||||
const areSomeVmsInExecution = computed(() =>
|
||||
vms.value.some((vm) => vm.power_state !== VM_POWER_STATE.HALTED)
|
||||
vms.value.some((vm) => vm.power_state !== POWER_STATE.HALTED)
|
||||
);
|
||||
|
||||
const isDisabled = computed(
|
||||
() => vms.value.length === 0 || areSomeVmsInExecution.value
|
||||
);
|
||||
const deleteVms = async () => {
|
||||
await xenApi.vm.delete(props.vmRefs);
|
||||
closeDeleteModal();
|
||||
};
|
||||
|
||||
const openDeleteModal = () =>
|
||||
useModal(() => import("@/components/modals/VmDeleteModal.vue"), {
|
||||
vmRefs: props.vmRefs,
|
||||
});
|
||||
const { textClass } = useContext(ColorContext);
|
||||
</script>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
<template>
|
||||
<MenuItem
|
||||
v-tooltip="
|
||||
selectedRefs.length > 0 &&
|
||||
!isMigratable &&
|
||||
$t('no-selected-vm-can-be-migrated')
|
||||
"
|
||||
:busy="isMigrating"
|
||||
:disabled="isParentDisabled || !isMigratable"
|
||||
:icon="faRoute"
|
||||
@click="openModal()"
|
||||
>
|
||||
{{ $t("migrate") }}
|
||||
</MenuItem>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MenuItem from "@/components/menu/MenuItem.vue";
|
||||
import { useContext } from "@/composables/context.composable";
|
||||
import { useModal } from "@/composables/modal.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 isMigrating = computed(() =>
|
||||
getByOpaqueRefs(props.selectedRefs).some((vm) =>
|
||||
isOperationPending(vm, [
|
||||
VM_OPERATION.POOL_MIGRATE,
|
||||
VM_OPERATION.MIGRATE_SEND,
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
const openModal = () =>
|
||||
useModal(() => import("@/components/modals/VmMigrateModal.vue"), {
|
||||
vmRefs: props.selectedRefs,
|
||||
});
|
||||
</script>
|
||||
@@ -100,7 +100,7 @@ import { useHostMetricsCollection } from "@/stores/xen-api/host-metrics.store";
|
||||
import { usePoolCollection } from "@/stores/xen-api/pool.store";
|
||||
import { useVmCollection } from "@/stores/xen-api/vm.store";
|
||||
import type { XenApiHost, XenApiVm } from "@/libs/xen-api/xen-api.types";
|
||||
import { VM_POWER_STATE, VM_OPERATION } from "@/libs/xen-api/xen-api.enums";
|
||||
import { POWER_STATE, VM_OPERATION } from "@/libs/xen-api/xen-api.utils";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
import {
|
||||
faCirclePlay,
|
||||
@@ -136,16 +136,16 @@ const vmRefsWithPowerState = computed(() =>
|
||||
const xenApi = useXenApiStore().getXapi();
|
||||
|
||||
const areVmsRunning = computed(() =>
|
||||
vms.value.every((vm) => vm.power_state === VM_POWER_STATE.RUNNING)
|
||||
vms.value.every((vm) => vm.power_state === POWER_STATE.RUNNING)
|
||||
);
|
||||
const areVmsHalted = computed(() =>
|
||||
vms.value.every((vm) => vm.power_state === VM_POWER_STATE.HALTED)
|
||||
vms.value.every((vm) => vm.power_state === POWER_STATE.HALTED)
|
||||
);
|
||||
const areVmsSuspended = computed(() =>
|
||||
vms.value.every((vm) => vm.power_state === VM_POWER_STATE.SUSPENDED)
|
||||
vms.value.every((vm) => vm.power_state === POWER_STATE.SUSPENDED)
|
||||
);
|
||||
const areVmsPaused = computed(() =>
|
||||
vms.value.every((vm) => vm.power_state === VM_POWER_STATE.PAUSED)
|
||||
vms.value.every((vm) => vm.power_state === POWER_STATE.PAUSED)
|
||||
);
|
||||
|
||||
const areOperationsPending = (operation: VM_OPERATION | VM_OPERATION[]) =>
|
||||
@@ -179,7 +179,7 @@ const areVmsBusyToForceShutdown = computed(() =>
|
||||
areOperationsPending(VM_OPERATION.HARD_SHUTDOWN)
|
||||
);
|
||||
const getHostState = (host: XenApiHost) =>
|
||||
isHostRunning(host) ? VM_POWER_STATE.RUNNING : VM_POWER_STATE.HALTED;
|
||||
isHostRunning(host) ? POWER_STATE.RUNNING : POWER_STATE.HALTED;
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
<template>
|
||||
<MenuItem
|
||||
:busy="areSomeVmsSnapshoting"
|
||||
:disabled="isDisabled"
|
||||
:icon="faCamera"
|
||||
@click="handleSnapshot"
|
||||
>
|
||||
{{ $t("snapshot") }}
|
||||
</MenuItem>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MenuItem from "@/components/menu/MenuItem.vue";
|
||||
import { useVmCollection } from "@/stores/xen-api/vm.store";
|
||||
import { VM_OPERATION } from "@/libs/xen-api/xen-api.enums";
|
||||
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
import { faCamera } from "@fortawesome/free-solid-svg-icons";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
vmRefs: XenApiVm["$ref"][];
|
||||
}>();
|
||||
|
||||
const { getByOpaqueRef, isOperationPending } = useVmCollection();
|
||||
|
||||
const vms = computed(() =>
|
||||
props.vmRefs
|
||||
.map((vmRef) => getByOpaqueRef(vmRef))
|
||||
.filter((vm): vm is XenApiVm => vm !== undefined)
|
||||
);
|
||||
|
||||
const areSomeVmsSnapshoting = computed(() =>
|
||||
vms.value.some((vm) => isOperationPending(vm, VM_OPERATION.SNAPSHOT))
|
||||
);
|
||||
|
||||
const isDisabled = computed(
|
||||
() => vms.value.length === 0 || areSomeVmsSnapshoting.value
|
||||
);
|
||||
|
||||
const handleSnapshot = () => {
|
||||
const vmRefsToSnapshot = Object.fromEntries(
|
||||
vms.value.map((vm) => [
|
||||
vm.$ref,
|
||||
`${vm.name_label}_${new Date().toISOString()}`,
|
||||
])
|
||||
);
|
||||
return useXenApiStore().getXapi().vm.snapshot(vmRefsToSnapshot);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped></style>
|
||||
@@ -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>
|
||||
|
||||
@@ -15,12 +15,16 @@
|
||||
<VmActionPowerStateItems :vm-refs="selectedRefs" />
|
||||
</template>
|
||||
</MenuItem>
|
||||
<VmActionMigrateItem :selected-refs="selectedRefs" />
|
||||
<MenuItem v-tooltip="$t('coming-soon')" :icon="faRoute">
|
||||
{{ $t("migrate") }}
|
||||
</MenuItem>
|
||||
<VmActionCopyItem :selected-refs="selectedRefs" />
|
||||
<MenuItem v-tooltip="$t('coming-soon')" :icon="faEdit">
|
||||
{{ $t("edit-config") }}
|
||||
</MenuItem>
|
||||
<VmActionSnapshotItem :vm-refs="selectedRefs" />
|
||||
<MenuItem v-tooltip="$t('coming-soon')" :icon="faCamera">
|
||||
{{ $t("snapshot") }}
|
||||
</MenuItem>
|
||||
<VmActionExportItem :vm-refs="selectedRefs" />
|
||||
<VmActionDeleteItem :vm-refs="selectedRefs" />
|
||||
</AppMenu>
|
||||
@@ -31,18 +35,18 @@ import AppMenu from "@/components/menu/AppMenu.vue";
|
||||
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 VmActionExportItem from "@/components/vm/VmActionItems/VmActionExportItem.vue";
|
||||
import VmActionMigrateItem from "@/components/vm/VmActionItems/VmActionMigrateItem.vue";
|
||||
import VmActionDeleteItem from "@/components/vm/VmActionItems/VmActionDeleteItem.vue";
|
||||
import VmActionPowerStateItems from "@/components/vm/VmActionItems/VmActionPowerStateItems.vue";
|
||||
import VmActionSnapshotItem from "@/components/vm/VmActionItems/VmActionSnapshotItem.vue";
|
||||
import { vTooltip } from "@/directives/tooltip.directive";
|
||||
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
|
||||
import { useUiStore } from "@/stores/ui.store";
|
||||
import {
|
||||
faCamera,
|
||||
faEdit,
|
||||
faEllipsis,
|
||||
faPowerOff,
|
||||
faRoute,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ export const useChartTheme = () => {
|
||||
|
||||
const getColors = () => ({
|
||||
background: style.getPropertyValue("--background-color-primary"),
|
||||
text: style.getPropertyValue("--color-blue-scale-300"),
|
||||
title: style.getPropertyValue("--color-blue-scale-100"),
|
||||
subtitle: style.getPropertyValue("--color-blue-scale-300"),
|
||||
splitLine: style.getPropertyValue("--color-blue-scale-400"),
|
||||
primary: style.getPropertyValue("--color-extra-blue-base"),
|
||||
secondary: style.getPropertyValue("--color-orange-world-base"),
|
||||
@@ -27,10 +28,24 @@ export const useChartTheme = () => {
|
||||
backgroundColor: colors.value.background,
|
||||
textStyle: {},
|
||||
grid: {
|
||||
top: 40,
|
||||
top: 80,
|
||||
left: 80,
|
||||
right: 20,
|
||||
},
|
||||
title: {
|
||||
textStyle: {
|
||||
color: colors.value.title,
|
||||
fontFamily: "Poppins, sans-serif",
|
||||
fontWeight: 500,
|
||||
fontSize: 20,
|
||||
},
|
||||
subtextStyle: {
|
||||
color: colors.value.subtitle,
|
||||
fontFamily: "Poppins, sans-serif",
|
||||
fontWeight: 400,
|
||||
fontSize: 14,
|
||||
},
|
||||
},
|
||||
line: {
|
||||
itemStyle: {
|
||||
borderWidth: 2,
|
||||
@@ -220,7 +235,7 @@ export const useChartTheme = () => {
|
||||
},
|
||||
axisLabel: {
|
||||
show: true,
|
||||
color: colors.value.text,
|
||||
color: colors.value.subtitle,
|
||||
},
|
||||
splitLine: {
|
||||
show: true,
|
||||
@@ -280,7 +295,7 @@ export const useChartTheme = () => {
|
||||
},
|
||||
axisLabel: {
|
||||
show: true,
|
||||
color: colors.value.text,
|
||||
color: colors.value.subtitle,
|
||||
},
|
||||
splitLine: {
|
||||
show: true,
|
||||
@@ -310,7 +325,7 @@ export const useChartTheme = () => {
|
||||
left: "right",
|
||||
top: "bottom",
|
||||
textStyle: {
|
||||
color: colors.value.text,
|
||||
color: colors.value.subtitle,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { XenApiHost } from "@/libs/xen-api/xen-api.types";
|
||||
import { useHostStore } from "@/stores/xen-api/host.store";
|
||||
import type { XenApiPatch } from "@/types/xen-api";
|
||||
import { type Pausable, useTimeoutPoll, watchArray } from "@vueuse/core";
|
||||
import { computed, type MaybeRefOrGetter, reactive, toValue } from "vue";
|
||||
|
||||
export type XenApiPatchWithHostRefs = XenApiPatch & { $hostRefs: Set<string> };
|
||||
|
||||
type HostConfig = {
|
||||
timeoutPoll: Pausable;
|
||||
patches: XenApiPatch[];
|
||||
isLoaded: boolean;
|
||||
};
|
||||
|
||||
export const useHostPatches = (hosts: MaybeRefOrGetter<XenApiHost[]>) => {
|
||||
const hostStore = useHostStore();
|
||||
|
||||
const configByHost = reactive(new Map<string, HostConfig>());
|
||||
|
||||
const fetchHostPatches = async (hostRef: XenApiHost["$ref"]) => {
|
||||
if (!configByHost.has(hostRef)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = configByHost.get(hostRef)!;
|
||||
|
||||
config.patches = await hostStore.fetchMissingPatches(hostRef);
|
||||
config.isLoaded = true;
|
||||
};
|
||||
|
||||
const registerHost = (hostRef: XenApiHost["$ref"]) => {
|
||||
if (configByHost.has(hostRef)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutPoll = useTimeoutPoll(() => fetchHostPatches(hostRef), 10000, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
configByHost.set(hostRef, {
|
||||
timeoutPoll,
|
||||
patches: [],
|
||||
isLoaded: false,
|
||||
});
|
||||
};
|
||||
|
||||
const unregisterHost = (hostRef: string) => {
|
||||
configByHost.get(hostRef)?.timeoutPoll.pause();
|
||||
configByHost.delete(hostRef);
|
||||
};
|
||||
|
||||
watchArray(
|
||||
() => toValue(hosts).map((host) => host.$ref),
|
||||
(_n, _p, addedRefs, removedRefs) => {
|
||||
addedRefs.forEach((ref) => registerHost(ref));
|
||||
removedRefs?.forEach((ref) => unregisterHost(ref));
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const patches = computed(() => {
|
||||
const records = new Map<string, XenApiPatchWithHostRefs>();
|
||||
|
||||
configByHost.forEach(({ patches }, hostRef) => {
|
||||
patches.forEach((patch) => {
|
||||
const record = records.get(patch.$id);
|
||||
|
||||
if (record !== undefined) {
|
||||
return record.$hostRefs.add(hostRef);
|
||||
}
|
||||
|
||||
records.set(patch.$id, {
|
||||
...patch,
|
||||
$hostRefs: new Set([hostRef]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(records.values());
|
||||
});
|
||||
|
||||
const count = computed(() => patches.value.length);
|
||||
|
||||
const areAllLoaded = computed(() =>
|
||||
Array.from(configByHost.values()).every((config) => config.isLoaded)
|
||||
);
|
||||
|
||||
const areSomeLoaded = computed(
|
||||
() =>
|
||||
areAllLoaded.value ||
|
||||
Array.from(configByHost.values()).some((config) => config.isLoaded)
|
||||
);
|
||||
|
||||
return { patches, count, areAllLoaded, areSomeLoaded };
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user