Compare commits

..

1 Commits

Author SHA1 Message Date
Thierry
b8f741cb29 feat(lite): use new defineSlots macro from Vue 3.3 2023-07-12 10:52:11 +02:00
132 changed files with 3129 additions and 4286 deletions

View File

@@ -1,7 +1,9 @@
import LRU from 'lru-cache'
import Fuse from 'fuse-native'
import { VhdSynthetic } from 'vhd-lib'
import { Disposable, fromCallback } from 'promise-toolbox'
'use strict'
const LRU = require('lru-cache')
const Fuse = require('fuse-native')
const { VhdSynthetic } = require('vhd-lib')
const { Disposable, fromCallback } = require('promise-toolbox')
// build a s stat object from https://github.com/fuse-friends/fuse-native/blob/master/test/fixtures/stat.js
const stat = st => ({
@@ -14,7 +16,7 @@ const stat = st => ({
gid: st.gid !== undefined ? st.gid : process.getgid(),
})
export const mount = Disposable.factory(async function* mount(handler, diskPath, mountDir) {
exports.mount = Disposable.factory(async function* mount(handler, diskPath, mountDir) {
const vhd = yield VhdSynthetic.fromVhdChain(handler, diskPath)
const cache = new LRU({

View File

@@ -15,9 +15,8 @@
"url": "https://vates.fr"
},
"engines": {
"node": ">=14"
"node": ">=10.0"
},
"main": "./index.mjs",
"dependencies": {
"fuse-native": "^2.2.6",
"lru-cache": "^7.14.0",

View File

@@ -0,0 +1,42 @@
'use strict'
exports.INIT_PASSWD = Buffer.from('NBDMAGIC') // "NBDMAGIC" ensure we're connected to a nbd server
exports.OPTS_MAGIC = Buffer.from('IHAVEOPT') // "IHAVEOPT" start an option block
exports.NBD_OPT_REPLY_MAGIC = 1100100111001001n // magic received during negociation
exports.NBD_OPT_EXPORT_NAME = 1
exports.NBD_OPT_ABORT = 2
exports.NBD_OPT_LIST = 3
exports.NBD_OPT_STARTTLS = 5
exports.NBD_OPT_INFO = 6
exports.NBD_OPT_GO = 7
exports.NBD_FLAG_HAS_FLAGS = 1 << 0
exports.NBD_FLAG_READ_ONLY = 1 << 1
exports.NBD_FLAG_SEND_FLUSH = 1 << 2
exports.NBD_FLAG_SEND_FUA = 1 << 3
exports.NBD_FLAG_ROTATIONAL = 1 << 4
exports.NBD_FLAG_SEND_TRIM = 1 << 5
exports.NBD_FLAG_FIXED_NEWSTYLE = 1 << 0
exports.NBD_CMD_FLAG_FUA = 1 << 0
exports.NBD_CMD_FLAG_NO_HOLE = 1 << 1
exports.NBD_CMD_FLAG_DF = 1 << 2
exports.NBD_CMD_FLAG_REQ_ONE = 1 << 3
exports.NBD_CMD_FLAG_FAST_ZERO = 1 << 4
exports.NBD_CMD_READ = 0
exports.NBD_CMD_WRITE = 1
exports.NBD_CMD_DISC = 2
exports.NBD_CMD_FLUSH = 3
exports.NBD_CMD_TRIM = 4
exports.NBD_CMD_CACHE = 5
exports.NBD_CMD_WRITE_ZEROES = 6
exports.NBD_CMD_BLOCK_STATUS = 7
exports.NBD_CMD_RESIZE = 8
exports.NBD_REQUEST_MAGIC = 0x25609513 // magic number to create a new NBD request to send to the server
exports.NBD_REPLY_MAGIC = 0x67446698 // magic number received from the server when reading response to a nbd request
exports.NBD_REPLY_ACK = 1
exports.NBD_DEFAULT_PORT = 10809
exports.NBD_DEFAULT_BLOCK_SIZE = 64 * 1024

View File

@@ -1,41 +0,0 @@
export const INIT_PASSWD = Buffer.from('NBDMAGIC') // "NBDMAGIC" ensure we're connected to a nbd server
export const OPTS_MAGIC = Buffer.from('IHAVEOPT') // "IHAVEOPT" start an option block
export const NBD_OPT_REPLY_MAGIC = 1100100111001001n // magic received during negociation
export const NBD_OPT_EXPORT_NAME = 1
export const NBD_OPT_ABORT = 2
export const NBD_OPT_LIST = 3
export const NBD_OPT_STARTTLS = 5
export const NBD_OPT_INFO = 6
export const NBD_OPT_GO = 7
export const NBD_FLAG_HAS_FLAGS = 1 << 0
export const NBD_FLAG_READ_ONLY = 1 << 1
export const NBD_FLAG_SEND_FLUSH = 1 << 2
export const NBD_FLAG_SEND_FUA = 1 << 3
export const NBD_FLAG_ROTATIONAL = 1 << 4
export const NBD_FLAG_SEND_TRIM = 1 << 5
export const NBD_FLAG_FIXED_NEWSTYLE = 1 << 0
export const NBD_CMD_FLAG_FUA = 1 << 0
export const NBD_CMD_FLAG_NO_HOLE = 1 << 1
export const NBD_CMD_FLAG_DF = 1 << 2
export const NBD_CMD_FLAG_REQ_ONE = 1 << 3
export const NBD_CMD_FLAG_FAST_ZERO = 1 << 4
export const NBD_CMD_READ = 0
export const NBD_CMD_WRITE = 1
export const NBD_CMD_DISC = 2
export const NBD_CMD_FLUSH = 3
export const NBD_CMD_TRIM = 4
export const NBD_CMD_CACHE = 5
export const NBD_CMD_WRITE_ZEROES = 6
export const NBD_CMD_BLOCK_STATUS = 7
export const NBD_CMD_RESIZE = 8
export const NBD_REQUEST_MAGIC = 0x25609513 // magic number to create a new NBD request to send to the server
export const NBD_REPLY_MAGIC = 0x67446698 // magic number received from the server when reading response to a nbd request
export const NBD_REPLY_ACK = 1
export const NBD_DEFAULT_PORT = 10809
export const NBD_DEFAULT_BLOCK_SIZE = 64 * 1024

View File

@@ -1,11 +1,8 @@
import assert from 'node:assert'
import { Socket } from 'node:net'
import { connect } from 'node:tls'
import { fromCallback, pRetry, pDelay, pTimeout } from 'promise-toolbox'
import { readChunkStrict } from '@vates/read-chunk'
import { createLogger } from '@xen-orchestra/log'
import {
'use strict'
const assert = require('node:assert')
const { Socket } = require('node:net')
const { connect } = require('node:tls')
const {
INIT_PASSWD,
NBD_CMD_READ,
NBD_DEFAULT_BLOCK_SIZE,
@@ -20,13 +17,16 @@ import {
NBD_REQUEST_MAGIC,
OPTS_MAGIC,
NBD_CMD_DISC,
} from './constants.mjs'
} = require('./constants.js')
const { fromCallback, pRetry, pDelay, pTimeout } = require('promise-toolbox')
const { readChunkStrict } = require('@vates/read-chunk')
const { createLogger } = require('@xen-orchestra/log')
const { warn } = createLogger('vates:nbd-client')
// documentation is here : https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md
export default class NbdClient {
module.exports = class NbdClient {
#serverAddress
#serverCert
#serverPort

View File

@@ -17,7 +17,6 @@
"engines": {
"node": ">=14.0"
},
"main": "./index.mjs",
"dependencies": {
"@vates/async-each": "^1.0.0",
"@vates/read-chunk": "^1.1.1",
@@ -32,6 +31,6 @@
},
"scripts": {
"postversion": "npm publish --access public",
"test-integration": "tap --lines 97 --functions 95 --branches 74 --statements 97 tests/*.integ.mjs"
"test-integration": "tap --lines 97 --functions 95 --branches 74 --statements 97 tests/*.integ.js"
}
}

View File

@@ -1,12 +1,13 @@
import NbdClient from '../index.mjs'
import { spawn, exec } from 'node:child_process'
import fs from 'node:fs/promises'
import { test } from 'tap'
import tmp from 'tmp'
import { pFromCallback } from 'promise-toolbox'
import { Socket } from 'node:net'
import { NBD_DEFAULT_PORT } from '../constants.mjs'
import assert from 'node:assert'
'use strict'
const NbdClient = require('../index.js')
const { spawn, exec } = require('node:child_process')
const fs = require('node:fs/promises')
const { test } = require('tap')
const tmp = require('tmp')
const { pFromCallback } = require('promise-toolbox')
const { Socket } = require('node:net')
const { NBD_DEFAULT_PORT } = require('../constants.js')
const assert = require('node:assert')
const FILE_SIZE = 10 * 1024 * 1024

View File

@@ -1,3 +1,4 @@
'use strict'
/*
node-vsphere-soap
@@ -11,18 +12,17 @@
*/
import { EventEmitter } from 'events'
import axios from 'axios'
import https from 'node:https'
import util from 'util'
import soap from 'soap'
import Cookie from 'soap-cookie' // required for session persistence
const EventEmitter = require('events').EventEmitter
const axios = require('axios')
const https = require('node:https')
const util = require('util')
const soap = require('soap')
const Cookie = require('soap-cookie') // required for session persistence
// Client class
// inherits from EventEmitter
// possible events: connect, error, ready
export function Client(vCenterHostname, username, password, sslVerify) {
function Client(vCenterHostname, username, password, sslVerify) {
this.status = 'disconnected'
this.reconnectCount = 0
@@ -228,3 +228,4 @@ function _soapErrorHandler(self, emitter, command, args, err) {
}
// end
exports.Client = Client

View File

@@ -2,7 +2,7 @@
"name": "@vates/node-vsphere-soap",
"version": "1.0.0",
"description": "interface to vSphere SOAP/WSDL from node for interfacing with vCenter or ESXi, forked from node-vsphere-soap",
"main": "lib/client.mjs",
"main": "lib/client.js",
"author": "reedog117",
"repository": {
"directory": "@vates/node-vsphere-soap",
@@ -30,7 +30,7 @@
"private": false,
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/node-vsphere-soap",
"engines": {
"node": ">=14"
"node": ">=8.10"
},
"scripts": {
"postversion": "npm publish --access public"

View File

@@ -1,11 +1,15 @@
'use strict'
// place your own credentials here for a vCenter or ESXi server
// this information will be used for connecting to a vCenter instance
// for module testing
// name the file config-test.js
export const vCenterTestCreds = {
const vCenterTestCreds = {
vCenterIP: 'vcsa',
vCenterUser: 'vcuser',
vCenterPassword: 'vcpw',
vCenter: true,
}
exports.vCenterTestCreds = vCenterTestCreds

View File

@@ -1,16 +1,18 @@
'use strict'
/*
vsphere-soap.test.js
tests for the vCenterConnectionInstance class
*/
import assert from 'assert'
import { describe, it } from 'test'
const assert = require('assert')
const { describe, it } = require('test')
import * as vc from '../lib/client.mjs'
const vc = require('../lib/client')
// eslint-disable-next-line n/no-missing-import
import { vCenterTestCreds as TestCreds } from '../config-test.mjs'
// eslint-disable-next-line n/no-missing-require
const TestCreds = require('../config-test.js').vCenterTestCreds
const VItest = new vc.Client(TestCreds.vCenterIP, TestCreds.vCenterUser, TestCreds.vCenterPassword, false)

View File

@@ -1,7 +1,6 @@
'use strict'
const assert = require('assert')
const isUtf8 = require('isutf8')
/**
* Read a chunk of data from a stream.
@@ -82,13 +81,6 @@ exports.readChunkStrict = async function readChunkStrict(stream, size) {
if (size !== undefined && chunk.length !== size) {
const error = new Error(`stream has ended with not enough data (actual: ${chunk.length}, expected: ${size})`)
// Buffer.isUtf8 is too recent for now
// @todo : replace external package by Buffer.isUtf8 when the supported version of node reach 18
if (chunk.length < 1024 && isUtf8(chunk)) {
error.text = chunk.toString('utf8')
}
Object.defineProperties(error, {
chunk: {
value: chunk,

View File

@@ -102,37 +102,12 @@ describe('readChunkStrict', function () {
assert.strictEqual(error.chunk, undefined)
})
it('throws if stream ends with not enough data, utf8', async () => {
it('throws if stream ends with not enough data', async () => {
const error = await rejectionOf(readChunkStrict(makeStream(['foo', 'bar']), 10))
assert(error instanceof Error)
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 6, expected: 10)')
assert.strictEqual(error.text, 'foobar')
assert.deepEqual(error.chunk, Buffer.from('foobar'))
})
it('throws if stream ends with not enough data, non utf8 ', async () => {
const source = [Buffer.alloc(10, 128), Buffer.alloc(10, 128)]
const error = await rejectionOf(readChunkStrict(makeStream(source), 30))
assert(error instanceof Error)
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 20, expected: 30)')
assert.strictEqual(error.text, undefined)
assert.deepEqual(error.chunk, Buffer.concat(source))
})
it('throws if stream ends with not enough data, utf8 , long data', async () => {
const source = Buffer.from('a'.repeat(1500))
const error = await rejectionOf(readChunkStrict(makeStream([source]), 2000))
assert(error instanceof Error)
assert.strictEqual(error.message, `stream has ended with not enough data (actual: 1500, expected: 2000)`)
assert.strictEqual(error.text, undefined)
assert.deepEqual(error.chunk, source)
})
it('succeed', async () => {
const source = Buffer.from('a'.repeat(20))
const chunk = await readChunkStrict(makeStream([source]), 10)
assert.deepEqual(source.subarray(10), chunk)
})
})
describe('skip', function () {
@@ -159,16 +134,6 @@ describe('skip', function () {
it('returns less size if stream ends', async () => {
assert.deepEqual(await skip(makeStream('foo bar'), 10), 7)
})
it('put back if it read too much', async () => {
let source = makeStream(['foo', 'bar'])
await skip(source, 1) // read part of data chunk
const chunk = (await readChunkStrict(source, 2)).toString('utf-8')
assert.strictEqual(chunk, 'oo')
source = makeStream(['foo', 'bar'])
assert.strictEqual(await skip(source, 3), 3) // read aligned with data chunk
})
})
describe('skipStrict', function () {
@@ -179,9 +144,4 @@ describe('skipStrict', function () {
assert.strictEqual(error.message, 'stream has ended with not enough data (actual: 7, expected: 10)')
assert.deepEqual(error.bytesSkipped, 7)
})
it('succeed', async () => {
const source = makeStream(['foo', 'bar', 'baz'])
const res = await skipStrict(source, 4)
assert.strictEqual(res, undefined)
})
})

View File

@@ -33,8 +33,5 @@
},
"devDependencies": {
"test": "^3.2.1"
},
"dependencies": {
"isutf8": "^4.0.0"
}
}

View File

@@ -5,19 +5,19 @@ import { createLogger } from '@xen-orchestra/log'
import { createVhdDirectoryFromStream, openVhd, VhdAbstract, VhdDirectory, VhdSynthetic } from 'vhd-lib'
import { decorateMethodsWith } from '@vates/decorate-with'
import { deduped } from '@vates/disposable/deduped.js'
import { dirname, join, resolve } from 'node:path'
import { dirname, join, normalize, resolve } from 'node:path'
import { execFile } from 'child_process'
import { mount } from '@vates/fuse-vhd'
import { readdir, lstat } from 'node:fs/promises'
import { synchronized } from 'decorator-synchronized'
import { v4 as uuidv4 } from 'uuid'
import { ZipFile } from 'yazl'
import Disposable from 'promise-toolbox/Disposable'
import fromCallback from 'promise-toolbox/fromCallback'
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 tar from 'tar'
import zlib from 'zlib'
import { BACKUP_DIR } from './_getVmBackupDir.mjs'
@@ -29,7 +29,6 @@ import { isValidXva } from './_isValidXva.mjs'
import { listPartitions, LVM_PARTITION_TYPE } from './_listPartitions.mjs'
import { lvs, pvs } from './_lvm.mjs'
import { watchStreamSize } from './_watchStreamSize.mjs'
import { spawn } from 'node:child_process'
export const DIR_XO_CONFIG_BACKUPS = 'xo-config-backups'
@@ -42,8 +41,22 @@ const compareTimestamp = (a, b) => a.timestamp - b.timestamp
const noop = Function.prototype
const resolveRelativeFromFile = (file, path) => resolve('/', dirname(file), path).slice(1)
const makeRelative = path => resolve('/', path).slice(1)
const resolveSubpath = (root, path) => resolve(root, makeRelative(path))
const resolveSubpath = (root, path) => resolve(root, `.${resolve('/', path)}`)
async function addDirectory(files, realPath, metadataPath) {
const stats = await lstat(realPath)
if (stats.isDirectory()) {
await asyncMap(await readdir(realPath), file =>
addDirectory(files, realPath + '/' + file, metadataPath + '/' + file)
)
} else if (stats.isFile()) {
files.push({
realPath,
metadataPath,
})
}
}
const createSafeReaddir = (handler, methodName) => (path, options) =>
handler.list(path, options).catch(error => {
@@ -169,6 +182,17 @@ export class RemoteAdapter {
})
}
async *_usePartitionFiles(diskId, partitionId, paths) {
const path = yield this.getPartition(diskId, partitionId)
const files = []
await asyncMap(paths, file =>
addDirectory(files, resolveSubpath(path, file), normalize('./' + file).replace(/\/+$/, ''))
)
return files
}
// check if we will be allowed to merge a a vhd created in this adapter
// with the vhd at path `path`
async isMergeableParent(packedParentUid, path) {
@@ -185,30 +209,15 @@ export class RemoteAdapter {
})
}
fetchPartitionFiles(diskId, partitionId, paths, format) {
fetchPartitionFiles(diskId, partitionId, paths) {
const { promise, reject, resolve } = pDefer()
Disposable.use(
async function* () {
const path = yield this.getPartition(diskId, partitionId)
let outputStream
if (format === 'tgz') {
outputStream = tar.c({ cwd: path, gzip: true }, paths.map(makeRelative))
} else if (format === 'zip') {
// don't use --symlinks due to bug
//
// see https://bugs.launchpad.net/ubuntu/+source/zip/+bug/1892338
const cp = spawn('zip', ['--quiet', '--recurse-paths', '-', ...paths.map(makeRelative)], { cwd: path })
await new Promise((resolve, reject) => {
cp.on('error', reject).on('spawn', resolve)
})
outputStream = cp.stdout
} else {
throw new Error('unsupported format ' + format)
}
const files = yield this._usePartitionFiles(diskId, partitionId, paths)
const zip = new ZipFile()
files.forEach(({ realPath, metadataPath }) => zip.addFile(realPath, metadataPath))
zip.end()
const { outputStream } = zip
resolve(outputStream)
await fromEvent(outputStream, 'end')
}.bind(this)
@@ -815,6 +824,8 @@ decorateMethodsWith(RemoteAdapter, {
debounceResourceFactory,
]),
_usePartitionFiles: Disposable.factory,
getDisk: compose([Disposable.factory, [deduped, diskId => [diskId]], debounceResourceFactory]),
getPartition: Disposable.factory,

View File

@@ -16,8 +16,6 @@ export const TAG_BASE_DELTA = 'xo:base_delta'
export const TAG_COPY_SRC = 'xo:copy_of'
const TAG_BACKUP_SR = 'xo:backup:sr'
const ensureArray = value => (value === undefined ? [] : Array.isArray(value) ? value : [value])
const resolveUuid = async (xapi, cache, uuid, type) => {
if (uuid == null) {
@@ -159,10 +157,7 @@ export const importIncrementalVm = defer(async function importIncrementalVm(
if (detectBase) {
const remoteBaseVmUuid = vmRecord.other_config[TAG_BASE_DELTA]
if (remoteBaseVmUuid) {
baseVm = find(
xapi.objects.all,
obj => (obj = obj.other_config) && obj[TAG_COPY_SRC] === remoteBaseVmUuid && obj[TAG_BACKUP_SR] === sr.$id
)
baseVm = find(xapi.objects.all, obj => (obj = obj.other_config) && obj[TAG_COPY_SRC] === remoteBaseVmUuid)
if (!baseVm) {
throw new Error(`could not find the base VM (copy of ${remoteBaseVmUuid})`)

View File

@@ -40,10 +40,10 @@
"parse-pairs": "^2.0.0",
"promise-toolbox": "^0.21.0",
"proper-lockfile": "^4.1.2",
"tar": "^6.1.15",
"uuid": "^9.0.0",
"vhd-lib": "^4.5.0",
"xen-api": "^1.3.3"
"xen-api": "^1.3.3",
"yazl": "^2.5.1"
},
"devDependencies": {
"fs-extra": "^11.1.0",

View File

@@ -209,7 +209,7 @@ describe('encryption', () => {
// encrypt with a non default algorithm
const encryptor = _getEncryptor('aes-256-cbc', '73c1838d7d8a6088ca2317fb5f29cd91')
await fs.writeFile(`${dir}/encryption.json`, `{"algorithm": "aes-256-gcm"}`)
await fs.writeFile(`${dir}/encryption.json`, `{"algorithm": "aes-256-gmc"}`)
await fs.writeFile(`${dir}/metadata.json`, encryptor.encryptData(`{"random": "NOTSORANDOM"}`))
// remote is now non empty : can't modify key anymore

View File

@@ -2,10 +2,6 @@
## **next**
- Ability to export selected VMs as CSV file (PR [#6915](https://github.com/vatesfr/xen-orchestra/pull/6915))
- [Pool/VMs] Ability to export selected VMs as JSON file (PR [#6911](https://github.com/vatesfr/xen-orchestra/pull/6911))
- Add Tasks to Pool Dashboard (PR [#6713](https://github.com/vatesfr/xen-orchestra/pull/6713))
## **0.1.1** (2023-07-03)
- Invalidate sessionId token after logout (PR [#6480](https://github.com/vatesfr/xen-orchestra/pull/6480))

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XO Lite</title>
<title>Vite App</title>
</head>
<body>
<div id="root"></div>

View File

@@ -17,7 +17,6 @@
"@fortawesome/vue-fontawesome": "^3.0.1",
"@novnc/novnc": "^1.3.0",
"@types/d3-time-format": "^4.0.0",
"@types/file-saver": "^2.0.5",
"@types/lodash-es": "^4.17.6",
"@types/marked": "^4.0.8",
"@vueuse/core": "^10.1.2",
@@ -26,7 +25,6 @@
"d3-time-format": "^4.1.0",
"decorator-synchronized": "^0.6.0",
"echarts": "^5.3.3",
"file-saver": "^2.0.5",
"highlight.js": "^11.6.0",
"human-format": "^1.1.0",
"iterable-backoff": "^0.1.0",

View File

@@ -41,6 +41,8 @@ if (link == null) {
}
link.href = favicon;
document.title = "XO Lite";
const xenApiStore = useXenApiStore();
const { pool } = usePoolStore().subscribe();
useChartTheme();

View File

@@ -21,9 +21,14 @@ import AccountButton from "@/components/AccountButton.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { useNavigationStore } from "@/stores/navigation.store";
import { useUiStore } from "@/stores/ui.store";
import type { SlotDefinition } from "@/types";
import { faBars } from "@fortawesome/free-solid-svg-icons";
import { storeToRefs } from "pinia";
defineSlots<{
default: SlotDefinition;
}>();
const uiStore = useUiStore();
const { isMobile } = storeToRefs(uiStore);

View File

@@ -24,7 +24,6 @@
</template>
<script lang="ts" setup>
import { usePageTitleStore } from "@/stores/page-title.store";
import { storeToRefs } from "pinia";
import { onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
@@ -34,7 +33,6 @@ import UiButton from "@/components/ui/UiButton.vue";
import { useXenApiStore } from "@/stores/xen-api.store";
const { t } = useI18n();
usePageTitleStore().setTitle(t("login"));
const xenApiStore = useXenApiStore();
const { isConnecting } = storeToRefs(xenApiStore);
const login = ref("root");

View File

@@ -43,6 +43,7 @@
</template>
<script lang="ts" setup>
import type { SlotDefinition } from "@/types";
import { computed, toRef, watch } from "vue";
import type { Filters } from "@/types/filter";
import type { Sorts } from "@/types/sort";
@@ -55,6 +56,11 @@ import useFilteredCollection from "@/composables/filtered-collection.composable"
import useMultiSelect from "@/composables/multi-select.composable";
import useSortedCollection from "@/composables/sorted-collection.composable";
defineSlots<{
"head-row": SlotDefinition;
"body-row": SlotDefinition<{ item: any }>;
}>();
const props = defineProps<{
modelValue?: string[];
availableFilters?: Filters;

View File

@@ -11,8 +11,13 @@
<script lang="ts" setup>
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import type { SlotDefinition } from "@/types";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
defineSlots<{
default: SlotDefinition;
}>();
defineProps<{
icon?: IconDefinition;
}>();

View File

@@ -13,10 +13,15 @@
>
import UiSpinner from "@/components/ui/UiSpinner.vue";
import type { XenApiRecord } from "@/libs/xen-api";
import type { SlotDefinition } from "@/types";
import ObjectNotFoundView from "@/views/ObjectNotFoundView.vue";
import { computed } from "vue";
import { useRouter } from "vue-router";
defineSlots<{
default: SlotDefinition;
}>();
const props = defineProps<{
isReady: boolean;
uuidChecker: (uuid: I) => boolean;

View File

@@ -11,10 +11,15 @@
</template>
<script lang="ts" setup>
import UiTab from "@/components/ui/UiTab.vue";
import type { SlotDefinition } from "@/types";
import { IK_TAB_BAR_DISABLED } from "@/types/injection-keys";
import { computed, inject } from "vue";
import type { RouteLocationRaw } from "vue-router";
import UiTab from "@/components/ui/UiTab.vue";
defineSlots<{
default: SlotDefinition;
}>();
defineProps<{
to: RouteLocationRaw;

View File

@@ -12,8 +12,14 @@
<script lang="ts" setup>
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import type { SlotDefinition } from "@/types";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
defineSlots<{
default: SlotDefinition;
actions: SlotDefinition;
}>();
defineProps<{
icon: IconDefinition;
}>();

View File

@@ -26,8 +26,13 @@
import UiProgressBar from "@/components/ui/progress/UiProgressBar.vue";
import UiProgressLegend from "@/components/ui/progress/UiProgressLegend.vue";
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
import type { SlotDefinition } from "@/types";
import { computed } from "vue";
defineSlots<{
footer: SlotDefinition<{ totalPercent: number }>;
}>();
interface Data {
id: string;
value: number;

View File

@@ -7,6 +7,7 @@
<script lang="ts" setup>
import UiCard from "@/components/ui/UiCard.vue";
import type { SlotDefinition } from "@/types";
import type { LinearChartData, ValueFormatter } from "@/types/chart";
import { IK_CHART_VALUE_FORMATTER } from "@/types/injection-keys";
import { utcFormat } from "d3-time-format";
@@ -25,6 +26,10 @@ import VueCharts from "vue-echarts";
const Y_AXIS_MAX_VALUE = 200;
defineSlots<{
summary: SlotDefinition;
}>();
const props = defineProps<{
title?: string;
subtitle?: string;

View File

@@ -118,12 +118,20 @@ import {
ModelParam,
type Param,
} from "@/libs/story/story-param";
import type { SlotDefinition } from "@/types";
import { faSliders } from "@fortawesome/free-solid-svg-icons";
import "highlight.js/styles/github-dark.css";
import { uniqueId, upperFirst } from "lodash-es";
import { computed, reactive, ref, watch, watchEffect } from "vue";
import { useRoute } from "vue-router";
defineSlots<{
default: SlotDefinition<{
properties: Record<string, any>;
settings: Record<string, any>;
}>;
}>();
const tab = (tab: TAB, params: Param[]) =>
reactive({
onClick: () => (selectedTab.value = tab),

View File

@@ -41,8 +41,18 @@
</template>
<script lang="ts" setup>
import type { SlotDefinition } from "@/types";
const moonDistance = 384400;
defineSlots<{
default: SlotDefinition;
"named-slot": SlotDefinition;
"named-scoped-slot": SlotDefinition<{
moonDistance: number;
}>;
}>();
withDefaults(
defineProps<{
imString: string;

View File

@@ -4,7 +4,13 @@
</table>
</template>
<script lang="ts" setup></script>
<script lang="ts" setup>
import type { SlotDefinition } from "@/types";
defineSlots<{
default: SlotDefinition;
}>();
</script>
<style lang="postcss" scoped>
.story-params-table {

View File

@@ -52,7 +52,7 @@
<script lang="ts" setup>
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import type { Color } from "@/types";
import type { Color, SlotDefinition } from "@/types";
import {
IK_FORM_INPUT_COLOR,
IK_FORM_LABEL_DISABLED,
@@ -73,6 +73,10 @@ import {
defineOptions({ inheritAttrs: false });
defineSlots<{
default: SlotDefinition;
}>();
const props = withDefaults(
defineProps<{
id?: string;

View File

@@ -39,7 +39,6 @@
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import type { Color } from "@/types";
import {
IK_FORM_HAS_LABEL,
IK_FORM_INPUT_COLOR,
IK_FORM_LABEL_DISABLED,
IK_INPUT_ID,
@@ -47,9 +46,7 @@ import {
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { uniqueId } from "lodash-es";
import { computed, provide, useSlots } from "vue";
const slots = useSlots();
import { computed, provide } from "vue";
const props = defineProps<{
label?: string;
@@ -79,11 +76,6 @@ const color = computed<Color | undefined>(() => {
provide(IK_FORM_INPUT_COLOR, color);
provide(
IK_FORM_HAS_LABEL,
computed(() => slots.label !== undefined)
);
provide(
IK_FORM_LABEL_DISABLED,
computed(() => props.disabled ?? false)

View File

@@ -6,9 +6,14 @@
<script lang="ts" setup>
import FormInput from "@/components/form/FormInput.vue";
import type { SlotDefinition } from "@/types";
import { IK_INPUT_TYPE } from "@/types/injection-keys";
import { provide } from "vue";
defineSlots<{
default: SlotDefinition;
}>();
provide(IK_INPUT_TYPE, "select");
</script>

View File

@@ -8,8 +8,13 @@
<script lang="ts" setup>
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import type { SlotDefinition } from "@/types";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
defineSlots<{
default: SlotDefinition;
}>();
defineProps<{
icon?: IconDefinition;
}>();

View File

@@ -24,10 +24,16 @@
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { vTooltip } from "@/directives/tooltip.directive";
import { hasEllipsis } from "@/libs/utils";
import type { SlotDefinition } from "@/types";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
import { computed, ref } from "vue";
import type { RouteLocationRaw } from "vue-router";
defineSlots<{
default: SlotDefinition;
actions: SlotDefinition;
}>();
defineProps<{
icon: IconDefinition;
route: RouteLocationRaw;

View File

@@ -1,6 +1,6 @@
<template>
<slot :is-open="isOpen" :open="open" name="trigger" />
<Teleport to="body" :disabled="!shouldTeleport">
<Teleport :disabled="!shouldTeleport" to="body">
<ul
v-if="!hasTrigger || isOpen"
ref="menu"
@@ -14,15 +14,24 @@
</template>
<script lang="ts" setup>
import type { SlotDefinition } from "@/types";
import {
IK_CLOSE_MENU,
IK_MENU_DISABLED,
IK_MENU_HORIZONTAL,
IK_MENU_TELEPORTED,
} from "@/types/injection-keys";
import { onClickOutside, unrefElement, whenever } from "@vueuse/core";
import placementJs, { type Options } from "placement.js";
import { computed, inject, nextTick, provide, ref, useSlots } from "vue";
import { onClickOutside, unrefElement, whenever } from "@vueuse/core";
defineSlots<{
default: SlotDefinition;
trigger: SlotDefinition<{
isOpen: boolean;
open: (event: MouseEvent) => void;
}>;
}>();
const props = defineProps<{
horizontal?: boolean;

View File

@@ -36,6 +36,7 @@
import AppMenu from "@/components/menu/AppMenu.vue";
import MenuTrigger from "@/components/menu/MenuTrigger.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import type { SlotDefinition } from "@/types";
import {
IK_CLOSE_MENU,
IK_MENU_DISABLED,
@@ -45,6 +46,11 @@ import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
import { faAngleDown, faAngleRight } from "@fortawesome/free-solid-svg-icons";
import { computed, inject, ref } from "vue";
defineSlots<{
default: SlotDefinition;
submenu?: SlotDefinition;
}>();
const props = defineProps<{
icon?: IconDefinition;
onClick?: () => any;

View File

@@ -6,9 +6,14 @@
</template>
<script lang="ts" setup>
import type { SlotDefinition } from "@/types";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
defineSlots<{
default: SlotDefinition;
}>();
defineProps<{
active?: boolean;
busy?: boolean;

View File

@@ -1,48 +1,21 @@
<template>
<UiCard :color="hasError ? 'error' : undefined">
<UiCardTitle>
{{ $t("cpu-usage") }}
<template v-if="vmStatsCanBeExpired || hostStatsCanBeExpired" #right>
<UiSpinner v-tooltip="$t('fetching-fresh-data')" />
</template>
</UiCardTitle>
<UiCardTitle>{{ $t("cpu-usage") }}</UiCardTitle>
<HostsCpuUsage />
<VmsCpuUsage />
</UiCard>
</template>
<script lang="ts" setup>
import { vTooltip } from "@/directives/tooltip.directive";
import HostsCpuUsage from "@/components/pool/dashboard/cpuUsage/HostsCpuUsage.vue";
import VmsCpuUsage from "@/components/pool/dashboard/cpuUsage/VmsCpuUsage.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { useHostStore } from "@/stores/host.store";
import { useVmStore } from "@/stores/vm.store";
import { computed, inject, type ComputedRef } from "vue";
import type { Stat } from "@/composables/fetch-stats.composable";
import type { HostStats, VmStats } from "@/libs/xapi-stats";
import UiSpinner from "@/components/ui/UiSpinner.vue";
import { computed } from "vue";
const { hasError: hasVmError } = useVmStore().subscribe();
const { hasError: hasHostError } = useHostStore().subscribe();
const vmStats = inject<ComputedRef<Stat<VmStats>[]>>(
"vmStats",
computed(() => [])
);
const hostStats = inject<ComputedRef<Stat<HostStats>[]>>(
"hostStats",
computed(() => [])
);
const vmStatsCanBeExpired = computed(() =>
vmStats.value.some((stat) => stat.canBeExpired)
);
const hostStatsCanBeExpired = computed(() =>
hostStats.value.some((stat) => stat.canBeExpired)
);
const hasError = computed(() => hasVmError.value || hasHostError.value);
</script>

View File

@@ -1,6 +1,5 @@
<template>
<!-- TODO: add a loader when data is not fully loaded or undefined -->
<!-- TODO: add small loader with tooltips when stats can be expired -->
<!-- TODO: display the NoData component in case of a data recovery error -->
<LinearChart
:data="data"

View File

@@ -1,50 +1,22 @@
<template>
<UiCard :color="hasError ? 'error' : undefined">
<UiCardTitle>
{{ $t("ram-usage") }}
<template v-if="vmStatsCanBeExpired || hostStatsCanBeExpired" #right>
<UiSpinner v-tooltip="$t('fetching-fresh-data')" />
</template>
</UiCardTitle>
<UiCardTitle>{{ $t("ram-usage") }}</UiCardTitle>
<HostsRamUsage />
<VmsRamUsage />
</UiCard>
</template>
<script lang="ts" setup>
import { vTooltip } from "@/directives/tooltip.directive";
import HostsRamUsage from "@/components/pool/dashboard/ramUsage/HostsRamUsage.vue";
import VmsRamUsage from "@/components/pool/dashboard/ramUsage/VmsRamUsage.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { useHostStore } from "@/stores/host.store";
import { useVmStore } from "@/stores/vm.store";
import { computed, inject } from "vue";
import type { ComputedRef } from "vue";
import type { HostStats, VmStats } from "@/libs/xapi-stats";
import type { Stat } from "@/composables/fetch-stats.composable";
import UiSpinner from "@/components/ui/UiSpinner.vue";
import { computed } from "vue";
const { hasError: hasVmError } = useVmStore().subscribe();
const { hasError: hasHostError } = useHostStore().subscribe();
const vmStats = inject<ComputedRef<Stat<VmStats>[]>>(
"vmStats",
computed(() => [])
);
const hostStats = inject<ComputedRef<Stat<HostStats>[]>>(
"hostStats",
computed(() => [])
);
const vmStatsCanBeExpired = computed(() =>
vmStats.value.some((stat) => stat.canBeExpired)
);
const hostStatsCanBeExpired = computed(() =>
hostStats.value.some((stat) => stat.canBeExpired)
);
const hasError = computed(() => hasVmError.value || hasHostError.value);
</script>

View File

@@ -34,6 +34,7 @@ const {
isReady: isVmReady,
records: vms,
hasError: hasVmError,
runningVms,
} = useVmStore().subscribe();
const {
@@ -54,7 +55,5 @@ const activeHostsCount = computed(
const totalVmsCount = computed(() => vms.value.length);
const activeVmsCount = computed(
() => vms.value.filter((vm) => vm.power_state === "Running").length
);
const activeVmsCount = computed(() => runningVms.value.length);
</script>

View File

@@ -1,17 +0,0 @@
<template>
<UiCard>
<UiCardTitle :count="pendingTasks.length">{{ $t("tasks") }}</UiCardTitle>
<TasksTable :pending-tasks="pendingTasks" />
</UiCard>
</template>
<script lang="ts" setup>
import TasksTable from "@/components/tasks/TasksTable.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import { useTaskStore } from "@/stores/task.store";
const { pendingTasks } = useTaskStore().subscribe();
</script>
<style lang="postcss" scoped></style>

View File

@@ -1,6 +1,5 @@
<template>
<!-- TODO: add a loader when data is not fully loaded or undefined -->
<!-- TODO: add small loader with tooltips when stats can be expired -->
<!-- TODO: Display the NoDataError component in case of a data recovery error -->
<LinearChart
:data="data"

View File

@@ -1,6 +1,5 @@
<template>
<!-- TODO: add a loader when data is not fully loaded or undefined -->
<!-- TODO: add small loader with tooltips when stats can be expired -->
<!-- TODO: display the NoDataError component in case of a data recovery error -->
<LinearChart
:data="data"

View File

@@ -1,5 +1,5 @@
<template>
<UiTable :color="hasError ? 'error' : undefined" class="tasks-table">
<UiTable class="tasks-table" :color="hasError ? 'error' : undefined">
<thead>
<tr>
<th>{{ $t("name") }}</th>
@@ -20,9 +20,6 @@
<UiSpinner class="loader" />
</td>
</tr>
<tr v-else-if="!hasTasks">
<td class="no-tasks" colspan="5">{{ $t("no-tasks") }}</td>
</tr>
<template v-else>
<TaskRow
v-for="task in pendingTasks"
@@ -38,35 +35,20 @@
<script lang="ts" setup>
import TaskRow from "@/components/tasks/TaskRow.vue";
import UiSpinner from "@/components/ui/UiSpinner.vue";
import UiTable from "@/components/ui/UiTable.vue";
import type { XenApiTask } from "@/libs/xen-api";
import UiSpinner from "@/components/ui/UiSpinner.vue";
import { useTaskStore } from "@/stores/task.store";
import { computed } from "vue";
import type { XenApiTask } from "@/libs/xen-api";
const props = defineProps<{
defineProps<{
pendingTasks: XenApiTask[];
finishedTasks?: XenApiTask[];
finishedTasks: XenApiTask[];
}>();
const { hasError, isFetching } = useTaskStore().subscribe();
const hasTasks = computed(
() => props.pendingTasks.length > 0 || (props.finishedTasks?.length ?? 0) > 0
);
</script>
<style lang="postcss" scoped>
.tasks-table {
width: 100%;
}
.no-tasks {
text-align: center;
color: var(--color-blue-scale-300);
font-style: italic;
}
td[colspan="5"] {
text-align: center;
}

View File

@@ -7,8 +7,8 @@
'has-icon': icon !== undefined,
}"
:disabled="isBusy || isDisabled"
type="button"
class="ui-action-button"
type="button"
>
<UiIcon :busy="isBusy" :icon="icon" />
<slot />
@@ -17,6 +17,7 @@
<script lang="ts" setup>
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import type { SlotDefinition } from "@/types";
import {
IK_BUTTON_GROUP_BUSY,
IK_BUTTON_GROUP_DISABLED,
@@ -24,6 +25,10 @@ import {
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
import { computed, inject } from "vue";
defineSlots<{
default: SlotDefinition;
}>();
const props = withDefaults(
defineProps<{
busy?: boolean;

View File

@@ -6,9 +6,14 @@
</template>
<script lang="ts" setup>
import type { SlotDefinition } from "@/types";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
defineSlots<{
default: SlotDefinition;
}>();
defineProps<{
icon?: IconDefinition;
}>();

View File

@@ -14,7 +14,9 @@
</template>
<script lang="ts" setup>
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiSpinner from "@/components/ui/UiSpinner.vue";
import type { Color, SlotDefinition } from "@/types";
import {
IK_BUTTON_GROUP_BUSY,
IK_BUTTON_GROUP_COLOR,
@@ -22,10 +24,12 @@ import {
IK_BUTTON_GROUP_OUTLINED,
IK_BUTTON_GROUP_TRANSPARENT,
} from "@/types/injection-keys";
import { computed, inject } from "vue";
import type { Color } from "@/types";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { computed, inject } from "vue";
defineSlots<{
default: SlotDefinition;
}>();
const props = withDefaults(
defineProps<{

View File

@@ -5,7 +5,7 @@
</template>
<script lang="ts" setup>
import type { Color } from "@/types";
import type { Color, SlotDefinition } from "@/types";
import {
IK_BUTTON_GROUP_BUSY,
IK_BUTTON_GROUP_COLOR,
@@ -15,6 +15,9 @@ import {
} from "@/types/injection-keys";
import { computed, provide } from "vue";
defineSlots<{
default: SlotDefinition;
}>();
const props = defineProps<{
busy?: boolean;
disabled?: boolean;

View File

@@ -6,7 +6,6 @@
class="left"
>
<slot>{{ left }}</slot>
<UiCounter class="count" v-if="count > 0" :value="count" color="info" />
</component>
<component
:is="subtitle ? 'h6' : 'h5'"
@@ -19,17 +18,11 @@
</template>
<script lang="ts" setup>
import UiCounter from "@/components/ui/UiCounter.vue";
withDefaults(
defineProps<{
subtitle?: boolean;
left?: string;
right?: string;
count?: number;
}>(),
{ count: 0 }
);
defineProps<{
subtitle?: boolean;
left?: string;
right?: string;
}>();
</script>
<style lang="postcss" scoped>
@@ -62,9 +55,6 @@ withDefaults(
font-size: var(--section-title-left-size);
font-weight: var(--section-title-left-weight);
color: var(--section-title-left-color);
display: flex;
align-items: center;
gap: 2rem;
}
.right {
@@ -72,8 +62,4 @@ withDefaults(
font-weight: var(--section-title-right-weight);
color: var(--section-title-right-color);
}
.count {
font-size: 1.6rem;
}
</style>

View File

@@ -1,51 +0,0 @@
<template>
<MenuItem :icon="faFileExport">
{{ $t("export") }}
<template #submenu>
<MenuItem
v-tooltip="{ content: $t('coming-soon'), placement: 'left' }"
:icon="faDisplay"
>
{{ $t("export-vms") }}
</MenuItem>
<MenuItem
:icon="faCode"
@click="
exportVmsAsJsonFile(vms, `vms_${new Date().toISOString()}.json`)
"
>
{{ $t("export-table-to", { type: ".json" }) }}
</MenuItem>
<MenuItem
:icon="faFileCsv"
@click="exportVmsAsCsvFile(vms, `vms_${new Date().toISOString()}.csv`)"
>
{{ $t("export-table-to", { type: ".csv" }) }}
</MenuItem>
</template>
</MenuItem>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import { exportVmsAsCsvFile, exportVmsAsJsonFile } from "@/libs/vm";
import MenuItem from "@/components/menu/MenuItem.vue";
import {
faCode,
faDisplay,
faFileCsv,
faFileExport,
} from "@fortawesome/free-solid-svg-icons";
import { useVmStore } from "@/stores/vm.store";
import { vTooltip } from "@/directives/tooltip.directive";
import type { XenApiVm } from "@/libs/xen-api";
const props = defineProps<{
vmRefs: XenApiVm["$ref"][];
}>();
const { getByOpaqueRef: getVm } = useVmStore().subscribe();
const vms = computed(() =>
props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined)
);
</script>

View File

@@ -25,8 +25,30 @@
<MenuItem v-tooltip="$t('coming-soon')" :icon="faCamera">
{{ $t("snapshot") }}
</MenuItem>
<VmActionExportItem :vm-refs="selectedRefs" />
<VmActionDeleteItem :vm-refs="selectedRefs" />
<MenuItem :icon="faFileExport">
{{ $t("export") }}
<template #submenu>
<MenuItem
v-tooltip="{ content: $t('coming-soon'), placement: 'left' }"
:icon="faDisplay"
>
{{ $t("export-vms") }}
</MenuItem>
<MenuItem
v-tooltip="{ content: $t('coming-soon'), placement: 'left' }"
:icon="faCode"
>
{{ $t("export-table-to", { type: ".json" }) }}
</MenuItem>
<MenuItem
v-tooltip="{ content: $t('coming-soon'), placement: 'left' }"
:icon="faFileCsv"
>
{{ $t("export-table-to", { type: ".csv" }) }}
</MenuItem>
</template>
</MenuItem>
</AppMenu>
</template>
@@ -35,7 +57,6 @@ import AppMenu from "@/components/menu/AppMenu.vue";
import MenuItem from "@/components/menu/MenuItem.vue";
import UiButton from "@/components/ui/UiButton.vue";
import VmActionCopyItem from "@/components/vm/VmActionItems/VmActionCopyItem.vue";
import VmActionExportItem from "@/components/vm/VmActionItems/VmActionExportItem.vue";
import VmActionDeleteItem from "@/components/vm/VmActionItems/VmActionDeleteItem.vue";
import VmActionPowerStateItems from "@/components/vm/VmActionItems/VmActionPowerStateItems.vue";
import { vTooltip } from "@/directives/tooltip.directive";
@@ -43,8 +64,12 @@ import type { XenApiVm } from "@/libs/xen-api";
import { useUiStore } from "@/stores/ui.store";
import {
faCamera,
faCode,
faDisplay,
faEdit,
faEllipsis,
faFileCsv,
faFileExport,
faPowerOff,
faRoute,
} from "@fortawesome/free-solid-svg-icons";

View File

@@ -10,7 +10,6 @@ import { type Pausable, promiseTimeout, useTimeoutPoll } from "@vueuse/core";
import { computed, type ComputedRef, onUnmounted, ref } from "vue";
export type Stat<T> = {
canBeExpired: boolean;
id: string;
name: string;
stats: T | undefined;
@@ -22,10 +21,8 @@ type GetStats<
S extends HostStats | VmStats
> = (
uuid: T["uuid"],
granularity: GRANULARITY,
ignoreExpired: boolean,
opts: { abortSignal?: AbortSignal }
) => Promise<XapiStatsResponse<S> | undefined> | undefined;
granularity: GRANULARITY
) => Promise<XapiStatsResponse<S>> | undefined;
export type FetchedStats<
T extends XenApiHost | XenApiVm,
@@ -44,7 +41,6 @@ export default function useFetchStats<
>(getStats: GetStats<T, S>, granularity: GRANULARITY): FetchedStats<T, S> {
const stats = ref<Map<string, Stat<S>>>(new Map());
const timestamp = ref<number[]>([0, 0]);
const abortController = new AbortController();
const register = (object: T) => {
const mapKey = `${object.uuid}-${granularity}`;
@@ -53,18 +49,13 @@ export default function useFetchStats<
return;
}
const ignoreExpired = computed(() => !stats.value.has(mapKey));
const pausable = useTimeoutPoll(
async () => {
const newStats = (await getStats(
object.uuid,
granularity,
ignoreExpired.value,
{
abortSignal: abortController.signal,
}
)) as XapiStatsResponse<S>;
if (!stats.value.has(mapKey)) {
return;
}
const newStats = await getStats(object.uuid, granularity);
if (newStats === undefined) {
return;
@@ -78,7 +69,6 @@ export default function useFetchStats<
];
stats.value.get(mapKey)!.stats = newStats.stats;
stats.value.get(mapKey)!.canBeExpired = newStats.canBeExpired;
await promiseTimeout(newStats.interval * 1000);
},
0,
@@ -86,7 +76,6 @@ export default function useFetchStats<
);
stats.value.set(mapKey, {
canBeExpired: false,
id: object.uuid,
name: object.name_label,
stats: undefined,
@@ -101,7 +90,6 @@ export default function useFetchStats<
};
onUnmounted(() => {
abortController.abort();
stats.value.forEach((stat) => stat.pausable.pause());
});

View File

@@ -1,41 +0,0 @@
import { saveAs } from "file-saver";
import type { XenApiVm } from "@/libs/xen-api";
function stringifyCsvValue(value: any) {
let res = "";
if (Array.isArray(value)) {
res = value.join(";");
} else if (typeof value === "object") {
res = JSON.stringify(value);
} else {
res = String(value);
}
return `"${res.replace(/"/g, '""')}"`;
}
export function exportVmsAsCsvFile(vms: XenApiVm[], fileName: string) {
const csvHeaders = Object.keys(vms[0]);
const csvRows = vms.map((vm) =>
csvHeaders.map((header) => stringifyCsvValue(vm[header as keyof XenApiVm]))
);
saveAs(
new Blob(
[[csvHeaders, ...csvRows].map((row) => row.join(",")).join("\n")],
{
type: "text/csv;charset=utf-8",
}
),
fileName
);
}
export function exportVmsAsJsonFile(vms: XenApiVm[], fileName: string) {
saveAs(
new Blob([JSON.stringify(vms, null, 2)], {
type: "application/json",
}),
fileName
);
}

View File

@@ -295,22 +295,18 @@ export type HostStats = {
};
export type XapiStatsResponse<T> = {
canBeExpired: boolean;
endTimestamp: number;
interval: number;
stats: T;
};
type StatsByObject = {
[uuid: string]: {
[step: string]: XapiStatsResponse<HostStats | VmStats>;
};
};
export default class XapiStats {
#xapi;
#statsByObject: StatsByObject = {};
#cachedStatsByObject: StatsByObject = {};
#statsByObject: {
[uuid: string]: {
[step: string]: XapiStatsResponse<HostStats | any>;
};
} = {};
constructor(xapi: XenApi) {
this.#xapi = xapi;
}
@@ -318,12 +314,7 @@ export default class XapiStats {
// Execute one http request on a XenServer for get stats
// Return stats (Json format) or throws got exception
@limitConcurrency(3)
async _getJson(
host: XenApiHost,
timestamp: number,
step: RRD_STEP,
{ abortSignal }: { abortSignal?: AbortSignal } = {}
) {
async _getJson(host: XenApiHost, timestamp: any, step: any) {
const resp = await this.#xapi.getResource("/rrd_updates", {
host,
query: {
@@ -333,23 +324,13 @@ export default class XapiStats {
json: "true",
start: timestamp,
},
abortSignal,
});
return JSON5.parse(await resp.text());
}
// To avoid multiple requests, we keep a cache for the stats and
// only return it if we not exceed a step
#getCachedStats(
uuid: string,
step: RRD_STEP,
currentTimeStamp: number,
ignoreExpired = false
) {
if (ignoreExpired) {
return this.#cachedStatsByObject[uuid]?.[step];
}
#getCachedStats(uuid: any, step: any, currentTimeStamp: any) {
const statsByObject = this.#statsByObject;
const stats = statsByObject[uuid]?.[step];
@@ -366,16 +347,12 @@ export default class XapiStats {
}
@synchronized.withKey(({ host }: { host: XenApiHost }) => host.uuid)
async _getAndUpdateStats<T extends VmStats | HostStats>({
abortSignal,
async _getAndUpdateStats({
host,
ignoreExpired = false,
uuid,
granularity,
}: {
abortSignal?: AbortSignal;
host: XenApiHost;
ignoreExpired?: boolean;
uuid: any;
granularity: GRANULARITY;
}) {
@@ -390,13 +367,7 @@ export default class XapiStats {
}
const currentTimeStamp = Math.floor(new Date().getTime() / 1000);
const stats = this.#getCachedStats(
uuid,
step,
currentTimeStamp,
ignoreExpired
) as XapiStatsResponse<T>;
const stats = this.#getCachedStats(uuid, step, currentTimeStamp);
if (stats !== undefined) {
return stats;
}
@@ -405,113 +376,75 @@ export default class XapiStats {
// To avoid crossing over the boundary, we ask for one less step
const optimumTimestamp = currentTimeStamp - maxDuration + step;
const json = await this._getJson(host, optimumTimestamp, step);
try {
const json = await this._getJson(host, optimumTimestamp, step, {
abortSignal,
});
const actualStep = json.meta.step as number;
const actualStep = json.meta.step as number;
if (json.data.length > 0) {
// fetched data is organized from the newest to the oldest
// but this implementation requires it in the other direction
json.data.reverse();
json.meta.legend.forEach((legend: any, index: number) => {
const [, type, uuid, metricType] = /^AVERAGE:([^:]+):(.+):(.+)$/.exec(
legend
) as any;
if (json.data.length > 0) {
// fetched data is organized from the newest to the oldest
// but this implementation requires it in the other direction
json.data.reverse();
json.meta.legend.forEach((legend: any, index: number) => {
const [, type, uuid, metricType] = /^AVERAGE:([^:]+):(.+):(.+)$/.exec(
legend
) as any;
const metrics = STATS[type] as any;
if (metrics === undefined) {
return;
}
const metrics = STATS[type] as any;
if (metrics === undefined) {
return;
}
const { metric, testResult } = findMetric(metrics, metricType) as any;
if (metric === undefined) {
return;
}
const { metric, testResult } = findMetric(metrics, metricType) as any;
if (metric === undefined) {
return;
}
const xoObjectStats = createGetProperty(this.#statsByObject, uuid, {});
let stepStats = xoObjectStats[actualStep];
if (
stepStats === undefined ||
stepStats.endTimestamp !== json.meta.end
) {
stepStats = xoObjectStats[actualStep] = {
endTimestamp: json.meta.end,
interval: actualStep,
};
}
const xoObjectStats = createGetProperty(
this.#statsByObject,
uuid,
{}
);
const cacheXoObjectStats = createGetProperty(
this.#cachedStatsByObject,
uuid,
{}
);
const path =
metric.getPath !== undefined
? metric.getPath(testResult)
: [findKey(metrics, metric)];
let stepStats = xoObjectStats[actualStep];
let cacheStepStats = cacheXoObjectStats[actualStep];
if (
stepStats === undefined ||
stepStats.endTimestamp !== json.meta.end
) {
stepStats = xoObjectStats[actualStep] = {
endTimestamp: json.meta.end,
interval: actualStep,
canBeExpired: false,
};
cacheStepStats = cacheXoObjectStats[actualStep] = {
endTimestamp: json.meta.end,
interval: actualStep,
canBeExpired: true,
};
}
const path =
metric.getPath !== undefined
? metric.getPath(testResult)
: [findKey(metrics, metric)];
const lastKey = path.length - 1;
let metricStats = createGetProperty(stepStats, "stats", {});
let cacheMetricStats = createGetProperty(cacheStepStats, "stats", {});
path.forEach((property: any, key: number) => {
if (key === lastKey) {
metricStats[property] = computeValues(
json.data,
index,
metric.transformValue
);
cacheMetricStats[property] = computeValues(
json.data,
index,
metric.transformValue
);
return;
}
metricStats = createGetProperty(metricStats, property, {});
cacheMetricStats = createGetProperty(
cacheMetricStats,
property,
{}
const lastKey = path.length - 1;
let metricStats = createGetProperty(stepStats, "stats", {});
path.forEach((property: any, key: number) => {
if (key === lastKey) {
metricStats[property] = computeValues(
json.data,
index,
metric.transformValue
);
});
return;
}
metricStats = createGetProperty(metricStats, property, {});
});
}
if (actualStep !== step) {
throw new FaultyGranularity(
`Unable to get the true granularity: ${actualStep}`
);
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return;
}
throw error;
});
}
return (this.#statsByObject[uuid]?.[step] ?? {
endTimestamp: currentTimeStamp,
interval: step,
stats: {},
}) as XapiStatsResponse<T>;
if (actualStep !== step) {
throw new FaultyGranularity(
`Unable to get the true granularity: ${actualStep}`
);
}
return (
this.#statsByObject[uuid]?.[step] ?? {
endTimestamp: currentTimeStamp,
interval: step,
stats: {},
}
);
}
}

View File

@@ -266,11 +266,7 @@ export default class XenApi {
async getResource(
pathname: string,
{
abortSignal,
host,
query,
}: { abortSignal?: AbortSignal; host: XenApiHost; query: any }
{ host, query }: { host: XenApiHost; query: any }
) {
const url = new URL("http://localhost");
url.protocol = window.location.protocol;
@@ -281,7 +277,7 @@ export default class XenApi {
session_id: this.#sessionId,
}).toString();
return fetch(url, { signal: abortSignal });
return fetch(url);
}
async loadRecords<T extends XenApiRecord<string>>(

View File

@@ -36,7 +36,6 @@
"export": "Export",
"export-table-to": "Export table to {type}",
"export-vms": "Export VMs",
"fetching-fresh-data": "Fetching fresh data",
"filter": {
"comparison": {
"contains": "Contains",
@@ -77,8 +76,6 @@
"news": "News",
"news-name": "{name} news",
"new-features-are-coming": "New features are coming soon!",
"no-tasks": "No tasks",
"not-found": "Not found",
"object": "Object",
"object-not-found": "Object {id} can't be found…",
"or": "Or",
@@ -129,6 +126,7 @@
"system": "System",
"task": {
"estimated-end": "Estimated end",
"page-title": "Tasks | (1) Tasks | ({n}) Tasks",
"progress": "Progress",
"started": "Started"
},

View File

@@ -36,7 +36,6 @@
"export": "Exporter",
"export-table-to": "Exporter le tableau en {type}",
"export-vms": "Exporter les VMs",
"fetching-fresh-data": "Récupération de données à jour",
"filter": {
"comparison": {
"contains": "Contient",
@@ -77,8 +76,6 @@
"news": "Actualités",
"news-name": "Actualités {name}",
"new-features-are-coming": "De nouvelles fonctionnalités arrivent bientôt !",
"no-tasks": "Aucune tâche",
"not-found": "Non trouvé",
"object": "Objet",
"object-not-found": "L'objet {id} est introuvable…",
"or": "Ou",
@@ -129,6 +126,7 @@
"system": "Système",
"task": {
"estimated-end": "Fin estimée",
"page-title": "Tâches | (1) Tâches | ({n}) Tâches",
"progress": "Progression",
"started": "Démarré"
},

View File

@@ -33,7 +33,7 @@ const router = createRouter({
},
{
path: "/:pathMatch(.*)*",
name: "not-found",
name: "notFound",
component: () => import("@/views/PageNotFoundView.vue"),
},
],

View File

@@ -1,9 +1,5 @@
import { isHostRunning, sortRecordsByNameLabel } from "@/libs/utils";
import type {
GRANULARITY,
HostStats,
XapiStatsResponse,
} from "@/libs/xapi-stats";
import type { GRANULARITY, XapiStatsResponse } from "@/libs/xapi-stats";
import type { XenApiHost, XenApiHostMetrics } from "@/libs/xen-api";
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { useXenApiStore } from "@/stores/xen-api.store";
@@ -12,15 +8,11 @@ import { createSubscribe } from "@/types/xapi-collection";
import { defineStore } from "pinia";
import { computed, type ComputedRef } from "vue";
type GetStats = (
hostUuid: XenApiHost["uuid"],
granularity: GRANULARITY,
ignoreExpired: boolean,
opts: { abortSignal?: AbortSignal }
) => Promise<XapiStatsResponse<HostStats> | undefined> | undefined;
type GetStatsExtension = {
getStats: GetStats;
getStats: (
hostUuid: XenApiHost["uuid"],
granularity: GRANULARITY
) => Promise<XapiStatsResponse<any>> | undefined;
};
type RunningHostsExtension = [
@@ -39,11 +31,9 @@ export const useHostStore = defineStore("host", () => {
const subscribe = createSubscribe<XenApiHost, Extensions>((options) => {
const originalSubscription = hostCollection.subscribe(options);
const getStats: GetStats = (
hostUuid,
granularity,
ignoreExpired = false,
{ abortSignal }
const getStats = (
hostUuid: XenApiHost["uuid"],
granularity: GRANULARITY
) => {
const host = originalSubscription.getByUuid(hostUuid);
@@ -55,10 +45,8 @@ export const useHostStore = defineStore("host", () => {
? xenApiStore.getXapiStats()
: undefined;
return xapiStats?._getAndUpdateStats<HostStats>({
abortSignal,
return xapiStats?._getAndUpdateStats({
host,
ignoreExpired,
uuid: host.uuid,
granularity,
});

View File

@@ -1,92 +0,0 @@
import { useTitle } from "@vueuse/core";
import { defineStore } from "pinia";
import {
computed,
type MaybeRefOrGetter,
onBeforeUnmount,
reactive,
toRef,
watch,
} from "vue";
const PAGE_TITLE_SUFFIX = "XO Lite";
interface PageTitleConfig {
object: { name_label: string } | undefined;
title: string | undefined;
count: number | undefined;
}
export const usePageTitleStore = defineStore("page-title", () => {
const pageTitleConfig = reactive<PageTitleConfig>({
count: undefined,
title: undefined,
object: undefined,
});
const generatedPageTitle = computed(() => {
const { object, title, count } = pageTitleConfig;
const parts = [];
if (count !== undefined && count > 0) {
parts.push(`(${count})`);
}
if (title !== undefined && object !== undefined) {
parts.push(`${title} - ${object.name_label}`);
} else if (title !== undefined) {
parts.push(title);
} else if (object !== undefined) {
parts.push(object.name_label);
}
if (parts.length === 0) {
return undefined;
}
return parts.join(" ");
});
useTitle(generatedPageTitle, {
titleTemplate: computed(() =>
generatedPageTitle.value === undefined
? PAGE_TITLE_SUFFIX
: `%s - ${PAGE_TITLE_SUFFIX}`
),
});
const setPageTitleConfig = <T extends keyof PageTitleConfig>(
configKey: T,
value: MaybeRefOrGetter<PageTitleConfig[T]>
) => {
const stop = watch(
toRef(value),
(newValue) =>
(pageTitleConfig[configKey] = newValue as PageTitleConfig[T]),
{
immediate: true,
}
);
onBeforeUnmount(() => {
stop();
pageTitleConfig[configKey] = undefined;
});
};
const setObject = (
object: MaybeRefOrGetter<{ name_label: string } | undefined>
) => setPageTitleConfig("object", object);
const setTitle = (title: MaybeRefOrGetter<string | undefined>) =>
setPageTitleConfig("title", title);
const setCount = (count: MaybeRefOrGetter<number | undefined>) =>
setPageTitleConfig("count", count);
return {
setObject,
setTitle,
setCount,
};
});

View File

@@ -1,64 +1,6 @@
import useArrayRemovedItemsHistory from "@/composables/array-removed-items-history.composable";
import useCollectionFilter from "@/composables/collection-filter.composable";
import useCollectionSorter from "@/composables/collection-sorter.composable";
import useFilteredCollection from "@/composables/filtered-collection.composable";
import useSortedCollection from "@/composables/sorted-collection.composable";
import type { XenApiTask } from "@/libs/xen-api";
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { createSubscribe } from "@/types/xapi-collection";
import { defineStore } from "pinia";
import type { ComputedRef, Ref } from "vue";
type PendingTasksExtension = {
pendingTasks: ComputedRef<XenApiTask[]>;
};
type FinishedTasksExtension = {
finishedTasks: Ref<XenApiTask[]>;
};
type Extensions = [PendingTasksExtension, FinishedTasksExtension];
export const useTaskStore = defineStore("task", () => {
const tasksCollection = useXapiCollectionStore().get("task");
const subscribe = createSubscribe<XenApiTask, Extensions>(() => {
const subscription = tasksCollection.subscribe();
const { compareFn } = useCollectionSorter<XenApiTask>({
initialSorts: ["-created"],
});
const sortedTasks = useSortedCollection(subscription.records, compareFn);
const { predicate } = useCollectionFilter({
initialFilters: [
"!name_label:|(SR.scan host.call_plugin)",
"status:pending",
],
});
const extendedSubscription = {
pendingTasks: useFilteredCollection<XenApiTask>(sortedTasks, predicate),
finishedTasks: useArrayRemovedItemsHistory(
sortedTasks,
(task) => task.uuid,
{
limit: 50,
onRemove: (tasks) =>
tasks.map((task) => ({
...task,
finished: new Date().toISOString(),
})),
}
),
};
return {
...subscription,
...extendedSubscription,
};
});
return { ...tasksCollection, subscribe };
});
export const useTaskStore = defineStore("task", () =>
useXapiCollectionStore().get("task")
);

View File

@@ -1,9 +1,5 @@
import { sortRecordsByNameLabel } from "@/libs/utils";
import type {
GRANULARITY,
VmStats,
XapiStatsResponse,
} from "@/libs/xapi-stats";
import type { GRANULARITY, XapiStatsResponse } from "@/libs/xapi-stats";
import type { XenApiHost, XenApiVm } from "@/libs/xen-api";
import { POWER_STATE } from "@/libs/xen-api";
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
@@ -12,12 +8,6 @@ import { createSubscribe, type Subscription } from "@/types/xapi-collection";
import { defineStore } from "pinia";
import { computed, type ComputedRef } from "vue";
type GetStats = (
id: XenApiVm["uuid"],
granularity: GRANULARITY,
ignoreExpired: boolean,
opts: { abortSignal?: AbortSignal }
) => Promise<XapiStatsResponse<VmStats> | undefined> | undefined;
type DefaultExtension = {
recordsByHostRef: ComputedRef<Map<XenApiHost["$ref"], XenApiVm[]>>;
runningVms: ComputedRef<XenApiVm[]>;
@@ -25,7 +15,10 @@ type DefaultExtension = {
type GetStatsExtension = [
{
getStats: GetStats;
getStats: (
id: XenApiVm["uuid"],
granularity: GRANULARITY
) => Promise<XapiStatsResponse<any>>;
},
{ hostSubscription: Subscription<XenApiHost, object> }
];
@@ -67,49 +60,33 @@ export const useVmStore = defineStore("vm", () => {
const hostSubscription = options?.hostSubscription;
const getStatsSubscription:
| {
getStats: GetStats;
const getStatsSubscription = hostSubscription !== undefined && {
getStats: (vmUuid: XenApiVm["uuid"], granularity: GRANULARITY) => {
const xenApiStore = useXenApiStore();
if (!xenApiStore.isConnected) {
return undefined;
}
| undefined =
hostSubscription !== undefined
? {
getStats: (
id,
granularity,
ignoreExpired = false,
{ abortSignal }
) => {
const xenApiStore = useXenApiStore();
if (!xenApiStore.isConnected) {
return undefined;
}
const vm = originalSubscription.getByUuid(vmUuid);
const vm = originalSubscription.getByUuid(id);
if (vm === undefined) {
throw new Error(`VM ${vmUuid} could not be found.`);
}
if (vm === undefined) {
throw new Error(`VM ${id} could not be found.`);
}
const host = hostSubscription.getByOpaqueRef(vm.resident_on);
const host = hostSubscription.getByOpaqueRef(vm.resident_on);
if (host === undefined) {
throw new Error(`VM ${vmUuid} is halted or host could not be found.`);
}
if (host === undefined) {
throw new Error(
`VM ${id} is halted or host could not be found.`
);
}
return xenApiStore.getXapiStats()._getAndUpdateStats<VmStats>({
abortSignal,
host,
ignoreExpired,
uuid: vm.uuid,
granularity,
});
},
}
: undefined;
return xenApiStore.getXapiStats()._getAndUpdateStats({
host,
uuid: vm.uuid,
granularity,
});
},
};
return {
...originalSubscription,

View File

@@ -1 +1,6 @@
export type Color = "info" | "error" | "warning" | "success";
export type SlotDefinition<
T extends Record<string, unknown> = Record<string, never>,
R = any
> = (props: T) => R;

View File

@@ -9,8 +9,6 @@
</template>
<script setup lang="ts">
import { usePageTitleStore } from "@/stores/page-title.store";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import UiButton from "@/components/ui/UiButton.vue";
@@ -18,8 +16,6 @@ defineProps<{
id: string;
}>();
usePageTitleStore().setTitle(useI18n().t("not-found"));
const router = useRouter();
</script>

View File

@@ -10,13 +10,10 @@
</template>
<script setup lang="ts">
import UiButton from "@/components/ui/UiButton.vue";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import UiButton from "@/components/ui/UiButton.vue";
const router = useRouter();
usePageTitleStore().setTitle(useI18n().t("not-found"));
</script>
<style lang="postcss" scoped>

View File

@@ -6,7 +6,6 @@
</template>
<script lang="ts" setup>
import { usePageTitleStore } from "@/stores/page-title.store";
import { computed } from "vue";
import { useRouter } from "vue-router";
import { faBook } from "@fortawesome/free-solid-svg-icons";
@@ -21,8 +20,6 @@ const title = computed(() => {
return `${currentRoute.value.meta.storyTitle} Story`;
});
usePageTitleStore().setTitle(title);
</script>
<style lang="postcss" scoped></style>

View File

@@ -4,8 +4,4 @@
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useI18n } from "vue-i18n";
usePageTitleStore().setTitle(useI18n().t("dashboard"));
</script>

View File

@@ -8,22 +8,17 @@
import ObjectNotFoundWrapper from "@/components/ObjectNotFoundWrapper.vue";
import type { XenApiHost } from "@/libs/xen-api";
import { useHostStore } from "@/stores/host.store";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useUiStore } from "@/stores/ui.store";
import { computed, watchEffect } from "vue";
import { watchEffect } from "vue";
import { useRoute } from "vue-router";
const { hasUuid, isReady, getByUuid } = useHostStore().subscribe();
const route = useRoute();
const uiStore = useUiStore();
const currentHost = computed(() =>
getByUuid(route.params.uuid as XenApiHost["uuid"])
);
watchEffect(() => {
uiStore.currentHostOpaqueRef = currentHost.value?.$ref;
uiStore.currentHostOpaqueRef = getByUuid(
route.params.uuid as XenApiHost["uuid"]
)?.$ref;
});
usePageTitleStore().setObject(currentHost);
</script>

View File

@@ -4,8 +4,4 @@
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useI18n } from "vue-i18n";
usePageTitleStore().setTitle(useI18n().t("alarms"));
</script>

View File

@@ -21,7 +21,7 @@
</UiCardGroup>
</UiCardGroup>
<UiCardGroup>
<PoolDashboardTasks class="tasks" />
<UiCardComingSoon class="tasks" title="Tasks" />
</UiCardGroup>
</div>
</template>
@@ -31,24 +31,8 @@ export const N_ITEMS = 5;
</script>
<script lang="ts" setup>
import PoolDashboardTasks from "@/components/pool/dashboard/PoolDashboardTasks.vue";
import PoolCpuUsageChart from "@/components/pool/dashboard/cpuUsage/PoolCpuUsageChart.vue";
import PoolDashboardCpuProvisioning from "@/components/pool/dashboard/PoolDashboardCpuProvisioning.vue";
import PoolDashboardCpuUsage from "@/components/pool/dashboard/PoolDashboardCpuUsage.vue";
import PoolDashboardNetworkChart from "@/components/pool/dashboard/PoolDashboardNetworkChart.vue";
import PoolDashboardRamUsage from "@/components/pool/dashboard/PoolDashboardRamUsage.vue";
import PoolDashboardStatus from "@/components/pool/dashboard/PoolDashboardStatus.vue";
import PoolDashboardStorageUsage from "@/components/pool/dashboard/PoolDashboardStorageUsage.vue";
import PoolDashboardRamUsageChart from "@/components/pool/dashboard/ramUsage/PoolRamUsage.vue";
import UiCardComingSoon from "@/components/ui/UiCardComingSoon.vue";
import UiCardGroup from "@/components/ui/UiCardGroup.vue";
import useFetchStats from "@/composables/fetch-stats.composable";
import { GRANULARITY, type HostStats, type VmStats } from "@/libs/xapi-stats";
import type { XenApiHost, XenApiVm } from "@/libs/xen-api";
import { useHostMetricsStore } from "@/stores/host-metrics.store";
import { useHostStore } from "@/stores/host.store";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useVmStore } from "@/stores/vm.store";
import {
IK_HOST_LAST_WEEK_STATS,
IK_HOST_STATS,
@@ -56,9 +40,20 @@ import {
} from "@/types/injection-keys";
import { differenceBy } from "lodash-es";
import { provide, watch } from "vue";
import { useI18n } from "vue-i18n";
usePageTitleStore().setTitle(useI18n().t("dashboard"));
import UiCardComingSoon from "@/components/ui/UiCardComingSoon.vue";
import PoolCpuUsageChart from "@/components/pool/dashboard/cpuUsage/PoolCpuUsageChart.vue";
import PoolDashboardCpuUsage from "@/components/pool/dashboard/PoolDashboardCpuUsage.vue";
import PoolDashboardNetworkChart from "@/components/pool/dashboard/PoolDashboardNetworkChart.vue";
import PoolDashboardCpuProvisioning from "@/components/pool/dashboard/PoolDashboardCpuProvisioning.vue";
import PoolDashboardRamUsage from "@/components/pool/dashboard/PoolDashboardRamUsage.vue";
import PoolDashboardRamUsageChart from "@/components/pool/dashboard/ramUsage/PoolRamUsage.vue";
import PoolDashboardStatus from "@/components/pool/dashboard/PoolDashboardStatus.vue";
import PoolDashboardStorageUsage from "@/components/pool/dashboard/PoolDashboardStorageUsage.vue";
import useFetchStats from "@/composables/fetch-stats.composable";
import { GRANULARITY, type HostStats, type VmStats } from "@/libs/xapi-stats";
import type { XenApiHost, XenApiVm } from "@/libs/xen-api";
import { useHostStore } from "@/stores/host.store";
import { useVmStore } from "@/stores/vm.store";
const hostMetricsSubscription = useHostMetricsStore().subscribe();
@@ -129,18 +124,6 @@ runningVms.value.forEach((vm) => vmRegister(vm));
padding: 1rem;
}
@media (min-width: 768px) {
.pool-dashboard-view {
column-count: 2;
}
}
@media (min-width: 1500px) {
.pool-dashboard-view {
column-count: 3;
}
}
.alarms,
.tasks {
flex: 1;

View File

@@ -4,8 +4,4 @@
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useI18n } from "vue-i18n";
usePageTitleStore().setTitle(useI18n().t("hosts"));
</script>

View File

@@ -4,8 +4,4 @@
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useI18n } from "vue-i18n";
usePageTitleStore().setTitle(useI18n().t("network"));
</script>

View File

@@ -10,11 +10,6 @@
<script lang="ts" setup>
import PoolHeader from "@/components/pool/PoolHeader.vue";
import PoolTabBar from "@/components/pool/PoolTabBar.vue";
import { usePoolStore } from "@/stores/pool.store";
import { usePageTitleStore } from "@/stores/page-title.store";
const { pool } = usePoolStore().subscribe();
usePageTitleStore().setObject(pool);
</script>
<style lang="postcss" scoped></style>

View File

@@ -4,8 +4,4 @@
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useI18n } from "vue-i18n";
usePageTitleStore().setTitle(useI18n().t("stats"));
</script>

View File

@@ -4,8 +4,4 @@
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useI18n } from "vue-i18n";
usePageTitleStore().setTitle(useI18n().t("storage"));
</script>

View File

@@ -4,8 +4,4 @@
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useI18n } from "vue-i18n";
usePageTitleStore().setTitle(useI18n().t("system"));
</script>

View File

@@ -4,27 +4,58 @@
{{ $t("tasks") }}
<UiCounter :value="pendingTasks.length" color="info" />
</UiTitle>
<TasksTable :finished-tasks="finishedTasks" :pending-tasks="pendingTasks" />
<UiCardSpinner v-if="!isReady" />
</UiCard>
</template>
<script lang="ts" setup>
import TasksTable from "@/components/tasks/TasksTable.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardSpinner from "@/components/ui/UiCardSpinner.vue";
import UiCounter from "@/components/ui/UiCounter.vue";
import UiTitle from "@/components/ui/UiTitle.vue";
import useArrayRemovedItemsHistory from "@/composables/array-removed-items-history.composable";
import useCollectionFilter from "@/composables/collection-filter.composable";
import useCollectionSorter from "@/composables/collection-sorter.composable";
import useFilteredCollection from "@/composables/filtered-collection.composable";
import useSortedCollection from "@/composables/sorted-collection.composable";
import type { XenApiTask } from "@/libs/xen-api";
import { useTaskStore } from "@/stores/task.store";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useTitle } from "@vueuse/core";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
const { pendingTasks, finishedTasks, isReady, hasError } = useTaskStore().subscribe();
const { records, hasError } = useTaskStore().subscribe();
const { t } = useI18n();
const titleStore = usePageTitleStore();
titleStore.setTitle(t("tasks"));
titleStore.setCount(() => pendingTasks.value.length);
const { compareFn } = useCollectionSorter<XenApiTask>({
initialSorts: ["-created"],
});
const allTasks = useSortedCollection(records, compareFn);
const { predicate } = useCollectionFilter({
initialFilters: ["!name_label:|(SR.scan host.call_plugin)", "status:pending"],
});
const pendingTasks = useFilteredCollection<XenApiTask>(allTasks, predicate);
const finishedTasks = useArrayRemovedItemsHistory(
allTasks,
(task) => task.uuid,
{
limit: 50,
onRemove: (tasks) =>
tasks.map((task) => ({
...task,
finished: new Date().toISOString(),
})),
}
);
useTitle(
computed(() => t("task.page-title", { n: pendingTasks.value.length }))
);
</script>
<style lang="postcss" scoped>

View File

@@ -38,7 +38,6 @@ import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import VmsActionsBar from "@/components/vm/VmsActionsBar.vue";
import { POWER_STATE } from "@/libs/xen-api";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useUiStore } from "@/stores/ui.store";
import { useVmStore } from "@/stores/vm.store";
import type { Filters } from "@/types/filter";
@@ -47,13 +46,9 @@ import { storeToRefs } from "pinia";
import { ref } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const titleStore = usePageTitleStore();
titleStore.setTitle(t("vms"));
const { records: vms } = useVmStore().subscribe();
const { isMobile, isDesktop } = storeToRefs(useUiStore());
const { t } = useI18n();
const filters: Filters = {
name_label: { label: t("name"), type: "string" },
@@ -67,8 +62,6 @@ const filters: Filters = {
};
const selectedVmsRefs = ref([]);
titleStore.setCount(() => selectedVmsRefs.value.length);
</script>
<style lang="postcss" scoped>

View File

@@ -157,7 +157,6 @@
</template>
<script lang="ts" setup>
import { usePageTitleStore } from "@/stores/page-title.store";
import { computed } from "vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
@@ -182,9 +181,7 @@ import UiKeyValueRow from "@/components/ui/UiKeyValueRow.vue";
const xoLiteVersion = XO_LITE_VERSION;
const xoLiteGitHead = XO_LITE_GIT_HEAD;
const { t, locale } = useI18n();
usePageTitleStore().setTitle(() => t("settings"));
const { locale } = useI18n();
const { pool } = usePoolStore().subscribe();
const { getByOpaqueRef: getHost } = useHostStore().subscribe();

View File

@@ -4,8 +4,4 @@
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useI18n } from "vue-i18n";
usePageTitleStore().setTitle(useI18n().t("alarms"));
</script>

View File

@@ -3,21 +3,19 @@
<div v-else-if="!isVmRunning">Console is only available for running VMs.</div>
<RemoteConsole
v-else-if="vm && vmConsole"
:is-console-available="!isOperationsPending(vm, STOP_OPERATIONS)"
:location="vmConsole.location"
:is-console-available="!isOperationsPending(vm, STOP_OPERATIONS)"
/>
</template>
<script lang="ts" setup>
import RemoteConsole from "@/components/RemoteConsole.vue";
import { isOperationsPending } from "@/libs/utils";
import { POWER_STATE, VM_OPERATION, type XenApiVm } from "@/libs/xen-api";
import { useConsoleStore } from "@/stores/console.store";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useVmStore } from "@/stores/vm.store";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
import RemoteConsole from "@/components/RemoteConsole.vue";
import { useConsoleStore } from "@/stores/console.store";
import { useVmStore } from "@/stores/vm.store";
import { isOperationsPending } from "@/libs/utils";
const STOP_OPERATIONS = [
VM_OPERATION.SHUTDOWN,
@@ -29,8 +27,6 @@ const STOP_OPERATIONS = [
VM_OPERATION.SUSPEND,
];
usePageTitleStore().setTitle(useI18n().t("console"));
const route = useRoute();
const { isReady: isVmReady, getByUuid: getVmByUuid } = useVmStore().subscribe();

View File

@@ -4,8 +4,4 @@
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useI18n } from "vue-i18n";
usePageTitleStore().setTitle(useI18n().t("dashboard"));
</script>

View File

@@ -4,8 +4,4 @@
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useI18n } from "vue-i18n";
usePageTitleStore().setTitle(useI18n().t("network"));
</script>

View File

@@ -11,7 +11,6 @@ import ObjectNotFoundWrapper from "@/components/ObjectNotFoundWrapper.vue";
import VmHeader from "@/components/vm/VmHeader.vue";
import VmTabBar from "@/components/vm/VmTabBar.vue";
import type { XenApiVm } from "@/libs/xen-api";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useUiStore } from "@/stores/ui.store";
import { useVmStore } from "@/stores/vm.store";
import { whenever } from "@vueuse/core";
@@ -23,5 +22,4 @@ const { getByUuid, hasUuid, isReady } = useVmStore().subscribe();
const uiStore = useUiStore();
const vm = computed(() => getByUuid(route.params.uuid as XenApiVm["uuid"]));
whenever(vm, (vm) => (uiStore.currentHostOpaqueRef = vm.resident_on));
usePageTitleStore().setObject(vm);
</script>

View File

@@ -4,8 +4,4 @@
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useI18n } from "vue-i18n";
usePageTitleStore().setTitle(useI18n().t("stats"));
</script>

View File

@@ -4,8 +4,4 @@
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useI18n } from "vue-i18n";
usePageTitleStore().setTitle(useI18n().t("storage"));
</script>

View File

@@ -4,8 +4,4 @@
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useI18n } from "vue-i18n";
usePageTitleStore().setTitle(useI18n().t("system"));
</script>

View File

@@ -4,8 +4,4 @@
<script lang="ts" setup>
import PageUnderConstruction from "@/components/PageUnderConstruction.vue";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useI18n } from "vue-i18n";
usePageTitleStore().setTitle(useI18n().t("tasks"));
</script>

View File

@@ -11,18 +11,10 @@ const runHook = async (emitter, hook, onResult = noop) => {
const listeners = emitter.listeners(hook)
await Promise.all(
listeners.map(async listener => {
const handle = setInterval(() => {
warn(
`${hook} ${listener.name || 'anonymous'} listener is still running`,
listener.name ? undefined : { source: listener.toString() }
)
}, 5e3)
try {
onResult(await listener.call(emitter))
} catch (error) {
warn(`${hook} failure`, { error })
} finally {
clearInterval(handle)
}
})
)

View File

@@ -58,7 +58,7 @@ const ndJsonStream = asyncIteratorToStream(async function* (responseId, iterable
export default class Api {
constructor(app, { appVersion, httpServer }) {
this._ajv = new Ajv({ allErrors: true, useDefaults: true })
this._ajv = new Ajv({ allErrors: true })
this._methods = { __proto__: null }
const PREFIX = '/api/v1'
const router = new Router({ prefix: PREFIX }).post('/', async ctx => {

View File

@@ -174,15 +174,12 @@ export default class Backups {
},
],
fetchPartitionFiles: [
({ disk: diskId, format, remote, partition: partitionId, paths }) =>
Disposable.use(this.getAdapter(remote), adapter =>
adapter.fetchPartitionFiles(diskId, partitionId, paths, format)
),
({ disk: diskId, remote, partition: partitionId, paths }) =>
Disposable.use(this.getAdapter(remote), adapter => adapter.fetchPartitionFiles(diskId, partitionId, paths)),
{
description: 'fetch files from partition',
params: {
disk: { type: 'string' },
format: { type: 'string', default: 'zip' },
partition: { type: 'string', optional: true },
paths: { type: 'array', items: { type: 'string' } },
remote: { type: 'object' },

View File

@@ -48,7 +48,7 @@ export default class VhdEsxiCowd extends VhdAbstract {
// depending on the paramters we also look into the parent data
return (
this.#grainDirectory.readUInt32LE(blockId * 4) !== 0 ||
this.#grainDirectory.readInt32LE(blockId * 4) !== 0 ||
(this.#lookMissingBlockInParent && this.#parentVhd.containsBlock(blockId))
)
}
@@ -61,14 +61,14 @@ export default class VhdEsxiCowd extends VhdAbstract {
const buffer = await this.#read(0, 2048)
strictEqual(buffer.slice(0, 4).toString('ascii'), 'COWD')
strictEqual(buffer.readUInt32LE(4), 1) // version
strictEqual(buffer.readUInt32LE(8), 3) // flags
const numSectors = buffer.readUInt32LE(12)
const grainSize = buffer.readUInt32LE(16)
strictEqual(buffer.readInt32LE(4), 1) // version
strictEqual(buffer.readInt32LE(8), 3) // flags
const numSectors = buffer.readInt32LE(12)
const grainSize = buffer.readInt32LE(16)
strictEqual(grainSize, 1) // 1 grain should be 1 sector long
strictEqual(buffer.readUInt32LE(20), 4) // grain directory position in sectors
strictEqual(buffer.readInt32LE(20), 4) // grain directory position in sectors
const nbGrainDirectoryEntries = buffer.readUInt32LE(24)
const nbGrainDirectoryEntries = buffer.readInt32LE(24)
strictEqual(nbGrainDirectoryEntries, Math.ceil(numSectors / 4096))
const size = numSectors * 512
// a grain directory entry contains the address of a grain table
@@ -90,7 +90,7 @@ export default class VhdEsxiCowd extends VhdAbstract {
// we're lucky : a grain address can address exacty a full block
async readBlock(blockId) {
notEqual(this.#grainDirectory, undefined, 'grainDirectory is not loaded')
const sectorOffset = this.#grainDirectory.readUInt32LE(blockId * 4)
const sectorOffset = this.#grainDirectory.readInt32LE(blockId * 4)
const buffer = (await this.#parentVhd.readBlock(blockId)).buffer
@@ -137,7 +137,7 @@ export default class VhdEsxiCowd extends VhdAbstract {
}
for (let i = 0; i < graintable.length / 4; i++) {
const grainOffset = graintable.readUInt32LE(i * 4)
const grainOffset = graintable.readInt32LE(i * 4)
if (grainOffset === 0) {
// the content from parent : it is already in buffer
await changeRange()

View File

@@ -1,7 +1,7 @@
import _computeGeometryForSize from 'vhd-lib/_computeGeometryForSize.js'
import { createFooter, createHeader } from 'vhd-lib/_createFooterHeader.js'
import { DISK_TYPES, FOOTER_SIZE } from 'vhd-lib/_constants.js'
import { readChunkStrict, skipStrict } from '@vates/read-chunk'
import { readChunk } from '@vates/read-chunk'
import { Task } from '@vates/task'
import { unpackFooter, unpackHeader } from 'vhd-lib/Vhd/_utils.js'
import { VhdAbstract } from 'vhd-lib'
@@ -21,10 +21,6 @@ export default class VhdEsxiRaw extends VhdAbstract {
#header
#footer
#streamOffset = 0
#stream
#reading = false
static async open(esxi, datastore, path, opts) {
const vhd = new VhdEsxiRaw(esxi, datastore, path, opts)
await vhd.readHeaderAndFooter()
@@ -53,10 +49,10 @@ export default class VhdEsxiRaw extends VhdAbstract {
this.#header = unpackHeader(createHeader(length / VHD_BLOCK_LENGTH))
const geometry = _computeGeometryForSize(length)
const actualSize = geometry.actualSize
this.#footer = unpackFooter(
// length can be smaller than disk capacity due to alignment to head/cylinder/sector
createFooter(length, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DYNAMIC)
createFooter(actualSize, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DYNAMIC)
)
}
@@ -68,65 +64,12 @@ export default class VhdEsxiRaw extends VhdAbstract {
return this.#bat.has(blockId)
}
async #readChunk(start, length) {
if (this.#reading) {
throw new Error('reading must be done sequentially')
}
try {
this.#reading = true
if (this.#stream !== undefined) {
// stream is too far ahead or to far behind
if (this.#streamOffset > start || this.#streamOffset + VHD_BLOCK_LENGTH < start) {
this.#stream.destroy()
this.#stream = undefined
this.#streamOffset = 0
}
}
// no stream
if (this.#stream === undefined) {
const end = this.footer.currentSize - 1
const res = await this.#esxi.download(this.#datastore, this.#path, `${start}-${end}`)
this.#stream = res.body
this.#streamOffset = start
}
// stream a little behind
if (this.#streamOffset < start) {
await skipStrict(this.#stream, start - this.#streamOffset)
this.#streamOffset = start
}
// really read data
this.#streamOffset += length
const data = await readChunkStrict(this.#stream, length)
return data
} catch (error) {
error.start = start
error.length = length
error.streamLength = this.footer.currentSize
this.#stream?.destroy()
this.#stream = undefined
this.#streamOffset = 0
throw error
} finally {
this.#reading = false
}
}
async #readBlock(blockId) {
async readBlock(blockId) {
const start = blockId * VHD_BLOCK_LENGTH
let length = VHD_BLOCK_LENGTH
let partial = false
if (start + length > this.footer.currentSize) {
length = this.footer.currentSize - start
partial = true
}
const end = (blockId + 1) * VHD_BLOCK_LENGTH - 1
let data = await this.#readChunk(start, length)
const data = await (await this.#esxi.download(this.#datastore, this.#path, `${start}-${end}`)).buffer()
if (partial) {
data = Buffer.concat([data, Buffer.alloc(VHD_BLOCK_LENGTH - data.length)])
}
const bitmap = Buffer.alloc(512, 255)
return {
id: blockId,
@@ -136,44 +79,28 @@ export default class VhdEsxiRaw extends VhdAbstract {
}
}
async readBlock(blockId) {
let tries = 5
let lastError
while (tries > 0) {
try {
const res = await this.#readBlock(blockId)
return res
} catch (error) {
lastError = error
lastError.blockId = blockId
console.warn('got error , will retry in 2seconds', lastError)
}
await new Promise(resolve => setTimeout(() => resolve(), 2000))
tries--
}
throw lastError
}
// this will read all the disk once to check which block contains data, it can take a long time to execute depending on the network speed
async readBlockAllocationTable() {
if (!this.#thin) {
// fast path : is we do not use thin mode, the BAT is full
return
}
const res = await this.#esxi.download(this.#datastore, this.#path)
const length = res.headers.get('content-length')
const stream = res.body
const empty = Buffer.alloc(VHD_BLOCK_LENGTH, 0)
let pos = 0
this.#bat = new Set()
let nextChunkLength = Math.min(VHD_BLOCK_LENGTH, this.footer.currentSize)
Task.set('total', this.footer.currentSize / VHD_BLOCK_LENGTH)
let nextChunkLength = Math.min(VHD_BLOCK_LENGTH, length)
Task.set('total', length / VHD_BLOCK_LENGTH)
const progress = setInterval(() => {
Task.set('progress', Math.round((pos * 100) / this.footer.currentSize))
console.log('reading blocks', pos / VHD_BLOCK_LENGTH, '/', this.footer.currentSize / VHD_BLOCK_LENGTH)
Task.set('progress', Math.round((pos * 100) / length))
console.log('reading blocks', pos / VHD_BLOCK_LENGTH, '/', length / VHD_BLOCK_LENGTH)
}, 30 * 1000)
while (nextChunkLength > 0) {
try {
const chunk = await this.#readChunk(pos, nextChunkLength)
const chunk = await readChunk(stream, nextChunkLength)
let isEmpty
if (nextChunkLength === VHD_BLOCK_LENGTH) {
isEmpty = empty.equals(chunk)
@@ -185,28 +112,15 @@ export default class VhdEsxiRaw extends VhdAbstract {
this.#bat.add(pos / VHD_BLOCK_LENGTH)
}
pos += VHD_BLOCK_LENGTH
nextChunkLength = Math.min(VHD_BLOCK_LENGTH, this.footer.currentSize - pos)
nextChunkLength = Math.min(VHD_BLOCK_LENGTH, length - pos)
} catch (error) {
clearInterval(progress)
throw error
}
}
console.log(
'BAT reading done, remaining ',
this.#bat.size,
'/',
Math.ceil(this.footer.currentSize / VHD_BLOCK_LENGTH)
)
console.log('BAT reading done, remaining ', this.#bat.size, '/', Math.ceil(length / VHD_BLOCK_LENGTH))
clearInterval(progress)
}
rawContent() {
return this.#esxi.download(this.#datastore, this.#path).then(res => {
const stream = res.body
stream.length = this.footer.currentSize
return stream
})
}
}
/* eslint-enable no-console */

View File

@@ -1,54 +1,18 @@
import _computeGeometryForSize from 'vhd-lib/_computeGeometryForSize.js'
import { createFooter, createHeader } from 'vhd-lib/_createFooterHeader.js'
import { DISK_TYPES, FOOTER_SIZE } from 'vhd-lib/_constants.js'
import { FOOTER_SIZE } from 'vhd-lib/_constants.js'
import { notEqual, strictEqual } from 'node:assert'
import { unpackFooter, unpackHeader } from 'vhd-lib/Vhd/_utils.js'
import { VhdAbstract } from 'vhd-lib'
// one big difference with the other versions of VMDK is that the grain tables are actually sparse, they are pre-allocated but not used in grain order,
// so we have to read the grain directory to know where to find the grain tables
// from https://github.com/qemu/qemu/commit/98eb9733f4cf2eeab6d12db7e758665d2fd5367b#
const SE_SPARSE_DIR_NON_ALLOCATED = 0
const SE_SPARSE_DIR_ALLOCATED = 1
const SE_SPARSE_GRAIN_NON_ALLOCATED = 0 // check in parent
const SE_SPARSE_GRAIN_UNMAPPED = 1 // grain has been unmapped, but index of previous grain still readable for reclamation
const SE_SPARSE_GRAIN_ZERO = 2
const SE_SPARSE_GRAIN_ALLOCATED = 3
const VHD_BLOCK_SIZE_BYTES = 2 * 1024 * 1024
const GRAIN_SIZE_BYTES = 4 * 1024
const GRAIN_TABLE_COUNT = 4 * 1024
const ones = n => (1n << BigInt(n)) - 1n
function asNumber(n) {
if (n > Number.MAX_SAFE_INTEGER)
function readInt64(buffer, index) {
const n = buffer.readBigInt64LE(index * 8 /* size of an int64 in bytes */)
if (n > Number.MAX_SAFE_INTEGER) {
throw new Error(`can't handle ${n} ${Number.MAX_SAFE_INTEGER} ${n & 0x00000000ffffffffn}`)
return Number(n)
}
const readInt64 = (buffer, index) => asNumber(buffer.readBigInt64LE(index * 8))
/**
* @returns {{topNibble: number, low60: bigint}} topNibble is the first 4 bits of the 64 bits entry, indexPart is the remaining 60 bits
*/
function readTaggedEntry(buffer, index) {
const entry = buffer.readBigInt64LE(index * 8)
return { topNibble: Number(entry >> 60n), low60: entry & ones(60) }
}
function readSeSparseDir(buffer, index) {
const { topNibble, low60 } = readTaggedEntry(buffer, index)
return { type: topNibble, tableIndex: asNumber(low60) }
}
function readSeSparseTable(buffer, index) {
const { topNibble, low60 } = readTaggedEntry(buffer, index)
// https://lists.gnu.org/archive/html/qemu-block/2019-06/msg00934.html
const topIndexPart = low60 >> 48n // bring the top 12 bits down
const bottomIndexPart = (low60 & ones(48)) << 12n // bring the bottom 48 bits up
return { type: topNibble, grainIndex: asNumber(bottomIndexPart | topIndexPart) }
}
return +n
}
export default class VhdEsxiSeSparse extends VhdAbstract {
@@ -61,22 +25,27 @@ export default class VhdEsxiSeSparse extends VhdAbstract {
#header
#footer
#grainIndex // Map blockId => []
#grainDirectory
// as we will read all grain with data with load everything in memory
// in theory , that can be 512MB of data for a 2TB fully allocated
// but our use case is to transfer a relatively small diff
// and random access is expensive in HTTP, and migration is a one time cors
// so let's go with naive approach, and future me will have to handle a more
// clever approach if necessary
// grain at zero won't be stored
#grainDirOffsetBytes
#grainDirSizeBytes
#grainTableOffsetBytes
#grainOffsetBytes
#grainMap = new Map()
#grainSize
#grainTableSize
#grainTableOffset
#grainOffset
static async open(esxi, datastore, path, parentVhd, opts) {
const vhd = new VhdEsxiSeSparse(esxi, datastore, path, parentVhd, opts)
await vhd.readHeaderAndFooter()
return vhd
}
get path() {
return this.#path
}
constructor(esxi, datastore, path, parentVhd, { lookMissingBlockInParent = true } = {}) {
super()
this.#esxi = esxi
@@ -94,149 +63,156 @@ export default class VhdEsxiSeSparse extends VhdAbstract {
return this.#footer
}
async #readGrain(start, length = 4 * 1024) {
return (await this.#esxi.download(this.#datastore, this.#path, `${start}-${start + length - 1}`)).buffer()
}
containsBlock(blockId) {
notEqual(this.#grainIndex, undefined, "bat must be loaded to use contain blocks'")
notEqual(this.#grainDirectory, undefined, "bat must be loaded to use contain blocks'")
// a grain table is 4096 entries of 4KB
// a grain table cover 8 vhd blocks
// grain table always exists in sespars
// depending on the paramters we also look into the parent data
return (
this.#grainIndex.get(blockId) !== undefined ||
this.#grainDirectory.readInt32LE(blockId * 4) !== 0 ||
(this.#lookMissingBlockInParent && this.#parentVhd.containsBlock(blockId))
)
}
async #read(start, length) {
const buffer = await (
await this.#esxi.download(this.#datastore, this.#path, `${start}-${start + length - 1}`)
).buffer()
strictEqual(buffer.length, length)
return buffer
async #read(start, end) {
return (await this.#esxi.download(this.#datastore, this.#path, `${start}-${end}`)).buffer()
}
async readHeaderAndFooter() {
const vmdkHeaderBuffer = await this.#read(0, 2048)
const buffer = await this.#read(0, 2048)
strictEqual(buffer.readBigInt64LE(0), 0xcafebaben)
strictEqual(vmdkHeaderBuffer.readBigInt64LE(0), 0xcafebaben)
strictEqual(readInt64(vmdkHeaderBuffer, 1), 0x200000001) // version 2.1
strictEqual(readInt64(buffer, 1), 0x200000001) // version 2.1
this.#grainDirOffsetBytes = readInt64(vmdkHeaderBuffer, 16) * 512
// console.log('grainDirOffsetBytes', this.#grainDirOffsetBytes)
this.#grainDirSizeBytes = readInt64(vmdkHeaderBuffer, 17) * 512
// console.log('grainDirSizeBytes', this.#grainDirSizeBytes)
const capacity = readInt64(buffer, 2)
const grain_size = readInt64(buffer, 3)
const grainSizeSectors = readInt64(vmdkHeaderBuffer, 3)
const grainSizeBytes = grainSizeSectors * 512 // 8 sectors = 4KB default
strictEqual(grainSizeBytes, GRAIN_SIZE_BYTES) // we only support default grain size
const grain_tables_offset = readInt64(buffer, 18)
const grain_tables_size = readInt64(buffer, 19)
this.#grainOffset = readInt64(buffer, 24)
this.#grainTableOffsetBytes = readInt64(vmdkHeaderBuffer, 18) * 512
// console.log('grainTableOffsetBytes', this.#grainTableOffsetBytes)
this.#grainSize = grain_size * 512 // 8 sectors / 4KB default
this.#grainTableOffset = grain_tables_offset * 512
this.#grainTableSize = grain_tables_size * 512
const grainTableCount = (readInt64(vmdkHeaderBuffer, 4) * 512) / 8 // count is the number of 64b entries in each tables
// console.log('grainTableCount', grainTableCount)
strictEqual(grainTableCount, GRAIN_TABLE_COUNT) // we only support tables of 4096 entries (default)
this.#grainOffsetBytes = readInt64(vmdkHeaderBuffer, 24) * 512
// console.log('grainOffsetBytes', this.#grainOffsetBytes)
const sizeBytes = readInt64(vmdkHeaderBuffer, 2) * 512
// console.log('sizeBytes', sizeBytes)
const nbBlocks = Math.ceil(sizeBytes / VHD_BLOCK_SIZE_BYTES)
this.#header = unpackHeader(createHeader(nbBlocks))
const geometry = _computeGeometryForSize(sizeBytes)
const size = capacity * grain_size * 512
this.#header = unpackHeader(createHeader(Math.ceil(size / (4096 * 512))))
const geometry = _computeGeometryForSize(size)
const actualSize = geometry.actualSize
this.#footer = unpackFooter(
createFooter(sizeBytes, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, DISK_TYPES.DYNAMIC)
createFooter(actualSize, Math.floor(Date.now() / 1000), geometry, FOOTER_SIZE, this.#parentVhd.footer.diskType)
)
}
async readBlockAllocationTable() {
this.#grainIndex = new Map()
const CHUNK_SIZE = 64 * 512
const tableSizeBytes = GRAIN_TABLE_COUNT * 8
const grainDirBuffer = await this.#read(this.#grainDirOffsetBytes, this.#grainDirSizeBytes)
// read the grain dir ( first level )
for (let grainDirIndex = 0; grainDirIndex < grainDirBuffer.length / 8; grainDirIndex++) {
const { type: grainDirType, tableIndex } = readSeSparseDir(grainDirBuffer, grainDirIndex)
if (grainDirType === SE_SPARSE_DIR_NON_ALLOCATED) {
// no grain table allocated at all in this grain dir
strictEqual(this.#grainTableSize % CHUNK_SIZE, 0)
for (let chunkIndex = 0, grainIndex = 0; chunkIndex < this.#grainTableSize / CHUNK_SIZE; chunkIndex++) {
process.stdin.write('.')
const start = chunkIndex * CHUNK_SIZE + this.#grainTableOffset
const end = start + 4096 * 8 - 1
const buffer = await this.#read(start, end)
for (let indexInChunk = 0; indexInChunk < 4096; indexInChunk++) {
const entry = buffer.readBigInt64LE(indexInChunk * 8)
switch (entry) {
case 0n: // not allocated, go to parent
break
case 1n: // unmapped
break
}
if (entry > 3n) {
this.#grainMap.set(grainIndex)
grainIndex++
}
}
}
// read grain directory and the grain tables
const nbBlocks = this.header.maxTableEntries
this.#grainDirectory = await this.#read(2048 /* header length */, 2048 + nbBlocks * 4 - 1)
}
// we're lucky : a grain address can address exacty a full block
async readBlock(blockId) {
notEqual(this.#grainDirectory, undefined, 'grainDirectory is not loaded')
const sectorOffset = this.#grainDirectory.readInt32LE(blockId * 4)
const buffer = (await this.#parentVhd.readBlock(blockId)).buffer
if (sectorOffset === 0) {
strictEqual(this.#lookMissingBlockInParent, true, "shouldn't have empty block in a delta alone")
return {
id: blockId,
bitmap: buffer.slice(0, 512),
data: buffer.slice(512),
buffer,
}
}
const offset = sectorOffset * 512
const graintable = await this.#read(offset, offset + 4096 * 4 /* grain table length */ - 1)
strictEqual(graintable.length, 4096 * 4)
// we have no guaranty that data are order or contiguous
// let's construct ranges to limit the number of queries
let rangeStart, offsetStart, offsetEnd
const changeRange = async (index, offset) => {
if (offsetStart !== undefined) {
// if there was a
if (offset === offsetEnd) {
offsetEnd++
return
}
const grains = await this.#read(offsetStart * 512, offsetEnd * 512 - 1)
grains.copy(buffer, (rangeStart + 1) /* block bitmap */ * 512)
}
if (offset) {
// we're at the beginning of a range present in the file
rangeStart = index
offsetStart = offset
offsetEnd = offset + 1
} else {
// we're at the beginning of a range from the parent or empty
rangeStart = undefined
offsetStart = undefined
offsetEnd = undefined
}
}
for (let i = 0; i < graintable.length / 4; i++) {
const grainOffset = graintable.readInt32LE(i * 4)
if (grainOffset === 0) {
await changeRange()
// from parent
continue
}
strictEqual(grainDirType, SE_SPARSE_DIR_ALLOCATED)
// read the corresponding grain table ( second level )
const grainTableBuffer = await this.#read(
this.#grainTableOffsetBytes + tableIndex * tableSizeBytes,
tableSizeBytes
)
// offset in bytes if >0, grainType if <=0
let grainOffsets = []
let blockId = grainDirIndex * 8
const addGrain = val => {
grainOffsets.push(val)
// 4096 block of 4Kb per dir entry =>16MB/grain dir
// 1 block = 2MB
// 512 grain => 1 block
// 8 block per dir entry
if (grainOffsets.length === 512) {
this.#grainIndex.set(blockId, grainOffsets)
grainOffsets = []
blockId++
}
if (grainOffset === 1) {
await changeRange()
// this is a emptied grain, no data, don't look into parent
buffer.fill(0, (i + 1) /* block bitmap */ * 512)
}
for (let grainTableIndex = 0; grainTableIndex < grainTableBuffer.length / 8; grainTableIndex++) {
const { type: grainType, grainIndex } = readSeSparseTable(grainTableBuffer, grainTableIndex)
if (grainType === SE_SPARSE_GRAIN_ALLOCATED) {
// this is ok in 32 bits int with VMDK smaller than 2TB
const offsetByte = grainIndex * GRAIN_SIZE_BYTES + this.#grainOffsetBytes
addGrain(offsetByte)
} else {
// multiply by -1 to differenciate type and offset
// no offset can be zero
addGrain(-grainType)
}
}
strictEqual(grainOffsets.length, 0)
}
}
async readBlock(blockId) {
let changed = false
const parentBlock = await this.#parentVhd.readBlock(blockId)
const parentBuffer = parentBlock.buffer
const grainOffsets = this.#grainIndex.get(blockId) // may be undefined if the child contains block and lookMissingBlockInParent=true
const EMPTY_GRAIN = Buffer.alloc(GRAIN_SIZE_BYTES, 0)
for (const index in grainOffsets) {
const value = grainOffsets[index]
let data
if (value > 0) {
// it's the offset in byte of a grain type SE_SPARSE_GRAIN_ALLOCATED
data = await this.#read(value, GRAIN_SIZE_BYTES)
} else {
// back to the real grain type
const type = value * -1
switch (type) {
case SE_SPARSE_GRAIN_ZERO:
case SE_SPARSE_GRAIN_UNMAPPED:
data = EMPTY_GRAIN
break
case SE_SPARSE_GRAIN_NON_ALLOCATED:
/* from parent */
break
default:
throw new Error(`can't handle grain type ${type}`)
}
}
if (data) {
changed = true
data.copy(parentBuffer, index * GRAIN_SIZE_BYTES + 512 /* block bitmap */)
if (grainOffset > 1) {
// non empty grain
await changeRange(i, grainOffset)
}
}
// no need to copy if data all come from parent
return changed
? {
id: blockId,
bitmap: parentBuffer.slice(0, 512),
data: parentBuffer.slice(512),
buffer: parentBuffer,
}
: parentBlock
await changeRange()
return {
id: blockId,
bitmap: buffer.slice(0, 512),
data: buffer.slice(512),
buffer,
}
}
}

Some files were not shown because too many files have changed in this diff Show More