Compare commits

..

40 Commits

Author SHA1 Message Date
Julien Fontanet
402bdbf656 WiP: refactor(xo-server/rest-api): declarative approach 2023-11-14 15:31:43 +01:00
Julien Fontanet
d27698d131 fix(proxy/appliance): fix warning message 2023-11-14 10:12:41 +01:00
Julien Fontanet
836300755a feat: release 5.88.2 2023-11-13 15:07:43 +01:00
Julien Fontanet
55c8c8a6e9 feat(xo-server): 5.126.0 2023-11-13 11:41:08 +01:00
Julien Fontanet
38e32cd24c chore: update dev deps 2023-11-13 09:48:54 +01:00
Julien Fontanet
5ceacfaf5a fix(xo-server/redis): fix searching with multiple indexes
Introduced by 36b94f745
2023-11-12 22:18:19 +01:00
Thierry Goettelmann
1ee6b106b9 feat(lite/ui): compact layout (#7159) 2023-11-10 16:26:20 +01:00
Julien Fontanet
eaef4f22d2 fix(xo-web/settings/logs): use template when reporting
Related to #7142
2023-11-10 11:33:01 +01:00
Julien Fontanet
96025df12f feat(xo-server): only create a single token per web client (and user)
Related to e07e2d3cc

Similar to 581b42fa9
2023-11-09 17:13:10 +01:00
Mathieu
a8aac295eb fix(lite/login): correctly handle login from slave (#7110) 2023-11-09 15:00:29 +01:00
Mathieu
83141989f0 feat(xolite/modals): add onClose event (#7167) 2023-11-09 10:57:24 +01:00
Julien Fontanet
9dea52281d docs(installation): explicit Redis should be started 2023-11-07 17:10:35 +01:00
Julien Fontanet
2164c72034 fix(xo-server): log redis errors
Avoid unhandled error events.
2023-11-07 16:08:41 +01:00
Julien Fontanet
0d0c38f3b5 fix(backups): create suspend VDI on correct SR
Introduced by a958fe86d
2023-11-07 15:55:14 +01:00
Pierre Donias
e5be21a590 feat(lite): 0.1.5 (#7162) 2023-11-07 15:49:12 +01:00
Julien Fontanet
bc1a8be862 chore: fix formatting 2023-11-07 14:33:16 +01:00
Mathieu
3df4dbaae7 feat: release 5.88.1 (#7163) 2023-11-07 14:30:53 +01:00
Julien Fontanet
8f2cfebda6 feat(xo-server/rest-api): add users collection 2023-11-07 12:51:12 +01:00
Julien Fontanet
0d00c1c45f chore: update dev deps 2023-11-07 12:32:38 +01:00
Mathieu
9886e06d6a feat: technical release (#7160) 2023-11-07 10:18:09 +01:00
Thierry Goettelmann
478dbdfe41 feat(lite): new modal management (#7134) 2023-11-07 09:49:02 +01:00
Florent BEAUCHAMP
2bfdb60dda fix(fs): handle object storage server not implementing Object lock (#7157) 2023-11-06 17:18:00 +01:00
Pierre Donias
cabd04470d feat(xo-server-netbox): use Netbox version instead of Netbox API version (#7138)
Netbox version is more precise (X.Y.Z instead of X.Y)
2023-11-06 17:10:10 +01:00
Florent BEAUCHAMP
f6819b23f9 fix(xo-web/dashboard): empty VDIs shouldn't be flagged as orphan (#7102)
Fixes zammad#15524
2023-11-06 13:57:44 +01:00
Julien Fontanet
c9dbcf1384 fix(proxy/backup.importVmBackup): only dispose resources at the end (#7152)
Fixes #7052

Fixes zammad#17383

When a stream is returned, the handler immediately returned a stream which disposed the resource.

Due to the disposable having a 5 mins debounce delay, the problem was only apparent after 5 mins.
2023-11-06 10:30:34 +01:00
Pierre Donias
457fec0bc8 fix(xo-server-netbox): properly delete all interfaces that don't have a UUID (#7153)
Fixes zammad#18812

Introduced by 3b1bcc67ae

The first step of synchronizing VIFs with Netbox interfaces is to clean up any interface attached to the Netbox VM that isn't found on the XO VM, *based on their UUID*, including the interfaces that don't have a UUID at all (`uuid` is `null`).

But by looping over the keyed-by-UUID collection, we could only ever delete at most *one* null-UUID interface, the other ones being dropped by `keyBy`.

Using the flat-array collection instead makes sure all the interfaces are handled.
2023-11-06 10:28:43 +01:00
Pierre Donias
db99a22244 fix(xo-web/New network): only hide bond-PIFs when creating a bonded network (#7151)
Fixes #7150
See https://xcp-ng.org/forum/topic/7918
Introduced by dbdc5f3e3b
2023-11-03 11:22:10 +01:00
Pierre Donias
89d8adc6c6 feat(netbox): expose raw HTTP body if it cannot be JSON-parsed (#7146) 2023-11-02 14:28:06 +01:00
Pierre Donias
a3ea70c61c fix(xo-server-netbox): fix site property null/undefined cases (#7145)
Introduced by 1d7559ded2
2023-10-31 16:16:38 +01:00
Mathieu
ae0f3b4fe0 feat: release 5.88.0 (#7143) 2023-10-31 14:58:50 +01:00
Mathieu
2552ef37d2 feat: technical release (#7141) 2023-10-31 10:09:35 +01:00
Pierre Donias
9803e8c6cb feat(xo-web/patches): warning about updating pool master first (#7140) 2023-10-31 09:51:17 +01:00
Florent BEAUCHAMP
3410cbc3b9 fix(backups): use VDI virtual_size instead of physical_size
`physical_size` appears to be broken
2023-10-30 15:55:30 +01:00
Florent BEAUCHAMP
93fce0d4bf fix(backups): pass type to Xapi#getRecord
Fixes #7131

Introduced by 37b211376
2023-10-30 15:55:30 +01:00
MlssFrncJrg
dbdc5f3e3b feat(xo-web/New network): don't show PIFs that belong to a bond (#7136) 2023-10-30 15:47:38 +01:00
Julien Fontanet
581b42fa9d feat(xo-cli): only create a single token per instance (and user) 2023-10-30 15:47:17 +01:00
Julien Fontanet
e07e2d3ccd feat(xo-server/token): client info support 2023-10-30 15:47:17 +01:00
Mathieu
ad928ec23d fix(xo-web/licenses/XOSTOR): various fixes on XOSTOR licenses (#7137)
Introduced by #6983
2023-10-30 14:55:11 +01:00
Pierre Donias
1d7559ded2 fix(xo-server-netbox/VM): explicitly assign site (#7124)
See Zammad#17766
See https://xcp-ng.org/forum/topic/7887
2023-10-30 11:32:12 +01:00
Mathieu
9099b58557 feat: technical release (#7132) 2023-10-27 16:13:04 +02:00
116 changed files with 3170 additions and 2865 deletions

View File

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

View File

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

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 dictionnary of properties attached to the task
// undefined or a dictionary 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": "^16.0.0",
"sinon": "^17.0.1",
"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.0",
"@xen-orchestra/fs": "^4.1.0",
"@xen-orchestra/backups": "^0.43.2",
"@xen-orchestra/fs": "^4.1.2",
"filenamify": "^6.0.0",
"getopts": "^2.2.5",
"lodash": "^4.17.15",

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

@@ -39,10 +39,12 @@ export const FullXapi = class FullXapiVmBackupRunner extends AbstractXapi {
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(vdiRef)
// at most the xva will take the physical usage of the disk
// the resulting stream can be smaller due to the smaller block size for xva than vhd, and compression of xcp-ng
maxStreamLength += vdi.physical_utilisation
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)

View File

@@ -8,7 +8,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.43.0",
"version": "0.43.2",
"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.0",
"@xen-orchestra/fs": "^4.1.2",
"@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": "^16.0.0",
"sinon": "^17.0.1",
"test": "^3.2.1",
"tmp": "^0.2.1"
},
"peerDependencies": {
"@xen-orchestra/xapi": "^3.2.0"
"@xen-orchestra/xapi": "^3.3.0"
},
"license": "AGPL-3.0-or-later",
"author": {

View File

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

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "@xen-orchestra/fs",
"version": "4.1.0",
"version": "4.1.2",
"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": "^16.0.0",
"sinon": "^17.0.1",
"test": "^3.3.0",
"tmp": "^0.2.1"
},

View File

@@ -456,7 +456,7 @@ export default class S3Handler extends RemoteHandlerAbstract {
this.#s3.middlewareStack.use(getApplyMd5BodyChecksumPlugin(this.#s3.config))
}
} catch (error) {
if (error.Code !== 'ObjectLockConfigurationNotFoundError') {
if (error.Code !== 'ObjectLockConfigurationNotFoundError' && error.$metadata.httpStatusCode !== 501) {
throw error
}
}

View File

@@ -2,9 +2,15 @@
## **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)

View File

@@ -0,0 +1,111 @@
# 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.4",
"version": "0.1.5",
"scripts": {
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
"build": "run-p type-check build-only",

View File

@@ -1,5 +1,4 @@
<template>
<UnreachableHostsModal />
<div v-if="!$route.meta.hasStoryNav && !xenApiStore.isConnected">
<AppLogin />
</div>
@@ -13,6 +12,7 @@
</div>
<AppTooltips />
</div>
<ModalList />
</template>
<script lang="ts" setup>
@@ -21,8 +21,9 @@ 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 UnreachableHostsModal from "@/components/UnreachableHostsModal.vue";
import ModalList from "@/components/ui/modals/ModalList.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";
@@ -78,6 +79,8 @@ whenever(
xenApiStore.getXapi().startWatching(poolRef);
}
);
useUnreachableHosts();
</script>
<style lang="postcss">
@@ -88,7 +91,7 @@ whenever(
.main {
overflow: auto;
flex: 1;
height: calc(100vh - 8rem);
height: calc(100vh - 5.5rem);
background-color: var(--background-color-secondary);
&.no-ui {

View File

@@ -38,7 +38,7 @@ const { trigger: navigationTrigger } = storeToRefs(navigationStore);
display: flex;
align-items: center;
justify-content: space-between;
min-height: 8rem;
height: 5.5rem;
padding: 1rem;
border-bottom: 0.1rem solid var(--color-blue-scale-400);
background-color: var(--background-color-secondary);
@@ -48,7 +48,8 @@ const { trigger: navigationTrigger } = storeToRefs(navigationStore);
}
.text-logo {
margin: 1rem;
margin-left: 1rem;
vertical-align: middle;
}
}

View File

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

View File

@@ -3,79 +3,27 @@
<UiFilter
v-for="filter in activeFilters"
:key="filter"
@edit="editFilter(filter)"
@edit="openModal(filter)"
@remove="emit('removeFilter', filter)"
>
{{ filter }}
</UiFilter>
<UiActionButton :icon="faPlus" class="add-filter" @click="open">
<UiActionButton :icon="faPlus" class="add-filter" @click="openModal()">
{{ $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 { getFilterIcon } from "@/libs/utils";
import type { Filters, NewFilter } from "@/types/filter";
import { useModal } from "@/composables/modal.composable";
import type { Filters } from "@/types/filter";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { Or, parse } from "complex-matcher";
import { computed, ref } from "vue";
defineProps<{
const props = defineProps<{
activeFilters: string[];
availableFilters: Filters;
}>();
@@ -85,110 +33,19 @@ const emit = defineEmits<{
(event: "removeFilter", filter: string): void;
}>();
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 !== ""
const openModal = (editedFilter?: string) => {
const { onApprove } = useModal<string>(
() => import("@/components/modals/CollectionFilterModal.vue"),
{
availableFilters: props.availableFilters,
editedFilter,
}
);
if (filters.length === 0) {
return "";
if (editedFilter !== undefined) {
onApprove(() => emit("removeFilter", editedFilter));
}
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();
onApprove((newFilter) => emit("addFilter", newFilter));
};
</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,65 +12,27 @@
</span>
</UiFilter>
<UiActionButton :icon="faPlus" class="add-sort" @click="open">
<UiActionButton :icon="faPlus" class="add-sort" @click="openModal()">
{{ $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, Sorts } from "@/types/sort";
import { useModal } from "@/composables/modal.composable";
import type { ActiveSorts, NewSort, Sorts } from "@/types/sort";
import {
faCaretDown,
faCaretUp,
faPlus,
} from "@fortawesome/free-solid-svg-icons";
import { ref } from "vue";
import { computed } from "vue";
defineProps<{
const props = defineProps<{
availableSorts: Sorts;
activeSorts: ActiveSorts<Record<string, any>>;
}>();
@@ -81,29 +43,19 @@ const emit = defineEmits<{
(event: "removeSort", property: string): void;
}>();
const { isOpen, open, close } = useModal({ onClose: () => reset() });
const openModal = () => {
const { onApprove } = useModal<NewSort>(
() => import("@/components/modals/CollectionSorterModal.vue"),
{ availableSorts: computed(() => props.availableSorts) }
);
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();
onApprove(({ property, isAscending }) =>
emit("addSort", property, isAscending)
);
};
</script>
<style lang="postcss" scoped>
.form-widgets {
display: flex;
gap: 1rem;
}
.property {
display: inline-flex;
align-items: center;

View File

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

View File

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

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

View File

@@ -1,76 +0,0 @@
<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>
<div class="usage-bar">
<template v-if="data !== undefined">
<div
v-for="item in computedData.sortedArray"
@@ -67,6 +67,12 @@ 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,9 +1,4 @@
<template>
<UiModal v-model="isRawValueModalOpen">
<BasicModalLayout>
<CodeHighlight :code="rawValueModalPayload" />
</BasicModalLayout>
</UiModal>
<StoryParamsTable>
<thead>
<tr>
@@ -101,9 +96,7 @@ 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 BasicModalLayout from "@/components/ui/modals/layouts/BasicModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import useModal from "@/composables/modal.composable";
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";
@@ -131,11 +124,8 @@ const emit = defineEmits<{
const model = useVModel(props, "modelValue", emit);
const {
open: openRawValueModal,
isOpen: isRawValueModalOpen,
payload: rawValueModalPayload,
} = useModal<string>();
const openRawValueModal = (code: string) =>
useModal(() => import("@/components/CodeHighlight.vue"), { code });
</script>
<style lang="postcss" scoped>

View File

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

View File

@@ -1,46 +1,18 @@
<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
@click="openCodeModal"
:model-value="jsonValue"
:before="faCode"
:model-value="jsonValue"
readonly
@click="openModal()"
/>
</template>
<script lang="ts" setup>
import FormInput from "@/components/form/FormInput.vue";
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 { useModal } from "@/composables/modal.composable";
import { faCode } from "@fortawesome/free-solid-svg-icons";
import { useVModel, whenever } from "@vueuse/core";
import { computed, ref } from "vue";
import { useVModel } from "@vueuse/core";
import { computed } from "vue";
const props = defineProps<{
modelValue: any;
@@ -52,51 +24,14 @@ 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 isJsonValid = computed(() => {
try {
JSON.parse(editedJson.value);
return true;
} catch {
return false;
}
});
const openModal = () => {
const { onApprove } = useModal<string>(
() => import("@/components/modals/JsonEditorModal.vue"),
{ initialValue: jsonValue.value }
);
const formatJson = () => {
if (!isJsonValid.value) {
return;
}
editedJson.value = JSON.stringify(JSON.parse(editedJson.value), undefined, 2);
onApprove((newValue) => (model.value = JSON.parse(newValue)));
};
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: 3rem;
padding-left: 2rem;
}
.infra-vm-list:deep(.link),
.infra-vm-list:deep(.link-placeholder) {
padding-left: 4.5rem;
padding-left: 3rem;
}
.master-icon {

View File

@@ -72,7 +72,7 @@ const hasTooltip = computed(() => hasEllipsis(textElement.value));
align-items: center;
flex: 1;
min-width: 0;
padding: 1.5rem;
padding: 1rem;
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: 3rem;
padding-left: 2rem;
}
.text-error {

View File

@@ -43,10 +43,6 @@ 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

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

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

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

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

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

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

@@ -0,0 +1,64 @@
<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: 24rem;
max-height: 25rem;
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>
<UiCardFooter class="ui-card-footer">
<template #left>
<p>{{ $t("vcpus-used") }}</p>
<p class="footer-value">{{ nVCpuInUse }}</p>
@@ -113,4 +113,8 @@ 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" #right>
<template v-if="areAllLoaded && count > 0" #right>
{{ $t("n-missing", { n: count }) }}
</template>
</UiCardTitle>
@@ -35,7 +35,7 @@ const { count, patches, areSomeLoaded, areAllLoaded } = useHostPatches(hosts);
}
.table-container {
max-height: 40rem;
max-height: 25rem;
overflow: auto;
}
</style>

View File

@@ -43,6 +43,7 @@ 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.8rem;
font-size: 1.6rem;
font-weight: 600;
display: flex;
align-items: center;
padding: 0 1.2em;
padding: 0 1.5rem;
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: 6.5rem;
height: 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.6rem;
font-size: 1.4rem;
line-height: 2.4rem;
color: var(--color-blue-scale-200);

View File

@@ -0,0 +1,13 @@
<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="close"
@click="modal?.decline()"
/>
</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_CLOSE } from "@/types/injection-keys";
import { IK_MODAL } from "@/types/injection-keys";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { inject } from "vue";
const { textClass } = useContext(ColorContext);
const close = inject(IK_MODAL_CLOSE, undefined);
const modal = inject(IK_MODAL, undefined);
</script>
<style lang="postcss" scoped>

View File

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

View File

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

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

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

View File

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

View File

@@ -26,16 +26,12 @@ 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, DisabledContext } from "@/context";
import { ColorContext } from "@/context";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
const props = withDefaults(
defineProps<{
icon?: IconDefinition;
disabled?: boolean;
}>(),
{ disabled: undefined }
);
defineProps<{
icon?: IconDefinition;
}>();
defineSlots<{
title: () => void;
@@ -44,8 +40,6 @@ defineSlots<{
}>();
const { textClass, borderClass } = useContext(ColorContext);
useContext(DisabledContext, () => props.disabled);
</script>
<style lang="postcss" scoped>

View File

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

View File

@@ -7,59 +7,23 @@
>
{{ $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 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 { useModal } from "@/composables/modal.composable";
import { vTooltip } from "@/directives/tooltip.directive";
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 type { XenApiVm } from "@/libs/xen-api/xen-api.types";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { faSatellite, faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { 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)
@@ -73,10 +37,8 @@ const isDisabled = computed(
() => vms.value.length === 0 || areSomeVmsInExecution.value
);
const deleteVms = async () => {
await xenApi.vm.delete(props.vmRefs);
closeDeleteModal();
};
const { textClass } = useContext(ColorContext);
const openDeleteModal = () =>
useModal(() => import("@/components/modals/VmDeleteModal.vue"), {
vmRefs: props.vmRefs,
});
</script>

View File

@@ -1,95 +1,60 @@
<template>
<MenuItem
v-tooltip="
!areAllVmsMigratable && $t('some-selected-vms-can-not-be-migrated')
selectedRefs.length > 0 &&
!isMigratable &&
$t('no-selected-vm-can-be-migrated')
"
:busy="isMigrating"
:disabled="isParentDisabled || !areAllVmsMigratable"
:disabled="isParentDisabled || !isMigratable"
: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 { useVmMigration } from "@/composables/vm-migration.composable";
import { useModal } from "@/composables/modal.composable";
import { DisabledContext } from "@/context";
import { vTooltip } from "@/directives/tooltip.directive";
import { VM_OPERATION } from "@/libs/xen-api/xen-api.enums";
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
import { useVmCollection } from "@/stores/xen-api/vm.store";
import { faRoute } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
const props = defineProps<{
selectedRefs: XenApiVm["$ref"][];
}>();
const { getByOpaqueRefs, isOperationPending, areSomeOperationAllowed } =
useVmCollection();
const isParentDisabled = useContext(DisabledContext);
const {
open: openModal,
isOpen: isModalOpen,
close: closeModal,
} = useModal({
onClose: () => (selectedHost.value = undefined),
});
const isMigratable = computed(() =>
getByOpaqueRefs(props.selectedRefs).some((vm) =>
areSomeOperationAllowed(vm, [
VM_OPERATION.POOL_MIGRATE,
VM_OPERATION.MIGRATE_SEND,
])
)
);
const {
selectedHost,
availableHosts,
isValid,
migrate,
isMigrating,
areAllVmsMigratable,
} = useVmMigration(() => props.selectedRefs);
const isMigrating = computed(() =>
getByOpaqueRefs(props.selectedRefs).some((vm) =>
isOperationPending(vm, [
VM_OPERATION.POOL_MIGRATE,
VM_OPERATION.MIGRATE_SEND,
])
)
);
const handleMigrate = async () => {
try {
await migrate();
closeModal();
} catch (e) {
console.error("Error while migrating", e);
}
};
const openModal = () =>
useModal(() => import("@/components/modals/VmMigrateModal.vue"), {
vmRefs: props.selectedRefs,
});
</script>

View File

@@ -1,77 +0,0 @@
# 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,48 +1,5 @@
import { computed, readonly, ref } from "vue";
import { useModalStore } from "@/stores/modal.store";
import type { AsyncComponentLoader } from "vue";
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,
};
}
export const useModal = <T>(loader: AsyncComponentLoader, props: object = {}) =>
useModalStore().open<T>(loader, props);

View File

@@ -0,0 +1,37 @@
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,12 +39,6 @@ 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 &&
@@ -75,7 +69,6 @@ export const useVmMigration = (
isMigrating,
availableHosts,
selectedHost,
areAllVmsMigratable,
isValid,
migrate,
};

View File

@@ -18,6 +18,12 @@ 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);
};
@@ -49,6 +55,7 @@ export const useXenApiStoreBaseContext = <
lastError,
records,
getByOpaqueRef,
getByOpaqueRefs,
getByUuid,
hasUuid,
add,

View File

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

View File

@@ -88,6 +88,7 @@
"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",
@@ -103,6 +104,7 @@
"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",
@@ -144,7 +146,6 @@
"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",

View File

@@ -88,6 +88,7 @@
"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",
@@ -103,6 +104,7 @@
"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",
@@ -144,7 +146,6 @@
"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",

View File

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

@@ -36,6 +36,17 @@ 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)
);
@@ -92,6 +103,7 @@ export const useVmStore = defineStore("xen-api-vm", () => {
...context,
records,
isOperationPending,
areSomeOperationAllowed,
runningVms,
recordsByHostRef,
getStats,

View File

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

View File

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

View File

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

View File

@@ -2,21 +2,21 @@
<ComponentStory
v-slot="{ properties, settings }"
:params="[
iconProp(),
iconProp().preset(faShip),
slot('title'),
slot('subtitle'),
slot('default'),
slot('buttons').help('Meant to receive UiButton components'),
setting('title').preset('Modal Title').widget(),
setting('subtitle').preset('Modal Subtitle').widget(),
setting('title').preset('Do you confirm?').widget(),
setting('subtitle').preset('You should be sure about this').widget(),
]"
>
<ConfirmModalLayout v-bind="properties">
<template #title>{{ settings.title }}</template>
<template #subtitle>{{ settings.subtitle }}</template>
<template #buttons>
<UiButton outlined>Discard</UiButton>
<UiButton>Go</UiButton>
<UiButton outlined>I prefer not</UiButton>
<UiButton>Yes, I'm sure!</UiButton>
</template>
</ConfirmModalLayout>
</ComponentStory>
@@ -27,6 +27,5 @@ 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 v-model="isOpen">
<FormModalLayout :icon="faShip" @submit.prevent="handleSubmit">
<UiModal @submit.prevent="handleSubmit()">
<FormModalLayout :icon="faShip">
<template #title>Migrate 3 VMs/template>
<template #default>
@@ -8,18 +8,23 @@
</template>
<template #buttons>
<UiButton outlined @click="close">Cancel</UiButton>
<UiButton type="submit">Migrate 3 VMs</UiButton>
<ModalDeclineButton />
<ModalApproveButton>Migrate 3 VMs</UiButton>
</template>
</ConfirmModalLayout>
</UiModal>
```
```vue-script
const { isOpen, close } = useModal();
import { IK_MODAL } from "@/types/injection-keys";
const handleSubmit = async () => {
// Handling form submission...
close();
const { approve } = inject(IK_MODAL)!;
const migrate = async () => {
// Do the migration...
}
const handleSubmit = () => {
approve(migrate());
}
```

View File

@@ -1,12 +1,10 @@
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 an max height + overflow to prevent the modal growing out of the screen.
Modal content has a max height + overflow to prevent the modal growing out of the screen.
Modal containers can be nested.

View File

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

View File

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

@@ -1,24 +0,0 @@
<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 +1,10 @@
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,6 +2,7 @@ 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>;
@@ -51,3 +52,5 @@ 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,3 +17,8 @@ export interface SortConfig<T> {
queryStringParam?: string;
initialSorts?: InitialSorts<T>;
}
export type NewSort = {
property: string;
isAscending: boolean;
};

View File

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

View File

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

View File

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

View File

@@ -33,7 +33,7 @@ const callUpdate = params =>
} else if (method === 'server-error') {
reject(new Error(params.message))
} else if (method !== 'connected') {
warn('update.update, unhandled message', {
warn('updater.update, unhandled message', {
method,
params,
})

View File

@@ -5,7 +5,6 @@ import { compose } from '@vates/compose'
import { createLogger } from '@xen-orchestra/log'
import { decorateMethodsWith } from '@vates/decorate-with'
import { deduped } from '@vates/disposable/deduped.js'
import { defer } from 'golike-defer'
import { DurablePartition } from '@xen-orchestra/backups/DurablePartition.mjs'
import { execFile } from 'child_process'
import { formatVmBackups } from '@xen-orchestra/backups/formatVmBackups.mjs'
@@ -23,16 +22,18 @@ const noop = Function.prototype
const { warn } = createLogger('xo:proxy:backups')
const runWithLogs = (runner, args) =>
const runWithLogs = (runner, args, onEnd) =>
new Readable({
objectMode: true,
read() {
this._read = noop
runner(args, log => this.push(log)).then(
() => this.push(null),
error => this.emit('error', error)
)
runner(args, log => this.push(log))
.then(
() => this.push(null),
error => this.emit('error', error)
)
.then(onEnd)
},
})[Symbol.asyncIterator]()
@@ -190,30 +191,41 @@ export default class Backups {
},
],
importVmBackup: [
defer(($defer, { backupId, remote, srUuid, settings, streamLogs = false, xapi: xapiOpts }) =>
Disposable.use(this.getAdapter(remote), this.getXapi(xapiOpts), async (adapter, xapi) => {
const metadata = await adapter.readVmBackupMetadata(backupId)
const run = () => new ImportVmBackup({ adapter, metadata, settings, srUuid, xapi }).run()
return streamLogs
? runWithLogs(
async (args, onLog) =>
Task.run(
{
data: {
backupId,
jobId: metadata.jobId,
srId: srUuid,
time: metadata.timestamp,
},
name: 'restore',
onLog,
},
run
).catch(() => {}) // errors are handled by logs
)
: run()
})
),
async ({ backupId, remote, srUuid, settings, streamLogs = false, xapi: xapiOpts }) => {
const {
dispose,
value: [adapter, xapi],
} = await Disposable.all([this.getAdapter(remote), this.getXapi(xapiOpts)])
const metadata = await adapter.readVmBackupMetadata(backupId)
const run = () => new ImportVmBackup({ adapter, metadata, settings, srUuid, xapi }).run()
if (streamLogs) {
return runWithLogs(
async (args, onLog) =>
Task.run(
{
data: {
backupId,
jobId: metadata.jobId,
srId: srUuid,
time: metadata.timestamp,
},
name: 'restore',
onLog,
},
run
).catch(() => {}), // errors are handled by logs,
dispose
)
}
try {
return await run()
} finally {
await dispose()
}
},
{
description: 'create a new VM from a backup',
params: {

View File

@@ -1,7 +1,7 @@
{
"private": true,
"name": "@xen-orchestra/proxy",
"version": "0.26.35",
"version": "0.26.38",
"license": "AGPL-3.0-or-later",
"description": "XO Proxy used to remotely execute backup jobs",
"keywords": [
@@ -32,13 +32,13 @@
"@vates/decorate-with": "^2.0.0",
"@vates/disposable": "^0.1.4",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.43.0",
"@xen-orchestra/fs": "^4.1.0",
"@xen-orchestra/backups": "^0.43.2",
"@xen-orchestra/fs": "^4.1.2",
"@xen-orchestra/log": "^0.6.0",
"@xen-orchestra/mixin": "^0.1.0",
"@xen-orchestra/mixins": "^0.13.0",
"@xen-orchestra/mixins": "^0.14.0",
"@xen-orchestra/self-signed": "^0.1.3",
"@xen-orchestra/xapi": "^3.2.0",
"@xen-orchestra/xapi": "^3.3.0",
"ajv": "^8.0.3",
"app-conf": "^2.3.0",
"async-iterator-to-stream": "^1.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@xen-orchestra/xapi",
"version": "3.2.0",
"version": "3.3.0",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/xapi",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {

View File

@@ -1,8 +1,89 @@
# ChangeLog
## **5.88.2** (2023-11-13)
### Enhancement
- [REST API] Add `users` collection
- [Authentication] Re-use existing token instead of creating a new one when connecting with the same user on the same browser
### Released packages
- xo-server 5.125.3
## **5.88.1** (2023-11-07)
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
### Bug fixes
- [Netbox] Fix VMs' `site` property being unnecessarily updated on some versions of Netbox (PR [#7145](https://github.com/vatesfr/xen-orchestra/pull/7145))
- [Netbox] Fix "400 Bad Request" error (PR [#7153](https://github.com/vatesfr/xen-orchestra/pull/7153))
- [Backup/Restore] Fix timeout after 5 minutes [#7052](https://github.com/vatesfr/xen-orchestra/issues/7052)
- [Dashboard/Health] Empty VDIs are no longer considered orphans (PR [#7102](https://github.com/vatesfr/xen-orchestra/pull/7102))
- [S3] Handle S3 without _Object Lock_ implementation (PR [#7157](https://github.com/vatesfr/xen-orchestra/pull/7157))
### Released packages
- @xen-orchestra/fs 4.1.2
- @xen-orchestra/proxy 0.26.38
- xo-server 5.125.2
- xo-server-netbox 1.3.3
- xo-web 5.127.2
## **5.88.0** (2023-10-31)
### Highlights
- [About] For source users, display if their XO is up to date [#5934](https://github.com/vatesfr/xen-orchestra/issues/5934) (PR [#7091](https://github.com/vatesfr/xen-orchestra/pull/7091))
- [Self] Show number of VMs that belong to each Resource Set (PR [#7114](https://github.com/vatesfr/xen-orchestra/pull/7114))
- [VM/New] Possibility to create and attach a _VTPM_ to a VM [#7066](https://github.com/vatesfr/xen-orchestra/issues/7066) [Forum#6578](https://xcp-ng.org/forum/topic/6578/xcp-ng-8-3-public-alpha/109) (PR [#7077](https://github.com/vatesfr/xen-orchestra/pull/7077))
- [XOSTOR] Ability to create a XOSTOR storage (PR [#6983](https://github.com/vatesfr/xen-orchestra/pull/6983))
### Enhancements
- [Host/Advanced] Allow to force _Smart reboot_ if some resident VMs have the suspend operation blocked [Forum#7136](https://xcp-ng.org/forum/topic/7136/suspending-vms-during-host-reboot/23) (PR [#7025](https://github.com/vatesfr/xen-orchestra/pull/7025))
- [Plugin/backup-report] Errors are now listed in XO tasks
- [PIF] Show network name in PIF selectors (PR [#7081](https://github.com/vatesfr/xen-orchestra/pull/7081))
- [VM/Advanced] Possibility to create/delete VTPM [#7066](https://github.com/vatesfr/xen-orchestra/issues/7066) [Forum#6578](https://xcp-ng.org/forum/topic/6578/xcp-ng-8-3-public-alpha/109) (PR [#7085](https://github.com/vatesfr/xen-orchestra/pull/7085))
- [Dashboard/Health] Displays number of VDIs to coalesce (PR [#7111](https://github.com/vatesfr/xen-orchestra/pull/7111))
- [Proxy] Ability to open support tunnel on XO Proxy (PRs [#7126](https://github.com/vatesfr/xen-orchestra/pull/7126) [#7127](https://github.com/vatesfr/xen-orchestra/pull/7127))
- [New network] Remove bonded PIFs from selector when creating network (PR [#7136](https://github.com/vatesfr/xen-orchestra/pull/7136))
- Try to preserve current page across reauthentication (PR [#7013](https://github.com/vatesfr/xen-orchestra/pull/7013))
### Bug fixes
- [Rolling Pool Update] After the update, when migrating VMs back to their host, do not migrate VMs that are already on the right host [Forum#7802](https://xcp-ng.org/forum/topic/7802) (PR [#7071](https://github.com/vatesfr/xen-orchestra/pull/7071))
- [RPU] Fix "XenServer credentials not found" when running a Rolling Pool Update on a XenServer pool (PR [#7089](https://github.com/vatesfr/xen-orchestra/pull/7089))
- [Usage report] Fix "Converting circular structure to JSON" error
- [Home] Fix OS icons alignment (PR [#7090](https://github.com/vatesfr/xen-orchestra/pull/7090))
- [SR/Advanced] Fix the total number of VDIs to coalesce by taking into account common chains [#7016](https://github.com/vatesfr/xen-orchestra/issues/7016) (PR [#7098](https://github.com/vatesfr/xen-orchestra/pull/7098))
- Don't require to sign in again in XO after losing connection to XO Server (e.g. when restarting or upgrading XO) (PR [#7103](https://github.com/vatesfr/xen-orchestra/pull/7103))
- [Usage report] Fix "Converting circular structure to JSON" error (PR [#7096](https://github.com/vatesfr/xen-orchestra/pull/7096))
- [Usage report] Fix "Cannot convert undefined or null to object" error (PR [#7092](https://github.com/vatesfr/xen-orchestra/pull/7092))
- [Plugin/transport-xmpp] Fix plugin load
- [Self Service] Fix Self users not being able to snapshot VMs when they're members of a user group (PR [#7129](https://github.com/vatesfr/xen-orchestra/pull/7129))
- [Netbox] Fix "The selected cluster is not assigned to this site" error [Forum#7887](https://xcp-ng.org/forum/topic/7887) (PR [#7124](https://github.com/vatesfr/xen-orchestra/pull/7124))
- [Backups] Fix `MESSAGE_METHOD_UNKNOWN` during full backup [Forum#7894](https://xcp-ng.org/forum/topic/7894)(PR [#7139](https://github.com/vatesfr/xen-orchestra/pull/7139))
### Released packages
- @xen-orchestra/fs 4.1.1
- @xen-orchestra/xapi 3.3.0
- @xen-orchestra/mixins 0.14.0
- xo-server-backup-reports 0.18.0
- xo-server-transport-xmpp 0.1.3
- xo-server-usage-report 0.10.5
- @xen-orchestra/backups 0.43.2
- @xen-orchestra/proxy 0.26.37
- xo-cli 0.21.0
- xo-server 5.125.1
- xo-server-netbox 1.3.2
- xo-web 5.127.1
## **5.87.0** (2023-09-29)
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
### Highlights
@@ -53,8 +134,6 @@
## **5.86.1** (2023-09-07)
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
### Bug fixes
- [User] _Forget all connection tokens_ button should not delete other users' tokens, even when current user is an administrator (PR [#7014](https://github.com/vatesfr/xen-orchestra/pull/7014))

View File

@@ -7,31 +7,11 @@
> Users must be able to say: “Nice enhancement, I'm eager to test it”
- [Host/Advanced] Allow to force _Smart reboot_ if some resident VMs have the suspend operation blocked [Forum#7136](https://xcp-ng.org/forum/topic/7136/suspending-vms-during-host-reboot/23) (PR [#7025](https://github.com/vatesfr/xen-orchestra/pull/7025))
- [Plugin/backup-report] Errors are now listed in XO tasks
- [PIF] Show network name in PIF selectors (PR [#7081](https://github.com/vatesfr/xen-orchestra/pull/7081))
- [VM/Advanced] Possibility to create/delete VTPM [#7066](https://github.com/vatesfr/xen-orchestra/issues/7066) [Forum#6578](https://xcp-ng.org/forum/topic/6578/xcp-ng-8-3-public-alpha/109) (PR [#7085](https://github.com/vatesfr/xen-orchestra/pull/7085))
- [VM/New] Possibility to create and attach a _VTPM_ to a VM [#7066](https://github.com/vatesfr/xen-orchestra/issues/7066) [Forum#6578](https://xcp-ng.org/forum/topic/6578/xcp-ng-8-3-public-alpha/109) (PR [#7077](https://github.com/vatesfr/xen-orchestra/pull/7077))
- [Dashboard/Health] Displays number of VDIs to coalesce (PR [#7111](https://github.com/vatesfr/xen-orchestra/pull/7111))
- [Self] Show number of VMs that belong to each Resource Set (PR [#7114](https://github.com/vatesfr/xen-orchestra/pull/7114))
- [About] For source users, display if their XO is up to date [#5934](https://github.com/vatesfr/xen-orchestra/issues/5934) (PR [#7091](https://github.com/vatesfr/xen-orchestra/pull/7091))
- [Proxy] Ability to open support tunnel on XO Proxy (PRs [#7126](https://github.com/vatesfr/xen-orchestra/pull/7126) [#7127](https://github.com/vatesfr/xen-orchestra/pull/7127))
- [XOSTOR] Ability to create a XOSTOR storage (PR [#6983](https://github.com/vatesfr/xen-orchestra/pull/6983))
### Bug fixes
> Users must be able to say: “I had this issue, happy to know it's fixed”
- [Rolling Pool Update] After the update, when migrating VMs back to their host, do not migrate VMs that are already on the right host [Forum#7802](https://xcp-ng.org/forum/topic/7802) (PR [#7071](https://github.com/vatesfr/xen-orchestra/pull/7071))
- [RPU] Fix "XenServer credentials not found" when running a Rolling Pool Update on a XenServer pool (PR [#7089](https://github.com/vatesfr/xen-orchestra/pull/7089))
- [Usage report] Fix "Converting circular structure to JSON" error
- [Home] Fix OS icons alignment (PR [#7090](https://github.com/vatesfr/xen-orchestra/pull/7090))
- [SR/Advanced] Fix the total number of VDIs to coalesce by taking into account common chains [#7016](https://github.com/vatesfr/xen-orchestra/issues/7016) (PR [#7098](https://github.com/vatesfr/xen-orchestra/pull/7098))
- Don't require to sign in again in XO after losing connection to XO Server (e.g. when restarting or upgrading XO) (PR [#7103](https://github.com/vatesfr/xen-orchestra/pull/7103))
- [Usage report] Fix "Converting circular structure to JSON" error (PR [#7096](https://github.com/vatesfr/xen-orchestra/pull/7096))
- [Usage report] Fix "Cannot convert undefined or null to object" error (PR [#7092](https://github.com/vatesfr/xen-orchestra/pull/7092))
- [Plugin/transport-xmpp] Fix plugin load
- [Self Service] Fix Self users not being able to snapshot VMs when they're members of a user group (PR [#7129](https://github.com/vatesfr/xen-orchestra/pull/7129))
- [Backup/Restore] In case of snapshot with memory, create the suspend VDI on the correct SR instead of the default one
### Packages to release
@@ -50,15 +30,6 @@
<!--packages-start-->
- @xen-orchestra/backups patch
- @xen-orchestra/fs patch
- @xen-orchestra/mixins minor
- @xen-orchestra/xapi minor
- xo-cli minor
- xo-server minor
- xo-server-backup-reports minor
- xo-server-netbox patch
- xo-server-transport-xmpp patch
- xo-server-usage-report patch
- xo-web minor
- xo-web patch
<!--packages-end-->

View File

@@ -115,6 +115,21 @@ On Fedora/CentOS like:
dnf install redis libpng-devel git libvhdi-utils lvm2 cifs-utils make automake gcc gcc-c++
```
### Make sure Redis is running
Start the service:
```sh
systemctl restart redis.service
```
Ensure it's working:
```console
$ redis-cli ping
PONG
```
### Fetching the Code
You need to use the `git` source code manager to fetch the code. Ideally, you should run XO as a non-root user, and if you choose to, you need to set up `sudo` to be able to mount NFS remotes. As your chosen non-root (or root) user, run the following:

View File

@@ -3,8 +3,8 @@
"@babel/core": "^7.0.0",
"@babel/eslint-parser": "^7.13.8",
"@babel/register": "^7.0.0",
"@commitlint/cli": "^17.4.4",
"@commitlint/config-conventional": "^17.4.4",
"@commitlint/cli": "^18.2.0",
"@commitlint/config-conventional": "^18.1.0",
"@vates/async-each": "^1.0.0",
"babel-jest": "^29.0.3",
"benchmark": "^2.1.4",
@@ -23,7 +23,7 @@
"handlebars": "^4.7.6",
"husky": "^8.0.2",
"jest": "^29.0.3",
"lint-staged": "^14.0.1",
"lint-staged": "^15.0.2",
"lodash": "^4.17.4",
"prettier": "^3.0.1",
"promise-toolbox": "^0.21.0",

View File

@@ -23,7 +23,7 @@
"node": ">=10"
},
"dependencies": {
"@xen-orchestra/fs": "^4.1.0",
"@xen-orchestra/fs": "^4.1.2",
"cli-progress": "^3.1.0",
"exec-promise": "^0.7.0",
"getopts": "^2.2.3",

View File

@@ -20,7 +20,7 @@
"@vates/read-chunk": "^1.2.0",
"@vates/stream-reader": "^0.1.0",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/fs": "^4.1.0",
"@xen-orchestra/fs": "^4.1.2",
"@xen-orchestra/log": "^0.6.0",
"async-iterator-to-stream": "^1.0.2",
"decorator-synchronized": "^0.6.0",
@@ -33,7 +33,7 @@
"uuid": "^9.0.0"
},
"devDependencies": {
"@xen-orchestra/fs": "^4.1.0",
"@xen-orchestra/fs": "^4.1.2",
"execa": "^5.0.0",
"get-stream": "^6.0.0",
"rimraf": "^5.0.1",

View File

@@ -94,9 +94,15 @@ Usage:
xo-cli rest get tasks filter='status:pending'
xo-cli rest get vms fields=name_label,power_state
xo-cli rest get <object> [wait | wait=result]
xo-cli rest get [--output <file>] <object> [wait | wait=result]
Show an object from the REST API.
--output <file>
If specified, the response will be saved in <file> instead of being parsed.
If <file> ends with `/`, it will be considered as the directory in which
to save the response, and the filename will be last part of the <object> path.
<object>
Full path of the object to show

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "xo-cli",
"version": "0.20.0",
"version": "0.21.0",
"license": "AGPL-3.0-or-later",
"description": "Basic CLI for Xen-Orchestra",
"keywords": [

View File

@@ -2,6 +2,8 @@
# xo-server-auth-oidc
> OpenID Connect authentication plugin for XO-Server
## Usage
This plugin allows users to authenticate to Xen-Orchestra using [OpenID Connect](<https://en.wikipedia.org/wiki/OpenID#OpenID_Connect_(OIDC)>).

View File

@@ -1,6 +1,6 @@
{
"name": "xo-server-backup-reports",
"version": "0.17.4",
"version": "0.18.0",
"license": "AGPL-3.0-or-later",
"description": "Backup reports plugin for XO-Server",
"keywords": [

View File

@@ -1,6 +1,6 @@
{
"name": "xo-server-netbox",
"version": "1.3.0",
"version": "1.3.3",
"license": "AGPL-3.0-or-later",
"description": "Synchronizes pools managed by Xen Orchestra with Netbox",
"keywords": [

View File

@@ -39,7 +39,7 @@ class Netbox {
#endpoint
#intervalToken
#loaded
#netboxApiVersion
#netboxVersion
#xoPools
#removeApiMethods
#syncInterval
@@ -103,6 +103,7 @@ class Netbox {
}
async test() {
await this.#fetchNetboxVersion()
await this.#checkCustomFields()
const randomSuffix = Math.random().toString(36).slice(2, 11)
@@ -144,25 +145,24 @@ class Netbox {
const httpRequest = async () => {
try {
const response = await this.#xo.httpRequest(url, options)
this.#netboxApiVersion = response.headers['api-version']
const body = await response.text()
if (body.length > 0) {
return JSON.parse(body)
const resBody = await response.text()
if (resBody.length > 0) {
return JSON.parse(resBody)
}
} catch (error) {
error.data = {
method,
path,
body: dataDebug,
}
error.method = method
error.requestBody = dataDebug
let resBody = 'Netbox error could not be retrieved'
try {
const body = await error.response.text()
if (body.length > 0) {
error.data.error = JSON.parse(body)
}
} catch {
throw error
resBody = await error.response.text()
error.netboxError = JSON.parse(resBody)
} catch (err) {
log.error(err)
// If the error couldn't be parsed, expose the response's raw body
error.netboxError = resBody
}
throw error
}
}
@@ -183,7 +183,7 @@ class Netbox {
response = await httpRequest()
}
if (method !== 'GET') {
if (method !== 'GET' || response.results === undefined) {
return response
}
@@ -211,9 +211,24 @@ class Netbox {
}
}
async #fetchNetboxVersion() {
try {
this.#netboxVersion = semver.coerce((await this.#request('/status/'))['netbox-version']).version
} catch (err) {
if (err?.response?.statusCode === 404) {
// Endpoint not supported on versions prior to v2.10
// Best effort to support earlier versions without knowing the version explicitly
return
}
throw err
}
}
// ---------------------------------------------------------------------------
async #synchronize(xoPools = this.#xoPools) {
await this.#fetchNetboxVersion()
await this.#checkCustomFields()
log.info(`Synchronizing ${xoPools.length} pools with Netbox`, { pools: xoPools })
@@ -336,6 +351,14 @@ class Netbox {
tags: [],
}
// Prior to Netbox v3.3.0: no "site" field on VMs
// v3.3.0: "site" is REQUIRED and MUST be the same as cluster's site
// v3.3.5: "site" is OPTIONAL (auto-assigned in UI, not in API). `null` and cluster's site are accepted.
// v3.4.8: "site" is OPTIONAL and AUTO-ASSIGNED with cluster's site. If passed: ignored except if site is different from cluster's, then error.
if (this.#netboxVersion !== undefined && semver.satisfies(this.#netboxVersion, '3.3.0 - 3.4.7')) {
nbVm.site = find(nbClusters, { id: nbCluster.id })?.site?.id ?? null
}
const distro = xoVm.os_version?.distro
if (distro != null) {
const slug = slugify(distro)
@@ -379,10 +402,7 @@ class Netbox {
nbVm.tags = nbVmTags.sort(({ id: id1 }, { id: id2 }) => (id1 < id2 ? -1 : 1))
// https://netbox.readthedocs.io/en/stable/release-notes/version-2.7/#api-choice-fields-now-use-string-values-3569
if (
this.#netboxApiVersion !== undefined &&
!semver.satisfies(semver.coerce(this.#netboxApiVersion).version, '>=2.7.0')
) {
if (this.#netboxVersion === undefined || !semver.satisfies(this.#netboxVersion, '>=2.7.0')) {
nbVm.status = xoVm.power_state === 'Running' ? 1 : 0
}
@@ -395,6 +415,9 @@ class Netbox {
cluster: nbVm.cluster?.id ?? null,
status: nbVm.status?.value ?? null,
platform: nbVm.platform?.id ?? null,
// If site is not supported by Netbox, its value is undefined
// If site is supported by Netbox but empty, its value is null
site: nbVm.site == null ? nbVm.site : nbVm.site.id,
// Sort them so that they can be compared by diff()
tags: nbVm.tags.map(nbTag => ({ id: nbTag.id })).sort(({ id: id1 }, { id: id2 }) => (id1 < id2 ? -1 : 1)),
})
@@ -540,10 +563,12 @@ class Netbox {
continue
}
// Start by deleting old interfaces attached to this Netbox VM
Object.entries(nbIfs).forEach(([id, nbIf]) => {
if (nbIf.virtual_machine.id === nbVm.id && !xoVm.VIFs.includes(nbIf.custom_fields.uuid)) {
// Loop over the array to make sure interfaces with a `null` UUID also get deleted
nbIfsList.forEach(nbIf => {
const xoVifId = nbIf.custom_fields.uuid
if (nbIf.virtual_machine.id === nbVm.id && !xoVm.VIFs.includes(xoVifId)) {
ifsToDelete.push({ id: nbIf.id })
delete nbIfs[id]
delete nbIfs[xoVifId]
}
})

View File

@@ -1,6 +1,6 @@
{
"name": "xo-server-transport-xmpp",
"version": "0.1.2",
"version": "0.1.3",
"license": "AGPL-3.0-or-later",
"description": "Transport Xmpp plugin for XO-Server",
"keywords": [

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