mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-13 10:41:09 -06:00
Compare commits
5 Commits
proxy-api-
...
core/fulls
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74e18bde1a | ||
|
|
3270a209a9 | ||
|
|
3ce3209752 | ||
|
|
4a1758b302 | ||
|
|
a8f9e28e89 |
172
.eslintrc.js
172
.eslintrc.js
@@ -39,12 +39,6 @@ module.exports = {
|
||||
version: '>=16',
|
||||
},
|
||||
],
|
||||
'n/no-unsupported-features/es-builtins': [
|
||||
'error',
|
||||
{
|
||||
version: '>=16',
|
||||
},
|
||||
],
|
||||
'n/no-unsupported-features/es-syntax': [
|
||||
'error',
|
||||
{
|
||||
@@ -55,17 +49,16 @@ module.exports = {
|
||||
},
|
||||
{
|
||||
files: ['@xen-orchestra/{web-core,lite,web}/**/*.{vue,ts}'],
|
||||
parser: 'vue-eslint-parser',
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
parser: '@typescript-eslint/parser',
|
||||
},
|
||||
plugins: ['import'],
|
||||
extends: [
|
||||
'plugin:import/recommended',
|
||||
'plugin:import/typescript',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:vue/vue3-essential',
|
||||
'plugin:vue/vue3-recommended',
|
||||
'@vue/eslint-config-typescript/recommended',
|
||||
'@vue/eslint-config-prettier',
|
||||
],
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
@@ -80,164 +73,11 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
XO_LITE_VERSION: true,
|
||||
XO_LITE_GIT_HEAD: true,
|
||||
},
|
||||
rules: {
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
groups: ['builtin', 'object', 'internal', ['type', 'external']],
|
||||
alphabetize: {
|
||||
order: 'asc',
|
||||
caseInsensitive: true,
|
||||
},
|
||||
pathGroups: [
|
||||
{
|
||||
pattern: '@/**',
|
||||
group: 'internal',
|
||||
},
|
||||
{
|
||||
pattern: '@core/**',
|
||||
group: 'internal',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
'no-void': ['error', { allowAsStatement: true }],
|
||||
'n/no-missing-import': 'off', // using 'import' plugin instead, to support TS aliases
|
||||
'no-redeclare': 'off', // automatically checked by the TypeScript compiler
|
||||
'no-dupe-class-members': 'off', // automatically checked by the TypeScript compiler
|
||||
'no-undef': 'off', // automatically checked by the TypeScript compiler
|
||||
'no-use-before-define': 'off', // replaced with the rule below
|
||||
'@typescript-eslint/no-use-before-define': ['error', { functions: false }],
|
||||
'no-void': 'off',
|
||||
'n/no-missing-import': 'off', // using 'import' plugin instead to support TS aliases
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'vue/multi-word-component-names': 'off',
|
||||
// Vue 3 - Strongly Recommended
|
||||
'vue/attribute-hyphenation': 'error',
|
||||
'vue/component-definition-name-casing': 'error',
|
||||
'vue/first-attribute-linebreak': 'error',
|
||||
'vue/html-closing-bracket-newline': 'off', // Conflicts with Prettier
|
||||
'vue/html-closing-bracket-spacing': 'error',
|
||||
'vue/html-end-tags': 'error',
|
||||
'vue/html-indent': 'off', // Conflicts with Prettier
|
||||
'vue/html-quotes': 'error',
|
||||
'vue/html-self-closing': ['error', { html: { void: 'always', normal: 'always', component: 'always' } }],
|
||||
'vue/max-attributes-per-line': 'off', // Conflicts with Prettier
|
||||
'vue/multiline-html-element-content-newline': 'error',
|
||||
'vue/mustache-interpolation-spacing': 'error',
|
||||
'vue/no-multi-spaces': 'error',
|
||||
'vue/no-spaces-around-equal-signs-in-attribute': 'error',
|
||||
'vue/no-template-shadow': 'error',
|
||||
'vue/prop-name-casing': 'error',
|
||||
'vue/require-default-prop': 'off', // https://github.com/vuejs/eslint-plugin-vue/issues/2051
|
||||
'vue/require-explicit-emits': 'error',
|
||||
'vue/require-prop-types': 'error',
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'vue/v-bind-style': 'error',
|
||||
'vue/v-on-event-hyphenation': ['error', 'always', { autofix: true }],
|
||||
'vue/v-on-style': 'error',
|
||||
'vue/v-slot-style': 'error',
|
||||
// Vue 3 - Recommended
|
||||
'vue/attributes-order': 'error',
|
||||
'vue/no-lone-template': 'error',
|
||||
'vue/no-multiple-slot-args': 'error',
|
||||
'vue/no-v-html': 'error',
|
||||
'vue/this-in-template': 'error',
|
||||
// Vue 3 - Uncategorized
|
||||
'vue/block-lang': ['error', { script: { lang: 'ts' }, style: { lang: 'postcss' } }],
|
||||
'vue/block-order': ['error', { order: ['template', 'script', 'style'] }],
|
||||
'vue/block-tag-newline': ['error', { singleline: 'consistent', multiline: 'always', maxEmptyLines: 0 }],
|
||||
'vue/component-api-style': ['error', ['script-setup']],
|
||||
'vue/component-name-in-template-casing': ['error', 'PascalCase', { registeredComponentsOnly: false }],
|
||||
'vue/custom-event-name-casing': ['error', 'camelCase'],
|
||||
'vue/define-emits-declaration': ['error', 'type-literal'],
|
||||
'vue/define-macros-order': [
|
||||
'error',
|
||||
{
|
||||
order: ['defineOptions', 'defineProps', 'defineEmits', 'defineModel', 'defineSlots'],
|
||||
defineExposeLast: true,
|
||||
},
|
||||
],
|
||||
'vue/define-props-declaration': ['error', 'type-based'],
|
||||
'vue/enforce-style-attribute': ['error', { allow: ['scoped', 'module'] }],
|
||||
'vue/html-button-has-type': 'error',
|
||||
'vue/html-comment-content-newline': ['error', { singleline: 'never', multiline: 'always' }],
|
||||
'vue/html-comment-content-spacing': ['error', 'always'],
|
||||
'vue/no-duplicate-attr-inheritance': 'error',
|
||||
'vue/no-empty-component-block': 'error',
|
||||
'vue/no-multiple-objects-in-class': 'error',
|
||||
'vue/no-ref-object-reactivity-loss': 'error',
|
||||
'vue/no-required-prop-with-default': 'error',
|
||||
'vue/no-static-inline-styles': 'error',
|
||||
'vue/no-template-target-blank': 'error',
|
||||
'vue/no-undef-components': ['error', { ignorePatterns: ['RouterLink', 'RouterView', 'I18nT'] }],
|
||||
'vue/no-undef-properties': 'error',
|
||||
'vue/no-unused-refs': 'error',
|
||||
'vue/no-use-v-else-with-v-for': 'error',
|
||||
'vue/no-useless-mustaches': 'error',
|
||||
'vue/no-useless-v-bind': 'error',
|
||||
'vue/no-v-text': 'error',
|
||||
'vue/padding-line-between-blocks': 'error',
|
||||
'vue/prefer-define-options': 'error',
|
||||
'vue/prefer-separate-static-class': 'error',
|
||||
'vue/prefer-true-attribute-shorthand': 'error',
|
||||
'vue/require-macro-variable-name': 'error',
|
||||
'vue/valid-define-options': 'error',
|
||||
},
|
||||
},
|
||||
// Specific rules for XO i18n-related files
|
||||
{
|
||||
files: [
|
||||
'@xen-orchestra/lite/**/*.{vue,ts,json}',
|
||||
'@xen-orchestra/web/**/*.{vue,ts,json}',
|
||||
'@xen-orchestra/web-core/**/*.{vue,ts,json}',
|
||||
],
|
||||
extends: ['plugin:@intlify/vue-i18n/base'],
|
||||
settings: {
|
||||
'vue-i18n': {
|
||||
localeDir: [
|
||||
'@xen-orchestra/lite/src/locales/*.json',
|
||||
'@xen-orchestra/web/src/locales/*.json',
|
||||
'@xen-orchestra/web-core/lib/locales/*.json',
|
||||
],
|
||||
messageSyntaxVersion: '^9.9.0',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
strict: 'off',
|
||||
'no-irregular-whitespace': 'off',
|
||||
'@intlify/vue-i18n/no-deprecated-i18n-component': 'error',
|
||||
'@intlify/vue-i18n/no-deprecated-i18n-place-attr': 'error',
|
||||
'@intlify/vue-i18n/no-deprecated-i18n-places-prop': 'error',
|
||||
'@intlify/vue-i18n/no-deprecated-modulo-syntax': 'error',
|
||||
'@intlify/vue-i18n/no-deprecated-tc': 'error',
|
||||
'@intlify/vue-i18n/no-html-messages': 'error',
|
||||
'@intlify/vue-i18n/no-i18n-t-path-prop': 'error',
|
||||
'@intlify/vue-i18n/no-missing-keys': 'error',
|
||||
'@intlify/vue-i18n/no-raw-text': [
|
||||
'error',
|
||||
{
|
||||
ignoreText: ['Xen Orchestra Lite', 'Xen Orchestra', 'XO Lite', 'XCP-ng', 'XO 5', '404', '⚠️'],
|
||||
},
|
||||
],
|
||||
'@intlify/vue-i18n/no-v-html': 'error',
|
||||
'@intlify/vue-i18n/valid-message-syntax': 'error',
|
||||
'@intlify/vue-i18n/no-duplicate-keys-in-locale': 'error',
|
||||
'@intlify/vue-i18n/no-missing-keys-in-other-locales': ['error', { ignoreLocales: ['de', 'fa'] }],
|
||||
},
|
||||
},
|
||||
// Specific rules for XO dev-related files
|
||||
{
|
||||
files: [
|
||||
'@xen-orchestra/lite/src/stories/**/*',
|
||||
'@xen-orchestra/lite/src/components/component-story/**/*',
|
||||
'@xen-orchestra/web/src/pages/dev/**/*',
|
||||
'@xen-orchestra/lite/src/views/story/**/*',
|
||||
],
|
||||
rules: {
|
||||
'@intlify/vue-i18n/no-raw-text': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -284,7 +124,7 @@ module.exports = {
|
||||
// this rule can prevent race condition bugs like parallel `a += await foo()`
|
||||
//
|
||||
// as it has a lots of false positive, it is only enabled as a warning for now
|
||||
'require-atomic-updates': ['warn', { allowProperties: true }],
|
||||
'require-atomic-updates': 'warn',
|
||||
|
||||
strict: 'error',
|
||||
},
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# Ignore Handlebars files because partials are not currently supported
|
||||
# https://github.com/prettier/prettier/issues/11834
|
||||
**/*.hbs
|
||||
|
||||
# Generated files
|
||||
/@xen-orchestra/web/typed-router.d.ts
|
||||
@@ -33,7 +33,7 @@
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^18.0.0",
|
||||
"sinon": "^17.0.1",
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"ensure-array": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^18.0.0",
|
||||
"sinon": "^17.0.1",
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import LRU from 'lru-cache'
|
||||
import Fuse from 'fuse-native'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { VhdSynthetic } from 'vhd-lib'
|
||||
import { Disposable, fromCallback } from 'promise-toolbox'
|
||||
|
||||
const { warn } = createLogger('vates:fuse-vhd')
|
||||
|
||||
// build a s stat object from https://github.com/fuse-friends/fuse-native/blob/master/test/fixtures/stat.js
|
||||
const stat = st => ({
|
||||
mtime: st.mtime || new Date(),
|
||||
@@ -55,10 +52,7 @@ export const mount = Disposable.factory(async function* mount(handler, diskPath,
|
||||
},
|
||||
read(path, fd, buf, len, pos, cb) {
|
||||
if (path === '/vhd0') {
|
||||
return vhd.readRawData(pos, len, cache, buf).then(cb, error => {
|
||||
warn('read error', { path, len, pos, error })
|
||||
cb(Fuse.EIO)
|
||||
})
|
||||
return vhd.readRawData(pos, len, cache, buf).then(cb)
|
||||
}
|
||||
throw new Error(`read file ${path} not exists`)
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vates/fuse-vhd",
|
||||
"version": "2.1.1",
|
||||
"version": "2.1.0",
|
||||
"license": "ISC",
|
||||
"private": false,
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/fuse-vhd",
|
||||
@@ -19,12 +19,11 @@
|
||||
},
|
||||
"main": "./index.mjs",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/fs": "^4.1.7",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"@xen-orchestra/fs": "^4.1.6",
|
||||
"fuse-native": "^2.2.6",
|
||||
"lru-cache": "^7.14.0",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"vhd-lib": "^4.11.0"
|
||||
"vhd-lib": "^4.9.2"
|
||||
},
|
||||
"bin": {
|
||||
"xo-fuse-vhd": "./cli.mjs"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
The use for this librariy is to create a composite key from a JSON value, ignoring objects identity and properties order.
|
||||
|
||||
The hash algorithm used is intended to be fast and with low collisions and is not guaranteed to be be secure.
|
||||
|
||||
The hash is encoded using [Base64 URL](https://en.wikipedia.org/wiki/Base64#URL_applications) to make it easy to use in URLs and filenames.
|
||||
|
||||
```js
|
||||
import { jsonHash } from '@vates/json-hash'
|
||||
|
||||
console.log(jsonHash('foo'))
|
||||
// → "siEyldVkkW-JpqQkVVZ8h8P0gPzXocFeIg8X1xaaeQs"
|
||||
|
||||
// order of properties is ignored
|
||||
console.log(jsonHash({ foo: 0, bar: 1 }))
|
||||
// → "JckoRSMIBjNlgEWIXhgpBOuyLQYqABZqvf1ccb3BPg0"
|
||||
console.log(jsonHash({ bar: 1, foo: 0 }))
|
||||
// → "JckoRSMIBjNlgEWIXhgpBOuyLQYqABZqvf1ccb3BPg0"
|
||||
```
|
||||
@@ -1 +0,0 @@
|
||||
../../scripts/npmignore
|
||||
@@ -1,51 +0,0 @@
|
||||
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
|
||||
|
||||
# @vates/json-hash
|
||||
|
||||
[](https://npmjs.org/package/@vates/json-hash)  [](https://bundlephobia.com/result?p=@vates/json-hash) [](https://npmjs.org/package/@vates/json-hash)
|
||||
|
||||
> Compute a stable hash from a JSON-ifiable value
|
||||
|
||||
## Install
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/json-hash):
|
||||
|
||||
```sh
|
||||
npm install --save @vates/json-hash
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
The use for this librariy is to create a composite key from a JSON value, ignoring objects identity and properties order.
|
||||
|
||||
The hash algorithm used is intended to be fast and with low collisions and is not guaranteed to be be secure.
|
||||
|
||||
The hash is encoded using [Base64 URL](https://en.wikipedia.org/wiki/Base64#URL_applications) to make it easy to use in URLs and filenames.
|
||||
|
||||
```js
|
||||
import { jsonHash } from '@vates/json-hash'
|
||||
|
||||
console.log(jsonHash('foo'))
|
||||
// → "siEyldVkkW-JpqQkVVZ8h8P0gPzXocFeIg8X1xaaeQs"
|
||||
|
||||
// order of properties is ignored
|
||||
console.log(jsonHash({ foo: 0, bar: 1 }))
|
||||
// → "JckoRSMIBjNlgEWIXhgpBOuyLQYqABZqvf1ccb3BPg0"
|
||||
console.log(jsonHash({ bar: 1, foo: 0 }))
|
||||
// → "JckoRSMIBjNlgEWIXhgpBOuyLQYqABZqvf1ccb3BPg0"
|
||||
```
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are _very_ welcomed, either on the documentation or on
|
||||
the code.
|
||||
|
||||
You may:
|
||||
|
||||
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
|
||||
you've encountered;
|
||||
- fork and create a pull request.
|
||||
|
||||
## License
|
||||
|
||||
[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr)
|
||||
@@ -1,38 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const { createHash } = require('node:crypto')
|
||||
|
||||
function updateJsonHash(value, hash) {
|
||||
if (value !== null && typeof value === 'object') {
|
||||
if (Array.isArray(value)) {
|
||||
hash.update('[')
|
||||
for (const item of value) {
|
||||
updateJsonHash(item, hash)
|
||||
|
||||
// trailing is not a big deal because it does not need to be valid JSON
|
||||
hash.update(',')
|
||||
}
|
||||
hash.update(']')
|
||||
} else {
|
||||
hash.update('{')
|
||||
for (const key of Object.keys(value).sort()) {
|
||||
updateJsonHash(key, hash)
|
||||
hash.update(':')
|
||||
updateJsonHash(value[key], hash)
|
||||
|
||||
// trailing is not a big deal because it does not need to be valid JSON
|
||||
hash.update(',')
|
||||
}
|
||||
hash.update('}')
|
||||
}
|
||||
} else {
|
||||
hash.update(JSON.stringify(value))
|
||||
}
|
||||
}
|
||||
|
||||
exports.jsonHash = function jsonHash(value) {
|
||||
// this hash does not need to be secure, it just needs to be fast and with low collisions
|
||||
const hash = createHash('sha256')
|
||||
updateJsonHash(value, hash)
|
||||
return hash.digest('base64url')
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const { jsonHash } = require('@vates/json-hash')
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('test')
|
||||
|
||||
const cases = [
|
||||
[null, 'dCNOmK_nSY-12vHzasLXiswzlGT5UHA7jAGYkvmCuQs'],
|
||||
[42, 'c0dctApWjo2ooEXO0RATfhWfiQrE2og7axfcZRs6gEk'],
|
||||
['foo', 'siEyldVkkW-JpqQkVVZ8h8P0gPzXocFeIg8X1xaaeQs'],
|
||||
[[], 'T1PNoYwrqgwDVLtfmj7L5e0Sq02OEbqHPC8RFhICuUU'],
|
||||
[{}, 'RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o'],
|
||||
[{ a: 1, b: 2 }, 'LMjVxyj2vtMZ3W1cZ2cJrQij5zn7FBOlPiyBB38OmFE'],
|
||||
[{ b: 2, a: 1 }, 'LMjVxyj2vtMZ3W1cZ2cJrQij5zn7FBOlPiyBB38OmFE'],
|
||||
]
|
||||
|
||||
for (const [value, hash] of cases) {
|
||||
test(JSON.stringify(value), function () {
|
||||
assert.equal(jsonHash(value), hash)
|
||||
})
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@vates/json-hash",
|
||||
"description": "Compute a stable hash from a JSON-ifiable value",
|
||||
"keywords": [
|
||||
"hash",
|
||||
"json",
|
||||
"stable"
|
||||
],
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/json-hash",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@vates/json-hash",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "0.2.0",
|
||||
"engines": {
|
||||
"node": ">=14.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"test": "^3.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node--test"
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export default class NbdClient {
|
||||
#exportSize
|
||||
|
||||
#waitBeforeReconnect
|
||||
#readAhead
|
||||
#readBlockRetries
|
||||
#reconnectRetry
|
||||
#connectTimeout
|
||||
@@ -54,6 +55,7 @@ export default class NbdClient {
|
||||
connectTimeout = 6e4,
|
||||
messageTimeout = 6e4,
|
||||
waitBeforeReconnect = 1e3,
|
||||
readAhead = 10,
|
||||
readBlockRetries = 5,
|
||||
reconnectRetry = 5,
|
||||
} = {}
|
||||
@@ -63,6 +65,7 @@ export default class NbdClient {
|
||||
this.#exportName = exportname
|
||||
this.#serverCert = cert
|
||||
this.#waitBeforeReconnect = waitBeforeReconnect
|
||||
this.#readAhead = readAhead
|
||||
this.#readBlockRetries = readBlockRetries
|
||||
this.#reconnectRetry = reconnectRetry
|
||||
this.#connectTimeout = connectTimeout
|
||||
|
||||
@@ -6,10 +6,7 @@ import { createLogger } from '@xen-orchestra/log'
|
||||
const { warn } = createLogger('vates:nbd-client:multi')
|
||||
export default class MultiNbdClient {
|
||||
#clients = []
|
||||
#nbdConcurrency
|
||||
#options
|
||||
#readAhead
|
||||
#settings
|
||||
|
||||
get exportSize() {
|
||||
return this.#clients[0].exportSize
|
||||
@@ -17,64 +14,36 @@ export default class MultiNbdClient {
|
||||
|
||||
constructor(settings, { nbdConcurrency = 8, readAhead = 16, ...options } = {}) {
|
||||
this.#readAhead = readAhead
|
||||
this.#options = options
|
||||
this.#nbdConcurrency = nbdConcurrency
|
||||
if (!Array.isArray(settings)) {
|
||||
settings = [settings]
|
||||
}
|
||||
this.#settings = settings
|
||||
for (let i = 0; i < nbdConcurrency; i++) {
|
||||
this.#clients.push(
|
||||
new NbdClient(settings[i % settings.length], { ...options, readAhead: Math.ceil(readAhead / nbdConcurrency) })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* open nbdConcurrency connections to NBD servers
|
||||
* it must obtain at least one connection to succeed
|
||||
* it tries to spread connections on multiple host
|
||||
*/
|
||||
async connect() {
|
||||
const candidates = [...this.#settings]
|
||||
|
||||
const promises = []
|
||||
const baseOptions = this.#options
|
||||
const _connect = async () => {
|
||||
if (candidates.length === 0) {
|
||||
return
|
||||
}
|
||||
// a little bit of randomization to spread the load
|
||||
const nbdInfo = candidates[Math.floor(Math.random() * candidates.length)]
|
||||
const client = new NbdClient(nbdInfo, {
|
||||
...baseOptions,
|
||||
readAhead: Math.ceil(this.#readAhead / this.#nbdConcurrency),
|
||||
})
|
||||
const connectedClients = []
|
||||
for (const clientId in this.#clients) {
|
||||
const client = this.#clients[clientId]
|
||||
try {
|
||||
await client.connect()
|
||||
this.#clients.push(client)
|
||||
connectedClients.push(client)
|
||||
} catch (err) {
|
||||
client.disconnect().catch(() => {})
|
||||
// do not hammer unreachable hosts, once failed, remove from the list
|
||||
const candidateIndex = candidates.findIndex(({ address }) => address === nbdInfo.address)
|
||||
if (candidateIndex >= 0) {
|
||||
// this candidate may have already been deleted by another parallel promise
|
||||
candidates.splice(candidateIndex, 1)
|
||||
}
|
||||
|
||||
warn(`can't connect to one nbd client`, { err })
|
||||
// retry with another candidate (if available)
|
||||
return _connect()
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < this.#nbdConcurrency; i++) {
|
||||
promises.push(_connect())
|
||||
}
|
||||
await Promise.all(promises)
|
||||
|
||||
if (this.#clients.length === 0) {
|
||||
if (connectedClients.length === 0) {
|
||||
throw new Error(`Fail to connect to any Nbd client`)
|
||||
}
|
||||
if (this.#clients.length < this.#nbdConcurrency) {
|
||||
if (connectedClients.length < this.#clients.length) {
|
||||
warn(
|
||||
`incomplete connection by multi Nbd, only ${this.#clients.length} over ${this.#nbdConcurrency} expected clients`
|
||||
`incomplete connection by multi Nbd, only ${connectedClients.length} over ${this.#clients.length} expected clients`
|
||||
)
|
||||
this.#clients = connectedClients
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "3.1.0",
|
||||
"version": "3.0.1",
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
},
|
||||
@@ -24,7 +24,7 @@
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"xen-api": "^4.3.1"
|
||||
"xen-api": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tap": "^18.7.0",
|
||||
|
||||
@@ -78,14 +78,15 @@ async function killNbdKit() {
|
||||
)
|
||||
}
|
||||
|
||||
test('it works secured network', async tap => {
|
||||
test('it works with unsecured network', async tap => {
|
||||
const path = await createTempFile(FILE_SIZE)
|
||||
|
||||
let nbdServer = await spawnNbdKit(path)
|
||||
const connectionSettings = {
|
||||
address: '127.0.0.1',
|
||||
exportname: 'MY_SECRET_EXPORT',
|
||||
cert: `-----BEGIN CERTIFICATE-----
|
||||
const client = new MultiNbdClient(
|
||||
{
|
||||
address: '127.0.0.1',
|
||||
exportname: 'MY_SECRET_EXPORT',
|
||||
cert: `-----BEGIN CERTIFICATE-----
|
||||
MIIDazCCAlOgAwIBAgIUeHpQ0IeD6BmP2zgsv3LV3J4BI/EwDQYJKoZIhvcNAQEL
|
||||
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
|
||||
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMzA1MTcxMzU1MzBaFw0yNDA1
|
||||
@@ -107,18 +108,12 @@ CYu1Xn/FVPx1HoRgWc7E8wFhDcA/P3SJtfIQWHB9FzSaBflKGR4t8WCE2eE8+cTB
|
||||
57ABhfYpMlZ4aHjuN1bL
|
||||
-----END CERTIFICATE-----
|
||||
`,
|
||||
}
|
||||
const invalid = {
|
||||
address: '500.500.500.500',
|
||||
port: 0,
|
||||
exportname: 'nop',
|
||||
}
|
||||
// it should work even with some broken servers
|
||||
const nbdInfos = [connectionSettings, connectionSettings, invalid]
|
||||
const client = new MultiNbdClient(nbdInfos, {
|
||||
nbdConcurrency: 4,
|
||||
readAhead: 2,
|
||||
})
|
||||
},
|
||||
{
|
||||
nbdConcurrency: 1,
|
||||
readAhead: 2,
|
||||
}
|
||||
)
|
||||
|
||||
await client.connect()
|
||||
tap.equal(client.exportSize, BigInt(FILE_SIZE))
|
||||
@@ -161,18 +156,3 @@ CYu1Xn/FVPx1HoRgWc7E8wFhDcA/P3SJtfIQWHB9FzSaBflKGR4t8WCE2eE8+cTB
|
||||
nbdServer.kill()
|
||||
await fs.unlink(path)
|
||||
})
|
||||
|
||||
test('fails if server does not answer', async () => {
|
||||
const client = new MultiNbdClient(
|
||||
{
|
||||
address: '500.500.500.500',
|
||||
port: 0,
|
||||
exportname: 'nop',
|
||||
},
|
||||
{
|
||||
nbdConcurrency: 1,
|
||||
readAhead: 2,
|
||||
}
|
||||
)
|
||||
await assert.rejects(client.connect())
|
||||
})
|
||||
|
||||
@@ -17,20 +17,7 @@ import https from 'node:https'
|
||||
import util from 'util'
|
||||
import soap from 'soap'
|
||||
import Cookie from 'soap-cookie' // required for session persistence
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
|
||||
const { warn } = createLogger('xo:node-vsphere-soap:client')
|
||||
class VmwareError extends Error {
|
||||
constructor(rawError) {
|
||||
super(rawError.body)
|
||||
const matches = rawError.body?.match(/<faultstring[^>]*>(.*)<\/faultstring>/im)
|
||||
this.message = matches?.[1] ?? rawError.message
|
||||
this.name = 'VmwareError'
|
||||
this.stack = rawError.stack
|
||||
// not putting the cause since the error failed to be stringified correctly down the road
|
||||
warn(rawError)
|
||||
}
|
||||
}
|
||||
// Client class
|
||||
// inherits from EventEmitter
|
||||
// possible events: connect, error, ready
|
||||
@@ -153,9 +140,8 @@ Client.prototype._connect = function () {
|
||||
self.clientopts,
|
||||
function (err, client) {
|
||||
if (err) {
|
||||
const vErr = new VmwareError(err)
|
||||
self.emit('error', vErr)
|
||||
throw vErr
|
||||
self.emit('error', err)
|
||||
throw err
|
||||
}
|
||||
|
||||
self.client = client // save client for later use
|
||||
@@ -164,7 +150,6 @@ Client.prototype._connect = function () {
|
||||
.runCommand('RetrieveServiceContent', { _this: 'ServiceInstance' })
|
||||
.once('result', function (result, raw, soapHeader) {
|
||||
if (!result.returnval) {
|
||||
// this is a domain/server error return it as is
|
||||
self.status = 'disconnected'
|
||||
self.emit('error', raw)
|
||||
return
|
||||
@@ -223,7 +208,7 @@ Client.prototype._close = function () {
|
||||
function _soapErrorHandler(self, emitter, command, args, err) {
|
||||
err = err || { body: 'general error' }
|
||||
|
||||
if (err.body?.match(/session is not authenticated/)) {
|
||||
if (err.body.match(/session is not authenticated/)) {
|
||||
self.status = 'disconnected'
|
||||
process.removeAllListeners('beforeExit')
|
||||
|
||||
@@ -235,19 +220,16 @@ function _soapErrorHandler(self, emitter, command, args, err) {
|
||||
emitter.emit('result', result, raw, soapHeader)
|
||||
})
|
||||
.once('error', function (err) {
|
||||
const vErr = new VmwareError(err)
|
||||
emitter.emit('error', vErr)
|
||||
throw vErr
|
||||
emitter.emit('error', err.body)
|
||||
throw err
|
||||
})
|
||||
} else {
|
||||
const vErr = new VmwareError(err)
|
||||
emitter.emit('error', vErr)
|
||||
throw vErr
|
||||
emitter.emit('error', err.body)
|
||||
throw err
|
||||
}
|
||||
} else {
|
||||
const vErr = new VmwareError(err)
|
||||
emitter.emit('error', vErr)
|
||||
throw vErr
|
||||
emitter.emit('error', err.body)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vates/node-vsphere-soap",
|
||||
"version": "2.1.0",
|
||||
"version": "2.0.1",
|
||||
"description": "interface to vSphere SOAP/WSDL from node for interfacing with vCenter or ESXi, forked from node-vsphere-soap",
|
||||
"main": "lib/client.mjs",
|
||||
"author": "reedog117",
|
||||
@@ -11,7 +11,6 @@
|
||||
},
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"axios": "^1.4.0",
|
||||
"soap": "^1.0.0",
|
||||
"soap-cookie": "^0.10.1"
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
### `replace(value, replacement)`
|
||||
|
||||
> Recursively replace sensitive entries with a replacement value.
|
||||
|
||||
```js
|
||||
import { replace } from '@vates/obfuscate'
|
||||
|
||||
replace({ username: 'foo', password: 'bar' }, 'obfuscated')
|
||||
// → { username: 'foo', password: 'obfuscated'}
|
||||
```
|
||||
|
||||
### `obfuscate(value)`
|
||||
|
||||
> Recursively replace sensitive entries with a special tag.
|
||||
|
||||
```js
|
||||
import { obfuscate } from '@vates/obfuscate'
|
||||
|
||||
obfuscate({ username: 'foo', password: 'bar' })
|
||||
// → {
|
||||
// username: 'foo',
|
||||
// password: 'obfuscated-q3oi6d9X8uenGvdLnHk2',
|
||||
// }
|
||||
```
|
||||
|
||||
### `merge(newValue, oldValue)`
|
||||
|
||||
> Replace entries obfuscated by `obfuscate()` in the new value by entries from the old one.
|
||||
>
|
||||
> This is especially useful to give an obfuscated value to a client, let them modify it, and then merge modified and unmodified values.
|
||||
|
||||
```js
|
||||
import { obfuscate, merge } from '@vates/obfuscate'
|
||||
|
||||
const original = { username: 'foo', password: 'bar', token: 'baz' }
|
||||
|
||||
// compute an obfuscated value to send to a client
|
||||
const obfuscated = obfuscate(original)
|
||||
|
||||
// let the client update any entries
|
||||
obfuscated.token = 'qux'
|
||||
|
||||
// merge the updated value with the original one
|
||||
merge(obfuscated, original)
|
||||
// → { username: 'foo', password: 'bar', token: 'qux' }
|
||||
```
|
||||
@@ -1 +0,0 @@
|
||||
../../scripts/npmignore
|
||||
@@ -1,77 +0,0 @@
|
||||
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
|
||||
|
||||
# @vates/obfuscate
|
||||
|
||||
[](https://npmjs.org/package/@vates/obfuscate)  [](https://bundlephobia.com/result?p=@vates/obfuscate) [](https://npmjs.org/package/@vates/obfuscate)
|
||||
|
||||
## Install
|
||||
|
||||
Installation of the [npm package](https://npmjs.org/package/@vates/obfuscate):
|
||||
|
||||
```sh
|
||||
npm install --save @vates/obfuscate
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### `replace(value, replacement)`
|
||||
|
||||
> Recursively replace sensitive entries with a replacement value.
|
||||
|
||||
```js
|
||||
import { replace } from '@vates/obfuscate'
|
||||
|
||||
replace({ username: 'foo', password: 'bar' }, 'obfuscated')
|
||||
// → { username: 'foo', password: 'obfuscated'}
|
||||
```
|
||||
|
||||
### `obfuscate(value)`
|
||||
|
||||
> Recursively replace sensitive entries with a special tag.
|
||||
|
||||
```js
|
||||
import { obfuscate } from '@vates/obfuscate'
|
||||
|
||||
obfuscate({ username: 'foo', password: 'bar' })
|
||||
// → {
|
||||
// username: 'foo',
|
||||
// password: 'obfuscated-q3oi6d9X8uenGvdLnHk2',
|
||||
// }
|
||||
```
|
||||
|
||||
### `merge(newValue, oldValue)`
|
||||
|
||||
> Replace entries obfuscated by `obfuscate()` in the new value by entries from the old one.
|
||||
>
|
||||
> This is especially useful to give an obfuscated value to a client, let them modify it, and then merge modified and unmodified values.
|
||||
|
||||
```js
|
||||
import { obfuscate, merge } from '@vates/obfuscate'
|
||||
|
||||
const original = { username: 'foo', password: 'bar', token: 'baz' }
|
||||
|
||||
// compute an obfuscated value to send to a client
|
||||
const obfuscated = obfuscate(original)
|
||||
|
||||
// let the client update any entries
|
||||
obfuscated.token = 'qux'
|
||||
|
||||
// merge the updated value with the original one
|
||||
merge(obfuscated, original)
|
||||
// → { username: 'foo', password: 'bar', token: 'qux' }
|
||||
```
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are _very_ welcomed, either on the documentation or on
|
||||
the code.
|
||||
|
||||
You may:
|
||||
|
||||
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
|
||||
you've encountered;
|
||||
- fork and create a pull request.
|
||||
|
||||
## License
|
||||
|
||||
[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr)
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@vates/obfuscate",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/obfuscate",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "@vates/obfuscate",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "0.1.0",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,9 @@ exports.parseDuration = value => {
|
||||
if (typeof value === 'number') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string' && value !== '') {
|
||||
const duration = ms(value)
|
||||
if (duration !== undefined) {
|
||||
return duration
|
||||
}
|
||||
const duration = ms(value)
|
||||
if (duration === undefined) {
|
||||
throw new TypeError(`not a valid duration: ${value}`)
|
||||
}
|
||||
throw new TypeError(`not a valid duration: ${value}`)
|
||||
return duration
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import { parseDuration } from '@vates/parse-duration'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
describe('parseDuration()', () => {
|
||||
it('should parse string', () => {
|
||||
const input = '2 days'
|
||||
const expected = 172800000
|
||||
assert.strictEqual(parseDuration(input), expected)
|
||||
})
|
||||
|
||||
it('should return its input if already a number', () => {
|
||||
const input = 172800000
|
||||
assert.strictEqual(parseDuration(input), input)
|
||||
})
|
||||
|
||||
for (const input of [undefined, '', 'invalid duration']) {
|
||||
it('should throw an error for ' + input, () => {
|
||||
assert.throws(() => parseDuration(input), { message: `not a valid duration: ${input}` })
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -27,7 +27,6 @@
|
||||
"ms": "^2.1.2"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public",
|
||||
"test": "node --test"
|
||||
"postversion": "npm publish --access public"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +44,6 @@ exports.makeOnProgress = function ({ onRootTaskEnd = noop, onRootTaskStart = noo
|
||||
} else if (type === 'property') {
|
||||
;(taskLog.properties ?? (taskLog.properties = { __proto__: null }))[event.name] = event.value
|
||||
} else if (type === 'end') {
|
||||
taskLogs.delete(id)
|
||||
|
||||
taskLog.end = event.timestamp
|
||||
taskLog.result = event.result
|
||||
taskLog.status = event.status
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
const assert = require('node:assert').strict
|
||||
const { AsyncLocalStorage } = require('node:async_hooks')
|
||||
|
||||
function alreadyEnded() {
|
||||
throw new Error('task has already ended')
|
||||
}
|
||||
|
||||
// define a read-only, non-enumerable, non-configurable property
|
||||
function define(object, property, value) {
|
||||
Object.defineProperty(object, property, { value })
|
||||
@@ -25,7 +21,7 @@ const asyncStorage = global[asyncStorageKey] ?? (global[asyncStorageKey] = new A
|
||||
|
||||
const getTask = () => asyncStorage.getStore()
|
||||
|
||||
class Task {
|
||||
exports.Task = class Task {
|
||||
static get abortSignal() {
|
||||
const task = getTask()
|
||||
if (task !== undefined) {
|
||||
@@ -33,6 +29,13 @@ class Task {
|
||||
}
|
||||
}
|
||||
|
||||
static info(message, data) {
|
||||
const task = getTask()
|
||||
if (task !== undefined) {
|
||||
task.#emit('info', { data, message })
|
||||
}
|
||||
}
|
||||
|
||||
static run(...args) {
|
||||
let opts = args[0]
|
||||
let fn
|
||||
@@ -54,6 +57,20 @@ class Task {
|
||||
return new Task(opts).run(() => fn.apply(thisArg, args))
|
||||
}
|
||||
|
||||
static set(name, value) {
|
||||
const task = getTask()
|
||||
if (task !== undefined) {
|
||||
task.#emit('property', { name, value })
|
||||
}
|
||||
}
|
||||
|
||||
static warning(message, data) {
|
||||
const task = getTask()
|
||||
if (task !== undefined) {
|
||||
task.#emit('warning', { data, message })
|
||||
}
|
||||
}
|
||||
|
||||
static wrap(opts, fn) {
|
||||
// compatibility with @decorateWith
|
||||
if (typeof fn !== 'function') {
|
||||
@@ -110,7 +127,9 @@ class Task {
|
||||
this.#emit('abortionRequested', { reason: signal.reason })
|
||||
|
||||
if (!this.#running) {
|
||||
this.#end(FAILURE, signal.reason)
|
||||
const status = FAILURE
|
||||
this.#status = status
|
||||
this.#emit('end', { result: signal.reason, status })
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -121,26 +140,12 @@ class Task {
|
||||
}
|
||||
|
||||
#emit(type, data) {
|
||||
assert.equal(this.#startData, undefined, 'task has not started yet')
|
||||
|
||||
data.id = this.id
|
||||
data.timestamp = Date.now()
|
||||
data.type = type
|
||||
this.#onProgress(data)
|
||||
}
|
||||
|
||||
#end(status, result) {
|
||||
assert.equal(this.#status, PENDING)
|
||||
|
||||
this.#status = status
|
||||
this.#emit('end', { status, result })
|
||||
this.#onProgress = alreadyEnded
|
||||
}
|
||||
|
||||
info(message, data) {
|
||||
this.#emit('info', { data, message })
|
||||
}
|
||||
|
||||
#maybeStart() {
|
||||
const startData = this.#startData
|
||||
if (startData !== undefined) {
|
||||
@@ -152,7 +157,8 @@ class Task {
|
||||
async run(fn) {
|
||||
const result = await this.runInside(fn)
|
||||
if (this.status === PENDING) {
|
||||
this.#end(SUCCESS, result)
|
||||
this.#status = SUCCESS
|
||||
this.#emit('end', { status: SUCCESS, result })
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -166,27 +172,18 @@ class Task {
|
||||
this.#maybeStart()
|
||||
|
||||
try {
|
||||
const result = await asyncStorage.run(this, fn, this)
|
||||
const result = await asyncStorage.run(this, fn)
|
||||
this.#running = false
|
||||
return result
|
||||
} catch (result) {
|
||||
this.#end(FAILURE, result)
|
||||
const status = FAILURE
|
||||
|
||||
this.#status = status
|
||||
this.#emit('end', { status, result })
|
||||
throw result
|
||||
}
|
||||
}
|
||||
|
||||
set(name, value) {
|
||||
assert.equal(this.status, PENDING)
|
||||
|
||||
this.#emit('property', { name, value })
|
||||
}
|
||||
|
||||
warning(message, data) {
|
||||
assert.equal(this.status, PENDING)
|
||||
|
||||
this.#emit('warning', { data, message })
|
||||
}
|
||||
|
||||
wrap(fn) {
|
||||
const task = this
|
||||
return function taskRun() {
|
||||
@@ -201,15 +198,3 @@ class Task {
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.Task = Task
|
||||
|
||||
// setup static aliases
|
||||
for (const name of ['info', 'set', 'warning']) {
|
||||
const method = Task.prototype[name]
|
||||
Task[name] = function () {
|
||||
const task = getTask()
|
||||
if (task !== undefined) {
|
||||
return method.apply(task, arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,31 +219,6 @@ describe('Task', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('#set()', function () {
|
||||
const name = 'progress'
|
||||
const value = 10
|
||||
|
||||
it('throws when the task is not started', function () {
|
||||
const task = createTask()
|
||||
assert.throws(() => task.set(name, value), { message: 'task has not started yet' })
|
||||
})
|
||||
|
||||
it(`emits an property message`, async function () {
|
||||
const task = createTask()
|
||||
await task.run(async () => {
|
||||
await Task.run(() => {
|
||||
task.set(name, value)
|
||||
|
||||
assertEvent(task, {
|
||||
name,
|
||||
type: 'property',
|
||||
value,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('.warning()', function () {
|
||||
it('does nothing when run outside a task', function () {
|
||||
Task.warning('foo')
|
||||
@@ -262,30 +237,6 @@ describe('Task', function () {
|
||||
})
|
||||
})
|
||||
|
||||
for (const type of ['info', 'warning']) {
|
||||
describe(`#${type}()`, function () {
|
||||
it('throws when the task is not started', function () {
|
||||
const task = createTask()
|
||||
assert.throws(() => task[type]('foo'), { message: 'task has not started yet' })
|
||||
})
|
||||
|
||||
it(`emits an ${type} message`, async function () {
|
||||
const task = createTask()
|
||||
await task.run(async () => {
|
||||
await Task.run(() => {
|
||||
task[type]('foo')
|
||||
|
||||
assertEvent(task, {
|
||||
data: undefined,
|
||||
message: 'foo',
|
||||
type,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('#id', function () {
|
||||
it('can be set', function () {
|
||||
const task = createTask()
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"license": "ISC",
|
||||
"version": "0.4.1",
|
||||
"version": "0.3.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^18.0.0",
|
||||
"sinon": "^17.0.1",
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"dependencies": {
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/backups": "^0.54.0",
|
||||
"@xen-orchestra/fs": "^4.1.7",
|
||||
"@xen-orchestra/backups": "^0.48.1",
|
||||
"@xen-orchestra/fs": "^4.1.6",
|
||||
"filenamify": "^6.0.0",
|
||||
"getopts": "^2.2.5",
|
||||
"lodash": "^4.17.15",
|
||||
@@ -27,7 +27,7 @@
|
||||
"scripts": {
|
||||
"postversion": "npm publish --access public"
|
||||
},
|
||||
"version": "1.0.25",
|
||||
"version": "1.0.18",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
|
||||
@@ -49,25 +49,26 @@ export class HealthCheckVmBackup {
|
||||
}
|
||||
|
||||
// wait for the 'Running' event to be really stored in local xapi object cache
|
||||
|
||||
restoredVm = await xapi.waitObjectState(restoredVm.$ref, vm => vm.power_state === 'Running', {
|
||||
timeout: remainingTimeout,
|
||||
timeoutMessage: refOrUuid =>
|
||||
`local xapi did not get Running state for VM ${refOrUuid} after ${timeout / 1000} second`,
|
||||
})
|
||||
|
||||
const running = new Date()
|
||||
remainingTimeout -= running - started
|
||||
|
||||
if (remainingTimeout < 0) {
|
||||
throw new Error(`local xapi did not get Running state for VM ${restoredId} after ${timeout / 1000} second`)
|
||||
}
|
||||
// wait for the guest tool version to be defined
|
||||
await xapi.waitObjectState(restoredVm.guest_metrics, gm => gm?.PV_drivers_version?.major !== undefined, {
|
||||
timeout: remainingTimeout,
|
||||
timeoutMessage: refOrUuid =>
|
||||
`timeout reached while waiting for ${refOrUuid} to report the driver version through the Xen tools. Please check or update the Xen tools.`,
|
||||
})
|
||||
|
||||
const guestToolsReady = new Date()
|
||||
remainingTimeout -= guestToolsReady - running
|
||||
if (remainingTimeout < 0) {
|
||||
throw new Error(`local xapi did not get he guest tools check ${restoredId} after ${timeout / 1000} second`)
|
||||
}
|
||||
|
||||
if (waitForScript) {
|
||||
const startedRestoredVm = await xapi.waitObjectState(
|
||||
@@ -78,10 +79,19 @@ export class HealthCheckVmBackup {
|
||||
vm.xenstore_data['vm-data/xo-backup-health-check'] === 'failure'),
|
||||
{
|
||||
timeout: remainingTimeout,
|
||||
timeoutMessage: refOrUuid =>
|
||||
`timeout reached while waiting for ${refOrUuid} to report the startup script execution.`,
|
||||
}
|
||||
)
|
||||
const scriptOk = new Date()
|
||||
remainingTimeout -= scriptOk - guestToolsReady
|
||||
if (remainingTimeout < 0) {
|
||||
throw new Error(
|
||||
`Backup health check script did not update vm-data/xo-backup-health-check of ${restoredId} after ${
|
||||
timeout / 1000
|
||||
} second, got ${
|
||||
startedRestoredVm.xenstore_data['vm-data/xo-backup-health-check']
|
||||
} instead of 'success' or 'failure'`
|
||||
)
|
||||
}
|
||||
|
||||
if (startedRestoredVm.xenstore_data['vm-data/xo-backup-health-check'] !== 'success') {
|
||||
const message = startedRestoredVm.xenstore_data['vm-data/xo-backup-health-check-error']
|
||||
|
||||
@@ -18,7 +18,6 @@ import fromEvent from 'promise-toolbox/fromEvent'
|
||||
import groupBy from 'lodash/groupBy.js'
|
||||
import pDefer from 'promise-toolbox/defer'
|
||||
import pickBy from 'lodash/pickBy.js'
|
||||
import reduce from 'lodash/reduce.js'
|
||||
import tar from 'tar'
|
||||
import zlib from 'zlib'
|
||||
|
||||
@@ -827,29 +826,6 @@ export class RemoteAdapter {
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
#computeTotalBackupSizeRecursively(backups) {
|
||||
return reduce(
|
||||
backups,
|
||||
(prev, backup) => {
|
||||
const _backup = Array.isArray(backup) ? this.#computeTotalBackupSizeRecursively(backup) : backup
|
||||
return {
|
||||
onDisk: prev.onDisk + (_backup.onDisk ?? _backup.size),
|
||||
}
|
||||
},
|
||||
{ onDisk: 0 }
|
||||
)
|
||||
}
|
||||
|
||||
async getTotalVmBackupSize() {
|
||||
return this.#computeTotalBackupSizeRecursively(await this.listAllVmBackups())
|
||||
}
|
||||
|
||||
async getTotalBackupSize() {
|
||||
const vmBackupSize = await this.getTotalVmBackupSize()
|
||||
// @TODO: add `getTotalXoBackupSize` and `getTotalPoolBackupSize` once `size` is implemented by fs
|
||||
return vmBackupSize
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(RemoteAdapter.prototype, {
|
||||
|
||||
@@ -23,7 +23,7 @@ createCachedLookup().patchGlobal()
|
||||
|
||||
const logger = createLogger('xo:backups:worker')
|
||||
catchGlobalErrors(logger)
|
||||
const { debug, info, warn } = logger
|
||||
const { debug, info } = logger
|
||||
|
||||
class BackupWorker {
|
||||
#config
|
||||
@@ -204,13 +204,6 @@ process.on('message', async message => {
|
||||
info('backup has ended')
|
||||
await ignoreErrors.call(backupWorker.debounceResource.flushAll())
|
||||
process.disconnect()
|
||||
|
||||
setTimeout(() => {
|
||||
warn('worker process did not exit automatically, forcing...')
|
||||
|
||||
// eslint-disable-next-line n/no-process-exit
|
||||
process.exit()
|
||||
}, 30e3).unref()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import sum from 'lodash/sum.js'
|
||||
import { asyncMap } from '@xen-orchestra/async-map'
|
||||
import { Constants, openVhd, VhdAbstract, VhdFile } from 'vhd-lib'
|
||||
import { isVhdAlias, resolveVhdAlias } from 'vhd-lib/aliases.js'
|
||||
import { basename, dirname, resolve } from 'node:path'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { isMetadataFile, isVhdFile, isXvaFile, isXvaSumFile } from './_backupType.mjs'
|
||||
import { limitConcurrency } from 'limit-concurrency-decorator'
|
||||
import { mergeVhdChain } from 'vhd-lib/merge.js'
|
||||
@@ -179,8 +179,7 @@ export async function cleanVm(
|
||||
vmDir,
|
||||
{
|
||||
fixMetadata,
|
||||
remove = false,
|
||||
removeTmp = remove,
|
||||
remove,
|
||||
merge,
|
||||
mergeBlockConcurrency,
|
||||
mergeLimiter = defaultMergeLimiter,
|
||||
@@ -201,10 +200,6 @@ export async function cleanVm(
|
||||
|
||||
// remove broken VHDs
|
||||
await asyncMap(vhds, async path => {
|
||||
if(removeTmp && basename(path)[0] === '.'){
|
||||
logInfo('deleting temporary VHD', { path })
|
||||
return VhdAbstract.unlink(handler, path)
|
||||
}
|
||||
try {
|
||||
await Disposable.use(openVhd(handler, path, { checkSecondFooter: !interruptedVhds.has(path) }), vhd => {
|
||||
if (vhd.footer.diskType === DISK_TYPES.DIFFERENCING) {
|
||||
|
||||
@@ -1,23 +1,52 @@
|
||||
import groupBy from 'lodash/groupBy.js'
|
||||
import ignoreErrors from 'promise-toolbox/ignoreErrors'
|
||||
import omit from 'lodash/omit.js'
|
||||
import { asyncMap } from '@xen-orchestra/async-map'
|
||||
import { CancelToken } from 'promise-toolbox'
|
||||
import { compareVersions } from 'compare-versions'
|
||||
import { createVhdStreamWithLength } from 'vhd-lib'
|
||||
import { defer } from 'golike-defer'
|
||||
|
||||
import { cancelableMap } from './_cancelableMap.mjs'
|
||||
import { Task } from './Task.mjs'
|
||||
import pick from 'lodash/pick.js'
|
||||
import { BASE_DELTA_VDI, COPY_OF, VM_UUID } from './_otherConfig.mjs'
|
||||
|
||||
// in `other_config` of an incrementally replicated VM, contains the UUID of the source VM
|
||||
export const TAG_BASE_DELTA = 'xo:base_delta'
|
||||
|
||||
// in `other_config` of an incrementally replicated VM, contains the UUID of the target SR used for replication
|
||||
//
|
||||
// added after the complete replication
|
||||
export const TAG_BACKUP_SR = 'xo:backup:sr'
|
||||
|
||||
// in other_config of VDIs of an incrementally replicated VM, contains the UUID of the source VDI
|
||||
export const TAG_COPY_SRC = 'xo:copy_of'
|
||||
|
||||
const ensureArray = value => (value === undefined ? [] : Array.isArray(value) ? value : [value])
|
||||
|
||||
export async function exportIncrementalVm(
|
||||
vm,
|
||||
baseVdis = {},
|
||||
{ cancelToken = CancelToken.none, nbdConcurrency = 1, preferNbd } = {}
|
||||
baseVm,
|
||||
{
|
||||
cancelToken = CancelToken.none,
|
||||
|
||||
// Sets of UUIDs of VDIs that must be exported as full.
|
||||
fullVdisRequired = new Set(),
|
||||
|
||||
disableBaseTags = false,
|
||||
nbdConcurrency = 1,
|
||||
preferNbd,
|
||||
} = {}
|
||||
) {
|
||||
// refs of VM's VDIs → base's VDIs.
|
||||
const baseVdis = {}
|
||||
baseVm &&
|
||||
baseVm.$VBDs.forEach(vbd => {
|
||||
let vdi, snapshotOf
|
||||
if ((vdi = vbd.$VDI) && (snapshotOf = vdi.$snapshot_of) && !fullVdisRequired.has(snapshotOf.uuid)) {
|
||||
baseVdis[vdi.snapshot_of] = vdi
|
||||
}
|
||||
})
|
||||
|
||||
const streams = {}
|
||||
const vdis = {}
|
||||
@@ -38,16 +67,13 @@ export async function exportIncrementalVm(
|
||||
}
|
||||
|
||||
// Look for a snapshot of this vdi in the base VM.
|
||||
const baseVdi = baseVdis[vdi.$snapshot_of.uuid]
|
||||
const baseVdi = baseVdis[vdi.snapshot_of]
|
||||
|
||||
vdis[vdiRef] = {
|
||||
...vdi,
|
||||
other_config: {
|
||||
...vdi.other_config,
|
||||
[BASE_DELTA_VDI]: baseVdi?.uuid,
|
||||
[VM_UUID]:
|
||||
vm.$snapshot_of?.uuid ?? // vm is a snapshot
|
||||
vm.uuid, // vm is a not snapshot
|
||||
[TAG_BASE_DELTA]: baseVdi && !disableBaseTags ? baseVdi.uuid : undefined,
|
||||
},
|
||||
$snapshot_of$uuid: vdi.$snapshot_of?.uuid,
|
||||
$SR$uuid: vdi.$SR.uuid,
|
||||
@@ -94,6 +120,13 @@ export async function exportIncrementalVm(
|
||||
vifs,
|
||||
vm: {
|
||||
...vm,
|
||||
other_config:
|
||||
baseVm && !disableBaseTags
|
||||
? {
|
||||
...vm.other_config,
|
||||
[TAG_BASE_DELTA]: baseVm.uuid,
|
||||
}
|
||||
: omit(vm.other_config, TAG_BASE_DELTA),
|
||||
},
|
||||
},
|
||||
'streams',
|
||||
@@ -172,7 +205,7 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
||||
newVdi = await xapi.getRecord('VDI', await vdi.baseVdi.$clone())
|
||||
$defer.onFailure(() => newVdi.$destroy())
|
||||
|
||||
await newVdi.update_other_config(COPY_OF, vdi.uuid)
|
||||
await newVdi.update_other_config(TAG_COPY_SRC, vdi.uuid)
|
||||
if (vdi.virtual_size > newVdi.virtual_size) {
|
||||
await newVdi.$callAsync('resize', vdi.virtual_size)
|
||||
}
|
||||
@@ -226,6 +259,9 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
|
||||
if (typeof stream === 'function') {
|
||||
stream = await stream()
|
||||
}
|
||||
if (stream.length === undefined) {
|
||||
stream = await createVhdStreamWithLength(stream)
|
||||
}
|
||||
await xapi.setField('VDI', vdi.$ref, 'name_label', `[Importing] ${vdiRecords[id].name_label}`)
|
||||
await vdi.$importContent(stream, { cancelToken, format: 'vhd' })
|
||||
await xapi.setField('VDI', vdi.$ref, 'name_label', vdiRecords[id].name_label)
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import { formatDateTime } from '@xen-orchestra/xapi'
|
||||
import assert from 'node:assert/strict'
|
||||
// in `other_config` of an incrementally replicated VM or VDI
|
||||
// contains the UUID of the object used as a base for an incremental export
|
||||
// used to search for the replica of the base before applying a incremental replication
|
||||
export const BASE_DELTA_VDI = 'xo:base_delta_vdi'
|
||||
|
||||
// in `other_config` of an incrementally replicated VM, contains the UUID of the target SR used for replication
|
||||
//
|
||||
// added after the complete replication
|
||||
export const REPLICATED_TO_SR_UUID = 'xo:backup:sr'
|
||||
|
||||
// in other_config of VDIs of an incrementally replicated VM
|
||||
// contains the UUID of the source exported object (snapshot or VM)
|
||||
|
||||
export const COPY_OF = 'xo:copy_of'
|
||||
|
||||
export const DATETIME = 'xo:backup:datetime'
|
||||
|
||||
export const JOB_ID = 'xo:backup:job'
|
||||
export const SCHEDULE_ID = 'xo:backup:schedule'
|
||||
|
||||
// contains the number of delta in a chain, stored as a string
|
||||
export const DELTA_CHAIN_LENGTH = 'xo:backup:deltaChainLength'
|
||||
// contains the string true if this vdi has been exported successfully
|
||||
export const EXPORTED_SUCCESSFULLY = 'xo:backup:exported'
|
||||
|
||||
// the VM ( not the snapshot) uuid
|
||||
export const VM_UUID = 'xo:backup:vm'
|
||||
|
||||
async function listVdiRefs(xapi, vmRef) {
|
||||
return xapi.VM_getDisks(vmRef)
|
||||
}
|
||||
|
||||
async function applyToVmAndVdis(xapi, vmRef, fn) {
|
||||
const vdiRefs = await listVdiRefs(xapi, vmRef)
|
||||
return Promise.all([fn('VM', vmRef), ...vdiRefs.map(vdiRef => fn('VDI', vdiRef))])
|
||||
}
|
||||
|
||||
async function getDeltaChainLength(xapi, type, ref) {
|
||||
const otherConfig = await xapi.getField(type, ref, 'other_config')
|
||||
return Number(otherConfig[DELTA_CHAIN_LENGTH] ?? 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* set the delta chain lenght ( number of delta since last base backup) to a VM and its associated VDIs
|
||||
*
|
||||
* @param {Xapi} xapi
|
||||
* @param {String} vmRef
|
||||
* @param {Number} length
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function setVmDeltaChainLength(xapi, vmRef, length) {
|
||||
return applyToVmAndVdis(xapi, vmRef, async (type, ref) => {
|
||||
await xapi.setFieldEntry(type, ref, 'other_config', DELTA_CHAIN_LENGTH, String(length))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the delta chain length of a VM and its associated VDIs
|
||||
* if there is a discrependcy, use, the highest value
|
||||
* @param {Xapi} xapi
|
||||
* @param {String} vmRef
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function getVmDeltaChainLength(xapi, vmRef) {
|
||||
const lengths = await applyToVmAndVdis(xapi, vmRef, async (type, ref) => getDeltaChainLength(xapi, type, ref))
|
||||
return Math.max(...lengths)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Reset the other_config field of a VM and its VDIs
|
||||
*
|
||||
* @param {Xapi} xapi
|
||||
* @param {String} vmRef
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function resetVmOtherConfig(xapi, vmRef) {
|
||||
return applyToVmAndVdis(xapi, vmRef, (type, ref) => {
|
||||
return xapi.setFieldEntries(type, ref, 'other_config', {
|
||||
[DATETIME]: null,
|
||||
[DELTA_CHAIN_LENGTH]: null,
|
||||
[EXPORTED_SUCCESSFULLY]: null,
|
||||
[JOB_ID]: null,
|
||||
[SCHEDULE_ID]: null,
|
||||
[VM_UUID]: null,
|
||||
// REPLICATED_TO_SR_UUID is not reset since we can replicate a replication
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* used to ensure compatibiliy with the previous snapshots that were having the config stored only into VM
|
||||
*
|
||||
* @param {Xapi} xapi
|
||||
* @param {String} vmRef
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function populateVdisOtherConfig(xapi, vmRef) {
|
||||
const otherConfig = await xapi.getField('VM', vmRef, 'other_config')
|
||||
const {
|
||||
[DATETIME]: datetime,
|
||||
[DELTA_CHAIN_LENGTH]: chainLength,
|
||||
[EXPORTED_SUCCESSFULLY]: successfully,
|
||||
[JOB_ID]: jobId,
|
||||
[REPLICATED_TO_SR_UUID]: replicatedTo,
|
||||
[SCHEDULE_ID]: scheduleId,
|
||||
[VM_UUID]: vmUuid,
|
||||
} = otherConfig
|
||||
|
||||
return applyToVmAndVdis(xapi, vmRef, (type, ref) =>
|
||||
xapi.setFieldEntries(type, ref, 'other_config', {
|
||||
[DATETIME]: datetime,
|
||||
[DELTA_CHAIN_LENGTH]: chainLength,
|
||||
[EXPORTED_SUCCESSFULLY]: successfully,
|
||||
[JOB_ID]: jobId,
|
||||
[REPLICATED_TO_SR_UUID]: replicatedTo,
|
||||
[SCHEDULE_ID]: scheduleId,
|
||||
[VM_UUID]: vmUuid,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* set the other_config key related to a backup of a VM and its associated VDIs
|
||||
*
|
||||
* @param {Xapi} xapi
|
||||
* @param {String} vmRef
|
||||
* @param {*} settings
|
||||
* @returns {PRomise}
|
||||
*/
|
||||
export async function setVmOtherConfig(xapi, vmRef, { timestamp, jobId, scheduleId, vmUuid, srUuid = null, ...other }) {
|
||||
assert.notEqual(timestamp, undefined)
|
||||
assert.notEqual(jobId, undefined)
|
||||
assert.notEqual(scheduleId, undefined)
|
||||
assert.notEqual(vmUuid, undefined)
|
||||
// srUuid is nullish for backup
|
||||
assert.equal(Object.keys(other).length, 0)
|
||||
|
||||
return applyToVmAndVdis(xapi, vmRef, (type, ref) =>
|
||||
xapi.setFieldEntries(type, ref, 'other_config', {
|
||||
[REPLICATED_TO_SR_UUID]: srUuid,
|
||||
[DATETIME]: formatDateTime(timestamp),
|
||||
[JOB_ID]: jobId,
|
||||
[SCHEDULE_ID]: scheduleId,
|
||||
[VM_UUID]: vmUuid,
|
||||
})
|
||||
)
|
||||
}
|
||||
/**
|
||||
*
|
||||
* mark the export of he VM and its VDIs as successfull
|
||||
*
|
||||
* @param {Xapi} xapi
|
||||
* @param {String} vmRef
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function markExportSuccessfull(xapi, vmRef) {
|
||||
return applyToVmAndVdis(xapi, vmRef, (type, ref) =>
|
||||
xapi.setFieldEntry(type, ref, 'other_config', EXPORTED_SUCCESSFULLY, 'true')
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,6 @@ const noop = Function.prototype
|
||||
|
||||
const DEFAULT_XAPI_VM_SETTINGS = {
|
||||
bypassVdiChainsCheck: false,
|
||||
cbtDestroySnapshotData: false,
|
||||
checkpointSnapshot: false,
|
||||
concurrency: 2,
|
||||
copyRetention: 0,
|
||||
|
||||
@@ -29,10 +29,6 @@ export function forkStreamUnpipe(source) {
|
||||
if (source.forks === 0) {
|
||||
debug('no more forks, destroying original stream')
|
||||
source.destroy(new Error('no more consumers for this stream'))
|
||||
} else {
|
||||
// a combination of stream.unpipe, onReadable and onData may stall stream here
|
||||
// force it to flow again since we're piping it
|
||||
source.resume()
|
||||
}
|
||||
})
|
||||
return fork
|
||||
|
||||
@@ -2,39 +2,39 @@ import { AbstractRemote } from './_AbstractRemote.mjs'
|
||||
import { FullRemoteWriter } from '../_writers/FullRemoteWriter.mjs'
|
||||
import { forkStreamUnpipe } from '../_forkStreamUnpipe.mjs'
|
||||
import { watchStreamSize } from '../../_watchStreamSize.mjs'
|
||||
import { Task } from '../../Task.mjs'
|
||||
|
||||
export const FullRemote = class FullRemoteVmBackupRunner extends AbstractRemote {
|
||||
_getRemoteWriter() {
|
||||
return FullRemoteWriter
|
||||
}
|
||||
|
||||
_filterTransferList(transferList) {
|
||||
return transferList.filter(this._filterPredicate)
|
||||
}
|
||||
|
||||
async _run() {
|
||||
const transferList = await this._computeTransferList(({ mode }) => mode === 'full')
|
||||
|
||||
for (const metadata of transferList) {
|
||||
const stream = await this._sourceRemoteAdapter.readFullVmBackup(metadata)
|
||||
const sizeContainer = watchStreamSize(stream)
|
||||
if (transferList.length > 0) {
|
||||
for (const metadata of transferList) {
|
||||
const stream = await this._sourceRemoteAdapter.readFullVmBackup(metadata)
|
||||
const sizeContainer = watchStreamSize(stream)
|
||||
|
||||
// @todo shouldn't transfer backup if it will be deleted by retention policy (higher retention on source than destination)
|
||||
await this._callWriters(
|
||||
writer =>
|
||||
writer.run({
|
||||
stream: forkStreamUnpipe(stream),
|
||||
// stream will be forked and transformed, it's not safe to attach additionnal properties to it
|
||||
streamLength: stream.length,
|
||||
timestamp: metadata.timestamp,
|
||||
vm: metadata.vm,
|
||||
vmSnapshot: metadata.vmSnapshot,
|
||||
sizeContainer,
|
||||
}),
|
||||
'writer.run()'
|
||||
)
|
||||
// for healthcheck
|
||||
this._tags = metadata.vm.tags
|
||||
// @todo shouldn't transfer backup if it will be deleted by retention policy (higher retention on source than destination)
|
||||
await this._callWriters(
|
||||
writer =>
|
||||
writer.run({
|
||||
stream: forkStreamUnpipe(stream),
|
||||
// stream will be forked and transformed, it's not safe to attach additionnal properties to it
|
||||
streamLength: stream.length,
|
||||
timestamp: metadata.timestamp,
|
||||
vm: metadata.vm,
|
||||
vmSnapshot: metadata.vmSnapshot,
|
||||
sizeContainer,
|
||||
}),
|
||||
'writer.run()'
|
||||
)
|
||||
// for healthcheck
|
||||
this._tags = metadata.vm.tags
|
||||
}
|
||||
} else {
|
||||
Task.info('No new data to upload for this VM')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import mapValues from 'lodash/mapValues.js'
|
||||
import { AbstractRemote } from './_AbstractRemote.mjs'
|
||||
import { forkDeltaExport } from './_forkDeltaExport.mjs'
|
||||
import { IncrementalRemoteWriter } from '../_writers/IncrementalRemoteWriter.mjs'
|
||||
import { Task } from '../../Task.mjs'
|
||||
import { Disposable } from 'promise-toolbox'
|
||||
import { openVhd } from 'vhd-lib'
|
||||
import { getVmBackupDir } from '../../_getVmBackupDir.mjs'
|
||||
@@ -15,16 +16,6 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
|
||||
_getRemoteWriter() {
|
||||
return IncrementalRemoteWriter
|
||||
}
|
||||
|
||||
// we'll transfer the full list if at least one backup should be transfered
|
||||
// to ensure we don't cut the delta chain
|
||||
_filterTransferList(transferList) {
|
||||
if (transferList.some(vmBackupMetadata => this._filterPredicate(vmBackupMetadata))) {
|
||||
return transferList
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
async _selectBaseVm(metadata) {
|
||||
// for each disk , get the parent
|
||||
const baseUuidToSrcVdi = new Map()
|
||||
@@ -62,46 +53,50 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
|
||||
async _run() {
|
||||
const transferList = await this._computeTransferList(({ mode }) => mode === 'delta')
|
||||
|
||||
for (const metadata of transferList) {
|
||||
assert.strictEqual(metadata.mode, 'delta')
|
||||
await this._selectBaseVm(metadata)
|
||||
await this._callWriters(writer => writer.prepare({ isBase: metadata.isBase }), 'writer.prepare()')
|
||||
const incrementalExport = await this._sourceRemoteAdapter.readIncrementalVmBackup(metadata, undefined, {
|
||||
useChain: false,
|
||||
})
|
||||
if (transferList.length > 0) {
|
||||
for (const metadata of transferList) {
|
||||
assert.strictEqual(metadata.mode, 'delta')
|
||||
await this._selectBaseVm(metadata)
|
||||
await this._callWriters(writer => writer.prepare({ isBase: metadata.isBase }), 'writer.prepare()')
|
||||
const incrementalExport = await this._sourceRemoteAdapter.readIncrementalVmBackup(metadata, undefined, {
|
||||
useChain: false,
|
||||
})
|
||||
|
||||
const isVhdDifferencing = {}
|
||||
const isVhdDifferencing = {}
|
||||
|
||||
await asyncEach(Object.entries(incrementalExport.streams), async ([key, stream]) => {
|
||||
isVhdDifferencing[key] = await isVhdDifferencingDisk(stream)
|
||||
})
|
||||
await asyncEach(Object.entries(incrementalExport.streams), async ([key, stream]) => {
|
||||
isVhdDifferencing[key] = await isVhdDifferencingDisk(stream)
|
||||
})
|
||||
|
||||
incrementalExport.streams = mapValues(incrementalExport.streams, this._throttleStream)
|
||||
await this._callWriters(
|
||||
writer =>
|
||||
writer.transfer({
|
||||
deltaExport: forkDeltaExport(incrementalExport),
|
||||
isVhdDifferencing,
|
||||
timestamp: metadata.timestamp,
|
||||
vm: metadata.vm,
|
||||
vmSnapshot: metadata.vmSnapshot,
|
||||
}),
|
||||
'writer.transfer()'
|
||||
)
|
||||
// this will update parent name with the needed alias
|
||||
await this._callWriters(
|
||||
writer =>
|
||||
writer.updateUuidAndChain({
|
||||
isVhdDifferencing,
|
||||
timestamp: metadata.timestamp,
|
||||
vdis: incrementalExport.vdis,
|
||||
}),
|
||||
'writer.updateUuidAndChain()'
|
||||
)
|
||||
incrementalExport.streams = mapValues(incrementalExport.streams, this._throttleStream)
|
||||
await this._callWriters(
|
||||
writer =>
|
||||
writer.transfer({
|
||||
deltaExport: forkDeltaExport(incrementalExport),
|
||||
isVhdDifferencing,
|
||||
timestamp: metadata.timestamp,
|
||||
vm: metadata.vm,
|
||||
vmSnapshot: metadata.vmSnapshot,
|
||||
}),
|
||||
'writer.transfer()'
|
||||
)
|
||||
// this will update parent name with the needed alias
|
||||
await this._callWriters(
|
||||
writer =>
|
||||
writer.updateUuidAndChain({
|
||||
isVhdDifferencing,
|
||||
timestamp: metadata.timestamp,
|
||||
vdis: incrementalExport.vdis,
|
||||
}),
|
||||
'writer.updateUuidAndChain()'
|
||||
)
|
||||
|
||||
await this._callWriters(writer => writer.cleanup(), 'writer.cleanup()')
|
||||
// for healthcheck
|
||||
this._tags = metadata.vm.tags
|
||||
await this._callWriters(writer => writer.cleanup(), 'writer.cleanup()')
|
||||
// for healthcheck
|
||||
this._tags = metadata.vm.tags
|
||||
}
|
||||
} else {
|
||||
Task.info('No new data to upload for this VM')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { asyncEach } from '@vates/async-each'
|
||||
import { asyncMap } from '@xen-orchestra/async-map'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { pipeline } from 'node:stream'
|
||||
import findLast from 'lodash/findLast.js'
|
||||
import isVhdDifferencingDisk from 'vhd-lib/isVhdDifferencingDisk.js'
|
||||
import keyBy from 'lodash/keyBy.js'
|
||||
import mapValues from 'lodash/mapValues.js'
|
||||
@@ -13,15 +15,9 @@ import { IncrementalRemoteWriter } from '../_writers/IncrementalRemoteWriter.mjs
|
||||
import { IncrementalXapiWriter } from '../_writers/IncrementalXapiWriter.mjs'
|
||||
import { Task } from '../../Task.mjs'
|
||||
import { watchStreamSize } from '../../_watchStreamSize.mjs'
|
||||
import {
|
||||
DATETIME,
|
||||
DELTA_CHAIN_LENGTH,
|
||||
EXPORTED_SUCCESSFULLY,
|
||||
setVmDeltaChainLength,
|
||||
markExportSuccessfull,
|
||||
} from '../../_otherConfig.mjs'
|
||||
|
||||
const { debug } = createLogger('xo:backups:IncrementalXapiVmBackup')
|
||||
|
||||
const noop = Function.prototype
|
||||
|
||||
export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends AbstractXapi {
|
||||
@@ -34,14 +30,17 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
|
||||
}
|
||||
|
||||
async _copy() {
|
||||
const baseVdis = this._baseVdis
|
||||
const baseVm = this._baseVm
|
||||
const vm = this._vm
|
||||
const exportedVm = this._exportedVm
|
||||
const fullVdisRequired = this._fullVdisRequired
|
||||
|
||||
const isFull = fullVdisRequired === undefined || fullVdisRequired.size !== 0
|
||||
|
||||
const isFull = Object.values(baseVdis).length === 0 || Object.values(baseVdis).some(_ => !_)
|
||||
await this._callWriters(writer => writer.prepare({ isFull }), 'writer.prepare()')
|
||||
|
||||
const deltaExport = await exportIncrementalVm(exportedVm, baseVdis, {
|
||||
const deltaExport = await exportIncrementalVm(exportedVm, baseVm, {
|
||||
fullVdisRequired,
|
||||
nbdConcurrency: this._settings.nbdConcurrency,
|
||||
preferNbd: this._settings.preferNbd,
|
||||
})
|
||||
@@ -91,13 +90,18 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
|
||||
'writer.updateUuidAndChain()'
|
||||
)
|
||||
|
||||
if (!isFull) {
|
||||
await setVmDeltaChainLength(this._xapi, exportedVm.$ref, (this._deltaChainLength ?? 0) + 1)
|
||||
} // on a full the delta chain will be null
|
||||
this._baseVm = exportedVm
|
||||
|
||||
if (baseVm !== undefined) {
|
||||
await exportedVm.update_other_config(
|
||||
'xo:backup:deltaChainLength',
|
||||
String(+(baseVm.other_config['xo:backup:deltaChainLength'] ?? 0) + 1)
|
||||
)
|
||||
}
|
||||
|
||||
// not the case if offlineBackup
|
||||
if (exportedVm.is_a_snapshot) {
|
||||
await markExportSuccessfull(this._xapi, exportedVm.$ref)
|
||||
await exportedVm.update_other_config('xo:backup:exported', 'true')
|
||||
}
|
||||
|
||||
const size = Object.values(sizeContainers).reduce((sum, { size }) => sum + size, 0)
|
||||
@@ -115,67 +119,43 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
|
||||
async _selectBaseVm() {
|
||||
const xapi = this._xapi
|
||||
|
||||
// filter _jobSnapshotVdis to have only the last successfully exported vdi
|
||||
// compute delta chain length
|
||||
// fill baseUuidToSrcVdi with this data
|
||||
|
||||
const exportedVdis = this._jobSnapshotVdis.filter(
|
||||
_ => EXPORTED_SUCCESSFULLY in _.other_config && DATETIME in _.other_config
|
||||
)
|
||||
let lastSuccessfullBackup
|
||||
let lastExportedVdis = []
|
||||
for (const exportedVdi of exportedVdis) {
|
||||
if (lastSuccessfullBackup === undefined || lastSuccessfullBackup < exportedVdi.other_config[DATETIME]) {
|
||||
lastExportedVdis = []
|
||||
lastSuccessfullBackup = exportedVdi.other_config[DATETIME]
|
||||
}
|
||||
if (lastSuccessfullBackup === exportedVdi.other_config[DATETIME]) {
|
||||
lastExportedVdis.push(exportedVdi)
|
||||
}
|
||||
}
|
||||
|
||||
this._baseVdis = {}
|
||||
if (lastExportedVdis.length === 0) {
|
||||
debug('no base VDIS found', {
|
||||
jobLength: this._jobSnapshotVdis.length,
|
||||
lastSuccessfullBackup,
|
||||
exportedLength: exportedVdis.length,
|
||||
})
|
||||
let baseVm = findLast(this._jobSnapshots, _ => 'xo:backup:exported' in _.other_config)
|
||||
if (baseVm === undefined) {
|
||||
debug('no base VM found')
|
||||
return
|
||||
}
|
||||
const deltaChainLength = Math.max(
|
||||
...lastExportedVdis.map(({ other_config }) => Number(other_config[DELTA_CHAIN_LENGTH] ?? 0))
|
||||
)
|
||||
|
||||
const fullInterval = this._settings.fullInterval
|
||||
if (fullInterval !== 0 && fullInterval <= deltaChainLength + 1) {
|
||||
debug('not using base VM because fullInterval reached', {
|
||||
fullInterval,
|
||||
deltaChainLength,
|
||||
eq: fullInterval < deltaChainLength + 1,
|
||||
dc1: deltaChainLength + 1,
|
||||
})
|
||||
const deltaChainLength = +(baseVm.other_config['xo:backup:deltaChainLength'] ?? 0) + 1
|
||||
if (!(fullInterval === 0 || fullInterval > deltaChainLength)) {
|
||||
debug('not using base VM becaust fullInterval reached')
|
||||
return
|
||||
}
|
||||
|
||||
const srcVdis = keyBy(await xapi.getRecords('VDI', await this._vm.$getDisks()), '$ref')
|
||||
|
||||
const baseUuidToSrcVdiUuid = new Map()
|
||||
for (const lastExportedVdi of lastExportedVdis) {
|
||||
const baseUuid = lastExportedVdi.uuid
|
||||
const snapshotOf = lastExportedVdi.snapshot_of
|
||||
// resolve full record
|
||||
baseVm = await xapi.getRecord('VM', baseVm.$ref)
|
||||
|
||||
const baseUuidToSrcVdi = new Map()
|
||||
await asyncMap(await baseVm.$getDisks(), async baseRef => {
|
||||
const [baseUuid, snapshotOf] = await Promise.all([
|
||||
xapi.getField('VDI', baseRef, 'uuid'),
|
||||
xapi.getField('VDI', baseRef, 'snapshot_of'),
|
||||
])
|
||||
const srcVdi = srcVdis[snapshotOf]
|
||||
if (srcVdi !== undefined) {
|
||||
baseUuidToSrcVdiUuid.set(baseUuid, srcVdi.uuid)
|
||||
baseUuidToSrcVdi.set(baseUuid, srcVdi.uuid)
|
||||
} else {
|
||||
debug('ignore snapshot VDI because no longer present on VM', {
|
||||
vdi: baseUuid,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const presentBaseVdis = new Map(baseUuidToSrcVdiUuid)
|
||||
const presentBaseVdis = new Map(baseUuidToSrcVdi)
|
||||
await this._callWriters(
|
||||
writer => presentBaseVdis.size !== 0 && writer.checkBaseVdis(presentBaseVdis),
|
||||
writer => presentBaseVdis.size !== 0 && writer.checkBaseVdis(presentBaseVdis, baseVm),
|
||||
'writer.checkBaseVdis()',
|
||||
false
|
||||
)
|
||||
@@ -184,22 +164,24 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
|
||||
debug('no base VM found')
|
||||
return
|
||||
}
|
||||
baseUuidToSrcVdiUuid.forEach((srcVdiUuid, baseUuid) => {
|
||||
|
||||
const fullVdisRequired = new Set()
|
||||
baseUuidToSrcVdi.forEach((srcVdiUuid, baseUuid) => {
|
||||
if (presentBaseVdis.has(baseUuid)) {
|
||||
debug('found base VDI', {
|
||||
base: baseUuid,
|
||||
vdi: srcVdiUuid,
|
||||
})
|
||||
this._baseVdis[srcVdiUuid] = lastExportedVdis.find(vdi => vdi.uuid === baseUuid)
|
||||
} else {
|
||||
debug('missing base VDI', {
|
||||
base: baseUuid,
|
||||
vdi: srcVdiUuid,
|
||||
})
|
||||
// this will force a full for this vdi
|
||||
this._baseVdis[srcVdiUuid] = undefined
|
||||
fullVdisRequired.add(srcVdiUuid)
|
||||
}
|
||||
})
|
||||
this._deltaChainLength = deltaChainLength
|
||||
|
||||
this._baseVm = baseVm
|
||||
this._fullVdisRequired = fullVdisRequired
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import groupBy from 'lodash/groupBy.js'
|
||||
|
||||
import { asyncEach } from '@vates/async-each'
|
||||
import { decorateMethodsWith } from '@vates/decorate-with'
|
||||
import { defer } from 'golike-defer'
|
||||
import { Disposable } from 'promise-toolbox'
|
||||
import { createPredicate } from 'value-matcher'
|
||||
|
||||
import { getVmBackupDir } from '../../_getVmBackupDir.mjs'
|
||||
|
||||
import { Abstract } from './_Abstract.mjs'
|
||||
import { extractIdsFromSimplePattern } from '../../extractIdsFromSimplePattern.mjs'
|
||||
import { Task } from '../../Task.mjs'
|
||||
|
||||
export const AbstractRemote = class AbstractRemoteVmBackupRunner extends Abstract {
|
||||
_filterPredicate
|
||||
constructor({
|
||||
config,
|
||||
job,
|
||||
@@ -61,74 +57,39 @@ export const AbstractRemote = class AbstractRemoteVmBackupRunner extends Abstrac
|
||||
})
|
||||
)
|
||||
})
|
||||
const { filter } = job
|
||||
if (filter === undefined) {
|
||||
this._filterPredicate = () => true
|
||||
} else {
|
||||
this._filterPredicate = createPredicate(filter)
|
||||
}
|
||||
}
|
||||
|
||||
async #computeTransferListPerJob(sourceBackups, remotesBackups) {
|
||||
async _computeTransferList(predicate) {
|
||||
const vmBackups = await this._sourceRemoteAdapter.listVmBackups(this._vmUuid, predicate)
|
||||
const localMetada = new Map()
|
||||
sourceBackups.forEach(metadata => {
|
||||
Object.values(vmBackups).forEach(metadata => {
|
||||
const timestamp = metadata.timestamp
|
||||
localMetada.set(timestamp, metadata)
|
||||
})
|
||||
const nbRemotes = remotesBackups.length
|
||||
const nbRemotes = Object.keys(this.remoteAdapters).length
|
||||
const remoteMetadatas = {}
|
||||
remotesBackups.forEach(async remoteBackups => {
|
||||
remoteBackups.forEach(metadata => {
|
||||
await asyncEach(Object.values(this.remoteAdapters), async remoteAdapter => {
|
||||
const remoteMetadata = await remoteAdapter.listVmBackups(this._vmUuid, predicate)
|
||||
remoteMetadata.forEach(metadata => {
|
||||
const timestamp = metadata.timestamp
|
||||
remoteMetadatas[timestamp] = (remoteMetadatas[timestamp] ?? 0) + 1
|
||||
})
|
||||
})
|
||||
|
||||
let transferList = []
|
||||
let chain = []
|
||||
const timestamps = [...localMetada.keys()]
|
||||
timestamps.sort()
|
||||
for (const timestamp of timestamps) {
|
||||
if (remoteMetadatas[timestamp] !== nbRemotes) {
|
||||
// this backup is not present in all the remote
|
||||
// should be retransfered if not found later
|
||||
transferList.push(localMetada.get(timestamp))
|
||||
chain.push(localMetada.get(timestamp))
|
||||
} else {
|
||||
// backup is present in local and remote : the chain has already been transferred
|
||||
transferList = []
|
||||
chain = []
|
||||
}
|
||||
}
|
||||
if (transferList.length > 0) {
|
||||
const filteredTransferList = this._filterTransferList(transferList)
|
||||
if (filteredTransferList.length > 0) {
|
||||
return filteredTransferList
|
||||
} else {
|
||||
Task.info('This VM is excluded by the job filter')
|
||||
return []
|
||||
}
|
||||
} else {
|
||||
Task.info('No new data to upload for this VM')
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} vmPredicate a callback checking if backup is eligible for transfer. This filter MUST NOT cut delta chains
|
||||
* @returns
|
||||
*/
|
||||
async _computeTransferList(vmPredicate) {
|
||||
const sourceBackups = Object.values(await this._sourceRemoteAdapter.listVmBackups(this._vmUuid, vmPredicate))
|
||||
const remotesBackups = await Promise.all(
|
||||
Object.values(this.remoteAdapters).map(remoteAdapter => remoteAdapter.listVmBackups(this._vmUuid, vmPredicate))
|
||||
)
|
||||
const sourceBackupByJobId = groupBy(sourceBackups, 'jobId')
|
||||
const transferByJobs = await Promise.all(
|
||||
Object.values(sourceBackupByJobId).map(vmBackupsByJob =>
|
||||
this.#computeTransferListPerJob(vmBackupsByJob, remotesBackups)
|
||||
)
|
||||
)
|
||||
return transferByJobs.flat(1)
|
||||
return chain
|
||||
}
|
||||
|
||||
async run($defer) {
|
||||
|
||||
@@ -4,18 +4,11 @@ import ignoreErrors from 'promise-toolbox/ignoreErrors'
|
||||
import { asyncMap } from '@xen-orchestra/async-map'
|
||||
import { decorateMethodsWith } from '@vates/decorate-with'
|
||||
import { defer } from 'golike-defer'
|
||||
import { formatDateTime } from '@xen-orchestra/xapi'
|
||||
|
||||
import { getOldEntries } from '../../_getOldEntries.mjs'
|
||||
import { Task } from '../../Task.mjs'
|
||||
import { Abstract } from './_Abstract.mjs'
|
||||
import {
|
||||
DATETIME,
|
||||
JOB_ID,
|
||||
SCHEDULE_ID,
|
||||
populateVdisOtherConfig,
|
||||
resetVmOtherConfig,
|
||||
setVmOtherConfig,
|
||||
} from '../../_otherConfig.mjs'
|
||||
|
||||
export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
||||
constructor({
|
||||
@@ -32,7 +25,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
||||
vm,
|
||||
}) {
|
||||
super()
|
||||
if (vm.other_config[JOB_ID] === job.id && 'start' in vm.blocked_operations) {
|
||||
if (vm.other_config['xo:backup:job'] === job.id && 'start' in vm.blocked_operations) {
|
||||
// don't match replicated VMs created by this very job otherwise they
|
||||
// will be replicated again and again
|
||||
throw new Error('cannot backup a VM created by this very job')
|
||||
@@ -56,17 +49,17 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
||||
this._exportedVm = undefined
|
||||
this._vm = vm
|
||||
|
||||
this._baseVdis = undefined
|
||||
this._fullVdisRequired = undefined
|
||||
this._getSnapshotNameLabel = getSnapshotNameLabel
|
||||
this._isIncremental = job.mode === 'delta'
|
||||
this._healthCheckSr = healthCheckSr
|
||||
this._jobId = job.id
|
||||
this._jobSnapshotVdis = undefined
|
||||
this._jobSnapshots = undefined
|
||||
this._throttleStream = throttleStream
|
||||
this._xapi = vm.$xapi
|
||||
|
||||
// Base VM for the export
|
||||
this._baseVdis = undefined
|
||||
this._baseVm = undefined
|
||||
|
||||
// Settings for this specific run (job, schedule, VM)
|
||||
if (tags.includes('xo-memory-backup')) {
|
||||
@@ -130,8 +123,15 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
||||
// copied on manual snapshots and interfere with the backup jobs
|
||||
async _cleanMetadata() {
|
||||
const vm = this._vm
|
||||
if (JOB_ID in vm.other_config) {
|
||||
await resetVmOtherConfig(this._xapi, vm.$ref)
|
||||
if ('xo:backup:job' in vm.other_config) {
|
||||
await vm.update_other_config({
|
||||
'xo:backup:datetime': null,
|
||||
'xo:backup:deltaChainLength': null,
|
||||
'xo:backup:exported': null,
|
||||
'xo:backup:job': null,
|
||||
'xo:backup:schedule': null,
|
||||
'xo:backup:vm': null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,29 +146,23 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
||||
if (!settings.bypassVdiChainsCheck) {
|
||||
await vm.$assertHealthyVdiChains()
|
||||
}
|
||||
if (settings.preferNbd) {
|
||||
try {
|
||||
// enable CBT on all disks if possible
|
||||
const diskRefs = await xapi.VM_getDisks(vm.$ref)
|
||||
await Promise.all(diskRefs.map(diskRef => xapi.call('VDI.enable_cbt', diskRef)))
|
||||
} catch (error) {
|
||||
Task.info(`couldn't enable CBT`, error)
|
||||
}
|
||||
}
|
||||
|
||||
const snapshotRef = await vm[settings.checkpointSnapshot ? '$checkpoint' : '$snapshot']({
|
||||
ignoredVdisTag: '[NOBAK]',
|
||||
ignoreNobakVdis: true,
|
||||
name_label: this._getSnapshotNameLabel(vm),
|
||||
unplugVusbs: true,
|
||||
})
|
||||
this.timestamp = Date.now()
|
||||
await setVmOtherConfig(xapi, snapshotRef, {
|
||||
timestamp: this.timestamp,
|
||||
jobId: this._jobId,
|
||||
scheduleId: this.scheduleId,
|
||||
vmUuid: vm.uuid,
|
||||
|
||||
await xapi.setFieldEntries('VM', snapshotRef, 'other_config', {
|
||||
'xo:backup:datetime': formatDateTime(this.timestamp),
|
||||
'xo:backup:job': this._jobId,
|
||||
'xo:backup:schedule': this.scheduleId,
|
||||
'xo:backup:vm': vm.uuid,
|
||||
})
|
||||
|
||||
this._exportedVm = await xapi.getRecord('VM', snapshotRef)
|
||||
|
||||
return this._exportedVm.uuid
|
||||
})
|
||||
} else {
|
||||
@@ -182,123 +176,40 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
||||
const vmRef = this._vm.$ref
|
||||
const xapi = this._xapi
|
||||
|
||||
// to ensure compatibility with snapshots older than CBT implementation
|
||||
// update vdi data to ensure the vdi are correctly fetched in _jobSnapshotVdis
|
||||
// remove by then end of 2024
|
||||
const vmSnapshotsRef = await xapi.getField('VM', vmRef, 'snapshots')
|
||||
const vmSnapshotsOtherConfig = await asyncMap(vmSnapshotsRef, ref => xapi.getField('VM', ref, 'other_config'))
|
||||
const snapshotsRef = await xapi.getField('VM', vmRef, 'snapshots')
|
||||
const snapshotsOtherConfig = await asyncMap(snapshotsRef, ref => xapi.getField('VM', ref, 'other_config'))
|
||||
|
||||
const vmSnapshots = []
|
||||
vmSnapshotsOtherConfig.forEach((other_config, i) => {
|
||||
if (other_config[JOB_ID] === jobId) {
|
||||
vmSnapshots.push({ other_config, $ref: vmSnapshotsRef[i] })
|
||||
const snapshots = []
|
||||
snapshotsOtherConfig.forEach((other_config, i) => {
|
||||
if (other_config['xo:backup:job'] === jobId) {
|
||||
snapshots.push({ other_config, $ref: snapshotsRef[i] })
|
||||
}
|
||||
})
|
||||
await Promise.all(vmSnapshots.map(snapshot => populateVdisOtherConfig(xapi, snapshot.$ref)))
|
||||
// end of compatibiliy handling
|
||||
|
||||
// handle snapshot by VDI
|
||||
this._jobSnapshotVdis = []
|
||||
const srcVdis = await xapi.getRecords('VDI', await this._vm.$getDisks())
|
||||
for (const srcVdi of srcVdis) {
|
||||
const snapshots = await xapi.getRecords('VDI', srcVdi.snapshots)
|
||||
for (const snapshot of snapshots) {
|
||||
if (snapshot.other_config[JOB_ID] === jobId) {
|
||||
this._jobSnapshotVdis.push(snapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
snapshots.sort((a, b) => (a.other_config['xo:backup:datetime'] < b.other_config['xo:backup:datetime'] ? -1 : 1))
|
||||
this._jobSnapshots = snapshots
|
||||
}
|
||||
|
||||
async _removeUnusedSnapshots() {
|
||||
const allSettings = this.job.settings
|
||||
const baseSettings = this._baseSettings
|
||||
const baseVmRef = this._baseVm?.$ref
|
||||
|
||||
const snapshotsPerSchedule = groupBy(this._jobSnapshots, _ => _.other_config['xo:backup:schedule'])
|
||||
const xapi = this._xapi
|
||||
// ensure all the event has been processed by the xapi
|
||||
await xapi.barrier()
|
||||
// ensure cached object are up to date
|
||||
this._jobSnapshotVdis = this._jobSnapshotVdis.map(vdi => xapi.getObject(vdi.$ref))
|
||||
const snapshotsPerSchedule = groupBy(this._jobSnapshotVdis, _ => _.other_config[SCHEDULE_ID])
|
||||
await asyncMap(Object.entries(snapshotsPerSchedule), async ([scheduleId, snapshots]) => {
|
||||
const snapshotPerDatetime = groupBy(snapshots, _ => _.other_config[DATETIME])
|
||||
|
||||
const datetimes = Object.keys(snapshotPerDatetime)
|
||||
datetimes.sort()
|
||||
|
||||
await asyncMap(Object.entries(snapshotsPerSchedule), ([scheduleId, snapshots]) => {
|
||||
const settings = {
|
||||
...baseSettings,
|
||||
...allSettings[scheduleId],
|
||||
...allSettings[this._vm.uuid],
|
||||
}
|
||||
// ensure we never delete the last one for delta
|
||||
const minRetention = this.job.mode === 'delta' ? 1 : 0
|
||||
const retention = Math.max(settings.snapshotRetention ?? 0, minRetention)
|
||||
await asyncMap(getOldEntries(retention, datetimes), async datetime => {
|
||||
const vdis = snapshotPerDatetime[datetime]
|
||||
let vmRef
|
||||
// if there is an attached VM => destroy the VM (Non CBT backups)
|
||||
for (const vdi of vdis) {
|
||||
const vbds = vdi.$VBDs.filter(({ $VM }) => $VM.is_control_domain === false)
|
||||
if (vbds.length > 0) {
|
||||
// only one VM linked to this vdi
|
||||
// this will throw error for VDI still attached to control domain
|
||||
assert.strictEqual(vbds.length, 1, 'VDI must be free or attached to exactly one VM')
|
||||
const vm = vbds[0].$VM
|
||||
assert.strictEqual(vm.is_a_snapshot, true, `VM must be a snapshot`) // don't delete a VM (especially a control domain)
|
||||
|
||||
const vmRefVdi = vm.$ref
|
||||
// same vm than other vdi of the same batch
|
||||
assert.ok(
|
||||
vmRef === undefined || vmRef === vmRefVdi,
|
||||
'_removeUnusedSnapshots don t handle vdi related to multiple VMs '
|
||||
)
|
||||
vmRef = vmRefVdi
|
||||
}
|
||||
}
|
||||
if (vmRef !== undefined) {
|
||||
return xapi.VM_destroy(vmRef)
|
||||
} else {
|
||||
return asyncMap(
|
||||
vdis.map(async ({ $ref }) => {
|
||||
await xapi.VDI_destroy($ref)
|
||||
})
|
||||
)
|
||||
return asyncMap(getOldEntries(settings.snapshotRetention, snapshots), ({ $ref }) => {
|
||||
if ($ref !== baseVmRef) {
|
||||
return xapi.VM_destroy($ref)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async _removeSnapshotData() {
|
||||
// now that we use CBT, we can destroy the data of the snapshot used for this backup
|
||||
// going back to a previous version of XO not supporting CBT will create a full backup
|
||||
// this will only do something after snapshot and transfer
|
||||
if (
|
||||
// don't modify the VM
|
||||
this._exportedVm?.is_a_snapshot &&
|
||||
// user don't want to keep the snapshot data
|
||||
this._settings.snapshotRetention === 0 &&
|
||||
// preferNbd is not a guarantee that the backup used NBD, depending on the network configuration,
|
||||
// in that case next runs will be full, but there is not an easy way to prevent that
|
||||
this._settings.preferNbd &&
|
||||
// only delete snapshost data if the config allows it
|
||||
this._settings.cbtDestroySnapshotData
|
||||
) {
|
||||
Task.info('will delete snapshot data')
|
||||
const vdiRefs = await this._xapi.VM_getDisks(this._exportedVm.$ref)
|
||||
await this._xapi.call('VM.destroy', this._exportedVm.$ref)
|
||||
for (const vdiRef of vdiRefs) {
|
||||
try {
|
||||
// data_destroy will fail with a VDI_NO_CBT_METADATA error if CBT is not enabled on this VDI
|
||||
await this._xapi.VDI_dataDestroy(vdiRef)
|
||||
Task.info(`Snapshot data has been deleted`, { vdiRef })
|
||||
} catch (error) {
|
||||
Task.warning(`Couldn't delete snapshot data`, { error, vdiRef })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async copy() {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
@@ -333,27 +244,12 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
||||
|
||||
// block migration during the backup on the VM itself, not the latest snapshot
|
||||
{
|
||||
const { pool_migrate, migrate_send } = vm.blocked_operations
|
||||
const { pool_migrate = null, migrate_send = null } = vm.blocked_operations
|
||||
|
||||
const reason = 'VM migration is blocked during backup'
|
||||
await vm.update_blocked_operations({ pool_migrate: reason, migrate_send: reason })
|
||||
|
||||
$defer(async () => {
|
||||
// delete the entries if they did not exist previously or if they were
|
||||
// equal to reason (which happen if a previous backup was interrupted
|
||||
// before resetting them)
|
||||
await vm.update_blocked_operations({
|
||||
migrate_send: migrate_send === undefined || migrate_send === reason ? null : migrate_send,
|
||||
pool_migrate: pool_migrate === undefined || pool_migrate === reason ? null : pool_migrate,
|
||||
})
|
||||
|
||||
// 2024-08-19 - Work-around a XAPI bug where allowed_operations are not properly computed when blocked_operations is updated
|
||||
//
|
||||
// this is a problem because some clients (e.g. XenCenter) use this field to allow operations.
|
||||
//
|
||||
// internal source: https://team.vates.fr/vates/pl/mjmxnce9qfdx587r3qpe4z91ho
|
||||
await vm.$call('update_allowed_operations')
|
||||
})
|
||||
$defer(() => vm.update_blocked_operations({ pool_migrate, migrate_send }))
|
||||
}
|
||||
|
||||
await this._fetchJobSnapshots()
|
||||
@@ -385,7 +281,6 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
||||
|
||||
await this._fetchJobSnapshots()
|
||||
await this._removeUnusedSnapshots()
|
||||
await this._removeSnapshotData()
|
||||
}
|
||||
await this._healthCheck()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ignoreErrors from 'promise-toolbox/ignoreErrors'
|
||||
import { asyncMap, asyncMapSettled } from '@xen-orchestra/async-map'
|
||||
import { formatDateTime } from '@xen-orchestra/xapi'
|
||||
|
||||
import { formatFilenameDate } from '../../_filenameDate.mjs'
|
||||
import { getOldEntries } from '../../_getOldEntries.mjs'
|
||||
@@ -8,7 +9,6 @@ import { Task } from '../../Task.mjs'
|
||||
import { AbstractFullWriter } from './_AbstractFullWriter.mjs'
|
||||
import { MixinXapiWriter } from './_MixinXapiWriter.mjs'
|
||||
import { listReplicatedVms } from './_listReplicatedVms.mjs'
|
||||
import { setVmOtherConfig } from '../../_otherConfig.mjs'
|
||||
|
||||
export class FullXapiWriter extends MixinXapiWriter(AbstractFullWriter) {
|
||||
constructor(props) {
|
||||
@@ -76,12 +76,14 @@ export class FullXapiWriter extends MixinXapiWriter(AbstractFullWriter) {
|
||||
'Start operation for this vm is blocked, clone it if you want to use it.'
|
||||
)
|
||||
),
|
||||
setVmOtherConfig(xapi, targetVmRef, {
|
||||
timestamp,
|
||||
jobId: job.id,
|
||||
scheduleId,
|
||||
srUuid,
|
||||
vmUuid: vm.uuid,
|
||||
targetVm.update_other_config({
|
||||
'xo:backup:sr': srUuid,
|
||||
|
||||
// these entries need to be added in case of offline backup
|
||||
'xo:backup:datetime': formatDateTime(timestamp),
|
||||
'xo:backup:job': job.id,
|
||||
'xo:backup:schedule': scheduleId,
|
||||
'xo:backup:vm': vm.uuid,
|
||||
}),
|
||||
])
|
||||
|
||||
|
||||
@@ -140,7 +140,6 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
|
||||
if (isDifferencing) {
|
||||
assert.notStrictEqual(
|
||||
parentVdiPaths,
|
||||
undefined,
|
||||
'checkbasevdi must be called before updateUuidAndChain for incremental backups'
|
||||
)
|
||||
const parentPath = parentVdiPaths[dirname(path)]
|
||||
|
||||
@@ -1,34 +1,39 @@
|
||||
import assert from 'node:assert'
|
||||
import { asyncMap, asyncMapSettled } from '@xen-orchestra/async-map'
|
||||
import ignoreErrors from 'promise-toolbox/ignoreErrors'
|
||||
import { formatDateTime } from '@xen-orchestra/xapi'
|
||||
|
||||
import { formatFilenameDate } from '../../_filenameDate.mjs'
|
||||
import { getOldEntries } from '../../_getOldEntries.mjs'
|
||||
import { importIncrementalVm } from '../../_incrementalVm.mjs'
|
||||
import { importIncrementalVm, TAG_BACKUP_SR, TAG_BASE_DELTA, TAG_COPY_SRC } from '../../_incrementalVm.mjs'
|
||||
import { Task } from '../../Task.mjs'
|
||||
|
||||
import { AbstractIncrementalWriter } from './_AbstractIncrementalWriter.mjs'
|
||||
import { MixinXapiWriter } from './_MixinXapiWriter.mjs'
|
||||
import { listReplicatedVms } from './_listReplicatedVms.mjs'
|
||||
import { COPY_OF, setVmOtherConfig, BASE_DELTA_VDI } from '../../_otherConfig.mjs'
|
||||
import find from 'lodash/find.js'
|
||||
|
||||
import assert from 'node:assert'
|
||||
export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWriter) {
|
||||
async checkBaseVdis(baseUuidToSrcVdi) {
|
||||
async checkBaseVdis(baseUuidToSrcVdi, baseVm) {
|
||||
assert.notStrictEqual(baseVm, undefined)
|
||||
const sr = this._sr
|
||||
const replicatedVm = listReplicatedVms(sr.$xapi, this._job.id, sr.uuid, this._vmUuid).find(
|
||||
vm => vm.other_config[TAG_COPY_SRC] === baseVm.uuid
|
||||
)
|
||||
if (replicatedVm === undefined) {
|
||||
return baseUuidToSrcVdi.clear()
|
||||
}
|
||||
|
||||
// @todo use an index if possible
|
||||
// @todo : this seems similare to decorateVmMetadata
|
||||
|
||||
const replicatedVdis = sr.$VDIs
|
||||
.filter(vdi => {
|
||||
// REPLICATED_TO_SR_UUID is not used here since we are already filtering from sr.$VDIs
|
||||
return baseUuidToSrcVdi.has(vdi?.other_config[COPY_OF])
|
||||
const xapi = replicatedVm.$xapi
|
||||
const replicatedVdis = new Set(
|
||||
await asyncMap(await replicatedVm.$getDisks(), async vdiRef => {
|
||||
const otherConfig = await xapi.getField('VDI', vdiRef, 'other_config')
|
||||
return otherConfig[TAG_COPY_SRC]
|
||||
})
|
||||
.map(({ other_config }) => other_config?.[COPY_OF])
|
||||
.filter(_ => !!_)
|
||||
)
|
||||
|
||||
for (const uuid of baseUuidToSrcVdi.keys()) {
|
||||
if (!replicatedVdis.includes(uuid)) {
|
||||
if (!replicatedVdis.has(uuid)) {
|
||||
baseUuidToSrcVdi.delete(uuid)
|
||||
}
|
||||
}
|
||||
@@ -52,10 +57,10 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
||||
this.cleanup = task.wrapFn(this.cleanup, !hasHealthCheckSr)
|
||||
this.healthCheck = task.wrapFn(this.healthCheck, hasHealthCheckSr)
|
||||
|
||||
return task.run(() => this._prepare(isFull))
|
||||
return task.run(() => this._prepare())
|
||||
}
|
||||
|
||||
async _prepare(isFull) {
|
||||
async _prepare() {
|
||||
const settings = this._settings
|
||||
const { uuid: srUuid, $xapi: xapi } = this._sr
|
||||
const vmUuid = this._vmUuid
|
||||
@@ -67,19 +72,14 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
||||
this._oldEntries = getOldEntries(settings.copyRetention - 1, listReplicatedVms(xapi, scheduleId, srUuid, vmUuid))
|
||||
|
||||
if (settings.deleteFirst) {
|
||||
// we want to keep the baseVM when copying a delta
|
||||
// even if we want to keep only one after
|
||||
let mostRecentEntry
|
||||
if (this._oldEntries.length > 1 && settings.copyRetention === 1 && !isFull) {
|
||||
mostRecentEntry = this._oldEntries.pop()
|
||||
}
|
||||
await this._deleteOldEntries()
|
||||
this._oldEntries = mostRecentEntry !== undefined ? [mostRecentEntry] : []
|
||||
}
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
await this._deleteOldEntries()
|
||||
if (!this._settings.deleteFirst) {
|
||||
await this._deleteOldEntries()
|
||||
}
|
||||
}
|
||||
|
||||
async _deleteOldEntries() {
|
||||
@@ -89,39 +89,46 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
||||
#decorateVmMetadata(backup) {
|
||||
const { _warmMigration } = this._settings
|
||||
const sr = this._sr
|
||||
const xapi = sr.$xapi
|
||||
const vm = backup.vm
|
||||
vm.other_config[TAG_COPY_SRC] = vm.uuid
|
||||
const remoteBaseVmUuid = vm.other_config[TAG_BASE_DELTA]
|
||||
let baseVm
|
||||
if (remoteBaseVmUuid) {
|
||||
baseVm = find(
|
||||
xapi.objects.all,
|
||||
obj => (obj = obj.other_config) && obj[TAG_COPY_SRC] === remoteBaseVmUuid && obj[TAG_BACKUP_SR] === sr.$id
|
||||
)
|
||||
|
||||
vm.other_config[COPY_OF] = vm.uuid
|
||||
if (!baseVm) {
|
||||
throw new Error(`could not find the base VM (copy of ${remoteBaseVmUuid})`)
|
||||
}
|
||||
}
|
||||
const baseVdis = {}
|
||||
baseVm?.$VBDs.forEach(vbd => {
|
||||
const vdi = vbd.$VDI
|
||||
if (vdi !== undefined) {
|
||||
baseVdis[vbd.VDI] = vbd.$VDI
|
||||
}
|
||||
})
|
||||
|
||||
vm.other_config[TAG_COPY_SRC] = vm.uuid
|
||||
if (!_warmMigration) {
|
||||
vm.tags.push('Continuous Replication')
|
||||
}
|
||||
// extracting the uuid of each delta vdi on the source
|
||||
// get all in one pass, since there is a lot of objects
|
||||
const sourceVdiUuids = Object.values(backup.vdis)
|
||||
.map(({ other_config }) => other_config[BASE_DELTA_VDI])
|
||||
// full vdi don't have a base
|
||||
.filter(_ => !!_)
|
||||
// @todo use index ?
|
||||
|
||||
const replicatedVdis = sr.$VDIs
|
||||
.filter(vdi => {
|
||||
// REPLICATED_TO_SR_UUID is not used here since we are already filtering from sr.$VDIs
|
||||
return sourceVdiUuids.includes(vdi?.other_config[COPY_OF])
|
||||
})
|
||||
|
||||
Object.values(backup.vdis).forEach(vdi => {
|
||||
vdi.other_config[COPY_OF] = vdi.uuid
|
||||
if (sourceVdiUuids.length > 0) {
|
||||
const baseReplicatedTo = replicatedVdis.find(
|
||||
replicatedVdi => replicatedVdi.other_config[COPY_OF] === vdi.other_config[BASE_DELTA_VDI]
|
||||
)
|
||||
assert.notStrictEqual(baseReplicatedTo, undefined)
|
||||
vdi.baseVdi = baseReplicatedTo
|
||||
} else {
|
||||
vdi.baseVdi = undefined
|
||||
}
|
||||
// ensure the VDI are created on the target SR
|
||||
vdi.other_config[TAG_COPY_SRC] = vdi.uuid
|
||||
vdi.SR = sr.$ref
|
||||
// vdi.other_config[TAG_BASE_DELTA] is never defined on a suspend vdi
|
||||
if (vdi.other_config[TAG_BASE_DELTA]) {
|
||||
const remoteBaseVdiUuid = vdi.other_config[TAG_BASE_DELTA]
|
||||
const baseVdi = find(baseVdis, vdi => vdi.other_config[TAG_COPY_SRC] === remoteBaseVdiUuid)
|
||||
if (!baseVdi) {
|
||||
throw new Error(`missing base VDI (copy of ${remoteBaseVdiUuid})`)
|
||||
}
|
||||
vdi.baseVdi = baseVdi
|
||||
}
|
||||
})
|
||||
|
||||
return backup
|
||||
@@ -157,12 +164,14 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
||||
'Start operation for this vm is blocked, clone it if you want to use it.'
|
||||
)
|
||||
),
|
||||
setVmOtherConfig(xapi, targetVmRef, {
|
||||
timestamp,
|
||||
jobId: job.id,
|
||||
scheduleId,
|
||||
vmUuid: vm.uuid,
|
||||
srUuid,
|
||||
targetVm.update_other_config({
|
||||
[TAG_BACKUP_SR]: srUuid,
|
||||
|
||||
// these entries need to be added in case of offline backup
|
||||
'xo:backup:datetime': formatDateTime(timestamp),
|
||||
'xo:backup:job': job.id,
|
||||
'xo:backup:schedule': scheduleId,
|
||||
[TAG_BASE_DELTA]: vm.uuid,
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ export const MixinRemoteWriter = (BaseClass = Object) =>
|
||||
},
|
||||
lock: false,
|
||||
mergeBlockConcurrency: this._config.mergeBlockConcurrency,
|
||||
removeTmp: true,
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractOpaqueRef } from '@xen-orchestra/xapi'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { HealthCheckVmBackup } from '../../HealthCheckVmBackup.mjs'
|
||||
@@ -48,18 +49,23 @@ export const MixinXapiWriter = (BaseClass = Object) =>
|
||||
if (await this.#isAlreadyOnHealthCheckSr(baseVm)) {
|
||||
healthCheckVmRef = await Task.run(
|
||||
{ name: 'cloning-vm' },
|
||||
async () => await xapi.callAsync('VM.clone', this._targetVmRef, `Health Check - ${baseVm.name_label}`)
|
||||
async () =>
|
||||
await xapi
|
||||
.callAsync('VM.clone', this._targetVmRef, `Health Check - ${baseVm.name_label}`)
|
||||
.then(extractOpaqueRef)
|
||||
)
|
||||
} else {
|
||||
healthCheckVmRef = await Task.run(
|
||||
{ name: 'copying-vm' },
|
||||
async () =>
|
||||
await xapi.callAsync(
|
||||
'VM.copy',
|
||||
this._targetVmRef,
|
||||
`Health Check - ${baseVm.name_label}`,
|
||||
this._healthCheckSr.$ref
|
||||
)
|
||||
await xapi
|
||||
.callAsync(
|
||||
'VM.copy',
|
||||
this._targetVmRef,
|
||||
`Health Check - ${baseVm.name_label}`,
|
||||
this._healthCheckSr.$ref
|
||||
)
|
||||
.then(extractOpaqueRef)
|
||||
)
|
||||
}
|
||||
const healthCheckVm =
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { DATETIME, JOB_ID, REPLICATED_TO_SR_UUID, SCHEDULE_ID, VM_UUID } from '../../_otherConfig.mjs'
|
||||
|
||||
const getReplicatedVmDatetime = vm => {
|
||||
const { [DATETIME]: datetime = vm.name_label.slice(-17, -1) } = vm.other_config
|
||||
const { 'xo:backup:datetime': datetime = vm.name_label.slice(-17, -1) } = vm.other_config
|
||||
return datetime
|
||||
}
|
||||
|
||||
@@ -18,11 +16,11 @@ export function listReplicatedVms(xapi, scheduleOrJobId, srUuid, vmUuid) {
|
||||
!object.is_a_snapshot &&
|
||||
!object.is_a_template &&
|
||||
'start' in object.blocked_operations &&
|
||||
(oc[JOB_ID] === scheduleOrJobId || oc[SCHEDULE_ID] === scheduleOrJobId) &&
|
||||
oc[REPLICATED_TO_SR_UUID] === srUuid &&
|
||||
(oc[VM_UUID] === vmUuid ||
|
||||
(oc['xo:backup:job'] === scheduleOrJobId || oc['xo:backup:schedule'] === scheduleOrJobId) &&
|
||||
oc['xo:backup:sr'] === srUuid &&
|
||||
(oc['xo:backup:vm'] === vmUuid ||
|
||||
// 2018-03-28, JFT: to catch VMs replicated before this fix
|
||||
oc[VM_UUID] === undefined)
|
||||
oc['xo:backup:vm'] === undefined)
|
||||
) {
|
||||
vms[object.$id] = object
|
||||
}
|
||||
|
||||
@@ -59,13 +59,13 @@ In case any incoherence is detected, the file is deleted so it will be fully gen
|
||||
|
||||
## Attributes
|
||||
|
||||
### Of created snapshots (VMs and associated VDIs)
|
||||
### Of created snapshots
|
||||
|
||||
- `other_config`:
|
||||
- `xo:backup:deltaChainLength` = n (number of delta copies/replicated since a full)
|
||||
- `xo:backup:exported` = 'true' (added at the end of the backup)
|
||||
|
||||
### Of created VMs , their associated VDIs and snapshots
|
||||
### Of created VMs and snapshots
|
||||
|
||||
- `other_config`:
|
||||
- `xo:backup:datetime`: format is UTC %Y%m%dT%H:%M:%SZ
|
||||
@@ -75,7 +75,7 @@ In case any incoherence is detected, the file is deleted so it will be fully gen
|
||||
- `xo:backup:schedule` = schedule.id
|
||||
- `xo:backup:vm` = vm.uuid
|
||||
|
||||
### Of created VMs and their associated VDIs
|
||||
### Of created VMs
|
||||
|
||||
- `name_label`: `${original name} - ${job name} - (${safeDateFormat(backup timestamp)})`
|
||||
- tag:
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"version": "0.54.0",
|
||||
"version": "0.48.1",
|
||||
"engines": {
|
||||
"node": ">=14.18"
|
||||
},
|
||||
@@ -24,20 +24,20 @@
|
||||
"@vates/compose": "^2.1.0",
|
||||
"@vates/decorate-with": "^2.1.0",
|
||||
"@vates/disposable": "^0.1.5",
|
||||
"@vates/fuse-vhd": "^2.1.1",
|
||||
"@vates/nbd-client": "^3.1.0",
|
||||
"@vates/fuse-vhd": "^2.1.0",
|
||||
"@vates/nbd-client": "^3.0.1",
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@xen-orchestra/async-map": "^0.1.2",
|
||||
"@xen-orchestra/fs": "^4.1.7",
|
||||
"@xen-orchestra/fs": "^4.1.6",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"@xen-orchestra/template": "^0.1.0",
|
||||
"app-conf": "^3.0.0",
|
||||
"app-conf": "^2.3.0",
|
||||
"compare-versions": "^6.0.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"decorator-synchronized": "^0.6.0",
|
||||
"golike-defer": "^0.5.1",
|
||||
"human-format": "^1.2.0",
|
||||
"limit-concurrency-decorator": "^0.6.0",
|
||||
"limit-concurrency-decorator": "^0.5.0",
|
||||
"lodash": "^4.17.20",
|
||||
"ms": "^2.1.3",
|
||||
"node-zone": "^0.4.0",
|
||||
@@ -46,20 +46,19 @@
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"tar": "^6.1.15",
|
||||
"uuid": "^9.0.0",
|
||||
"value-matcher": "^0.2.0",
|
||||
"vhd-lib": "^4.11.0",
|
||||
"xen-api": "^4.3.1",
|
||||
"vhd-lib": "^4.9.2",
|
||||
"xen-api": "^3.0.1",
|
||||
"yazl": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"fs-extra": "^11.1.0",
|
||||
"rimraf": "^5.0.1",
|
||||
"sinon": "^18.0.0",
|
||||
"sinon": "^17.0.1",
|
||||
"test": "^3.2.1",
|
||||
"tmp": "^0.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xen-orchestra/xapi": "^7.6.0"
|
||||
"@xen-orchestra/xapi": "^5.0.1"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/cr-seed-cli",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.1",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/cr-seed-cli",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
@@ -18,7 +18,7 @@
|
||||
"preferGlobal": true,
|
||||
"dependencies": {
|
||||
"golike-defer": "^0.5.1",
|
||||
"xen-api": "^4.3.1"
|
||||
"xen-api": "^3.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish"
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"test": "node--test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sinon": "^18.0.0",
|
||||
"sinon": "^17.0.1",
|
||||
"test": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
### `defined()`
|
||||
|
||||
Returns the first non-`undefined` value from a list of arguments, evaluating functions if needed.
|
||||
|
||||
There is two ways to use this function:
|
||||
|
||||
- either with a single array argument: it should return the first non-undefined item
|
||||
- or with multiple arguments: it should return the first non-undefined argument
|
||||
|
||||
If only `undefined` values, return `undefined`.
|
||||
|
||||
```js
|
||||
import defined from '@xen-orchestra/defined/index.js'
|
||||
|
||||
defined(undefined, 'foo', 42)
|
||||
// Returns 'foo'
|
||||
|
||||
defined([undefined, null, 10])
|
||||
// Returns null
|
||||
|
||||
defined([undefined, undefined], [undefined, undefined, 10])
|
||||
// Returns [undefined, undefined]
|
||||
|
||||
defined(() => 'bar', 42)
|
||||
// Returns 'bar'
|
||||
|
||||
defined(undefined, undefined)
|
||||
// Returns undefined
|
||||
```
|
||||
|
||||
@@ -14,37 +14,6 @@ Installation of the [npm package](https://npmjs.org/package/@xen-orchestra/defin
|
||||
npm install --save @xen-orchestra/defined
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### `defined()`
|
||||
|
||||
Returns the first non-`undefined` value from a list of arguments, evaluating functions if needed.
|
||||
|
||||
There is two ways to use this function:
|
||||
- either with a single array argument: it should return the first non-undefined item
|
||||
- or with multiple arguments: it should return the first non-undefined argument
|
||||
|
||||
If only `undefined` values, return `undefined`.
|
||||
|
||||
```js
|
||||
import defined from '@xen-orchestra/defined/index.js'
|
||||
|
||||
defined(undefined, 'foo', 42)
|
||||
// Returns 'foo'
|
||||
|
||||
defined([undefined, null, 10])
|
||||
// Returns null
|
||||
|
||||
defined([undefined, undefined], [undefined, undefined, 10])
|
||||
// Returns [undefined, undefined]
|
||||
|
||||
defined(() => 'bar', 42)
|
||||
// Returns 'bar'
|
||||
|
||||
defined(undefined, undefined)
|
||||
// Returns undefined
|
||||
```
|
||||
|
||||
## Contributions
|
||||
|
||||
Contributions are _very_ welcomed, either on the documentation or on
|
||||
|
||||
@@ -22,7 +22,7 @@ function defined() {
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; ++i) {
|
||||
let arg = args[i]
|
||||
let arg = arguments[i]
|
||||
if (typeof arg === 'function') {
|
||||
arg = get(arg)
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import defined_ from '@xen-orchestra/defined/index.js'
|
||||
|
||||
// for each test, use the flat syntax and the array syntax
|
||||
for (const [title, defined] of Object.entries({
|
||||
'defined(...values)': defined_,
|
||||
'defined([ ...values ])': (...args) => defined_(args),
|
||||
})) {
|
||||
describe(title, () => {
|
||||
it('returns the first non undefined value', () => {
|
||||
assert.deepEqual(defined(undefined, 'foo', 42), 'foo')
|
||||
})
|
||||
|
||||
it('returns undefined if only undefined values', () => {
|
||||
assert.equal(defined(undefined, undefined), undefined)
|
||||
})
|
||||
|
||||
it('resolves functions with `get(fn)`', () => {
|
||||
const o = { foo: undefined, baz: 'baz' }
|
||||
assert.deepEqual(
|
||||
defined(
|
||||
() => o.foo.bar,
|
||||
() => o.baz
|
||||
),
|
||||
'baz'
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/defined",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.1",
|
||||
"license": "ISC",
|
||||
"description": "Utilities to help handling (possibly) undefined values",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/defined",
|
||||
@@ -23,7 +23,6 @@
|
||||
"node": ">=6"
|
||||
},
|
||||
"scripts": {
|
||||
"postversion": "npm publish",
|
||||
"test": "node --test"
|
||||
"postversion": "npm publish"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "@xen-orchestra/fs",
|
||||
"version": "4.1.7",
|
||||
"version": "4.1.6",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "The File System for Xen Orchestra backups.",
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/fs",
|
||||
@@ -35,7 +35,7 @@
|
||||
"execa": "^5.0.0",
|
||||
"fs-extra": "^11.1.0",
|
||||
"get-stream": "^6.0.0",
|
||||
"limit-concurrency-decorator": "^0.6.0",
|
||||
"limit-concurrency-decorator": "^0.5.0",
|
||||
"lodash": "^4.17.4",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
@@ -52,7 +52,7 @@
|
||||
"cross-env": "^7.0.2",
|
||||
"dotenv": "^16.0.0",
|
||||
"rimraf": "^5.0.1",
|
||||
"sinon": "^18.0.0",
|
||||
"sinon": "^17.0.1",
|
||||
"test": "^3.3.0",
|
||||
"tmp": "^0.2.1"
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ import { synchronized } from 'decorator-synchronized'
|
||||
|
||||
import { basename, dirname, normalize as normalizePath } from './path'
|
||||
import { createChecksumStream, validChecksumOfReadStream } from './checksum'
|
||||
import { DEFAULT_ENCRYPTION_ALGORITHM, UNENCRYPTED_ALGORITHM, _getEncryptor } from './_encryptor'
|
||||
import { DEFAULT_ENCRYPTION_ALGORITHM, _getEncryptor } from './_encryptor'
|
||||
|
||||
const { info, warn } = createLogger('xo:fs:abstract')
|
||||
|
||||
@@ -674,10 +674,6 @@ export default class RemoteHandlerAbstract {
|
||||
get isEncrypted() {
|
||||
return this.#encryptor.id !== 'NULL_ENCRYPTOR'
|
||||
}
|
||||
|
||||
get encryptionAlgorithm() {
|
||||
return this.#encryptor?.algorithm ?? UNENCRYPTED_ALGORITHM
|
||||
}
|
||||
}
|
||||
|
||||
// from implementation methods, which names start with `__`, create public
|
||||
|
||||
@@ -17,19 +17,19 @@
|
||||
"xo-lift-remote-immutability": "./liftProtection.mjs"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "1.0.12",
|
||||
"version": "1.0.5",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.0",
|
||||
"@xen-orchestra/backups": "^0.54.0",
|
||||
"@xen-orchestra/backups": "^0.48.1",
|
||||
"@xen-orchestra/log": "^0.6.0",
|
||||
"app-conf": "^3.0.0",
|
||||
"app-conf": "^2.3.0",
|
||||
"chokidar": "^3.5.3",
|
||||
"execa": "^5.0.0",
|
||||
"ms": "^2.1.3",
|
||||
"vhd-lib": "^4.11.0"
|
||||
"vhd-lib": "^4.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"rimraf": "^5.0.5",
|
||||
|
||||
@@ -2,24 +2,6 @@
|
||||
|
||||
## **next**
|
||||
|
||||
- Handle IPv6 hosts
|
||||
|
||||
## **0.4.0** (2024-09-30)
|
||||
|
||||
- [Settings] Default UI theme is now _auto_ instead of _dark_
|
||||
|
||||
## **0.2.7** (2024-07-31)
|
||||
|
||||
- [Pool/Dashboard] Add missing translations for hosts and VMs statuses (PR [#7744](https://github.com/vatesfr/xen-orchestra/pull/7744))
|
||||
- [i18n] Add Persian translation (based on the contribution made by [@Jokar-xen](https://github.com/Jokar-xen)) (PR [#7775](https://github.com/vatesfr/xen-orchestra/pull/7775))
|
||||
- [Access XOA] Support `xo-server`'s' setting `http.publicUrl` to redirect to a custom URL when "Access XOA" button is clicked in XO Lite [Forum#9392](https://xcp-ng.org/forum/topic/9392) (PR [#7849](https://github.com/vatesfr/xen-orchestra/pull/7849))
|
||||
|
||||
## **0.2.3** (2024-05-31)
|
||||
|
||||
- [Pool/Dashboard] In the `Storage usage` card, DVDs are no longer taken into account (PR [#7670](https://github.com/vatesfr/xen-orchestra/pull/7670))
|
||||
- [Header] Add link to XOA when XOA is detected on the pool (PR [#7679](https://github.com/vatesfr/xen-orchestra/pull/7679))
|
||||
- Add german translation (based on the contribution made by [@borzel](https://github.com/borzel)) (PR [#7686](https://github.com/vatesfr/xen-orchestra/pull/7686))
|
||||
|
||||
## **0.2.2** (2024-04-30)
|
||||
|
||||
- [Tree view] Update tree view with new components from design system (PR [#7531](https://github.com/vatesfr/xen-orchestra/pull/7531))
|
||||
|
||||
@@ -5,6 +5,213 @@
|
||||
- `yarn`
|
||||
- `yarn dev`
|
||||
|
||||
## Guidelines
|
||||
## Conventions
|
||||
|
||||
Please follow the guidelines from the `@xen-orchestra/web-core/docs/guidelines` directory.
|
||||
### File names
|
||||
|
||||
| Type | Format | Exemple |
|
||||
| ---------- | ---------------------------------------- | ----------------------------------- |
|
||||
| Component | `components/<PascalCase>.vue` | `components/FooBar.vue` |
|
||||
| View | `views/<PascalCase>View.vue` | `views/FooBarView.vue` |
|
||||
| Composable | `composables/<kebab-case>.composable.ts` | `composables/foo-bar.composable.ts` |
|
||||
| Store | `stores/<kebab-case>.store.ts` | `stores/foo-bar.store.ts` |
|
||||
| Other | `libs/<kebab-case>.ts` | `libs/foo-bar.ts` |
|
||||
|
||||
For components and views, prepend the subdirectories names to the resulting filename.
|
||||
|
||||
Example: `components/foo/bar/FooBarBaz.vue`
|
||||
|
||||
### Vue Components
|
||||
|
||||
Use Vue Single File Components (`*.vue`).
|
||||
|
||||
Insert blocks in the following order: `template`, `script` then `style`.
|
||||
|
||||
#### Template
|
||||
|
||||
Use HTML.
|
||||
|
||||
If your component only has one root element, add the component name as a class.
|
||||
|
||||
```vue
|
||||
<!-- MyComponent.vue -->
|
||||
<template>
|
||||
<div class="my-component">...</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### Script
|
||||
|
||||
Use composition API + TypeScript + `setup` attribute (`<script lang="ts" setup>`).
|
||||
|
||||
Note: When reading Vue official doc, don't forget to set "API Preference" toggle (in the upper left) on "Composition".
|
||||
|
||||
```vue
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
greetings: string
|
||||
}>()
|
||||
|
||||
const firstName = ref('')
|
||||
const lastName = ref('')
|
||||
|
||||
const fullName = computed(() => `${props.greetings} ${firstName.value} ${lastName.value}`)
|
||||
</script>
|
||||
```
|
||||
|
||||
#### CSS
|
||||
|
||||
Always use `scoped` attribute (`<style scoped>`).
|
||||
|
||||
Nested rules are allowed.
|
||||
|
||||
Vue variables can be interpolated with `v-bind`.
|
||||
|
||||
```vue
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const fontSize = ref('2rem')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.my-item {
|
||||
.nested {
|
||||
font-size: v-bind(fontSize);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Icons
|
||||
|
||||
This project is using Font Awesome 6 Free.
|
||||
|
||||
Icons can be displayed with the `UiIcon` component.
|
||||
|
||||
Passing `undefined` as `icon` prop will disable the component (no need to use an additional `v-if` condition).
|
||||
|
||||
Use the `busy` prop to display a loader icon.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<UiIcon :icon="faDisplay" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from '@/components/ui/icon/UiIcon.vue'
|
||||
import { faDisplay } from '@fortawesome/free-solid-svg-icons'
|
||||
</script>
|
||||
```
|
||||
|
||||
#### Font weight <=> Style name
|
||||
|
||||
Here is the equivalent between font weight and style name.
|
||||
|
||||
| Style name | Font weight |
|
||||
| ---------- | ----------- |
|
||||
| Solid | 900 |
|
||||
| Regular | 400 |
|
||||
|
||||
### CSS
|
||||
|
||||
Always use `rem` unit (`1rem` = `10px`)
|
||||
|
||||
### Store
|
||||
|
||||
Use Pinia store with setup function.
|
||||
|
||||
State are `ref`
|
||||
|
||||
Getters are `computed`
|
||||
|
||||
Actions/Mutations are simple functions
|
||||
|
||||
#### Naming convention
|
||||
|
||||
For a `foobar` store, create a `store/foobar.store.ts` then use `defineStore('foobar', setupFunc)`
|
||||
|
||||
#### Example
|
||||
|
||||
```typescript
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export const useFoobarStore = defineStore('foobar', () => {
|
||||
const aStateVar = ref(0)
|
||||
const otherStateVar = ref(0)
|
||||
const aGetter = computed(() => aStateVar.value * 2)
|
||||
const anAction = () => (otherStateVar.value += 10)
|
||||
|
||||
return {
|
||||
aStateVar,
|
||||
otherStateVar,
|
||||
aGetter,
|
||||
anAction,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### I18n
|
||||
|
||||
Internationalization of the app is done with [Vue-i18n](https://vue-i18n.intlify.dev/).
|
||||
|
||||
Locale files are located in `src/locales` directory.
|
||||
|
||||
Source of truth is `en-US.json` file.
|
||||
|
||||
To quickly check if there are missing translations in other locale files, open `main.ts` and check the `messages`
|
||||
property of `createI18n()` for TypeScript error.
|
||||
|
||||
#### Example
|
||||
|
||||
```json
|
||||
{
|
||||
"hello": "Hello",
|
||||
"hello_name": "Hello {name}",
|
||||
"hello_linked": "@:hello_name how are you?",
|
||||
"hello_plural": "No hello | Hello to you | Hello to {count} persons"
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- String -->
|
||||
|
||||
<p>{{ $t("hello") }}</p>
|
||||
<!-- Hello -->
|
||||
<p>{{ $t("hello_name", { name: "World" }) }}</p>
|
||||
<!-- Hello World -->
|
||||
<p>{{ $t("hello_linked", { name: "World" }) }}</p>
|
||||
<!-- Hello World how are you? -->
|
||||
<p>{{ $tc("hello_plural", 0) }}</p>
|
||||
<!-- No hello -->
|
||||
<p>{{ $tc("hello_plural", 1) }}</p>
|
||||
<!-- Hello to you -->
|
||||
<p>{{ $tc("hello_plural", 4) }}</p>
|
||||
<!-- Hello to 4 persons -->
|
||||
|
||||
<!-- Date and time -->
|
||||
|
||||
<p>{{ $d(date, "date_short") }}</p>
|
||||
<!-- 9/10/2022 -->
|
||||
<p>{{ $d(date, "date_medium") }}</p>
|
||||
<!-- Sep 10, 2022 -->
|
||||
<p>{{ $d(date, "date_long") }}</p>
|
||||
<!-- September 10, 2022 -->
|
||||
<p>{{ $d(date, "datetime_short") }}</p>
|
||||
<!-- 9/10/2022, 06:30 PM -->
|
||||
<p>{{ $d(date, "datetime_medium") }}</p>
|
||||
<!-- Sep 10, 2022, 06:30 PM -->
|
||||
<p>{{ $d(date, "datetime_long") }}</p>
|
||||
<!-- September 10, 2022 at 06:30 PM -->
|
||||
<p>{{ $d(date, "time") }}</p>
|
||||
<!-- 06:30 PM -->
|
||||
|
||||
<!-- Number -->
|
||||
|
||||
<p>{{ $n(1234567.898765) }}</p>
|
||||
<!-- 1,234,567.899 -->
|
||||
```
|
||||
|
||||
@@ -130,10 +130,10 @@ withDefaults(
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
clickWithArg: [id: string]
|
||||
'update:modelValue': [value: string]
|
||||
'update:customModel': [value: number]
|
||||
(event: 'click'): void
|
||||
(event: 'clickWithArg', id: string): void
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'update:customModel', value: number): void
|
||||
}>()
|
||||
|
||||
const moonDistance = 384400
|
||||
|
||||
1
@xen-orchestra/lite/env.d.ts
vendored
1
@xen-orchestra/lite/env.d.ts
vendored
@@ -1,7 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="json-rpc-2.0/dist" />
|
||||
/// <reference types="vite-plugin-pages/client" />
|
||||
/// <reference types="novnc__novnc" />
|
||||
|
||||
declare const XO_LITE_VERSION: string
|
||||
declare const XO_LITE_GIT_HEAD: string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xen-orchestra/lite",
|
||||
"version": "0.4.0",
|
||||
"version": "0.2.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
|
||||
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@csstools/postcss-global-data": "^2.1.1",
|
||||
"@fontsource/poppins": "^5.0.8",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.5.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||
@@ -31,7 +32,7 @@
|
||||
"@vueuse/core": "^10.7.1",
|
||||
"@vueuse/math": "^10.7.1",
|
||||
"@vueuse/shared": "^10.7.1",
|
||||
"@xen-orchestra/web-core": "^0.3.0",
|
||||
"@xen-orchestra/web-core": "*",
|
||||
"complex-matcher": "^0.7.1",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"decorator-synchronized": "^0.6.0",
|
||||
@@ -42,7 +43,7 @@
|
||||
"iterable-backoff": "^0.1.0",
|
||||
"json-rpc-2.0": "^1.7.0",
|
||||
"json5": "^2.2.3",
|
||||
"limit-concurrency-decorator": "^0.6.0",
|
||||
"limit-concurrency-decorator": "^0.5.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"make-error": "^1.3.6",
|
||||
"marked": "^9.1.5",
|
||||
@@ -56,10 +57,10 @@
|
||||
"postcss-nested": "^6.0.1",
|
||||
"typescript": "~5.3.3",
|
||||
"vite": "^5.0.11",
|
||||
"vue": "~3.4.13",
|
||||
"vue": "^3.4.13",
|
||||
"vue-echarts": "^6.6.8",
|
||||
"vue-i18n": "^9.9.0",
|
||||
"vue-router": "^4.4.0",
|
||||
"vue-router": "^4.2.5",
|
||||
"vue-tsc": "^1.8.27",
|
||||
"zx": "^7.2.3"
|
||||
},
|
||||
|
||||
@@ -270,13 +270,6 @@ if (ghRelease || deploy) {
|
||||
if (ghRelease) {
|
||||
console.log(`Adding LICENSE file to ${dist}`)
|
||||
await fs.copy(fileURLToPath(new URL('agpl-3.0.txt', import.meta.url)), path.join(dist, 'LICENSE'))
|
||||
|
||||
console.log(`Adding CHANGELOG.md file to ${dist}`)
|
||||
await fs.copy(fileURLToPath(new URL('../CHANGELOG.md', import.meta.url)), path.join(dist, 'CHANGELOG.md'))
|
||||
|
||||
console.log(`Adding xolite-loader.html to ${dist}`)
|
||||
await fs.copy(path.join(dist, 'index.html'), path.join(dist, 'xolite.html'))
|
||||
await fs.copy(fileURLToPath(new URL('xolite-loader.html', import.meta.url)), path.join(dist, 'index.html'))
|
||||
}
|
||||
|
||||
if (deploy) {
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||
<meta name="theme-color" content="#cc584c" />
|
||||
<title>XO Lite</title>
|
||||
<script>
|
||||
;(async () => {
|
||||
try {
|
||||
// Try to load XO Lite from lite.xen-orchestra.com
|
||||
const response = await fetch('https://lite.xen-orchestra.com/dist/index.html')
|
||||
if (!response.ok) {
|
||||
throw new Error(`Cannot download XO Lite from lite.xen-orchestra.com (${response.status})`)
|
||||
}
|
||||
|
||||
document.open()
|
||||
document.write(await response.text())
|
||||
document.close()
|
||||
} catch (err) {
|
||||
console.log(err?.message ?? err)
|
||||
// Fallback to local version of XO Lite
|
||||
document.open()
|
||||
document.write(await (await fetch('./xolite.html')).text())
|
||||
document.close()
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
</html>
|
||||
@@ -4,7 +4,7 @@
|
||||
</div>
|
||||
<div v-else>
|
||||
<AppHeader v-if="uiStore.hasUi" />
|
||||
<div class="container">
|
||||
<div style="display: flex">
|
||||
<AppNavigation v-if="uiStore.hasUi" />
|
||||
<main class="main" :class="{ 'no-ui': !uiStore.hasUi }">
|
||||
<RouterView />
|
||||
@@ -19,13 +19,13 @@
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import AppLogin from '@/components/AppLogin.vue'
|
||||
import AppNavigation from '@/components/AppNavigation.vue'
|
||||
import TooltipList from '@core/components/tooltip/TooltipList.vue'
|
||||
import ModalList from '@/components/ui/modals/ModalList.vue'
|
||||
import { useChartTheme } from '@/composables/chart-theme.composable'
|
||||
import { useUnreachableHosts } from '@/composables/unreachable-hosts.composable'
|
||||
import { usePoolStore } from '@/stores/xen-api/pool.store'
|
||||
import { useXenApiStore } from '@/stores/xen-api.store'
|
||||
import TooltipList from '@core/components/tooltip/TooltipList.vue'
|
||||
import { useUiStore } from '@core/stores/ui.store'
|
||||
import { usePoolCollection } from '@/stores/xen-api/pool.store'
|
||||
import { useXenApiStore } from '@/stores/xen-api.store'
|
||||
import { useActiveElement, useMagicKeys, whenever } from '@vueuse/core'
|
||||
import { logicAnd } from '@vueuse/math'
|
||||
import { computed } from 'vue'
|
||||
@@ -33,7 +33,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
const xenApiStore = useXenApiStore()
|
||||
|
||||
const { pool } = usePoolStore().subscribe()
|
||||
const { pool } = usePoolCollection()
|
||||
|
||||
useChartTheme()
|
||||
const uiStore = useUiStore()
|
||||
@@ -67,15 +67,11 @@ useUnreachableHosts()
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.main {
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
height: calc(100vh - 5.5rem);
|
||||
background-color: var(--color-neutral-background-secondary);
|
||||
background-color: var(--background-color-secondary);
|
||||
|
||||
&.no-ui {
|
||||
height: 100vh;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<MenuList placement="bottom-end" border>
|
||||
<MenuList placement="bottom-end" shadow>
|
||||
<template #trigger="{ open, isOpen }">
|
||||
<button type="button" :class="{ active: isOpen }" class="account-button" @click="open">
|
||||
<button :class="{ active: isOpen }" class="account-button" @click="open">
|
||||
<UiIcon :icon="faCircleUser" class="user-icon" />
|
||||
<UiIcon :icon="faAngleDown" class="dropdown-icon" />
|
||||
</button>
|
||||
@@ -11,7 +11,7 @@
|
||||
{{ $t('send-us-feedback') }}
|
||||
</MenuItem>
|
||||
<MenuItem :icon="faArrowRightFromBracket" class="menu-item-logout" @click="logout">
|
||||
{{ $t('log-out') }}
|
||||
{{ $t('core.log-out') }}
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</template>
|
||||
@@ -46,19 +46,19 @@ const openFeedbackUrl = () => {
|
||||
const openSettings = () => router.push({ name: 'settings' })
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
<style scoped>
|
||||
.account-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
color: var(--color-neutral-txt-primary);
|
||||
color: var(--color-grey-100);
|
||||
border: none;
|
||||
border-radius: 0.8rem;
|
||||
background-color: var(--color-neutral-background-secondary);
|
||||
background-color: var(--background-color-secondary);
|
||||
gap: 0.8rem;
|
||||
|
||||
&:disabled {
|
||||
color: var(--color-neutral-border);
|
||||
color: var(--color-grey-500);
|
||||
}
|
||||
|
||||
&:not(:disabled) {
|
||||
@@ -67,12 +67,12 @@ const openSettings = () => router.push({ name: 'settings' })
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
background-color: var(--color-neutral-background-primary);
|
||||
background-color: var(--background-color-primary);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&.active {
|
||||
color: var(--color-normal-txt-base);
|
||||
color: var(--color-purple-base);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,6 @@ const openSettings = () => router.push({ name: 'settings' })
|
||||
}
|
||||
|
||||
.menu-item-logout {
|
||||
color: var(--color-danger-txt-base);
|
||||
color: var(--color-red-base);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
<slot />
|
||||
<div class="right">
|
||||
<PoolOverrideWarning as-tooltip />
|
||||
<XoaButton v-if="isDesktop" />
|
||||
<UiButton v-if="isDesktop" :left-icon="faDownload" @click="openXoaDeploy">
|
||||
{{ $t('deploy-xoa') }}
|
||||
</UiButton>
|
||||
<AccountButton />
|
||||
</div>
|
||||
</header>
|
||||
@@ -19,11 +21,16 @@ import AccountButton from '@/components/AccountButton.vue'
|
||||
import PoolOverrideWarning from '@/components/PoolOverrideWarning.vue'
|
||||
import TextLogo from '@/components/TextLogo.vue'
|
||||
import UiIcon from '@/components/ui/icon/UiIcon.vue'
|
||||
import XoaButton from '@/components/XoaButton.vue'
|
||||
import { useNavigationStore } from '@/stores/navigation.store'
|
||||
import UiButton from '@core/components/button/UiButton.vue'
|
||||
import { useUiStore } from '@core/stores/ui.store'
|
||||
import { faBars } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faBars, faDownload } from '@fortawesome/free-solid-svg-icons'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const openXoaDeploy = () => router.push({ name: 'xoa.deploy' })
|
||||
|
||||
const uiStore = useUiStore()
|
||||
const { isMobile, isDesktop } = storeToRefs(uiStore)
|
||||
@@ -39,8 +46,8 @@ const { trigger: navigationTrigger } = storeToRefs(navigationStore)
|
||||
justify-content: space-between;
|
||||
height: 5.5rem;
|
||||
padding: 1rem;
|
||||
border-bottom: 0.1rem solid var(--color-neutral-border);
|
||||
background-color: var(--color-neutral-background-secondary);
|
||||
border-bottom: 0.1rem solid var(--color-grey-500);
|
||||
background-color: var(--background-color-secondary);
|
||||
|
||||
img {
|
||||
width: 4rem;
|
||||
|
||||
@@ -22,12 +22,12 @@
|
||||
:readonly="isConnecting"
|
||||
required
|
||||
/>
|
||||
<LoginError :error />
|
||||
<LoginError :error="error" />
|
||||
<label class="remember-me-label">
|
||||
<FormCheckbox v-model="rememberMe" />
|
||||
{{ $t('keep-me-logged') }}
|
||||
</label>
|
||||
<UiButton size="medium" color="normal" level="primary" type="submit" :busy="isConnecting">
|
||||
<UiButton type="submit" :busy="isConnecting">
|
||||
{{ $t('login') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
@@ -107,7 +107,6 @@ async function handleSubmit() {
|
||||
display: flex;
|
||||
margin: 1rem;
|
||||
width: fit-content;
|
||||
|
||||
& .form-checkbox {
|
||||
margin: auto 1rem auto auto;
|
||||
}
|
||||
@@ -120,7 +119,7 @@ async function handleSubmit() {
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
max-width: 100vw;
|
||||
background-color: var(--color-neutral-background-primary);
|
||||
background-color: var(--background-color-primary);
|
||||
}
|
||||
|
||||
form {
|
||||
@@ -132,10 +131,10 @@ form {
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
padding: 8.5rem;
|
||||
background-color: var(--color-neutral-background-secondary);
|
||||
background-color: var(--background-color-secondary);
|
||||
|
||||
.error {
|
||||
color: var(--color-danger-txt-base);
|
||||
color: var(--color-red-base);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +155,7 @@ input {
|
||||
max-width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border: 1px solid var(--color-neutral-border);
|
||||
border: 1px solid var(--color-grey-500);
|
||||
border-radius: 0.8rem;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ import markdown from '@/libs/markdown'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, type Ref, ref } from 'vue'
|
||||
|
||||
const rootElement = ref() as Ref<HTMLElement>
|
||||
|
||||
const props = defineProps<{
|
||||
content: string
|
||||
}>()
|
||||
|
||||
const rootElement = ref() as Ref<HTMLElement>
|
||||
|
||||
const html = computed(() => markdown.parse(props.content ?? ''))
|
||||
|
||||
useEventListener(
|
||||
@@ -60,7 +60,7 @@ useEventListener(
|
||||
}
|
||||
|
||||
code:not(.hljs-code) {
|
||||
background-color: var(--color-normal-background-selected);
|
||||
background-color: var(--background-color-purple-10);
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 0.6rem;
|
||||
}
|
||||
@@ -81,12 +81,12 @@ useEventListener(
|
||||
}
|
||||
|
||||
thead th {
|
||||
border-bottom: 2px solid var(--color-neutral-border);
|
||||
background-color: var(--color-neutral-background-secondary);
|
||||
border-bottom: 2px solid var(--color-grey-500);
|
||||
background-color: var(--background-color-secondary);
|
||||
}
|
||||
|
||||
tbody td {
|
||||
border-bottom: 1px solid var(--color-neutral-border);
|
||||
border-bottom: 1px solid var(--color-grey-500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,11 +103,11 @@ useEventListener(
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-normal-txt-base);
|
||||
color: var(--color-purple-base);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--color-normal-txt-hover);
|
||||
color: var(--color-purple-d20);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<Transition name="slide">
|
||||
<transition name="slide">
|
||||
<nav v-if="isDesktop || isOpen" ref="navElement" :class="{ collapsible: isMobile }" class="app-navigation">
|
||||
<StoryMenu v-if="$route.meta.hasStoryNav" />
|
||||
<InfraPoolList v-else />
|
||||
</nav>
|
||||
</Transition>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -45,8 +45,8 @@ whenever(isOpen, () => {
|
||||
max-width: 37rem;
|
||||
height: calc(100vh - 5.5rem);
|
||||
padding: 0.5rem;
|
||||
border-right: 1px solid var(--color-neutral-border);
|
||||
background-color: var(--color-neutral-background-primary);
|
||||
border-right: 1px solid var(--color-grey-500);
|
||||
background-color: var(--background-color-primary);
|
||||
|
||||
&.collapsible {
|
||||
position: fixed;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<pre class="code-highlight hljs"><code v-html="codeAsHtml" /></pre>
|
||||
<pre class="code-highlight hljs"><code v-html="codeAsHtml"></code></pre>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</template>
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
addFilter: [filter: string]
|
||||
removeFilter: [filter: string]
|
||||
(event: 'addFilter', filter: string): void
|
||||
(event: 'removeFilter', filter: string): void
|
||||
}>()
|
||||
|
||||
const openModal = (editedFilter?: string) => {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<template v-else>
|
||||
<FormWidget :before="currentFilterIcon">
|
||||
<select v-model="newFilter.builder.property">
|
||||
<option v-if="!newFilter.builder.property" value="">{{ `- ${$t('property')} -` }}</option>
|
||||
<option v-if="!newFilter.builder.property" value="">- {{ $t('property') }} -</option>
|
||||
<option v-for="(filter, property) in availableFilters" :key="property" :value="property">
|
||||
{{ filter.label ?? property }}
|
||||
</option>
|
||||
@@ -54,8 +54,8 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: NewFilter]
|
||||
remove: [filterId: number]
|
||||
(event: 'update:modelValue', value: NewFilter): void
|
||||
(event: 'remove', filterId: number): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -181,7 +181,7 @@ const valueInputAfter = computed(() => (newFilter.value.builder.comparison === '
|
||||
.collection-filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--color-neutral-background-secondary);
|
||||
border-bottom: 1px solid var(--background-color-secondary);
|
||||
gap: 1rem;
|
||||
|
||||
.or {
|
||||
|
||||
@@ -34,9 +34,9 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleSortDirection: [property: string]
|
||||
addSort: [property: string, isAscending: boolean]
|
||||
removeSort: [property: string]
|
||||
(event: 'toggleSortDirection', property: string): void
|
||||
(event: 'addSort', property: string, isAscending: boolean): void
|
||||
(event: 'removeSort', property: string): void
|
||||
}>()
|
||||
|
||||
const openModal = () => {
|
||||
|
||||
@@ -60,7 +60,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [selectedRefs: T['$ref'][]]
|
||||
(event: 'update:modelValue', selectedRefs: T['$ref'][]): void
|
||||
}>()
|
||||
|
||||
const isSelectable = computed(() => props.modelValue !== undefined)
|
||||
|
||||
@@ -54,14 +54,14 @@ const isIcon = (maybeIcon: any): maybeIcon is IconDefinition => typeof maybeIcon
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
padding: 0 0.7rem;
|
||||
border: 1px solid var(--color-neutral-border);
|
||||
border: 1px solid var(--color-grey-500);
|
||||
border-radius: 0.8rem;
|
||||
background-color: var(--color-neutral-background-primary);
|
||||
background-color: var(--color-grey-600);
|
||||
box-shadow: var(--shadow-100);
|
||||
gap: 0.1rem;
|
||||
|
||||
&:focus-within {
|
||||
outline: 1px solid var(--color-normal-item-active);
|
||||
outline: 1px solid var(--color-purple-l40);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ const isIcon = (maybeIcon: any): maybeIcon is IconDefinition => typeof maybeIcon
|
||||
}
|
||||
|
||||
.form-widget:hover .widget {
|
||||
border-color: var(--color-normal-item-hover);
|
||||
border-color: var(--color-purple-l60);
|
||||
}
|
||||
|
||||
.element {
|
||||
@@ -93,8 +93,8 @@ const isIcon = (maybeIcon: any): maybeIcon is IconDefinition => typeof maybeIcon
|
||||
font-size: inherit;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--color-neutral-txt-primary);
|
||||
background-color: var(--color-neutral-background-primary);
|
||||
color: var(--color-grey-100);
|
||||
background-color: var(--color-grey-600);
|
||||
flex: 1;
|
||||
|
||||
&:disabled {
|
||||
@@ -134,7 +134,7 @@ const isIcon = (maybeIcon: any): maybeIcon is IconDefinition => typeof maybeIcon
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--color-neutral-txt-secondary);
|
||||
color: var(--color-grey-200);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</th>
|
||||
<td v-if="hasMultipleHosts">
|
||||
<UiSpinner v-if="!areAllLoaded" />
|
||||
<VtsCounter
|
||||
<UiCounter
|
||||
v-else
|
||||
v-tooltip="{
|
||||
placement: 'left',
|
||||
@@ -19,9 +19,7 @@
|
||||
}"
|
||||
:value="patch.$hostRefs.size"
|
||||
class="counter"
|
||||
accent="danger"
|
||||
variant="primary"
|
||||
size="small"
|
||||
color="error"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -33,7 +31,7 @@ import UiCardSpinner from '@/components/ui/UiCardSpinner.vue'
|
||||
import UiSpinner from '@/components/ui/UiSpinner.vue'
|
||||
import UiTable from '@/components/ui/UiTable.vue'
|
||||
import type { XenApiPatchWithHostRefs } from '@/composables/host-patches.composable'
|
||||
import VtsCounter from '@core/components/counter/VtsCounter.vue'
|
||||
import UiCounter from '@core/components/UiCounter.vue'
|
||||
import { vTooltip } from '@core/directives/tooltip.directive'
|
||||
import { useUiStore } from '@core/stores/ui.store'
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -23,7 +23,7 @@ defineProps<{
|
||||
<style lang="postcss" scoped>
|
||||
.error {
|
||||
margin: 0.5rem 0;
|
||||
color: var(--color-danger-txt-base);
|
||||
color: var(--color-red-base);
|
||||
|
||||
& svg {
|
||||
margin-right: 0.5rem;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="no-data">
|
||||
<img alt="" class="img" src="@/assets/undraw-bug-fixing.svg" />
|
||||
<img alt="No data" class="img" src="@/assets/undraw-bug-fixing.svg" />
|
||||
<p class="text-error typo h4-medium">{{ $t('error-no-data') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -22,6 +22,6 @@
|
||||
margin: auto;
|
||||
width: 8em;
|
||||
font-style: normal;
|
||||
color: var(--color-danger-txt-base);
|
||||
color: var(--color-red-base);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,6 +27,6 @@
|
||||
font-weight: 500;
|
||||
font-size: 2rem;
|
||||
line-height: 150%;
|
||||
color: var(--color-normal-txt-base);
|
||||
color: var(--color-purple-base);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
<script generic="T extends ObjectType" lang="ts" setup>
|
||||
import UiSpinner from '@/components/ui/UiSpinner.vue'
|
||||
import type { ObjectType, ObjectTypeToRecord } from '@/libs/xen-api/xen-api.types'
|
||||
import type { XapiContext } from '@/stores/xen-api/create-xapi-store-config'
|
||||
import { useHostStore } from '@/stores/xen-api/host.store'
|
||||
import { usePoolStore } from '@/stores/xen-api/pool.store'
|
||||
import { useSrStore } from '@/stores/xen-api/sr.store'
|
||||
import { useVmStore } from '@/stores/xen-api/vm.store'
|
||||
import { computed, watch } from 'vue'
|
||||
import type { StoreDefinition } from 'pinia'
|
||||
import { computed, onUnmounted, watch } from 'vue'
|
||||
import type { RouteRecordName } from 'vue-router'
|
||||
|
||||
type HandledTypes = 'host' | 'vm' | 'sr' | 'pool'
|
||||
@@ -28,7 +28,7 @@ type Config = Partial<
|
||||
Record<
|
||||
ObjectType,
|
||||
{
|
||||
context: XapiContext<any> & { start: () => void; stop: () => void }
|
||||
useStore: StoreDefinition<any, any, any, any>
|
||||
routeName: RouteRecordName | undefined
|
||||
}
|
||||
>
|
||||
@@ -40,27 +40,33 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const config: Config = {
|
||||
host: { context: useHostStore().subscribe({ defer: true }), routeName: 'host.dashboard' },
|
||||
vm: { context: useVmStore().subscribe({ defer: true }), routeName: 'vm.console' },
|
||||
sr: { context: useSrStore().subscribe({ defer: true }), routeName: undefined },
|
||||
pool: { context: usePoolStore().subscribe({ defer: true }), routeName: 'pool.dashboard' },
|
||||
host: { useStore: useHostStore, routeName: 'host.dashboard' },
|
||||
vm: { useStore: useVmStore, routeName: 'vm.console' },
|
||||
sr: { useStore: useSrStore, routeName: undefined },
|
||||
pool: { useStore: usePoolStore, routeName: 'pool.dashboard' },
|
||||
} satisfies Record<HandledTypes, any>
|
||||
|
||||
const context = computed(() => config[props.type]?.context)
|
||||
const store = computed(() => config[props.type]?.useStore())
|
||||
|
||||
const subscriptionId = Symbol('OBJECT_LINK_SUBSCRIPTION_ID')
|
||||
|
||||
watch(
|
||||
context,
|
||||
(nextContext, previousContext) => {
|
||||
previousContext?.stop()
|
||||
nextContext?.start()
|
||||
store,
|
||||
(nextStore, previousStore) => {
|
||||
previousStore?.unsubscribe(subscriptionId)
|
||||
nextStore?.subscribe(subscriptionId)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const record = computed(() => context.value?.getByUuid(props.uuid as any))
|
||||
onUnmounted(() => {
|
||||
store.value?.unsubscribe(subscriptionId)
|
||||
})
|
||||
|
||||
const record = computed<ObjectTypeToRecord<HandledTypes> | undefined>(() => store.value?.getByUuid(props.uuid as any))
|
||||
|
||||
const isReady = computed(() => {
|
||||
return context.value?.isReady.value ?? true
|
||||
return store.value?.isReady ?? true
|
||||
})
|
||||
|
||||
const objectRoute = computed(() => {
|
||||
@@ -79,7 +85,7 @@ const objectRoute = computed(() => {
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.unknown {
|
||||
color: var(--color-neutral-txt-secondary);
|
||||
color: var(--color-grey-300);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,14 +26,14 @@ const id = computed(() => props.id ?? (currentRoute.value.params.uuid as I))
|
||||
const isRecordNotFound = computed(() => props.isReady && !props.uuidChecker(id.value))
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
<style scoped>
|
||||
.wrapper-spinner {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
color: var(--color-normal-txt-base);
|
||||
color: var(--color-purple-base);
|
||||
display: flex;
|
||||
margin: auto;
|
||||
width: 10rem;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{{ `${$t('here')} →` }}
|
||||
{{ $t('here') }} →
|
||||
</a>
|
||||
</p>
|
||||
</UiStatusPanel>
|
||||
@@ -24,7 +24,7 @@ import UiStatusPanel from '@/components/ui/UiStatusPanel.vue'
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.contact {
|
||||
color: var(--color-neutral-txt-primary);
|
||||
color: var(--color-grey-100);
|
||||
|
||||
& a {
|
||||
text-transform: lowercase;
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
<div class="wrapper">
|
||||
<UiIcon :icon="faWarning" />
|
||||
<p v-if="!asTooltip">
|
||||
<I18nT keypath="you-are-currently-on">
|
||||
<i18n-t keypath="you-are-currently-on">
|
||||
<strong>{{ masterSessionStorage }}</strong>
|
||||
</I18nT>
|
||||
</i18n-t>
|
||||
<br />
|
||||
{{ $t('click-to-return-default-pool') }}
|
||||
</p>
|
||||
@@ -27,11 +27,12 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { faWarning } from '@fortawesome/free-solid-svg-icons'
|
||||
import { useSessionStorage } from '@vueuse/core'
|
||||
|
||||
import UiIcon from '@/components/ui/icon/UiIcon.vue'
|
||||
import { useXenApiStore } from '@/stores/xen-api.store'
|
||||
import { vTooltip } from '@core/directives/tooltip.directive'
|
||||
import { faWarning } from '@fortawesome/free-solid-svg-icons'
|
||||
import { useSessionStorage } from '@vueuse/core'
|
||||
|
||||
defineProps<{
|
||||
asTooltip?: boolean
|
||||
@@ -43,7 +44,7 @@ const masterSessionStorage = useSessionStorage('master', null)
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.warning-not-current-pool {
|
||||
color: var(--color-warning-txt-base);
|
||||
color: var(--color-orange-base);
|
||||
cursor: pointer;
|
||||
|
||||
.wrapper {
|
||||
|
||||
@@ -26,22 +26,18 @@ const className = computed(() => `state-${props.state.toLocaleLowerCase()}`)
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.power-state-icon {
|
||||
color: var(--color-normal-txt-item);
|
||||
color: var(--color-purple-d60);
|
||||
|
||||
&.state-running {
|
||||
color: var(--color-success-item-base);
|
||||
color: var(--color-green-base);
|
||||
}
|
||||
|
||||
&.state-paused {
|
||||
color: var(--color-neutral-txt-secondary);
|
||||
color: var(--color-grey-300);
|
||||
}
|
||||
|
||||
&.state-suspended {
|
||||
color: var(--color-normal-txt-hover);
|
||||
}
|
||||
|
||||
&.state-halted {
|
||||
color: var(--color-danger-item-base);
|
||||
color: var(--color-purple-d20);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
class="progress-circle-fill"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
/>
|
||||
<text class="progress-circle-text" text-anchor="middle" x="50%" y="50%">
|
||||
{{ $n(progress / 100, 'percent') }}
|
||||
</text>
|
||||
<text class="progress-circle-text" text-anchor="middle" x="50%" y="50%">{{ progress }}%</text>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
@@ -39,7 +37,7 @@ const progress = computed(() => {
|
||||
.progress-circle-fill {
|
||||
animation: progress 1s ease-out forwards;
|
||||
fill: none;
|
||||
stroke: var(--color-success-item-base);
|
||||
stroke: var(--color-green-base);
|
||||
stroke-width: 1.2;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: v-bind(progress), 100;
|
||||
@@ -48,13 +46,13 @@ const progress = computed(() => {
|
||||
.progress-circle-background {
|
||||
fill: none;
|
||||
stroke-width: 1.2;
|
||||
stroke: var(--color-neutral-background-disabled);
|
||||
stroke: var(--color-grey-500);
|
||||
}
|
||||
|
||||
.progress-circle-text {
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
fill: var(--color-success-item-base);
|
||||
fill: var(--color-green-base);
|
||||
text-anchor: middle;
|
||||
alignment-baseline: middle;
|
||||
}
|
||||
|
||||
@@ -16,3 +16,5 @@ const date = computed(() => new Date(parseDateTime(props.date)))
|
||||
const now = useNow({ interval: 1000 })
|
||||
const relativeTime = useRelativeTime(date, now)
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped></style>
|
||||
|
||||
@@ -4,19 +4,19 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useXenApiStore } from '@/stores/xen-api.store'
|
||||
import VncClient from '@novnc/novnc/lib/rfb'
|
||||
import VncClient from '@novnc/novnc/core/rfb'
|
||||
import { promiseTimeout } from '@vueuse/shared'
|
||||
import { fibonacci } from 'iterable-backoff'
|
||||
import { computed, onBeforeUnmount, ref, watchEffect } from 'vue'
|
||||
|
||||
const N_TOTAL_TRIES = 8
|
||||
const FIBONACCI_MS_ARRAY: number[] = Array.from(fibonacci().toMs().take(N_TOTAL_TRIES))
|
||||
|
||||
const props = defineProps<{
|
||||
location: string
|
||||
isConsoleAvailable: boolean
|
||||
}>()
|
||||
|
||||
const N_TOTAL_TRIES = 8
|
||||
const FIBONACCI_MS_ARRAY: number[] = Array.from(fibonacci().toMs().take(N_TOTAL_TRIES))
|
||||
|
||||
const vmConsoleContainer = ref<HTMLDivElement>()
|
||||
const xenApiStore = useXenApiStore()
|
||||
const url = computed(() => {
|
||||
@@ -32,7 +32,7 @@ const url = computed(() => {
|
||||
let vncClient: VncClient | undefined
|
||||
let nConnectionAttempts = 0
|
||||
|
||||
function handleDisconnectionEvent() {
|
||||
const handleDisconnectionEvent = () => {
|
||||
clearVncClient()
|
||||
|
||||
if (props.isConsoleAvailable) {
|
||||
@@ -51,12 +51,9 @@ function handleDisconnectionEvent() {
|
||||
createVncConnection()
|
||||
}
|
||||
}
|
||||
const handleConnectionEvent = () => (nConnectionAttempts = 0)
|
||||
|
||||
function handleConnectionEvent() {
|
||||
nConnectionAttempts = 0
|
||||
}
|
||||
|
||||
function clearVncClient() {
|
||||
const clearVncClient = () => {
|
||||
if (vncClient === undefined) {
|
||||
return
|
||||
}
|
||||
@@ -64,12 +61,14 @@ function clearVncClient() {
|
||||
vncClient.removeEventListener('disconnect', handleDisconnectionEvent)
|
||||
vncClient.removeEventListener('connect', handleConnectionEvent)
|
||||
|
||||
vncClient.disconnect()
|
||||
if (vncClient._rfbConnectionState !== 'disconnected') {
|
||||
vncClient.disconnect()
|
||||
}
|
||||
|
||||
vncClient = undefined
|
||||
}
|
||||
|
||||
async function createVncConnection() {
|
||||
const createVncConnection = async () => {
|
||||
if (nConnectionAttempts !== 0) {
|
||||
await promiseTimeout(FIBONACCI_MS_ARRAY[nConnectionAttempts - 1])
|
||||
|
||||
|
||||
@@ -29,18 +29,18 @@ defineProps<{
|
||||
align-items: center;
|
||||
height: 6rem;
|
||||
padding: 0 1.5rem;
|
||||
border-bottom: 1px solid var(--color-neutral-border);
|
||||
background-color: var(--color-neutral-background-primary);
|
||||
border-bottom: 1px solid var(--color-grey-500);
|
||||
background-color: var(--background-color-primary);
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 2.5rem;
|
||||
color: var(--color-normal-txt-base);
|
||||
color: var(--color-purple-base);
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--color-neutral-txt-primary);
|
||||
color: var(--color-grey-100);
|
||||
}
|
||||
|
||||
.actions {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
class="progress-item"
|
||||
>
|
||||
<UiProgressBar :value="item.value" color="custom" />
|
||||
<UiProgressLegend :label="item.label" :value="item.badgeLabel ?? $n(item.value / 100, 'percent')" />
|
||||
<UiProgressLegend :label="item.label" :value="item.badgeLabel ?? `${item.value}%`" />
|
||||
</div>
|
||||
<slot :total-percent="computedData.totalPercentUsage" name="footer" />
|
||||
</div>
|
||||
@@ -27,11 +27,11 @@ interface Props {
|
||||
nItems?: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const MIN_WARNING_VALUE = 80
|
||||
const MIN_DANGEROUS_VALUE = 90
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const computedData = computed(() => {
|
||||
const _data = props.data
|
||||
let totalPercentUsage = 0
|
||||
@@ -60,28 +60,28 @@ const computedData = computed(() => {
|
||||
}
|
||||
|
||||
.progress-item:nth-child(1) {
|
||||
--progress-bar-color: var(--color-normal-txt-item);
|
||||
--progress-bar-color: var(--color-purple-d60);
|
||||
}
|
||||
|
||||
.progress-item:nth-child(2) {
|
||||
--progress-bar-color: var(--color-normal-txt-active);
|
||||
--progress-bar-color: var(--color-purple-d40);
|
||||
}
|
||||
|
||||
.progress-item:nth-child(3) {
|
||||
--progress-bar-color: var(--color-normal-txt-hover);
|
||||
--progress-bar-color: var(--color-purple-d20);
|
||||
}
|
||||
|
||||
.progress-item {
|
||||
--progress-bar-height: 1.2rem;
|
||||
--progress-bar-color: var(--color-normal-item-hover);
|
||||
--progress-bar-background-color: var(--color-neutral-background-disabled);
|
||||
--progress-bar-color: var(--color-purple-l20);
|
||||
--progress-bar-background-color: var(--color-grey-500);
|
||||
|
||||
&.warning {
|
||||
--progress-bar-color: var(--color-warning-item-base);
|
||||
--progress-bar-color: var(--color-orange-base);
|
||||
}
|
||||
|
||||
&.error {
|
||||
--progress-bar-color: var(--color-danger-item-base);
|
||||
--progress-bar-color: var(--color-red-base);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<template>
|
||||
<UiButton
|
||||
v-if="xoaFound"
|
||||
size="medium"
|
||||
color="normal"
|
||||
level="primary"
|
||||
:left-icon="faArrowUpRightFromSquare"
|
||||
class="xoa-button"
|
||||
@click="openXoa()"
|
||||
>
|
||||
{{ $t('access-xoa') }}
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-else
|
||||
size="medium"
|
||||
color="normal"
|
||||
level="primary"
|
||||
:left-icon="faDownload"
|
||||
class="xoa-button"
|
||||
@click="openXoaDeploy()"
|
||||
>
|
||||
{{ $t('deploy-xoa') }}
|
||||
</UiButton>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { PRIMARY_ADDRESS_TYPE } from '@/libs/xen-api/xen-api.enums'
|
||||
import { usePoolStore } from '@/stores/xen-api/pool.store'
|
||||
import UiButton from '@core/components/button/UiButton.vue'
|
||||
import { faDownload, faArrowUpRightFromSquare } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const { pool } = usePoolStore().subscribe()
|
||||
const XOA_INFO_KEY_PREFIX = 'xo:clientInfo:'
|
||||
|
||||
interface InterfaceInfo {
|
||||
address: string
|
||||
netmask: string
|
||||
mac: string
|
||||
cidr: string
|
||||
family: PRIMARY_ADDRESS_TYPE
|
||||
internal: boolean
|
||||
}
|
||||
|
||||
interface ClientInfo {
|
||||
lastConnected: number
|
||||
networkInterfaces?: Record<string, InterfaceInfo[]>
|
||||
publicUrl?: string
|
||||
}
|
||||
|
||||
const xoasInfo = computed(() => {
|
||||
if (pool.value === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(pool.value.other_config)
|
||||
.filter(([key]) => key.startsWith(XOA_INFO_KEY_PREFIX))
|
||||
.map(([key, json]) => [key.slice(XOA_INFO_KEY_PREFIX.length), JSON.parse(json)])
|
||||
) as Record<string, ClientInfo>
|
||||
})
|
||||
|
||||
const xoaInfo = computed(() => {
|
||||
if (xoasInfo.value === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
return Object.values(xoasInfo.value).reduce((lastXoaInfo, info) =>
|
||||
lastXoaInfo === undefined || info.lastConnected > lastXoaInfo.lastConnected ? info : lastXoaInfo
|
||||
)
|
||||
})
|
||||
|
||||
const xoaAddress = computed(() => {
|
||||
if (xoaInfo.value === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const { networkInterfaces, publicUrl } = xoaInfo.value
|
||||
|
||||
if (publicUrl !== undefined) {
|
||||
return publicUrl
|
||||
}
|
||||
|
||||
if (networkInterfaces === undefined || Object.keys(networkInterfaces).length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const ifName = 'eth0' in networkInterfaces ? 'eth0' : Object.keys(networkInterfaces)[0]
|
||||
const xoaAddress = networkInterfaces[ifName][0].address
|
||||
|
||||
return `https://${xoaAddress}`
|
||||
})
|
||||
|
||||
const xoaFound = computed(() => xoaAddress.value !== undefined)
|
||||
|
||||
const openXoaDeploy = () => router.push({ name: 'xoa.deploy' })
|
||||
const openXoa = () => window.open(xoaAddress.value, '_blank', 'noopener')
|
||||
</script>
|
||||
@@ -14,14 +14,14 @@ import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { computed, provide } from 'vue'
|
||||
import VueCharts from 'vue-echarts'
|
||||
|
||||
const Y_AXIS_MAX_VALUE = 200
|
||||
|
||||
const props = defineProps<{
|
||||
data: LinearChartData
|
||||
valueFormatter?: ValueFormatter
|
||||
maxValue?: number
|
||||
}>()
|
||||
|
||||
const Y_AXIS_MAX_VALUE = 200
|
||||
|
||||
const valueFormatter = computed<ValueFormatter>(() => {
|
||||
const formatter = props.valueFormatter
|
||||
|
||||
|
||||
@@ -3,17 +3,11 @@
|
||||
<TabItem v-bind="tab(TAB.PROPS, propParams)">Props</TabItem>
|
||||
<TabItem class="event-tab" v-bind="tab(TAB.EVENTS, eventParams)">
|
||||
Events
|
||||
<VtsCounter
|
||||
v-if="unreadEventsCount > 0"
|
||||
:value="unreadEventsCount"
|
||||
accent="success"
|
||||
variant="primary"
|
||||
size="small"
|
||||
/>
|
||||
<UiCounter v-if="unreadEventsCount > 0" :value="unreadEventsCount" color="success" />
|
||||
</TabItem>
|
||||
<TabItem v-bind="tab(TAB.SLOTS, slotParams)">Slots</TabItem>
|
||||
<TabItem v-bind="tab(TAB.SETTINGS, settingParams)">Settings</TabItem>
|
||||
<MenuList placement="bottom" border>
|
||||
<MenuList placement="bottom" shadow>
|
||||
<template #trigger="{ open, isOpen }">
|
||||
<TabItem :active="isOpen" :disabled="presets === undefined" class="preset-tab" @click="open">
|
||||
<UiIcon :icon="faSliders" />
|
||||
@@ -41,9 +35,7 @@
|
||||
<UiCardTitle>
|
||||
Logs
|
||||
<template #right>
|
||||
<UiButton v-if="eventsLog.length > 0" size="medium" color="normal" level="tertiary" @click="eventsLog = []">
|
||||
Clear
|
||||
</UiButton>
|
||||
<UiButton v-if="eventsLog.length > 0" level="tertiary" @click="eventsLog = []"> Clear </UiButton>
|
||||
</template>
|
||||
</UiCardTitle>
|
||||
<div class="events-log">
|
||||
@@ -86,17 +78,32 @@ import {
|
||||
type Param,
|
||||
} from '@/libs/story/story-param'
|
||||
import UiButton from '@core/components/button/UiButton.vue'
|
||||
import VtsCounter from '@core/components/counter/VtsCounter.vue'
|
||||
import MenuItem from '@core/components/menu/MenuItem.vue'
|
||||
import MenuList from '@core/components/menu/MenuList.vue'
|
||||
import TabItem from '@core/components/tab/TabItem.vue'
|
||||
import TabList from '@core/components/tab/TabList.vue'
|
||||
import UiCounter from '@core/components/UiCounter.vue'
|
||||
import { faSliders } from '@fortawesome/free-solid-svg-icons'
|
||||
import 'highlight.js/styles/github-dark.css'
|
||||
import { uniqueId, upperFirst } from 'lodash-es'
|
||||
import { computed, onBeforeMount, reactive, ref, watch, watchEffect } from 'vue'
|
||||
import { computed, reactive, ref, watch, watchEffect } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
enum TAB {
|
||||
NONE,
|
||||
PROPS,
|
||||
EVENTS,
|
||||
SLOTS,
|
||||
SETTINGS,
|
||||
}
|
||||
|
||||
const tab = (tab: TAB, params: Param[]) =>
|
||||
reactive({
|
||||
onClick: () => (selectedTab.value = tab),
|
||||
active: computed(() => selectedTab.value === tab),
|
||||
disabled: computed(() => params.length === 0),
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
params: (Param | ModelParam)[]
|
||||
presets?: Record<
|
||||
@@ -109,23 +116,6 @@ const props = defineProps<{
|
||||
fullWidthComponent?: boolean
|
||||
}>()
|
||||
|
||||
enum TAB {
|
||||
NONE,
|
||||
PROPS,
|
||||
EVENTS,
|
||||
SLOTS,
|
||||
SETTINGS,
|
||||
}
|
||||
|
||||
const selectedTab = ref<TAB>(TAB.NONE)
|
||||
|
||||
const tab = (tab: TAB, params: Param[]) =>
|
||||
reactive({
|
||||
onClick: () => (selectedTab.value = tab),
|
||||
active: computed(() => selectedTab.value === tab),
|
||||
disabled: computed(() => params.length === 0),
|
||||
})
|
||||
|
||||
const modelParams = computed(() => props.params.filter(isModelParam))
|
||||
|
||||
const propParams = computed(() => [
|
||||
@@ -141,17 +131,17 @@ const eventParams = computed(() => [
|
||||
const settingParams = computed(() => props.params.filter(isSettingParam))
|
||||
const slotParams = computed(() => props.params.filter(isSlotParam))
|
||||
|
||||
onBeforeMount(() => {
|
||||
if (propParams.value.length !== 0) {
|
||||
selectedTab.value = TAB.PROPS
|
||||
} else if (eventParams.value.length !== 0) {
|
||||
selectedTab.value = TAB.EVENTS
|
||||
} else if (slotParams.value.length !== 0) {
|
||||
selectedTab.value = TAB.SLOTS
|
||||
} else if (settingParams.value.length !== 0) {
|
||||
selectedTab.value = TAB.SETTINGS
|
||||
}
|
||||
})
|
||||
const selectedTab = ref<TAB>(TAB.NONE)
|
||||
|
||||
if (propParams.value.length !== 0) {
|
||||
selectedTab.value = TAB.PROPS
|
||||
} else if (eventParams.value.length !== 0) {
|
||||
selectedTab.value = TAB.EVENTS
|
||||
} else if (slotParams.value.length !== 0) {
|
||||
selectedTab.value = TAB.SLOTS
|
||||
} else if (settingParams.value.length !== 0) {
|
||||
selectedTab.value = TAB.SETTINGS
|
||||
}
|
||||
|
||||
const propValues = ref<Record<string, any>>({})
|
||||
const settingValues = ref<Record<string, any>>({})
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<tr>
|
||||
<th>Event</th>
|
||||
<th>Type</th>
|
||||
<th />
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const moonDistance = 384400
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
imString: string
|
||||
@@ -44,13 +46,11 @@ withDefaults(
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
'update:customModel': [value: string]
|
||||
click: []
|
||||
clickWithArg: [id: string]
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'update:customModel', value: string): void
|
||||
(event: 'click'): void
|
||||
(event: 'clickWithArg', id: string): void
|
||||
}>()
|
||||
|
||||
const moonDistance = 384400
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user