Compare commits

..

5 Commits

784 changed files with 12978 additions and 44113 deletions

View File

@@ -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',
},

View File

@@ -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

View File

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

View File

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

View File

@@ -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`)
},

View File

@@ -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"

View File

@@ -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"
```

View File

@@ -1 +0,0 @@
../../scripts/npmignore

View File

@@ -1,51 +0,0 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/json-hash
[![Package Version](https://badgen.net/npm/v/@vates/json-hash)](https://npmjs.org/package/@vates/json-hash) ![License](https://badgen.net/npm/license/@vates/json-hash) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/json-hash)](https://bundlephobia.com/result?p=@vates/json-hash) [![Node compatibility](https://badgen.net/npm/node/@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)

View File

@@ -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')
}

View File

@@ -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)
})
}

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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",

View File

@@ -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())
})

View File

@@ -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
}
}

View File

@@ -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"

View File

@@ -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' }
```

View File

@@ -1 +0,0 @@
../../scripts/npmignore

View File

@@ -1,77 +0,0 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/obfuscate
[![Package Version](https://badgen.net/npm/v/@vates/obfuscate)](https://npmjs.org/package/@vates/obfuscate) ![License](https://badgen.net/npm/license/@vates/obfuscate) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/obfuscate)](https://bundlephobia.com/result?p=@vates/obfuscate) [![Node compatibility](https://badgen.net/npm/node/@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)

View File

@@ -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"
}
}

View File

@@ -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
}

View File

@@ -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}` })
})
}
})

View File

@@ -27,7 +27,6 @@
"ms": "^2.1.2"
},
"scripts": {
"postversion": "npm publish --access public",
"test": "node --test"
"postversion": "npm publish --access public"
}
}

View File

@@ -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

View File

@@ -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)
}
}
}

View File

@@ -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()

View File

@@ -13,7 +13,7 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.4.1",
"version": "0.3.0",
"engines": {
"node": ">=14"
},

View File

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

View File

@@ -7,8 +7,8 @@
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.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",

View File

@@ -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']

View File

@@ -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, {

View File

@@ -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()
}
}
})

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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')
)
}

View File

@@ -15,7 +15,6 @@ const noop = Function.prototype
const DEFAULT_XAPI_VM_SETTINGS = {
bypassVdiChainsCheck: false,
cbtDestroySnapshotData: false,
checkpointSnapshot: false,
concurrency: 2,
copyRetention: 0,

View File

@@ -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

View File

@@ -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')
}
}
}

View File

@@ -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')
}
}
}

View File

@@ -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
}
}

View File

@@ -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) {

View File

@@ -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()
}

View File

@@ -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,
}),
])

View File

@@ -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)]

View File

@@ -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,
}),
])
}

View File

@@ -38,7 +38,6 @@ export const MixinRemoteWriter = (BaseClass = Object) =>
},
lock: false,
mergeBlockConcurrency: this._config.mergeBlockConcurrency,
removeTmp: true,
})
})
} catch (error) {

View File

@@ -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 =

View File

@@ -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
}

View File

@@ -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:

View File

@@ -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": {

View File

@@ -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"

View File

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

View File

@@ -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
```

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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'
)
})
})
}

View File

@@ -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"
}
}

View File

@@ -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"
},

View File

@@ -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

View File

@@ -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",

View File

@@ -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))

View File

@@ -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 -->
```

View File

@@ -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

View File

@@ -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

View File

@@ -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"
},

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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) => {

View File

@@ -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 {

View File

@@ -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 = () => {

View File

@@ -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)

View File

@@ -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>

View File

@@ -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'

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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])

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>>({})

View File

@@ -4,7 +4,7 @@
<tr>
<th>Event</th>
<th>Type</th>
<th />
<th></th>
</tr>
</thead>
<tbody>

View File

@@ -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