Compare commits

..

10 Commits

Author SHA1 Message Date
Florent BEAUCHAMP
d5853645bc fixes: plugin now work with vdi-tools plugin 2023-10-05 14:43:04 +02:00
Florent BEAUCHAMP
b80a3449e5 wip 2023-09-27 15:44:13 +02:00
Florent BEAUCHAMP
f5a2cfebf1 code cleanup 2023-09-25 15:26:41 +02:00
Florent BEAUCHAMP
b195e1e854 rounding is hard 2023-09-25 15:26:41 +02:00
Florent BEAUCHAMP
3c8bc08681 fix parent locator and bat length 2023-09-25 15:26:41 +02:00
Florent BEAUCHAMP
d201c26a68 more fixes 2023-09-25 15:26:41 +02:00
Florent BEAUCHAMP
5008a9c822 fixes 2023-09-25 15:26:41 +02:00
Florent Beauchamp
75dcea9e86 feat(vhd-cli): implement deeper checks for vhd 2023-09-25 15:26:41 +02:00
Florent BEAUCHAMP
767ac4e738 feat: vhd synthetic 2023-09-25 15:26:41 +02:00
Florent BEAUCHAMP
1066cdea32 feat(backup): use tcp direct transfer - DO NOT MERGE 2023-09-25 15:26:41 +02:00
286 changed files with 4975 additions and 9576 deletions

View File

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

View File

@@ -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,

View File

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

View File

@@ -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"

View File

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

View File

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

View File

@@ -7,8 +7,8 @@
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.43.2",
"@xen-orchestra/fs": "^4.1.2",
"@xen-orchestra/backups": "^0.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",

View File

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

View File

@@ -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())

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

@@ -8,7 +8,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.43.2",
"version": "0.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": {

View File

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

View File

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

View File

@@ -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)
}

View File

@@ -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
}
}

View File

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

View File

@@ -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))

View File

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

View File

@@ -1,4 +1,4 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />

View File

@@ -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",

View File

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

View File

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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

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

View File

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

View File

@@ -2,34 +2,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>

View File

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

View File

@@ -3,27 +3,79 @@
<UiFilter
v-for="filter in activeFilters"
:key="filter"
@edit="openModal(filter)"
@edit="editFilter(filter)"
@remove="emit('removeFilter', filter)"
>
{{ filter }}
</UiFilter>
<UiActionButton :icon="faPlus" class="add-filter" @click="openModal()">
<UiActionButton :icon="faPlus" class="add-filter" @click="open">
{{ $t("add-filter") }}
</UiActionButton>
</UiFilterGroup>
<UiModal
v-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>

View File

@@ -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>

View File

@@ -12,27 +12,65 @@
</span>
</UiFilter>
<UiActionButton :icon="faPlus" class="add-sort" @click="openModal()">
<UiActionButton :icon="faPlus" class="add-sort" @click="open">
{{ $t("add-sort") }}
</UiActionButton>
</UiFilterGroup>
<UiModal
v-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;

View File

@@ -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);

View File

@@ -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>

View File

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

View File

@@ -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(() => {

View File

@@ -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);

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>

View File

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

View File

@@ -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>

View File

@@ -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),
},

View File

@@ -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) {

View File

@@ -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>

View File

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

View File

@@ -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 {

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@
<UiProgressBar :max-value="maxValue" :value="value" color="custom" />
<UiProgressScale :max-value="maxValue" :steps="1" unit="%" />
<UiProgressLegend :label="$t('vcpus')" :value="`${value}%`" />
<UiCardFooter class="ui-card-footer">
<UiCardFooter>
<template #left>
<p>{{ $t("vcpus-used") }}</p>
<p class="footer-value">{{ nVCpuInUse }}</p>
@@ -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>

View File

@@ -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>

View File

@@ -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(

View File

@@ -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();

View File

@@ -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>

View File

@@ -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();

View File

@@ -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";

View File

@@ -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>

View File

@@ -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();

View File

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

View File

@@ -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;

View 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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

View File

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

View File

@@ -1,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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

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

View File

@@ -1,9 +1,6 @@
<template>
<MenuItem
v-tooltip="
!areAllSelectedVmsHalted &&
$t(isSingleAction ? 'vm-is-running' : 'selected-vms-in-execution')
"
v-tooltip="!areAllSelectedVmsHalted && $t('selected-vms-in-execution')"
:busy="areSomeSelectedVmsCloning"
:disabled="isDisabled"
:icon="faCopy"
@@ -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
)
);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

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

View File

@@ -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";

View File

@@ -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: {

View File

@@ -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