Compare commits
4 Commits
xo-lite-v0
...
improveFor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d70da2a960 | ||
|
|
637eb1d2d7 | ||
|
|
86b86c5c99 | ||
|
|
0b8525febe |
@@ -33,7 +33,7 @@
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^17.0.1",
|
||||
"sinon": "^16.0.0",
|
||||
"tap": "^16.3.0",
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"ensure-array": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^17.0.1",
|
||||
"sinon": "^16.0.0",
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ const onProgress = makeOnProgress({
|
||||
// current status of the task as described in the previous section
|
||||
taskLog.status
|
||||
|
||||
// undefined or a dictionary of properties attached to the task
|
||||
// undefined or a dictionnary of properties attached to the task
|
||||
taskLog.properties
|
||||
|
||||
// timestamp at which the abortion was requested, undefined otherwise
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^17.0.1",
|
||||
"sinon": "^16.0.0",
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"dependencies": {
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.43.2",
|
||||
"@xen-orchestra/fs": "^4.1.2",
|
||||
"@xen-orchestra/fs": "^4.1.1",
|
||||
"filenamify": "^6.0.0",
|
||||
"getopts": "^2.2.5",
|
||||
"lodash": "^4.17.15",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"@vates/nbd-client": "^2.0.0",
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/fs": "^4.1.2",
|
||||
"@xen-orchestra/fs": "^4.1.1",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"@xen-orchestra/template": "^0.1.0",
|
||||
"app-conf": "^2.3.0",
|
||||
@@ -51,7 +51,7 @@
|
||||
"devDependencies": {
|
||||
"fs-extra": "^11.1.0",
|
||||
"rimraf": "^5.0.1",
|
||||
"sinon": "^17.0.1",
|
||||
"sinon": "^16.0.0",
|
||||
"test": "^3.2.1",
|
||||
"tmp": "^0.2.1"
|
||||
},
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^17.0.1",
|
||||
"sinon": "^16.0.0",
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/fs",
|
||||
"version": "4.1.2",
|
||||
"version": "4.1.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "The File System for Xen Orchestra backups.",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/fs",
|
||||
@@ -53,7 +53,7 @@
|
||||
"cross-env": "^7.0.2",
|
||||
"dotenv": "^16.0.0",
|
||||
"rimraf": "^5.0.1",
|
||||
"sinon": "^17.0.1",
|
||||
"sinon": "^16.0.0",
|
||||
"test": "^3.3.0",
|
||||
"tmp": "^0.2.1"
|
||||
},
|
||||
|
||||
@@ -456,7 +456,7 @@ export default class S3Handler extends RemoteHandlerAbstract {
|
||||
this.#s3.middlewareStack.use(getApplyMd5BodyChecksumPlugin(this.#s3.config))
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.Code !== 'ObjectLockConfigurationNotFoundError' && error.$metadata.httpStatusCode !== 501) {
|
||||
if (error.Code !== 'ObjectLockConfigurationNotFoundError') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,9 @@
|
||||
|
||||
## **next**
|
||||
|
||||
## **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)
|
||||
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
# Modal System Documentation
|
||||
|
||||
## Opening a modal
|
||||
|
||||
To open a modal, call `useModal(loader, props?)`.
|
||||
|
||||
- `loader`: The modal component loader (e.g. `() => import("path/to/MyModal.vue")`)
|
||||
- `props`: The optional props to pass to the modal component
|
||||
|
||||
This will return an object with the following methods:
|
||||
|
||||
- `onApprove(cb)`:
|
||||
- A function to register a callback to be called when the modal is approved.
|
||||
- The callback will receive the modal payload as first argument, if any.
|
||||
- The callback can return a Promise, in which case the modal will wait for it to resolve before closing.
|
||||
- `onDecline(cb)`:
|
||||
- A function to register a callback to be called when the modal is declined.
|
||||
- The callback can return a Promise, in which case the modal will wait for it to resolve before closing.
|
||||
|
||||
### Static modal
|
||||
|
||||
```ts
|
||||
useModal(MyModal);
|
||||
```
|
||||
|
||||
### Modal with props
|
||||
|
||||
```ts
|
||||
useModal(MyModal, { message: "Hello world!" });
|
||||
```
|
||||
|
||||
### Handle modal approval
|
||||
|
||||
```ts
|
||||
const { onApprove } = useModal(MyModal, { message: "Hello world!" });
|
||||
|
||||
onApprove(() => console.log("Modal approved"));
|
||||
```
|
||||
|
||||
### Handle modal approval with payload
|
||||
|
||||
```ts
|
||||
const { onApprove } = useModal(MyModal, { message: "Hello world!" });
|
||||
|
||||
onApprove((payload) => console.log("Modal approved with payload", payload));
|
||||
```
|
||||
|
||||
### Handle modal decline
|
||||
|
||||
```ts
|
||||
const { onDecline } = useModal(MyModal, { message: "Hello world!" });
|
||||
|
||||
onDecline(() => console.log("Modal declined"));
|
||||
```
|
||||
|
||||
## Modal controller
|
||||
|
||||
Inside the modal component, you can inject the modal controller with `inject(IK_MODAL)!`.
|
||||
|
||||
```ts
|
||||
const modal = inject(IK_MODAL)!;
|
||||
```
|
||||
|
||||
You can then use the following properties and methods on the `modal` object:
|
||||
|
||||
- `isBusy`: Whether the modal is currently doing something (e.g. waiting for a promise to resolve).
|
||||
- `approve(payload?: any | Promise<any>)`: Approve the modal with an optional payload.
|
||||
- Set `isBusy` to `true`.
|
||||
- Wait for the `payload` to resolve (if any).
|
||||
- Wait for all callbacks registered with `onApprove` to resolve (if any).
|
||||
- Close the modal in case of success.
|
||||
- `decline()`: Decline the modal.
|
||||
- Set `isBusy` to `true`.
|
||||
- Wait for all callbacks registered with `onDecline` to resolve (if any).
|
||||
- Close the modal in case of success.
|
||||
|
||||
## Components
|
||||
|
||||
Some components are available for quick modal creation.
|
||||
|
||||
### `UiModal`
|
||||
|
||||
The root component of the modal which will display the backdrop.
|
||||
|
||||
A click on the backdrop will execute `modal.decline()`.
|
||||
|
||||
It accepts `color` and `disabled` props which will update the `ColorContext` and `DisabledContext`.
|
||||
|
||||
`DisabledContext` will also be set to `true` when `modal.isBusy` is `true`.
|
||||
|
||||
The component itself is a `form` and is meant to be used with `<UiModal @submit.prevent="modal.approve()">`.
|
||||
|
||||
### `ModalApproveButton`
|
||||
|
||||
A wrapper around `UiButton` with `type="submit"` and with the `busy` prop set to `modal.isBusy`.
|
||||
|
||||
### `ModalDeclineButton`
|
||||
|
||||
A wrapper around `UiButton` with an `outline` prop and with the `busy` prop set to `modal.isBusy`.
|
||||
|
||||
This button will call `modal.decline()` on click.
|
||||
|
||||
Default text is `$t("cancel")`.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xen-orchestra/lite",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.4",
|
||||
"scripts": {
|
||||
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
|
||||
"build": "run-p type-check build-only",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<template>
|
||||
<UnreachableHostsModal />
|
||||
<div v-if="!$route.meta.hasStoryNav && !xenApiStore.isConnected">
|
||||
<AppLogin />
|
||||
</div>
|
||||
@@ -12,7 +13,6 @@
|
||||
</div>
|
||||
<AppTooltips />
|
||||
</div>
|
||||
<ModalList />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -21,9 +21,8 @@ import AppHeader from "@/components/AppHeader.vue";
|
||||
import AppLogin from "@/components/AppLogin.vue";
|
||||
import AppNavigation from "@/components/AppNavigation.vue";
|
||||
import AppTooltips from "@/components/AppTooltips.vue";
|
||||
import ModalList from "@/components/ui/modals/ModalList.vue";
|
||||
import UnreachableHostsModal from "@/components/UnreachableHostsModal.vue";
|
||||
import { useChartTheme } from "@/composables/chart-theme.composable";
|
||||
import { useUnreachableHosts } from "@/composables/unreachable-hosts.composable";
|
||||
import { useUiStore } from "@/stores/ui.store";
|
||||
import { usePoolCollection } from "@/stores/xen-api/pool.store";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
@@ -79,8 +78,6 @@ whenever(
|
||||
xenApiStore.getXapi().startWatching(poolRef);
|
||||
}
|
||||
);
|
||||
|
||||
useUnreachableHosts();
|
||||
</script>
|
||||
|
||||
<style lang="postcss">
|
||||
|
||||
@@ -3,27 +3,79 @@
|
||||
<UiFilter
|
||||
v-for="filter in activeFilters"
|
||||
:key="filter"
|
||||
@edit="openModal(filter)"
|
||||
@edit="editFilter(filter)"
|
||||
@remove="emit('removeFilter', filter)"
|
||||
>
|
||||
{{ filter }}
|
||||
</UiFilter>
|
||||
|
||||
<UiActionButton :icon="faPlus" class="add-filter" @click="openModal()">
|
||||
<UiActionButton :icon="faPlus" class="add-filter" @click="open">
|
||||
{{ $t("add-filter") }}
|
||||
</UiActionButton>
|
||||
</UiFilterGroup>
|
||||
|
||||
<UiModal v-model="isOpen">
|
||||
<ConfirmModalLayout @submit.prevent="handleSubmit">
|
||||
<template #default>
|
||||
<div class="rows">
|
||||
<CollectionFilterRow
|
||||
v-for="(newFilter, index) in newFilters"
|
||||
:key="newFilter.id"
|
||||
v-model="newFilters[index]"
|
||||
:available-filters="availableFilters"
|
||||
@remove="removeNewFilter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="newFilters.some((filter) => filter.isAdvanced)"
|
||||
class="available-properties"
|
||||
>
|
||||
{{ $t("available-properties-for-advanced-filter") }}
|
||||
<div class="properties">
|
||||
<UiBadge
|
||||
v-for="(filter, property) in availableFilters"
|
||||
:key="property"
|
||||
:icon="getFilterIcon(filter)"
|
||||
>
|
||||
{{ property }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<UiButton transparent @click="addNewFilter">
|
||||
{{ $t("add-or") }}
|
||||
</UiButton>
|
||||
<UiButton :disabled="!isFilterValid" type="submit">
|
||||
{{ $t(editedFilter ? "update" : "add") }}
|
||||
</UiButton>
|
||||
<UiButton outlined @click="close">
|
||||
{{ $t("cancel") }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</ConfirmModalLayout>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import CollectionFilterRow from "@/components/CollectionFilterRow.vue";
|
||||
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
|
||||
import UiModal from "@/components/ui/modals/UiModal.vue";
|
||||
import UiActionButton from "@/components/ui/UiActionButton.vue";
|
||||
import UiBadge from "@/components/ui/UiBadge.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import UiFilter from "@/components/ui/UiFilter.vue";
|
||||
import UiFilterGroup from "@/components/ui/UiFilterGroup.vue";
|
||||
import { useModal } from "@/composables/modal.composable";
|
||||
import type { Filters } from "@/types/filter";
|
||||
import useModal from "@/composables/modal.composable";
|
||||
import { getFilterIcon } from "@/libs/utils";
|
||||
import type { Filters, NewFilter } from "@/types/filter";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Or, parse } from "complex-matcher";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
activeFilters: string[];
|
||||
availableFilters: Filters;
|
||||
}>();
|
||||
@@ -33,19 +85,110 @@ const emit = defineEmits<{
|
||||
(event: "removeFilter", filter: string): void;
|
||||
}>();
|
||||
|
||||
const openModal = (editedFilter?: string) => {
|
||||
const { onApprove } = useModal<string>(
|
||||
() => import("@/components/modals/CollectionFilterModal.vue"),
|
||||
{
|
||||
availableFilters: props.availableFilters,
|
||||
editedFilter,
|
||||
}
|
||||
const { isOpen, open, close } = useModal({ onClose: () => reset() });
|
||||
const newFilters = ref<NewFilter[]>([]);
|
||||
let newFilterId = 0;
|
||||
|
||||
const addNewFilter = () =>
|
||||
newFilters.value.push({
|
||||
id: newFilterId++,
|
||||
content: "",
|
||||
isAdvanced: false,
|
||||
builder: { property: "", comparison: "", value: "", negate: false },
|
||||
});
|
||||
|
||||
const removeNewFilter = (id: number) => {
|
||||
const index = newFilters.value.findIndex((newFilter) => newFilter.id === id);
|
||||
if (index >= 0) {
|
||||
newFilters.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
addNewFilter();
|
||||
|
||||
const generatedFilter = computed(() => {
|
||||
const filters = newFilters.value.filter(
|
||||
(newFilter) => newFilter.content !== ""
|
||||
);
|
||||
|
||||
if (editedFilter !== undefined) {
|
||||
onApprove(() => emit("removeFilter", editedFilter));
|
||||
if (filters.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
onApprove((newFilter) => emit("addFilter", newFilter));
|
||||
if (filters.length === 1) {
|
||||
return filters[0].content;
|
||||
}
|
||||
|
||||
return `|(${filters.map((filter) => filter.content).join(" ")})`;
|
||||
});
|
||||
|
||||
const isFilterValid = computed(() => generatedFilter.value !== "");
|
||||
|
||||
const editedFilter = ref();
|
||||
|
||||
const editFilter = (filter: string) => {
|
||||
const parsedFilter = parse(filter);
|
||||
|
||||
const nodes =
|
||||
parsedFilter instanceof Or ? parsedFilter.children : [parsedFilter];
|
||||
|
||||
newFilters.value = nodes.map((node) => ({
|
||||
id: newFilterId++,
|
||||
content: node.toString(),
|
||||
isAdvanced: true,
|
||||
builder: { property: "", comparison: "", value: "", negate: false },
|
||||
}));
|
||||
editedFilter.value = filter;
|
||||
open();
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
editedFilter.value = "";
|
||||
newFilters.value = [];
|
||||
addNewFilter();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (editedFilter.value) {
|
||||
emit("removeFilter", editedFilter.value);
|
||||
}
|
||||
emit("addFilter", generatedFilter.value);
|
||||
reset();
|
||||
close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.properties {
|
||||
font-size: 1.6rem;
|
||||
margin-top: 1rem;
|
||||
|
||||
ul {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
li {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.available-properties {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.properties {
|
||||
display: flex;
|
||||
margin-top: 0.6rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,27 +12,65 @@
|
||||
</span>
|
||||
</UiFilter>
|
||||
|
||||
<UiActionButton :icon="faPlus" class="add-sort" @click="openModal()">
|
||||
<UiActionButton :icon="faPlus" class="add-sort" @click="open">
|
||||
{{ $t("add-sort") }}
|
||||
</UiActionButton>
|
||||
</UiFilterGroup>
|
||||
|
||||
<UiModal v-model="isOpen">
|
||||
<ConfirmModalLayout @submit.prevent="handleSubmit">
|
||||
<template #default>
|
||||
<div class="form-widgets">
|
||||
<FormWidget :label="$t('sort-by')">
|
||||
<select v-model="newSortProperty">
|
||||
<option v-if="!newSortProperty"></option>
|
||||
<option
|
||||
v-for="(sort, property) in availableSorts"
|
||||
:key="property"
|
||||
:value="property"
|
||||
>
|
||||
{{ sort.label ?? property }}
|
||||
</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
<FormWidget>
|
||||
<select v-model="newSortIsAscending">
|
||||
<option :value="true">{{ $t("ascending") }}</option>
|
||||
<option :value="false">{{ $t("descending") }}</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<UiButton type="submit">{{ $t("add") }}</UiButton>
|
||||
<UiButton outlined @click="close">
|
||||
{{ $t("cancel") }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</ConfirmModalLayout>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import FormWidget from "@/components/FormWidget.vue";
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
|
||||
import UiModal from "@/components/ui/modals/UiModal.vue";
|
||||
import UiActionButton from "@/components/ui/UiActionButton.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import UiFilter from "@/components/ui/UiFilter.vue";
|
||||
import UiFilterGroup from "@/components/ui/UiFilterGroup.vue";
|
||||
import { useModal } from "@/composables/modal.composable";
|
||||
import type { ActiveSorts, NewSort, Sorts } from "@/types/sort";
|
||||
import useModal from "@/composables/modal.composable";
|
||||
import type { ActiveSorts, Sorts } from "@/types/sort";
|
||||
import {
|
||||
faCaretDown,
|
||||
faCaretUp,
|
||||
faPlus,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { computed } from "vue";
|
||||
import { ref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
availableSorts: Sorts;
|
||||
activeSorts: ActiveSorts<Record<string, any>>;
|
||||
}>();
|
||||
@@ -43,19 +81,29 @@ const emit = defineEmits<{
|
||||
(event: "removeSort", property: string): void;
|
||||
}>();
|
||||
|
||||
const openModal = () => {
|
||||
const { onApprove } = useModal<NewSort>(
|
||||
() => import("@/components/modals/CollectionSorterModal.vue"),
|
||||
{ availableSorts: computed(() => props.availableSorts) }
|
||||
);
|
||||
const { isOpen, open, close } = useModal({ onClose: () => reset() });
|
||||
|
||||
onApprove(({ property, isAscending }) =>
|
||||
emit("addSort", property, isAscending)
|
||||
);
|
||||
const newSortProperty = ref();
|
||||
const newSortIsAscending = ref<boolean>(true);
|
||||
|
||||
const reset = () => {
|
||||
newSortProperty.value = undefined;
|
||||
newSortIsAscending.value = true;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit("addSort", newSortProperty.value, newSortIsAscending.value);
|
||||
reset();
|
||||
close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.form-widgets {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.property {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
76
@xen-orchestra/lite/src/components/UnreachableHostsModal.vue
Normal file
76
@xen-orchestra/lite/src/components/UnreachableHostsModal.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<UiModal v-model="isSslModalOpen" color="error">
|
||||
<ConfirmModalLayout :icon="faServer">
|
||||
<template #title>{{ $t("unreachable-hosts") }}</template>
|
||||
|
||||
<template #default>
|
||||
<div class="description">
|
||||
<p>{{ $t("following-hosts-unreachable") }}</p>
|
||||
<p>{{ $t("allow-self-signed-ssl") }}</p>
|
||||
<ul>
|
||||
<li v-for="url in unreachableHostsUrls" :key="url">
|
||||
<a :href="url" class="link" rel="noopener" target="_blank">{{
|
||||
url
|
||||
}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<UiButton color="success" @click="reload">
|
||||
{{ $t("unreachable-hosts-reload-page") }}
|
||||
</UiButton>
|
||||
<UiButton @click="closeSslModal">{{ $t("cancel") }}</UiButton>
|
||||
</template>
|
||||
</ConfirmModalLayout>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
|
||||
import UiModal from "@/components/ui/modals/UiModal.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import useModal from "@/composables/modal.composable";
|
||||
import { useHostCollection } from "@/stores/xen-api/host.store";
|
||||
import { faServer } from "@fortawesome/free-solid-svg-icons";
|
||||
import { difference } from "lodash-es";
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const { records: hosts } = useHostCollection();
|
||||
const unreachableHostsUrls = ref<Set<string>>(new Set());
|
||||
const reload = () => window.location.reload();
|
||||
|
||||
const { isOpen: isSslModalOpen, close: closeSslModal } = useModal({
|
||||
onClose: () => unreachableHostsUrls.value.clear(),
|
||||
});
|
||||
|
||||
watch(
|
||||
() => unreachableHostsUrls.value.size,
|
||||
(size) => {
|
||||
isSslModalOpen.value = size > 0;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(hosts, (nextHosts, previousHosts) => {
|
||||
difference(nextHosts, previousHosts).forEach((host) => {
|
||||
const url = new URL("http://localhost");
|
||||
url.protocol = window.location.protocol;
|
||||
url.hostname = host.address;
|
||||
fetch(url, { mode: "no-cors" }).catch(() =>
|
||||
unreachableHostsUrls.value.add(url.toString())
|
||||
);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.description {
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,9 @@
|
||||
<template>
|
||||
<UiModal v-model="isRawValueModalOpen">
|
||||
<BasicModalLayout>
|
||||
<CodeHighlight :code="rawValueModalPayload" />
|
||||
</BasicModalLayout>
|
||||
</UiModal>
|
||||
<StoryParamsTable>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -96,7 +101,9 @@ import CodeHighlight from "@/components/CodeHighlight.vue";
|
||||
import StoryParamsTable from "@/components/component-story/StoryParamsTable.vue";
|
||||
import StoryWidget from "@/components/component-story/StoryWidget.vue";
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import { useModal } from "@/composables/modal.composable";
|
||||
import BasicModalLayout from "@/components/ui/modals/layouts/BasicModalLayout.vue";
|
||||
import UiModal from "@/components/ui/modals/UiModal.vue";
|
||||
import useModal from "@/composables/modal.composable";
|
||||
import useSortedCollection from "@/composables/sorted-collection.composable";
|
||||
import { vTooltip } from "@/directives/tooltip.directive";
|
||||
import type { PropParam } from "@/libs/story/story-param";
|
||||
@@ -124,8 +131,11 @@ const emit = defineEmits<{
|
||||
|
||||
const model = useVModel(props, "modelValue", emit);
|
||||
|
||||
const openRawValueModal = (code: string) =>
|
||||
useModal(() => import("@/components/CodeHighlight.vue"), { code });
|
||||
const {
|
||||
open: openRawValueModal,
|
||||
isOpen: isRawValueModalOpen,
|
||||
payload: rawValueModalPayload,
|
||||
} = useModal<string>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
@@ -273,7 +273,6 @@ defineExpose({
|
||||
.textarea {
|
||||
height: auto;
|
||||
min-height: 2em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.input {
|
||||
|
||||
@@ -1,18 +1,46 @@
|
||||
<template>
|
||||
<UiModal
|
||||
v-model="isCodeModalOpen"
|
||||
:color="isJsonValid ? 'success' : 'error'"
|
||||
closable
|
||||
>
|
||||
<FormModalLayout @submit.prevent="saveJson" :icon="faCode">
|
||||
<template #default>
|
||||
<FormTextarea class="modal-textarea" v-model="editedJson" />
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<UiButton transparent @click="formatJson">
|
||||
{{ $t("reformat") }}
|
||||
</UiButton>
|
||||
<UiButton outlined @click="closeCodeModal">
|
||||
{{ $t("cancel") }}
|
||||
</UiButton>
|
||||
<UiButton :disabled="!isJsonValid" type="submit">
|
||||
{{ $t("save") }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</FormModalLayout>
|
||||
</UiModal>
|
||||
|
||||
<FormInput
|
||||
:before="faCode"
|
||||
@click="openCodeModal"
|
||||
:model-value="jsonValue"
|
||||
:before="faCode"
|
||||
readonly
|
||||
@click="openModal()"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import FormInput from "@/components/form/FormInput.vue";
|
||||
import { useModal } from "@/composables/modal.composable";
|
||||
import FormTextarea from "@/components/form/FormTextarea.vue";
|
||||
import FormModalLayout from "@/components/ui/modals/layouts/FormModalLayout.vue";
|
||||
import UiModal from "@/components/ui/modals/UiModal.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import useModal from "@/composables/modal.composable";
|
||||
import { faCode } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useVModel } from "@vueuse/core";
|
||||
import { computed } from "vue";
|
||||
import { useVModel, whenever } from "@vueuse/core";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: any;
|
||||
@@ -24,14 +52,51 @@ const emit = defineEmits<{
|
||||
|
||||
const model = useVModel(props, "modelValue", emit);
|
||||
|
||||
const {
|
||||
open: openCodeModal,
|
||||
close: closeCodeModal,
|
||||
isOpen: isCodeModalOpen,
|
||||
} = useModal();
|
||||
|
||||
const jsonValue = computed(() => JSON.stringify(model.value, undefined, 2));
|
||||
|
||||
const openModal = () => {
|
||||
const { onApprove } = useModal<string>(
|
||||
() => import("@/components/modals/JsonEditorModal.vue"),
|
||||
{ initialValue: jsonValue.value }
|
||||
);
|
||||
const isJsonValid = computed(() => {
|
||||
try {
|
||||
JSON.parse(editedJson.value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
onApprove((newValue) => (model.value = JSON.parse(newValue)));
|
||||
const formatJson = () => {
|
||||
if (!isJsonValid.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
editedJson.value = JSON.stringify(JSON.parse(editedJson.value), undefined, 2);
|
||||
};
|
||||
|
||||
const saveJson = () => {
|
||||
if (!isJsonValid.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
formatJson();
|
||||
|
||||
model.value = JSON.parse(editedJson.value);
|
||||
|
||||
closeCodeModal();
|
||||
};
|
||||
|
||||
whenever(isCodeModalOpen, () => (editedJson.value = jsonValue.value));
|
||||
|
||||
const editedJson = ref();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
:deep(.modal-textarea) {
|
||||
min-width: 50rem;
|
||||
min-height: 20rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<template>
|
||||
<UiModal>
|
||||
<BasicModalLayout>
|
||||
<CodeHighlight :code="code" />
|
||||
</BasicModalLayout>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import CodeHighlight from "@/components/CodeHighlight.vue";
|
||||
import BasicModalLayout from "@/components/ui/modals/layouts/BasicModalLayout.vue";
|
||||
import UiModal from "@/components/ui/modals/UiModal.vue";
|
||||
|
||||
defineProps<{
|
||||
code: string;
|
||||
}>();
|
||||
</script>
|
||||
@@ -1,154 +0,0 @@
|
||||
<template>
|
||||
<UiModal @submit.prevent="modal.approve(generatedFilter)">
|
||||
<ConfirmModalLayout>
|
||||
<template #default>
|
||||
<div class="rows">
|
||||
<CollectionFilterRow
|
||||
v-for="(newFilter, index) in newFilters"
|
||||
:key="newFilter.id"
|
||||
v-model="newFilters[index]"
|
||||
:available-filters="availableFilters"
|
||||
@remove="removeNewFilter($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="newFilters.some((filter) => filter.isAdvanced)"
|
||||
class="available-properties"
|
||||
>
|
||||
{{ $t("available-properties-for-advanced-filter") }}
|
||||
<div class="properties">
|
||||
<UiBadge
|
||||
v-for="(filter, property) in availableFilters"
|
||||
:key="property"
|
||||
:icon="getFilterIcon(filter)"
|
||||
>
|
||||
{{ property }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<UiButton transparent @click="addNewFilter()">
|
||||
{{ $t("add-or") }}
|
||||
</UiButton>
|
||||
<ModalDeclineButton />
|
||||
<ModalApproveButton :disabled="!isFilterValid">
|
||||
{{ $t(editedFilter ? "update" : "add") }}
|
||||
</ModalApproveButton>
|
||||
</template>
|
||||
</ConfirmModalLayout>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import CollectionFilterRow from "@/components/CollectionFilterRow.vue";
|
||||
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
|
||||
import ModalApproveButton from "@/components/ui/modals/ModalApproveButton.vue";
|
||||
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
|
||||
import UiModal from "@/components/ui/modals/UiModal.vue";
|
||||
import UiBadge from "@/components/ui/UiBadge.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import { getFilterIcon } from "@/libs/utils";
|
||||
import type { Filters, NewFilter } from "@/types/filter";
|
||||
import { IK_MODAL } from "@/types/injection-keys";
|
||||
import { Or, parse } from "complex-matcher";
|
||||
import { computed, inject, onMounted, ref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
availableFilters: Filters;
|
||||
editedFilter?: string;
|
||||
}>();
|
||||
|
||||
const modal = inject(IK_MODAL)!;
|
||||
const newFilters = ref<NewFilter[]>([]);
|
||||
let newFilterId = 0;
|
||||
|
||||
const addNewFilter = () =>
|
||||
newFilters.value.push({
|
||||
id: newFilterId++,
|
||||
content: "",
|
||||
isAdvanced: false,
|
||||
builder: { property: "", comparison: "", value: "", negate: false },
|
||||
});
|
||||
|
||||
const removeNewFilter = (id: number) => {
|
||||
const index = newFilters.value.findIndex((newFilter) => newFilter.id === id);
|
||||
if (index >= 0) {
|
||||
newFilters.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const generatedFilter = computed(() => {
|
||||
const filters = newFilters.value.filter(
|
||||
(newFilter) => newFilter.content !== ""
|
||||
);
|
||||
|
||||
if (filters.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (filters.length === 1) {
|
||||
return filters[0].content;
|
||||
}
|
||||
|
||||
return `|(${filters.map((filter) => filter.content).join(" ")})`;
|
||||
});
|
||||
|
||||
const isFilterValid = computed(() => generatedFilter.value !== "");
|
||||
|
||||
onMounted(() => {
|
||||
if (props.editedFilter === undefined) {
|
||||
addNewFilter();
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedFilter = parse(props.editedFilter);
|
||||
|
||||
const nodes =
|
||||
parsedFilter instanceof Or ? parsedFilter.children : [parsedFilter];
|
||||
|
||||
newFilters.value = nodes.map((node) => ({
|
||||
id: newFilterId++,
|
||||
content: node.toString(),
|
||||
isAdvanced: true,
|
||||
builder: { property: "", comparison: "", value: "", negate: false },
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.properties {
|
||||
font-size: 1.6rem;
|
||||
margin-top: 1rem;
|
||||
|
||||
ul {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
li {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.available-properties {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.properties {
|
||||
display: flex;
|
||||
margin-top: 0.6rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,67 +0,0 @@
|
||||
<template>
|
||||
<UiModal @submit.prevent="handleSubmit">
|
||||
<ConfirmModalLayout>
|
||||
<template #default>
|
||||
<div class="form-widgets">
|
||||
<FormWidget :label="$t('sort-by')">
|
||||
<select v-model="newSortProperty">
|
||||
<option v-if="!newSortProperty"></option>
|
||||
<option
|
||||
v-for="(sort, property) in availableSorts"
|
||||
:key="property"
|
||||
:value="property"
|
||||
>
|
||||
{{ sort.label ?? property }}
|
||||
</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
<FormWidget>
|
||||
<select v-model="newSortIsAscending">
|
||||
<option :value="true">{{ $t("ascending") }}</option>
|
||||
<option :value="false">{{ $t("descending") }}</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<ModalDeclineButton />
|
||||
<ModalApproveButton>{{ $t("add") }}</ModalApproveButton>
|
||||
</template>
|
||||
</ConfirmModalLayout>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import FormWidget from "@/components/FormWidget.vue";
|
||||
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
|
||||
import ModalApproveButton from "@/components/ui/modals/ModalApproveButton.vue";
|
||||
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
|
||||
import UiModal from "@/components/ui/modals/UiModal.vue";
|
||||
import { IK_MODAL } from "@/types/injection-keys";
|
||||
import type { NewSort, Sorts } from "@/types/sort";
|
||||
import { inject, ref } from "vue";
|
||||
|
||||
defineProps<{
|
||||
availableSorts: Sorts;
|
||||
}>();
|
||||
|
||||
const newSortProperty = ref();
|
||||
const newSortIsAscending = ref<boolean>(true);
|
||||
|
||||
const modal = inject(IK_MODAL)!;
|
||||
|
||||
const handleSubmit = () => {
|
||||
modal.approve<NewSort>({
|
||||
property: newSortProperty.value,
|
||||
isAscending: newSortIsAscending.value,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.form-widgets {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,75 +0,0 @@
|
||||
<template>
|
||||
<UiModal
|
||||
:color="isJsonValid ? 'success' : 'error'"
|
||||
@submit.prevent="handleSubmit()"
|
||||
>
|
||||
<FormModalLayout :icon="faCode" class="layout">
|
||||
<template #default>
|
||||
<FormTextarea v-model="editedJson" class="modal-textarea" />
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<UiButton transparent @click="formatJson()">
|
||||
{{ $t("reformat") }}
|
||||
</UiButton>
|
||||
<ModalDeclineButton />
|
||||
<ModalApproveButton :disabled="!isJsonValid">
|
||||
{{ $t("save") }}
|
||||
</ModalApproveButton>
|
||||
</template>
|
||||
</FormModalLayout>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import FormTextarea from "@/components/form/FormTextarea.vue";
|
||||
import FormModalLayout from "@/components/ui/modals/layouts/FormModalLayout.vue";
|
||||
import ModalApproveButton from "@/components/ui/modals/ModalApproveButton.vue";
|
||||
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
|
||||
import UiModal from "@/components/ui/modals/UiModal.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import { IK_MODAL } from "@/types/injection-keys";
|
||||
import { faCode } from "@fortawesome/free-solid-svg-icons";
|
||||
import { computed, inject, ref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
initialValue?: string;
|
||||
}>();
|
||||
|
||||
const editedJson = ref<string>(props.initialValue ?? "");
|
||||
const modal = inject(IK_MODAL)!;
|
||||
|
||||
const isJsonValid = computed(() => {
|
||||
try {
|
||||
JSON.parse(editedJson.value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const formatJson = () => {
|
||||
if (!isJsonValid.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
editedJson.value = JSON.stringify(JSON.parse(editedJson.value), undefined, 2);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!isJsonValid.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
formatJson();
|
||||
|
||||
modal.approve(editedJson.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.layout:deep(.modal-textarea) {
|
||||
min-width: 50rem;
|
||||
min-height: 20rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,54 +0,0 @@
|
||||
<template>
|
||||
<UiModal color="error" @submit="modal.approve()">
|
||||
<ConfirmModalLayout :icon="faServer">
|
||||
<template #title>{{ $t("unreachable-hosts") }}</template>
|
||||
|
||||
<template #default>
|
||||
<div class="description">
|
||||
<p>{{ $t("following-hosts-unreachable") }}</p>
|
||||
<p>{{ $t("allow-self-signed-ssl") }}</p>
|
||||
<ul>
|
||||
<li v-for="url in urls" :key="url">
|
||||
<a :href="url" class="link" rel="noopener" target="_blank">
|
||||
{{ url }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<ModalDeclineButton />
|
||||
<ModalApproveButton>
|
||||
{{ $t("unreachable-hosts-reload-page") }}
|
||||
</ModalApproveButton>
|
||||
</template>
|
||||
</ConfirmModalLayout>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
|
||||
import ModalApproveButton from "@/components/ui/modals/ModalApproveButton.vue";
|
||||
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
|
||||
import UiModal from "@/components/ui/modals/UiModal.vue";
|
||||
import { IK_MODAL } from "@/types/injection-keys";
|
||||
import { faServer } from "@fortawesome/free-solid-svg-icons";
|
||||
import { inject } from "vue";
|
||||
|
||||
defineProps<{
|
||||
urls: string[];
|
||||
}>();
|
||||
|
||||
const modal = inject(IK_MODAL)!;
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.description {
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,53 +0,0 @@
|
||||
<template>
|
||||
<UiModal @submit.prevent="handleSubmit()">
|
||||
<ConfirmModalLayout :icon="faSatellite">
|
||||
<template #title>
|
||||
<i18n-t keypath="confirm-delete" scope="global" tag="div">
|
||||
<span :class="textClass">
|
||||
{{ $t("n-vms", { n: vmRefs.length }) }}
|
||||
</span>
|
||||
</i18n-t>
|
||||
</template>
|
||||
|
||||
<template #subtitle>
|
||||
{{ $t("please-confirm") }}
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<ModalDeclineButton>
|
||||
{{ $t("go-back") }}
|
||||
</ModalDeclineButton>
|
||||
<ModalApproveButton>
|
||||
{{ $t("delete-vms", { n: vmRefs.length }) }}
|
||||
</ModalApproveButton>
|
||||
</template>
|
||||
</ConfirmModalLayout>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
|
||||
import ModalApproveButton from "@/components/ui/modals/ModalApproveButton.vue";
|
||||
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
|
||||
import UiModal from "@/components/ui/modals/UiModal.vue";
|
||||
import { useContext } from "@/composables/context.composable";
|
||||
import { ColorContext } from "@/context";
|
||||
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
import { IK_MODAL } from "@/types/injection-keys";
|
||||
import { faSatellite } from "@fortawesome/free-solid-svg-icons";
|
||||
import { inject } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
vmRefs: XenApiVm["$ref"][];
|
||||
}>();
|
||||
|
||||
const modal = inject(IK_MODAL)!;
|
||||
|
||||
const { textClass } = useContext(ColorContext);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const xenApi = useXenApiStore().getXapi();
|
||||
modal.approve(xenApi.vm.delete(props.vmRefs));
|
||||
};
|
||||
</script>
|
||||
@@ -1,64 +0,0 @@
|
||||
<template>
|
||||
<UiModal @submit.prevent="handleSubmit()">
|
||||
<FormModalLayout>
|
||||
<template #title>
|
||||
{{ $t("migrate-n-vms", { n: vmRefs.length }) }}
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<FormInputWrapper :label="$t('select-destination-host')" light>
|
||||
<FormSelect v-model="selectedHost">
|
||||
<option :value="undefined">
|
||||
{{ $t("select-destination-host") }}
|
||||
</option>
|
||||
<option
|
||||
v-for="host in availableHosts"
|
||||
:key="host.$ref"
|
||||
:value="host"
|
||||
>
|
||||
{{ host.name_label }}
|
||||
</option>
|
||||
</FormSelect>
|
||||
</FormInputWrapper>
|
||||
</div>
|
||||
|
||||
<template #buttons>
|
||||
<ModalDeclineButton />
|
||||
<ModalApproveButton>
|
||||
{{ $t("migrate-n-vms", { n: vmRefs.length }) }}
|
||||
</ModalApproveButton>
|
||||
</template>
|
||||
</FormModalLayout>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import FormInputWrapper from "@/components/form/FormInputWrapper.vue";
|
||||
import FormSelect from "@/components/form/FormSelect.vue";
|
||||
import FormModalLayout from "@/components/ui/modals/layouts/FormModalLayout.vue";
|
||||
import ModalApproveButton from "@/components/ui/modals/ModalApproveButton.vue";
|
||||
import ModalDeclineButton from "@/components/ui/modals/ModalDeclineButton.vue";
|
||||
import UiModal from "@/components/ui/modals/UiModal.vue";
|
||||
import { useVmMigration } from "@/composables/vm-migration.composable";
|
||||
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
|
||||
import { IK_MODAL } from "@/types/injection-keys";
|
||||
import { inject } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
vmRefs: XenApiVm["$ref"][];
|
||||
}>();
|
||||
|
||||
const modal = inject(IK_MODAL)!;
|
||||
|
||||
const { selectedHost, availableHosts, isValid, migrate } = useVmMigration(
|
||||
() => props.vmRefs
|
||||
);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!isValid.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
modal.approve(migrate());
|
||||
};
|
||||
</script>
|
||||
@@ -1,13 +0,0 @@
|
||||
<template>
|
||||
<UiButton :busy="modal.isBusy" type="submit">
|
||||
<slot />
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import { IK_MODAL } from "@/types/injection-keys";
|
||||
import { inject } from "vue";
|
||||
|
||||
const modal = inject(IK_MODAL)!;
|
||||
</script>
|
||||
@@ -3,7 +3,7 @@
|
||||
:class="textClass"
|
||||
:icon="faXmark"
|
||||
class="modal-close-icon"
|
||||
@click="modal?.decline()"
|
||||
@click="close"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
import UiIcon from "@/components/ui/icon/UiIcon.vue";
|
||||
import { useContext } from "@/composables/context.composable";
|
||||
import { ColorContext } from "@/context";
|
||||
import { IK_MODAL } from "@/types/injection-keys";
|
||||
import { IK_MODAL_CLOSE } from "@/types/injection-keys";
|
||||
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { inject } from "vue";
|
||||
|
||||
const { textClass } = useContext(ColorContext);
|
||||
|
||||
const modal = inject(IK_MODAL, undefined);
|
||||
const close = inject(IK_MODAL_CLOSE, undefined);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<div :class="[backgroundClass, { nested: isNested }]" class="modal-container">
|
||||
<component
|
||||
:is="tag"
|
||||
:class="[backgroundClass, { nested: isNested }]"
|
||||
class="modal-container"
|
||||
>
|
||||
<header v-if="$slots.header" class="modal-header">
|
||||
<slot name="header" />
|
||||
</header>
|
||||
@@ -9,7 +13,7 @@
|
||||
<footer v-if="$slots.footer" class="modal-footer">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -19,9 +23,13 @@ import type { Color } from "@/types";
|
||||
import { IK_MODAL_NESTED } from "@/types/injection-keys";
|
||||
import { inject, provide } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
color?: Color;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
tag?: string;
|
||||
color?: Color;
|
||||
}>(),
|
||||
{ tag: "div" }
|
||||
);
|
||||
|
||||
defineSlots<{
|
||||
header: () => any;
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<template>
|
||||
<UiButton outlined :busy="modal.isBusy" @click="modal.decline()">
|
||||
<slot>{{ $t("cancel") }}</slot>
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import { IK_MODAL } from "@/types/injection-keys";
|
||||
import { inject } from "vue";
|
||||
|
||||
const modal = inject(IK_MODAL)!;
|
||||
</script>
|
||||
@@ -1,14 +0,0 @@
|
||||
<template>
|
||||
<ModalListItem
|
||||
v-for="modal in modalStore.modals"
|
||||
:key="modal.id"
|
||||
:modal="modal"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ModalListItem from "@/components/ui/modals/ModalListItem.vue";
|
||||
import { useModalStore } from "@/stores/modal.store";
|
||||
|
||||
const modalStore = useModalStore();
|
||||
</script>
|
||||
@@ -1,15 +0,0 @@
|
||||
<template>
|
||||
<component :is="modal.component" v-bind="modal.props" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { ModalController } from "@/types";
|
||||
import { IK_MODAL } from "@/types/injection-keys";
|
||||
import { provide } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
modal: ModalController;
|
||||
}>();
|
||||
|
||||
provide(IK_MODAL, props.modal);
|
||||
</script>
|
||||
@@ -1,36 +1,39 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<form class="ui-modal" v-bind="$attrs" @click.self="modal.decline()">
|
||||
<div v-if="isOpen" class="ui-modal" @click.self="close">
|
||||
<slot />
|
||||
</form>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useContext } from "@/composables/context.composable";
|
||||
import { ColorContext, DisabledContext } from "@/context";
|
||||
import { ColorContext } from "@/context";
|
||||
import type { Color } from "@/types";
|
||||
import { IK_MODAL } from "@/types/injection-keys";
|
||||
import { useMagicKeys, whenever } from "@vueuse/core/index";
|
||||
import { inject } from "vue";
|
||||
import { IK_MODAL_CLOSE } from "@/types/injection-keys";
|
||||
import { useMagicKeys, useVModel, whenever } from "@vueuse/core/index";
|
||||
import { provide } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
color?: Color;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: boolean): void;
|
||||
}>();
|
||||
|
||||
const modal = inject(IK_MODAL)!;
|
||||
const isOpen = useVModel(props, "modelValue", emit);
|
||||
|
||||
const close = () => (isOpen.value = false);
|
||||
|
||||
provide(IK_MODAL_CLOSE, close);
|
||||
|
||||
useContext(ColorContext, () => props.color);
|
||||
useContext(DisabledContext, () => props.disabled || modal.isBusy);
|
||||
|
||||
const { escape } = useMagicKeys();
|
||||
|
||||
whenever(escape, () => modal.decline());
|
||||
whenever(escape, () => close());
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<ModalContainer>
|
||||
<ModalContainer tag="form">
|
||||
<template #header>
|
||||
<div class="close-bar">
|
||||
<ModalCloseIcon />
|
||||
|
||||
@@ -26,12 +26,16 @@ import ModalCloseIcon from "@/components/ui/modals/ModalCloseIcon.vue";
|
||||
import ModalContainer from "@/components/ui/modals/ModalContainer.vue";
|
||||
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
|
||||
import { useContext } from "@/composables/context.composable";
|
||||
import { ColorContext } from "@/context";
|
||||
import { ColorContext, DisabledContext } from "@/context";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
|
||||
defineProps<{
|
||||
icon?: IconDefinition;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
icon?: IconDefinition;
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{ disabled: undefined }
|
||||
);
|
||||
|
||||
defineSlots<{
|
||||
title: () => void;
|
||||
@@ -40,6 +44,8 @@ defineSlots<{
|
||||
}>();
|
||||
|
||||
const { textClass, borderClass } = useContext(ColorContext);
|
||||
|
||||
useContext(DisabledContext, () => props.disabled);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
@@ -7,23 +7,59 @@
|
||||
>
|
||||
{{ $t("delete") }}
|
||||
</MenuItem>
|
||||
<UiModal v-model="isDeleteModalOpen">
|
||||
<ConfirmModalLayout :icon="faSatellite">
|
||||
<template #title>
|
||||
<i18n-t keypath="confirm-delete" scope="global" tag="div">
|
||||
<span :class="textClass">
|
||||
{{ $t("n-vms", { n: vmRefs.length }) }}
|
||||
</span>
|
||||
</i18n-t>
|
||||
</template>
|
||||
|
||||
<template #subtitle>
|
||||
{{ $t("please-confirm") }}
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<UiButton outlined @click="closeDeleteModal">
|
||||
{{ $t("go-back") }}
|
||||
</UiButton>
|
||||
<UiButton @click="deleteVms">
|
||||
{{ $t("delete-vms", { n: vmRefs.length }) }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</ConfirmModalLayout>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MenuItem from "@/components/menu/MenuItem.vue";
|
||||
import { useModal } from "@/composables/modal.composable";
|
||||
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
|
||||
import UiModal from "@/components/ui/modals/UiModal.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import { useContext } from "@/composables/context.composable";
|
||||
import useModal from "@/composables/modal.composable";
|
||||
import { ColorContext } from "@/context";
|
||||
import { vTooltip } from "@/directives/tooltip.directive";
|
||||
import { VM_POWER_STATE } from "@/libs/xen-api/xen-api.enums";
|
||||
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
|
||||
import { VM_POWER_STATE } from "@/libs/xen-api/xen-api.enums";
|
||||
import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
import { useVmCollection } from "@/stores/xen-api/vm.store";
|
||||
import { faTrashCan } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faSatellite, faTrashCan } from "@fortawesome/free-solid-svg-icons";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
vmRefs: XenApiVm["$ref"][];
|
||||
}>();
|
||||
|
||||
const xenApi = useXenApiStore().getXapi();
|
||||
const { getByOpaqueRef: getVm } = useVmCollection();
|
||||
const {
|
||||
open: openDeleteModal,
|
||||
close: closeDeleteModal,
|
||||
isOpen: isDeleteModalOpen,
|
||||
} = useModal();
|
||||
|
||||
const vms = computed<XenApiVm[]>(() =>
|
||||
props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined)
|
||||
@@ -37,8 +73,10 @@ const isDisabled = computed(
|
||||
() => vms.value.length === 0 || areSomeVmsInExecution.value
|
||||
);
|
||||
|
||||
const openDeleteModal = () =>
|
||||
useModal(() => import("@/components/modals/VmDeleteModal.vue"), {
|
||||
vmRefs: props.vmRefs,
|
||||
});
|
||||
const deleteVms = async () => {
|
||||
await xenApi.vm.delete(props.vmRefs);
|
||||
closeDeleteModal();
|
||||
};
|
||||
|
||||
const { textClass } = useContext(ColorContext);
|
||||
</script>
|
||||
|
||||
@@ -1,60 +1,95 @@
|
||||
<template>
|
||||
<MenuItem
|
||||
v-tooltip="
|
||||
selectedRefs.length > 0 &&
|
||||
!isMigratable &&
|
||||
$t('no-selected-vm-can-be-migrated')
|
||||
!areAllVmsMigratable && $t('some-selected-vms-can-not-be-migrated')
|
||||
"
|
||||
:busy="isMigrating"
|
||||
:disabled="isParentDisabled || !isMigratable"
|
||||
:disabled="isParentDisabled || !areAllVmsMigratable"
|
||||
:icon="faRoute"
|
||||
@click="openModal()"
|
||||
@click="openModal"
|
||||
>
|
||||
{{ $t("migrate") }}
|
||||
</MenuItem>
|
||||
|
||||
<UiModal v-model="isModalOpen">
|
||||
<FormModalLayout :disabled="isMigrating" @submit.prevent="handleMigrate">
|
||||
<template #title>
|
||||
{{ $t("migrate-n-vms", { n: selectedRefs.length }) }}
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<FormInputWrapper :label="$t('select-destination-host')" light>
|
||||
<FormSelect v-model="selectedHost">
|
||||
<option :value="undefined">
|
||||
{{ $t("select-destination-host") }}
|
||||
</option>
|
||||
<option
|
||||
v-for="host in availableHosts"
|
||||
:key="host.$ref"
|
||||
:value="host"
|
||||
>
|
||||
{{ host.name_label }}
|
||||
</option>
|
||||
</FormSelect>
|
||||
</FormInputWrapper>
|
||||
</div>
|
||||
|
||||
<template #buttons>
|
||||
<UiButton outlined @click="closeModal">
|
||||
{{ isMigrating ? $t("close") : $t("cancel") }}
|
||||
</UiButton>
|
||||
<UiButton :busy="isMigrating" :disabled="!isValid" type="submit">
|
||||
{{ $t("migrate-n-vms", { n: selectedRefs.length }) }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</FormModalLayout>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import FormInputWrapper from "@/components/form/FormInputWrapper.vue";
|
||||
import FormSelect from "@/components/form/FormSelect.vue";
|
||||
import MenuItem from "@/components/menu/MenuItem.vue";
|
||||
import FormModalLayout from "@/components/ui/modals/layouts/FormModalLayout.vue";
|
||||
import UiModal from "@/components/ui/modals/UiModal.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import { useContext } from "@/composables/context.composable";
|
||||
import { useModal } from "@/composables/modal.composable";
|
||||
import useModal from "@/composables/modal.composable";
|
||||
import { useVmMigration } from "@/composables/vm-migration.composable";
|
||||
import { DisabledContext } from "@/context";
|
||||
import { vTooltip } from "@/directives/tooltip.directive";
|
||||
import { VM_OPERATION } from "@/libs/xen-api/xen-api.enums";
|
||||
import type { XenApiVm } from "@/libs/xen-api/xen-api.types";
|
||||
import { useVmCollection } from "@/stores/xen-api/vm.store";
|
||||
import { faRoute } from "@fortawesome/free-solid-svg-icons";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
selectedRefs: XenApiVm["$ref"][];
|
||||
}>();
|
||||
|
||||
const { getByOpaqueRefs, isOperationPending, areSomeOperationAllowed } =
|
||||
useVmCollection();
|
||||
|
||||
const isParentDisabled = useContext(DisabledContext);
|
||||
|
||||
const isMigratable = computed(() =>
|
||||
getByOpaqueRefs(props.selectedRefs).some((vm) =>
|
||||
areSomeOperationAllowed(vm, [
|
||||
VM_OPERATION.POOL_MIGRATE,
|
||||
VM_OPERATION.MIGRATE_SEND,
|
||||
])
|
||||
)
|
||||
);
|
||||
const {
|
||||
open: openModal,
|
||||
isOpen: isModalOpen,
|
||||
close: closeModal,
|
||||
} = useModal({
|
||||
onClose: () => (selectedHost.value = undefined),
|
||||
});
|
||||
|
||||
const isMigrating = computed(() =>
|
||||
getByOpaqueRefs(props.selectedRefs).some((vm) =>
|
||||
isOperationPending(vm, [
|
||||
VM_OPERATION.POOL_MIGRATE,
|
||||
VM_OPERATION.MIGRATE_SEND,
|
||||
])
|
||||
)
|
||||
);
|
||||
const {
|
||||
selectedHost,
|
||||
availableHosts,
|
||||
isValid,
|
||||
migrate,
|
||||
isMigrating,
|
||||
areAllVmsMigratable,
|
||||
} = useVmMigration(() => props.selectedRefs);
|
||||
|
||||
const openModal = () =>
|
||||
useModal(() => import("@/components/modals/VmMigrateModal.vue"), {
|
||||
vmRefs: props.selectedRefs,
|
||||
});
|
||||
const handleMigrate = async () => {
|
||||
try {
|
||||
await migrate();
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
console.error("Error while migrating", e);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
77
@xen-orchestra/lite/src/composables/modal.composable.md
Normal file
77
@xen-orchestra/lite/src/composables/modal.composable.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# useModal composable
|
||||
|
||||
### Usage
|
||||
|
||||
#### API
|
||||
|
||||
`useModal<T>(options: ModalOptions)`
|
||||
|
||||
Type parameter:
|
||||
|
||||
- `T`: The type for the modal's payload.
|
||||
|
||||
Parameters:
|
||||
|
||||
- `options`: An optional object of type `ModalOptions`.
|
||||
|
||||
Returns an object with:
|
||||
|
||||
- `payload: ReadOnly<Ref<T | undefined>>`: The payload data of the modal. Mainly used if a single modal is used for
|
||||
multiple items (typically with `v-for`)
|
||||
- `isOpen: WritableComputedRef<boolean>`: A writable computed indicating if the modal is open or not.
|
||||
- `open(currentPayload?: T)`: A function to open the modal and optionally set its payload.
|
||||
- `close(force = false)`: A function to close the modal. If force is set to `true`, the modal will be closed without
|
||||
calling the `confirmClose` callback.
|
||||
|
||||
#### Types
|
||||
|
||||
`ModalOptions`
|
||||
|
||||
An object type that accepts:
|
||||
|
||||
- `confirmClose?: () => boolean`: An optional callback that is called before the modal is closed. If this function
|
||||
returns `false`, the modal will not be closed.
|
||||
- `onClose?: () => void`: An optional callback that is called after the modal is closed.
|
||||
|
||||
### Example
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div v-for="item in items">
|
||||
{{ item.name }}
|
||||
<button @click="openRemoveModal(item)">Delete</button>
|
||||
</div>
|
||||
|
||||
<UiModal v-model="isRemoveModalOpen">
|
||||
<ModalContainer>
|
||||
<template #header>
|
||||
Are you sure you want to delete {{ removeModalPayload.name }}?
|
||||
</template>
|
||||
<template #footer>
|
||||
<button @click="handleRemove">Yes</button>
|
||||
<button @click="closeRemoveModal">No</button>
|
||||
</template>
|
||||
</ModalContainer>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import useModal from "@/composables/modal.composable";
|
||||
|
||||
const {
|
||||
payload: removeModalPayload,
|
||||
isOpen: isRemoveModalOpen,
|
||||
open: openRemoveModal,
|
||||
close: closeRemoveModal,
|
||||
} = useModal({
|
||||
confirmClose: () =>
|
||||
window.confirm("Are you sure you want to close this modal?"),
|
||||
onClose: () => console.log("Modal closed"),
|
||||
});
|
||||
|
||||
async function handleRemove() {
|
||||
await removeItem(removeModalPayload.id);
|
||||
closeRemoveModal();
|
||||
}
|
||||
</script>
|
||||
```
|
||||
@@ -1,5 +1,48 @@
|
||||
import { useModalStore } from "@/stores/modal.store";
|
||||
import type { AsyncComponentLoader } from "vue";
|
||||
import { computed, readonly, ref } from "vue";
|
||||
|
||||
export const useModal = <T>(loader: AsyncComponentLoader, props: object = {}) =>
|
||||
useModalStore().open<T>(loader, props);
|
||||
type ModalOptions = {
|
||||
confirmClose?: () => boolean;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export default function useModal<T>(options: ModalOptions = {}) {
|
||||
const $payload = ref<T>();
|
||||
const $isOpen = ref(false);
|
||||
|
||||
const open = (payload?: T) => {
|
||||
$isOpen.value = true;
|
||||
$payload.value = payload;
|
||||
};
|
||||
const close = (force = false) => {
|
||||
if (!force && options.confirmClose?.() === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.onClose) {
|
||||
options.onClose();
|
||||
}
|
||||
|
||||
$isOpen.value = false;
|
||||
$payload.value = undefined;
|
||||
};
|
||||
|
||||
const isOpen = computed({
|
||||
get() {
|
||||
return $isOpen.value;
|
||||
},
|
||||
set(value) {
|
||||
if (value) {
|
||||
open();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
payload: readonly($payload),
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useModal } from "@/composables/modal.composable";
|
||||
import { useHostCollection } from "@/stores/xen-api/host.store";
|
||||
import { whenever } from "@vueuse/core";
|
||||
import { difference } from "lodash-es";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
export const useUnreachableHosts = () => {
|
||||
const { records: hosts } = useHostCollection();
|
||||
const unreachableHostsUrls = ref<Set<string>>(new Set());
|
||||
|
||||
watch(hosts, (nextHosts, previousHosts) => {
|
||||
difference(nextHosts, previousHosts).forEach((host) => {
|
||||
const url = new URL("http://localhost");
|
||||
url.protocol = window.location.protocol;
|
||||
url.hostname = host.address;
|
||||
fetch(url, { mode: "no-cors" }).catch(() =>
|
||||
unreachableHostsUrls.value.add(url.toString())
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
whenever(
|
||||
() => unreachableHostsUrls.value.size > 0,
|
||||
() => {
|
||||
const { onApprove, onDecline } = useModal(
|
||||
() => import("@/components/modals/UnreachableHostsModal.vue"),
|
||||
{
|
||||
urls: computed(() => Array.from(unreachableHostsUrls.value.values())),
|
||||
}
|
||||
);
|
||||
|
||||
onApprove(() => window.location.reload());
|
||||
onDecline(() => unreachableHostsUrls.value.clear());
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
};
|
||||
@@ -39,6 +39,12 @@ export const useVmMigration = (
|
||||
.sort(sortRecordsByNameLabel);
|
||||
});
|
||||
|
||||
const areAllVmsMigratable = computed(() =>
|
||||
vms.value.every((vm) =>
|
||||
vm.allowed_operations.includes(VM_OPERATION.POOL_MIGRATE)
|
||||
)
|
||||
);
|
||||
|
||||
const isValid = computed(
|
||||
() =>
|
||||
!isMigrating.value &&
|
||||
@@ -69,6 +75,7 @@ export const useVmMigration = (
|
||||
isMigrating,
|
||||
availableHosts,
|
||||
selectedHost,
|
||||
areAllVmsMigratable,
|
||||
isValid,
|
||||
migrate,
|
||||
};
|
||||
|
||||
@@ -18,12 +18,6 @@ export const useXenApiStoreBaseContext = <
|
||||
return recordsByOpaqueRef.get(opaqueRef);
|
||||
};
|
||||
|
||||
const getByOpaqueRefs = (opaqueRefs: XRecord["$ref"][]) => {
|
||||
return opaqueRefs
|
||||
.map(getByOpaqueRef)
|
||||
.filter((record) => record !== undefined) as XRecord[];
|
||||
};
|
||||
|
||||
const getByUuid = (uuid: XRecord["uuid"]) => {
|
||||
return recordsByUuid.get(uuid);
|
||||
};
|
||||
@@ -55,7 +49,6 @@ export const useXenApiStoreBaseContext = <
|
||||
lastError,
|
||||
records,
|
||||
getByOpaqueRef,
|
||||
getByOpaqueRefs,
|
||||
getByUuid,
|
||||
hasUuid,
|
||||
add,
|
||||
|
||||
@@ -103,7 +103,6 @@
|
||||
"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",
|
||||
@@ -145,6 +144,7 @@
|
||||
"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",
|
||||
|
||||
@@ -103,7 +103,6 @@
|
||||
"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",
|
||||
@@ -145,6 +144,7 @@
|
||||
"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",
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { ModalController } from "@/types";
|
||||
import { createEventHook } from "@vueuse/core";
|
||||
import { defineStore } from "pinia";
|
||||
import {
|
||||
type AsyncComponentLoader,
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
markRaw,
|
||||
reactive,
|
||||
ref,
|
||||
} from "vue";
|
||||
|
||||
export const useModalStore = defineStore("modal", () => {
|
||||
const modals = ref(new Map<symbol, ModalController>());
|
||||
|
||||
const close = (id: symbol) => {
|
||||
modals.value.delete(id);
|
||||
};
|
||||
|
||||
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 approve = async (payload: any) => {
|
||||
try {
|
||||
isBusy.value = true;
|
||||
const result = await payload;
|
||||
await approveEvent.trigger(result);
|
||||
close(id);
|
||||
} finally {
|
||||
isBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const decline = async () => {
|
||||
try {
|
||||
isBusy.value = true;
|
||||
await declineEvent.trigger(undefined);
|
||||
close(id);
|
||||
} finally {
|
||||
isBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
modals.value.set(
|
||||
id,
|
||||
reactive({
|
||||
id,
|
||||
component: markRaw(component),
|
||||
props,
|
||||
approve,
|
||||
decline,
|
||||
isBusy,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
onApprove: approveEvent.on,
|
||||
onDecline: declineEvent.on,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
open,
|
||||
close,
|
||||
modals: computed(() => modals.value.values()),
|
||||
};
|
||||
});
|
||||
@@ -36,17 +36,6 @@ export const useVmStore = defineStore("xen-api-vm", () => {
|
||||
);
|
||||
};
|
||||
|
||||
const areSomeOperationAllowed = (
|
||||
vm: XenApiVm,
|
||||
operations: VM_OPERATION[] | VM_OPERATION
|
||||
) => {
|
||||
const allowedOperations = Object.values(vm.allowed_operations);
|
||||
|
||||
return castArray(operations).some((operation) =>
|
||||
allowedOperations.includes(operation)
|
||||
);
|
||||
};
|
||||
|
||||
const runningVms = computed(() =>
|
||||
records.value.filter((vm) => vm.power_state === VM_POWER_STATE.RUNNING)
|
||||
);
|
||||
@@ -103,7 +92,6 @@ export const useVmStore = defineStore("xen-api-vm", () => {
|
||||
...context,
|
||||
records,
|
||||
isOperationPending,
|
||||
areSomeOperationAllowed,
|
||||
runningVms,
|
||||
recordsByHostRef,
|
||||
getStats,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
```vue-template
|
||||
<UiModal>
|
||||
<UiModal v-model="isOpen">
|
||||
<BasicModalLayout>
|
||||
Here is a basic modal...
|
||||
</BasicModalLayout>
|
||||
</UiModal>
|
||||
```
|
||||
|
||||
```vue-script
|
||||
const { isOpen } = useModal();
|
||||
```
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
v-slot="{ settings }"
|
||||
:params="[
|
||||
slot(),
|
||||
setting('defaultSlotContent')
|
||||
.preset('Here is a basic modal...')
|
||||
.widget(text()),
|
||||
setting('defaultSlotContent').preset('Modal content').widget(text()),
|
||||
]"
|
||||
>
|
||||
<BasicModalLayout>
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
```vue-template
|
||||
<UiModal @submit.prevent="approve()">
|
||||
<UiModal v-model="isOpen">
|
||||
<ConfirmModalLayout :icon="faShip">
|
||||
<template #title>Do you confirm?</template>
|
||||
<template #subtitle>You should be sure about this</template>
|
||||
<template #buttons>
|
||||
<ModalDeclineButton>I prefer not</UiButton>
|
||||
<ModalApproveButton>Yes, I'm sure!</UiButton>
|
||||
<UiButton outlined @click="close">I prefer not</UiButton>
|
||||
<UiButton @click="accept">Yes, I'm sure!</UiButton>
|
||||
</template>
|
||||
</ConfirmModalLayout>
|
||||
</UiModal>
|
||||
```
|
||||
|
||||
```vue-script
|
||||
import { IK_MODAL } from "@/types/injection-keys";
|
||||
const { isOpen, close } = useModal();
|
||||
|
||||
const { approve } = inject(IK_MODAL)!;
|
||||
const accept = async () => {
|
||||
// do something
|
||||
close();
|
||||
}
|
||||
```
|
||||
|
||||
@@ -2,21 +2,21 @@
|
||||
<ComponentStory
|
||||
v-slot="{ properties, settings }"
|
||||
:params="[
|
||||
iconProp().preset(faShip),
|
||||
iconProp(),
|
||||
slot('title'),
|
||||
slot('subtitle'),
|
||||
slot('default'),
|
||||
slot('buttons').help('Meant to receive UiButton components'),
|
||||
setting('title').preset('Do you confirm?').widget(),
|
||||
setting('subtitle').preset('You should be sure about this').widget(),
|
||||
setting('title').preset('Modal Title').widget(),
|
||||
setting('subtitle').preset('Modal Subtitle').widget(),
|
||||
]"
|
||||
>
|
||||
<ConfirmModalLayout v-bind="properties">
|
||||
<template #title>{{ settings.title }}</template>
|
||||
<template #subtitle>{{ settings.subtitle }}</template>
|
||||
<template #buttons>
|
||||
<UiButton outlined>I prefer not</UiButton>
|
||||
<UiButton>Yes, I'm sure!</UiButton>
|
||||
<UiButton outlined>Discard</UiButton>
|
||||
<UiButton>Go</UiButton>
|
||||
</template>
|
||||
</ConfirmModalLayout>
|
||||
</ComponentStory>
|
||||
@@ -27,5 +27,6 @@ import ComponentStory from "@/components/component-story/ComponentStory.vue";
|
||||
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import { iconProp, setting, slot } from "@/libs/story/story-param";
|
||||
import { faShip } from "@fortawesome/free-solid-svg-icons";
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped></style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
```vue-template
|
||||
<UiModal @submit.prevent="handleSubmit()">
|
||||
<FormModalLayout :icon="faShip">
|
||||
<UiModal v-model="isOpen">
|
||||
<FormModalLayout :icon="faShip" @submit.prevent="handleSubmit">
|
||||
<template #title>Migrate 3 VMs/template>
|
||||
|
||||
<template #default>
|
||||
@@ -8,23 +8,18 @@
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<ModalDeclineButton />
|
||||
<ModalApproveButton>Migrate 3 VMs</UiButton>
|
||||
<UiButton outlined @click="close">Cancel</UiButton>
|
||||
<UiButton type="submit">Migrate 3 VMs</UiButton>
|
||||
</template>
|
||||
</ConfirmModalLayout>
|
||||
</UiModal>
|
||||
```
|
||||
|
||||
```vue-script
|
||||
import { IK_MODAL } from "@/types/injection-keys";
|
||||
const { isOpen, close } = useModal();
|
||||
|
||||
const { approve } = inject(IK_MODAL)!;
|
||||
|
||||
const migrate = async () => {
|
||||
// Do the migration...
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
approve(migrate());
|
||||
const handleSubmit = async () => {
|
||||
// Handling form submission...
|
||||
close();
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
A basic modal container containing 3 slots: `header`, `default` and `footer`.
|
||||
|
||||
Tag will be `div` by default but can be changed with the `tag` prop.
|
||||
|
||||
Color can be changed with the `color` prop.
|
||||
|
||||
To keep the content centered vertically, header and footer will always have the same height.
|
||||
|
||||
Modal content has a max height + overflow to prevent the modal growing out of the screen.
|
||||
Modal content has an max height + overflow to prevent the modal growing out of the screen.
|
||||
|
||||
Modal containers can be nested.
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<ComponentStory
|
||||
v-slot="{ properties, settings }"
|
||||
:params="[
|
||||
prop('tag').str().default('div').widget(),
|
||||
colorProp(),
|
||||
slot('header'),
|
||||
slot(),
|
||||
@@ -46,6 +47,6 @@
|
||||
<script lang="ts" setup>
|
||||
import ComponentStory from "@/components/component-story/ComponentStory.vue";
|
||||
import ModalContainer from "@/components/ui/modals/ModalContainer.vue";
|
||||
import { colorProp, setting, slot } from "@/libs/story/story-param";
|
||||
import { colorProp, prop, setting, slot } from "@/libs/story/story-param";
|
||||
import { boolean, text } from "@/libs/story/story-widget";
|
||||
</script>
|
||||
|
||||
21
@xen-orchestra/lite/src/stories/modals/ui-modal.story.md
Normal file
21
@xen-orchestra/lite/src/stories/modals/ui-modal.story.md
Normal file
@@ -0,0 +1,21 @@
|
||||
This component only handle the modal backdrop and content positioning.
|
||||
|
||||
You can use any pre-made layouts, create your own or use the `ModalContainer` component.
|
||||
|
||||
It is meant to be used with `useModal` composable.
|
||||
|
||||
```vue-template
|
||||
<button @click="open">Delete all items</button>
|
||||
|
||||
<UiModal v-model="isOpen">
|
||||
<ModalContainer...>
|
||||
<!-- <ConfirmModalLayout ...> (Or you can use a pre-made layout) -->
|
||||
</UiModal>
|
||||
```
|
||||
|
||||
```vue-script
|
||||
import { faRemove } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useModal } from "@composable/modal.composable";
|
||||
|
||||
const { open, close, isOpen } = useModal().
|
||||
```
|
||||
24
@xen-orchestra/lite/src/stories/modals/ui-modal.story.vue
Normal file
24
@xen-orchestra/lite/src/stories/modals/ui-modal.story.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<ComponentStory
|
||||
:params="[
|
||||
model()
|
||||
.required()
|
||||
.type('boolean')
|
||||
.help('Whether the modal is opened or not'),
|
||||
colorProp().ctx(),
|
||||
slot().help('Place your ModalContainer here'),
|
||||
]"
|
||||
>
|
||||
<button type="button" @click="open">Open modal</button>
|
||||
<UiModal v-model="isOpen" />
|
||||
</ComponentStory>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ComponentStory from "@/components/component-story/ComponentStory.vue";
|
||||
import UiModal from "@/components/ui/modals/UiModal.vue";
|
||||
import useModal from "@/composables/modal.composable";
|
||||
import { colorProp, model, slot } from "@/libs/story/story-param";
|
||||
|
||||
const { isOpen, open } = useModal();
|
||||
</script>
|
||||
@@ -1,10 +1 @@
|
||||
export type Color = "info" | "error" | "warning" | "success";
|
||||
|
||||
export type ModalController = {
|
||||
id: symbol;
|
||||
component: any;
|
||||
props: object;
|
||||
approve: <P>(payload?: P) => void;
|
||||
decline: () => void;
|
||||
isBusy: boolean;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { FetchedStats, Stat } from "@/composables/fetch-stats.composable";
|
||||
import type { HostStats, VmStats } from "@/libs/xapi-stats";
|
||||
import type { XenApiHost } from "@/libs/xen-api/xen-api.types";
|
||||
import type { ValueFormatter } from "@/types/chart";
|
||||
import type { ModalController } from "@/types/index";
|
||||
import type { ComputedRef, InjectionKey } from "vue";
|
||||
|
||||
export const IK_MENU_TELEPORTED = Symbol() as InjectionKey<boolean>;
|
||||
@@ -52,5 +51,3 @@ export const IK_INPUT_ID = Symbol() as InjectionKey<ComputedRef<string>>;
|
||||
export const IK_MODAL_CLOSE = Symbol() as InjectionKey<() => void>;
|
||||
|
||||
export const IK_MODAL_NESTED = Symbol() as InjectionKey<boolean>;
|
||||
|
||||
export const IK_MODAL = Symbol() as InjectionKey<ModalController>;
|
||||
|
||||
@@ -17,8 +17,3 @@ export interface SortConfig<T> {
|
||||
queryStringParam?: string;
|
||||
initialSorts?: InitialSorts<T>;
|
||||
}
|
||||
|
||||
export type NewSort = {
|
||||
property: string;
|
||||
isAscending: boolean;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ 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'
|
||||
@@ -22,18 +23,16 @@ const noop = Function.prototype
|
||||
|
||||
const { warn } = createLogger('xo:proxy:backups')
|
||||
|
||||
const runWithLogs = (runner, args, onEnd) =>
|
||||
const runWithLogs = (runner, args) =>
|
||||
new Readable({
|
||||
objectMode: true,
|
||||
read() {
|
||||
this._read = noop
|
||||
|
||||
runner(args, log => this.push(log))
|
||||
.then(
|
||||
() => this.push(null),
|
||||
error => this.emit('error', error)
|
||||
)
|
||||
.then(onEnd)
|
||||
runner(args, log => this.push(log)).then(
|
||||
() => this.push(null),
|
||||
error => this.emit('error', error)
|
||||
)
|
||||
},
|
||||
})[Symbol.asyncIterator]()
|
||||
|
||||
@@ -191,41 +190,30 @@ export default class Backups {
|
||||
},
|
||||
],
|
||||
importVmBackup: [
|
||||
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()
|
||||
}
|
||||
},
|
||||
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()
|
||||
})
|
||||
),
|
||||
{
|
||||
description: 'create a new VM from a backup',
|
||||
params: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "@xen-orchestra/proxy",
|
||||
"version": "0.26.38",
|
||||
"version": "0.26.37",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "XO Proxy used to remotely execute backup jobs",
|
||||
"keywords": [
|
||||
@@ -33,7 +33,7 @@
|
||||
"@vates/disposable": "^0.1.4",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.43.2",
|
||||
"@xen-orchestra/fs": "^4.1.2",
|
||||
"@xen-orchestra/fs": "^4.1.1",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"@xen-orchestra/mixin": "^0.1.0",
|
||||
"@xen-orchestra/mixins": "^0.14.0",
|
||||
|
||||
22
CHANGELOG.md
22
CHANGELOG.md
@@ -1,27 +1,9 @@
|
||||
# ChangeLog
|
||||
|
||||
## **5.88.1** (2023-11-07)
|
||||
## **5.88.0** (2023-10-31)
|
||||
|
||||
<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))
|
||||
@@ -39,6 +21,7 @@
|
||||
- [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))
|
||||
- [XO-WEB/Forget SR] Changed the modal message and added a confirmation text to be sure the action is understood by the user (PR [#7154](https://github.com/vatesfr/xen-orchestra/pull/7154))
|
||||
|
||||
### Bug fixes
|
||||
|
||||
@@ -54,6 +37,7 @@
|
||||
- [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))
|
||||
- [Resource Set] Fix error displayed after successful VM addition to resource set PR ([#7144](https://github.com/vatesfr/xen-orchestra/pull/7144))
|
||||
|
||||
### Released packages
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
|
||||
> Users must be able to say: “Nice enhancement, I'm eager to test it”
|
||||
|
||||
- [REST API] Add `users` collection
|
||||
|
||||
### Bug fixes
|
||||
|
||||
> Users must be able to say: “I had this issue, happy to know it's fixed”
|
||||
|
||||
- [Netbox] Fix VMs' `site` property being unnecessarily updated on some versions of Netbox (PR [#7145](https://github.com/vatesfr/xen-orchestra/pull/7145))
|
||||
|
||||
### Packages to release
|
||||
|
||||
> When modifying a package, add it here with its release type.
|
||||
@@ -29,6 +29,6 @@
|
||||
|
||||
<!--packages-start-->
|
||||
|
||||
- xo-server minor
|
||||
- xo-server-netbox patch
|
||||
|
||||
<!--packages-end-->
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"@babel/core": "^7.0.0",
|
||||
"@babel/eslint-parser": "^7.13.8",
|
||||
"@babel/register": "^7.0.0",
|
||||
"@commitlint/cli": "^18.2.0",
|
||||
"@commitlint/config-conventional": "^18.1.0",
|
||||
"@commitlint/cli": "^17.4.4",
|
||||
"@commitlint/config-conventional": "^17.4.4",
|
||||
"@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": "^15.0.2",
|
||||
"lint-staged": "^14.0.1",
|
||||
"lodash": "^4.17.4",
|
||||
"prettier": "^3.0.1",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"node": ">=10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xen-orchestra/fs": "^4.1.2",
|
||||
"@xen-orchestra/fs": "^4.1.1",
|
||||
"cli-progress": "^3.1.0",
|
||||
"exec-promise": "^0.7.0",
|
||||
"getopts": "^2.2.3",
|
||||
|
||||
@@ -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.2",
|
||||
"@xen-orchestra/fs": "^4.1.1",
|
||||
"@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.2",
|
||||
"@xen-orchestra/fs": "^4.1.1",
|
||||
"execa": "^5.0.0",
|
||||
"get-stream": "^6.0.0",
|
||||
"rimraf": "^5.0.1",
|
||||
|
||||
@@ -94,15 +94,9 @@ Usage:
|
||||
xo-cli rest get tasks filter='status:pending'
|
||||
xo-cli rest get vms fields=name_label,power_state
|
||||
|
||||
xo-cli rest get [--output <file>] <object> [wait | wait=result]
|
||||
xo-cli rest get <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
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
# 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)>).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xo-server-netbox",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.2",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Synchronizes pools managed by Xen Orchestra with Netbox",
|
||||
"keywords": [
|
||||
|
||||
@@ -39,7 +39,7 @@ class Netbox {
|
||||
#endpoint
|
||||
#intervalToken
|
||||
#loaded
|
||||
#netboxVersion
|
||||
#netboxApiVersion
|
||||
#xoPools
|
||||
#removeApiMethods
|
||||
#syncInterval
|
||||
@@ -103,7 +103,6 @@ class Netbox {
|
||||
}
|
||||
|
||||
async test() {
|
||||
await this.#fetchNetboxVersion()
|
||||
await this.#checkCustomFields()
|
||||
|
||||
const randomSuffix = Math.random().toString(36).slice(2, 11)
|
||||
@@ -145,24 +144,27 @@ class Netbox {
|
||||
const httpRequest = async () => {
|
||||
try {
|
||||
const response = await this.#xo.httpRequest(url, options)
|
||||
const resBody = await response.text()
|
||||
if (resBody.length > 0) {
|
||||
return JSON.parse(resBody)
|
||||
// API version only follows minor version, which is less precise and is not semver-valid
|
||||
// See https://github.com/netbox-community/netbox/issues/12879#issuecomment-1589190236
|
||||
this.#netboxApiVersion = semver.coerce(response.headers['api-version'])?.version ?? undefined
|
||||
const body = await response.text()
|
||||
if (body.length > 0) {
|
||||
return JSON.parse(body)
|
||||
}
|
||||
} catch (error) {
|
||||
error.method = method
|
||||
error.requestBody = dataDebug
|
||||
|
||||
let resBody = 'Netbox error could not be retrieved'
|
||||
try {
|
||||
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
|
||||
error.data = {
|
||||
method,
|
||||
path,
|
||||
body: dataDebug,
|
||||
}
|
||||
try {
|
||||
const body = await error.response.text()
|
||||
if (body.length > 0) {
|
||||
error.data.error = JSON.parse(body)
|
||||
}
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -183,7 +185,7 @@ class Netbox {
|
||||
response = await httpRequest()
|
||||
}
|
||||
|
||||
if (method !== 'GET' || response.results === undefined) {
|
||||
if (method !== 'GET') {
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -211,24 +213,9 @@ 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 })
|
||||
@@ -355,7 +342,7 @@ class Netbox {
|
||||
// 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')) {
|
||||
if (this.#netboxApiVersion === undefined || semver.satisfies(this.#netboxApiVersion, '3.3.0 - 3.4.7')) {
|
||||
nbVm.site = find(nbClusters, { id: nbCluster.id })?.site?.id ?? null
|
||||
}
|
||||
|
||||
@@ -402,7 +389,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.#netboxVersion === undefined || !semver.satisfies(this.#netboxVersion, '>=2.7.0')) {
|
||||
if (this.#netboxApiVersion !== undefined && !semver.satisfies(this.#netboxApiVersion, '>=2.7.0')) {
|
||||
nbVm.status = xoVm.power_state === 'Running' ? 1 : 0
|
||||
}
|
||||
|
||||
@@ -563,12 +550,10 @@ class Netbox {
|
||||
continue
|
||||
}
|
||||
// Start by deleting old interfaces attached to this Netbox VM
|
||||
// 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)) {
|
||||
Object.entries(nbIfs).forEach(([id, nbIf]) => {
|
||||
if (nbIf.virtual_machine.id === nbVm.id && !xoVm.VIFs.includes(nbIf.custom_fields.uuid)) {
|
||||
ifsToDelete.push({ id: nbIf.id })
|
||||
delete nbIfs[xoVifId]
|
||||
delete nbIfs[id]
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "xo-server",
|
||||
"version": "5.125.2",
|
||||
"version": "5.125.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Server part of Xen-Orchestra",
|
||||
"keywords": [
|
||||
@@ -45,7 +45,7 @@
|
||||
"@xen-orchestra/cron": "^1.0.6",
|
||||
"@xen-orchestra/defined": "^0.0.1",
|
||||
"@xen-orchestra/emit-async": "^1.0.0",
|
||||
"@xen-orchestra/fs": "^4.1.2",
|
||||
"@xen-orchestra/fs": "^4.1.1",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"@xen-orchestra/mixin": "^0.1.0",
|
||||
"@xen-orchestra/mixins": "^0.14.0",
|
||||
|
||||
@@ -444,6 +444,7 @@ export default class {
|
||||
|
||||
async shareVmResourceSet(vmId) {
|
||||
const xapi = this._app.getXapi(vmId)
|
||||
await xapi.barrier(xapi.getObject(vmId).$ref)
|
||||
const resourceSetId = xapi.xo.getData(vmId, 'resourceSet')
|
||||
if (resourceSetId === undefined) {
|
||||
throw new Error('the vm is not in a resource set')
|
||||
|
||||
@@ -10,8 +10,6 @@ import pick from 'lodash/pick.js'
|
||||
import * as CM from 'complex-matcher'
|
||||
import { VDI_FORMAT_RAW, VDI_FORMAT_VHD } from '@xen-orchestra/xapi'
|
||||
|
||||
import { getUserPublicProperties } from '../utils.mjs'
|
||||
|
||||
const { join } = path.posix
|
||||
const noop = Function.prototype
|
||||
|
||||
@@ -178,7 +176,6 @@ export default class RestApi {
|
||||
collections.backups = { id: 'backups' }
|
||||
collections.restore = { id: 'restore' }
|
||||
collections.tasks = { id: 'tasks' }
|
||||
collections.users = { id: 'users' }
|
||||
|
||||
collections.hosts.routes = {
|
||||
__proto__: null,
|
||||
@@ -392,30 +389,6 @@ export default class RestApi {
|
||||
}, true)
|
||||
)
|
||||
|
||||
api
|
||||
.get(
|
||||
'/users',
|
||||
wrap(async (req, res) => {
|
||||
let users = await app.getAllUsers()
|
||||
|
||||
const { filter, limit } = req.query
|
||||
if (filter !== undefined) {
|
||||
users = users.filter(CM.parse(filter).createPredicate())
|
||||
}
|
||||
if (limit < users.length) {
|
||||
users.length = limit
|
||||
}
|
||||
|
||||
sendObjects(users.map(getUserPublicProperties), req, res)
|
||||
})
|
||||
)
|
||||
.get(
|
||||
'/users/:id',
|
||||
wrap(async (req, res) => {
|
||||
res.json(getUserPublicProperties(await app.getUser(req.params.id)))
|
||||
})
|
||||
)
|
||||
|
||||
api.get(
|
||||
'/:collection',
|
||||
wrap(async (req, res) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "xo-web",
|
||||
"version": "5.127.2",
|
||||
"version": "5.127.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Web interface client for Xen-Orchestra",
|
||||
"keywords": [
|
||||
|
||||
@@ -2372,9 +2372,9 @@ const messages = {
|
||||
'This will disconnect each selected SR from its host (local SR) or from every hosts of its pool (shared SR).',
|
||||
srForgetModalTitle: 'Forget SR',
|
||||
srsForgetModalTitle: 'Forget selected SRs',
|
||||
srForgetModalMessage: "Are you sure you want to forget this SR? VDIs on this storage won't be removed.",
|
||||
srForgetModalMessage: "Are you sure you want to forget this SR? You will lose all the metadata for it, meaning all the links between the VDIs (disks) and their respective VMs. This operation cannot be undone.",
|
||||
srsForgetModalMessage:
|
||||
"Are you sure you want to forget all the selected SRs? VDIs on these storages won't be removed.",
|
||||
"Are you sure you want to forget {nPbds, number} SR{nPbds, plural, one {} other {s}}? You will lose all the metadata for it, meaning all the links between the VDIs (disks) and their respective VMs. This operation cannot be undone.",
|
||||
srAllDisconnected: 'Disconnected',
|
||||
srSomeConnected: 'Partially connected',
|
||||
srAllConnected: 'Connected',
|
||||
|
||||
@@ -2268,15 +2268,31 @@ export const deleteSr = sr =>
|
||||
|
||||
export const fetchSrStats = (sr, granularity) => _call('sr.stats', { id: resolveId(sr), granularity })
|
||||
|
||||
export const forgetSr = sr =>
|
||||
export const forgetSr = sr => {
|
||||
confirm({
|
||||
title: _('srForgetModalTitle'),
|
||||
body: _('srForgetModalMessage'),
|
||||
}).then(() => _call('sr.forget', { id: resolveId(sr) }), noop)
|
||||
body: (
|
||||
<div className='text-warning'>
|
||||
<p className='font-weight-bold'>{_('srForgetModalMessage')}</p>
|
||||
</div>
|
||||
),
|
||||
strongConfirm: {
|
||||
messageId: 'srForget',
|
||||
},
|
||||
}).then(() => _call('sr.forget', { id: resolveId(sr) }), noop);
|
||||
};
|
||||
|
||||
export const forgetSrs = srs =>
|
||||
confirm({
|
||||
title: _('srsForgetModalTitle'),
|
||||
body: _('srsForgetModalMessage'),
|
||||
body: (
|
||||
<div className='text-warning'>
|
||||
<p className='font-weight-bold'>{_('srsForgetModalMessage')}</p>
|
||||
</div>
|
||||
),
|
||||
strongConfirm: {
|
||||
messageId: 'srsForget',
|
||||
},
|
||||
}).then(() => Promise.all(map(resolveIds(srs), id => _call('sr.forget', { id }))), noop)
|
||||
|
||||
export const reconnectAllHostsSr = sr =>
|
||||
|
||||
@@ -525,11 +525,7 @@ const HANDLED_VDI_TYPES = new Set(['system', 'user', 'ephemeral'])
|
||||
Object.assign({}, vdis, snapshotVdis)
|
||||
),
|
||||
createSelector(getSrs, srs => vdi => {
|
||||
if (
|
||||
vdi.$VBDs.length !== 0 || // vdi with a vbd aren't orphans
|
||||
!HANDLED_VDI_TYPES.has(vdi.VDI_type) || // only for vdi with handled types
|
||||
vdi.size === 0 // empty vdi aren't considered as orphans
|
||||
) {
|
||||
if (vdi.$VBDs.length !== 0 || !HANDLED_VDI_TYPES.has(vdi.VDI_type)) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -154,9 +154,9 @@ const NewNetwork = decorate([
|
||||
host =>
|
||||
host.$pool === pool.id || networks.some(({ pool }) => pool !== undefined && pool.id === host.$pool),
|
||||
pifPredicate:
|
||||
({ bonded }, { pool }) =>
|
||||
(_, { pool }) =>
|
||||
pif =>
|
||||
!pif.isBondSlave && !(bonded && pif.isBondMaster) && pif.vlan === -1 && pif.$host === (pool && pool.master),
|
||||
!pif.isBondSlave && !pif.isBondMaster && pif.vlan === -1 && pif.$host === (pool && pool.master),
|
||||
pifPredicateSdnController:
|
||||
(_, { pool }) =>
|
||||
pif =>
|
||||
|
||||
Reference in New Issue
Block a user