Compare commits
37 Commits
fix-refs-b
...
cleanup_vm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a15428ac88 | ||
|
|
85a23c68f2 | ||
|
|
c16c1f8eb9 | ||
|
|
8af95b41fd | ||
|
|
d0e3603663 | ||
|
|
2e755ec083 | ||
|
|
724195d66d | ||
|
|
b132ff4fd0 | ||
|
|
6f1054e2d1 | ||
|
|
60c59a0529 | ||
|
|
d382f262fd | ||
|
|
f6baef3bd6 | ||
|
|
4a27fd35bf | ||
|
|
edd37be295 | ||
|
|
e38f00c18b | ||
|
|
24b08037f9 | ||
|
|
1d9bc390bb | ||
|
|
44ba19990e | ||
|
|
5571a1c262 | ||
|
|
9617241b6d | ||
|
|
4b5eadcf88 | ||
|
|
c76295e5c9 | ||
|
|
b61ab4c79a | ||
|
|
2d01192204 | ||
|
|
eb6763b0bb | ||
|
|
2bb935e9ca | ||
|
|
1e72e9d749 | ||
|
|
59700834cc | ||
|
|
95d6ed0376 | ||
|
|
5dfc8b2e0a | ||
|
|
6961361cf8 | ||
|
|
c105057b91 | ||
|
|
29b20753e9 | ||
|
|
f0b93dc7fe | ||
|
|
dd2b054b35 | ||
|
|
bc09387f5e | ||
|
|
6e8e725a94 |
@@ -16,7 +16,6 @@ const {
|
||||
NBD_REPLY_MAGIC,
|
||||
NBD_REQUEST_MAGIC,
|
||||
OPTS_MAGIC,
|
||||
NBD_CMD_DISC,
|
||||
} = require('./constants.js')
|
||||
const { fromCallback } = require('promise-toolbox')
|
||||
const { readChunkStrict } = require('@vates/read-chunk')
|
||||
@@ -90,11 +89,6 @@ module.exports = class NbdClient {
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
const buffer = Buffer.alloc(28)
|
||||
buffer.writeInt32BE(NBD_REQUEST_MAGIC, 0) // it is a nbd request
|
||||
buffer.writeInt16BE(0, 4) // no command flags for a disconnect
|
||||
buffer.writeInt16BE(NBD_CMD_DISC, 6) // we want to disconnect from nbd server
|
||||
await this.#write(buffer)
|
||||
await this.#serverSocket.destroy()
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.0",
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
},
|
||||
@@ -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.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tap": "^16.3.0",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.29.6",
|
||||
"@xen-orchestra/backups": "^0.29.5",
|
||||
"@xen-orchestra/fs": "^3.3.1",
|
||||
"filenamify": "^4.1.0",
|
||||
"getopts": "^2.2.5",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
const { finished, PassThrough } = require('node:stream')
|
||||
const eos = require('end-of-stream')
|
||||
const { PassThrough } = require('stream')
|
||||
|
||||
const { debug } = require('@xen-orchestra/log').createLogger('xo:backups:forkStreamUnpipe')
|
||||
|
||||
@@ -8,29 +9,29 @@ const { debug } = require('@xen-orchestra/log').createLogger('xo:backups:forkStr
|
||||
//
|
||||
// in case of error in the new readable stream, it will simply be unpiped
|
||||
// from the original one
|
||||
exports.forkStreamUnpipe = function forkStreamUnpipe(source) {
|
||||
const { forks = 0 } = source
|
||||
source.forks = forks + 1
|
||||
exports.forkStreamUnpipe = function forkStreamUnpipe(stream) {
|
||||
const { forks = 0 } = stream
|
||||
stream.forks = forks + 1
|
||||
|
||||
debug('forking', { forks: source.forks })
|
||||
debug('forking', { forks: stream.forks })
|
||||
|
||||
const fork = new PassThrough()
|
||||
source.pipe(fork)
|
||||
finished(source, { writable: false }, error => {
|
||||
const proxy = new PassThrough()
|
||||
stream.pipe(proxy)
|
||||
eos(stream, error => {
|
||||
if (error !== undefined) {
|
||||
debug('error on original stream, destroying fork', { error })
|
||||
fork.destroy(error)
|
||||
proxy.destroy(error)
|
||||
}
|
||||
})
|
||||
finished(fork, { readable: false }, error => {
|
||||
debug('end of stream, unpiping', { error, forks: --source.forks })
|
||||
eos(proxy, error => {
|
||||
debug('end of stream, unpiping', { error, forks: --stream.forks })
|
||||
|
||||
source.unpipe(fork)
|
||||
stream.unpipe(proxy)
|
||||
|
||||
if (source.forks === 0) {
|
||||
if (stream.forks === 0) {
|
||||
debug('no more forks, destroying original stream')
|
||||
source.destroy(new Error('no more consumers for this stream'))
|
||||
stream.destroy(new Error('no more consumers for this stream'))
|
||||
}
|
||||
})
|
||||
return fork
|
||||
return proxy
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "0.29.6",
|
||||
"version": "0.29.5",
|
||||
"engines": {
|
||||
"node": ">=14.6"
|
||||
},
|
||||
@@ -23,7 +23,7 @@
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@vates/disposable": "^0.1.4",
|
||||
"@vates/fuse-vhd": "^1.0.0",
|
||||
"@vates/nbd-client": "^1.0.1",
|
||||
"@vates/nbd-client": "*",
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/fs": "^3.3.1",
|
||||
@@ -32,6 +32,7 @@
|
||||
"compare-versions": "^5.0.1",
|
||||
"d3-time-format": "^3.0.0",
|
||||
"decorator-synchronized": "^0.6.0",
|
||||
"end-of-stream": "^1.4.4",
|
||||
"fs-extra": "^11.1.0",
|
||||
"golike-defer": "^0.5.1",
|
||||
"limit-concurrency-decorator": "^0.5.0",
|
||||
|
||||
@@ -7,8 +7,6 @@ const ignoreErrors = require('promise-toolbox/ignoreErrors')
|
||||
const { asyncMap } = require('@xen-orchestra/async-map')
|
||||
const { chainVhd, checkVhdChain, openVhd, VhdAbstract } = require('vhd-lib')
|
||||
const { createLogger } = require('@xen-orchestra/log')
|
||||
const { decorateClass } = require('@vates/decorate-with')
|
||||
const { defer } = require('golike-defer')
|
||||
const { dirname } = require('path')
|
||||
|
||||
const { formatFilenameDate } = require('../_filenameDate.js')
|
||||
@@ -24,7 +22,7 @@ const NbdClient = require('@vates/nbd-client')
|
||||
|
||||
const { debug, warn, info } = createLogger('xo:backups:DeltaBackupWriter')
|
||||
|
||||
class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
|
||||
exports.DeltaBackupWriter = class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
|
||||
async checkBaseVdis(baseUuidToSrcVdi) {
|
||||
const { handler } = this._adapter
|
||||
const backup = this._backup
|
||||
@@ -135,7 +133,7 @@ class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
|
||||
}
|
||||
}
|
||||
|
||||
async _transfer($defer, { timestamp, deltaExport }) {
|
||||
async _transfer({ timestamp, deltaExport }) {
|
||||
const adapter = this._adapter
|
||||
const backup = this._backup
|
||||
|
||||
@@ -212,11 +210,6 @@ class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
|
||||
debug('got NBD info', { nbdInfo, vdi: id, path })
|
||||
nbdClient = new NbdClient(nbdInfo)
|
||||
await nbdClient.connect()
|
||||
|
||||
// this will inform the xapi that we don't need this anymore
|
||||
// and will detach the vdi from dom0
|
||||
$defer(() => nbdClient.disconnect())
|
||||
|
||||
info('NBD client ready', { vdi: id, path })
|
||||
} catch (error) {
|
||||
nbdClient = undefined
|
||||
@@ -255,6 +248,3 @@ class DeltaBackupWriter extends MixinBackupWriter(AbstractDeltaWriter) {
|
||||
// TODO: run cleanup?
|
||||
}
|
||||
}
|
||||
exports.DeltaBackupWriter = decorateClass(DeltaBackupWriter, {
|
||||
_transfer: defer,
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"preferGlobal": true,
|
||||
"dependencies": {
|
||||
"golike-defer": "^0.5.1",
|
||||
"xen-api": "^1.2.5"
|
||||
"xen-api": "^1.2.2"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -7,7 +7,7 @@
|
||||
@remove="emit('removeSort', property)"
|
||||
>
|
||||
<span class="property">
|
||||
<UiIcon :icon="isAscending ? faCaretUp : faCaretDown" />
|
||||
<FontAwesomeIcon :icon="isAscending ? faCaretUp : faCaretDown" />
|
||||
{{ property }}
|
||||
</span>
|
||||
</UiFilter>
|
||||
@@ -17,7 +17,7 @@
|
||||
</UiActionButton>
|
||||
</UiFilterGroup>
|
||||
|
||||
<UiModal v-if="isOpen" :icon="faSort" @submit.prevent="handleSubmit">
|
||||
<UiModal v-if="isOpen" @submit.prevent="handleSubmit" :icon="faSort">
|
||||
<div class="form-widgets">
|
||||
<FormWidget :label="$t('sort-by')">
|
||||
<select v-model="newSortProperty">
|
||||
@@ -48,14 +48,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import FormWidget from "@/components/FormWidget.vue";
|
||||
import UiActionButton from "@/components/ui/UiActionButton.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import UiFilter from "@/components/ui/UiFilter.vue";
|
||||
import UiFilterGroup from "@/components/ui/UiFilterGroup.vue";
|
||||
import UiIcon from "@/components/ui/UiIcon.vue";
|
||||
import UiModal from "@/components/ui/UiModal.vue";
|
||||
import useModal from "@/composables/modal.composable";
|
||||
import { ref } from "vue";
|
||||
import type { ActiveSorts, Sorts } from "@/types/sort";
|
||||
import {
|
||||
faCaretDown,
|
||||
@@ -63,7 +56,13 @@ import {
|
||||
faPlus,
|
||||
faSort,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { ref } from "vue";
|
||||
import FormWidget from "@/components/FormWidget.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import UiActionButton from "@/components/ui/UiActionButton.vue";
|
||||
import UiFilter from "@/components/ui/UiFilter.vue";
|
||||
import UiFilterGroup from "@/components/ui/UiFilterGroup.vue";
|
||||
import UiModal from "@/components/ui/UiModal.vue";
|
||||
import useModal from "@/composables/modal.composable";
|
||||
|
||||
defineProps<{
|
||||
availableSorts: Sorts;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<th>
|
||||
<div class="content">
|
||||
<span class="label">
|
||||
<UiIcon :icon="icon" />
|
||||
<FontAwesomeIcon v-if="icon" :icon="icon" />
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
@@ -10,7 +10,6 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from "@/components/ui/UiIcon.vue";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<span class="widget">
|
||||
<span v-if="before || $slots.before" class="before">
|
||||
<slot name="before">
|
||||
<UiIcon v-if="isIcon(before)" :icon="before" fixed-width />
|
||||
<FontAwesomeIcon v-if="isIcon(before)" :icon="before" fixed-width />
|
||||
<template v-else>{{ before }}</template>
|
||||
</slot>
|
||||
</span>
|
||||
@@ -17,7 +17,7 @@
|
||||
</span>
|
||||
<span v-if="after || $slots.after" class="after">
|
||||
<slot name="after">
|
||||
<UiIcon v-if="isIcon(after)" :icon="after" fixed-width />
|
||||
<FontAwesomeIcon v-if="isIcon(after)" :icon="after" fixed-width />
|
||||
<template v-else>{{ after }}</template>
|
||||
</slot>
|
||||
</span>
|
||||
@@ -26,7 +26,6 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from "@/components/ui/UiIcon.vue";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<template>
|
||||
<UiIcon :class="className" :icon="icon" class="power-state-icon" />
|
||||
<FontAwesomeIcon class="power-state-icon" :class="className" :icon="icon" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from "@/components/ui/UiIcon.vue";
|
||||
import type { PowerState } from "@/libs/xen-api";
|
||||
import { computed } from "vue";
|
||||
import {
|
||||
faMoon,
|
||||
faPause,
|
||||
@@ -12,7 +11,7 @@ import {
|
||||
faQuestion,
|
||||
faStop,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { computed } from "vue";
|
||||
import type { PowerState } from "@/libs/xen-api";
|
||||
|
||||
const props = defineProps<{
|
||||
state: PowerState;
|
||||
@@ -30,7 +29,7 @@ const icon = computed(() => icons[props.state] ?? faQuestion);
|
||||
const className = computed(() => `state-${props.state.toLocaleLowerCase()}`);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
<style scoped lang="postcss">
|
||||
.power-state-icon {
|
||||
color: var(--color-extra-blue-d60);
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
54
@xen-orchestra/lite/src/components/TabBarItem.vue
Normal file
54
@xen-orchestra/lite/src/components/TabBarItem.vue
Normal 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>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="title-bar">
|
||||
<UiIcon :icon="icon" class="icon" />
|
||||
<FontAwesomeIcon :icon="icon" class="icon" />
|
||||
<div class="title">
|
||||
<slot />
|
||||
</div>
|
||||
@@ -11,7 +11,6 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from "@/components/ui/UiIcon.vue";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject } from "vue";
|
||||
import { percent } from "@/libs/utils";
|
||||
import { computed, type ComputedRef, inject } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
total: number;
|
||||
@@ -34,9 +34,7 @@ const freePercent = computed(() =>
|
||||
percent(props.total - props.used, props.total)
|
||||
);
|
||||
|
||||
const valueFormatter = inject("valueFormatter") as ComputedRef<
|
||||
(value: number) => string
|
||||
>;
|
||||
const valueFormatter = inject("valueFormatter") as (value: number) => string;
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
@@ -33,17 +33,8 @@ const props = defineProps<{
|
||||
maxValue?: number;
|
||||
}>();
|
||||
|
||||
const valueFormatter = computed(() => {
|
||||
const formatter = props.valueFormatter;
|
||||
|
||||
return (value: OptionDataValue | OptionDataValue[]) => {
|
||||
if (formatter) {
|
||||
return formatter(value as number);
|
||||
}
|
||||
|
||||
return value.toString();
|
||||
};
|
||||
});
|
||||
const valueFormatter = (value: OptionDataValue | OptionDataValue[]) =>
|
||||
props.valueFormatter?.(value as number) ?? `${value}`;
|
||||
|
||||
provide("valueFormatter", valueFormatter);
|
||||
|
||||
@@ -65,7 +56,7 @@ const option = computed<EChartsOption>(() => ({
|
||||
data: props.data.map((series) => series.label),
|
||||
},
|
||||
tooltip: {
|
||||
valueFormatter: valueFormatter.value,
|
||||
valueFormatter,
|
||||
},
|
||||
xAxis: {
|
||||
type: "time",
|
||||
@@ -79,9 +70,9 @@ const option = computed<EChartsOption>(() => ({
|
||||
yAxis: {
|
||||
type: "value",
|
||||
axisLabel: {
|
||||
formatter: valueFormatter.value,
|
||||
formatter: valueFormatter,
|
||||
},
|
||||
max: props.maxValue ?? Y_AXIS_MAX_VALUE,
|
||||
max: () => props.maxValue ?? Y_AXIS_MAX_VALUE,
|
||||
},
|
||||
series: props.data.map((series, index) => ({
|
||||
type: "line",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,13 +1,12 @@
|
||||
<template>
|
||||
<div class="infra-action">
|
||||
<slot>
|
||||
<UiIcon :icon="icon" fixed-width />
|
||||
<FontAwesomeIcon :icon="icon" fixed-width />
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from "@/components/ui/UiIcon.vue";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<a :href="href" class="link" @click="navigate">
|
||||
<UiIcon :icon="icon" class="icon" />
|
||||
<FontAwesomeIcon :icon="icon" class="icon" />
|
||||
<div class="text">
|
||||
<slot />
|
||||
</div>
|
||||
@@ -21,9 +21,8 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from "@/components/ui/UiIcon.vue";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
import type { RouteLocationRaw } from "vue-router";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
|
||||
defineProps<{
|
||||
icon: IconDefinition;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<li class="infra-loading-item">
|
||||
<div class="infra-item-label-placeholder">
|
||||
<div class="link-placeholder">
|
||||
<UiIcon :icon="icon" class="icon" />
|
||||
<FontAwesomeIcon :icon="icon" class="icon" />
|
||||
<div class="loader"> </div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -10,7 +10,6 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from "@/components/ui/UiIcon.vue";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<template>
|
||||
<tr class="finished-task-row" :class="{ finished: !isPending }">
|
||||
<td>{{ task.name_label }}</td>
|
||||
<td>
|
||||
<RouterLink
|
||||
:to="{
|
||||
name: 'host.dashboard',
|
||||
params: { uuid: host.uuid },
|
||||
}"
|
||||
>
|
||||
{{ host.name_label }}
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td>
|
||||
<UiProgressBar v-if="isPending" :max-value="1" :value="task.progress" />
|
||||
</td>
|
||||
<td>
|
||||
<RelativeTime v-if="isPending" :date="createdAt" />
|
||||
<template v-else>{{ $d(createdAt, "datetime_short") }}</template>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="finishedAt !== undefined">
|
||||
{{ $d(finishedAt, "datetime_short") }}
|
||||
</template>
|
||||
<RelativeTime
|
||||
v-else-if="isPending && estimatedEndAt !== Infinity"
|
||||
:date="estimatedEndAt"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import RelativeTime from "@/components/RelativeTime.vue";
|
||||
import UiProgressBar from "@/components/ui/UiProgressBar.vue";
|
||||
import { parseDateTime } from "@/libs/utils";
|
||||
import type { XenApiTask } from "@/libs/xen-api";
|
||||
import { useHostStore } from "@/stores/host.store";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
isPending?: boolean;
|
||||
task: XenApiTask;
|
||||
}>();
|
||||
|
||||
const { getRecord: getHost } = useHostStore();
|
||||
|
||||
const createdAt = computed(() => parseDateTime(props.task.created));
|
||||
|
||||
const host = computed(() => getHost(props.task.resident_on));
|
||||
|
||||
const estimatedEndAt = computed(
|
||||
() =>
|
||||
createdAt.value +
|
||||
(new Date().getTime() - createdAt.value) / props.task.progress
|
||||
);
|
||||
|
||||
const finishedAt = computed(() =>
|
||||
props.isPending ? undefined : parseDateTime(props.task.finished)
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.finished {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,32 +0,0 @@
|
||||
<template>
|
||||
<UiTable class="tasks-table">
|
||||
<template #header>
|
||||
<th>{{ $t("name") }}</th>
|
||||
<th>{{ $t("object") }}</th>
|
||||
<th>{{ $t("task.progress") }}</th>
|
||||
<th>{{ $t("task.started") }}</th>
|
||||
<th>{{ $t("task.estimated-end") }}</th>
|
||||
</template>
|
||||
|
||||
<TaskRow
|
||||
v-for="task in pendingTasks"
|
||||
:key="task.uuid"
|
||||
:task="task"
|
||||
is-pending
|
||||
/>
|
||||
<TaskRow v-for="task in finishedTasks" :key="task.uuid" :task="task" />
|
||||
</UiTable>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import TaskRow from "@/components/tasks/TaskRow.vue";
|
||||
import UiTable from "@/components/ui/UiTable.vue";
|
||||
import type { XenApiTask } from "@/libs/xen-api";
|
||||
|
||||
defineProps<{
|
||||
pendingTasks: XenApiTask[];
|
||||
finishedTasks: XenApiTask[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped></style>
|
||||
@@ -5,7 +5,7 @@
|
||||
:type="type || 'button'"
|
||||
class="ui-button"
|
||||
>
|
||||
<UiSpinner v-if="isBusy" />
|
||||
<span v-if="isBusy" class="loader" />
|
||||
<template v-else>
|
||||
<UiIcon :icon="icon" class="icon" />
|
||||
<slot />
|
||||
@@ -14,7 +14,6 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
||||
import { computed, inject, unref } from "vue";
|
||||
import type { Color } from "@/types";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<template>
|
||||
<span :class="color" class="ui-counter">
|
||||
<span :class="{ overflow: value > 99 }">
|
||||
{{ value }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Color } from "@/types";
|
||||
|
||||
defineProps<{
|
||||
value: number;
|
||||
color?: Color;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-counter {
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
color: var(--color-blue-scale-500);
|
||||
border-radius: calc(var(--size) / 2);
|
||||
background-color: var(--color-blue-scale-300);
|
||||
--size: 1.75em;
|
||||
|
||||
.overflow {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
&.info {
|
||||
background-color: var(--color-extra-blue-base);
|
||||
}
|
||||
|
||||
&.success {
|
||||
background-color: var(--color-green-infra-base);
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background-color: var(--color-orange-world-base);
|
||||
}
|
||||
|
||||
&.error {
|
||||
background-color: var(--color-red-vates-base);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,13 +4,12 @@
|
||||
<slot />
|
||||
</span>
|
||||
<span class="remove" @click.stop="emit('remove')">
|
||||
<UiIcon :icon="faRemove" />
|
||||
<FontAwesomeIcon :icon="faRemove" class="icon" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from "@/components/ui/UiIcon.vue";
|
||||
import { faRemove } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -40,7 +39,6 @@ const emit = defineEmits<{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -56,7 +54,6 @@ const emit = defineEmits<{
|
||||
width: 2.8rem;
|
||||
margin: 0.2rem;
|
||||
background-color: var(--color-extra-blue-l40);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-red-vates-l20);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<template>
|
||||
<UiSpinner v-if="busy" class="ui-icon" />
|
||||
<FontAwesomeIcon
|
||||
v-else-if="icon !== undefined"
|
||||
v-if="icon !== undefined"
|
||||
:icon="icon"
|
||||
:spin="busy"
|
||||
class="ui-icon"
|
||||
:fixed-width="fixedWidth"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
||||
import { computed } from "vue";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
busy?: boolean;
|
||||
icon?: IconDefinition;
|
||||
@@ -21,6 +21,8 @@ withDefaults(
|
||||
}>(),
|
||||
{ fixedWidth: true }
|
||||
);
|
||||
|
||||
const icon = computed(() => (props.busy ? faSpinner : props.icon));
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped></style>
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
>
|
||||
<div class="container">
|
||||
<span v-if="onClose" class="close-icon" @click="emit('close')">
|
||||
<UiIcon :icon="faXmark" />
|
||||
<FontAwesomeIcon :icon="faXmark" />
|
||||
</span>
|
||||
<div v-if="icon || $slots.icon" class="modal-icon">
|
||||
<slot name="icon">
|
||||
<UiIcon :icon="icon" />
|
||||
<FontAwesomeIcon :icon="icon" />
|
||||
</slot>
|
||||
</div>
|
||||
<UiTitle v-if="$slots.title" type="h4">
|
||||
@@ -34,7 +34,6 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
|
||||
import UiIcon from "@/components/ui/UiIcon.vue";
|
||||
import UiTitle from "@/components/ui/UiTitle.vue";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
@@ -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>
|
||||
@@ -18,8 +18,8 @@
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
:deep(th),
|
||||
:deep(td) {
|
||||
:slotted(th),
|
||||
:slotted(td) {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid lightgrey;
|
||||
border-right: 1px solid lightgrey;
|
||||
@@ -30,14 +30,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.header-row th) {
|
||||
:slotted(.header-row th) {
|
||||
color: var(--color-extra-blue-base);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 400;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
:deep(.body td) {
|
||||
:slotted(.body td) {
|
||||
font-weight: 400;
|
||||
font-size: 1.6rem;
|
||||
line-height: 2.4rem;
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import type { MaybeRef } from "@vueuse/core";
|
||||
import { differenceBy } from "lodash-es";
|
||||
import { type Ref, ref, unref, watch } from "vue";
|
||||
|
||||
export default function useArrayRemovedItemsHistory<T>(
|
||||
list: Ref<T[]>,
|
||||
iteratee: (item: T) => unknown = (item) => item,
|
||||
options: {
|
||||
limit?: MaybeRef<number>;
|
||||
onRemove?: (items: T[]) => any[];
|
||||
} = {}
|
||||
limit = Infinity,
|
||||
iteratee: (item: T) => unknown = (item) => item
|
||||
) {
|
||||
const currentList: Ref<T[]> = ref([]);
|
||||
const history: Ref<T[]> = ref([]);
|
||||
const { limit = Infinity, onRemove = (items) => items } = options;
|
||||
|
||||
watch(
|
||||
list,
|
||||
@@ -24,10 +19,10 @@ export default function useArrayRemovedItemsHistory<T>(
|
||||
|
||||
watch(currentList, (nextList, previousList) => {
|
||||
const removedItems = differenceBy(previousList, nextList, iteratee);
|
||||
history.value.push(...onRemove(removedItems));
|
||||
history.value.push(...removedItems);
|
||||
const currentLimit = unref(limit);
|
||||
if (history.value.length > currentLimit) {
|
||||
history.value = history.value.slice(-currentLimit);
|
||||
history.value.slice(-currentLimit);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -56,7 +56,6 @@ export function getFilterIcon(filter: Filter | undefined) {
|
||||
}
|
||||
|
||||
export function parseDateTime(dateTime: string) {
|
||||
dateTime = dateTime.replace(/(-|\.\d{3})/g, ""); // Allow toISOString() date-time format
|
||||
const date = utcParse("%Y%m%dT%H:%M:%SZ")(dateTime);
|
||||
if (date === null) {
|
||||
throw new RangeError(
|
||||
|
||||
@@ -115,15 +115,6 @@ export type XenApiVmMetrics = XenApiRecord;
|
||||
|
||||
export type XenApiVmGuestMetrics = XenApiRecord;
|
||||
|
||||
export interface XenApiTask extends XenApiRecord {
|
||||
name_label: string;
|
||||
resident_on: string;
|
||||
created: string;
|
||||
finished: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
type WatchCallbackResult = {
|
||||
id: string;
|
||||
class: ObjectType;
|
||||
|
||||
@@ -36,14 +36,12 @@
|
||||
"log-out": "Log out",
|
||||
"login": "Login",
|
||||
"migrate": "Migrate",
|
||||
"name": "Name",
|
||||
"network": "Network",
|
||||
"network-download": "Download",
|
||||
"network-throughput": "Network throughput",
|
||||
"network-upload": "Upload",
|
||||
"news": "News",
|
||||
"news-name": "{name} news",
|
||||
"object": "Object",
|
||||
"object-not-found": "Object {id} can't be found…",
|
||||
"or": "Or",
|
||||
"page-not-found": "This page is not to be found…",
|
||||
@@ -55,7 +53,6 @@
|
||||
"property": "Property",
|
||||
"ram-usage": "RAM usage",
|
||||
"reboot": "Reboot",
|
||||
"reformat": "Reformat",
|
||||
"relative-time": {
|
||||
"day": "1 day | {n} days",
|
||||
"future": "In {str}",
|
||||
@@ -68,7 +65,6 @@
|
||||
"year": "1 year | {n} years"
|
||||
},
|
||||
"resume": "Resume",
|
||||
"save": "Save",
|
||||
"send-us-feedback": "Send us feedback",
|
||||
"settings": "Settings",
|
||||
"shutdown": "Shutdown",
|
||||
@@ -85,12 +81,6 @@
|
||||
"suspend": "Suspend",
|
||||
"switch-theme": "Switch theme",
|
||||
"system": "System",
|
||||
"task": {
|
||||
"estimated-end": "Estimated end",
|
||||
"page-title": "Tasks | (1) Tasks | ({n}) Tasks",
|
||||
"progress": "Progress",
|
||||
"started": "Started"
|
||||
},
|
||||
"tasks": "Tasks",
|
||||
"theme-auto": "Auto",
|
||||
"theme-dark": "Dark",
|
||||
|
||||
@@ -36,14 +36,12 @@
|
||||
"log-out": "Se déconnecter",
|
||||
"login": "Connexion",
|
||||
"migrate": "Migrer",
|
||||
"name": "Nom",
|
||||
"network": "Réseau",
|
||||
"network-download": "Descendant",
|
||||
"network-throughput": "Débit du réseau",
|
||||
"network-upload": "Montant",
|
||||
"news": "Actualités",
|
||||
"news-name": "Actualités {name}",
|
||||
"object": "Objet",
|
||||
"object-not-found": "L'objet {id} est introuvable…",
|
||||
"or": "Ou",
|
||||
"page-not-found": "Cette page est introuvable…",
|
||||
@@ -55,7 +53,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 +65,6 @@
|
||||
"year": "1 an | {n} ans"
|
||||
},
|
||||
"resume": "Reprendre",
|
||||
"save": "Enregistrer",
|
||||
"send-us-feedback": "Envoyez-nous vos commentaires",
|
||||
"settings": "Paramètres",
|
||||
"shutdown": "Arrêter",
|
||||
@@ -85,12 +81,6 @@
|
||||
"suspend": "Suspendre",
|
||||
"switch-theme": "Changer de thème",
|
||||
"system": "Système",
|
||||
"task": {
|
||||
"estimated-end": "Fin estimée",
|
||||
"page-title": "Tâches | (1) Tâches | ({n}) Tâches",
|
||||
"progress": "Progression",
|
||||
"started": "Démarré"
|
||||
},
|
||||
"tasks": "Tâches",
|
||||
"theme-auto": "Auto",
|
||||
"theme-dark": "Sombre",
|
||||
|
||||
@@ -3,11 +3,13 @@ import { createApp } from "vue";
|
||||
import App from "@/App.vue";
|
||||
import i18n from "@/i18n";
|
||||
import router from "@/router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(i18n);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.component("FontAwesomeIcon", FontAwesomeIcon);
|
||||
|
||||
app.mount("#root");
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineStore } from "pinia";
|
||||
import type { XenApiTask } from "@/libs/xen-api";
|
||||
import { createRecordContext } from "@/stores/index";
|
||||
|
||||
export const useTaskStore = defineStore("task", () =>
|
||||
createRecordContext<XenApiTask>("task")
|
||||
);
|
||||
@@ -10,7 +10,6 @@ import { useHostStore } from "@/stores/host.store";
|
||||
import { usePoolStore } from "@/stores/pool.store";
|
||||
import { useRecordsStore } from "@/stores/records.store";
|
||||
import { useSrStore } from "@/stores/storage.store";
|
||||
import { useTaskStore } from "@/stores/task.store";
|
||||
import { useVmGuestMetricsStore } from "@/stores/vm-guest-metrics.store";
|
||||
import { useVmMetricsStore } from "@/stores/vm-metrics.store";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
@@ -87,9 +86,6 @@ export const useXenApiStore = defineStore("xen-api", () => {
|
||||
srStore.init(),
|
||||
]);
|
||||
|
||||
const taskStore = useTaskStore();
|
||||
taskStore.init();
|
||||
|
||||
const consoleStore = useConsoleStore();
|
||||
consoleStore.init();
|
||||
}
|
||||
|
||||
@@ -126,7 +126,6 @@ onMounted(() => {
|
||||
.item {
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
|
||||
@@ -1,82 +1 @@
|
||||
<template>
|
||||
<UiCard>
|
||||
<UiTitle class="title-with-counter" type="h4">
|
||||
{{ $t("tasks") }}
|
||||
<UiCounter :value="pendingTasks.length" color="info" />
|
||||
</UiTitle>
|
||||
|
||||
<TasksTable :pending-tasks="pendingTasks" :finished-tasks="finishedTasks" />
|
||||
<UiSpinner class="loader" v-if="!isReady" />
|
||||
</UiCard>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import TasksTable from "@/components/tasks/TasksTable.vue";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiCounter from "@/components/ui/UiCounter.vue";
|
||||
import UiSpinner from "@/components/ui/UiSpinner.vue";
|
||||
import UiTitle from "@/components/ui/UiTitle.vue";
|
||||
import useArrayRemovedItemsHistory from "@/composables/array-removed-items-history.composable";
|
||||
import useCollectionFilter from "@/composables/collection-filter.composable";
|
||||
import useCollectionSorter from "@/composables/collection-sorter.composable";
|
||||
import useFilteredCollection from "@/composables/filtered-collection.composable";
|
||||
import useSortedCollection from "@/composables/sorted-collection.composable";
|
||||
import type { XenApiTask } from "@/libs/xen-api";
|
||||
import { useTaskStore } from "@/stores/task.store";
|
||||
import { useTitle } from "@vueuse/core";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { allRecords, isReady } = storeToRefs(useTaskStore());
|
||||
const { t } = useI18n();
|
||||
|
||||
const { compareFn } = useCollectionSorter<XenApiTask>({
|
||||
initialSorts: ["-created"],
|
||||
});
|
||||
|
||||
const allTasks = useSortedCollection(allRecords, compareFn);
|
||||
|
||||
const { predicate } = useCollectionFilter({
|
||||
initialFilters: ["!name_label:|(SR.scan host.call_plugin)", "status:pending"],
|
||||
});
|
||||
|
||||
const pendingTasks = useFilteredCollection<XenApiTask>(allTasks, predicate);
|
||||
|
||||
const finishedTasks = useArrayRemovedItemsHistory(
|
||||
allTasks,
|
||||
(task) => task.uuid,
|
||||
{
|
||||
limit: 50,
|
||||
onRemove: (tasks) =>
|
||||
tasks.map((task) => ({
|
||||
...task,
|
||||
finished: new Date().toISOString(),
|
||||
})),
|
||||
}
|
||||
);
|
||||
|
||||
useTitle(
|
||||
computed(() => t("task.page-title", { n: pendingTasks.value.length }))
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.title-with-counter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
gap: 0.5rem;
|
||||
|
||||
.ui-counter {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.loader {
|
||||
color: var(--color-extra-blue-base);
|
||||
display: block;
|
||||
font-size: 4rem;
|
||||
margin: 2rem auto 0;
|
||||
}
|
||||
</style>
|
||||
<template>Tasks (coming soon)</template>
|
||||
|
||||
@@ -67,44 +67,38 @@ ${pkg.name} v${pkg.version}`
|
||||
// sequence path of the current call
|
||||
const callPath = []
|
||||
|
||||
let url
|
||||
let { token } = opts
|
||||
if (opts.url !== '') {
|
||||
url = new URL(opts.url)
|
||||
const { username } = url
|
||||
if (username !== '') {
|
||||
token = username
|
||||
url.username = ''
|
||||
}
|
||||
} else {
|
||||
url = new URL('https://localhost/')
|
||||
if (opts.host !== '') {
|
||||
url.host = opts.host
|
||||
} else {
|
||||
const { hostname = 'localhost', port } = config?.http?.listen?.https ?? {}
|
||||
url.hostname = hostname
|
||||
url.port = port
|
||||
}
|
||||
}
|
||||
|
||||
url = new URL('/api/v1', url)
|
||||
const baseRequest = {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
cookie: `authenticationToken=${token}`,
|
||||
},
|
||||
method: 'POST',
|
||||
pathname: '/api/v1',
|
||||
rejectUnauthorized: false,
|
||||
}
|
||||
let { token } = opts
|
||||
if (opts.url !== '') {
|
||||
const { protocol, host, username } = new URL(opts.url)
|
||||
Object.assign(baseRequest, { protocol, host })
|
||||
if (username !== '') {
|
||||
token = username
|
||||
}
|
||||
} else {
|
||||
baseRequest.protocol = 'https:'
|
||||
if (opts.host !== '') {
|
||||
baseRequest.host = opts.host
|
||||
} else {
|
||||
const { hostname = 'localhost', port } = config?.http?.listen?.https ?? {}
|
||||
baseRequest.hostname = hostname
|
||||
baseRequest.port = port
|
||||
}
|
||||
}
|
||||
baseRequest.headers.cookie = `authenticationToken=${token}`
|
||||
|
||||
const call = async ({ method, params }) => {
|
||||
if (callPath.length !== 0) {
|
||||
process.stderr.write(`\n${colors.bold(`--- call #${callPath.join('.')}`)} ---\n\n`)
|
||||
}
|
||||
|
||||
const response = await hrp(url, {
|
||||
...baseRequest,
|
||||
|
||||
const response = await hrp.post(baseRequest, {
|
||||
body: format.request(0, method, params),
|
||||
})
|
||||
|
||||
|
||||
@@ -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": "^0.14.0",
|
||||
"json-rpc-protocol": "^0.13.1",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"pumpify": "^2.0.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "@xen-orchestra/proxy",
|
||||
"version": "0.26.13",
|
||||
"version": "0.26.10",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "XO Proxy used to remotely execute backup jobs",
|
||||
"keywords": [
|
||||
@@ -32,7 +32,7 @@
|
||||
"@vates/decorate-with": "^2.0.0",
|
||||
"@vates/disposable": "^0.1.4",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.29.6",
|
||||
"@xen-orchestra/backups": "^0.29.5",
|
||||
"@xen-orchestra/fs": "^3.3.1",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"@xen-orchestra/mixin": "^0.1.0",
|
||||
@@ -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.2",
|
||||
"xo-common": "^0.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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": "^0.14.0",
|
||||
"human-format": "^1.0.0",
|
||||
"lodash": "^4.17.4",
|
||||
"pretty-ms": "^7.0.0",
|
||||
@@ -43,7 +43,7 @@
|
||||
"pw": "^0.0.4",
|
||||
"xdg-basedir": "^4.0.0",
|
||||
"xo-lib": "^0.11.1",
|
||||
"xo-vmdk-to-vhd": "^2.5.3"
|
||||
"xo-vmdk-to-vhd": "^2.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.0.0",
|
||||
|
||||
@@ -230,15 +230,10 @@ export async function upload(args) {
|
||||
)
|
||||
formData.append('file', input, { filename: 'file', knownLength: length })
|
||||
try {
|
||||
const response = await hrp(url.toString(), { body: formData, headers: formData.getHeaders(), method: 'POST' })
|
||||
return await response.text()
|
||||
return await hrp.post(url.toString(), { body: formData, headers: formData.getHeaders() }).readAll('utf-8')
|
||||
} catch (e) {
|
||||
console.log('ERROR', e)
|
||||
const { response } = e
|
||||
if (response !== undefined) {
|
||||
console.log('ERROR content', await response.text())
|
||||
}
|
||||
|
||||
console.log('ERROR content', await e.response.readAll('utf-8'))
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { FOOTER_SIZE } from 'vhd-lib/_constants.js'
|
||||
import { notEqual, strictEqual } from 'node:assert'
|
||||
import { unpackFooter, unpackHeader } from 'vhd-lib/Vhd/_utils.js'
|
||||
import { VhdAbstract } from 'vhd-lib'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
|
||||
const { debug } = createLogger('xen-orchestra:vmware-explorer:vhdesxisesparse')
|
||||
|
||||
// from https://github.com/qemu/qemu/commit/98eb9733f4cf2eeab6d12db7e758665d2fd5367b#
|
||||
|
||||
@@ -88,6 +91,9 @@ export default class VhdEsxiSeSparse extends VhdAbstract {
|
||||
async readHeaderAndFooter() {
|
||||
const buffer = await this.#read(0, 2048)
|
||||
strictEqual(buffer.readBigInt64LE(0), 0xcafebaben)
|
||||
for (let i = 0; i < 2048 / 8; i++) {
|
||||
debug(i, '> ', buffer.readBigInt64LE(8 * i).toString(16), buffer.readBigInt64LE(8 * i))
|
||||
}
|
||||
|
||||
strictEqual(readInt64(buffer, 1), 0x200000001) // version 2.1
|
||||
|
||||
@@ -98,6 +104,14 @@ export default class VhdEsxiSeSparse extends VhdAbstract {
|
||||
const grain_tables_size = readInt64(buffer, 19)
|
||||
this.#grainOffset = readInt64(buffer, 24)
|
||||
|
||||
debug({
|
||||
capacity,
|
||||
grain_size,
|
||||
grain_tables_offset,
|
||||
grain_tables_size,
|
||||
grainSize: this.#grainSize,
|
||||
})
|
||||
|
||||
this.#grainSize = grain_size * 512 // 8 sectors / 4KB default
|
||||
this.#grainTableOffset = grain_tables_offset * 512
|
||||
this.#grainTableSize = grain_tables_size * 512
|
||||
@@ -112,10 +126,12 @@ export default class VhdEsxiSeSparse extends VhdAbstract {
|
||||
}
|
||||
|
||||
async readBlockAllocationTable() {
|
||||
debug('READ BLOCK ALLOCATION', this.#grainTableSize)
|
||||
const CHUNK_SIZE = 64 * 512
|
||||
|
||||
strictEqual(this.#grainTableSize % CHUNK_SIZE, 0)
|
||||
|
||||
debug(' will read ', this.#grainTableSize / CHUNK_SIZE, 'table')
|
||||
for (let chunkIndex = 0, grainIndex = 0; chunkIndex < this.#grainTableSize / CHUNK_SIZE; chunkIndex++) {
|
||||
process.stdin.write('.')
|
||||
const start = chunkIndex * CHUNK_SIZE + this.#grainTableOffset
|
||||
@@ -130,11 +146,15 @@ export default class VhdEsxiSeSparse extends VhdAbstract {
|
||||
break
|
||||
}
|
||||
if (entry > 3n) {
|
||||
const intIndex = +(((entry & 0x0fff000000000000n) >> 48n) | ((entry & 0x0000ffffffffffffn) << 12n))
|
||||
const pos = intIndex * this.#grainSize + CHUNK_SIZE * chunkIndex + this.#grainOffset
|
||||
debug({ indexInChunk, grainIndex, intIndex, pos })
|
||||
this.#grainMap.set(grainIndex)
|
||||
grainIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
debug('found', this.#grainMap.size)
|
||||
|
||||
// read grain directory and the grain tables
|
||||
const nbBlocks = this.header.maxTableEntries
|
||||
|
||||
5
@xen-orchestra/vmware-explorer/index.mjs
Normal file
5
@xen-orchestra/vmware-explorer/index.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
import Esxi from './esxi.mjs'
|
||||
import openDeltaVmdkasVhd from './openDeltaVmdkAsVhd.mjs'
|
||||
import VhdEsxiRaw from './VhdEsxiRaw.mjs'
|
||||
|
||||
export { openDeltaVmdkasVhd, Esxi, VhdEsxiRaw }
|
||||
@@ -4,8 +4,9 @@
|
||||
"version": "0.0.3",
|
||||
"name": "@xen-orchestra/vmware-explorer",
|
||||
"dependencies": {
|
||||
"@vates/task": "^0.0.1",
|
||||
"@vates/read-chunk": "^1.0.1",
|
||||
"@vates/task": "^0.0.1",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"lodash": "^4.17.21",
|
||||
"node-fetch": "^3.3.0",
|
||||
"node-vsphere-soap": "^0.0.2-5",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"xen-api": "^1.2.5"
|
||||
"xen-api": "^1.2.2"
|
||||
},
|
||||
"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": "^0.14.0",
|
||||
"json-rpc-protocol": "^0.13.2",
|
||||
"lodash": "^4.17.15",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
|
||||
43
CHANGELOG.md
43
CHANGELOG.md
@@ -1,44 +1,5 @@
|
||||
# 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)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- [Continuous Replication] Fix `VDI_IO_ERROR` when after a VDI has been resized
|
||||
- [REST API] Fix VDI import
|
||||
- Fix failing imports (REST API and web UI) [Forum#58146](https://xcp-ng.org/forum/post/58146)
|
||||
- [Pool/License] Fix license expiration on license binding modal (PR [#6666](https://github.com/vatesfr/xen-orchestra/pull/6666))
|
||||
- [NBD Backup] Fix VDI not disconnecting from control domain (PR [#6660](https://github.com/vatesfr/xen-orchestra/pull/6660))
|
||||
- [NBD Backup] Improve performance by avoid unnecessary VDI transfers
|
||||
- [Home/Pool] Do not check for support on non `XCP-ng` pool (PR [#6661](https://github.com/vatesfr/xen-orchestra/pull/6661))
|
||||
- [VMDK/OVA import] Fix error importing a VMDK or an OVA generated from XO (PR [#6669](https://github.com/vatesfr/xen-orchestra/pull/6669))
|
||||
|
||||
### Released packages
|
||||
|
||||
- xen-api 1.2.4
|
||||
- @vates/nbd-client 1.0.1
|
||||
- @xen-orchestra/backups 0.29.6
|
||||
- @xen-orchestra/proxy 0.26.12
|
||||
- xo-vmdk-to-vhd 2.5.3
|
||||
- xo-cli 0.14.4
|
||||
- xo-server 5.109.2
|
||||
- xo-server-transport-email 0.6.1
|
||||
- xo-web 5.111.1
|
||||
|
||||
## **5.79.0** (2023-01-31)
|
||||
|
||||
### Highlights
|
||||
@@ -97,7 +58,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 +82,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
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
> Users must be able to say: “I had this issue, happy to know it's fixed”
|
||||
|
||||
- [Continuous Replication] Fix `VDI_IO_ERROR` when after a VDI has been resized
|
||||
|
||||
### Packages to release
|
||||
|
||||
> When modifying a package, add it here with its release type.
|
||||
@@ -27,6 +29,7 @@
|
||||
|
||||
<!--packages-start-->
|
||||
|
||||
- xo-cli minor
|
||||
- @xen-orchestra/backups patch
|
||||
- xen-api patch
|
||||
|
||||
<!--packages-end-->
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM ubuntu:xenial
|
||||
# https://qastack.fr/programming/25899912/how-to-install-nvm-in-docker
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y curl qemu-utils vmdk-stream-converter git libxml2-utils libfuse2 nbdkit
|
||||
RUN apt-get install -y curl qemu-utils blktap-utils vmdk-stream-converter git libxml2-utils libfuse2 nbdkit
|
||||
ENV NVM_DIR /usr/local/nvm
|
||||
RUN mkdir -p /usr/local/nvm
|
||||
RUN cd /usr/local/nvm
|
||||
|
||||
@@ -242,13 +242,10 @@ class StreamNbdParser extends StreamParser {
|
||||
|
||||
async *parse() {
|
||||
yield* this.headers()
|
||||
|
||||
// the VHD stream is no longer necessary, destroy it
|
||||
//
|
||||
// - not destroying it would leave other writers stuck
|
||||
// - resuming it would download the whole stream unnecessarily if not other writers
|
||||
this._stream.destroy()
|
||||
|
||||
// put it in free flowing state
|
||||
// the stream is a fork from the main stream of xapi
|
||||
// if one of the fork is stopped, all the stream are stopped
|
||||
this._stream.resume()
|
||||
yield* this.blocks()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
/* eslint-env jest */
|
||||
|
||||
const fs = require('fs-extra')
|
||||
const rimraf = require('rimraf')
|
||||
const tmp = require('tmp')
|
||||
const { getSyncedHandler } = require('@xen-orchestra/fs')
|
||||
const { pFromCallback } = require('promise-toolbox')
|
||||
|
||||
const { checkFile, createRandomFile, convertFromRawToVhd } = require('./utils')
|
||||
|
||||
let tempDir = null
|
||||
let disposeHandler
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await pFromCallback(cb => tmp.dir(cb))
|
||||
|
||||
const d = await getSyncedHandler({ url: `file://${tempDir}` })
|
||||
disposeHandler = d.dispose
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rimraf(tempDir)
|
||||
disposeHandler()
|
||||
})
|
||||
|
||||
test('checkFile fails with unvalid VHD file', async () => {
|
||||
const initalSizeInMB = 4
|
||||
const rawFileName = `${tempDir}/randomfile`
|
||||
await createRandomFile(rawFileName, initalSizeInMB)
|
||||
const vhdFileName = `${tempDir}/vhdFile.vhd`
|
||||
await convertFromRawToVhd(rawFileName, vhdFileName)
|
||||
|
||||
await checkFile(vhdFileName)
|
||||
|
||||
const sizeToTruncateInByte = 250000
|
||||
await fs.truncate(vhdFileName, sizeToTruncateInByte)
|
||||
await expect(async () => await checkFile(vhdFileName)).rejects.toThrow()
|
||||
})
|
||||
@@ -5,7 +5,6 @@ const { pipeline } = require('readable-stream')
|
||||
const asyncIteratorToStream = require('async-iterator-to-stream')
|
||||
const execa = require('execa')
|
||||
const fs = require('fs-extra')
|
||||
const fsPromise = require('node:fs/promises')
|
||||
const { randomBytes } = require('crypto')
|
||||
|
||||
const createRandomStream = asyncIteratorToStream(function* (size) {
|
||||
@@ -22,11 +21,7 @@ async function createRandomFile(name, sizeMB) {
|
||||
exports.createRandomFile = createRandomFile
|
||||
|
||||
async function checkFile(vhdName) {
|
||||
// Since the qemu-img check command isn't compatible with vhd format, we use
|
||||
// the convert command to do a check by conversion. Indeed, the conversion will
|
||||
// fail if the source file isn't a proper vhd format.
|
||||
await execa('qemu-img', ['convert', '-fvpc', '-Oqcow2', vhdName, 'outputFile.qcow2'])
|
||||
await fsPromise.unlink('./outputFile.qcow2')
|
||||
await execa('vhd-util', ['check', '-p', '-b', '-t', '-n', vhdName])
|
||||
}
|
||||
exports.checkFile = checkFile
|
||||
|
||||
|
||||
@@ -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.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.1.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "xen-api",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.2",
|
||||
"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": "^0.14.0",
|
||||
"jest-diff": "^29.0.3",
|
||||
"json-rpc-protocol": "^0.13.1",
|
||||
"kindof": "^2.0.0",
|
||||
|
||||
@@ -5,12 +5,13 @@ import ms from 'ms'
|
||||
import httpRequest from 'http-request-plus'
|
||||
import map from 'lodash/map'
|
||||
import noop from 'lodash/noop'
|
||||
import omit from 'lodash/omit'
|
||||
import ProxyAgent from 'proxy-agent'
|
||||
import { coalesceCalls } from '@vates/coalesce-calls'
|
||||
import { Collection } from 'xo-collection'
|
||||
import { EventEmitter } from 'events'
|
||||
import { Index } from 'xo-collection/index'
|
||||
import { cancelable, defer, fromCallback, ignoreErrors, pDelay, pRetry, pTimeout } from 'promise-toolbox'
|
||||
import { cancelable, defer, fromCallback, fromEvents, ignoreErrors, pDelay, pRetry, pTimeout } from 'promise-toolbox'
|
||||
import { limitConcurrency } from 'limit-concurrency-decorator'
|
||||
|
||||
import autoTransport from './transports/auto'
|
||||
@@ -90,7 +91,6 @@ 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._pool = null
|
||||
this._readOnly = Boolean(opts.readOnly)
|
||||
this._RecordsByType = { __proto__: null }
|
||||
@@ -152,14 +152,13 @@ export class Xapi extends EventEmitter {
|
||||
this._resolveObjectsFetched = resolve
|
||||
})
|
||||
this._eventWatchers = { __proto__: null }
|
||||
this._taskWatchers = undefined // set in _watchEvents
|
||||
this._taskWatchers = { __proto__: null }
|
||||
this._watchedTypes = undefined
|
||||
const { watchEvents } = opts
|
||||
if (watchEvents !== false) {
|
||||
if (Array.isArray(watchEvents)) {
|
||||
this._watchedTypes = watchEvents
|
||||
}
|
||||
|
||||
this.watchEvents()
|
||||
}
|
||||
}
|
||||
@@ -392,7 +391,7 @@ export class Xapi extends EventEmitter {
|
||||
const response = await this._addSyncStackTrace(
|
||||
pRetry(
|
||||
async () =>
|
||||
httpRequest(url, {
|
||||
httpRequest($cancelToken, url.href, {
|
||||
rejectUnauthorized: !this._allowUnauthorized,
|
||||
|
||||
// this is an inactivity timeout (unclear in Node doc)
|
||||
@@ -403,8 +402,6 @@ export class Xapi extends EventEmitter {
|
||||
// Support XS <= 6.5 with Node => 12
|
||||
minVersion: 'TLSv1',
|
||||
agent: this.httpAgent,
|
||||
|
||||
signal: $cancelToken,
|
||||
}),
|
||||
{
|
||||
when: { code: 302 },
|
||||
@@ -413,7 +410,7 @@ export class Xapi extends EventEmitter {
|
||||
if (response === undefined) {
|
||||
throw error
|
||||
}
|
||||
response.destroy()
|
||||
response.cancel()
|
||||
url = await this._replaceHostAddressInUrl(new URL(response.headers.location, url))
|
||||
},
|
||||
}
|
||||
@@ -469,45 +466,40 @@ export class Xapi extends EventEmitter {
|
||||
url.search = new URLSearchParams(query)
|
||||
await this._setHostAddressInUrl(url, host)
|
||||
|
||||
const doRequest = (url, opts) =>
|
||||
httpRequest(url, {
|
||||
agent: this.httpAgent,
|
||||
const doRequest = httpRequest.put.bind(undefined, $cancelToken, {
|
||||
agent: this.httpAgent,
|
||||
|
||||
body,
|
||||
headers,
|
||||
method: 'PUT',
|
||||
rejectUnauthorized: !this._allowUnauthorized,
|
||||
signal: $cancelToken,
|
||||
body,
|
||||
headers,
|
||||
rejectUnauthorized: !this._allowUnauthorized,
|
||||
|
||||
// this is an inactivity timeout (unclear in Node doc)
|
||||
timeout: this._httpInactivityTimeout,
|
||||
// this is an inactivity timeout (unclear in Node doc)
|
||||
timeout: this._httpInactivityTimeout,
|
||||
|
||||
// Support XS <= 6.5 with Node => 12
|
||||
minVersion: 'TLSv1',
|
||||
|
||||
...opts,
|
||||
})
|
||||
|
||||
const dummyUrl = new URL(url)
|
||||
dummyUrl.searchParams.delete('task_id')
|
||||
// Support XS <= 6.5 with Node => 12
|
||||
minVersion: 'TLSv1',
|
||||
})
|
||||
|
||||
// if body is a stream, sends a dummy request to probe for a redirection
|
||||
// before consuming body
|
||||
const response = await this._addSyncStackTrace(
|
||||
isStream
|
||||
? doRequest(dummyUrl, {
|
||||
? doRequest(url.href, {
|
||||
body: '',
|
||||
|
||||
// omit task_id because this request will fail on purpose
|
||||
query: 'task_id' in query ? omit(query, 'task_id') : query,
|
||||
|
||||
maxRedirects: 0,
|
||||
}).then(
|
||||
response => {
|
||||
response.destroy()
|
||||
return doRequest(url)
|
||||
response.cancel()
|
||||
return doRequest(url.href)
|
||||
},
|
||||
async error => {
|
||||
let response
|
||||
if (error != null && (response = error.response) != null) {
|
||||
response.destroy()
|
||||
response.cancel()
|
||||
|
||||
const {
|
||||
headers: { location },
|
||||
@@ -517,14 +509,14 @@ export class Xapi extends EventEmitter {
|
||||
// ensure the original query is sent
|
||||
const newUrl = new URL(location, url)
|
||||
newUrl.searchParams.set('task_id', query.task_id)
|
||||
return doRequest(await this._replaceHostAddressInUrl(newUrl))
|
||||
return doRequest((await this._replaceHostAddressInUrl(newUrl)).href)
|
||||
}
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
)
|
||||
: doRequest(url)
|
||||
: doRequest(url.href)
|
||||
)
|
||||
|
||||
if (pTaskResult !== undefined) {
|
||||
@@ -544,30 +536,16 @@ export class Xapi extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { req } = response
|
||||
if (!req.finished) {
|
||||
await new Promise((resolve, reject) => {
|
||||
req.on('finish', resolve)
|
||||
response.on('error', reject)
|
||||
})
|
||||
}
|
||||
const { req } = response
|
||||
if (!req.finished) {
|
||||
await fromEvents(req, ['close', 'finish'])
|
||||
}
|
||||
|
||||
if (useHack) {
|
||||
response.destroy()
|
||||
} else {
|
||||
// consume the response
|
||||
response.resume()
|
||||
await new Promise((resolve, reject) => {
|
||||
response.on('end', resolve).on('error', reject)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (this._ignorePrematureClose && error.code === 'ERR_STREAM_PREMATURE_CLOSE') {
|
||||
console.warn(this._humanId, 'Xapi#putResource', pathname, error)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
if (useHack) {
|
||||
response.cancel()
|
||||
} else {
|
||||
// consume the response
|
||||
response.resume()
|
||||
}
|
||||
|
||||
return pTaskResult
|
||||
@@ -981,7 +959,7 @@ export class Xapi extends EventEmitter {
|
||||
}
|
||||
|
||||
const taskWatchers = this._taskWatchers
|
||||
const taskWatcher = taskWatchers?.[ref]
|
||||
const taskWatcher = taskWatchers[ref]
|
||||
if (taskWatcher !== undefined) {
|
||||
const result = getTaskResult(object)
|
||||
if (result !== undefined) {
|
||||
@@ -1083,7 +1061,7 @@ export class Xapi extends EventEmitter {
|
||||
}
|
||||
|
||||
const taskWatchers = this._taskWatchers
|
||||
const taskWatcher = taskWatchers?.[ref]
|
||||
const taskWatcher = taskWatchers[ref]
|
||||
if (taskWatcher !== undefined) {
|
||||
const error = new Error('task has been destroyed before completion')
|
||||
error.task = object
|
||||
@@ -1101,13 +1079,6 @@ export class Xapi extends EventEmitter {
|
||||
_watchEvents = coalesceCalls(this._watchEvents)
|
||||
// eslint-disable-next-line no-dupe-class-members
|
||||
async _watchEvents() {
|
||||
{
|
||||
const watchedTypes = this._watchedTypes
|
||||
if (this._taskWatchers === undefined && (watchedTypes === undefined || watchedTypes.includes('task'))) {
|
||||
this._taskWatchers = { __proto__: null }
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-labels
|
||||
mainLoop: while (true) {
|
||||
if (this._resolveObjectsFetched === undefined) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import httpRequestPlus from 'http-request-plus'
|
||||
import { format, parse } from 'json-rpc-protocol'
|
||||
import { join } from 'path'
|
||||
|
||||
import XapiError from '../_XapiError'
|
||||
|
||||
@@ -7,37 +8,41 @@ import UnsupportedTransport from './_UnsupportedTransport'
|
||||
|
||||
// https://github.com/xenserver/xenadmin/blob/0df39a9d83cd82713f32d24704852a0fd57b8a64/XenModel/XenAPI/Session.cs#L403-L433
|
||||
export default ({ secureOptions, url, agent }) => {
|
||||
url = new URL('./jsonrpc', Object.assign(new URL('http://localhost'), url))
|
||||
return (method, args) =>
|
||||
httpRequestPlus
|
||||
.post(url, {
|
||||
...secureOptions,
|
||||
body: format.request(0, method, args),
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
pathname: join(url.pathname, 'jsonrpc'),
|
||||
agent,
|
||||
})
|
||||
.readAll('utf8')
|
||||
.then(
|
||||
text => {
|
||||
let response
|
||||
try {
|
||||
response = parse(text)
|
||||
} catch (error) {
|
||||
throw new UnsupportedTransport()
|
||||
}
|
||||
|
||||
return async function (method, args) {
|
||||
const res = await httpRequestPlus(url, {
|
||||
...secureOptions,
|
||||
body: format.request(0, method, args),
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
agent,
|
||||
}).catch(error => {
|
||||
console.warn('xen-api/transports/json-rpc', error)
|
||||
if (response.type === 'response') {
|
||||
return response.result
|
||||
}
|
||||
|
||||
throw new UnsupportedTransport()
|
||||
})
|
||||
throw XapiError.wrap(response.error)
|
||||
},
|
||||
error => {
|
||||
if (error.response !== undefined) {
|
||||
// HTTP error
|
||||
throw new UnsupportedTransport()
|
||||
}
|
||||
|
||||
const text = await res.text()
|
||||
|
||||
let response
|
||||
try {
|
||||
response = parse(text)
|
||||
} catch (error) {
|
||||
throw new UnsupportedTransport()
|
||||
}
|
||||
|
||||
if (response.type === 'response') {
|
||||
return response.result
|
||||
}
|
||||
|
||||
throw XapiError.wrap(response.error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createReadStream, createWriteStream, readFileSync } from 'fs'
|
||||
import { PassThrough, pipeline } from 'stream'
|
||||
import { stat } from 'fs/promises'
|
||||
import chalk from 'chalk'
|
||||
import execPromise from 'exec-promise'
|
||||
import forEach from 'lodash/forEach.js'
|
||||
import fromCallback from 'promise-toolbox/fromCallback'
|
||||
import getKeys from 'lodash/keys.js'
|
||||
@@ -23,7 +24,6 @@ import XoLib from 'xo-lib'
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
import * as config from './config.mjs'
|
||||
import { inspect } from 'util'
|
||||
|
||||
const Xo = XoLib.default
|
||||
|
||||
@@ -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
|
||||
@@ -404,20 +391,16 @@ async function listObjects(args) {
|
||||
const filter = args.length ? parseParameters(args) : undefined
|
||||
|
||||
const xo = await connect()
|
||||
try {
|
||||
const objects = await xo.call('xo.getAllObjects', { filter })
|
||||
const objects = await xo.call('xo.getAllObjects', { filter })
|
||||
|
||||
const stdout = process.stdout
|
||||
stdout.write('[\n')
|
||||
const keys = Object.keys(objects)
|
||||
for (let i = 0, n = keys.length; i < n; ) {
|
||||
stdout.write(JSON.stringify(filterProperties(objects[keys[i]]), null, 2))
|
||||
stdout.write(++i < n ? ',\n' : '\n')
|
||||
}
|
||||
stdout.write(']\n')
|
||||
} finally {
|
||||
await xo.close()
|
||||
const stdout = process.stdout
|
||||
stdout.write('[\n')
|
||||
const keys = Object.keys(objects)
|
||||
for (let i = 0, n = keys.length; i < n; ) {
|
||||
stdout.write(JSON.stringify(filterProperties(objects[keys[i]]), null, 2))
|
||||
stdout.write(++i < n ? ',\n' : '\n')
|
||||
}
|
||||
stdout.write(']\n')
|
||||
}
|
||||
COMMANDS.listObjects = listObjects
|
||||
|
||||
@@ -486,15 +469,14 @@ async function call(args) {
|
||||
noop
|
||||
)
|
||||
|
||||
const response = await hrp(url, {
|
||||
...httpOptions,
|
||||
body: input,
|
||||
headers: {
|
||||
'content-length': length,
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
return response.text()
|
||||
return hrp
|
||||
.post(url, httpOptions, {
|
||||
body: input,
|
||||
headers: {
|
||||
'content-length': length,
|
||||
},
|
||||
})
|
||||
.readAll('utf-8')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,25 +489,4 @@ COMMANDS.call = call
|
||||
|
||||
// ===================================================================
|
||||
|
||||
// don't call process.exit() to avoid truncated output
|
||||
main(process.argv.slice(2)).then(
|
||||
result => {
|
||||
if (result !== undefined) {
|
||||
if (Number.isInteger(result)) {
|
||||
process.exitCode = result
|
||||
} else {
|
||||
const { stdout } = process
|
||||
stdout.write(typeof result === 'string' ? result : inspect(result))
|
||||
stdout.write('\n')
|
||||
}
|
||||
}
|
||||
},
|
||||
error => {
|
||||
const { stderr } = process
|
||||
stderr.write(chalk.bold.red('✖'))
|
||||
stderr.write(' ')
|
||||
stderr.write(typeof error === 'string' ? error : inspect(error))
|
||||
stderr.write('\n')
|
||||
process.exitCode = 1
|
||||
}
|
||||
)
|
||||
execPromise(main)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "xo-cli",
|
||||
"version": "0.14.4",
|
||||
"version": "0.14.3",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Basic CLI for Xen-Orchestra",
|
||||
"keywords": [
|
||||
@@ -30,9 +30,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.0.1",
|
||||
"exec-promise": "^0.7.0",
|
||||
"fs-extra": "^11.1.0",
|
||||
"getopts": "^2.3.0",
|
||||
"http-request-plus": "^1.0.0",
|
||||
"http-request-plus": "^0.14.0",
|
||||
"human-format": "^1.0.0",
|
||||
"lodash": "^4.17.4",
|
||||
"micromatch": "^4.0.2",
|
||||
|
||||
@@ -195,7 +195,7 @@ class BackupReportsXoPlugin {
|
||||
this._xo = xo
|
||||
this._eventListener = async (...args) => {
|
||||
try {
|
||||
await this._report(...args)
|
||||
this._report(...args)
|
||||
} catch (error) {
|
||||
logger.warn(error)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@ const indexName = (name, index) => {
|
||||
return name.slice(0, NAME_MAX_LENGTH - suffix.length) + suffix
|
||||
}
|
||||
|
||||
const onRequest = req => {
|
||||
req.setTimeout(REQUEST_TIMEOUT)
|
||||
req.on('timeout', req.abort)
|
||||
}
|
||||
|
||||
class Netbox {
|
||||
#allowUnauthorized
|
||||
#endpoint
|
||||
@@ -103,15 +108,15 @@ class Netbox {
|
||||
const options = {
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Token ${this.#token}` },
|
||||
method,
|
||||
onRequest,
|
||||
rejectUnauthorized: !this.#allowUnauthorized,
|
||||
timeout: REQUEST_TIMEOUT,
|
||||
}
|
||||
|
||||
const httpRequest = async () => {
|
||||
try {
|
||||
const response = await this.#xo.httpRequest(url, options)
|
||||
this.#netboxApiVersion = response.headers['api-version']
|
||||
const body = await response.text()
|
||||
const body = await response.readAll()
|
||||
if (body.length > 0) {
|
||||
return JSON.parse(body)
|
||||
}
|
||||
@@ -122,7 +127,7 @@ class Netbox {
|
||||
body: dataDebug,
|
||||
}
|
||||
try {
|
||||
const body = await error.response.text()
|
||||
const body = await error.response.readAll()
|
||||
if (body.length > 0) {
|
||||
error.data.error = JSON.parse(body)
|
||||
}
|
||||
|
||||
@@ -689,7 +689,7 @@ ${entriesWithMissingStats.map(({ listItem }) => listItem).join('\n')}`
|
||||
payload.vm_uuid = xapiObject.uuid
|
||||
}
|
||||
// JSON is not well formed, can't use the default node parser
|
||||
return JSON5.parse(await (await xapi.getResource('/rrd_updates', payload)).text())
|
||||
return JSON5.parse(await (await xapi.getResource('/rrd_updates', payload)).readAll())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "xo-server-transport-email",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "xo-server plugin to send emails",
|
||||
"keywords": [
|
||||
|
||||
@@ -121,7 +121,7 @@ class XoServerIcinga2 {
|
||||
exit_status: icinga2Status,
|
||||
}),
|
||||
})
|
||||
.then(response => response.text())
|
||||
.readAll()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,10 @@ class XoServerHooks {
|
||||
body: JSON.stringify({ ...data, type }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
timeout: 1e4,
|
||||
onRequest: req => {
|
||||
req.setTimeout(1e4)
|
||||
req.on('timeout', req.abort)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -132,10 +132,7 @@ port = 80
|
||||
#
|
||||
# This breaks a number of XO use cases, for instance uploading a VDI via the
|
||||
# REST API, therefore it's changed to 1 day.
|
||||
#
|
||||
# Completely disabled for now because it appears to be broken:
|
||||
# https://github.com/nodejs/node/issues/46574
|
||||
requestTimeout = 0
|
||||
requestTimeout = 86400000
|
||||
|
||||
[http.mounts]
|
||||
'/' = '../xo-web/dist'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "xo-server",
|
||||
"version": "5.109.3",
|
||||
"version": "5.109.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Server part of Xen-Orchestra",
|
||||
"keywords": [
|
||||
@@ -41,7 +41,7 @@
|
||||
"@vates/predicates": "^1.1.0",
|
||||
"@vates/read-chunk": "^1.0.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.29.6",
|
||||
"@xen-orchestra/backups": "^0.29.5",
|
||||
"@xen-orchestra/cron": "^1.0.6",
|
||||
"@xen-orchestra/defined": "^0.0.1",
|
||||
"@xen-orchestra/emit-async": "^1.0.0",
|
||||
@@ -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": "^0.14.0",
|
||||
"http-server-plus": "^1.0.0",
|
||||
"human-format": "^1.0.0",
|
||||
"iterable-backoff": "^0.1.0",
|
||||
@@ -131,12 +131,12 @@
|
||||
"vhd-lib": "^4.2.1",
|
||||
"ws": "^8.2.3",
|
||||
"xdg-basedir": "^5.1.0",
|
||||
"xen-api": "^1.2.5",
|
||||
"xen-api": "^1.2.2",
|
||||
"xo-acl-resolver": "^0.4.1",
|
||||
"xo-collection": "^0.5.0",
|
||||
"xo-common": "^0.8.0",
|
||||
"xo-remote-parser": "^0.9.2",
|
||||
"xo-vmdk-to-vhd": "^2.5.3"
|
||||
"xo-vmdk-to-vhd": "^2.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.0.0",
|
||||
|
||||
@@ -11,7 +11,6 @@ import { vmdkToVhd } from 'xo-vmdk-to-vhd'
|
||||
|
||||
import { VDI_FORMAT_VHD, VDI_FORMAT_RAW } from '../xapi/index.mjs'
|
||||
import { parseSize } from '../utils.mjs'
|
||||
import { readChunk } from '@vates/read-chunk'
|
||||
|
||||
const log = createLogger('xo:disk')
|
||||
|
||||
@@ -226,14 +225,6 @@ async function handleImport(req, res, { type, name, description, vmdkData, srId,
|
||||
)
|
||||
try {
|
||||
await vdi.$importContent(vhdStream, { format: diskFormat })
|
||||
let buffer
|
||||
const CHUNK_SIZE = 1024 * 1024
|
||||
// drain remaining content ( padding .header)
|
||||
// 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)
|
||||
|
||||
res.end(format.response(0, vdi.$id))
|
||||
} catch (e) {
|
||||
await vdi.$destroy()
|
||||
|
||||
@@ -1133,7 +1133,7 @@ async function handleExport(req, res, { xapi, vmRef, compress, format = 'xva' })
|
||||
const stream =
|
||||
format === 'ova' ? await xapi.exportVmOva(vmRef) : await xapi.VM_export(FAIL_ON_QUEUE, vmRef, { compress })
|
||||
|
||||
res.on('close', () => stream.destroy())
|
||||
res.on('close', () => stream.cancel())
|
||||
// Remove the filename as it is already part of the URL.
|
||||
stream.headers['content-disposition'] = 'attachment'
|
||||
|
||||
|
||||
@@ -263,7 +263,7 @@ export default class XapiStats {
|
||||
start: timestamp,
|
||||
},
|
||||
})
|
||||
.then(response => response.text().then(JSON5.parse))
|
||||
.then(response => response.readAll().then(JSON5.parse))
|
||||
}
|
||||
|
||||
// To avoid multiple requests, we keep a cash for the stats and
|
||||
|
||||
@@ -65,7 +65,11 @@ export default {
|
||||
async _getXenUpdates() {
|
||||
const response = await this.xo.httpRequest('http://updates.xensource.com/XenServer/updates.xml')
|
||||
|
||||
const data = parseXml(await response.buffer()).patchdata
|
||||
if (response.statusCode !== 200) {
|
||||
throw new Error('cannot fetch patches list from Citrix')
|
||||
}
|
||||
|
||||
const data = parseXml(await response.readAll()).patchdata
|
||||
|
||||
const patches = { __proto__: null }
|
||||
forEach(data.patches.patch, patch => {
|
||||
|
||||
@@ -26,11 +26,13 @@ export default class Http {
|
||||
})
|
||||
}
|
||||
|
||||
httpRequest(url, opts) {
|
||||
return hrp(url, {
|
||||
...opts,
|
||||
agent: this._agent,
|
||||
})
|
||||
httpRequest(...args) {
|
||||
return hrp(
|
||||
{
|
||||
agent: this._agent,
|
||||
},
|
||||
...args
|
||||
)
|
||||
}
|
||||
|
||||
// Inject the proxy into the environnement, it will be automatically used by `_agent` and by most libs (e.g `axios`)
|
||||
|
||||
@@ -6,10 +6,8 @@ import { Task } from '@xen-orchestra/mixins/Tasks.mjs'
|
||||
import { v4 as generateUuid } from 'uuid'
|
||||
import { VDI_FORMAT_VHD } from '@xen-orchestra/xapi'
|
||||
import asyncMapSettled from '@xen-orchestra/async-map/legacy.js'
|
||||
import Esxi from '@xen-orchestra/vmware-explorer/esxi.mjs'
|
||||
import openDeltaVmdkasVhd from '@xen-orchestra/vmware-explorer/openDeltaVmdkAsVhd.mjs'
|
||||
import { openDeltaVmdkasVhd, Esxi, VhdEsxiRaw } from '@xen-orchestra/vmware-explorer'
|
||||
import OTHER_CONFIG_TEMPLATE from '../xapi/other-config-template.mjs'
|
||||
import VhdEsxiRaw from '@xen-orchestra/vmware-explorer/VhdEsxiRaw.mjs'
|
||||
|
||||
export default class MigrateVm {
|
||||
constructor(app) {
|
||||
|
||||
@@ -419,8 +419,6 @@ export default class Proxy {
|
||||
async callProxyMethod(id, method, params, { assertType = 'scalar' } = {}) {
|
||||
const proxy = await this._getProxy(id)
|
||||
|
||||
const url = new URL('https://localhost/api/v1')
|
||||
|
||||
const request = {
|
||||
body: format.request(0, method, params),
|
||||
headers: {
|
||||
@@ -428,12 +426,14 @@ export default class Proxy {
|
||||
Cookie: cookie.serialize('authenticationToken', proxy.authenticationToken),
|
||||
},
|
||||
method: 'POST',
|
||||
pathname: '/api/v1',
|
||||
protocol: 'https:',
|
||||
rejectUnauthorized: false,
|
||||
timeout: this._app.config.getDuration('xo-proxy.callTimeout'),
|
||||
}
|
||||
|
||||
if (proxy.address !== undefined) {
|
||||
url.host = proxy.address
|
||||
request.host = proxy.address
|
||||
} else {
|
||||
const vm = this._app.getXapi(proxy.vmUuid).getObjectByUuid(proxy.vmUuid)
|
||||
|
||||
@@ -444,10 +444,11 @@ export default class Proxy {
|
||||
throw error
|
||||
}
|
||||
|
||||
url.hostname = address.includes(':') ? `[${address}]` : address
|
||||
// use hostname field to avoid issues with IPv6 addresses
|
||||
request.hostname = address
|
||||
}
|
||||
|
||||
const response = await hrp(url, request)
|
||||
const response = await hrp(request)
|
||||
|
||||
const authenticationToken = parseSetCookie(response, {
|
||||
map: true,
|
||||
|
||||
@@ -150,32 +150,6 @@ export default class RestApi {
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// should go before routes /:collection/:object because they will match but
|
||||
// will not work due to the extension being included in the object identifer
|
||||
api.get(
|
||||
'/:collection(vdis|vdi-snapshots)/:object.:format(vhd|raw)',
|
||||
wrap(async (req, res) => {
|
||||
const stream = await req.xapiObject.$exportContent({ format: req.params.format })
|
||||
|
||||
stream.headers['content-disposition'] = 'attachment'
|
||||
res.writeHead(stream.statusCode, stream.statusMessage != null ? stream.statusMessage : '', stream.headers)
|
||||
|
||||
await fromCallback(pipeline, stream, res)
|
||||
})
|
||||
)
|
||||
api.get(
|
||||
'/:collection(vms|vm-snapshots|vm-templates)/:object.xva',
|
||||
wrap(async (req, res) => {
|
||||
const stream = await req.xapiObject.$export({ compress: req.query.compress })
|
||||
|
||||
stream.headers['content-disposition'] = 'attachment'
|
||||
res.writeHead(stream.statusCode, stream.statusMessage != null ? stream.statusMessage : '', stream.headers)
|
||||
|
||||
await fromCallback(pipeline, stream, res)
|
||||
})
|
||||
)
|
||||
|
||||
api.get('/:collection/:object', (req, res) => {
|
||||
res.json(req.xoObject)
|
||||
})
|
||||
@@ -199,7 +173,7 @@ export default class RestApi {
|
||||
)
|
||||
|
||||
api.post(
|
||||
'/:collection(srs)/:object/vdis',
|
||||
'/srs/:object/vdis',
|
||||
wrap(async (req, res) => {
|
||||
const sr = req.xapiObject
|
||||
req.length = +req.headers['content-length']
|
||||
@@ -222,5 +196,29 @@ export default class RestApi {
|
||||
res.sendStatus(200)
|
||||
})
|
||||
)
|
||||
|
||||
api.get(
|
||||
'/:collection(vdis|vdi-snapshots)/:object.:format(vhd|raw)',
|
||||
wrap(async (req, res) => {
|
||||
const stream = await req.xapiObject.$exportContent({ format: req.params.format })
|
||||
|
||||
stream.headers['content-disposition'] = 'attachment'
|
||||
res.writeHead(stream.statusCode, stream.statusMessage != null ? stream.statusMessage : '', stream.headers)
|
||||
|
||||
await fromCallback(pipeline, stream, res)
|
||||
})
|
||||
)
|
||||
|
||||
api.get(
|
||||
'/:collection(vms|vm-snapshots|vm-templates)/:object.xva',
|
||||
wrap(async (req, res) => {
|
||||
const stream = await req.xapiObject.$export({ compress: req.query.compress })
|
||||
|
||||
stream.headers['content-disposition'] = 'attachment'
|
||||
res.writeHead(stream.statusCode, stream.statusMessage != null ? stream.statusMessage : '', stream.headers)
|
||||
|
||||
await fromCallback(pipeline, stream, res)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "xo-vmdk-to-vhd",
|
||||
"version": "2.5.3",
|
||||
"version": "2.5.2",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "JS lib reading and writing .vmdk and .ova files",
|
||||
"keywords": [
|
||||
|
||||
@@ -186,11 +186,7 @@ export default class VMDKDirectParser {
|
||||
const grainPosition = this.grainFileOffsetList[tableIndex] * SECTOR_SIZE
|
||||
const grainSizeBytes = this.header.grainSizeSectors * SECTOR_SIZE
|
||||
const lba = this.grainLogicalAddressList[tableIndex] * grainSizeBytes
|
||||
assert.strictEqual(
|
||||
grainPosition >= position,
|
||||
true,
|
||||
`Grain position ${grainPosition} must be after current position ${position}`
|
||||
)
|
||||
assert.strictEqual(grainPosition >= position, true)
|
||||
await this.virtualBuffer.readChunk(grainPosition - position, `blank from ${position} to ${grainPosition}`)
|
||||
let grain
|
||||
if (this.header.flags.hasMarkers) {
|
||||
|
||||
@@ -12,7 +12,6 @@ import { vmdkToVhd, readVmdkGrainTable } from '.'
|
||||
import VMDKDirectParser from './vmdk-read'
|
||||
import { generateVmdkData } from './vmdk-generate'
|
||||
import asyncIteratorToStream from 'async-iterator-to-stream'
|
||||
import fs from 'fs'
|
||||
|
||||
const initialDir = process.cwd()
|
||||
jest.setTimeout(100000)
|
||||
@@ -37,14 +36,6 @@ function bufferToArray(buffer) {
|
||||
return res
|
||||
}
|
||||
|
||||
async function checkFile(vhdName) {
|
||||
// Since the qemu-img check command isn't compatible with vhd format, we use
|
||||
// the convert command to do a check by conversion. Indeed, the conversion will
|
||||
// fail if the source file isn't a proper vhd format.
|
||||
await execa('qemu-img', ['convert', '-fvpc', '-Oqcow2', vhdName, 'outputFile.qcow2'])
|
||||
await fs.promises.unlink('./outputFile.qcow2')
|
||||
}
|
||||
|
||||
function createFileAccessor(file) {
|
||||
return async (start, end) => {
|
||||
if (start < 0 || end < 0) {
|
||||
@@ -80,7 +71,7 @@ test('VMDK to VHD can convert a random data file with VMDKDirectParser', async (
|
||||
)
|
||||
).pipe(createWriteStream(vhdFileName))
|
||||
await fromEvent(pipe, 'finish')
|
||||
await checkFile(vhdFileName)
|
||||
await execa('vhd-util', ['check', '-p', '-b', '-t', '-n', vhdFileName])
|
||||
await execa('qemu-img', ['convert', '-fvmdk', '-Oraw', vmdkFileName, reconvertedFromVmdk])
|
||||
await execa('qemu-img', ['convert', '-fvpc', '-Oraw', vhdFileName, reconvertedFromVhd])
|
||||
await execa('qemu-img', ['compare', inputRawFileName, vhdFileName])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "xo-web",
|
||||
"version": "5.111.1",
|
||||
"version": "5.111.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Web interface client for Xen-Orchestra",
|
||||
"keywords": [
|
||||
@@ -138,7 +138,7 @@
|
||||
"xo-common": "^0.8.0",
|
||||
"xo-lib": "^0.11.1",
|
||||
"xo-remote-parser": "^0.9.2",
|
||||
"xo-vmdk-to-vhd": "^2.5.3"
|
||||
"xo-vmdk-to-vhd": "^2.5.2"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "GIT_HEAD=$(git rev-parse HEAD) NODE_ENV=production gulp build",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -909,39 +909,6 @@ export default {
|
||||
// Original text: "Domain"
|
||||
remoteSmbPlaceHolderDomain: 'Domaine',
|
||||
|
||||
// Original text : "Use HTTPS"
|
||||
remoteS3LabelUseHttps: 'Utiliser HTTPS',
|
||||
|
||||
// Original text : "Allow unauthorized"
|
||||
remoteS3LabelAllowInsecure: 'Autoriser les connexions non-sécurisées',
|
||||
|
||||
// Original text : "AWS S3 endpoint (ex: s3.us-east-2.amazonaws.com)"
|
||||
remoteS3PlaceHolderEndpoint: 'Point de terminaison AWS S3 (ex: s3.us-east-2.amazonaws.com)',
|
||||
|
||||
// Original text : "AWS S3 bucket name"
|
||||
remoteS3PlaceHolderBucket: 'Nom du bucket AWS S3',
|
||||
|
||||
// Original text : "Directory"
|
||||
remoteS3PlaceHolderDirectory: 'Chemin',
|
||||
|
||||
// Original text : "Access key ID"
|
||||
remoteS3PlaceHolderAccessKeyID: "Clé d'accès",
|
||||
|
||||
// Original text : "Paste secret here to change it"
|
||||
remoteS3PlaceHolderSecret: 'Secret',
|
||||
|
||||
// Original text : "Enter your encryption key here (32 characters)"
|
||||
remoteS3PlaceHolderEncryptionKey: 'Clé de chiffrement (32 caractères)',
|
||||
|
||||
// Original text : "Region, leave blank for default"
|
||||
remoteS3Region: 'Région (optionnel)',
|
||||
|
||||
// Original text : "Uncheck if you want HTTP instead of HTTPS"
|
||||
remoteS3TooltipProtocol: 'Décocher pour utiliser HTTP au lieu de HTTPS',
|
||||
|
||||
// Original text : "Check if you want to accept self signed certificates"
|
||||
remoteS3TooltipAcceptInsecure: 'Cochez pour accepter les certificats auto-signés',
|
||||
|
||||
// Original text: "<address>\\<share> *"
|
||||
remoteSmbPlaceHolderAddressShare: '<adresse>\\<partage>',
|
||||
|
||||
@@ -2657,8 +2624,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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user