Compare commits

..

1 Commits

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import assert from 'node:assert'
import { Socket } from 'node:net'
import { connect } from 'node:tls'
import { fromCallback, pRetry, pDelay, pTimeout } from 'promise-toolbox'
import { fromCallback, pRetry, pDelay, pTimeout, pFromCallback } from 'promise-toolbox'
import { readChunkStrict } from '@vates/read-chunk'
import { createLogger } from '@xen-orchestra/log'
@@ -112,18 +112,22 @@ export default class NbdClient {
}
async disconnect() {
warn('will try to disconnect', { serverAddress: this.#serverAddress })
if (!this.#connected) {
warn('was already disconnected', { serverAddress: this.#serverAddress })
return
}
warn('will really disconnect', { serverAddress: this.#serverAddress })
const buffer = Buffer.alloc(28)
buffer.writeInt32BE(NBD_REQUEST_MAGIC, 0) // it is a nbd request
buffer.writeInt16BE(0, 4) // no command flags for a disconnect
buffer.writeInt16BE(NBD_CMD_DISC, 6) // we want to disconnect from nbd server
await this.#write(buffer)
await this.#serverSocket.destroy()
warn('will send end buffer', { serverAddress: this.#serverAddress })
this.#connected = false // optimistically mark as disconnected to ensure we don' send another disconnection while handling this one
await pFromCallback(cb => this.#serverSocket.end(buffer, cb))
warn('end buffer sent', { serverAddress: this.#serverAddress })
this.#serverSocket = undefined
this.#connected = false
}
#clearReconnectPromise = () => {

View File

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

View File

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

View File

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

View File

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

@@ -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.43.0",
"engines": {
"node": ">=14.18"
},
@@ -28,7 +28,7 @@
"@vates/nbd-client": "^2.0.0",
"@vates/parse-duration": "^0.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/fs": "^4.1.2",
"@xen-orchestra/fs": "^4.1.0",
"@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/template": "^0.1.0",
"app-conf": "^2.3.0",
@@ -51,12 +51,12 @@
"devDependencies": {
"fs-extra": "^11.1.0",
"rimraf": "^5.0.1",
"sinon": "^17.0.1",
"sinon": "^16.0.0",
"test": "^3.2.1",
"tmp": "^0.2.1"
},
"peerDependencies": {
"@xen-orchestra/xapi": "^3.3.0"
"@xen-orchestra/xapi": "^3.2.0"
},
"license": "AGPL-3.0-or-later",
"author": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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>
@@ -113,8 +113,4 @@ const hasError = computed(
color: var(--footer-value-color);
}
}
.ui-card-footer {
margin-top: 2rem;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -75,8 +75,6 @@
"following-hosts-unreachable": "The following hosts are unreachable",
"force-reboot": "Force reboot",
"force-shutdown": "Force shutdown",
"fullscreen": "Fullscreen",
"fullscreen-leave": "Leave fullscreen",
"go-back": "Go back",
"here": "Here",
"hosts": "Hosts",
@@ -88,8 +86,6 @@
"loading-hosts": "Loading hosts…",
"log-out": "Log out",
"login": "Login",
"login-only-on-master": "Login is only possible on the master host",
"more-actions": "More actions",
"migrate": "Migrate",
"migrate-n-vms": "Migrate 1 VM | Migrate {n} VMs",
"n-hosts-awaiting-patch": "{n} host is awaiting this patch | {n} hosts are awaiting this patch",
@@ -104,13 +100,12 @@
"news": "News",
"news-name": "{name} news",
"no-alarm-triggered": "No alarm triggered",
"no-selected-vm-can-be-migrated": "No selected VM can be migrated",
"no-tasks": "No tasks",
"not-found": "Not found",
"object": "Object",
"object-not-found": "Object {id} can't be found…",
"on-object": "on {object}",
"open-console-in-new-tab": "Open console in new tab",
"open-in-new-window": "Open in new window",
"or": "Or",
"page-not-found": "This page is not to be found…",
"password": "Password",
@@ -141,11 +136,11 @@
"save": "Save",
"select-destination-host": "Select a destination host",
"selected-vms-in-execution": "Some selected VMs are running",
"send-ctrl-alt-del": "Send Ctrl+Alt+Del",
"send-us-feedback": "Send us feedback",
"settings": "Settings",
"shutdown": "Shutdown",
"snapshot": "Snapshot",
"some-selected-vms-can-not-be-migrated": "Some selected VMs can't be migrated",
"sort-by": "Sort by",
"stacked-cpu-usage": "Stacked CPU usage",
"stacked-ram-usage": "Stacked RAM usage",
@@ -178,7 +173,6 @@
"vcpus": "vCPUs",
"vcpus-used": "vCPUs used",
"version": "Version",
"vm-is-running": "The VM is running",
"vms": "VMs",
"xo-lite-under-construction": "XOLite is under construction"
}

View File

@@ -75,8 +75,6 @@
"following-hosts-unreachable": "Les hôtes suivants sont inaccessibles",
"force-reboot": "Forcer le redémarrage",
"force-shutdown": "Forcer l'arrêt",
"fullscreen": "Plein écran",
"fullscreen-leave": "Quitter plein écran",
"go-back": "Revenir en arrière",
"here": "Ici",
"hosts": "Hôtes",
@@ -88,8 +86,6 @@
"loading-hosts": "Chargement des hôtes…",
"log-out": "Se déconnecter",
"login": "Connexion",
"login-only-on-master": "La connexion n'est possible que sur l'hôte primaire",
"more-actions": "Plus d'actions",
"migrate": "Migrer",
"migrate-n-vms": "Migrer 1 VM | Migrer {n} VMs",
"n-hosts-awaiting-patch": "{n} hôte attend ce patch | {n} hôtes attendent ce patch",
@@ -104,13 +100,12 @@
"news": "Actualités",
"news-name": "Actualités {name}",
"no-alarm-triggered": "Aucune alarme déclenchée",
"no-selected-vm-can-be-migrated": "Aucune VM sélectionnée ne peut être migrée",
"no-tasks": "Aucune tâche",
"not-found": "Non trouvé",
"object": "Objet",
"object-not-found": "L'objet {id} est introuvable…",
"on-object": "sur {object}",
"open-console-in-new-tab": "Ouvrir la console dans un nouvel onglet",
"open-in-new-window": "Ouvrir dans une nouvelle fenêtre",
"or": "Ou",
"page-not-found": "Cette page est introuvable…",
"password": "Mot de passe",
@@ -141,11 +136,11 @@
"save": "Enregistrer",
"select-destination-host": "Sélectionnez un hôte de destination",
"selected-vms-in-execution": "Certaines VMs sélectionnées sont en cours d'exécution",
"send-ctrl-alt-del": "Envoyer Ctrl+Alt+Suppr",
"send-us-feedback": "Envoyez-nous vos commentaires",
"settings": "Paramètres",
"shutdown": "Arrêter",
"snapshot": "Instantané",
"some-selected-vms-can-not-be-migrated": "Certaines VMs sélectionnées ne peuvent pas être migrées",
"sort-by": "Trier par",
"stacked-cpu-usage": "Utilisation CPU empilée",
"stacked-ram-usage": "Utilisation RAM empilée",
@@ -178,7 +173,6 @@
"vcpus": "vCPUs",
"vcpus-used": "vCPUs utilisés",
"version": "Version",
"vm-is-running": "La VM est en cours d'exécution",
"vms": "VMs",
"xo-lite-under-construction": "XOLite est en construction"
}

View File

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

View File

@@ -1,7 +1,7 @@
import { useBreakpoints, useColorMode } from "@vueuse/core";
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useRoute } from "vue-router";
export const useUiStore = defineStore("ui", () => {
const currentHostOpaqueRef = ref();
@@ -14,15 +14,8 @@ export const useUiStore = defineStore("ui", () => {
const isMobile = computed(() => !isDesktop.value);
const router = useRouter();
const route = useRoute();
const hasUi = computed<boolean>({
get: () => route.query.ui !== "0",
set: (value: boolean) => {
void router.replace({ query: { ui: value ? undefined : "0" } });
},
});
const hasUi = computed(() => route.query.ui !== "0");
return {
colorMode,

View File

@@ -36,17 +36,6 @@ export const useVmStore = defineStore("xen-api-vm", () => {
);
};
const areSomeOperationAllowed = (
vm: XenApiVm,
operations: VM_OPERATION[] | VM_OPERATION
) => {
const allowedOperations = Object.values(vm.allowed_operations);
return castArray(operations).some((operation) =>
allowedOperations.includes(operation)
);
};
const runningVms = computed(() =>
records.value.filter((vm) => vm.power_state === VM_POWER_STATE.RUNNING)
);
@@ -103,7 +92,6 @@ export const useVmStore = defineStore("xen-api-vm", () => {
...context,
records,
isOperationPending,
areSomeOperationAllowed,
runningVms,
recordsByHostRef,
getStats,

View File

@@ -1,7 +1,11 @@
```vue-template
<UiModal>
<UiModal v-model="isOpen">
<BasicModalLayout>
Here is a basic modal...
</BasicModalLayout>
</UiModal>
```
```vue-script
const { isOpen } = useModal();
```

View File

@@ -3,9 +3,7 @@
v-slot="{ settings }"
:params="[
slot(),
setting('defaultSlotContent')
.preset('Here is a basic modal...')
.widget(text()),
setting('defaultSlotContent').preset('Modal content').widget(text()),
]"
>
<BasicModalLayout>

View File

@@ -1,18 +1,21 @@
```vue-template
<UiModal @submit.prevent="approve()">
<UiModal v-model="isOpen">
<ConfirmModalLayout :icon="faShip">
<template #title>Do you confirm?</template>
<template #subtitle>You should be sure about this</template>
<template #buttons>
<ModalDeclineButton>I prefer not</UiButton>
<ModalApproveButton>Yes, I'm sure!</UiButton>
<UiButton outlined @click="close">I prefer not</UiButton>
<UiButton @click="accept">Yes, I'm sure!</UiButton>
</template>
</ConfirmModalLayout>
</UiModal>
```
```vue-script
import { IK_MODAL } from "@/types/injection-keys";
const { isOpen, close } = useModal();
const { approve } = inject(IK_MODAL)!;
const accept = async () => {
// do something
close();
}
```

View File

@@ -2,21 +2,21 @@
<ComponentStory
v-slot="{ properties, settings }"
:params="[
iconProp().preset(faShip),
iconProp(),
slot('title'),
slot('subtitle'),
slot('default'),
slot('buttons').help('Meant to receive UiButton components'),
setting('title').preset('Do you confirm?').widget(),
setting('subtitle').preset('You should be sure about this').widget(),
setting('title').preset('Modal Title').widget(),
setting('subtitle').preset('Modal Subtitle').widget(),
]"
>
<ConfirmModalLayout v-bind="properties">
<template #title>{{ settings.title }}</template>
<template #subtitle>{{ settings.subtitle }}</template>
<template #buttons>
<UiButton outlined>I prefer not</UiButton>
<UiButton>Yes, I'm sure!</UiButton>
<UiButton outlined>Discard</UiButton>
<UiButton>Go</UiButton>
</template>
</ConfirmModalLayout>
</ComponentStory>
@@ -27,5 +27,6 @@ import ComponentStory from "@/components/component-story/ComponentStory.vue";
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { iconProp, setting, slot } from "@/libs/story/story-param";
import { faShip } from "@fortawesome/free-solid-svg-icons";
</script>
<style lang="postcss" scoped></style>

View File

@@ -1,6 +1,6 @@
```vue-template
<UiModal @submit.prevent="handleSubmit()">
<FormModalLayout :icon="faShip">
<UiModal v-model="isOpen">
<FormModalLayout :icon="faShip" @submit.prevent="handleSubmit">
<template #title>Migrate 3 VMs/template>
<template #default>
@@ -8,23 +8,18 @@
</template>
<template #buttons>
<ModalDeclineButton />
<ModalApproveButton>Migrate 3 VMs</UiButton>
<UiButton outlined @click="close">Cancel</UiButton>
<UiButton type="submit">Migrate 3 VMs</UiButton>
</template>
</ConfirmModalLayout>
</UiModal>
```
```vue-script
import { IK_MODAL } from "@/types/injection-keys";
const { isOpen, close } = useModal();
const { approve } = inject(IK_MODAL)!;
const migrate = async () => {
// Do the migration...
}
const handleSubmit = () => {
approve(migrate());
const handleSubmit = async () => {
// Handling form submission...
close();
}
```

View File

@@ -1,10 +1,12 @@
A basic modal container containing 3 slots: `header`, `default` and `footer`.
Tag will be `div` by default but can be changed with the `tag` prop.
Color can be changed with the `color` prop.
To keep the content centered vertically, header and footer will always have the same height.
Modal content has a max height + overflow to prevent the modal growing out of the screen.
Modal content has an max height + overflow to prevent the modal growing out of the screen.
Modal containers can be nested.

View File

@@ -2,6 +2,7 @@
<ComponentStory
v-slot="{ properties, settings }"
:params="[
prop('tag').str().default('div').widget(),
colorProp(),
slot('header'),
slot(),
@@ -46,6 +47,6 @@
<script lang="ts" setup>
import ComponentStory from "@/components/component-story/ComponentStory.vue";
import ModalContainer from "@/components/ui/modals/ModalContainer.vue";
import { colorProp, setting, slot } from "@/libs/story/story-param";
import { colorProp, prop, setting, slot } from "@/libs/story/story-param";
import { boolean, text } from "@/libs/story/story-widget";
</script>

View File

@@ -0,0 +1,21 @@
This component only handle the modal backdrop and content positioning.
You can use any pre-made layouts, create your own or use the `ModalContainer` component.
It is meant to be used with `useModal` composable.
```vue-template
<button @click="open">Delete all items</button>
<UiModal v-model="isOpen">
<ModalContainer...>
<!-- <ConfirmModalLayout ...> (Or you can use a pre-made layout) -->
</UiModal>
```
```vue-script
import { faRemove } from "@fortawesome/free-solid-svg-icons";
import { useModal } from "@composable/modal.composable";
const { open, close, isOpen } = useModal().
```

View File

@@ -0,0 +1,24 @@
<template>
<ComponentStory
:params="[
model()
.required()
.type('boolean')
.help('Whether the modal is opened or not'),
colorProp().ctx(),
slot().help('Place your ModalContainer here'),
]"
>
<button type="button" @click="open">Open modal</button>
<UiModal v-model="isOpen" />
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from "@/components/component-story/ComponentStory.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import useModal from "@/composables/modal.composable";
import { colorProp, model, slot } from "@/libs/story/story-param";
const { isOpen, open } = useModal();
</script>

View File

@@ -1,10 +1 @@
export type Color = "info" | "error" | "warning" | "success";
export type ModalController = {
id: symbol;
component: any;
props: object;
approve: <P>(payload?: P) => void;
decline: () => void;
isBusy: boolean;
};

View File

@@ -2,7 +2,6 @@ import type { FetchedStats, Stat } from "@/composables/fetch-stats.composable";
import type { HostStats, VmStats } from "@/libs/xapi-stats";
import type { XenApiHost } from "@/libs/xen-api/xen-api.types";
import type { ValueFormatter } from "@/types/chart";
import type { ModalController } from "@/types/index";
import type { ComputedRef, InjectionKey } from "vue";
export const IK_MENU_TELEPORTED = Symbol() as InjectionKey<boolean>;
@@ -52,5 +51,3 @@ export const IK_INPUT_ID = Symbol() as InjectionKey<ComputedRef<string>>;
export const IK_MODAL_CLOSE = Symbol() as InjectionKey<() => void>;
export const IK_MODAL_NESTED = Symbol() as InjectionKey<boolean>;
export const IK_MODAL = Symbol() as InjectionKey<ModalController>;

View File

@@ -17,8 +17,3 @@ export interface SortConfig<T> {
queryStringParam?: string;
initialSorts?: InitialSorts<T>;
}
export type NewSort = {
property: string;
isAscending: boolean;
};

View File

@@ -39,8 +39,4 @@ titleStore.setCount(() => pendingTasks.value.length);
font-size: 1.4rem;
}
}
.ui-card {
margin: 1rem;
}
</style>

View File

@@ -87,7 +87,6 @@ const isMigrating = (vm: XenApiVm) =>
<style lang="postcss" scoped>
.pool-vms-view {
overflow: auto;
margin: 1rem;
}
.vm-name {

View File

@@ -7,62 +7,40 @@
{{ $t("power-on-for-console") }}
</div>
<template v-else-if="vm && vmConsole">
<AppMenu horizontal>
<MenuItem
:icon="faArrowUpRightFromSquare"
@click="openInNewTab"
v-if="uiStore.hasUi"
>
{{ $t("open-console-in-new-tab") }}
</MenuItem>
<MenuItem
:icon="
uiStore.hasUi
? faUpRightAndDownLeftFromCenter
: faDownLeftAndUpRightToCenter
"
@click="toggleFullScreen"
>
{{ $t(uiStore.hasUi ? "fullscreen" : "fullscreen-leave") }}
</MenuItem>
<MenuItem
:disabled="!consoleElement"
:icon="faKeyboard"
@click="sendCtrlAltDel"
>
{{ $t("send-ctrl-alt-del") }}
</MenuItem>
</AppMenu>
<RemoteConsole
ref="consoleElement"
:is-console-available="isConsoleAvailable"
:location="vmConsole.location"
class="remote-console"
/>
<div class="open-in-new-window">
<RouterLink
v-if="uiStore.hasUi"
:to="{ query: { ui: '0' } }"
class="link"
target="_blank"
>
<UiIcon :icon="faArrowUpRightFromSquare" />
{{ $t("open-in-new-window") }}
</RouterLink>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import AppMenu from "@/components/menu/AppMenu.vue";
import MenuItem from "@/components/menu/MenuItem.vue";
import RemoteConsole from "@/components/RemoteConsole.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiSpinner from "@/components/ui/UiSpinner.vue";
import { VM_OPERATION, VM_POWER_STATE } from "@/libs/xen-api/xen-api.enums";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useUiStore } from "@/stores/ui.store";
import { useConsoleCollection } from "@/stores/xen-api/console.store";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import {
faArrowUpRightFromSquare,
faDownLeftAndUpRightToCenter,
faKeyboard,
faUpRightAndDownLeftFromCenter,
} from "@fortawesome/free-solid-svg-icons";
import { computed, ref } from "vue";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
import { VM_POWER_STATE, VM_OPERATION } from "@/libs/xen-api/xen-api.enums";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useUiStore } from "@/stores/ui.store";
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import { useRoute } from "vue-router";
const STOP_OPERATIONS = [
VM_OPERATION.SHUTDOWN,
@@ -76,7 +54,6 @@ const STOP_OPERATIONS = [
usePageTitleStore().setTitle(useI18n().t("console"));
const router = useRouter();
const route = useRoute();
const uiStore = useUiStore();
@@ -118,26 +95,14 @@ const isConsoleAvailable = computed(() =>
? !isOperationPending(vm.value, STOP_OPERATIONS)
: false
);
const consoleElement = ref();
const sendCtrlAltDel = () => consoleElement.value?.sendCtrlAltDel();
const toggleFullScreen = () => {
uiStore.hasUi = !uiStore.hasUi;
};
const openInNewTab = () => {
const routeData = router.resolve({ query: { ui: "0" } });
window.open(routeData.href, "_blank");
};
</script>
<style lang="postcss" scoped>
.vm-console-view {
display: flex;
align-items: center;
justify-content: center;
height: calc(100% - 14.5rem);
flex-direction: column;
&.no-ui {
height: 100%;
@@ -195,9 +160,4 @@ const openInNewTab = () => {
}
}
}
.vm-console-view:deep(.app-menu) {
background-color: transparent;
align-self: center;
}
</style>

View File

@@ -14,7 +14,7 @@
"url": "https://vates.fr"
},
"license": "AGPL-3.0-or-later",
"version": "0.14.0",
"version": "0.13.0",
"engines": {
"node": ">=15.6"
},

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