Compare commits

..

77 Commits

Author SHA1 Message Date
Thierry
57a57f4391 feat(lite): rework subscriptions 2023-08-03 16:21:49 +02:00
Thierry Goettelmann
dfd5f6882f feat(lite): enhance typings for improved type safety (#6949) 2023-08-03 11:33:29 +02:00
Julien Fontanet
7214016338 fix(xo-server/_authenticateUser): don't use registerUser()
Introduced by 99605bf18
2023-08-03 10:25:15 +02:00
Julien Fontanet
606e3c4ce5 docs(xo-server-test-plugin): explain configurationPresets 2023-08-03 10:21:15 +02:00
Julien Fontanet
fb04d3d25d docs(xo-server-test-plugin): show title/description for settings 2023-08-03 10:20:51 +02:00
Julien Fontanet
db8c042131 fix(xo-web/plugins): merge preset with existing config
Instead of replacing it.
2023-08-03 10:14:07 +02:00
Julien Fontanet
fd9005fba8 fix(xo-web/plugins): don't disable presets when config not edited 2023-08-03 10:12:52 +02:00
Julien Fontanet
2d25413b8d fix(xo-server-auth-ldap): mark userIdAttribute as required
It can no longer be ommited since 99605bf18
2023-08-03 09:56:33 +02:00
Julien Fontanet
035679800a chore(xo-server-auth-ldap): defaults are merged automatically by xo-server
Related to 8c7d25424
2023-08-03 09:53:01 +02:00
Thierry Goettelmann
abd0a3035a feat(lite/component): created UiResources + UiResource (#6932) 2023-08-01 11:18:10 +02:00
Julien Fontanet
d307730c68 feat: release 5.85.0 2023-07-31 17:06:25 +02:00
Julien Fontanet
1b44de4958 feat(xo-server): 5.120.2 2023-07-31 16:52:03 +02:00
Julien Fontanet
ec78a1ce8b feat(xo-web): 5.122.2 2023-07-31 16:32:42 +02:00
Julien Fontanet
19c82ab30d feat(xo-server): 5.120.1 2023-07-31 16:32:41 +02:00
Julien Fontanet
9986f3fb18 fix(xo-web/removeUserAuthProvider): notify on error
Introduced by 52cf2d151
2023-07-31 16:29:09 +02:00
Julien Fontanet
d24e9c093d fix(xo-server/updaterUser): fix current user auth protection
Introduced by 2d52aee95
2023-07-31 16:28:16 +02:00
Julien Fontanet
70c8b24fac feat(xo-web): 5.122.1 2023-07-31 15:58:15 +02:00
Julien Fontanet
9c9c11104b feat(xo-server-auth-google): 0.3.0 2023-07-31 15:58:05 +02:00
Julien Fontanet
cba90b27f4 feat(xo-server-auth-github): 0.3.0 2023-07-31 15:57:44 +02:00
Julien Fontanet
46cbced570 feat(xo-server): 5.120.0 2023-07-31 15:56:48 +02:00
Julien Fontanet
52cf2d1514 feat(xo-web/settings/users): auth providers can be removed 2023-07-31 15:48:49 +02:00
Julien Fontanet
e51351be8d feat(xo-server/api): user.removeAuthProvider 2023-07-31 15:48:49 +02:00
Julien Fontanet
2a42e0ff94 feat(xo-web/users): display users auth providers
Related to zammad#16318
2023-07-31 15:48:49 +02:00
Julien Fontanet
3a824a2bfc fix(xo-server/updateUser): check password xor auth providers 2023-07-31 15:48:49 +02:00
Julien Fontanet
fc1c809a18 fix(xo-server): remove password when sign in with provider
Password can no longer be used/edited when an auth provider is registered.

For security concerns, this useless password should be removed from the database.
2023-07-31 15:48:49 +02:00
Julien Fontanet
221cd40199 fix(xo-server/updateUser): can remove password 2023-07-31 15:48:49 +02:00
Julien Fontanet
aca19d9a81 fix(xo-server): user pass disabled when associated auth providers 2023-07-31 15:48:49 +02:00
Julien Fontanet
0601bbe18d fix(xo-server/recover-account): remove all auth providers 2023-07-31 15:48:49 +02:00
Julien Fontanet
2d52aee952 fix(xo-server/updateUser): can remove all auth providers with null 2023-07-31 15:48:49 +02:00
Julien Fontanet
99605bf185 feat(xo-server/registerUser): completely disable 2023-07-31 15:48:49 +02:00
Julien Fontanet
91b19d9bc4 feat(xo-server-auth-google): use registerUser2 2023-07-31 15:48:49 +02:00
Julien Fontanet
562401ebe4 feat(xo-server-auth-github): use registerUser2 2023-07-31 15:48:49 +02:00
Julien Fontanet
6fd2f2610d fix(xo-web/new-vm): don't send device in VIFs
Introduced by 6ae19b064

Fixes #6960
2023-07-31 09:34:30 +02:00
Gabriel Gunullu
6ae19b0640 fix(xo-web/new-vm): list VIFs ordered by device (#6944)
Fixes zammad#15920
2023-07-28 18:51:48 +02:00
Pierre Donias
6b936d8a8c feat(lite): 0.1.2 (#6958) 2023-07-28 17:37:07 +02:00
Thierry Goettelmann
8f2cfaae00 feat(lite): open console in new window (#6868)
Add a link to open the console in a new window.
2023-07-28 14:04:06 +02:00
Thierry Goettelmann
5c215e1a8a feat(lite/console): rework VM console page (#6863)
Rework the VM Console page to be better aligned with Figma mockup.
- Spinner while loading the console
- Added the "monitor" image with correct message when VM is powered off
- Better screen space usage
2023-07-28 11:39:33 +02:00
Pierre Donias
e3cb98124f feat: technical release (#6956) 2023-07-28 10:05:26 +02:00
Julien Fontanet
90c3319880 feat(xo-web/backup/file-restore): add export format selection 2023-07-27 17:22:58 +02:00
Julien Fontanet
348db876d2 feat(xo-server/backupNg.fetchFiles): add format param 2023-07-27 17:22:58 +02:00
Julien Fontanet
408fd7ec03 feat(proxy/backup.fetchPartitionFiles): add format param 2023-07-27 17:22:58 +02:00
Julien Fontanet
1fd84836b1 feat(backups/fetchPartitionFiles): add tgz (tar+gzip) support
Around 6 times faster than ZIP export.
2023-07-27 17:22:58 +02:00
Julien Fontanet
522204795f fix(backups/fetchPartitionFiles): rewrite ZIP creation
It's now sequential which leads to better performance and less memory consumption.

Empty directories are now included and all entries have correct mode and modification time.
2023-07-27 17:22:58 +02:00
Julien Fontanet
e29c422ac9 fix(xo-server/_handleHttpRequest): use pipeline between result and response
Properly closes one stream if the other is destroyed.
2023-07-27 17:22:58 +02:00
Florent BEAUCHAMP
152cf09b7e feat(vmware-explorer): handle sesparse files (#6909) 2023-07-27 17:15:29 +02:00
Pierre Donias
ff728099dc docs(netbox): update screenshot (#6955) 2023-07-27 17:13:57 +02:00
Mathieu
706d94221d feat(xo-server/pool/rpu): avoid unnecessary VMs migration (#6943) 2023-07-27 17:12:31 +02:00
Gabriel Gunullu
340e9af7f4 fix(backups): handle incremental replication to multiple SRs (#6811)
Fix matching previous replications when multiple SRs.

Fixes #6582
2023-07-27 17:09:15 +02:00
Pierre Donias
40e536ba61 feat(xo-server-netbox): synchronize VM platform (#6954)
See Zammad#12478
See https://xcp-ng.org/forum/topic/6902
2023-07-27 16:59:50 +02:00
Thierry Goettelmann
fd4c56c8c2 feat(lite/pool): add tasks to Pool Dashboard (#6713)
Other updates:
- Move pending/finished tasks logic to store subscription
- Add `count` prop to `UiCardTitle`
- Add "No tasks" message on Task table if empty
- Make the `finishedTasks` prop optional
- Add ability to have full width dashboard cards
2023-07-27 16:23:52 +02:00
Thierry Goettelmann
20d04ba956 feat(lite): dynamic page title (#6853)
See #6793

ℹ️ This PR adds a `pageTitleStore` which allows defining the current page title
according to 3 parts: an object, a string, and a count. Each part is optional.

 The page title is **reactive** when function argument is a `Ref`, a `Computed`
or a getter. For example, when updating a VM name, the page title will be
updated in every tabs.

🪄 Each title part is automatically unset when the component that set it is
unmounted.
2023-07-27 11:41:33 +02:00
Pierre Donias
3b1bcc67ae feat(xo-server-netbox): rewrite (#6950)
Fixes #6038, Fixes #6135, Fixes #6024, Fixes #6036
See https://xcp-ng.org/forum/topic/6070
See zammad#5695
See https://xcp-ng.org/forum/topic/6149
See https://xcp-ng.org/forum/topic/6332

Complete rewrite of the plugin. Main functional changes:
- Synchronize VM description
- Fix duplicated VMs in Netbox after disconnecting one pool
- Migrating a VM from one pool to another keeps VM data added manually
- Fix largest IP prefix being picked instead of smallest
- Fix synchronization not working if some pools are unavailable
- Better error messages
2023-07-27 10:07:26 +02:00
Julien Fontanet
1add3fbf9d fix(yarn.lock): refresh
Introduced by 1c23bd5ff
2023-07-26 13:36:28 +02:00
Julien Fontanet
97f0759de0 feat(mixins/Hooks): warning every 5s if listener still running
This helps diagnosticate issues when a hook is stuck.'
2023-07-25 16:41:29 +02:00
Julien Fontanet
005ab47d9b fix(xo-web): clear token on authentication failure (#6937)
This prevents infinite refreshes when the token is deemed valid by the server
but the authentication failed for any reasons.
2023-07-25 09:49:11 +02:00
Julien Fontanet
14a0caa4c6 fix(xo-web/xoa/licenses): fix message *go TO* 2023-07-25 09:43:11 +02:00
Florent BEAUCHAMP
1c23bd5ff7 feat(read-chunk/readChunkStrict): attach read chunk to error if small text (#6940) 2023-07-20 17:01:26 +02:00
Julien Fontanet
49c161b17a fix(xo-server,xo-web): send version when probing NFS SR
Reported by @benjamreis
2023-07-20 16:46:18 +02:00
Gabriel Gunullu
18dce3fce6 test(fs): fix wrong encryption (#6945) 2023-07-20 16:32:09 +02:00
Julien Fontanet
d6fc86b6bc chore(xo-server-transport-xmpp): remove old dep node-xmpp-client
Fix possibly #6942
2023-07-20 10:54:52 +02:00
Florent BEAUCHAMP
61d960d4b1 fix(vmware-explorer): handle snapshot of 1TB+ disks 2023-07-20 10:25:28 +02:00
Florent BEAUCHAMP
02d3465832 feat(vmware-explorer): don't transform stream for raw import in thick mode 2023-07-20 10:25:28 +02:00
Florent BEAUCHAMP
4bbadc9515 feat(vmware-explorer): improve import
- use one stream instead of per block queries if possible
- retry block reading if failing
- handle unaligned end block
2023-07-20 10:25:28 +02:00
Florent BEAUCHAMP
78586291ca fix(vmware-explorer): better disk size computation 2023-07-20 10:25:28 +02:00
Florent BEAUCHAMP
945dec94bf feat(vmware-explorer): retry connection to ESXi 2023-07-20 10:25:28 +02:00
Julien Fontanet
003140d96b test(nbd-client): fix issues introduced by conversion to ESM
Introduced by 7c80d0c1e
2023-07-19 23:09:48 +02:00
Julien Fontanet
363d7cf0d0 fix(node-vsphere-soap): add missing files
Introduced by f0c94496b
2023-07-19 23:02:34 +02:00
Julien Fontanet
f0c94496bf chore(node-vsphere-soap): convert to ESM
BREAKING CHANGE
2023-07-19 11:03:56 +02:00
Julien Fontanet
de217eabd9 test(nbd-client): fix issues introduced by conversion to ESM
Introduced by 7c80d0c1e
2023-07-19 11:02:12 +02:00
Julien Fontanet
7c80d0c1e1 chore(nbd-client): convert to ESM
BREAKING CHANGE
2023-07-19 10:46:05 +02:00
Julien Fontanet
9fb749b1db chore(fuse-vhd): convert to ESM
BREAKING CHANGE
2023-07-19 10:13:35 +02:00
Julien Fontanet
ad9c59669a chore: update dev deps 2023-07-19 10:11:30 +02:00
Julien Fontanet
76a038e403 fix(xo-web): fix doc link to incremental/key backup interval 2023-07-19 09:48:23 +02:00
Julien Fontanet
0e12072922 fix(xo-server/pool.mergeInto): fix IPv6 handling 2023-07-18 17:28:39 +02:00
Julien Fontanet
158a8e14a2 chore(xen-api): expose bracketless IPv6 as hostnameRaw 2023-07-18 17:28:38 +02:00
Julien Fontanet
0c97910349 chore(xo-server): remove unused _mounts property
Introduced by 5c9a47b6b
2023-07-18 11:14:24 +02:00
Florent BEAUCHAMP
8347ac6ed8 fix(xo-server/xapi-stats): simplify caching (#6920)
Following #6903

- change cache system per object => per host
- update cache at the beginning of the query to handle race conditions leading to duplicate requests
- remove concurrency limit (was leading to a huge backlog of queries, and response handling is quite fast)
2023-07-18 09:47:39 +02:00
165 changed files with 5169 additions and 4153 deletions

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@vates/fuse-vhd",
"version": "1.0.0",
"version": "2.0.0",
"license": "ISC",
"private": false,
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/fuse-vhd",
@@ -15,8 +15,9 @@
"url": "https://vates.fr"
},
"engines": {
"node": ">=10.0"
"node": ">=14"
},
"main": "./index.mjs",
"dependencies": {
"fuse-native": "^2.2.6",
"lru-cache": "^7.14.0",

View File

@@ -1,42 +0,0 @@
'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

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

View File

@@ -13,17 +13,18 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "1.2.1",
"version": "2.0.0",
"engines": {
"node": ">=14.0"
},
"main": "./index.mjs",
"dependencies": {
"@vates/async-each": "^1.0.0",
"@vates/read-chunk": "^1.1.1",
"@vates/read-chunk": "^1.2.0",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.6.0",
"promise-toolbox": "^0.21.0",
"xen-api": "^1.3.3"
"xen-api": "^1.3.4"
},
"devDependencies": {
"tap": "^16.3.0",
@@ -31,6 +32,6 @@
},
"scripts": {
"postversion": "npm publish --access public",
"test-integration": "tap --lines 97 --functions 95 --branches 74 --statements 97 tests/*.integ.js"
"test-integration": "tap --lines 97 --functions 95 --branches 74 --statements 97 tests/*.integ.mjs"
}
}

View File

@@ -1,13 +1,12 @@
'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')
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'
const FILE_SIZE = 10 * 1024 * 1024

View File

@@ -1,4 +1,3 @@
'use strict'
/*
node-vsphere-soap
@@ -12,17 +11,18 @@
*/
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
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
// Client class
// inherits from EventEmitter
// possible events: connect, error, ready
function Client(vCenterHostname, username, password, sslVerify) {
export function Client(vCenterHostname, username, password, sslVerify) {
this.status = 'disconnected'
this.reconnectCount = 0
@@ -228,4 +228,3 @@ function _soapErrorHandler(self, emitter, command, args, err) {
}
// end
exports.Client = Client

View File

@@ -1,8 +1,8 @@
{
"name": "@vates/node-vsphere-soap",
"version": "1.0.0",
"version": "2.0.0",
"description": "interface to vSphere SOAP/WSDL from node for interfacing with vCenter or ESXi, forked from node-vsphere-soap",
"main": "lib/client.js",
"main": "lib/client.mjs",
"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": ">=8.10"
"node": ">=14"
},
"scripts": {
"postversion": "npm publish --access public"

View File

@@ -1,15 +1,11 @@
'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
const vCenterTestCreds = {
export const vCenterTestCreds = {
vCenterIP: 'vcsa',
vCenterUser: 'vcuser',
vCenterPassword: 'vcpw',
vCenter: true,
}
exports.vCenterTestCreds = vCenterTestCreds

View File

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

View File

@@ -1,6 +1,7 @@
'use strict'
const assert = require('assert')
const isUtf8 = require('isutf8')
/**
* Read a chunk of data from a stream.
@@ -81,6 +82,13 @@ 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,12 +102,37 @@ describe('readChunkStrict', function () {
assert.strictEqual(error.chunk, undefined)
})
it('throws if stream ends with not enough data', async () => {
it('throws if stream ends with not enough data, utf8', 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 () {
@@ -134,6 +159,16 @@ 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 () {
@@ -144,4 +179,9 @@ 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

@@ -19,7 +19,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "1.1.1",
"version": "1.2.0",
"engines": {
"node": ">=8.10"
},
@@ -33,5 +33,8 @@
},
"devDependencies": {
"test": "^3.2.1"
},
"dependencies": {
"isutf8": "^4.0.0"
}
}

View File

@@ -7,7 +7,7 @@
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/backups": "^0.39.0",
"@xen-orchestra/backups": "^0.40.0",
"@xen-orchestra/fs": "^4.0.1",
"filenamify": "^4.1.0",
"getopts": "^2.2.5",
@@ -27,7 +27,7 @@
"scripts": {
"postversion": "npm publish --access public"
},
"version": "1.0.9",
"version": "1.0.10",
"license": "AGPL-3.0-or-later",
"author": {
"name": "Vates SAS",

View File

@@ -5,7 +5,7 @@ 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, normalize, resolve } from 'node:path'
import { dirname, join, resolve } from 'node:path'
import { execFile } from 'child_process'
import { mount } from '@vates/fuse-vhd'
import { readdir, lstat } from 'node:fs/promises'
@@ -18,6 +18,7 @@ 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'
@@ -41,20 +42,23 @@ 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 addZipEntries(zip, realBasePath, virtualBasePath, relativePaths) {
for (const relativePath of relativePaths) {
const realPath = join(realBasePath, relativePath)
const virtualPath = join(virtualBasePath, relativePath)
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 stats = await lstat(realPath)
const { mode, mtime } = stats
const opts = { mode, mtime }
if (stats.isDirectory()) {
zip.addEmptyDirectory(virtualPath, opts)
await addZipEntries(zip, realPath, virtualPath, await readdir(realPath))
} else if (stats.isFile()) {
zip.addFile(realPath, virtualPath, opts)
}
}
}
@@ -182,17 +186,6 @@ 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) {
@@ -209,15 +202,24 @@ export class RemoteAdapter {
})
}
fetchPartitionFiles(diskId, partitionId, paths) {
fetchPartitionFiles(diskId, partitionId, paths, format) {
const { promise, reject, resolve } = pDefer()
Disposable.use(
async function* () {
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
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') {
const zip = new ZipFile()
await addZipEntries(zip, path, '', paths.map(makeRelative))
zip.end()
;({ outputStream } = zip)
} else {
throw new Error('unsupported format ' + format)
}
resolve(outputStream)
await fromEvent(outputStream, 'end')
}.bind(this)
@@ -824,8 +826,6 @@ decorateMethodsWith(RemoteAdapter, {
debounceResourceFactory,
]),
_usePartitionFiles: Disposable.factory,
getDisk: compose([Disposable.factory, [deduped, diskId => [diskId]], debounceResourceFactory]),
getPartition: Disposable.factory,

View File

@@ -16,6 +16,8 @@ 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) {
@@ -157,7 +159,10 @@ 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)
baseVm = find(
xapi.objects.all,
obj => (obj = obj.other_config) && obj[TAG_COPY_SRC] === remoteBaseVmUuid && obj[TAG_BACKUP_SR] === sr.$id
)
if (!baseVm) {
throw new Error(`could not find the base VM (copy of ${remoteBaseVmUuid})`)

View File

@@ -8,7 +8,7 @@
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"version": "0.39.0",
"version": "0.40.0",
"engines": {
"node": ">=14.18"
},
@@ -23,8 +23,8 @@
"@vates/compose": "^2.1.0",
"@vates/decorate-with": "^2.0.0",
"@vates/disposable": "^0.1.4",
"@vates/fuse-vhd": "^1.0.0",
"@vates/nbd-client": "^1.2.1",
"@vates/fuse-vhd": "^2.0.0",
"@vates/nbd-client": "^2.0.0",
"@vates/parse-duration": "^0.1.1",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/fs": "^4.0.1",
@@ -40,9 +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.4",
"yazl": "^2.5.1"
},
"devDependencies": {
@@ -53,7 +54,7 @@
"tmp": "^0.2.1"
},
"peerDependencies": {
"@xen-orchestra/xapi": "^2.2.1"
"@xen-orchestra/xapi": "^3.0.0"
},
"license": "AGPL-3.0-or-later",
"author": {

View File

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

View File

@@ -29,7 +29,7 @@
"@vates/async-each": "^1.0.0",
"@vates/coalesce-calls": "^0.1.0",
"@vates/decorate-with": "^2.0.0",
"@vates/read-chunk": "^1.1.1",
"@vates/read-chunk": "^1.2.0",
"@xen-orchestra/log": "^0.6.0",
"bind-property-descriptor": "^2.0.0",
"decorator-synchronized": "^0.6.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-gmc"}`)
await fs.writeFile(`${dir}/encryption.json`, `{"algorithm": "aes-256-gcm"}`)
await fs.writeFile(`${dir}/metadata.json`, encryptor.encryptData(`{"random": "NOTSORANDOM"}`))
// remote is now non empty : can't modify key anymore

View File

@@ -2,8 +2,11 @@
## **next**
## **0.1.2** (2023-07-28)
- 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)

View File

@@ -4,6 +4,53 @@ All collections of `XenApiRecord` are stored inside the `xapiCollectionStore`.
To retrieve a collection, invoke `useXapiCollectionStore().get(type)`.
## TL;DR - How to extend a subscription
_**Note:** Once the extension grows in complexity, it's recommended to create a dedicated file for it (e.g. `host.extension.ts` for `host.store.ts`)._
```typescript
type MyExtension1 = Extension<{ propA: string }>;
type MyExtension2 = Extension<{ propB: string }, { withB: true }>;
type Extensions = [
XenApiRecordExtension<XenApiHost>, // If needed
DeferExtension, // If needed
MyExtension1,
MyExtension2
];
export const useHostStore = defineStore("host", () => {
const hostCollection = useXapiCollectionStore().get("console");
const subscribe = <O extends Options<Extensions>>(options?: O) => {
const originalSubscription = hostCollection.subscribe(options);
const myExtension1: PartialSubscription<MyExtension1> = {
propA: "Hello",
};
const myExtension2: PartialSubscription<MyExtension2> | undefined =
options?.withB
? {
propB: "World",
}
: undefined;
return {
...originalSubscription,
...myExtension1,
...myExtension2,
};
};
return {
...hostCollection,
subscribe,
};
});
```
## Accessing a collection
In order to use a collection, you'll need to subscribe to it.
@@ -40,71 +87,102 @@ export const useConsoleStore = defineStore("console", () =>
To extend the base Subscription, you'll need to override the `subscribe` method.
For that, you can use the `createSubscribe<XenApiRecord, Extensions>((options) => { /* ... */})` helper.
### Define the extensions
Subscription extensions are defined as `(object | [object, RequiredOptions])[]`.
Subscription extensions are defined as a simple extension (`Extension<object>`) or as a conditional
extension (`Extension<object, object>`).
When using a tuple (`[object, RequiredOptions]`), the corresponding `object` type will be added to the subscription if
the `RequiredOptions` for that tuple are present in the options passed to `subscribe`.
When using a conditional extension, the corresponding `object` type will be added to the subscription only if
the the options passed to `subscribe(options)` do match the second argument or `Extension`.
There is two existing extensions:
- `XenApiRecordExtension<T extends XenApiRecord>`: a simple extension which defined all the base
properties and methods (`records`, `getByOpaqueRef`, `getByUuid`, etc.)
- `DeferExtension`: a conditional extension which add the `start` and `isStarted` properties if the
`immediate` option is set to `false`.
```typescript
// Always present extension
type DefaultExtension = {
type PropABExtension = Extension<{
propA: string;
propB: ComputedRef<number>;
};
}>;
// Conditional extension 1
type FirstConditionalExtension = [
type PropCExtension = Extension<
{ propC: ComputedRef<string> }, // <- This signature will be added
{ optC: string } // <- if this condition is met
];
>;
// Conditional extension 2
type SecondConditionalExtension = [
type PropDExtension = Extension<
{ propD: () => void }, // <- This signature will be added
{ optD: number } // <- if this condition is met
];
>;
// Create the extensions array
type Extensions = [
DefaultExtension,
FirstConditionalExtension,
SecondConditionalExtension
XenApiRecordExtension<XenApiHost>,
DeferExtension,
PropABExtension,
PropCExtension,
PropDExtension
];
```
### Define the subscription
### Define the `subscribe` method
You can then create the `subscribe` function with the help of `Options` and `Subscription` helper types.
This will allow to get correct completion and type checking for the `options` argument, and to get the correct return
type based on passed options.
```typescript
const subscribe = <O extends Options<Extensions>>(options?: O) => {
return {
// ...
} as Subscription<Extensions, O>;
};
```
### Extend the subscription
The `PartialSubscription` type will help to define and check the data to add to subscription for each extension.
```typescript
export const useConsoleStore = defineStore("console", () => {
const consoleCollection = useXapiCollectionStore().get("console");
const subscribe = createSubscribe<XenApiConsole, Extensions>((options) => {
const subscribe = <O extends Options<Extensions>>(options?: O) => {
const originalSubscription = consoleCollection.subscribe(options);
const extendedSubscription = {
const propABSubscription: PartialSubscription<PropABExtension> = {
propA: "Some string",
propB: computed(() => 42),
};
const propCSubscription = options?.optC !== undefined && {
propC: computed(() => "Some other string"),
};
const propCSubscription: PartialSubscription<PropCExtension> | undefined =
options?.optC !== undefined
? {
propC: computed(() => "Some other string"),
}
: undefined;
const propDSubscription = options?.optD !== undefined && {
propD: () => console.log("Hello"),
};
const propDSubscription: PartialSubscription<PropDExtension> | undefined =
options?.optD !== undefined
? {
propD: () => console.log("Hello"),
}
: undefined;
return {
...originalSubscription,
...extendedSubscription,
...propABSubscription,
...propCSubscription,
...propDSubscription,
};
});
};
return {
...consoleCollection,
@@ -125,20 +203,18 @@ type Options = {
### Use the subscription
In each case, all the default properties (`records`, `getByUuid`, etc.) will be present.
```typescript
const store = useConsoleStore();
// No options (propA and propB will be present)
const subscription = store.subscribe();
// No options (Contains common properties: `propA`, `propB`, `records`, `getByUuid`, etc.)
const subscription1 = store.subscribe();
// optC option (propA, propB and propC will be present)
const subscription = store.subscribe({ optC: "Hello" });
// optC option (Contains common properties + `propC`)
const subscription2 = store.subscribe({ optC: "Hello" });
// optD option (propA, propB and propD will be present)
const subscription = store.subscribe({ optD: 12 });
// optD option (Contains common properties + `propD`)
const subscription3 = store.subscribe({ optD: 12 });
// optC and optD options (propA, propB, propC and propD will be present)
const subscription = store.subscribe({ optC: "Hello", optD: 12 });
// optC and optD options (Contains common properties + `propC` + `propD`)
const subscription4 = store.subscribe({ optC: "Hello", optD: 12 });
```

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>Vite App</title>
<title>XO Lite</title>
</head>
<body>
<div id="root"></div>

View File

@@ -1,6 +1,6 @@
{
"name": "@xen-orchestra/lite",
"version": "0.1.1",
"version": "0.1.2",
"scripts": {
"dev": "GIT_HEAD=$(git rev-parse HEAD) vite",
"build": "run-p type-check build-only",
@@ -20,10 +20,9 @@
"@types/file-saver": "^2.0.5",
"@types/lodash-es": "^4.17.6",
"@types/marked": "^4.0.8",
"@vueform/multiselect": "^2.6.2",
"@vueuse/core": "^10.1.2",
"@vueuse/math": "^10.1.2",
"complex-matcher": "^0.7.0",
"complex-matcher": "^0.7.1",
"d3-time-format": "^4.1.0",
"decorator-synchronized": "^0.6.0",
"echarts": "^5.3.3",

View File

@@ -4,10 +4,10 @@
<AppLogin />
</div>
<div v-else>
<AppHeader />
<AppHeader v-if="uiStore.hasUi" />
<div style="display: flex">
<AppNavigation />
<main class="main">
<AppNavigation v-if="uiStore.hasUi" />
<main class="main" :class="{ 'no-ui': !uiStore.hasUi }">
<RouterView />
</main>
</div>
@@ -41,8 +41,6 @@ if (link == null) {
}
link.href = favicon;
document.title = "XO Lite";
const xenApiStore = useXenApiStore();
const { pool } = usePoolStore().subscribe();
useChartTheme();
@@ -92,5 +90,9 @@ whenever(
flex: 1;
height: calc(100vh - 8rem);
background-color: var(--background-color-secondary);
&.no-ui {
height: 100vh;
}
}
</style>

View File

@@ -1,6 +1,5 @@
@import "reset.css";
@import "theme.css";
@import "multi-select.css";
/* TODO Serve fonts locally */
@import url("https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;0,900;1,400;1,500;1,600;1,700;1,900&display=swap");

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -1,164 +0,0 @@
@import "@vueform/multiselect/themes/default.css";
:root {
--ms-font-size: 0.8em;
--ms-line-height: 1.375;
--ms-bg: var(--background-color-primary);
--ms-bg-disabled: var(--background-color-secondary);
--ms-border-color: var(--color-blue-scale-400);
--ms-border-width: 0.1rem;
--ms-border-color-active: var(--color-extra-blue-base);
--ms-border-width-active: 0.1rem;
--ms-radius: 0.4em;
--ms-py: 1.08em;
--ms-px: 0.625em;
--ms-ring-width: 0;
--ms-ring-color: transparent;
--ms-placeholder-color: var(--color-blue-scale-100);
--ms-max-height: 35rem;
--ms-spinner-color: var(--color-green-infra-base);
--ms-caret-color: var(--color-blue-scale-300);
--ms-clear-color: var(--color-blue-scale-300);
--ms-clear-color-hover: var(--color-blue-scale-100);
--ms-tag-font-size: 1em;
--ms-tag-line-height: 150%;
--ms-tag-font-weight: 400;
--ms-tag-bg: var(--background-color-secondary);
--ms-tag-bg-disabled: var(--color-grayscale-200);
--ms-tag-color: var(--color-blue-scale-200);
--ms-tag-color-disabled: var(--color-blue-scale-500);
--ms-tag-radius: 0.4em;
--ms-tag-py: 0.4rem;
--ms-tag-px: 1.2rem;
--ms-tag-my: 0.25rem;
--ms-tag-mx: 0.5rem;
--ms-tag-remove-radius: 4rem;
--ms-tag-remove-py: 0.5rem;
--ms-tag-remove-px: 0.5rem;
--ms-tag-remove-my: 0rem;
--ms-tag-remove-mx: 0.5rem;
--ms-dropdown-bg: var(--background-color-primary);
--ms-dropdown-border-color: var(--color-extra-blue-base);
--ms-dropdown-border-width: 0.1rem;
--ms-dropdown-radius: 0.8rem;
--ms-group-label-py: 0.5rem;
--ms-group-label-px: 2rem;
--ms-group-label-line-height: 1.375;
--ms-group-label-bg: var(--background-color-secondary);
--ms-group-label-color: var(--color-blue-scale-100);
--ms-group-label-bg-pointed: var(--color-blue-scale-400);
--ms-group-label-color-pointed: var(--color-blue-scale-200);
--ms-group-label-bg-disabled: var(--color-blue-scale-200);
--ms-group-label-color-disabled: var(--color-blue-scale-500);
--ms-group-label-bg-selected: var(--color-green-infra-base);
--ms-group-label-color-selected: var(--color-blue-scale-500);
--ms-group-label-bg-selected-pointed: var(--color-green-infra-d20);
--ms-group-label-color-selected-pointed: var(--color-blue-scale-500);
--ms-group-label-bg-selected-disabled: var(--color-blue-scale-200);
--ms-group-label-color-selected-disabled: var(--color-green-infra-base);
--ms-option-font-size: 1em;
--ms-option-line-height: 1.375;
--ms-option-bg-pointed: var(--background-color-secondary);
--ms-option-color-pointed: var(--color-blue-scale-200);
--ms-option-bg-selected: var(--background-color-primary);
--ms-option-color-selected: var(--color-green-infra-base);
--ms-option-bg-disabled: var(--background-color-primary);
--ms-option-color-disabled: var(--color-blue-scale-400);
--ms-option-bg-selected-pointed: var(--background-color-secondary);
--ms-option-color-selected-pointed: var(--color-green-infra-base);
--ms-option-bg-selected-disabled: var(--background-color-primary);
--ms-option-color-selected-disabled: var(--color-blue-scale-300);
--ms-option-py: 1rem;
--ms-option-px: 2rem;
--ms-empty-color: var(--color-grayscale-200);
}
.multiselect {
min-width: 15rem;
box-shadow: var(--shadow-100);
&:not(.is-disabled) {
&.color-info {
--ms-border-color: var(--color-blue-scale-400);
--ms-border-color-active: var(--color-extra-blue-l40);
&:hover {
--ms-border-color: var(--color-extra-blue-l60);
}
}
&.color-success {
--ms-border-color: var(--color-green-infra-base);
--ms-border-color-active: var(--color-green-infra-l40);
&:hover {
--ms-border-color: var(--color-green-infra-l60);
}
}
&.color-warning {
--ms-border-color: var(--color-orange-world-base);
--ms-border-color-active: var(--color-orange-world-l40);
&:hover {
--ms-border-color: var(--color-orange-world-l60);
}
}
&.color-error {
--ms-border-color: var(--color-red-vates-base);
--ms-border-color-active: var(--color-red-vates-l40);
&:hover {
--ms-border-color: var(--color-red-vates-l60);
}
}
}
& .multiselect-group-label {
font-size: 1.2rem;
text-transform: uppercase;
}
& .caret-icon {
flex-grow: 0;
flex-shrink: 0;
margin: 0 var(--ms-px, 0.875rem) 0 0;
pointer-events: none;
position: relative;
transform: rotateX(0deg);
z-index: 10;
color: var(--ms-caret-color);
}
&.is-open .caret-icon {
transform: rotateX(180deg);
}
& .multiselect-tag-remove {
color: var(--background-color-secondary);
background-color: var(--color-blue-scale-200);
}
& .multiselect-tag-remove:hover {
background-color: var(--color-red-vates-l40);
}
& .multiselect-tag-remove-icon {
width: 1rem;
height: 1rem;
}
& .multiselect-search,
& .multiselect-tags-search {
color: var(--color-blue-scale-100);
background-color: transparent;
}
}

View File

@@ -24,6 +24,7 @@
</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";
@@ -33,6 +34,7 @@ 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");
@@ -62,7 +64,7 @@ async function handleSubmit() {
isInvalidPassword.value = true;
error.value = t("password-invalid");
} else {
error.value = t("error-occured");
error.value = t("error-occurred");
console.error(err);
}
}

View File

@@ -7,12 +7,12 @@
</template>
<script
generic="T extends XenApiRecord<string>, I extends T['uuid']"
generic="T extends XenApiRecord<RawObjectType>, I extends T['uuid']"
lang="ts"
setup
>
import UiSpinner from "@/components/ui/UiSpinner.vue";
import type { XenApiRecord } from "@/libs/xen-api";
import type { RawObjectType, XenApiRecord } from "@/libs/xen-api";
import ObjectNotFoundView from "@/views/ObjectNotFoundView.vue";
import { computed } from "vue";
import { useRouter } from "vue-router";

View File

@@ -3,11 +3,11 @@
</template>
<script lang="ts" setup>
import { useXenApiStore } from "@/stores/xen-api.store";
import VncClient from "@novnc/novnc/core/rfb";
import { promiseTimeout } from "@vueuse/shared";
import { fibonacci } from "iterable-backoff";
import { computed, onBeforeUnmount, ref, watchEffect } from "vue";
import VncClient from "@novnc/novnc/core/rfb";
import { useXenApiStore } from "@/stores/xen-api.store";
import { promiseTimeout } from "@vueuse/shared";
const N_TOTAL_TRIES = 8;
const FIBONACCI_MS_ARRAY: number[] = Array.from(

View File

@@ -33,7 +33,7 @@
</AppMenu>
</UiTabBar>
<div class="tabs">
<div :class="{ 'full-width': fullWidthComponent }" class="tabs">
<UiCard v-if="selectedTab === TAB.NONE" class="tab-content">
<i>No configuration defined</i>
</UiCard>
@@ -102,11 +102,11 @@ import StorySettingParams from "@/components/component-story/StorySettingParams.
import StorySlotParams from "@/components/component-story/StorySlotParams.vue";
import AppMenu from "@/components/menu/AppMenu.vue";
import MenuItem from "@/components/menu/MenuItem.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiCardTitle from "@/components/ui/UiCardTitle.vue";
import UiCounter from "@/components/ui/UiCounter.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiTab from "@/components/ui/UiTab.vue";
import UiTabBar from "@/components/ui/UiTabBar.vue";
import {
@@ -121,15 +121,7 @@ import {
import { faSliders } from "@fortawesome/free-solid-svg-icons";
import "highlight.js/styles/github-dark.css";
import { uniqueId, upperFirst } from "lodash-es";
import {
computed,
effectScope,
onBeforeUnmount,
reactive,
ref,
watch,
watchEffect,
} from "vue";
import { computed, reactive, ref, watch, watchEffect } from "vue";
import { useRoute } from "vue-router";
const tab = (tab: TAB, params: Param[]) =>
@@ -148,6 +140,7 @@ const props = defineProps<{
settings?: Record<string, any>;
}
>;
fullWidthComponent?: boolean;
}>();
enum TAB {
@@ -186,24 +179,6 @@ if (propParams.value.length !== 0) {
}
const propValues = ref<Record<string, any>>({});
const scope = effectScope();
scope.run(() => {
for (const param of props.params) {
if (!isPropParam(param) || !param.hasChangeHandler()) {
continue;
}
watch(
() => propValues.value[param.name],
(value) => param.getOnChangeHandler()?.(value, propValues.value)
);
}
});
onBeforeUnmount(() => scope.stop());
const settingValues = ref<Record<string, any>>({});
const eventsLog = ref<
{ id: string; name: string; args: { name: string; value: any }[] }[]
@@ -262,12 +237,8 @@ const eventLogRows = computed(() => {
const slotProperties = computed(() => {
const properties: Record<string, any> = {};
propParams.value.forEach((param) => {
const value = propValues.value[param.name];
if (param.isRequired() || value !== undefined) {
properties[param.name] = value;
}
propParams.value.forEach(({ name }) => {
properties[name] = propValues.value[name];
});
eventParams.value.forEach((eventParam) => {
@@ -359,6 +330,10 @@ const applyPreset = (preset: {
padding: 1rem;
gap: 1rem;
&.full-width {
flex-direction: column;
}
.tab-content {
flex: 1;
height: auto;

View File

@@ -10,7 +10,7 @@
<th><!-- Reset Default --></th>
<th><!-- Widget --></th>
<th>Default</th>
<th><!-- Help --></th>
<th>Help</th>
</tr>
</thead>
<tfoot>
@@ -78,11 +78,7 @@
<span v-else>-</span>
</td>
<td class="help">
<UiIcon
v-if="param.getHelp()"
v-tooltip="param.getHelp()"
:icon="faInfoCircle"
/>
{{ param.getHelp() }}
</td>
</tr>
</tbody>
@@ -99,11 +95,7 @@ import useModal from "@/composables/modal.composable";
import useSortedCollection from "@/composables/sorted-collection.composable";
import { vTooltip } from "@/directives/tooltip.directive";
import type { PropParam } from "@/libs/story/story-param";
import {
faClose,
faInfoCircle,
faRepeat,
} from "@fortawesome/free-solid-svg-icons";
import { faClose, faRepeat } from "@fortawesome/free-solid-svg-icons";
import { useVModel } from "@vueuse/core";
import { toRef } from "vue";
@@ -176,7 +168,6 @@ const {
.help {
font-style: italic;
color: var(--color-blue-scale-200);
text-align: center;
}
.default-value {

View File

@@ -1,25 +1,34 @@
<template>
<div class="story-widget">
<div v-if="isSelectWidget(widget)">
<FormSelect :options="widget.choices" v-model="model" />
</div>
<div v-else-if="isRadioWidget(widget)" class="radio">
<FormInputWrapper v-for="choice in widget.choices" :key="choice.label">
<FormRadio v-model="model" :value="choice.value" />
{{ choice.label }}
</FormInputWrapper>
</div>
<div v-else-if="isBooleanWidget(widget)">
<FormCheckbox v-model="model" />
</div>
<FormInput
v-else-if="isNumberWidget(widget)"
v-model.number="model"
type="number"
/>
<FormInput v-else-if="isTextWidget(widget)" v-model="model" />
<FormJson v-else-if="isObjectWidget(widget)" v-model="model" />
<FormSelect
v-if="isSelectWidget(widget)"
v-model="model"
:wrapper-attrs="{ class: 'full-width' }"
>
<option v-if="!required && model === undefined" :value="undefined" />
<option
v-for="choice in widget.choices"
:key="choice.label"
:value="choice.value"
>
{{ choice.label }}
</option>
</FormSelect>
<div v-else-if="isRadioWidget(widget)" class="radio">
<FormInputWrapper v-for="choice in widget.choices" :key="choice.label">
<FormRadio v-model="model" :value="choice.value" />
{{ choice.label }}
</FormInputWrapper>
</div>
<div v-else-if="isBooleanWidget(widget)">
<FormCheckbox v-model="model" />
</div>
<FormInput
v-else-if="isNumberWidget(widget)"
v-model.number="model"
type="number"
/>
<FormInput v-else-if="isTextWidget(widget)" v-model="model" />
<FormJson v-else-if="isObjectWidget(widget)" v-model="model" />
</template>
<script lang="ts" setup>
@@ -73,11 +82,9 @@ const model = useVModel(props, "modelValue", emit);
gap: 1rem;
}
.story-widget {
&:deep(.form-select),
&:deep(.form-input),
&:deep(.form-json) {
font-size: 1.4rem;
}
.form-select,
.form-input,
.form-json {
font-size: 1.4rem;
}
</style>

View File

@@ -157,7 +157,7 @@ defineExpose({
max-width: 30em;
--before-width: v-bind('beforeWidth || "1.75em"');
--after-width: v-bind('afterWidth || "1.75em"');
--after-width: v-bind('afterWidth || "1.625em"');
--caret-width: 1.5em;
--text-color: var(--color-blue-scale-100);
@@ -187,9 +187,9 @@ defineExpose({
.input,
.textarea,
.select {
font-size: 0.8em;
font-size: 1em;
width: 100%;
height: 3.5em;
height: 3em;
margin: 0;
color: var(--text-color);
border: 0.05em solid var(--border-color);
@@ -292,11 +292,11 @@ defineExpose({
padding-left: 0.625em;
&.has-before {
padding-left: calc(var(--before-width) + 0.6em);
padding-left: calc(var(--before-width) + 0.25em);
}
&.has-after {
padding-right: calc(var(--after-width) + 0.6em);
padding-right: calc(var(--after-width) + 0.25em);
}
}

View File

@@ -9,8 +9,8 @@
display: inline-flex;
align-items: center;
&:deep(.form-input),
&:deep(.form-select) {
:slotted(.form-input),
:slotted(.form-select) {
&:hover {
z-index: 1;
}
@@ -23,8 +23,7 @@
margin-left: -1px;
.input,
.select,
.multiselect {
.select {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
@@ -32,8 +31,7 @@
&:not(:last-child) {
.input,
.select,
.multiselect {
.select {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}

View File

@@ -41,6 +41,7 @@ import type { Color } from "@/types";
import {
IK_FORM_HAS_LABEL,
IK_FORM_INPUT_COLOR,
IK_FORM_LABEL_DISABLED,
IK_INPUT_ID,
} from "@/types/injection-keys";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
@@ -58,6 +59,7 @@ const props = defineProps<{
warning?: string;
error?: string;
help?: string;
disabled?: boolean;
}>();
const id = computed(() => props.id ?? uniqueId("form-input-"));
@@ -81,6 +83,11 @@ provide(
IK_FORM_HAS_LABEL,
computed(() => slots.label !== undefined)
);
provide(
IK_FORM_LABEL_DISABLED,
computed(() => props.disabled ?? false)
);
</script>
<style lang="postcss" scoped>

View File

@@ -1,93 +1,15 @@
<template>
<span class="form-select">
<MultiSelect
v-model="modelValue"
:can-clear="clearable"
:class="colorClass"
:close-on-deselect="!multiple"
:close-on-select="!multiple"
:groups="isGrouped"
:hide-selected="false"
:label="labelKey"
:mode="multiple ? 'multiple' : 'single'"
:multiple-label="getMultipleLabel"
:no-options-text="$t('no-options-available')"
:no-results-text="$t('no-results-found')"
:options="options"
:track-by="labelKey"
:value-prop="valueKey"
:object="object"
:disabled="busy || disabled"
:searchable="options.length > SEARCHABLE_THRESHOLD"
:loading="busy"
>
<template #caret>
<UiIcon :icon="faAngleDown" class="caret-icon" />
</template>
</MultiSelect>
</span>
<FormInput>
<slot />
</FormInput>
</template>
<script generic="T extends XenApiRecord<string>" lang="ts" setup>
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import type { XenApiRecord } from "@/libs/xen-api";
import type { Color } from "@/types";
import { IK_FORM_INPUT_COLOR } from "@/types/injection-keys";
import { faAngleDown } from "@fortawesome/free-solid-svg-icons";
import MultiSelect from "@vueform/multiselect";
import { useVModel } from "@vueuse/core";
import { computed, inject } from "vue";
import { useI18n } from "vue-i18n";
<script lang="ts" setup>
import FormInput from "@/components/form/FormInput.vue";
import { IK_INPUT_TYPE } from "@/types/injection-keys";
import { provide } from "vue";
const SEARCHABLE_THRESHOLD = 10;
const props = withDefaults(
defineProps<{
modelValue: any;
multiple?: boolean;
options: { label: string; options: T[] }[] | T[];
labelKey?: string;
valueKey?: string;
clearable?: boolean;
color?: Color;
object?: boolean;
disabled?: boolean;
busy?: boolean;
}>(),
{
labelKey: "label",
valueKey: "value",
color: undefined,
}
);
const emit = defineEmits<{
(event: "update:modelValue", value: any): void;
}>();
const isGrouped = computed(() => {
const option = props.options[0];
return "object" === typeof option && "options" in option && "label" in option;
});
const modelValue = useVModel(props, "modelValue", emit);
const i18n = useI18n();
const getMultipleLabel = (values: any[]) =>
i18n.t("n-options-selected", { n: values.length });
const parentColor = inject(IK_FORM_INPUT_COLOR, undefined);
const colorClass = computed(() => {
const color = props.color ?? parentColor?.value ?? "info";
return `color-${color}`;
});
provide(IK_INPUT_TYPE, "select");
</script>
<style lang="postcss" scoped>
.form-select {
font-size: 2rem;
}
</style>
<style lang="postcss" scoped></style>

View File

@@ -1,82 +0,0 @@
<template>
<span class="form-tag">
<MultiSelect
v-model="modelValue"
:class="colorClass"
:create-option="allowNew"
:no-options-text="$t('no-options-available')"
:no-results-text="$t('no-results-found')"
:on-create="handleCreate"
:options="options"
:searchable="allowNew || options.length > SEARCHABLE_THRESHOLD"
mode="tags"
@deselect="($event) => handleDeselect($event as string)"
:disabled="disabled"
>
<template #caret>
<UiIcon :icon="faAngleDown" class="caret-icon" />
</template>
</MultiSelect>
</span>
</template>
<script lang="ts" setup>
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import type { Color } from "@/types";
import { IK_FORM_INPUT_COLOR } from "@/types/injection-keys";
import { faAngleDown } from "@fortawesome/free-solid-svg-icons";
import MultiSelect from "@vueform/multiselect";
import { useVModel } from "@vueuse/core";
import { computed, inject } from "vue";
const SEARCHABLE_THRESHOLD = 10;
const props = withDefaults(
defineProps<{
modelValue: any;
createdTags?: string[];
options: string[];
allowNew?: boolean;
color?: Color;
disabled?: boolean;
}>(),
{ createdTags: () => [] }
);
const emit = defineEmits<{
(event: "update:modelValue", value: string[]): void;
(event: "update:createdTags", value: string[]): void;
}>();
const modelValue = useVModel(props, "modelValue", emit);
const handleCreate = (tag: { label: string; value: string }) => {
emit("update:createdTags", [...props.createdTags, tag.value]);
return tag;
};
const handleDeselect = (value: string) => {
if (!props.allowNew) {
return;
}
emit(
"update:createdTags",
props.createdTags.filter((t) => t !== value)
);
};
const parentColor = inject(IK_FORM_INPUT_COLOR, undefined);
const colorClass = computed(() => {
const color = props.color ?? parentColor?.value ?? "info";
return `color-${color}`;
});
</script>
<style lang="postcss" scoped>
.form-tag {
font-size: 2rem;
}
</style>

View File

@@ -1,31 +0,0 @@
<template>
<FormSelect
object
v-model="modelValue"
:color="color"
:multiple="multiple"
:options="options"
label-key="name_label"
value-key="$ref"
/>
</template>
<script generic="T extends XenApiRecord<string>" lang="ts" setup>
import FormSelect from "@/components/form/FormSelect.vue";
import type { XenApiRecord } from "@/libs/xen-api";
import type { Color } from "@/types";
import { useVModel } from "@vueuse/core";
const props = defineProps<{
modelValue: any;
multiple?: boolean;
options: { label: string; options: T[] }[] | T[];
color?: Color;
}>();
const emit = defineEmits<{
(event: "update:modelValue", value: any): void;
}>();
const modelValue = useVModel(props, "modelValue", emit);
</script>

View File

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

View File

@@ -0,0 +1,17 @@
<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,5 +1,5 @@
<template>
<UiTable class="tasks-table" :color="hasError ? 'error' : undefined">
<UiTable :color="hasError ? 'error' : undefined" class="tasks-table">
<thead>
<tr>
<th>{{ $t("name") }}</th>
@@ -20,6 +20,9 @@
<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"
@@ -35,20 +38,35 @@
<script lang="ts" setup>
import TaskRow from "@/components/tasks/TaskRow.vue";
import UiTable from "@/components/ui/UiTable.vue";
import UiSpinner from "@/components/ui/UiSpinner.vue";
import { useTaskStore } from "@/stores/task.store";
import UiTable from "@/components/ui/UiTable.vue";
import type { XenApiTask } from "@/libs/xen-api";
import { useTaskStore } from "@/stores/task.store";
import { computed } from "vue";
defineProps<{
const props = 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

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

View File

@@ -0,0 +1,50 @@
<template>
<li class="ui-resource">
<UiIcon :icon="icon" class="icon" />
<div class="separator" />
<div class="label">{{ label }}</div>
<div class="count">{{ count }}</div>
</li>
</template>
<script lang="ts" setup>
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
defineProps<{
icon: IconDefinition;
label: string;
count: string | number;
}>();
</script>
<style lang="postcss" scoped>
.ui-resource {
display: flex;
align-items: center;
}
.icon {
color: var(--color-extra-blue-base);
font-size: 3.2rem;
}
.separator {
height: 4.5rem;
width: 0;
border-left: 0.1rem solid var(--color-extra-blue-base);
background-color: var(--color-extra-blue-base);
margin: 0 1.5rem;
}
.label {
font-size: 1.6rem;
font-weight: 700;
}
.count {
font-size: 1.4rem;
font-weight: 400;
margin-left: 2rem;
}
</style>

View File

@@ -0,0 +1,13 @@
<template>
<ul class="ui-resources">
<slot />
</ul>
</template>
<style lang="postcss" scoped>
.ui-resources {
display: flex;
gap: 1rem 5.4rem;
flex-wrap: wrap;
}
</style>

View File

@@ -102,26 +102,12 @@ export class PropParam extends mixin(BaseParam, WithWidget, WithType) {
#isRequired = false;
#defaultValue: any;
#isVModel: boolean;
#onChangeHandler: ((value: any, context: object) => void) | undefined;
constructor(name: string, isVModel = false) {
super(name);
this.#isVModel = isVModel;
}
onChange(handler: (value: any, context: object) => void) {
this.#onChangeHandler = handler;
return this;
}
hasChangeHandler() {
return this.#onChangeHandler !== undefined;
}
getOnChangeHandler() {
return this.#onChangeHandler;
}
isRequired() {
return this.#isRequired;
}

View File

@@ -1,13 +1,14 @@
import type {
RawXenApiRecord,
XenApiHost,
XenApiHostMetrics,
XenApiRecord,
XenApiVm,
VM_OPERATION,
RawObjectType,
XenApiHostMetrics,
} from "@/libs/xen-api";
import type { Filter } from "@/types/filter";
import type { Subscription } from "@/types/xapi-collection";
import type { XenApiRecordSubscription } from "@/types/subscription";
import { faSquareCheck } from "@fortawesome/free-regular-svg-icons";
import { faFont, faHashtag, faList } from "@fortawesome/free-solid-svg-icons";
import { utcParse } from "d3-time-format";
@@ -116,14 +117,14 @@ export function getStatsLength(stats?: object | any[]) {
export function isHostRunning(
host: XenApiHost,
hostMetricsSubscription: Subscription<XenApiHostMetrics, object>
hostMetricsSubscription: XenApiRecordSubscription<XenApiHostMetrics>
) {
return hostMetricsSubscription.getByOpaqueRef(host.metrics)?.live === true;
}
export function getHostMemory(
host: XenApiHost,
hostMetricsSubscription: Subscription<XenApiHostMetrics, object>
hostMetricsSubscription: XenApiRecordSubscription<XenApiHostMetrics>
) {
const hostMetrics = hostMetricsSubscription.getByOpaqueRef(host.metrics);
@@ -136,7 +137,7 @@ export function getHostMemory(
}
}
export const buildXoObject = <T extends XenApiRecord<string>>(
export const buildXoObject = <T extends XenApiRecord<RawObjectType>>(
record: RawXenApiRecord<T>,
params: { opaqueRef: T["$ref"] }
) => {

View File

@@ -90,14 +90,17 @@ export enum VM_OPERATION {
declare const __brand: unique symbol;
export interface XenApiRecord<Name extends string> {
export interface XenApiRecord<Name extends RawObjectType> {
$ref: string & { [__brand]: `${Name}Ref` };
uuid: string & { [__brand]: `${Name}Uuid` };
}
export type RawXenApiRecord<T extends XenApiRecord<string>> = Omit<T, "$ref">;
export type RawXenApiRecord<T extends XenApiRecord<RawObjectType>> = Omit<
T,
"$ref"
>;
export interface XenApiPool extends XenApiRecord<"Pool"> {
export interface XenApiPool extends XenApiRecord<"pool"> {
cpu_info: {
cpu_count: string;
};
@@ -105,7 +108,7 @@ export interface XenApiPool extends XenApiRecord<"Pool"> {
name_label: string;
}
export interface XenApiHost extends XenApiRecord<"Host"> {
export interface XenApiHost extends XenApiRecord<"host"> {
address: string;
name_label: string;
metrics: XenApiHostMetrics["$ref"];
@@ -114,13 +117,13 @@ export interface XenApiHost extends XenApiRecord<"Host"> {
software_version: { product_version: string };
}
export interface XenApiSr extends XenApiRecord<"Sr"> {
export interface XenApiSr extends XenApiRecord<"SR"> {
name_label: string;
physical_size: number;
physical_utilisation: number;
}
export interface XenApiVm extends XenApiRecord<"Vm"> {
export interface XenApiVm extends XenApiRecord<"VM"> {
current_operations: Record<string, VM_OPERATION>;
guest_metrics: string;
metrics: XenApiVmMetrics["$ref"];
@@ -135,24 +138,24 @@ export interface XenApiVm extends XenApiRecord<"Vm"> {
VCPUs_at_startup: number;
}
export interface XenApiConsole extends XenApiRecord<"Console"> {
export interface XenApiConsole extends XenApiRecord<"console"> {
protocol: string;
location: string;
}
export interface XenApiHostMetrics extends XenApiRecord<"HostMetrics"> {
export interface XenApiHostMetrics extends XenApiRecord<"host_metrics"> {
live: boolean;
memory_free: number;
memory_total: number;
}
export interface XenApiVmMetrics extends XenApiRecord<"VmMetrics"> {
export interface XenApiVmMetrics extends XenApiRecord<"VM_metrics"> {
VCPUs_number: number;
}
export type XenApiVmGuestMetrics = XenApiRecord<"VmGuestMetrics">;
export type XenApiVmGuestMetrics = XenApiRecord<"VM_guest_metrics">;
export interface XenApiTask extends XenApiRecord<"Task"> {
export interface XenApiTask extends XenApiRecord<"task"> {
name_label: string;
resident_on: XenApiHost["$ref"];
created: string;
@@ -161,17 +164,61 @@ export interface XenApiTask extends XenApiRecord<"Task"> {
progress: number;
}
export interface XenApiMessage extends XenApiRecord<"Message"> {
export interface XenApiMessage<T extends RawObjectType = RawObjectType>
extends XenApiRecord<"message"> {
body: string;
cls: T;
name: string;
cls: RawObjectType;
obj_uuid: RawTypeToRecord<T>["uuid"];
priority: number;
timestamp: string;
}
export type XenApiAlarmType =
| "cpu_usage"
| "disk_usage"
| "fs_usage"
| "log_fs_usage"
| "mem_usage"
| "memory_free_kib"
| "network_usage"
| "physical_utilisation"
| "sr_io_throughput_total_per_host";
export interface XenApiAlarm extends XenApiMessage {
level: number;
triggerLevel: number;
type: XenApiAlarmType;
}
export type RawTypeToRecord<T extends RawObjectType> = T extends "SR"
? XenApiSr
: T extends "VM"
? XenApiVm
: T extends "VM_guest_metrics"
? XenApiVmGuestMetrics
: T extends "VM_metrics"
? XenApiVmMetrics
: T extends "console"
? XenApiConsole
: T extends "host"
? XenApiHost
: T extends "host_metrics"
? XenApiHostMetrics
: T extends "message"
? XenApiMessage
: T extends "pool"
? XenApiPool
: T extends "task"
? XenApiTask
: never;
type WatchCallbackResult = {
id: string;
class: ObjectType;
operation: "add" | "mod" | "del";
ref: XenApiRecord<string>["$ref"];
snapshot: RawXenApiRecord<XenApiRecord<string>>;
ref: XenApiRecord<RawObjectType>["$ref"];
snapshot: RawXenApiRecord<XenApiRecord<RawObjectType>>;
};
type WatchCallback = (results: WatchCallbackResult[]) => void;
@@ -284,16 +331,17 @@ export default class XenApi {
return fetch(url, { signal: abortSignal });
}
async loadRecords<T extends XenApiRecord<string>>(
type: RawObjectType
): Promise<T[]> {
const result = await this.#call<{ [key: string]: RawXenApiRecord<T> }>(
async loadRecords<
T extends RawObjectType,
R extends RawTypeToRecord<T> = RawTypeToRecord<T>
>(type: T): Promise<R[]> {
const result = await this.#call<{ [key: string]: R }>(
`${type}.get_all_records`,
[this.sessionId]
);
return Object.entries(result).map(([opaqueRef, record]) =>
buildXoObject(record, { opaqueRef: opaqueRef as T["$ref"] })
buildXoObject(record, { opaqueRef: opaqueRef as R["$ref"] })
);
}

View File

@@ -18,6 +18,7 @@
"community": "Community",
"community-name": "{name} community",
"console": "Console",
"console-unavailable": "Console unavailable",
"copy": "Copy",
"cpu-provisioning": "CPU provisioning",
"cpu-usage": "CPU usage",
@@ -32,7 +33,7 @@
"do-you-have-needs": "You have needs and/or expectations? Let us know",
"edit-config": "Edit config",
"error-no-data": "Error, can't collect data.",
"error-occured": "An error has occurred",
"error-occurred": "An error has occurred",
"export": "Export",
"export-table-to": "Export table to {type}",
"export-vms": "Export VMs",
@@ -68,7 +69,6 @@
"log-out": "Log out",
"login": "Login",
"migrate": "Migrate",
"n-options-selected": "{n} option selected | {n} options selected",
"n-vms": "1 VM | {n} VMs",
"name": "Name",
"network": "Network",
@@ -78,10 +78,11 @@
"news": "News",
"news-name": "{name} news",
"new-features-are-coming": "New features are coming soon!",
"no-options-available": "No options available",
"no-results-found": "No results found",
"no-tasks": "No tasks",
"not-found": "Not found",
"object": "Object",
"object-not-found": "Object {id} can't be found…",
"open-in-new-window": "Open in new window",
"or": "Or",
"page-not-found": "This page is not to be found…",
"password": "Password",
@@ -90,6 +91,7 @@
"please-confirm": "Please confirm",
"pool-cpu-usage": "Pool CPU Usage",
"pool-ram-usage": "Pool RAM Usage",
"power-on-for-console": "Power on your VM to access its console",
"power-state": "Power state",
"property": "Property",
"ram-usage": "RAM usage",
@@ -113,7 +115,6 @@
"settings": "Settings",
"shutdown": "Shutdown",
"snapshot": "Snapshot",
"selected-vms-in-execution": "Some selected VMs are running",
"sort-by": "Sort by",
"stacked-cpu-usage": "Stacked CPU usage",
"stacked-ram-usage": "Stacked RAM usage",
@@ -130,7 +131,6 @@
"system": "System",
"task": {
"estimated-end": "Estimated end",
"page-title": "Tasks | (1) Tasks | ({n}) Tasks",
"progress": "Progress",
"started": "Started"
},

View File

@@ -18,6 +18,7 @@
"community": "Communauté",
"community-name": "Communauté {name}",
"console": "Console",
"console-unavailable": "Console indisponible",
"copy": "Copier",
"cpu-provisioning": "Provisionnement CPU",
"cpu-usage": "Utilisation CPU",
@@ -32,7 +33,7 @@
"do-you-have-needs": "Vous avez des besoins et/ou des attentes ? Faites le nous savoir",
"edit-config": "Modifier config",
"error-no-data": "Erreur, impossible de collecter les données.",
"error-occured": "Une erreur est survenue",
"error-occurred": "Une erreur est survenue",
"export": "Exporter",
"export-table-to": "Exporter le tableau en {type}",
"export-vms": "Exporter les VMs",
@@ -68,7 +69,6 @@
"log-out": "Se déconnecter",
"login": "Connexion",
"migrate": "Migrer",
"n-options-selected": "{n} option sélectionnée | {n} options sélectionnées",
"n-vms": "1 VM | {n} VMs",
"name": "Nom",
"network": "Réseau",
@@ -78,10 +78,11 @@
"news": "Actualités",
"news-name": "Actualités {name}",
"new-features-are-coming": "De nouvelles fonctionnalités arrivent bientôt !",
"no-options-available": "Aucune option disponible",
"no-results-found": "Aucun résultat trouvé",
"no-tasks": "Aucune tâche",
"not-found": "Non trouvé",
"object": "Objet",
"object-not-found": "L'objet {id} est introuvable…",
"open-in-new-window": "Ouvrir dans une nouvelle fenêtre",
"or": "Ou",
"page-not-found": "Cette page est introuvable…",
"password": "Mot de passe",
@@ -90,6 +91,7 @@
"please-confirm": "Veuillez confirmer",
"pool-cpu-usage": "Utilisation CPU du Pool",
"pool-ram-usage": "Utilisation RAM du Pool",
"power-on-for-console": "Allumez votre VM pour accéder à sa console",
"power-state": "État d'alimentation",
"property": "Propriété",
"ram-usage": "Utilisation de la RAM",
@@ -113,7 +115,6 @@
"settings": "Paramètres",
"shutdown": "Arrêter",
"snapshot": "Instantané",
"selected-vms-in-execution": "Certaines VMs sélectionnées sont en cours d'exécution",
"sort-by": "Trier par",
"stacked-cpu-usage": "Utilisation CPU empilée",
"stacked-ram-usage": "Utilisation RAM empilée",
@@ -130,7 +131,6 @@
"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: "notFound",
name: "not-found",
component: () => import("@/views/PageNotFoundView.vue"),
},
],

View File

@@ -1,28 +1,33 @@
import type { XenApiMessage } from "@/libs/xen-api";
import type { XenApiAlarm } from "@/libs/xen-api";
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { createSubscribe } from "@/types/xapi-collection";
import type {
DeferExtension,
Options,
Subscription,
XenApiRecordExtension,
} from "@/types/subscription";
import { defineStore } from "pinia";
import { computed } from "vue";
type Extensions = [XenApiRecordExtension<XenApiAlarm>, DeferExtension];
export const useAlarmStore = defineStore("alarm", () => {
const messageCollection = useXapiCollectionStore().get("message");
const subscribe = createSubscribe<XenApiMessage, []>((options) => {
const originalSubscription = messageCollection.subscribe(options);
const subscribe = <O extends Options<Extensions>>(options?: O) => {
const subscription = messageCollection.subscribe(options);
const extendedSubscription = {
records: computed(() =>
originalSubscription.records.value.filter(
(record) => record.name === "alarm"
)
subscription.records.value.filter((record) => record.name === "alarm")
),
};
return {
...originalSubscription,
...subscription,
...extendedSubscription,
};
});
} as Subscription<Extensions, O>;
};
return {
...messageCollection,

View File

@@ -0,0 +1,88 @@
import { isHostRunning } from "@/libs/utils";
import type {
GRANULARITY,
HostStats,
XapiStatsResponse,
} from "@/libs/xapi-stats";
import type { XenApiHost, XenApiHostMetrics } from "@/libs/xen-api";
import { useXenApiStore } from "@/stores/xen-api.store";
import type {
Extension,
XenApiRecordExtension,
XenApiRecordSubscription,
} from "@/types/subscription";
import type { PartialSubscription } from "@/types/subscription";
import { computed } from "vue";
import type { ComputedRef } from "vue";
type GetStatsExtension = Extension<{
getStats: (
hostUuid: XenApiHost["uuid"],
granularity: GRANULARITY,
ignoreExpired: boolean,
opts: { abortSignal?: AbortSignal }
) => Promise<XapiStatsResponse<HostStats> | undefined> | undefined;
}>;
type RunningHostsExtension = Extension<
{ runningHosts: ComputedRef<XenApiHost[]> },
{ hostMetricsSubscription: XenApiRecordSubscription<XenApiHostMetrics> }
>;
export type HostExtensions = [
XenApiRecordExtension<XenApiHost>,
GetStatsExtension,
RunningHostsExtension
];
export const getStatsSubscription = (
hostSubscription: XenApiRecordSubscription<XenApiHost>
): PartialSubscription<GetStatsExtension> => {
const xenApiStore = useXenApiStore();
return {
getStats: (
hostUuid,
granularity,
ignoreExpired = false,
{ abortSignal }
) => {
const host = hostSubscription.getByUuid(hostUuid);
if (host === undefined) {
throw new Error(`Host ${hostUuid} could not be found.`);
}
const xapiStats = xenApiStore.isConnected
? xenApiStore.getXapiStats()
: undefined;
return xapiStats?._getAndUpdateStats<HostStats>({
abortSignal,
host,
ignoreExpired,
uuid: host.uuid,
granularity,
});
},
};
};
export const runningHostsSubscription = (
hostSubscription: XenApiRecordSubscription<XenApiHost>,
hostMetricsSubscription:
| XenApiRecordSubscription<XenApiHostMetrics>
| undefined
): PartialSubscription<RunningHostsExtension> | undefined => {
if (hostMetricsSubscription === undefined) {
return undefined;
}
return {
runningHosts: computed(() =>
hostSubscription.records.value.filter((host) =>
isHostRunning(host, hostMetricsSubscription)
)
),
};
};

View File

@@ -1,88 +1,28 @@
import { isHostRunning, sortRecordsByNameLabel } from "@/libs/utils";
import type {
GRANULARITY,
HostStats,
XapiStatsResponse,
} from "@/libs/xapi-stats";
import type { XenApiHost, XenApiHostMetrics } from "@/libs/xen-api";
import { sortRecordsByNameLabel } from "@/libs/utils";
import type { HostExtensions } from "@/stores/host.extension";
import {
getStatsSubscription,
runningHostsSubscription,
} from "@/stores/host.extension";
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { useXenApiStore } from "@/stores/xen-api.store";
import type { Subscription } from "@/types/xapi-collection";
import { createSubscribe } from "@/types/xapi-collection";
import type { Options, Subscription } from "@/types/subscription";
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;
};
type RunningHostsExtension = [
{ runningHosts: ComputedRef<XenApiHost[]> },
{ hostMetricsSubscription: Subscription<XenApiHostMetrics, any> }
];
type Extensions = [GetStatsExtension, RunningHostsExtension];
export const useHostStore = defineStore("host", () => {
const xenApiStore = useXenApiStore();
const hostCollection = useXapiCollectionStore().get("host");
hostCollection.setSort(sortRecordsByNameLabel);
const subscribe = createSubscribe<XenApiHost, Extensions>((options) => {
const originalSubscription = hostCollection.subscribe(options);
const subscribe = <O extends Options<HostExtensions>>(options?: O) => {
const subscription = hostCollection.subscribe(options);
const { hostMetricsSubscription } = options ?? {};
const getStats: GetStats = (
hostUuid,
granularity,
ignoreExpired = false,
{ abortSignal }
) => {
const host = originalSubscription.getByUuid(hostUuid);
if (host === undefined) {
throw new Error(`Host ${hostUuid} could not be found.`);
}
const xapiStats = xenApiStore.isConnected
? xenApiStore.getXapiStats()
: undefined;
return xapiStats?._getAndUpdateStats<HostStats>({
abortSignal,
host,
ignoreExpired,
uuid: host.uuid,
granularity,
});
};
const extendedSubscription = {
getStats,
};
const hostMetricsSubscription = options?.hostMetricsSubscription;
const runningHostsSubscription = hostMetricsSubscription !== undefined && {
runningHosts: computed(() =>
originalSubscription.records.value.filter((host) =>
isHostRunning(host, hostMetricsSubscription)
)
),
};
return {
...originalSubscription,
...extendedSubscription,
...runningHostsSubscription,
};
});
...subscription,
...getStatsSubscription(subscription),
...runningHostsSubscription(subscription, hostMetricsSubscription),
} as Subscription<HostExtensions, O>;
};
return {
...hostCollection,

View File

@@ -0,0 +1,92 @@
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,31 +1,36 @@
import { getFirst } from "@/libs/utils";
import type { XenApiPool } from "@/libs/xen-api";
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { createSubscribe } from "@/types/xapi-collection";
import type {
Extension,
Options,
PartialSubscription,
Subscription,
XenApiRecordExtension,
} from "@/types/subscription";
import { defineStore } from "pinia";
import { computed, type ComputedRef } from "vue";
type PoolExtension = {
type PoolExtension = Extension<{
pool: ComputedRef<XenApiPool | undefined>;
};
}>;
type Extensions = [PoolExtension];
type Extensions = [XenApiRecordExtension<XenApiPool>, PoolExtension];
export const usePoolStore = defineStore("pool", () => {
const poolCollection = useXapiCollectionStore().get("pool");
const subscribe = <O extends Options<Extensions>>(options?: O) => {
const subscription = poolCollection.subscribe(options);
const subscribe = createSubscribe<XenApiPool, Extensions>((options) => {
const originalSubscription = poolCollection.subscribe(options);
const extendedSubscription = {
pool: computed(() => getFirst(originalSubscription.records.value)),
const extendedSubscription: PartialSubscription<PoolExtension> = {
pool: computed(() => getFirst(subscription.records.value)),
};
return {
...originalSubscription,
...subscription,
...extendedSubscription,
};
});
} as Subscription<Extensions, O>;
};
return {
...poolCollection,

View File

@@ -0,0 +1,56 @@
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 type {
Extension,
PartialSubscription,
XenApiRecordExtension,
XenApiRecordSubscription,
} from "@/types/subscription";
import type { ComputedRef, Ref } from "vue";
type AdditionalTasksExtension = Extension<{
pendingTasks: ComputedRef<XenApiTask[]>;
finishedTasks: Ref<XenApiTask[]>;
}>;
export type TaskExtensions = [
XenApiRecordExtension<XenApiTask>,
AdditionalTasksExtension
];
export const additionalTasksSubscription = (
taskSubscription: XenApiRecordSubscription<XenApiTask>
): PartialSubscription<AdditionalTasksExtension> => {
const { compareFn } = useCollectionSorter<XenApiTask>({
initialSorts: ["-created"],
});
const { predicate } = useCollectionFilter({
initialFilters: [
"!name_label:|(SR.scan host.call_plugin)",
"status:pending",
],
});
const sortedTasks = useSortedCollection(taskSubscription.records, compareFn);
return {
pendingTasks: useFilteredCollection<XenApiTask>(sortedTasks, predicate),
finishedTasks: useArrayRemovedItemsHistory(
sortedTasks,
(task) => task.uuid,
{
limit: 50,
onRemove: (tasks) =>
tasks.map((task) => ({
...task,
finished: new Date().toISOString(),
})),
}
),
};
};

View File

@@ -1,6 +1,22 @@
import {
additionalTasksSubscription,
type TaskExtensions,
} from "@/stores/task.extension";
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import type { Options, Subscription } from "@/types/subscription";
import { defineStore } from "pinia";
export const useTaskStore = defineStore("task", () =>
useXapiCollectionStore().get("task")
);
export const useTaskStore = defineStore("task", () => {
const tasksCollection = useXapiCollectionStore().get("task");
const subscribe = <O extends Options<TaskExtensions>>(options?: O) => {
const subscription = tasksCollection.subscribe(options);
return {
...subscription,
...additionalTasksSubscription(subscription),
} as Subscription<TaskExtensions, O>;
};
return { ...tasksCollection, subscribe };
});

View File

@@ -1,6 +1,7 @@
import { useBreakpoints, useColorMode } from "@vueuse/core";
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { useRoute } from "vue-router";
export const useUiStore = defineStore("ui", () => {
const currentHostOpaqueRef = ref();
@@ -13,10 +14,14 @@ export const useUiStore = defineStore("ui", () => {
const isMobile = computed(() => !isDesktop.value);
const route = useRoute();
const hasUi = computed(() => route.query.ui !== "0");
return {
colorMode,
currentHostOpaqueRef,
isDesktop,
isMobile,
hasUi,
};
});

View File

@@ -0,0 +1,108 @@
import type {
GRANULARITY,
VmStats,
XapiStatsResponse,
} from "@/libs/xapi-stats";
import { POWER_STATE, type XenApiHost, type XenApiVm } from "@/libs/xen-api";
import { useXenApiStore } from "@/stores/xen-api.store";
import type {
Extension,
PartialSubscription,
XenApiRecordExtension,
XenApiRecordSubscription,
} from "@/types/subscription";
import { computed, type ComputedRef } from "vue";
type RecordsByHostRefExtension = Extension<{
recordsByHostRef: ComputedRef<Map<XenApiHost["$ref"], XenApiVm[]>>;
}>;
type RunningVmsExtension = Extension<{
runningVms: ComputedRef<XenApiVm[]>;
}>;
type GetStatsExtension = Extension<
{
getStats: (
id: XenApiVm["uuid"],
granularity: GRANULARITY,
ignoreExpired: boolean,
opts: { abortSignal?: AbortSignal }
) => Promise<XapiStatsResponse<VmStats> | undefined> | undefined;
},
{ hostSubscription: XenApiRecordSubscription<XenApiHost> }
>;
export type VmExtensions = [
XenApiRecordExtension<XenApiVm>,
RecordsByHostRefExtension,
RunningVmsExtension,
GetStatsExtension
];
export const recordsByHostRefSubscription = (
vmSubscription: XenApiRecordSubscription<XenApiVm>
): PartialSubscription<RecordsByHostRefExtension> => ({
recordsByHostRef: computed(() => {
const vmsByHostOpaqueRef = new Map<XenApiHost["$ref"], XenApiVm[]>();
vmSubscription.records.value.forEach((vm) => {
if (!vmsByHostOpaqueRef.has(vm.resident_on)) {
vmsByHostOpaqueRef.set(vm.resident_on, []);
}
vmsByHostOpaqueRef.get(vm.resident_on)?.push(vm);
});
return vmsByHostOpaqueRef;
}),
});
export const runningVmsSubscription = (
vmSubscription: XenApiRecordSubscription<XenApiVm>
): PartialSubscription<RunningVmsExtension> => ({
runningVms: computed(() =>
vmSubscription.records.value.filter(
(vm) => vm.power_state === POWER_STATE.RUNNING
)
),
});
export const getStatsSubscription = (
vmSubscription: XenApiRecordSubscription<XenApiVm>,
hostSubscription: XenApiRecordSubscription<XenApiHost> | undefined
): PartialSubscription<GetStatsExtension> | undefined => {
if (hostSubscription === undefined) {
return;
}
return {
getStats: (id, granularity, ignoreExpired = false, { abortSignal }) => {
const xenApiStore = useXenApiStore();
if (!xenApiStore.isConnected) {
return undefined;
}
const vm = vmSubscription.getByUuid(id);
if (vm === undefined) {
throw new Error(`VM ${id} could not be found.`);
}
const host = hostSubscription.getByOpaqueRef(vm.resident_on);
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,
});
},
};
};

View File

@@ -1,36 +1,13 @@
import { sortRecordsByNameLabel } from "@/libs/utils";
import type {
GRANULARITY,
VmStats,
XapiStatsResponse,
} from "@/libs/xapi-stats";
import type { XenApiHost, XenApiVm } from "@/libs/xen-api";
import { POWER_STATE } from "@/libs/xen-api";
import {
getStatsSubscription,
recordsByHostRefSubscription,
runningVmsSubscription,
type VmExtensions,
} from "@/stores/vm.extension";
import { useXapiCollectionStore } from "@/stores/xapi-collection.store";
import { useXenApiStore } from "@/stores/xen-api.store";
import { createSubscribe, type Subscription } from "@/types/xapi-collection";
import type { Options, Subscription } from "@/types/subscription";
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[]>;
};
type GetStatsExtension = [
{
getStats: GetStats;
},
{ hostSubscription: Subscription<XenApiHost, object> }
];
type Extensions = [DefaultExtension, GetStatsExtension];
export const useVmStore = defineStore("vm", () => {
const vmCollection = useXapiCollectionStore().get("VM");
@@ -41,82 +18,16 @@ export const useVmStore = defineStore("vm", () => {
vmCollection.setSort(sortRecordsByNameLabel);
const subscribe = createSubscribe<XenApiVm, Extensions>((options) => {
const originalSubscription = vmCollection.subscribe(options);
const extendedSubscription = {
recordsByHostRef: computed(() => {
const vmsByHostOpaqueRef = new Map<XenApiHost["$ref"], XenApiVm[]>();
originalSubscription.records.value.forEach((vm) => {
if (!vmsByHostOpaqueRef.has(vm.resident_on)) {
vmsByHostOpaqueRef.set(vm.resident_on, []);
}
vmsByHostOpaqueRef.get(vm.resident_on)?.push(vm);
});
return vmsByHostOpaqueRef;
}),
runningVms: computed(() =>
originalSubscription.records.value.filter(
(vm) => vm.power_state === POWER_STATE.RUNNING
)
),
};
const hostSubscription = options?.hostSubscription;
const getStatsSubscription:
| {
getStats: GetStats;
}
| undefined =
hostSubscription !== undefined
? {
getStats: (
id,
granularity,
ignoreExpired = false,
{ abortSignal }
) => {
const xenApiStore = useXenApiStore();
if (!xenApiStore.isConnected) {
return undefined;
}
const vm = originalSubscription.getByUuid(id);
if (vm === undefined) {
throw new Error(`VM ${id} could not be found.`);
}
const host = hostSubscription.getByOpaqueRef(vm.resident_on);
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;
const subscribe = <O extends Options<VmExtensions>>(options?: O) => {
const subscription = vmCollection.subscribe(options);
return {
...originalSubscription,
...extendedSubscription,
...getStatsSubscription,
};
});
...subscription,
...recordsByHostRefSubscription(subscription),
...runningVmsSubscription(subscription),
...getStatsSubscription(subscription, options?.hostSubscription),
} as Subscription<VmExtensions, O>;
};
return {
...vmCollection,

View File

@@ -1,25 +1,23 @@
import type { RawObjectType, XenApiRecord } from "@/libs/xen-api";
import type { RawObjectType, RawTypeToRecord } from "@/libs/xen-api";
import { useXenApiStore } from "@/stores/xen-api.store";
import type {
RawTypeToObject,
SubscribeOptions,
DeferExtension,
Options,
Subscription,
} from "@/types/xapi-collection";
XenApiRecordExtension,
} from "@/types/subscription";
import { tryOnUnmounted, whenever } from "@vueuse/core";
import { defineStore } from "pinia";
import { computed, readonly, ref } from "vue";
export const useXapiCollectionStore = defineStore("xapiCollection", () => {
const collections = ref(
new Map<RawObjectType, ReturnType<typeof createXapiCollection<any>>>()
);
const collections = ref(new Map());
function get<
T extends RawObjectType,
S extends XenApiRecord<string> = RawTypeToObject[T]
>(type: T): ReturnType<typeof createXapiCollection<S>> {
function get<T extends RawObjectType>(
type: T
): ReturnType<typeof createXapiCollection<T>> {
if (!collections.value.has(type)) {
collections.value.set(type, createXapiCollection<S>(type));
collections.value.set(type, createXapiCollection(type));
}
return collections.value.get(type)!;
@@ -28,8 +26,11 @@ export const useXapiCollectionStore = defineStore("xapiCollection", () => {
return { get };
});
const createXapiCollection = <T extends XenApiRecord<string>>(
type: RawObjectType
const createXapiCollection = <
T extends RawObjectType,
R extends RawTypeToRecord<T> = RawTypeToRecord<T>
>(
type: T
) => {
const isReady = ref(false);
const isFetching = ref(false);
@@ -37,31 +38,31 @@ const createXapiCollection = <T extends XenApiRecord<string>>(
const lastError = ref<string>();
const hasError = computed(() => lastError.value !== undefined);
const subscriptions = ref(new Set<symbol>());
const recordsByOpaqueRef = ref(new Map<T["$ref"], T>());
const recordsByUuid = ref(new Map<T["uuid"], T>());
const filter = ref<(record: T) => boolean>();
const sort = ref<(record1: T, record2: T) => 1 | 0 | -1>();
const recordsByOpaqueRef = ref(new Map<R["$ref"], R>());
const recordsByUuid = ref(new Map<R["uuid"], R>());
const filter = ref<(record: R) => boolean>();
const sort = ref<(record1: R, record2: R) => 1 | 0 | -1>();
const xenApiStore = useXenApiStore();
const setFilter = (newFilter: (record: T) => boolean) =>
const setFilter = (newFilter: (record: R) => boolean) =>
(filter.value = newFilter);
const setSort = (newSort: (record1: T, record2: T) => 1 | 0 | -1) =>
const setSort = (newSort: (record1: R, record2: R) => 1 | 0 | -1) =>
(sort.value = newSort);
const records = computed<T[]>(() => {
const records = computed<R[]>(() => {
const records = Array.from(recordsByOpaqueRef.value.values()).sort(
sort.value
);
return filter.value !== undefined ? records.filter(filter.value) : records;
});
const getByOpaqueRef = (opaqueRef: T["$ref"]) =>
const getByOpaqueRef = (opaqueRef: R["$ref"]) =>
recordsByOpaqueRef.value.get(opaqueRef);
const getByUuid = (uuid: T["uuid"]) => recordsByUuid.value.get(uuid);
const getByUuid = (uuid: R["uuid"]) => recordsByUuid.value.get(uuid);
const hasUuid = (uuid: T["uuid"]) => recordsByUuid.value.has(uuid);
const hasUuid = (uuid: R["uuid"]) => recordsByUuid.value.has(uuid);
const hasSubscriptions = computed(() => subscriptions.value.size > 0);
@@ -69,7 +70,7 @@ const createXapiCollection = <T extends XenApiRecord<string>>(
try {
isFetching.value = true;
lastError.value = undefined;
const records = await xenApiStore.getXapi().loadRecords<T>(type);
const records = await xenApiStore.getXapi().loadRecords<T, R>(type);
recordsByOpaqueRef.value.clear();
recordsByUuid.value.clear();
records.forEach(add);
@@ -81,17 +82,17 @@ const createXapiCollection = <T extends XenApiRecord<string>>(
}
};
const add = (record: T) => {
const add = (record: R) => {
recordsByOpaqueRef.value.set(record.$ref, record);
recordsByUuid.value.set(record.uuid, record);
};
const update = (record: T) => {
const update = (record: R) => {
recordsByOpaqueRef.value.set(record.$ref, record);
recordsByUuid.value.set(record.uuid, record);
};
const remove = (opaqueRef: T["$ref"]) => {
const remove = (opaqueRef: R["$ref"]) => {
if (!recordsByOpaqueRef.value.has(opaqueRef)) {
return;
}
@@ -106,9 +107,11 @@ const createXapiCollection = <T extends XenApiRecord<string>>(
() => fetchAll()
);
function subscribe<O extends SubscribeOptions<any>>(
type Extensions = [XenApiRecordExtension<R>, DeferExtension];
function subscribe<O extends Options<Extensions>>(
options?: O
): Subscription<T, O> {
): Subscription<Extensions, O> {
const id = Symbol();
tryOnUnmounted(() => {
@@ -131,14 +134,14 @@ const createXapiCollection = <T extends XenApiRecord<string>>(
if (options?.immediate !== false) {
start();
return subscription as unknown as Subscription<T, O>;
return subscription as Subscription<Extensions, O>;
}
return {
...subscription,
start,
isStarted: computed(() => subscriptions.value.has(id)),
} as unknown as Subscription<T, O>;
} as Subscription<Extensions, O>;
}
const unsubscribe = (id: symbol) => subscriptions.value.delete(id);

View File

@@ -1,7 +1,11 @@
```vue-template
<FormInputGroup>
<FormInput ... />
<FormInput ... />
<FormSelect ... />
<FormInput />
<FormInput />
<FormSelect>
<option>Option 1</option>
<option>Option 2</option>
<option>Option 3</option>
</FormSelect>
</FormInputGroup>
```

View File

@@ -3,12 +3,13 @@
:params="[slot().help('Can contains multiple FormInput and FormSelect')]"
>
<FormInputGroup>
<FormInput v-model="model" />
<FormInput v-model="model" />
<FormSelect
v-model="model"
:options="['Option 1', 'Option 2', 'Option 3']"
/>
<FormInput />
<FormInput />
<FormSelect>
<option>Option 1</option>
<option>Option 2</option>
<option>Option 3</option>
</FormSelect>
</FormInputGroup>
</ComponentStory>
</template>
@@ -19,7 +20,4 @@ import FormInput from "@/components/form/FormInput.vue";
import FormInputGroup from "@/components/form/FormInputGroup.vue";
import FormSelect from "@/components/form/FormSelect.vue";
import { slot } from "@/libs/story/story-param";
import { ref } from "vue";
const model = ref("");
</script>

View File

@@ -1,72 +0,0 @@
# `options` prop
## Array of strings
```ts
const options = ["Option 1", "Option 2", "Option 3"];
```
## Array of objects
```ts
const options = [
{ label: "Option 1", value: "option1" },
{ label: "Option 2", value: "option2" },
{ label: "Option 3", value: "option3" },
];
```
### Custom properties
When not using `label` and `value` properties, you can change them with `label-key` and `value-key` props.
```ts
const options = [
{ name: "Option 1", id: "option1" },
{ name: "Option 2", id: "option2" },
{ name: "Option 3", id: "option3" },
];
```
```html
<FormSelect :options="options" label-key="name" value-key="id" />
```
## Array of groups
```ts
const options = [
{
label: "Group 1",
options: [
{ label: "Option 1", value: "option1" },
{ label: "Option 2", value: "option2" },
{ label: "Option 3", value: "option3" },
],
},
{
label: "Group 2",
options: [
{ label: "Option 4", value: "option4" },
{ label: "Option 5", value: "option5" },
{ label: "Option 6", value: "option6" },
],
},
];
```
# `object` prop
```ts
const options = [
{ label: "Option 1", value: "option1" },
{ label: "Option 2", value: "option2" },
{ label: "Option 3", value: "option3" },
];
```
By default, when selection "Option 2", the value sent to `v-model` will be `option2`.
If you want to send the whole object, you can use `object` prop.
In this case, the value sent to `v-model` will be `{ label: 'Option 2', value: 'option2' }`.

View File

@@ -1,60 +0,0 @@
<template>
<ComponentStory
v-slot="{ properties }"
:params="[
model().required().type('any'),
prop('options').required().arr().preset(options),
prop('multiple').bool().widget().onChange(handleMultipleChange),
prop('labelKey')
.default('label')
.str()
.help(
'If `options` is an array of objects, item label will be extracted from this key'
),
prop('valueKey')
.default('value')
.str()
.help(
'If `options` is an array of objects, item value will be extracted from this key'
),
prop('clearable')
.bool()
.widget()
.help('When true, adds a clear button on the right side of the select'),
colorProp(),
prop('disabled').bool().widget(),
prop('object')
.bool()
.widget()
.help(
'If `options` is an array of objects, the whole object will be selected instead of only the value'
),
]"
>
<FormSelect v-if="isActive" v-bind="properties" />
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from "@/components/component-story/ComponentStory.vue";
import FormSelect from "@/components/form/FormSelect.vue";
import { colorProp, model, prop } from "@/libs/story/story-param";
import { nextTick, ref } from "vue";
const options = [
{ label: "Option 1", value: "1" },
{ label: "Option 2", value: "2" },
{ label: "Option 3", value: "3" },
];
// Workaround to prevent errors when `multiple` changes
const isActive = ref(true);
const handleMultipleChange = (isMultiple, context) => {
isActive.value = false;
context.modelValue = isMultiple ? [] : null;
nextTick(() => {
isActive.value = true;
});
};
</script>

View File

@@ -1,31 +0,0 @@
<template>
<ComponentStory
v-slot="{ properties }"
:params="[
model()
.required()
.type('string[]')
.help('List of selected tags (including created ones)'),
model('createdTags').type('string[]').help('List of created tags'),
prop('options')
.required()
.arr('string')
.preset(availableTags)
.help('List of available tags')
.widget(object()),
prop('allowNew').bool().help('Allow to create new tags').widget(),
colorProp(),
]"
>
<FormTag v-bind="properties" />
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from "@/components/component-story/ComponentStory.vue";
import FormTag from "@/components/form/FormTag.vue";
import { colorProp, model, prop } from "@/libs/story/story-param";
import { object } from "@/libs/story/story-widget";
const availableTags = ["First tag", "Second tag", "Third tag"];
</script>

View File

@@ -1,6 +0,0 @@
```typescript
type XenApiRecordGroup = {
label: string;
options: XenApiRecord[];
}[];
```

View File

@@ -1,58 +0,0 @@
<template>
<ComponentStory
v-slot="{ properties }"
:params="[
model().type('XenApiRecord').required(),
prop('multiple').bool().widget().onChange(handleMultipleChange),
prop('options')
.required()
.arr()
.type('XenApiRecord[] | XenApiRecordGroup[]')
.widget()
.preset(options),
colorProp(),
prop('disabled').bool().widget(),
]"
>
<FormXapiRecord v-if="isActive" v-bind="properties" />
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from "@/components/component-story/ComponentStory.vue";
import FormXapiRecord from "@/components/form/FormXapiRecord.vue";
import { colorProp, model, prop } from "@/libs/story/story-param";
import { nextTick, ref } from "vue";
const options = [
{
label: "ISOs - Storage Lab",
options: [
{
$ref: "1",
name_label: "AlmaLinux-8.3-x86_64-minimal.iso",
},
{
$ref: "2",
name_label: "AlmaLinux-8.5-x86_64-boot.iso",
},
{ $ref: "3", name_label: "CentOS-6.10-i386-minimal.iso" },
],
},
{
label: "XCP-ng Tools - XO Lab",
options: [{ $ref: "4", name_label: "guest-tools.iso" }],
},
];
// Workaround to prevent errors when `multiple` is changed
const isActive = ref(true);
const handleMultipleChange = (isMultiple, context) => {
isActive.value = false;
context.modelValue = isMultiple ? [] : null;
nextTick(() => {
isActive.value = true;
});
};
</script>

View File

@@ -0,0 +1,70 @@
<template>
<ComponentStory
v-slot="{ properties }"
:params="[
iconProp().preset(faRocket),
prop('label').required().str().widget().preset('Rockets'),
prop('count')
.required()
.type('string | number')
.widget(text())
.preset('175'),
]"
:presets="presets"
>
<UiResource v-bind="properties" />
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from "@/components/component-story/ComponentStory.vue";
import UiResource from "@/components/ui/resources/UiResource.vue";
import { iconProp, prop } from "@/libs/story/story-param";
import { text } from "@/libs/story/story-widget";
import {
faDatabase,
faDisplay,
faMemory,
faMicrochip,
faNetworkWired,
faRocket,
} from "@fortawesome/free-solid-svg-icons";
const presets = {
VMs: {
props: {
icon: faDisplay,
count: 1,
label: "VMs",
},
},
vCPUs: {
props: {
icon: faMicrochip,
count: 4,
label: "vCPUs",
},
},
RAM: {
props: {
icon: faMemory,
count: 2,
label: "RAM",
},
},
SR: {
props: {
icon: faDatabase,
count: 1,
label: "SR",
},
},
Interfaces: {
props: {
icon: faNetworkWired,
count: 2,
label: "Interfaces",
},
},
};
</script>

View File

@@ -0,0 +1,11 @@
# Example
```vue-template
<UiResources>
<UiResource :icon="faDisplay" count="1" label="VMs" />
<UiResource :icon="faMicrochip" count="4" label="vCPUs" />
<UiResource :icon="faMemory" count="2" label="RAM" />
<UiResource :icon="faDatabase" count="1" label="SR" />
<UiResource :icon="faNetworkWired" count="2" label="Interfaces" />
</UiResources>
```

View File

@@ -0,0 +1,28 @@
<template>
<ComponentStory
:params="[slot().help('One or multiple `UiResource`')]"
full-width-component
>
<UiResources>
<UiResource :icon="faDisplay" count="1" label="VMs" />
<UiResource :icon="faMicrochip" count="4" label="vCPUs" />
<UiResource :icon="faMemory" count="2" label="RAM" />
<UiResource :icon="faDatabase" count="1" label="SR" />
<UiResource :icon="faNetworkWired" count="2" label="Interfaces" />
</UiResources>
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from "@/components/component-story/ComponentStory.vue";
import UiResource from "@/components/ui/resources/UiResource.vue";
import UiResources from "@/components/ui/resources/UiResources.vue";
import { slot } from "@/libs/story/story-param";
import {
faDatabase,
faDisplay,
faMemory,
faMicrochip,
faNetworkWired,
} from "@fortawesome/free-solid-svg-icons";
</script>

View File

@@ -0,0 +1,74 @@
import type { XenApiRecord } from "@/libs/xen-api";
import type { ComputedRef, Ref } from "vue";
type SimpleExtension<Value extends object> = { type: "simple"; value: Value };
type ConditionalExtension<Value extends object, Condition extends object> = {
type: "conditional";
value: Value;
condition: Condition;
};
type UnpackExtension<E, Options> = E extends SimpleExtension<infer Value>
? Value
: E extends ConditionalExtension<infer Value, infer Condition>
? Options extends Condition
? Value
: object
: object;
export type Extension<
Value extends object,
Condition extends object | undefined = undefined
> = Condition extends object
? ConditionalExtension<Value, Condition>
: SimpleExtension<Value>;
export type Options<Extensions extends any[]> = Extensions extends [
infer First,
...infer Rest
]
? First extends ConditionalExtension<any, infer Condition>
? Rest extends any[]
? Partial<Condition> & Options<Rest>
: Partial<Condition>
: Rest extends any[]
? Options<Rest>
: object
: object;
export type Subscription<
Extensions extends any[],
Options extends object
> = Extensions extends [infer First, ...infer Rest]
? UnpackExtension<First, Options> & Subscription<Rest, Options>
: object;
export type PartialSubscription<E> = E extends SimpleExtension<infer Value>
? Value
: E extends ConditionalExtension<infer Value, any>
? Value
: never;
export type XenApiRecordExtension<T extends XenApiRecord<any>> = Extension<{
records: ComputedRef<T[]>;
getByOpaqueRef: (opaqueRef: T["$ref"]) => T | undefined;
getByUuid: (uuid: T["uuid"]) => T | undefined;
hasUuid: (uuid: T["uuid"]) => boolean;
isReady: Readonly<Ref<boolean>>;
isFetching: Readonly<Ref<boolean>>;
isReloading: ComputedRef<boolean>;
hasError: ComputedRef<boolean>;
lastError: Readonly<Ref<string | undefined>>;
}>;
export type DeferExtension = Extension<
{
start: () => void;
isStarted: ComputedRef<boolean>;
},
{ immediate: false }
>;
export type XenApiRecordSubscription<T extends XenApiRecord<any>> =
PartialSubscription<XenApiRecordExtension<T>>;

View File

@@ -1,140 +0,0 @@
import type {
XenApiConsole,
XenApiHost,
XenApiHostMetrics,
XenApiMessage,
XenApiPool,
XenApiRecord,
XenApiSr,
XenApiTask,
XenApiVm,
XenApiVmGuestMetrics,
XenApiVmMetrics,
} from "@/libs/xen-api";
import type { ComputedRef, Ref } from "vue";
type DefaultExtension<T extends XenApiRecord<string>> = {
records: ComputedRef<T[]>;
getByOpaqueRef: (opaqueRef: T["$ref"]) => T | undefined;
getByUuid: (uuid: T["uuid"]) => T | undefined;
hasUuid: (uuid: T["uuid"]) => boolean;
isReady: Readonly<Ref<boolean>>;
isFetching: Readonly<Ref<boolean>>;
isReloading: ComputedRef<boolean>;
hasError: ComputedRef<boolean>;
lastError: Readonly<Ref<string | undefined>>;
};
type DeferExtension = [
{
start: () => void;
isStarted: ComputedRef<boolean>;
},
{ immediate: false }
];
type DefaultExtensions<T extends XenApiRecord<string>> = [
DefaultExtension<T>,
DeferExtension
];
type GenerateSubscribeOptions<Extensions extends any[]> = Extensions extends [
infer FirstExtension,
...infer RestExtension
]
? FirstExtension extends [object, infer FirstCondition]
? FirstCondition & GenerateSubscribeOptions<RestExtension>
: GenerateSubscribeOptions<RestExtension>
: object;
export type SubscribeOptions<Extensions extends any[]> = Partial<
GenerateSubscribeOptions<Extensions> &
GenerateSubscribeOptions<DefaultExtensions<any>>
>;
type GenerateSubscription<
Options extends object,
Extensions extends any[]
> = Extensions extends [infer FirstExtension, ...infer RestExtension]
? FirstExtension extends [infer FirstObject, infer FirstCondition]
? Options extends FirstCondition
? FirstObject & GenerateSubscription<Options, RestExtension>
: GenerateSubscription<Options, RestExtension>
: FirstExtension & GenerateSubscription<Options, RestExtension>
: object;
export type Subscription<
T extends XenApiRecord<string>,
Options extends object,
Extensions extends any[] = []
> = GenerateSubscription<Options, Extensions> &
GenerateSubscription<Options, DefaultExtensions<T>>;
export function createSubscribe<
T extends XenApiRecord<string>,
Extensions extends any[],
Options extends object = SubscribeOptions<Extensions>
>(builder: (options?: Options) => Subscription<T, Options, Extensions>) {
return function subscribe<O extends Options>(
options?: O
): Subscription<T, O, Extensions> {
return builder(options);
};
}
export type RawTypeToObject = {
Bond: never;
Certificate: never;
Cluster: never;
Cluster_host: never;
DR_task: never;
Feature: never;
GPU_group: never;
PBD: never;
PCI: never;
PGPU: never;
PIF: never;
PIF_metrics: never;
PUSB: never;
PVS_cache_storage: never;
PVS_proxy: never;
PVS_server: never;
PVS_site: never;
SDN_controller: never;
SM: never;
SR: XenApiSr;
USB_group: never;
VBD: never;
VBD_metrics: never;
VDI: never;
VGPU: never;
VGPU_type: never;
VIF: never;
VIF_metrics: never;
VLAN: never;
VM: XenApiVm;
VMPP: never;
VMSS: never;
VM_guest_metrics: XenApiVmGuestMetrics;
VM_metrics: XenApiVmMetrics;
VUSB: never;
blob: never;
console: XenApiConsole;
crashdump: never;
host: XenApiHost;
host_cpu: never;
host_crashdump: never;
host_metrics: XenApiHostMetrics;
host_patch: never;
message: XenApiMessage;
network: never;
network_sriov: never;
pool: XenApiPool;
pool_patch: never;
pool_update: never;
role: never;
secret: never;
subject: never;
task: XenApiTask;
tunnel: never;
};

View File

@@ -9,6 +9,8 @@
</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";
@@ -16,6 +18,8 @@ defineProps<{
id: string;
}>();
usePageTitleStore().setTitle(useI18n().t("not-found"));
const router = useRouter();
</script>

View File

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

View File

@@ -6,6 +6,7 @@
</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";
@@ -20,6 +21,8 @@ const title = computed(() => {
return `${currentRoute.value.meta.storyTitle} Story`;
});
usePageTitleStore().setTitle(title);
</script>
<style lang="postcss" scoped></style>

View File

@@ -4,4 +4,8 @@
<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,17 +8,22 @@
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 { watchEffect } from "vue";
import { computed, 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 = getByUuid(
route.params.uuid as XenApiHost["uuid"]
)?.$ref;
uiStore.currentHostOpaqueRef = currentHost.value?.$ref;
});
usePageTitleStore().setObject(currentHost);
</script>

View File

@@ -4,4 +4,8 @@
<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>
<UiCardComingSoon class="tasks" title="Tasks" />
<PoolDashboardTasks class="tasks" />
</UiCardGroup>
</div>
</template>
@@ -31,8 +31,24 @@ 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,
@@ -40,20 +56,9 @@ import {
} from "@/types/injection-keys";
import { differenceBy } from "lodash-es";
import { provide, watch } from "vue";
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";
import { useI18n } from "vue-i18n";
usePageTitleStore().setTitle(useI18n().t("dashboard"));
const hostMetricsSubscription = useHostMetricsStore().subscribe();
@@ -124,6 +129,18 @@ 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,4 +4,8 @@
<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,4 +4,8 @@
<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,6 +10,11 @@
<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,4 +4,8 @@
<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,4 +4,8 @@
<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,4 +4,8 @@
<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,58 +4,29 @@
{{ $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 { useTitle } from "@vueuse/core";
import { computed } from "vue";
import { usePageTitleStore } from "@/stores/page-title.store";
import { useI18n } from "vue-i18n";
const { records, hasError } = useTaskStore().subscribe();
const { pendingTasks, finishedTasks, isReady, hasError } =
useTaskStore().subscribe();
const { t } = useI18n();
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 }))
);
const titleStore = usePageTitleStore();
titleStore.setTitle(t("tasks"));
titleStore.setCount(() => pendingTasks.value.length);
</script>
<style lang="postcss" scoped>

View File

@@ -38,6 +38,7 @@ 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";
@@ -46,9 +47,13 @@ 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" },
@@ -62,6 +67,8 @@ const filters: Filters = {
};
const selectedVmsRefs = ref([]);
titleStore.setCount(() => selectedVmsRefs.value.length);
</script>
<style lang="postcss" scoped>

View File

@@ -157,6 +157,7 @@
</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";
@@ -181,7 +182,9 @@ import UiKeyValueRow from "@/components/ui/UiKeyValueRow.vue";
const xoLiteVersion = XO_LITE_VERSION;
const xoLiteGitHead = XO_LITE_GIT_HEAD;
const { locale } = useI18n();
const { t, locale } = useI18n();
usePageTitleStore().setTitle(() => t("settings"));
const { pool } = usePoolStore().subscribe();
const { getByOpaqueRef: getHost } = useHostStore().subscribe();

View File

@@ -4,7 +4,12 @@
<div class="row">
Choose a component
<FormSelect v-model="componentPath" :options="componentPaths" />
<FormSelect v-model="componentPath">
<option value="" />
<option v-for="path in componentPaths" :key="path">
{{ path }}
</option>
</FormSelect>
</div>
<div class="row">

View File

@@ -4,4 +4,8 @@
<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

@@ -1,21 +1,46 @@
<template>
<div v-if="!isReady">Loading...</div>
<div v-else-if="!isVmRunning">Console is only available for running VMs.</div>
<RemoteConsole
v-else-if="vm && vmConsole"
:location="vmConsole.location"
:is-console-available="!isOperationsPending(vm, STOP_OPERATIONS)"
/>
<div :class="{ 'no-ui': !uiStore.hasUi }" class="vm-console-view">
<div v-if="hasError">{{ $t("error-occurred") }}</div>
<UiSpinner v-else-if="!isReady" class="spinner" />
<div v-else-if="!isVmRunning" class="not-running">
<div><img alt="" src="@/assets/monitor.svg" /></div>
{{ $t("power-on-for-console") }}
</div>
<template v-else-if="vm && vmConsole">
<RemoteConsole
:is-console-available="isConsoleAvailable"
:location="vmConsole.location"
class="remote-console"
/>
<div class="open-in-new-window">
<RouterLink
v-if="uiStore.hasUi"
:to="{ query: { ui: '0' } }"
class="link"
target="_blank"
>
<UiIcon :icon="faArrowUpRightFromSquare" />
{{ $t("open-in-new-window") }}
</RouterLink>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import { POWER_STATE, VM_OPERATION, type XenApiVm } from "@/libs/xen-api";
import { computed } from "vue";
import { useRoute } from "vue-router";
import RemoteConsole from "@/components/RemoteConsole.vue";
import { useConsoleStore } from "@/stores/console.store";
import { useVmStore } from "@/stores/vm.store";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiSpinner from "@/components/ui/UiSpinner.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 { useUiStore } from "@/stores/ui.store";
import { useVmStore } from "@/stores/vm.store";
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
const STOP_OPERATIONS = [
VM_OPERATION.SHUTDOWN,
@@ -27,15 +52,27 @@ const STOP_OPERATIONS = [
VM_OPERATION.SUSPEND,
];
usePageTitleStore().setTitle(useI18n().t("console"));
const route = useRoute();
const uiStore = useUiStore();
const { isReady: isVmReady, getByUuid: getVmByUuid } = useVmStore().subscribe();
const {
isReady: isVmReady,
getByUuid: getVmByUuid,
hasError: hasVmError,
} = useVmStore().subscribe();
const { isReady: isConsoleReady, getByOpaqueRef: getConsoleByOpaqueRef } =
useConsoleStore().subscribe();
const {
isReady: isConsoleReady,
getByOpaqueRef: getConsoleByOpaqueRef,
hasError: hasConsoleError,
} = useConsoleStore().subscribe();
const isReady = computed(() => isVmReady.value && isConsoleReady.value);
const hasError = computed(() => hasVmError.value || hasConsoleError.value);
const vm = computed(() => getVmByUuid(route.params.uuid as XenApiVm["uuid"]));
const isVmRunning = computed(
@@ -51,4 +88,74 @@ const vmConsole = computed(() => {
return getConsoleByOpaqueRef(consoleOpaqueRef);
});
const isConsoleAvailable = computed(
() =>
vm.value !== undefined && !isOperationsPending(vm.value, STOP_OPERATIONS)
);
</script>
<style lang="postcss" scoped>
.vm-console-view {
display: flex;
align-items: center;
justify-content: center;
height: calc(100% - 14.5rem);
&.no-ui {
height: 100%;
}
}
.spinner {
color: var(--color-extra-blue-base);
display: flex;
margin: auto;
width: 10rem;
height: 10rem;
}
.remote-console {
flex: 1;
max-width: 100%;
height: 100%;
}
.not-running,
.not-available {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
text-align: center;
gap: 4rem;
color: var(--color-extra-blue-base);
font-size: 3.6rem;
}
.open-in-new-window {
position: absolute;
top: 0;
right: 0;
overflow: hidden;
& > .link {
display: flex;
align-items: center;
gap: 1rem;
background-color: var(--color-extra-blue-base);
color: var(--color-blue-scale-500);
text-decoration: none;
padding: 1.5rem;
font-size: 1.6rem;
border-radius: 0 0 0 0.8rem;
white-space: nowrap;
transform: translateX(calc(100% - 4.5rem));
transition: transform 0.2s ease-in-out;
&:hover {
transform: translateX(0);
}
}
}
</style>

View File

@@ -4,4 +4,8 @@
<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,4 +4,8 @@
<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

@@ -1,7 +1,9 @@
<template>
<ObjectNotFoundWrapper :is-ready="isReady" :uuid-checker="hasUuid">
<VmHeader />
<VmTabBar :uuid="vm!.uuid" />
<template v-if="uiStore.hasUi">
<VmHeader />
<VmTabBar :uuid="vm!.uuid" />
</template>
<RouterView />
</ObjectNotFoundWrapper>
</template>
@@ -11,6 +13,7 @@ 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";
@@ -22,4 +25,5 @@ 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,4 +4,8 @@
<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,4 +4,8 @@
<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>

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