Compare commits

..

2 Commits

Author SHA1 Message Date
Julien Fontanet
35ca5667d9 feat(xen-api/putResource): can ignore connection premature close
This is opt-in via the `ignorePrematureClose` option.
2023-02-17 10:02:51 +01:00
Julien Fontanet
e9d9fbbf91 chore: use http-request-plus@1 2023-02-17 10:02:18 +01:00
54 changed files with 1917 additions and 2802 deletions

View File

@@ -22,7 +22,7 @@
"@vates/read-chunk": "^1.0.1",
"@xen-orchestra/async-map": "^0.1.2",
"promise-toolbox": "^0.21.0",
"xen-api": "^1.2.5"
"xen-api": "^1.2.3"
},
"devDependencies": {
"tap": "^16.3.0",

View File

@@ -1,12 +1,5 @@
'use strict'
/**
* Read a chunk of data from a stream.
*
* @param {Readable} stream - A readable stream to read from.
* @param {number} size - The number of bytes to read.
* @returns {Promise<Buffer|null>} - A Promise that resolves to a Buffer of up to size bytes if available, or null if end of stream is reached. The Promise is rejected if there is an error while reading from the stream.
*/
const readChunk = (stream, size) =>
stream.closed || stream.readableEnded
? Promise.resolve(null)
@@ -40,13 +33,6 @@ const readChunk = (stream, size) =>
})
exports.readChunk = readChunk
/**
* Read a chunk of data from a stream.
*
* @param {Readable} stream - A readable stream to read from.
* @param {number} size - The number of bytes to read.
* @returns {Promise<Buffer>} - A Promise that resolves to a Buffer of size bytes. The Promise is rejected if there is an error while reading from the stream.
*/
exports.readChunkStrict = async function readChunkStrict(stream, size) {
const chunk = await readChunk(stream, size)
if (chunk === null) {

View File

@@ -18,7 +18,7 @@
"preferGlobal": true,
"dependencies": {
"golike-defer": "^0.5.1",
"xen-api": "^1.2.5"
"xen-api": "^1.2.3"
},
"scripts": {
"postversion": "npm publish"

View File

@@ -24,14 +24,12 @@
"d3-time-format": "^4.1.0",
"decorator-synchronized": "^0.6.0",
"echarts": "^5.3.3",
"highlight.js": "^11.6.0",
"human-format": "^1.0.0",
"json-rpc-2.0": "^1.3.0",
"json5": "^2.2.1",
"limit-concurrency-decorator": "^0.5.0",
"lodash-es": "^4.17.21",
"make-error": "^1.3.6",
"markdown-it": "^13.0.1",
"pinia": "^2.0.14",
"placement.js": "^1.0.0-beta.5",
"vue": "^3.2.37",

View File

@@ -16,8 +16,8 @@ a {
color: var(--color-extra-blue-base);
}
code, code * {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
code {
font-family: monospace;
}
.card-view {

View File

@@ -1,114 +0,0 @@
<template>
<div ref="rootElement" class="app-markdown" v-html="html" />
</template>
<script lang="ts" setup>
import { type Ref, computed, ref } from "vue";
import { useEventListener } from "@vueuse/core";
import "highlight.js/styles/github-dark.css";
import { markdown } from "@/libs/markdown";
const rootElement = ref() as Ref<HTMLElement>;
const props = defineProps<{
content: string;
}>();
const html = computed(() => markdown.render(props.content ?? ""));
useEventListener(
rootElement,
"click",
(event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.classList.contains("copy-button")) {
return;
}
const copyable =
target.parentElement!.querySelector<HTMLElement>(".copyable");
if (copyable !== null) {
navigator.clipboard.writeText(copyable.innerText);
}
},
{ capture: true }
);
</script>
<style lang="postcss" scoped>
.app-markdown {
font-size: 1.6rem;
:deep() {
p,
h1,
h2,
h3,
h4,
h5,
h6,
pre {
margin: 1rem 0;
}
pre {
width: 100%;
padding: 0.8rem 1.4rem;
border-radius: 1rem;
}
code:not(.hljs-code) {
background-color: var(--background-color-extra-blue);
padding: 0.3rem 0.6rem;
border-radius: 0.6rem;
}
ul,
ol {
margin: revert;
padding-left: 2rem;
list-style-type: revert;
}
table {
border-spacing: 0;
th,
td {
padding: 0.5rem 1rem;
}
thead th {
border-bottom: 2px solid var(--color-blue-scale-400);
background-color: var(--background-color-secondary);
}
tbody td {
border-bottom: 1px solid var(--color-blue-scale-400);
}
}
.copy-button {
font-size: 1.6rem;
font-weight: 400;
position: absolute;
z-index: 1;
right: 1rem;
cursor: pointer;
color: white;
border: none;
background-color: transparent;
&:hover {
color: var(--color-extra-blue-base);
}
&:active {
color: var(--color-extra-blue-d20);
}
}
}
}
</style>

View File

@@ -1,41 +0,0 @@
<template>
<pre class="code-highlight hljs"><code v-html="codeAsHtml"></code></pre>
</template>
<script lang="ts" setup>
import HLJS from "highlight.js";
import { computed } from "vue";
import "highlight.js/styles/github-dark.css";
const props = withDefaults(
defineProps<{
code?: any;
lang?: string;
}>(),
{ lang: "typescript" }
);
const codeAsText = computed(() => {
switch (typeof props.code) {
case "string":
return props.code;
case "function":
return String(props.code);
default:
return JSON.stringify(props.code, undefined, 2);
}
});
const codeAsHtml = computed(
() => HLJS.highlight(codeAsText.value, { language: props.lang }).value
);
</script>
<style lang="postcss" scoped>
.code-highlight {
display: inline-block;
padding: 0.3rem 0.6rem;
text-align: left;
border-radius: 0.6rem;
}
</style>

View File

@@ -1,29 +0,0 @@
<template>
<RouterLink
v-slot="{ isActive, href }"
:to="disabled || isTabBarDisabled ? '' : to"
custom
>
<UiTab :active="isActive" :disabled="disabled" :href="href" tag="a">
<slot />
</UiTab>
</RouterLink>
</template>
<script lang="ts" setup>
import { type ComputedRef, computed, inject } from "vue";
import type { RouteLocationRaw } from "vue-router";
import UiTab from "@/components/ui/UiTab.vue";
const props = defineProps<{
to: RouteLocationRaw;
disabled?: boolean;
}>();
const isTabBarDisabled = inject<ComputedRef<boolean>>(
"isTabBarDisabled",
computed(() => false)
);
</script>
<style lang="postcss" scoped></style>

View File

@@ -1,29 +1,16 @@
<template>
<div class="ui-tab-bar">
<div class="tab-bar">
<slot />
</div>
</template>
<script lang="ts" setup>
import { computed, provide } from "vue";
const props = defineProps<{
disabled?: boolean;
}>();
provide(
"isTabBarDisabled",
computed(() => props.disabled)
);
</script>
<style lang="postcss" scoped>
.ui-tab-bar {
.tab-bar {
display: flex;
align-items: stretch;
height: 6.5rem;
border-bottom: 1px solid var(--color-blue-scale-400);
background-color: var(--background-color-primary);
border-bottom: 1px solid var(--color-blue-scale-400);
max-width: 100%;
overflow: auto;
}

View File

@@ -0,0 +1,54 @@
<template>
<span v-if="disabled" class="tab-bar-item disabled">
<slot />
</span>
<RouterLink v-else class="tab-bar-item" v-bind="$props">
<slot />
</RouterLink>
</template>
<script lang="ts" setup>
import type { RouterLinkProps } from "vue-router";
// https://vuejs.org/api/sfc-script-setup.html#type-only-props-emit-declarations
interface Props extends RouterLinkProps {
disabled?: boolean;
}
defineProps<Props>();
</script>
<style lang="postcss" scoped>
.tab-bar-item {
font-size: 1.8rem;
font-weight: 600;
display: flex;
align-items: center;
padding: 0 1.2em;
text-decoration: none;
text-transform: uppercase;
color: var(--color-blue-scale-100);
border-bottom: 2px solid transparent;
&:hover:not(.disabled) {
border-bottom-color: var(--color-extra-blue-base);
background-color: var(--background-color-secondary);
}
&:active:not(.disabled) {
color: var(--color-extra-blue-base);
border-bottom-color: var(--color-extra-blue-base);
background-color: var(--background-color-secondary);
}
&.router-link-active {
color: var(--color-extra-blue-base);
border-bottom-color: var(--color-extra-blue-base);
background-color: var(--background-color-primary);
}
&.disabled {
color: var(--color-blue-scale-400);
}
}
</style>

View File

@@ -6,7 +6,6 @@
>
<input
v-model="value"
:class="{ indeterminate: type === 'checkbox' && value === undefined }"
:disabled="isLabelDisabled || disabled"
:type="type === 'radio' ? 'radio' : 'checkbox'"
class="input"
@@ -33,7 +32,7 @@ import {
inject,
ref,
} from "vue";
import { faCheck, faCircle, faMinus } from "@fortawesome/free-solid-svg-icons";
import { faCheck, faCircle } from "@fortawesome/free-solid-svg-icons";
import { useVModel } from "@vueuse/core";
import UiIcon from "@/components/ui/UiIcon.vue";
@@ -54,17 +53,7 @@ const value = useVModel(props, "modelValue", emit);
const type = inject<"checkbox" | "radio" | "toggle">("inputType", "checkbox");
const hasLabel = inject("hasLabel", false);
const isLabelDisabled = inject("isLabelDisabled", ref(false));
const icon = computed(() => {
if (type !== "checkbox") {
return faCircle;
}
if (value.value === undefined) {
return faMinus;
}
return faCheck;
});
const icon = computed(() => (type === "checkbox" ? faCheck : faCircle));
</script>
<style lang="postcss" scoped>
@@ -85,11 +74,6 @@ const icon = computed(() => {
.form-checkbox {
--checkbox-border-radius: 0.25em;
--checkbox-icon-size: 1em;
.input.indeterminate + .fake-checkbox > .icon {
opacity: 1;
color: var(--color-blue-scale-300);
}
}
.form-checkbox,
@@ -125,8 +109,8 @@ const icon = computed(() => {
}
.icon {
transition: transform 0.125s ease-in-out;
transform: translateX(-0.7em);
transition: transform 0.125s ease-in-out;
}
.input:checked + .fake-checkbox > .icon {
@@ -156,12 +140,12 @@ const icon = computed(() => {
align-items: center;
justify-content: center;
height: 1.25em;
transition: background-color 0.125s ease-in-out,
border-color 0.125s ease-in-out;
border: var(--checkbox-border-width) solid var(--border-color);
border-radius: var(--checkbox-border-radius);
background-color: var(--background-color);
box-shadow: var(--shadow-100);
transition: background-color 0.125s ease-in-out,
border-color 0.125s ease-in-out;
--border-color: var(--color-blue-scale-400);
}

View File

@@ -1,6 +1,15 @@
<template>
<span :class="wrapperClass" v-bind="wrapperAttrs">
<template v-if="inputType === 'select'">
<input
v-if="!isSelect"
v-model="value"
:class="inputClass"
:disabled="disabled || isLabelDisabled"
class="input"
ref="inputElement"
v-bind="$attrs"
/>
<template v-else>
<select
v-model="value"
:class="inputClass"
@@ -15,24 +24,6 @@
<UiIcon :fixed-width="false" :icon="faAngleDown" />
</span>
</template>
<textarea
v-else-if="inputType === 'textarea'"
ref="textarea"
v-model="value"
:class="inputClass"
:disabled="disabled || isLabelDisabled"
class="textarea"
v-bind="$attrs"
/>
<input
v-else
v-model="value"
:class="inputClass"
:disabled="disabled || isLabelDisabled"
class="input"
ref="inputElement"
v-bind="$attrs"
/>
<span v-if="before !== undefined" class="before">
<template v-if="typeof before === 'string'">{{ before }}</template>
<UiIcon v-else :icon="before" class="before" />
@@ -52,19 +43,18 @@ export default {
</script>
<script lang="ts" setup>
import { isEmpty } from "lodash-es";
import {
type HTMLAttributes,
type InputHTMLAttributes,
computed,
inject,
nextTick,
ref,
watch,
} from "vue";
import type { Color } from "@/types";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
import { faAngleDown } from "@fortawesome/free-solid-svg-icons";
import { useTextareaAutosize, useVModel } from "@vueuse/core";
import { useVModel } from "@vueuse/core";
import UiIcon from "@/components/ui/UiIcon.vue";
// Temporary workaround for https://github.com/vuejs/core/issues/4294
@@ -89,10 +79,8 @@ const emit = defineEmits<{
}>();
const value = useVModel(props, "modelValue", emit);
const isEmpty = computed(
() => props.modelValue == null || String(props.modelValue).trim() === ""
);
const inputType = inject("inputType", "input");
const empty = computed(() => isEmpty(props.modelValue));
const isSelect = inject("isSelect", false);
const isLabelDisabled = inject("isLabelDisabled", ref(false));
const color = inject(
"color",
@@ -100,10 +88,10 @@ const color = inject(
);
const wrapperClass = computed(() => [
`form-${inputType}`,
isSelect ? "form-select" : "form-input",
{
disabled: props.disabled || isLabelDisabled.value,
empty: isEmpty.value,
empty: empty.value,
},
]);
@@ -116,12 +104,6 @@ const inputClass = computed(() => [
},
]);
const { textarea, triggerResize } = useTextareaAutosize();
watch(value, () => nextTick(() => triggerResize()), {
immediate: true,
});
const focus = () => inputElement.value.focus();
defineExpose({
@@ -131,13 +113,12 @@ defineExpose({
<style lang="postcss" scoped>
.form-input,
.form-select,
.form-textarea {
.form-select {
display: inline-grid;
align-items: stretch;
--before-width: v-bind('beforeWidth || "1.75em"');
--after-width: v-bind('afterWidth || "1.625em"');
--before-width: v-bind('beforeWidth ?? "1.75em"');
--after-width: v-bind('afterWidth ?? "1.625em"');
--caret-width: 1.5em;
--text-color: var(--color-blue-scale-100);
@@ -151,8 +132,7 @@ defineExpose({
}
}
.form-input,
.form-textarea {
.form-input {
grid-template-columns: var(--before-width) auto var(--after-width);
}
@@ -165,10 +145,8 @@ defineExpose({
}
.input,
.textarea,
.select {
font-size: 1em;
width: 100%;
height: 2em;
margin: 0;
color: var(--text-color);
@@ -257,19 +235,8 @@ defineExpose({
}
}
.textarea {
height: auto;
min-height: 2em;
}
.input {
padding: 0;
}
.input,
.textarea {
padding-right: 0.625em;
padding-left: 0.625em;
padding: 0 0.625em 0 0.625em;
&.has-before {
padding-left: calc(var(--before-width) + 0.25em);

View File

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

View File

@@ -8,7 +8,7 @@
import { provide } from "vue";
import FormInput from "@/components/form/FormInput.vue";
provide("inputType", "select");
provide("isSelect", true);
</script>
<style lang="postcss" scoped></style>

View File

@@ -1,12 +0,0 @@
<template>
<FormInput />
</template>
<script lang="ts" setup>
import { provide } from "vue";
import FormInput from "@/components/form/FormInput.vue";
provide("inputType", "textarea");
</script>
<style lang="postcss" scoped></style>

View File

@@ -1,40 +1,67 @@
<template>
<UiTabBar :disabled="!isReady">
<RouterTab :to="{ name: 'pool.dashboard', params: { uuid: poolUuid } }">
<TabBar>
<TabBarItem
:disabled="!isReady"
:to="{ name: 'pool.dashboard', params: { uuid: poolUuid } }"
>
{{ $t("dashboard") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.alarms', params: { uuid: poolUuid } }">
</TabBarItem>
<TabBarItem
:disabled="!isReady"
:to="{ name: 'pool.alarms', params: { uuid: poolUuid } }"
>
{{ $t("alarms") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.stats', params: { uuid: poolUuid } }">
</TabBarItem>
<TabBarItem
:disabled="!isReady"
:to="{ name: 'pool.stats', params: { uuid: poolUuid } }"
>
{{ $t("stats") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.system', params: { uuid: poolUuid } }">
</TabBarItem>
<TabBarItem
:disabled="!isReady"
:to="{ name: 'pool.system', params: { uuid: poolUuid } }"
>
{{ $t("system") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.network', params: { uuid: poolUuid } }">
</TabBarItem>
<TabBarItem
:disabled="!isReady"
:to="{ name: 'pool.network', params: { uuid: poolUuid } }"
>
{{ $t("network") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.storage', params: { uuid: poolUuid } }">
</TabBarItem>
<TabBarItem
:disabled="!isReady"
:to="{ name: 'pool.storage', params: { uuid: poolUuid } }"
>
{{ $t("storage") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.tasks', params: { uuid: poolUuid } }">
</TabBarItem>
<TabBarItem
:disabled="!isReady"
:to="{ name: 'pool.tasks', params: { uuid: poolUuid } }"
>
{{ $t("tasks") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.hosts', params: { uuid: poolUuid } }">
</TabBarItem>
<TabBarItem
:disabled="!isReady"
:to="{ name: 'pool.hosts', params: { uuid: poolUuid } }"
>
{{ $t("hosts") }}
</RouterTab>
<RouterTab :to="{ name: 'pool.vms', params: { uuid: poolUuid } }">
</TabBarItem>
<TabBarItem
:disabled="!isReady"
:to="{ name: 'pool.vms', params: { uuid: poolUuid } }"
>
{{ $t("vms") }}
</RouterTab>
</UiTabBar>
</TabBarItem>
</TabBar>
</template>
<script lang="ts" setup>
import { storeToRefs } from "pinia";
import { computed } from "vue";
import RouterTab from "@/components/RouterTab.vue";
import UiTabBar from "@/components/ui/UiTabBar.vue";
import TabBar from "@/components/TabBar.vue";
import TabBarItem from "@/components/TabBarItem.vue";
import { usePoolStore } from "@/stores/pool.store";
const poolStore = usePoolStore();

View File

@@ -1,68 +0,0 @@
<template>
<component
:is="tag"
:class="{ active, disabled: disabled || isTabBarDisabled }"
class="ui-tab"
>
<slot />
</component>
</template>
<script lang="ts" setup>
import { type ComputedRef, computed, inject } from "vue";
withDefaults(
defineProps<{
disabled?: boolean;
active?: boolean;
tag?: string;
}>(),
{ tag: "span" }
);
const isTabBarDisabled = inject<ComputedRef<boolean>>(
"isTabBarDisabled",
computed(() => false)
);
</script>
<style lang="postcss" scoped>
.ui-tab {
font-size: 1.8rem;
font-weight: 600;
display: flex;
align-items: center;
padding: 0 1.2em;
text-decoration: none;
text-transform: uppercase;
color: var(--color-blue-scale-100);
border-bottom: 2px solid transparent;
&.disabled {
pointer-events: none;
color: var(--color-blue-scale-400);
}
&:not(.disabled) {
cursor: pointer;
&:hover {
cursor: pointer;
border-bottom-color: var(--color-extra-blue-base);
background-color: var(--background-color-secondary);
}
&:active {
color: var(--color-extra-blue-base);
border-bottom-color: var(--color-extra-blue-base);
background-color: var(--background-color-secondary);
}
&.active {
color: var(--color-extra-blue-base);
border-bottom-color: var(--color-extra-blue-base);
background-color: var(--background-color-primary);
}
}
}
</style>

View File

@@ -1,64 +0,0 @@
import HLJS from "highlight.js";
import MarkdownIt from "markdown-it";
export const markdown = new MarkdownIt();
markdown.set({
highlight: (str: string, lang: string) => {
const code = highlight(str, lang);
return `<pre class="hljs"><button class="copy-button" type="button">Copy</button><code class="hljs-code">${code}</code></pre>`;
},
});
function highlight(str: string, lang: string) {
switch (lang) {
case "vue-template": {
const indented = str
.trim()
.split("\n")
.map((s) => ` ${s}`)
.join("\n");
return wrap(indented, "template");
}
case "vue-script":
return wrap(str.trim(), "script");
case "vue-style":
return wrap(str.trim(), "style");
default: {
if (HLJS.getLanguage(lang) !== undefined) {
return copyable(HLJS.highlight(str, { language: lang }).value);
}
return copyable(markdown.utils.escapeHtml(str));
}
}
}
function wrap(str: string, tag: "template" | "script" | "style") {
let openTag;
let code;
switch (tag) {
case "template":
openTag = "<template>";
code = HLJS.highlight(str, { language: "xml" }).value;
break;
case "script":
openTag = '<script lang="ts" setup>';
code = HLJS.highlight(str, { language: "typescript" }).value;
break;
case "style":
openTag = '<style lang="postcss" scoped>';
code = HLJS.highlight(str, { language: "scss" }).value;
break;
}
const openTagHtml = HLJS.highlight(openTag, { language: "xml" }).value;
const closeTagHtml = HLJS.highlight(`</${tag}>`, { language: "xml" }).value;
return `${openTagHtml}${copyable(code)}${closeTagHtml}`;
}
function copyable(code: string) {
return `<div class="copyable">${code}</div>`;
}

View File

@@ -55,7 +55,6 @@
"property": "Property",
"ram-usage": "RAM usage",
"reboot": "Reboot",
"reformat": "Reformat",
"relative-time": {
"day": "1 day | {n} days",
"future": "In {str}",
@@ -68,7 +67,6 @@
"year": "1 year | {n} years"
},
"resume": "Resume",
"save": "Save",
"send-us-feedback": "Send us feedback",
"settings": "Settings",
"shutdown": "Shutdown",

View File

@@ -55,7 +55,6 @@
"property": "Propriété",
"ram-usage": "Utilisation de la RAM",
"reboot": "Redémarrer",
"reformat": "Reformater",
"relative-time": {
"day": "1 jour | {n} jours",
"future": "Dans {str}",
@@ -68,7 +67,6 @@
"year": "1 an | {n} ans"
},
"resume": "Reprendre",
"save": "Enregistrer",
"send-us-feedback": "Envoyez-nous vos commentaires",
"settings": "Paramètres",
"shutdown": "Arrêter",

View File

@@ -32,7 +32,7 @@
"content-type": "^1.0.4",
"cson-parser": "^4.0.7",
"getopts": "^2.2.3",
"http-request-plus": "^1.0.0",
"http-request-plus": "github:JsCommunity/http-request-plus#v1",
"json-rpc-protocol": "^0.13.1",
"promise-toolbox": "^0.21.0",
"pumpify": "^2.0.1",

View File

@@ -1,7 +1,7 @@
{
"private": true,
"name": "@xen-orchestra/proxy",
"version": "0.26.13",
"version": "0.26.11",
"license": "AGPL-3.0-or-later",
"description": "XO Proxy used to remotely execute backup jobs",
"keywords": [
@@ -46,7 +46,7 @@
"get-stream": "^6.0.0",
"getopts": "^2.2.3",
"golike-defer": "^0.5.1",
"http-server-plus": "^1.0.0",
"http-server-plus": "^0.12.0",
"http2-proxy": "^5.0.53",
"json-rpc-protocol": "^0.13.1",
"jsonrpc-websocket-client": "^0.7.2",
@@ -60,7 +60,7 @@
"source-map-support": "^0.5.16",
"stoppable": "^1.0.6",
"xdg-basedir": "^5.1.0",
"xen-api": "^1.2.5",
"xen-api": "^1.2.3",
"xo-common": "^0.8.0"
},
"devDependencies": {

View File

@@ -35,7 +35,7 @@
"form-data": "^4.0.0",
"fs-extra": "^11.1.0",
"get-stream": "^6.0.0",
"http-request-plus": "^1.0.0",
"http-request-plus": "github:JsCommunity/http-request-plus#v1",
"human-format": "^1.0.0",
"lodash": "^4.17.4",
"pretty-ms": "^7.0.0",

View File

@@ -15,7 +15,7 @@
"node": ">=14"
},
"peerDependencies": {
"xen-api": "^1.2.5"
"xen-api": "^1.2.3"
},
"scripts": {
"postversion": "npm publish --access public"
@@ -26,7 +26,7 @@
"@xen-orchestra/log": "^0.6.0",
"d3-time-format": "^3.0.0",
"golike-defer": "^0.5.1",
"http-request-plus": "^1.0.0",
"http-request-plus": "github:JsCommunity/http-request-plus#v1",
"json-rpc-protocol": "^0.13.2",
"lodash": "^4.17.15",
"promise-toolbox": "^0.21.0",

View File

@@ -1,20 +1,6 @@
# ChangeLog
## **5.79.2** (2023-02-20)
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
### Bug fixes
- [Disk import] Fixes ` Cannot read properties of null (reading "length")` error
- [Continuous Replication] Work-around _premature close_ error
### Released packages
- xen-api 1.2.5
- @xen-orchestra/proxy 0.26.13
- xo-server 5.109.3
## **5.79.1** (2023-02-17)
## **next**
### Bug fixes
@@ -29,13 +15,13 @@
### Released packages
- xen-api 1.2.4
- xen-api 1.2.3
- @vates/nbd-client 1.0.1
- @xen-orchestra/backups 0.29.6
- @xen-orchestra/proxy 0.26.12
- @xen-orchestra/proxy 0.26.11
- xo-vmdk-to-vhd 2.5.3
- xo-cli 0.14.4
- xo-server 5.109.2
- xo-server 5.109.1
- xo-server-transport-email 0.6.1
- xo-web 5.111.1
@@ -97,7 +83,7 @@
## **5.78.0** (2022-12-20)
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
<img id="latest" src="https://badgen.net/badge/channel/latest/yellow" alt="Channel: latest" />
### Highlights
@@ -121,6 +107,8 @@
## **5.77.2** (2022-12-12)
<img id="stable" src="https://badgen.net/badge/channel/stable/green" alt="Channel: stable" />
### Bug fixes
- [Backups] Fixes most of the _unexpected number of entries in backup cache_ errors

View File

@@ -27,6 +27,6 @@
<!--packages-start-->
- xo-cli minor
- xen-api patch
<!--packages-end-->

View File

@@ -40,7 +40,7 @@
"human-format": "^1.0.0",
"lodash": "^4.17.4",
"pw": "^0.0.4",
"xen-api": "^1.2.5"
"xen-api": "^1.2.3"
},
"devDependencies": {
"@babel/cli": "^7.1.5",

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "xen-api",
"version": "1.2.5",
"version": "1.2.3",
"license": "ISC",
"description": "Connector to the Xen API",
"keywords": [
@@ -35,7 +35,7 @@
"bind-property-descriptor": "^2.0.0",
"blocked": "^1.2.1",
"debug": "^4.0.1",
"http-request-plus": "^1.0.0",
"http-request-plus": "github:JsCommunity/http-request-plus#v1",
"jest-diff": "^29.0.3",
"json-rpc-protocol": "^0.13.1",
"kindof": "^2.0.0",

View File

@@ -90,7 +90,7 @@ export class Xapi extends EventEmitter {
this._callTimeout = makeCallSetting(opts.callTimeout, 60 * 60 * 1e3) // 1 hour but will be reduced in the future
this._httpInactivityTimeout = opts.httpInactivityTimeout ?? 5 * 60 * 1e3 // 5 mins
this._eventPollDelay = opts.eventPollDelay ?? 60 * 1e3 // 1 min
this._ignorePrematureClose = opts.ignorePrematureClose ?? true
this._ignorePrematureClose = opts.ignorePrematureClose ?? false
this._pool = null
this._readOnly = Boolean(opts.readOnly)
this._RecordsByType = { __proto__: null }
@@ -563,9 +563,7 @@ export class Xapi extends EventEmitter {
})
}
} catch (error) {
if (this._ignorePrematureClose && error.code === 'ERR_STREAM_PREMATURE_CLOSE') {
console.warn(this._humanId, 'Xapi#putResource', pathname, error)
} else {
if (!(this._ignorePrematureClose && error.code === 'ERR_STREAM_PREMATURE_CLOSE')) {
throw error
}
}

View File

@@ -46,47 +46,34 @@ async function connect() {
return xo
}
async function parseRegisterArgs(args, tokenDescription, acceptToken = false) {
async function parseRegisterArgs(args) {
const {
allowUnauthorized,
expiresIn,
token,
_: opts,
} = getopts(args, {
alias: {
allowUnauthorized: 'au',
token: 't',
},
boolean: ['allowUnauthorized'],
stopEarly: true,
string: ['expiresIn', 'token'],
})
const result = {
allowUnauthorized,
expiresIn: expiresIn || undefined,
url: opts[0],
}
if (token !== '') {
if (!acceptToken) {
// eslint-disable-next-line no-throw-literal
throw '`token` option is not accepted by this command'
}
result.token = token
} else {
const [
,
_: [
url,
email,
password = await new Promise(function (resolve) {
process.stdout.write('Password: ')
pw(resolve)
}),
] = opts
result.token = await _createToken({ ...result, description: tokenDescription, email, password })
}
],
} = getopts(args, {
alias: {
allowUnauthorized: 'au',
},
boolean: ['allowUnauthorized'],
stopEarly: true,
string: ['expiresIn'],
})
return result
return {
allowUnauthorized,
email,
expiresIn: expiresIn || undefined,
password,
url,
}
}
async function _createToken({ allowUnauthorized, description, email, expiresIn, password, url }) {
@@ -206,20 +193,16 @@ const help = wrap(
(function (pkg) {
return `Usage:
$name --register [--allowUnauthorized] [--expiresIn <duration>] <XO-Server URL> <username> [<password>]
$name --register [--allowUnauthorized] [--expiresIn <duration>] --token <token> <XO-Server URL>
$name --register [--allowUnauthorized] [--expiresIn duration] <XO-Server URL> <username> [<password>]
Registers the XO instance to use.
--allowUnauthorized, --au
Accept invalid certificate (e.g. self-signed).
--expiresIn <duration>
--expiresIn duration
Can be used to change the validity duration of the
authorization token (default: one month).
--token <token>
An authentication token to use instead of username/password.
$name --createToken <params>…
Create an authentication token for XO API.
@@ -311,8 +294,10 @@ async function main(args) {
COMMANDS.help = help
async function createToken(args) {
const { token } = await parseRegisterArgs(args, 'xo-cli --createToken')
const opts = await parseRegisterArgs(args)
opts.description = 'xo-cli --createToken'
const token = await _createToken(opts)
console.warn('Authentication token created')
console.warn()
console.log(token)
@@ -320,11 +305,13 @@ async function createToken(args) {
COMMANDS.createToken = createToken
async function register(args) {
const opts = await parseRegisterArgs(args, 'xo-cli --register', true)
const opts = await parseRegisterArgs(args)
opts.description = 'xo-cli --register'
await config.set({
allowUnauthorized: opts.allowUnauthorized,
server: opts.url,
token: opts.token,
token: await _createToken(opts),
})
}
COMMANDS.register = register

View File

@@ -32,7 +32,7 @@
"chalk": "^5.0.1",
"fs-extra": "^11.1.0",
"getopts": "^2.3.0",
"http-request-plus": "^1.0.0",
"http-request-plus": "github:JsCommunity/http-request-plus#v1",
"human-format": "^1.0.0",
"lodash": "^4.17.4",
"micromatch": "^4.0.2",

View File

@@ -1,7 +1,7 @@
{
"private": true,
"name": "xo-server",
"version": "5.109.3",
"version": "5.109.1",
"license": "AGPL-3.0-or-later",
"description": "Server part of Xen-Orchestra",
"keywords": [
@@ -82,7 +82,7 @@
"helmet": "^3.9.0",
"highland": "^2.11.1",
"http-proxy": "^1.16.2",
"http-request-plus": "^1.0.0",
"http-request-plus": "github:JsCommunity/http-request-plus#v1",
"http-server-plus": "^1.0.0",
"human-format": "^1.0.0",
"iterable-backoff": "^0.1.0",
@@ -131,7 +131,7 @@
"vhd-lib": "^4.2.1",
"ws": "^8.2.3",
"xdg-basedir": "^5.1.0",
"xen-api": "^1.2.5",
"xen-api": "^1.2.3",
"xo-acl-resolver": "^0.4.1",
"xo-collection": "^0.5.0",
"xo-common": "^0.8.0",

View File

@@ -232,7 +232,7 @@ async function handleImport(req, res, { type, name, description, vmdkData, srId,
// didn't succeed to ensure the stream is completly consumed with resume/finished
do {
buffer = await readChunk(part, CHUNK_SIZE)
} while (buffer?.length === CHUNK_SIZE)
} while (buffer.length === CHUNK_SIZE)
res.end(format.response(0, vdi.$id))
} catch (e) {

View File

@@ -2598,8 +2598,8 @@ export default {
// Original text: "To SR:"
vmImportToSr: 'al SR:',
// Original text: "VM{nVms, plural, one {} other {s}} to import"
vmsToImport: 'VM{nVms, plural, one {} other {s}} para importar',
// Original text: "VMs to import"
vmsToImport: 'VMs para importar',
// Original text: "Reset"
importVmsCleanList: 'Reiniciar',

View File

@@ -2657,8 +2657,8 @@ export default {
// Original text: "To SR:"
vmImportToSr: 'Sur le SR:',
// Original text: "VM{nVms, plural, one {} other {s}} to import"
vmsToImport: 'VM{nVms, plural, one {} other {s}} à importer',
// Original text: "VMs to import"
vmsToImport: 'VMs à importer',
// Original text: "Reset"
importVmsCleanList: 'Réinitialiser',

View File

@@ -2276,7 +2276,7 @@ export default {
// Original text: 'To SR:'
vmImportToSr: undefined,
// Original text: 'VM{nVms, plural, one {} other {s}} to import'
// Original text: 'VMs to import'
vmsToImport: undefined,
// Original text: 'Reset'

View File

@@ -2490,7 +2490,7 @@ export default {
// Original text: "To SR:"
vmImportToSr: 'Adattárolóra:',
// Original text: "VM{nVms, plural, one {} other {s}} to import"
// Original text: "VMs to import"
vmsToImport: 'Importálandó VPS-el',
// Original text: "Reset"

View File

@@ -3746,8 +3746,8 @@ export default {
// Original text: 'To SR:'
vmImportToSr: 'Per SR:',
// Original text: 'VM{nVms, plural, one {} other {s}} to import'
vmsToImport: 'VM{nVms, plural, one {} other {s}} da importare',
// Original text: 'VMs to import'
vmsToImport: 'VMs da importare',
// Original text: 'Reset'
importVmsCleanList: 'Ripristina',

View File

@@ -2280,8 +2280,8 @@ export default {
// Original text: "To SR:"
vmImportToSr: 'To SR:',
// Original text: "VM{nVms, plural, one {} other {s}} to import"
vmsToImport: 'VM{nVms, plural, one {} other {s}} to import',
// Original text: "VMs to import"
vmsToImport: 'VMs to import',
// Original text: "Reset"
importVmsCleanList: 'Reset',

View File

@@ -2279,8 +2279,8 @@ export default {
// Original text: "To SR:"
vmImportToSr: 'Enviar para SR:',
// Original text: "VM{nVms, plural, one {} other {s}} to import"
vmsToImport: 'Importar VM{nVms, plural, one {} other {s}} ',
// Original text: "VMs to import"
vmsToImport: 'Importar VMs',
// Original text: "Reset"
importVmsCleanList: 'Reiniciar',

View File

@@ -2600,7 +2600,7 @@ export default {
// Original text: "To SR:"
vmImportToSr: 'В SR:',
// Original text: "VM{nVms, plural, one {} other {s}} to import"
// Original text: "VMs to import"
vmsToImport: 'ВМ для импорта',
// Original text: "Reset"

View File

@@ -3223,7 +3223,7 @@ export default {
// Original text: "To SR:"
vmImportToSr: 'SR:',
// Original text: "VM{nVms, plural, one {} other {s}} to import"
// Original text: "VMs to import"
vmsToImport: "içe aktarılacak VM'ler",
// Original text: "Reset"

View File

@@ -1739,7 +1739,7 @@ export default {
// Original text: "To SR:"
vmImportToSr: '到存储库',
// Original text: "VM{nVms, plural, one {} other {s}} to import"
// Original text: "VMs to import"
vmsToImport: '导入虚拟机',
// Original text: "Reset"

View File

@@ -9,10 +9,7 @@ const messages = {
description: 'Description',
deleteSourceVm: 'Delete source VM',
expiration: 'Expiration',
hostIp: 'Host IP',
keyValue: '{key}: {value}',
sslCertificate: 'SSL certificate',
vmSrUsage: '{used} used of {total} ({free} free)',
notDefined: 'Not defined',
statusConnecting: 'Connecting',
@@ -1612,13 +1609,12 @@ const messages = {
// ---- VM import ---
fileType: 'File type:',
fromUrl: 'From URL',
fromVmware: 'From VMware',
importVmsList: 'Drop OVA or XVA files here to import Virtual Machines.',
noSelectedVms: 'No selected VMs.',
url: 'URL:',
vmImportToPool: 'To Pool:',
vmImportToSr: 'To SR:',
vmsToImport: 'VM{nVms, plural, one {} other {s}} to import',
vmsToImport: 'VMs to import',
importVmsCleanList: 'Reset',
vmImportSuccess: 'VM import success',
vmImportFailed: 'VM import failed',

View File

@@ -3468,10 +3468,3 @@ export const synchronizeNetbox = pools =>
body: _('syncNetboxWarning'),
icon: 'refresh',
}).then(() => _call('netbox.synchronize', { pools: resolveIds(pools) }))
// ESXi import ---------------------------------------------------------------
export const esxiConnect = (host, user, password, sslVerify) =>
_call('esxi.connect', { host, user, password, sslVerify })
export const importVmFromEsxi = params => _call('vm.importFromEsxi', params)

View File

@@ -7,7 +7,6 @@ import { NavLink, NavTabs } from 'nav'
import { routes } from 'utils'
import DiskImport from '../disk-import'
import EsxiImport from '../vm-import/esxi-import'
import VmImport from '../vm-import'
const HEADER = (
@@ -26,9 +25,6 @@ const HEADER = (
<NavLink to='/import/disk'>
<Icon icon='disk' /> {_('labelDisk')}
</NavLink>
<NavLink to='/import/vmware'>
<Icon icon='vm' /> {_('fromVmware')}
</NavLink>
</NavTabs>
</Col>
</Row>
@@ -38,7 +34,6 @@ const HEADER = (
const Import = routes('vm', {
disk: DiskImport,
vm: VmImport,
vmware: EsxiImport,
})(({ children }) => (
<Page header={HEADER} title='newImport' formatTitle>
{children}

View File

@@ -494,11 +494,6 @@ export default class Menu extends Component {
icon: 'disk',
label: 'labelDisk',
},
{
to: '/import/vmware',
icon: 'vm',
label: 'fromVmware',
},
],
},
!(noOperatablePools && noResourceSets) && {

View File

@@ -1,251 +0,0 @@
import _, { messages } from 'intl'
import ActionButton from 'action-button'
import Button from 'button'
import decorate from 'apply-decorators'
import React from 'react'
import { esxiConnect, importVmFromEsxi, isSrWritable } from 'xo'
import { injectIntl } from 'react-intl'
import { injectState, provideState } from 'reaclette'
import { Input } from 'debounce-input-decorator'
import { InputCol, LabelCol, Row } from 'form-grid'
import { isEmpty, map } from 'lodash'
import { linkState } from 'reaclette-utils'
import { Password, Select } from 'form'
import { SelectNetwork, SelectPool, SelectSr } from 'select-objects'
import VmData from './vm-data'
const getInitialState = () => ({
hasCertificate: true,
hostIp: '',
isConnected: false,
network: undefined,
password: '',
pool: undefined,
sr: undefined,
user: '',
vm: undefined,
vmData: undefined,
vmsById: undefined,
})
const EsxiImport = decorate([
provideState({
initialState: getInitialState,
effects: {
importVm:
() =>
({ hasCertificate, hostIp, network, password, sr, user, vmData }) => {
importVmFromEsxi({
host: hostIp,
network: network.id,
password,
sr,
sslVerify: hasCertificate,
user,
vm: vmData.id,
})
},
connect:
() =>
async ({ hostIp, hasCertificate, password, user }) => {
const vms = await esxiConnect(hostIp, user, password, hasCertificate)
return { isConnected: true, vmsById: vms.reduce((vms, vm) => ({ ...vms, [vm.id]: vm }), {}) }
},
linkState,
onChangeVm: (_, vm) => state => ({ vm, vmData: state.vmsById[vm.value] }),
onChangeVmData: (_, vmData) => ({ vmData }),
onChangeNetwork: (_, network) => ({ network }),
onChangePool: (_, pool) => ({ pool, sr: pool.default_SR }),
onChangeSr: (_, sr) => ({ sr }),
toggleCertificateCheck:
(_, { target: { checked, name } }) =>
state => ({
...state,
[name]: checked,
}),
reset: getInitialState,
},
computed: {
selectVmOptions: ({ vmsById }) =>
map(vmsById, vm => ({
label: vm.nameLabel,
value: vm.id,
})),
networkpredicate:
({ pool }) =>
network =>
network.$poolId === pool?.uuid,
srPredicate:
({ pool }) =>
sr =>
isSrWritable(sr) && sr.$poolId === pool?.uuid,
},
}),
injectIntl,
injectState,
({
effects: {
connect,
importVm,
linkState,
onChangeVm,
onChangeVmData,
onChangeNetwork,
onChangePool,
onChangeSr,
reset,
srPredicate,
toggleCertificateCheck,
},
intl: { formatMessage },
state: {
hasCertificate,
hostIp,
isConnected,
network,
networkPredicate,
password,
pool,
selectVmOptions,
sr,
user,
vm,
vmsById,
vmData,
},
}) => (
<div>
{!isConnected && (
<form id='esxi-connect-form'>
<Row>
<LabelCol>{_('hostIp')}</LabelCol>
<InputCol>
<Input
className='form-control'
name='hostIp'
onChange={linkState}
placeholder='192.168.2.20'
required
value={hostIp}
/>
</InputCol>
</Row>
<Row>
<LabelCol>{_('user')}</LabelCol>
<InputCol>
<Input
className='form-control'
name='user'
onChange={linkState}
placeholder={formatMessage(messages.user)}
required
value={user}
/>
</InputCol>
</Row>
<Row>
<LabelCol>{_('password')}</LabelCol>
<InputCol>
<Password
name='password'
onChange={linkState}
placeholder={formatMessage(messages.password)}
required
value={password}
/>
</InputCol>
</Row>
<Row>
<LabelCol>{_('sslCertificate')}</LabelCol>
<InputCol>
<input
checked={hasCertificate}
name='hasCertificate'
onChange={toggleCertificateCheck}
type='checkbox'
value={hasCertificate}
/>
</InputCol>
</Row>
<div className='form-group pull-right'>
<ActionButton
btnStyle='primary'
className='mr-1'
form='esxi-connect-form'
handler={connect}
icon='connect'
type='submit'
>
{_('serverConnect')}
</ActionButton>
<Button onClick={reset}>{_('formReset')}</Button>
</div>
</form>
)}
{isConnected && (
<form id='esxi-migrate-form'>
<Row>
<LabelCol>{_('vm')}</LabelCol>
<InputCol>
<Select disabled={isEmpty(vmsById)} onChange={onChangeVm} options={selectVmOptions} required value={vm} />
</InputCol>
</Row>
<Row>
<LabelCol>{_('vmImportToPool')}</LabelCol>
<InputCol>
<SelectPool onChange={onChangePool} required value={pool} />
</InputCol>
</Row>
<Row>
<LabelCol>{_('vmImportToSr')}</LabelCol>
<InputCol>
<SelectSr
disabled={pool === undefined}
onChange={onChangeSr}
predicate={srPredicate}
required
value={sr}
/>
</InputCol>
</Row>
<Row>
<LabelCol>{_('network')}</LabelCol>
<InputCol>
<SelectNetwork
disabled={pool === undefined}
onChange={onChangeNetwork}
predicate={networkPredicate}
required
value={network}
/>
</InputCol>
</Row>
{vm !== undefined && (
<div>
<hr />
<h5>{_('vmsToImport', { nVms: 1 })}</h5>
<VmData data={vmData} onChange={onChangeVmData} pool={pool} />
</div>
)}
<div className='form-group pull-right'>
<ActionButton
btnStyle='primary'
className='mr-1'
disabled={vm === undefined}
form='esxi-migrate-form'
handler={importVm}
icon='import'
type='submit'
>
{_('newImport')}
</ActionButton>
<Button onClick={reset}>{_('formReset')}</Button>
</div>
</form>
)}
</div>
),
])
export default EsxiImport

View File

@@ -1,29 +1,302 @@
import * as CM from 'complex-matcher'
import * as FormGrid from 'form-grid'
import _ from 'intl'
import ActionButton from 'action-button'
import Button from 'button'
import Component from 'base-component'
import Dropzone from 'dropzone'
import isEmpty from 'lodash/isEmpty'
import map from 'lodash/map'
import orderBy from 'lodash/orderBy'
import PropTypes from 'prop-types'
import React from 'react'
import { Container } from 'grid'
import { Toggle } from 'form'
import { Container, Col, Row } from 'grid'
import { importVm, importVms, isSrWritable } from 'xo'
import { Select, SizeInput, Toggle } from 'form'
import { createFinder, createGetObject, createGetObjectsOfType, createSelector } from 'selectors'
import { connectStore, formatSize, mapPlus, noop } from 'utils'
import { Input } from 'debounce-input-decorator'
import XvaImport from './xva-import'
import UrlImport from './url-import'
import { SelectNetwork, SelectPool, SelectSr } from 'select-objects'
import parseOvaFile from './ova'
import styles from './index.css'
// ===================================================================
const RENDER_BY_TYPE = {
xva: <XvaImport />,
url: <UrlImport />,
const FILE_TYPES = [
{
label: 'XVA',
value: 'xva',
},
]
const FORMAT_TO_HANDLER = {
ova: parseOvaFile,
xva: noop,
}
// ===================================================================
@connectStore(
() => {
const getHostMaster = createGetObject((_, props) => props.pool.master)
const getPifs = createGetObjectsOfType('PIF').pick((state, props) => getHostMaster(state, props).$PIFs)
const getDefaultNetworkId = createSelector(createFinder(getPifs, [pif => pif.management]), pif => pif.$network)
return {
defaultNetwork: getDefaultNetworkId,
}
},
{ withRef: true }
)
class VmData extends Component {
static propTypes = {
descriptionLabel: PropTypes.string,
disks: PropTypes.objectOf(
PropTypes.shape({
capacity: PropTypes.number.isRequired,
descriptionLabel: PropTypes.string.isRequired,
nameLabel: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
compression: PropTypes.string,
})
),
memory: PropTypes.number,
nameLabel: PropTypes.string,
nCpus: PropTypes.number,
networks: PropTypes.array,
pool: PropTypes.object.isRequired,
}
get value() {
const { props, refs } = this
return {
descriptionLabel: refs.descriptionLabel.value,
disks: map(props.disks, ({ capacity, path, compression, position }, diskId) => ({
capacity,
descriptionLabel: refs[`disk-description-${diskId}`].value,
nameLabel: refs[`disk-name-${diskId}`].value,
path,
position,
compression,
})),
memory: +refs.memory.value,
nameLabel: refs.nameLabel.value,
networks: map(props.networks, (_, networkId) => {
const network = refs[`network-${networkId}`].value
return network.id ? network.id : network
}),
nCpus: +refs.nCpus.value,
tables: props.tables,
}
}
_getNetworkPredicate = createSelector(
() => this.props.pool.id,
id => network => network.$pool === id
)
render() {
const { descriptionLabel, defaultNetwork, disks, memory, nameLabel, nCpus, networks } = this.props
return (
<div>
<Row>
<Col mediumSize={6}>
<div className='form-group'>
<label>{_('vmNameLabel')}</label>
<input className='form-control' ref='nameLabel' defaultValue={nameLabel} type='text' required />
</div>
<div className='form-group'>
<label>{_('vmNameDescription')}</label>
<input className='form-control' ref='descriptionLabel' defaultValue={descriptionLabel} type='text' />
</div>
</Col>
<Col mediumSize={6}>
<div className='form-group'>
<label>{_('nCpus')}</label>
<input className='form-control' ref='nCpus' defaultValue={nCpus} type='number' required />
</div>
<div className='form-group'>
<label>{_('vmMemory')}</label>
<SizeInput defaultValue={memory} ref='memory' required />
</div>
</Col>
</Row>
<Row>
<Col mediumSize={6}>
{!isEmpty(disks)
? map(disks, (disk, diskId) => (
<Row key={diskId}>
<Col mediumSize={6}>
<div className='form-group'>
<label>
{_('diskInfo', {
position: `${disk.position}`,
capacity: formatSize(disk.capacity),
})}
</label>
<input
className='form-control'
ref={`disk-name-${diskId}`}
defaultValue={disk.nameLabel}
type='text'
required
/>
</div>
</Col>
<Col mediumSize={6}>
<div className='form-group'>
<label>{_('diskDescription')}</label>
<input
className='form-control'
ref={`disk-description-${diskId}`}
defaultValue={disk.descriptionLabel}
type='text'
required
/>
</div>
</Col>
</Row>
))
: _('noDisks')}
</Col>
<Col mediumSize={6}>
{networks.length > 0
? map(networks, (name, networkId) => (
<div className='form-group' key={networkId}>
<label>{_('networkInfo', { name })}</label>
<SelectNetwork
defaultValue={defaultNetwork}
ref={`network-${networkId}`}
predicate={this._getNetworkPredicate()}
/>
</div>
))
: _('noNetworks')}
</Col>
</Row>
</div>
)
}
}
// ===================================================================
const parseFile = async (file, type, func) => {
try {
return {
data: await func(file),
file,
type,
}
} catch (error) {
console.error(error)
return { error, file, type }
}
}
const getRedirectionUrl = vms =>
vms.length === 0
? undefined // no redirect
: vms.length === 1
? `/vms/${vms[0]}`
: `/home?s=${encodeURIComponent(new CM.Property('id', new CM.Or(vms.map(_ => new CM.String(_)))).toString())}&t=VM`
export default class Import extends Component {
constructor(props) {
super(props)
this.state = {
isFromUrl: false,
type: {
label: 'XVA',
value: 'xva',
},
url: '',
vms: [],
}
}
_import = () => {
const { state } = this
return importVms(
mapPlus(state.vms, (vm, push, vmIndex) => {
if (!vm.error) {
const ref = this.refs[`vm-data-${vmIndex}`]
push({
...vm,
data: ref && ref.value,
})
}
}),
state.sr
)
}
_importVmFromUrl = () => {
const { type, url } = this.state
const file = {
name: decodeURIComponent(url.slice(url.lastIndexOf('/') + 1)),
}
return importVm(file, type.value, undefined, this.state.sr, url)
}
_handleDrop = async files => {
this.setState({
vms: [],
})
const vms = await Promise.all(
mapPlus(files, (file, push) => {
const { name } = file
const extIndex = name.lastIndexOf('.')
let func
let type
if (extIndex >= 0 && (type = name.slice(extIndex + 1).toLowerCase()) && (func = FORMAT_TO_HANDLER[type])) {
push(parseFile(file, type, func))
}
})
)
this.setState({
vms: orderBy(vms, vm => [vm.error != null, vm.type, vm.file.name]),
})
}
_handleCleanSelectedVms = () => {
this.setState({
vms: [],
})
}
_handleSelectedPool = pool => {
if (pool === '') {
this.setState({
pool: undefined,
sr: undefined,
srPredicate: undefined,
})
} else {
this.setState({
pool,
sr: pool.default_SR,
srPredicate: sr => sr.$pool === this.state.pool.id && isSrWritable(sr),
})
}
}
_handleSelectedSr = sr => {
this.setState({
sr: sr === '' ? undefined : sr,
})
}
render() {
const { isFromUrl } = this.state
const { isFromUrl, pool, sr, srPredicate, type, url, vms } = this.state
return (
<Container>
@@ -31,7 +304,113 @@ export default class Import extends Component {
<p>
<Toggle value={isFromUrl} onChange={this.toggleState('isFromUrl')} /> {_('fromUrl')}
</p>
{RENDER_BY_TYPE[isFromUrl ? 'url' : 'xva']}
<FormGrid.Row>
<FormGrid.LabelCol>{_('vmImportToPool')}</FormGrid.LabelCol>
<FormGrid.InputCol>
<SelectPool value={pool} onChange={this._handleSelectedPool} required />
</FormGrid.InputCol>
</FormGrid.Row>
<FormGrid.Row>
<FormGrid.LabelCol>{_('vmImportToSr')}</FormGrid.LabelCol>
<FormGrid.InputCol>
<SelectSr
disabled={!pool}
onChange={this._handleSelectedSr}
predicate={srPredicate}
required
value={sr}
/>
</FormGrid.InputCol>
</FormGrid.Row>
{sr &&
(!isFromUrl ? (
<div>
<Dropzone onDrop={this._handleDrop} message={_('importVmsList')} />
<hr />
<h5>{_('vmsToImport')}</h5>
{vms.length > 0 ? (
<div>
{map(vms, ({ data, error, file, type }, vmIndex) => (
<div key={file.preview} className={styles.vmContainer}>
<strong>{file.name}</strong>
<span className='pull-right'>
<strong>{`(${formatSize(file.size)})`}</strong>
</span>
{!error ? (
data && (
<div>
<hr />
<div className='alert alert-info' role='alert'>
<strong>{_('vmImportFileType', { type })}</strong> {_('vmImportConfigAlert')}
</div>
<VmData {...data} ref={`vm-data-${vmIndex}`} pool={pool} />
</div>
)
) : (
<div>
<hr />
<div className='alert alert-danger' role='alert'>
<strong>{_('vmImportError')}</strong>{' '}
{(error && error.message) || _('noVmImportErrorDescription')}
</div>
</div>
)}
</div>
))}
</div>
) : (
<p>{_('noSelectedVms')}</p>
)}
<hr />
<div className='form-group pull-right'>
<ActionButton
btnStyle='primary'
disabled={!vms.length}
className='mr-1'
form='import-form'
handler={this._import}
icon='import'
redirectOnSuccess={getRedirectionUrl}
type='submit'
>
{_('newImport')}
</ActionButton>
<Button onClick={this._handleCleanSelectedVms}>{_('importVmsCleanList')}</Button>
</div>
</div>
) : (
<div>
<FormGrid.Row>
<FormGrid.LabelCol>{_('url')}</FormGrid.LabelCol>
<FormGrid.InputCol>
<Input
className='form-control'
onChange={this.linkState('url')}
placeholder='https://my-company.net/vm.xva'
type='url'
/>
</FormGrid.InputCol>
</FormGrid.Row>
<FormGrid.Row>
<FormGrid.LabelCol>{_('fileType')}</FormGrid.LabelCol>
<FormGrid.InputCol>
<Select onChange={this.linkState('type')} options={FILE_TYPES} required value={type} />
</FormGrid.InputCol>
</FormGrid.Row>
<ActionButton
btnStyle='primary'
className='mr-1 mt-1'
disabled={isEmpty(url)}
form='import-form'
handler={this._importVmFromUrl}
icon='import'
redirectOnSuccess={getRedirectionUrl}
type='submit'
>
{_('newImport')}
</ActionButton>
</div>
))}
</form>
</Container>
)

View File

@@ -1,113 +0,0 @@
import _ from 'intl'
import ActionButton from 'action-button'
import Button from 'button'
import decorate from 'apply-decorators'
import React from 'react'
import { importVm, isSrWritable } from 'xo'
import { injectState, provideState } from 'reaclette'
import { Input } from 'debounce-input-decorator'
import { InputCol, LabelCol, Row } from 'form-grid'
import { isEmpty } from 'lodash'
import { linkState } from 'reaclette-utils'
import { Select } from 'form'
import { SelectPool, SelectSr } from 'select-objects'
import { getRedirectionUrl } from './utils'
const FILE_TYPES = [
{
label: 'XVA',
value: 'xva',
},
]
const getInitialState = () => ({
pool: undefined,
sr: undefined,
type: {
label: 'XVA',
value: 'xva',
},
url: '',
})
const UrlImport = decorate([
provideState({
initialState: getInitialState,
effects: {
handleImport() {
const { type, url } = this.state
const file = {
name: decodeURIComponent(url.slice(url.lastIndexOf('/') + 1)),
}
importVm(file, type.value, undefined, this.state.sr, url)
},
linkState,
onChangePool: (_, pool) => ({ pool, sr: pool.default_SR }),
onChangeSr: (_, sr) => ({ sr }),
reset: getInitialState,
},
computed: {
srPredicate:
({ pool }) =>
sr =>
isSrWritable(sr) && sr.$poolId === pool?.uuid,
},
}),
injectState,
({
effects: { handleImport, linkState, onChangePool, onChangeSr, reset },
state: { pool, sr, srPredicate, type, url },
}) => (
<div>
<Row>
<LabelCol>{_('vmImportToPool')}</LabelCol>
<InputCol>
<SelectPool value={pool} onChange={onChangePool} required />
</InputCol>
</Row>
<Row>
<LabelCol>{_('vmImportToSr')}</LabelCol>
<InputCol>
<SelectSr disabled={pool === undefined} onChange={onChangeSr} predicate={srPredicate} required value={sr} />
</InputCol>
</Row>
<Row>
<LabelCol>{_('url')}</LabelCol>
<InputCol>
<Input
className='form-control'
name='url'
onChange={linkState}
placeholder='https://my-company.net/vm.xva'
type='url'
/>
</InputCol>
</Row>
<Row>
<LabelCol>{_('fileType')}</LabelCol>
<InputCol>
<Select name='type' onChange={linkState} options={FILE_TYPES} required value={type} />
</InputCol>
</Row>
<div className='form-group pull-right'>
<ActionButton
btnStyle='primary'
className='mr-1'
disabled={isEmpty(url)}
form='import-form'
handler={handleImport}
icon='import'
redirectOnSuccess={getRedirectionUrl}
type='submit'
>
{_('newImport')}
</ActionButton>
<Button onClick={reset}>{_('formReset')}</Button>
</div>
</div>
),
])
export default UrlImport

View File

@@ -1,8 +0,0 @@
import * as CM from 'complex-matcher'
const getRedirectionUrl = vms =>
vms.length === 0
? undefined // no redirect
: vms.length === 1
? `/vms/${vms[0]}`
: `/home?s=${encodeURIComponent(new CM.Property('id', new CM.Or(vms.map(_ => new CM.String(_)))).toString())}&t=VM`

View File

@@ -1,187 +0,0 @@
import _ from 'intl'
import decorate from 'apply-decorators'
import React from 'react'
import { Col, Row } from 'grid'
import { connectStore, formatSize } from 'utils'
import { createFinder, createGetObject, createGetObjectsOfType, createSelector } from 'selectors'
import { injectState, provideState } from 'reaclette'
import { map } from 'lodash'
import { SelectNetwork } from 'select-objects'
import { SizeInput } from 'form'
const VmData = decorate([
connectStore(() => {
const getHostMaster = createGetObject((_, props) => props.pool.master)
const getPifs = createGetObjectsOfType('PIF').pick((state, props) => getHostMaster(state, props).$PIFs)
const getDefaultNetworkId = createSelector(createFinder(getPifs, [pif => pif.management]), pif => pif.$network)
return {
defaultNetwork: getDefaultNetworkId,
}
}),
provideState({
effects: {
onChangeDisks(_, { target: { name, value } }) {
const { onChange, data: prevValue } = this.props
// name: nameLabel-index or descriptionLabel-index
const data = name.split('-')
const index = data[1]
onChange({
...prevValue,
disks: {
...prevValue.disks,
[index]: { ...prevValue.disks[index], [data[0]]: value },
},
})
},
onChangeMemory(_, memory) {
const { onChange, data: prevData } = this.props
onChange({
...prevData,
memory,
})
},
onChangeNCpus(_, { target: { value } }) {
const { onChange, data: prevData } = this.props
onChange({
...prevData,
nCpus: +value,
})
},
onChangeNetworks(_, network, networkIndex) {
const { onChange, data } = this.props
onChange({
...data,
networks: data.networks.map((prevNetwork, index) => (index === networkIndex ? network.id : prevNetwork)),
})
},
onChangeValue(_, { target: { name, value } }) {
const { onChange, data } = this.props
onChange({
...data,
[name]: value,
})
},
},
computed: {
networkPredicate:
(_, { pool }) =>
network =>
pool.id === network.$pool,
},
}),
injectState,
({
data,
defaultNetwork,
effects: { onChangeDisks, onChangeMemory, onChangeNCpus, onChangeNetworks, onChangeValue },
state: { networkPredicate },
}) => (
<div>
<Row>
<Col mediumSize={6}>
<div className='form-group'>
<label>{_('vmNameLabel')}</label>
<input
className='form-control'
name='nameLabel'
onChange={onChangeValue}
type='text'
required
value={data.nameLabel}
/>
</div>
<div className='form-group'>
<label>{_('vmNameDescription')}</label>
<input
className='form-control'
name='descriptionLabel'
onChange={onChangeValue}
type='text'
value={data.descriptionLabel}
/>
</div>
</Col>
<Col mediumSize={6}>
<div className='form-group'>
<label>{_('nCpus')}</label>
<input className='form-control' onChange={onChangeNCpus} type='number' required value={data.nCpus} />
</div>
<div className='form-group'>
<label>{_('vmMemory')}</label>
<SizeInput onChange={onChangeMemory} required value={data.memory} />
</div>
</Col>
</Row>
<Row>
<Col mediumSize={6}>
{map(data.disks, (disk, diskId) => (
<Row key={diskId}>
<Col mediumSize={6}>
<div className='form-group'>
<label>
{_('diskInfo', {
position: `${disk.position}`,
capacity: formatSize(disk.capacity),
})}
</label>
<input
className='form-control'
name={`nameLabel-${diskId}`}
onChange={onChangeDisks}
type='text'
required
value={disk.nameLabel}
/>
</div>
</Col>
<Col mediumSize={6}>
<div className='form-group'>
<label>{_('diskDescription')}</label>
<input
className='form-control'
name={`descriptionLabel-${diskId}`}
onChange={onChangeDisks}
type='text'
required
value={disk.descriptionLabel}
/>
</div>
</Col>
</Row>
))}
</Col>
<Col mediumSize={6}>
{map(data.networks, (networkId, index) => (
<div className='form-group' key={networkId}>
<label>{_('networkInfo', { name: index + 1 })}</label>
<SelectNetwork
onChange={network => onChangeNetworks(network, index)}
predicate={networkPredicate}
value={data.networks[index] ?? defaultNetwork}
/>
</div>
))}
</Col>
</Row>
{data.storage !== undefined && (
<Row className='mt-1'>
<Col mediumSize={12}>
<div className='form-group'>
<label>{_('homeSrPage')}:</label>{' '}
<span>
{_('vmSrUsage', {
free: formatSize(data.storage.free),
total: formatSize(data.storage.used + data.storage.free),
used: formatSize(data.storage.used),
})}
</span>
</div>
</Col>
</Row>
)}
</div>
),
])
export default VmData

View File

@@ -1,202 +0,0 @@
import _ from 'intl'
import ActionButton from 'action-button'
import Button from 'button'
import decorate from 'apply-decorators'
import Dropzone from 'dropzone'
import React from 'react'
import { createGetObjectsOfType } from 'selectors'
import { connectStore, formatSize, mapPlus, noop } from 'utils'
import { importVms, isSrWritable } from 'xo'
import { injectState, provideState } from 'reaclette'
import { InputCol, LabelCol, Row } from 'form-grid'
import { orderBy } from 'lodash'
import { SelectPool, SelectSr } from 'select-objects'
import parseOvaFile from './ova'
import styles from './index.css'
import VmData from './vm-data'
import { getRedirectionUrl } from './utils'
const FORMAT_TO_HANDLER = {
ova: parseOvaFile,
xva: noop,
}
const parseFile = async (file, type, func) => {
try {
return {
data: await func(file),
file,
type,
}
} catch (error) {
console.error(error)
return { error, file, type }
}
}
const getInitialState = () => ({
pool: undefined,
sr: undefined,
vms: [],
})
const XvaImport = decorate([
connectStore(() => ({
networksByName: createGetObjectsOfType('network').groupBy('name_label'),
})),
provideState({
initialState: getInitialState,
effects: {
handleImport:
() =>
({ sr, vms }) => {
importVms(
mapPlus(vms, (vm, push) => {
if (!vm.error) {
const { data } = vm
push(
data === undefined
? { ...vm }
: {
...vm,
data: {
...vm.data,
disks: Object.values(vm.data.disks),
},
}
)
}
}),
sr
)
},
onChangePool: (_, pool) => ({ pool, sr: pool.default_SR }),
onChangeSr: (_, sr) => ({ sr }),
onChangeVmData: (_, data, vmIndex) => state => {
const vms = [...state.vms]
vms[vmIndex].data = data
return { vms }
},
onDrop: (_, files) => async (_, props) => {
const vms = (
await Promise.all(
mapPlus(files, (file, push) => {
const { name } = file
const extIndex = name.lastIndexOf('.')
let func
let type
if (
extIndex >= 0 &&
(type = name.slice(extIndex + 1).toLowerCase()) &&
(func = FORMAT_TO_HANDLER[type])
) {
push(parseFile(file, type, func))
}
})
)
).map(vm => {
const { data } = vm
return data === undefined
? vm
: {
...vm,
data: {
...data,
networks: data.networks.map(name => props.networksByName[name][0].id),
},
}
})
return {
vms: orderBy(vms, vm => [vm.error != null, vm.type, vm.file.name]),
}
},
reset: getInitialState,
},
computed: {
srPredicate:
({ pool }) =>
sr =>
isSrWritable(sr) && sr.$poolId === pool?.uuid,
},
}),
injectState,
({
effects: { handleImport, onChangePool, onChangeSr, onChangeVmData, onDrop, reset },
state: { pool, sr, srPredicate, vms },
}) => (
<div>
<Row>
<LabelCol>{_('vmImportToPool')}</LabelCol>
<InputCol>
<SelectPool value={pool} onChange={onChangePool} required />
</InputCol>
</Row>
<Row>
<LabelCol>{_('vmImportToSr')}</LabelCol>
<InputCol>
<SelectSr disabled={pool === undefined} onChange={onChangeSr} predicate={srPredicate} required value={sr} />
</InputCol>
</Row>
<div>
<Dropzone onDrop={onDrop} message={_('importVmsList')} />
<hr />
<h5>{_('vmsToImport', { nVms: vms.length })}</h5>
{vms.length > 0 ? (
<div>
{vms.map(({ data, error, file, type }, vmIndex) => (
<div key={file.preview} className={styles.vmContainer}>
<strong>{file.name}</strong>
<span className='pull-right'>
<strong>{`(${formatSize(file.size)})`}</strong>
</span>
{!error ? (
data && (
<div>
<hr />
<div className='alert alert-info' role='alert'>
<strong>{_('vmImportFileType', { type })}</strong> {_('vmImportConfigAlert')}
</div>
<VmData data={data} onChange={data => onChangeVmData(data, vmIndex)} pool={pool} />
</div>
)
) : (
<div>
<hr />
<div className='alert alert-danger' role='alert'>
<strong>{_('vmImportError')}</strong>{' '}
{(error && error.message) || _('noVmImportErrorDescription')}
</div>
</div>
)}
</div>
))}
</div>
) : (
<p>{_('noSelectedVms')}</p>
)}
<hr />
<div className='form-group pull-right'>
<ActionButton
btnStyle='primary'
disabled={vms.length === 0}
className='mr-1'
form='import-form'
handler={handleImport}
icon='import'
redirectOnSuccess={getRedirectionUrl}
type='submit'
>
{_('newImport')}
</ActionButton>
<Button onClick={reset}>{_('importVmsCleanList')}</Button>
</div>
</div>
</div>
),
])
export default XvaImport

2679
yarn.lock

File diff suppressed because it is too large Load Diff