From 0c1947aba26b65986e8c5f7d8cd91967fd645166 Mon Sep 17 00:00:00 2001 From: Mathieu <70369997+MathieuRA@users.noreply.github.com> Date: Tue, 10 Jun 2025 14:41:05 +0200 Subject: [PATCH] feat(@xen-orchestra/rest-api): expose dashboard endpoint (#8580) --- @vates/types/package.json | 4 + @vates/types/src/common.mts | 4 + @vates/types/src/lib/xen-orchestra-xapi.mts | 61 +++ @vates/types/src/xen-api.mts | 27 +- @vates/types/src/xo.mts | 86 ++- @xen-orchestra/rest-api/package.json | 10 +- .../rest-api/src/helpers/cache.helper.mts | 78 +++ .../src/helpers/cache.helper.test.mts | 89 +++ .../rest-api/src/helpers/utils.helper.mts | 5 + @xen-orchestra/rest-api/src/ioc/ioc.mts | 9 + .../open-api/oa-examples/xoa.oa-example.mts | 61 +++ .../rest-api/src/rest-api/rest-api.mts | 5 +- .../rest-api/src/rest-api/rest-api.type.mts | 44 +- .../rest-api/src/xoa/xoa.controller.mts | 30 ++ .../rest-api/src/xoa/xoa.service.mts | 506 ++++++++++++++++++ @xen-orchestra/rest-api/src/xoa/xoa.type.mts | 66 +++ CHANGELOG.unreleased.md | 1 + packages/xo-server/src/utils.mjs | 82 --- packages/xo-server/src/utils.test.mjs | 106 +--- packages/xo-server/src/xo-mixins/rest-api.mjs | 396 +------------- yarn.lock | 45 ++ 21 files changed, 1095 insertions(+), 620 deletions(-) create mode 100644 @vates/types/src/lib/xen-orchestra-xapi.mts create mode 100644 @xen-orchestra/rest-api/src/helpers/cache.helper.mts create mode 100644 @xen-orchestra/rest-api/src/helpers/cache.helper.test.mts create mode 100644 @xen-orchestra/rest-api/src/helpers/utils.helper.mts create mode 100644 @xen-orchestra/rest-api/src/open-api/oa-examples/xoa.oa-example.mts create mode 100644 @xen-orchestra/rest-api/src/xoa/xoa.controller.mts create mode 100644 @xen-orchestra/rest-api/src/xoa/xoa.service.mts create mode 100644 @xen-orchestra/rest-api/src/xoa/xoa.type.mts diff --git a/@vates/types/package.json b/@vates/types/package.json index 097507b647..ab3f2efc30 100644 --- a/@vates/types/package.json +++ b/@vates/types/package.json @@ -17,6 +17,10 @@ "./lib/vates/*": { "default": "./dist/lib/vates-*.mjs", "type": "./dist/lib/vates-*.d.mts" + }, + "./lib/xen-orchestra/*": { + "default": "./dist/lib/xen-orchestra-*.mjs", + "type": "./dist/lib/xen-orchestra-*.d.mts" } }, "type": "module", diff --git a/@vates/types/src/common.mts b/@vates/types/src/common.mts index 0627db2adb..4eb5df7168 100644 --- a/@vates/types/src/common.mts +++ b/@vates/types/src/common.mts @@ -649,6 +649,10 @@ export const OPAQUE_REF = { EMPTY: 'OpaqueRef:NULL' } as const export type OPAQUE_REF_NULL = (typeof OPAQUE_REF)['EMPTY'] +export const BACKUP_TYPE = { backup: 'backup', metadata: 'metadataBackup', mirror: 'mirrorBackup' } as const + +export type BACKUP_TYPE = (typeof BACKUP_TYPE)[keyof typeof BACKUP_TYPE] + // ----- XAPI Stats type XapiStatsResponse = { diff --git a/@vates/types/src/lib/xen-orchestra-xapi.mts b/@vates/types/src/lib/xen-orchestra-xapi.mts new file mode 100644 index 0000000000..5ce7c83b80 --- /dev/null +++ b/@vates/types/src/lib/xen-orchestra-xapi.mts @@ -0,0 +1,61 @@ +import { WrappedXenApiRecord, XenApiNetworkWrapped, XenApiRecord } from '../xen-api.mjs' +import type { XoHost, XoNetwork, XoPif } from '../xo.mjs' + +type XcpPatches = { + changelog?: { + author: string + date: number + description: string + } + description: string + license: string + name: string + release: string + size: number + url: string + version: string +} +type XsPatches = { + conflicts?: string[] + date: string + description: string + documentationUrl?: string + guidances: string + name: string + id?: string + paid?: boolean + requirements?: string[] + upgrade?: boolean + url?: string + uuid?: string +} + +export interface Xapi { + call: (...args: unknown[]) => Promise + callAsync: (...args: unknown[]) => Promise + + getField( + type: Extract['$type'], + ref: T['$ref'], + field: K + ): Promise + createNetwork( + params: + | { + name: string + description?: string + mtu?: number + } + | { + name: string + description?: string + pifId: XoPif['id'] + mtu?: number + /* between 0 and 4094 */ + vlan: number + } + ): Promise + deleteNetwork(id: XoNetwork['id']): Promise + listMissingPatches(host: XoHost['id']): Promise + pool_emergencyShutdown(): Promise +} diff --git a/@vates/types/src/xen-api.mts b/@vates/types/src/xen-api.mts index fea023894f..391b12fb27 100644 --- a/@vates/types/src/xen-api.mts +++ b/@vates/types/src/xen-api.mts @@ -64,7 +64,7 @@ import type { VTPM_OPERATIONS, VUSB_OPERATIONS, } from './common.mjs' -import type { XoNetwork, XoPif } from './xo.mjs' +import type { Xapi } from './lib/xen-orchestra-xapi.mjs' // types automatically generated by the @ByScripts script. // https://github.com/vatesfr/xen-orchestra/tree/wip-xapi-types-generator/%40xen-orchestra/xapi-generator @@ -84,30 +84,7 @@ type WrapperXenApi = T & { name_label?: string unplugVusbs?: boolean }): Promise - $xapi: { - call: (...args: unknown[]) => Promise - callAsync: (...args: unknown[]) => Promise - getField(type: Type, ref: T['$ref'], field: K): Promise - - createNetwork( - params: - | { - name: string - description?: string - mtu?: number - } - | { - name: string - description?: string - pifId: XoPif['id'] - mtu?: number - /* between 0 and 4094 */ - vlan: number - } - ): Promise - deleteNetwork(id: XoNetwork['id']): Promise - pool_emergencyShutdown(): Promise - } + $xapi: Xapi } export interface XenApiSession { diff --git a/@vates/types/src/xo.mts b/@vates/types/src/xo.mts index fd319dbfeb..712143fabb 100644 --- a/@vates/types/src/xo.mts +++ b/@vates/types/src/xo.mts @@ -1,6 +1,7 @@ // Types based on xapi-object-to-xo import type { + BACKUP_TYPE, Branded, DOMAIN_TYPE, HOST_ALLOWED_OPERATIONS, @@ -131,6 +132,17 @@ export type XoAlarm = Omit & { } } +export type XoBackupRepository = { + benchmarks?: { readRate: number; timestamp: number; writeRate: number }[] + enabled: boolean + error?: Record + id: Branded<'backup-repository'> + name: string + options?: string + proxy?: XoProxy['id'] + url: string +} + export type XoGroup = { id: Branded<'group'> name: string @@ -335,15 +347,65 @@ export type XoPool = BaseXapiXo & { zstdSupported: boolean } -export type XoJob = { +export type XoProxy = { + id: Branded<'proxy'> +} + +type BaseXoJob = { id: Branded<'job'> } +// @TODO: create type for complex matcher +export type XoBackupJob = BaseXoJob & { + compression?: 'native' | 'zstd' | '' + proxy?: XoProxy['id'] + mode: 'full' | 'delta' + name?: string + remotes?: { + id: XoBackupRepository['id'] | { __or: XoBackupRepository['id'][] } + } + vms?: { + id: XoVm['id'] | { __or: XoVm['id'][] } | Record + } + srs: { + id: XoSr['id'] | { __or: XoSr['id'][] } + } + type: BACKUP_TYPE + settings: { + '': { + cbtDestroySnapshotData?: boolean + concurrency?: number + longTermRetention?: { + daily?: { retention: number; settings: Record } + weekly?: { retention: number; settings: Record } + monthly?: { retention: number; settings: Record } + yearly?: { retention: number; settings: Record } + } + maxExportRate?: number + nbdConcurrency?: number + nRetriesVmBackupFailures?: number + preferNbd?: boolean + timezone?: string + [key: string]: unknown + } + [key: XoSchedule['id']]: { + exportRetention?: number + healthCheckSr?: XoSr['id'] + healthCheckVmsWithTags?: string[] + fullInterval?: number + copyRetention?: number + snapshotRetention?: number + cbtDestroySnapshotData?: boolean + [key: string]: unknown + } + } +} +export type XoJob = BaseXoJob & {} export type XoSchedule = { cron: string - enable: boolean + enabled: boolean id: Branded<'schedule'> - jobId: XoJob['id'] + jobId: (XoJob | XoBackupJob)['id'] name?: string timezone?: string } @@ -370,7 +432,7 @@ export type XoSr = BaseXapiXo & { $container: XoPool['id'] | XoHost['id'] - VDIs: XoVdi['id'][] + VDIs: AnyXoVdi['id'][] allocationStrategy: 'thin' | 'thick' | 'unknown' content_type: string @@ -380,9 +442,9 @@ export type XoSr = BaseXapiXo & { name_description: string name_label: string other_config: Record - physical_usage: number | null + physical_usage: number shared: boolean - size: number | null + size: number sm_config: Record SR_type: string tags: string[] @@ -410,8 +472,8 @@ export type XoVbd = BaseXapiXo & { position: string read_only: boolean type: 'VBD' - VDI: XoVdi['id'] - VM: XoVm['id'] + VDI: AnyXoVdi['id'] + VM: AnyXoVm['id'] } type BaseXoVdi = BaseXapiXo & { @@ -540,6 +602,12 @@ export type XapiXoRecord = | XoVmTemplate | XoVtpm -export type NonXapiXoRecord = XoGroup | XoJob | XoSchedule | XoServer | XoUser +export type NonXapiXoRecord = XoGroup | XoProxy | XoJob | XoBackupRepository | XoSchedule | XoServer | XoUser export type XoRecord = XapiXoRecord | NonXapiXoRecord + +export type AnyXoVm = XoVm | XoVmSnapshot | XoVmTemplate | XoVmController + +export type AnyXoVdi = XoVdi | XoVdiSnapshot | XoVdiUnmanaged + +export type AnyXoJob = XoJob | XoBackupJob diff --git a/@xen-orchestra/rest-api/package.json b/@xen-orchestra/rest-api/package.json index 2c81dc6581..da6a51f0fa 100644 --- a/@xen-orchestra/rest-api/package.json +++ b/@xen-orchestra/rest-api/package.json @@ -21,7 +21,8 @@ "prepublishOnly": "npm run build", "postversion": "npm publish --access public", "prebuild": "npm run clean", - "predev": "npm run clean" + "predev": "npm run clean", + "test": "npm run build && node --test dist/**/*.test.mjs" }, "devDependencies": { "@eslint/js": "^9.19.0", @@ -32,15 +33,20 @@ "typescript-eslint": "^8.23.0" }, "dependencies": { + "@vates/async-each": "^1.0.0", "@vates/types": "^1.4.1", + "@xen-orchestra/backups": "^0.59.0", "@xen-orchestra/log": "^0.7.1", "complex-matcher": "^0.7.1", "inversify": "^6.2.2", "inversify-binding-decorators": "^4.0.0", "lodash": "^4.17.21", + "semver": "^7.7.2", "swagger-ui-express": "^5.0.1", "tsoa": "^6.6.0", - "xo-common": "^0.8.0" + "value-matcher": "^0.2.0", + "xo-common": "^0.8.0", + "xo-remote-parser": "^0.9.3" }, "bugs": "https://github.com/vatesfr/xen-orchestra/issues", "repository": { diff --git a/@xen-orchestra/rest-api/src/helpers/cache.helper.mts b/@xen-orchestra/rest-api/src/helpers/cache.helper.mts new file mode 100644 index 0000000000..a53891491c --- /dev/null +++ b/@xen-orchestra/rest-api/src/helpers/cache.helper.mts @@ -0,0 +1,78 @@ +import { MaybePromise } from './helper.type.mjs' + +export type AsyncCacheEntry = { + current: T | Promise + expires?: number + previous?: T | Promise +} + +/** + * + * If the value is cached and not expired, it will return + * the cached value immediately. If expired or not present, it will invoke the provided + * function to fetch the value, cache it, and return it. + * + * The function also handles timeout for fetching the value, ensuring that if fetching + * takes too long, it resolves to `undefined` or returns the expired value based on the + * cache's state. + * + */ +export async function getFromAsyncCache( + cache: Map>>, + key: string, + fn: () => Promise, + { expiresIn = 60000, timeout = 5000, forceRefresh = false } = {} +): Promise<{ value: T | undefined; isExpired?: true } | undefined> { + if (forceRefresh) { + cache.delete(key) + } + + const { current, expires } = cache.get(key) ?? {} + if (current === undefined || (expires ?? 0) < Date.now()) { + const _promise = fn() + + const promise = _promise.then(result => { + cache.set(key, { + current: result, + expires: Date.now() + expiresIn, + previous: undefined, + }) + + return result + }) + + cache.set(key, { + current: promise, + previous: current, + expires: undefined, + }) + } + + let timeoutId + const timeoutPromise = new Promise( + (resolve, reject) => + (timeoutId = setTimeout(() => reject(new Error('Promise timed out', { cause: 'ERR_TIMEOUT' })), timeout)) + ) + + const result = {} as { + value: T | undefined + isExpired?: true + } + + try { + result.value = await Promise.race([timeoutPromise, cache.get(key)!.current]) + } catch (error) { + if (error instanceof Error && error.cause !== 'ERR_TIMEOUT') { + throw error + } + + result.value = await cache.get(key)!.previous + if (result.value !== undefined) { + result.isExpired = true + } + } finally { + clearTimeout(timeoutId) + } + + return result +} diff --git a/@xen-orchestra/rest-api/src/helpers/cache.helper.test.mts b/@xen-orchestra/rest-api/src/helpers/cache.helper.test.mts new file mode 100644 index 0000000000..58e9bf9a0c --- /dev/null +++ b/@xen-orchestra/rest-api/src/helpers/cache.helper.test.mts @@ -0,0 +1,89 @@ +import assert from 'node:assert' +import { describe, it } from 'node:test' + +import { getFromAsyncCache } from './cache.helper.mjs' + +describe('getFromAsyncCache()', function () { + const cacheTest = new Map() + const cacheTimeout = 500 + const cacheExpiresIn = 1000 + const cacheTestOps = { timeout: cacheTimeout, expiresIn: cacheExpiresIn } + const sleep = times => new Promise(resolve => setTimeout(resolve, times)) + + // start the promise, advance time and return the promise + const _getFromAsyncCache = async (t, ms, cache, key, fn, opts) => { + const p = getFromAsyncCache(cache, key, fn, opts) + t.mock.timers.tick(ms) + return p + } + + it('Ensure the callback is called', async t => { + const cb = t.mock.fn(async () => {}) + + assert.equal(cb.mock.callCount(), 0) + await getFromAsyncCache(cacheTest, 'simpleTest', cb) + assert.equal(cb.mock.callCount(), 1) + }) + + it('Returns the computed value', async function () { + const result = await getFromAsyncCache(cacheTest, 'simpleTest', async () => 'foo') + assert.equal(result?.value, 'foo') + }) + + it('Returns the cached value', async function () { + const result = await getFromAsyncCache(cacheTest, 'simpleTest', async () => 'bar') + assert.equal(result?.value, 'foo') + }) + + it('Recomputes the value if forceRefresh is passed', async function () { + const result = await getFromAsyncCache(cacheTest, 'simpleTest', async () => 'baz', { + forceRefresh: true, + }) + assert.equal(result?.value, 'baz') + }) + + it('Returns undefined if the fn takes too long to execute, then returns the computed value when the promise is resolved', async function (t) { + t.mock.timers.enable({ apis: ['setTimeout'] }) + const cb = async () => { + await sleep(cacheTimeout * 2.5) + return 'foo' + } + + const result = await _getFromAsyncCache(t, cacheTimeout, cacheTest, 'timeout', cb, cacheTestOps) + assert.equal(result?.value, undefined) + + const secondResult = await _getFromAsyncCache(t, cacheTimeout, cacheTest, 'timeout', async () => {}, cacheTestOps) + assert.equal(secondResult?.value, undefined) + + t.mock.timers.tick(cacheTimeout) + }) + + it('If cached value is expired, returns the new computed value', async function (t) { + t.mock.timers.enable({ apis: ['Date'], now: 0 }) + + const result = await getFromAsyncCache(cacheTest, 'expired', async () => 'foo', cacheTestOps) + assert.equal(result?.value, 'foo') + + t.mock.timers.setTime(cacheExpiresIn * 2) + + const secondResult = await getFromAsyncCache(cacheTest, 'expired', async () => 'bar', cacheTestOps) + assert.equal(secondResult?.value, 'bar') + }) + + it('If cached value is expired and the fn takes too long time to execute, returns the expired cached value with "isExpired" property and updates the cache when the promise is resolved', async function (t) { + t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 0 }) + const cb = async () => { + await sleep(cacheTimeout * 1.5) + return 'bar' + } + + const result = await getFromAsyncCache(cacheTest, 'expiredAndTimeout', async () => 'foo', cacheTestOps) + assert.equal(result?.value, 'foo') + + t.mock.timers.setTime(cacheExpiresIn * 2) + + const secondResult = await _getFromAsyncCache(t, cacheTimeout, cacheTest, 'expiredAndTimeout', cb, cacheTestOps) + assert.equal(secondResult?.value, 'foo') + assert.equal(secondResult?.isExpired, true) + }) +}) diff --git a/@xen-orchestra/rest-api/src/helpers/utils.helper.mts b/@xen-orchestra/rest-api/src/helpers/utils.helper.mts new file mode 100644 index 0000000000..12cb5d6e66 --- /dev/null +++ b/@xen-orchestra/rest-api/src/helpers/utils.helper.mts @@ -0,0 +1,5 @@ +import { AnyXoVm, XoSr } from '@vates/types' + +export const isSrWritable = (sr: XoSr) => sr.content_type !== 'iso' && sr.size > 0 +export const isReplicaVm = (vm: AnyXoVm) => 'start' in vm.blockedOperations && vm.other['xo:backup:job'] !== undefined +export const vmContainsNoBakTag = (vm: AnyXoVm) => vm.tags.some(t => t.split('=', 1)[0] === 'xo:no-bak') diff --git a/@xen-orchestra/rest-api/src/ioc/ioc.mts b/@xen-orchestra/rest-api/src/ioc/ioc.mts index 9bf2f859a1..9bf9768180 100644 --- a/@xen-orchestra/rest-api/src/ioc/ioc.mts +++ b/@xen-orchestra/rest-api/src/ioc/ioc.mts @@ -4,6 +4,7 @@ import { Controller } from 'tsoa' import { RestApi } from '../rest-api/rest-api.mjs' import type { XoApp } from '../rest-api/rest-api.type.mjs' +import { XoaService } from '../xoa/xoa.service.mjs' const iocContainer = new Container() @@ -19,6 +20,14 @@ export function setupContainer(xoApp: XoApp) { .bind(RestApi) .toDynamicValue(() => new RestApi(xoApp)) .inSingletonScope() + + iocContainer + .bind(XoaService) + .toDynamicValue(ctx => { + const restApi = ctx.container.get(RestApi) + return new XoaService(restApi) + }) + .inSingletonScope() } export { iocContainer } diff --git a/@xen-orchestra/rest-api/src/open-api/oa-examples/xoa.oa-example.mts b/@xen-orchestra/rest-api/src/open-api/oa-examples/xoa.oa-example.mts new file mode 100644 index 0000000000..2562005c92 --- /dev/null +++ b/@xen-orchestra/rest-api/src/open-api/oa-examples/xoa.oa-example.mts @@ -0,0 +1,61 @@ +export const xoaDashboard = { + nPools: 2, + nHosts: 5, + backupRepositories: { + s3: { + size: { + backups: 286295393792, + }, + }, + other: { + size: { + available: 62630354944, + backups: 20684251648, + other: 66875031040, + total: 150189637632, + used: 87559282688, + }, + }, + }, + resourcesOverview: { + nCpus: 52, + memorySize: 107374182400, + srSize: 751123595264, + }, + poolsStatus: { + connected: 2, + unreachable: 7, + unknown: 0, + }, + nHostsEol: 0, + missingPatches: { + hasAuthorization: true, + nHostsFailed: 1, + nHostsWithMissingPatches: 4, + nPoolsWithMissingPatches: 2, + }, + storageRepositories: { + size: { + available: 628454834176, + other: 122641256960, + replicated: 27504128, + total: 751123595264, + used: 122668761088, + }, + }, + backups: { + jobs: { + disabled: 8, + failed: 0, + skipped: 0, + successful: 0, + total: 8, + }, + issues: [], + vmsProtection: { + protected: 0, + unprotected: 0, + notInJob: 20, + }, + }, +} diff --git a/@xen-orchestra/rest-api/src/rest-api/rest-api.mts b/@xen-orchestra/rest-api/src/rest-api/rest-api.mts index 1cc2f19064..5014d9d21d 100644 --- a/@xen-orchestra/rest-api/src/rest-api/rest-api.mts +++ b/@xen-orchestra/rest-api/src/rest-api/rest-api.mts @@ -25,7 +25,10 @@ export class RestApi { return this.#xoApp.getObject(id, type) } - getObjectsByType(type: T['type'], opts: Parameters[1]) { + getObjectsByType( + type: T['type'], + opts?: { filter?: string | ((obj: T) => boolean); limit?: number } + ) { return this.#xoApp.getObjectsByType(type, opts) } diff --git a/@xen-orchestra/rest-api/src/rest-api/rest-api.type.mts b/@xen-orchestra/rest-api/src/rest-api/rest-api.type.mts index a2f31ce8b3..a9ec4bc80a 100644 --- a/@xen-orchestra/rest-api/src/rest-api/rest-api.type.mts +++ b/@xen-orchestra/rest-api/src/rest-api/rest-api.type.mts @@ -1,6 +1,7 @@ import type { EventEmitter } from 'node:events' import type { Task } from '@vates/types/lib/vates/task' -import type { XapiHostStats, XapiVmStats, XapiStatsGranularity } from '@vates/types/common' +import type { Xapi } from '@vates/types/lib/xen-orchestra/xapi' +import type { XapiHostStats, XapiVmStats, XapiStatsGranularity, BACKUP_TYPE } from '@vates/types/common' import type { XenApiHostWrapped, XenApiMessage, @@ -16,7 +17,19 @@ import type { XenApiVmWrapped, XenApiVtpmWrapped, } from '@vates/types/xen-api' -import type { XoHost, XoServer, XoUser, XapiXoRecord, XoVm, XoSchedule, XoJob, XoGroup, XoPool } from '@vates/types/xo' +import type { + AnyXoJob, + XoBackupRepository, + XoHost, + XoServer, + XoUser, + XapiXoRecord, + XoVm, + XoSchedule, + XoJob, + XoGroup, + XoPool +} from '@vates/types/xo' import type { InsertableXoServer } from '../servers/server.type.mjs' @@ -42,6 +55,9 @@ type XapiRecordByXapiXoRecord = { } export type XoApp = { + config: { + getOptionalDuration(path: string): number | undefined + } tasks: EventEmitter & { create: (params: { name: string; objectId?: string; type?: string }) => Task } @@ -59,22 +75,46 @@ export type XoApp = { /* disconnect a server (XCP-ng/XenServer) */ disconnectXenServer(id: XoServer['id']): Promise getAllGroups(): Promise + getAllJobs(type?: BACKUP_TYPE): Promise + getAllRemotes(): Promise + getAllRemotesInfo(): Promise< + Record< + XoBackupRepository['id'], + { + size?: number + used: number + available?: number + encryption: { + algorithm: string + isLegacy: boolean + recommanded: string + } + } + > + > getAllSchedules(): Promise getAllUsers(): Promise getAllXenServers(): Promise + // @TODO: Correctly type this methods and XoLogs when migrate the endpoint "backup/logs" + getBackupNgLogsSorted(opts: { filter: (log: Record) => boolean }): Promise[]> getGroup(id: XoGroup['id']): Promise + getHVSupportedVersions: undefined | (() => Promise<{ [key: XoHost['productBrand']]: string }>) getJob(id: XoJob['id']): Promise getObject: (id: T['id'], type?: T['type']) => T getObjectsByType: ( type: T['type'], opts?: { filter?: string | ((obj: T) => boolean); limit?: number } ) => Record + getTotalBackupSizeOnRemote(id: XoBackupRepository['id']): Promise<{ onDisk: number }> getSchedule(id: XoSchedule['id']): Promise getUser: (id: XoUser['id']) => Promise + getXapi(maybeId: XapiXoRecord['id'] | XapiXoRecord): Xapi getXapiHostStats: (hostId: XoHost['id'], granularity?: XapiStatsGranularity) => Promise getXapiObject: (maybeId: T['id'] | T, type: T['type']) => XapiRecordByXapiXoRecord[T['type']] getXapiVmStats: (vmId: XoVm['id'], granularity?: XapiStatsGranularity) => Promise getXenServer(id: XoServer['id']): Promise + hasFeatureAuthorization(featureCode: string): Promise + hasObject(id: T['id'], type: T['type']): boolean /** Allow to add a new server in the DB (XCP-ng/XenServer) */ registerXenServer(body: InsertableXoServer): Promise rollingPoolReboot(pool: XoPool, opts?: { parentTask?: Task }): Promise diff --git a/@xen-orchestra/rest-api/src/xoa/xoa.controller.mts b/@xen-orchestra/rest-api/src/xoa/xoa.controller.mts new file mode 100644 index 0000000000..a97d2e2f28 --- /dev/null +++ b/@xen-orchestra/rest-api/src/xoa/xoa.controller.mts @@ -0,0 +1,30 @@ +import { Controller, Example, Get, Response, Route, Security, Tags } from 'tsoa' +import { inject } from 'inversify' +import { provide } from 'inversify-binding-decorators' + +import type { XoaDashboard } from './xoa.type.mjs' + +import { unauthorizedResp } from '../open-api/common/response.common.mjs' +import { xoaDashboard } from '../open-api/oa-examples/xoa.oa-example.mjs' +import { XoaService } from './xoa.service.mjs' + +@Route('') +@Security('*') +@Response(unauthorizedResp.status, unauthorizedResp.description) +@Tags('xoa') +@provide(XoaController) +export class XoaController extends Controller { + #xoaService: XoaService + + constructor(@inject(XoaService) xoaService: XoaService) { + super() + this.#xoaService = xoaService + } + + @Example(xoaDashboard) + @Get('dashboard') + async getDashboard(): Promise { + const dashboard = await this.#xoaService.getDashboard() + return dashboard + } +} diff --git a/@xen-orchestra/rest-api/src/xoa/xoa.service.mts b/@xen-orchestra/rest-api/src/xoa/xoa.service.mts new file mode 100644 index 0000000000..a2aab189cd --- /dev/null +++ b/@xen-orchestra/rest-api/src/xoa/xoa.service.mts @@ -0,0 +1,506 @@ +import groupBy from 'lodash/groupBy.js' +import semver from 'semver' +import { + AnyXoVdi, + AnyXoVm, + BACKUP_TYPE, + XoBackupJob, + XoHost, + XoPool, + XoSchedule, + XoSr, + XoVbd, + XoVm, +} from '@vates/types' +import { asyncEach } from '@vates/async-each' +import { createLogger } from '@xen-orchestra/log' +import { createPredicate } from 'value-matcher' +import { extractIdsFromSimplePattern } from '@xen-orchestra/backups/extractIdsFromSimplePattern.mjs' +import { noSuchObject } from 'xo-common/api-errors.js' +import { parse } from 'xo-remote-parser' + +import { type AsyncCacheEntry, getFromAsyncCache } from '../helpers/cache.helper.mjs' +import { DashboardBackupRepositoriesSizeInfo, DashboardBackupsInfo, XoaDashboard } from './xoa.type.mjs' +import { isReplicaVm, isSrWritable, vmContainsNoBakTag } from '../helpers/utils.helper.mjs' +import type { MaybePromise } from '../helpers/helper.type.mjs' +import { RestApi } from '../rest-api/rest-api.mjs' + +const log = createLogger('xo:rest-api:xoa-service') + +type DashboardAsyncCache = { + backupRepositories: MaybePromise + backups: MaybePromise +} + +export class XoaService { + #restApi: RestApi + #dashboardAsyncCache = new Map< + keyof DashboardAsyncCache, + AsyncCacheEntry + >() + #dashboardCacheOpts: { timeout?: number; expiresIn?: number } + + constructor(restApi: RestApi) { + this.#restApi = restApi + this.#dashboardCacheOpts = { + timeout: this.#restApi.xoApp.config.getOptionalDuration('rest-api.dashboardCacheTimeout'), + expiresIn: this.#restApi.xoApp.config.getOptionalDuration('rest-api.dashboardCacheExpiresIn'), + } + } + + async #getBackupRepositoriesSizeInfo(): Promise< + (DashboardBackupRepositoriesSizeInfo & { isExpired?: true }) | undefined + > { + const brResult = await getFromAsyncCache( + this.#dashboardAsyncCache as Map< + 'backupRepositories', + AsyncCacheEntry + >, + 'backupRepositories', + async () => { + const xoApp = this.#restApi.xoApp + + const s3Brsize: DashboardBackupRepositoriesSizeInfo['s3']['size'] = { backups: 0 } + const otherBrSize: DashboardBackupRepositoriesSizeInfo['other']['size'] = { + available: 0, + backups: 0, + other: 0, + total: 0, + used: 0, + } + + const backupRepositories = await xoApp.getAllRemotes() + const backupRepositoriesInfo = await xoApp.getAllRemotesInfo() + for (const backupRepository of backupRepositories) { + const { type } = parse(backupRepository.url) + const backupRepositoryInfo = backupRepositoriesInfo[backupRepository.id] + + if (!backupRepository.enabled || backupRepositoryInfo === undefined) { + continue + } + + const totalBackupSize = await xoApp.getTotalBackupSizeOnRemote(backupRepository.id) + + const { available, size, used } = backupRepositoryInfo + + const isS3 = type === 's3' + const target = isS3 ? s3Brsize : otherBrSize + + target.backups += totalBackupSize.onDisk + if (!isS3) { + const _target = target as DashboardBackupRepositoriesSizeInfo['other']['size'] + _target.available += available ?? 0 + _target.other += used - totalBackupSize.onDisk + _target.total += size ?? 0 + _target.used += used + } + } + + return { s3: { size: s3Brsize }, other: { size: otherBrSize } } + }, + this.#dashboardCacheOpts + ) + + if (brResult?.value !== undefined) { + return { ...brResult.value, isExpired: brResult.isExpired } + } + } + + #getNumberOfPools() { + const pools = this.#restApi.getObjectsByType('pool') + return Object.keys(pools).length + } + + #getNumberOfHosts() { + const hosts = this.#restApi.getObjectsByType('host') + return Object.keys(hosts).length + } + + #getResourcesOverview(): XoaDashboard['resourcesOverview'] { + const pools = Object.values(this.#restApi.getObjectsByType('pool')) + const hosts = Object.values(this.#restApi.getObjectsByType('host')) + const writableSrs = Object.values( + this.#restApi.getObjectsByType('SR', { + filter: isSrWritable, + }) + ) + + const maxLenght = Math.max(hosts.length, writableSrs.length) + + const resourcesOverview = { nCpus: 0, memorySize: 0, srSize: 0 } + for (let index = 0; index < maxLenght; index++) { + const pool = pools[index] + const host = hosts[index] + const sr = writableSrs[index] + + if (pool !== undefined) { + resourcesOverview.nCpus += pool.cpus.cores ?? 0 + } + if (host !== undefined) { + resourcesOverview.memorySize += host.memory.size + } + if (sr !== undefined) { + resourcesOverview.srSize += sr.size + } + } + + return resourcesOverview + } + + async #getPoolsStatus(): Promise { + const servers = await this.#restApi.xoApp.getAllXenServers() + const pools = this.#restApi.getObjectsByType('pool') + + let nConnectedServers = 0 + let nUnreachableServers = 0 + let nUnknownServers = 0 + servers.forEach(server => { + // it may happen that some servers are marked as "connected", but no pool matches "server.pool" + // so they are counted as `nUnknownServers` + if (server.status === 'connected' && server.poolId !== undefined && pools[server.poolId] !== undefined) { + nConnectedServers++ + return + } + + if ( + server.status === 'disconnected' && + server.error !== undefined && + server.error.connectedServerId === undefined + ) { + nUnreachableServers++ + return + } + + if (server.status === 'disconnected') { + return + } + + nUnknownServers++ + }) + + return { + connected: nConnectedServers, + unreachable: nUnreachableServers, + unknown: nUnknownServers, + } + } + + async #getNumberOfEolHosts(): Promise { + const getHVSupportedVersions = this.#restApi.xoApp.getHVSupportedVersions + + if (getHVSupportedVersions === undefined) { + return + } + + const hvSupportedVersions = await getHVSupportedVersions() + + const hosts = this.#restApi.getObjectsByType('host') + let nHostsEol = 0 + + for (const hostId in hosts) { + const host = hosts[hostId as XoHost['id']] + if (!semver.satisfies(host.version, hvSupportedVersions[host.productBrand])) { + nHostsEol++ + } + } + + return nHostsEol + } + + async #getMissingPatchesInfo(): Promise { + if (!(await this.#restApi.xoApp.hasFeatureAuthorization('LIST_MISSING_PATCHES'))) { + return { + hasAuthorization: false, + } + } + + const hosts = Object.values(this.#restApi.getObjectsByType('host')) + const poolsWithMissingPatches = new Set() + let nHostsWithMissingPatches = 0 + let nHostsFailed = 0 + + await asyncEach(hosts, async (host: XoHost) => { + const xapi = this.#restApi.xoApp.getXapi(host) + + try { + const patches = await xapi.listMissingPatches(host.id) + + if (patches.length > 0) { + nHostsWithMissingPatches++ + poolsWithMissingPatches.add(host.$pool) + } + } catch (err) { + log.error('listMissingPatches failed', err) + nHostsFailed++ + } + }) + + return { + hasAuthorization: true, + nHostsFailed, + nHostsWithMissingPatches, + nPoolsWithMissingPatches: poolsWithMissingPatches.size, + } + } + + #isReplicaVmInVdb(vbds: XoVbd['id'][]): boolean { + for (const vbd of vbds) { + try { + const vdbObject = this.#restApi.getObject(vbd) + const { VM } = vdbObject + const vmObject = this.#restApi.getObject(VM) + return isReplicaVm(vmObject) + } catch (err) { + if (!noSuchObject.is(err)) { + throw err + } + } + } + return false + } + + #calculateReplicatedSize(vdiId: AnyXoVdi['id'], cache: Set): number { + if (cache.has(vdiId)) { + return 0 + } + + let vdiObject: AnyXoVdi + try { + vdiObject = this.#restApi.getObject(vdiId) + cache.add(vdiId) + } catch (err) { + if (!noSuchObject.is(err)) { + throw err + } + return 0 + } + + const { parent, usage, $VBDs } = vdiObject + const replicaUsage = this.#isReplicaVmInVdb($VBDs) && usage ? usage : 0 + const parentUsage = parent ? this.#calculateReplicatedSize(parent, cache) : 0 + + return replicaUsage + parentUsage + } + + #getStorageRepositoriesSizeInfo() { + const writableSrs = this.#restApi.getObjectsByType('SR', { + filter: isSrWritable, + }) + + let replicated = 0 + let total = 0 + let used = 0 + + for (const srId in writableSrs) { + const sr = writableSrs[srId as XoSr['id']] + + const cache = new Set() + const { VDIs } = sr + + replicated += VDIs.reduce((total, vdi) => total + this.#calculateReplicatedSize(vdi, cache), 0) + total += sr.size + used += sr.physical_usage + } + + return { + size: { available: total - used, other: used - replicated, replicated, total, used }, + } + } + + async #getbackupsInfo(): Promise<(DashboardBackupsInfo & { isExpired?: true }) | undefined> { + const vmIdsProtected = new Set() + const vmIdsUnprotected = new Set() + const nonReplicaVms = Object.values(this.#restApi.getObjectsByType('VM', { filter: vm => !isReplicaVm(vm) })) + const restApi = this.#restApi + const xoApp = restApi.xoApp + function _extractVmIdsFromBackupJob(job: XoBackupJob) { + let vmIds: XoVm['id'][] + try { + vmIds = extractIdsFromSimplePattern(job.vms) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_) { + const predicate = createPredicate(job.vms) + vmIds = nonReplicaVms.filter(predicate).map(vm => vm.id) + } + return vmIds + } + function _processVmsProtection(job: XoBackupJob, isProtected: boolean) { + if (job.type !== BACKUP_TYPE.backup) { + return + } + + _extractVmIdsFromBackupJob(job).forEach(vmId => { + _updateVmProtection(vmId, isProtected) + }) + } + function _updateVmProtection(vmId: XoVm['id'], isProtected: boolean) { + if (vmIdsProtected.has(vmId) || !xoApp.hasObject(vmId, 'VM')) { + return + } + + const vm = restApi.getObject(vmId, 'VM') + if (vmContainsNoBakTag(vm)) { + return + } + + if (isProtected) { + vmIdsProtected.add(vmId) + vmIdsUnprotected.delete(vmId) + } else { + vmIdsUnprotected.add(vmId) + } + } + async function _jobHasAtLeastOneScheduleEnabled(job: XoBackupJob) { + for (const maybeScheduleId in job.settings) { + if (maybeScheduleId === '') { + continue + } + + try { + const schedule = await xoApp.getSchedule(maybeScheduleId as XoSchedule['id']) + if (schedule.enabled) { + return true + } + } catch (error) { + if (!noSuchObject.is(error, { id: maybeScheduleId, type: 'schedule' })) { + console.error(error) + } + continue + } + } + return false + } + + const backupsResult = await getFromAsyncCache( + this.#dashboardAsyncCache as Map<'backups', AsyncCacheEntry>, + 'backups', + async () => { + const [logs, jobs] = await Promise.all([ + xoApp.getBackupNgLogsSorted({ + filter: log => log.message === 'backup' || log.message === 'metadata', + }), + Promise.all([ + xoApp.getAllJobs('backup'), + xoApp.getAllJobs('mirrorBackup'), + xoApp.getAllJobs('metadataBackup'), + ]).then(jobs => jobs.flat(1)) as Promise, + ]) + const logsByJob = groupBy(logs, 'jobId') + + let disabledJobs = 0 + let failedJobs = 0 + let skippedJobs = 0 + let successfulJobs = 0 + const backupJobIssues: DashboardBackupsInfo['issues'] = [] + + for (const job of jobs) { + if (!(await _jobHasAtLeastOneScheduleEnabled(job))) { + _processVmsProtection(job, false) + disabledJobs++ + continue + } + + // Get only the last 3 runs + const jobLogs = logsByJob[job.id]?.slice(-3).reverse() + if (jobLogs === undefined || jobLogs.length === 0) { + _processVmsProtection(job, false) + continue + } + + if (job.type === BACKUP_TYPE.backup) { + const lastJobLog = jobLogs[0] + const { tasks, status } = lastJobLog + + if (tasks === undefined) { + _processVmsProtection(job, status === 'success') + } else { + // @TODO: remove as when logs are correctly typed + ;(tasks as unknown as { data: { id: XoVm['id'] }; status: string }[]).forEach(task => { + _updateVmProtection(task.data.id, task.status === 'success') + }) + } + } + + const failedLog = jobLogs.find(log => log.status !== 'success') + if (failedLog !== undefined) { + const { status } = failedLog + if (status === 'failure' || status === 'interrupted') { + failedJobs++ + } else if (status === 'skipped') { + skippedJobs++ + } + backupJobIssues.push({ + // @TODO: remove as when logs are correctly typed + logs: jobLogs.map(log => log.status) as DashboardBackupsInfo['issues'][number]['logs'], + name: job.name, + type: job.type, + uuid: job.id, + }) + } else { + successfulJobs++ + } + } + + const nVmsProtected = vmIdsProtected.size + const nVmsUnprotected = vmIdsUnprotected.size + const nVmsNotInJob = nonReplicaVms.length - (nVmsProtected + nVmsUnprotected) + + return { + jobs: { + disabled: disabledJobs, + failed: failedJobs, + skipped: skippedJobs, + successful: successfulJobs, + total: jobs.length, + }, + issues: backupJobIssues, + vmsProtection: { + protected: nVmsProtected, + unprotected: nVmsUnprotected, + notInJob: nVmsNotInJob, + }, + } + }, + this.#dashboardCacheOpts + ) + + if (backupsResult?.value !== undefined) { + return { ...backupsResult.value, isExpired: backupsResult.isExpired } + } + } + + async getDashboard() { + const nPools = this.#getNumberOfPools() + const nHosts = this.#getNumberOfHosts() + const resourcesOverview = this.#getResourcesOverview() + const storageRepositories = this.#getStorageRepositoriesSizeInfo() + + const poolsStatus = await this.#getPoolsStatus() + const missingPatches = await this.#getMissingPatchesInfo() + const backupRepositories = await this.#getBackupRepositoriesSizeInfo().catch(err => { + log.error('#getBackupRepositoriesSizeInfo failed', err) + // explicitly return undefined because typescript understand it as void instead of undefined + return undefined + }) + const nHostsEol = await this.#getNumberOfEolHosts().catch(err => { + log.err('#getNumberOfEolHosts failed', err) + return undefined + }) + const backups = await this.#getbackupsInfo().catch(err => { + log.error('#getbackupsInfo failed', err) + return undefined + }) + + return { + nPools, + nHosts, + backupRepositories, + resourcesOverview, + poolsStatus, + nHostsEol, + missingPatches, + storageRepositories, + backups, + } + } +} diff --git a/@xen-orchestra/rest-api/src/xoa/xoa.type.mts b/@xen-orchestra/rest-api/src/xoa/xoa.type.mts new file mode 100644 index 0000000000..304b7d3849 --- /dev/null +++ b/@xen-orchestra/rest-api/src/xoa/xoa.type.mts @@ -0,0 +1,66 @@ +import { BACKUP_TYPE } from '@vates/types' + +export type DashboardBackupRepositoriesSizeInfo = { + s3: { + size: { + backups: number + } + } + other: { size: { available: number; backups: number; other: number; total: number; used: number } } +} + +export type DashboardBackupsInfo = { + jobs: { + disabled: number + failed: number + skipped: number + successful: number + total: number + } + issues: { + logs: ('failure' | 'interrupted' | 'skipped' | 'success')[] + name?: string + type: BACKUP_TYPE + uuid: string + }[] + vmsProtection: { + protected: number + unprotected: number + notInJob: number + } +} + +export type XoaDashboard = { + nPools: number + nHosts: number + nHostsEol?: number + missingPatches: + | { hasAuthorization: false } + | { + hasAuthorization: true + nHostsWithMissingPatches: number + nPoolsWithMissingPatches: number + nHostsFailed: number + } + backupRepositories?: DashboardBackupRepositoriesSizeInfo + storageRepositories: { + size: { + available: number + other: number + replicated: number + total: number + used: number + } + } + backups?: DashboardBackupsInfo + resourcesOverview: { + nCpus: number + memorySize: number + srSize: number + } + poolsStatus: { + connected: number + unreachable: number + unknown: number + } +} diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index d2cec6c21f..bbc422f66c 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -19,6 +19,7 @@ - `/rest/v0/pools//actions/emergency_shutdown` (PR [#8653](https://github.com/vatesfr/xen-orchestra/pull/8653)) - `/rest/v0/pools//actions/rolling_reboot` (PR [#8653](https://github.com/vatesfr/xen-orchestra/pull/8653)) - `/rest/v0/pools//actions/rolling_update` (PR [#8653](https://github.com/vatesfr/xen-orchestra/pull/8653)) + - `/rest/v0/dashboard` (PR [#8580](https://github.com/vatesfr/xen-orchestra/pull/8580)) - [REST API] Ability to create a network `POST /rest/v0/pools/` (PR [#8671](https://github.com/vatesfr/xen-orchestra/pull/8671)) diff --git a/packages/xo-server/src/utils.mjs b/packages/xo-server/src/utils.mjs index cbd3013f3b..e8fd4bb194 100644 --- a/packages/xo-server/src/utils.mjs +++ b/packages/xo-server/src/utils.mjs @@ -352,85 +352,3 @@ export const vmContainsNoBakTag = vm => vm.tags.some(t => t.split('=', 1)[0] === export const isAlarm = alarm => alarm.type === 'message' && ['ALARM', 'BOND_STATUS_CHANGED', 'MULTIPATH_PERIODIC_ALERT'].includes(alarm.name) - -// ------------------------------------------------------------------- - -/** - * - * If the value is cached and not expired, it will return - * the cached value immediately. If expired or not present, it will invoke the provided - * function to fetch the value, cache it, and return it. - * - * The function also handles timeout for fetching the value, ensuring that if fetching - * takes too long, it resolves to `undefined` or returns the expired value based on the - * cache's state. - * - * @param {Map>} cache - * @param {string} key - * @param {() => Promise} fn - * @param {Object} [options] - * @param {number} [options.timeout] - default to 5000ms - * @param {number} [options.expiresIn] - default to 60000ms - * @param {boolean} [options.forceRefresh] - default to `false` - * - * @returns {Promise<{value: T, isExpired?: boolean} | undefined>} - */ -export const getFromAsyncCache = async ( - cache, - key, - fn, - { expiresIn = 60000, timeout = 5000, forceRefresh = false } = {} -) => { - if (forceRefresh) { - cache.delete(key) - } - - const { current, expires } = cache.get(key) ?? {} - if (current === undefined || expires < Date.now()) { - const _promise = fn() - - if (_promise.then === undefined) { - throw new Error('fn need to be asynchronous') - } - - const promise = _promise.then(result => { - cache.set(key, { - current: result, - expires: Date.now() + expiresIn, - previous: undefined, - }) - - return result - }) - - cache.set(key, { - current: promise, - previous: current, - expires: undefined, - }) - } - - let timeoutId - const timeoutPromise = new Promise( - (resolve, reject) => - (timeoutId = setTimeout(() => reject(new Error('Promise timed out', { cause: 'ERR_TIMEOUT' })), timeout)) - ) - - const result = {} - try { - result.value = await Promise.race([timeoutPromise, cache.get(key).current]) - } catch (error) { - if (error.cause !== 'ERR_TIMEOUT') { - throw error - } - - result.value = cache.get(key).previous - if (result.value !== undefined) { - result.isExpired = true - } - } finally { - clearTimeout(timeoutId) - } - - return result -} diff --git a/packages/xo-server/src/utils.test.mjs b/packages/xo-server/src/utils.test.mjs index cf92df729a..f3f7a13825 100644 --- a/packages/xo-server/src/utils.test.mjs +++ b/packages/xo-server/src/utils.test.mjs @@ -1,15 +1,7 @@ import assert from 'assert/strict' import test from 'node:test' -import { - camelToSnakeCase, - diffItems, - extractProperty, - generateToken, - getFromAsyncCache, - parseSize, - parseXml, -} from './utils.mjs' +import { camelToSnakeCase, diffItems, extractProperty, generateToken, parseSize, parseXml } from './utils.mjs' const { describe, it } = test @@ -186,99 +178,3 @@ describe('parseSize()', function () { assert.equal(parseSize('3MB'), 3e6) }) }) - -// =================================================================== - -describe('getFromAsyncCache()', function () { - const cacheTest = new Map() - const cacheTimeout = 500 - const cacheExpiresIn = 1000 - const cacheTestOps = { timeout: cacheTimeout, expiresIn: cacheExpiresIn } - const sleep = times => new Promise(resolve => setTimeout(resolve, times)) - - // start the promise, advance time and return the promise - const _getFromAsyncCache = async (t, ms, cache, key, fn, opts) => { - const p = getFromAsyncCache(cache, key, fn, opts) - t.mock.timers.tick(ms) - return p - } - - it('Ensure the callback is called', async t => { - const cb = t.mock.fn(async () => {}) - - assert.equal(cb.mock.callCount(), 0) - await getFromAsyncCache(cacheTest, 'simpleTest', cb) - assert.equal(cb.mock.callCount(), 1) - }) - - it('Returns the computed value', async function () { - const result = await getFromAsyncCache(cacheTest, 'simpleTest', async () => 'foo') - assert.equal(result.value, 'foo') - }) - - it('Returns the cached value', async function () { - const result = await getFromAsyncCache(cacheTest, 'simpleTest', async () => 'bar') - assert.equal(result.value, 'foo') - }) - - it('Recomputes the value if forceRefresh is passed', async function () { - const result = await getFromAsyncCache(cacheTest, 'simpleTest', async () => 'baz', { - forceRefresh: true, - }) - assert.equal(result.value, 'baz') - }) - - it('Returns undefined if the fn takes too long to execute, then returns the computed value when the promise is resolved', async function (t) { - t.mock.timers.enable({ apis: ['setTimeout'] }) - const cb = async () => { - await sleep(cacheTimeout * 2.5) - return 'foo' - } - - const result = await _getFromAsyncCache(t, cacheTimeout, cacheTest, 'timeout', cb, cacheTestOps) - assert.equal(result.value, undefined) - - const secondResult = await _getFromAsyncCache(t, cacheTimeout, cacheTest, 'timeout', undefined, cacheTestOps) - assert.equal(secondResult.value, undefined) - - t.mock.timers.tick(cacheTimeout) - - const thirdResult = await getFromAsyncCache(cacheTest, 'timeout', undefined, cacheTestOps) - assert.equal(thirdResult.value, 'foo') - }) - - it('If cached value is expired, returns the new computed value', async function (t) { - t.mock.timers.enable({ apis: ['Date'], now: 0 }) - - const result = await getFromAsyncCache(cacheTest, 'expired', async () => 'foo', cacheTestOps) - assert.equal(result.value, 'foo') - - t.mock.timers.setTime(cacheExpiresIn * 2) - - const secondResult = await getFromAsyncCache(cacheTest, 'expired', async () => 'bar', cacheTestOps) - assert.equal(secondResult.value, 'bar') - }) - - it('If cached value is expired and the fn takes too long time to execute, returns the expired cached value with "isExpired" property and updates the cache when the promise is resolved', async function (t) { - t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 0 }) - const cb = async () => { - await sleep(cacheTimeout * 1.5) - return 'bar' - } - - const result = await getFromAsyncCache(cacheTest, 'expiredAndTimeout', async () => 'foo', cacheTestOps) - assert.equal(result.value, 'foo') - - t.mock.timers.setTime(cacheExpiresIn * 2) - - const secondResult = await _getFromAsyncCache(t, cacheTimeout, cacheTest, 'expiredAndTimeout', cb, cacheTestOps) - assert.equal(secondResult.value, 'foo') - assert.equal(secondResult.isExpired, true) - - t.mock.timers.tick(cacheTimeout) - - const thirdResult = await getFromAsyncCache(cacheTest, 'expiredAndTimeout', undefined, cacheTestOps) - assert.equal(thirdResult.value, 'bar') - assert.equal(thirdResult.isExpired, undefined) - }) -}) diff --git a/packages/xo-server/src/xo-mixins/rest-api.mjs b/packages/xo-server/src/xo-mixins/rest-api.mjs index 63003fbee8..35da63ab10 100644 --- a/packages/xo-server/src/xo-mixins/rest-api.mjs +++ b/packages/xo-server/src/xo-mixins/rest-api.mjs @@ -3,7 +3,6 @@ import { asyncEach } from '@vates/async-each' import { createGzip } from 'node:zlib' import { defer } from 'golike-defer' import { every } from '@vates/predicates' -import { extractIdsFromSimplePattern } from '@xen-orchestra/backups/extractIdsFromSimplePattern.mjs' import { ifDef } from '@xen-orchestra/defined' import { featureUnauthorized, invalidCredentials, noSuchObject } from 'xo-common/api-errors.js' import { pipeline } from 'node:stream/promises' @@ -11,27 +10,14 @@ import { json, Router } from 'express' import { Readable } from 'node:stream' import cloneDeep from 'lodash/cloneDeep.js' import isEmpty from 'lodash/isEmpty.js' -import groupBy from 'lodash/groupBy.js' import path from 'node:path' import pDefer from 'promise-toolbox/defer' import pick from 'lodash/pick.js' -import semver from 'semver' import * as CM from 'complex-matcher' import { VDI_FORMAT_RAW, VDI_FORMAT_VHD } from '@xen-orchestra/xapi' -import { parse } from 'xo-remote-parser' -import { - getFromAsyncCache, - getUserPublicProperties, - isAlarm, - isReplicaVm, - isSrWritable, - vmContainsNoBakTag, -} from '../utils.mjs' +import { getUserPublicProperties, isAlarm } from '../utils.mjs' import { compileXoJsonSchema } from './_xoJsonSchema.mjs' -import { createPredicate } from 'value-matcher' - -const DASHBOARD_CACHE = new Map() const { join } = path.posix const noop = Function.prototype @@ -166,377 +152,6 @@ function wrap(middleware, handleNoSuchObject = false) { } } -async function _getDashboardStats(app) { - const dashboard = {} - const dashboardCacheOps = { - timeout: app.config.getOptionalDuration('rest-api.dashboardCacheTimeout'), - expiresIn: app.config.getOptionalDuration('rest-api.dashboardCacheExpiresIn'), - } - - let hvSupportedVersions - let nHostsEol - if (typeof app.getHVSupportedVersions === 'function') { - try { - hvSupportedVersions = await app.getHVSupportedVersions() - nHostsEol = 0 - } catch (error) { - console.error(error) - } - } - - const pools = Object.values(app.objects.indexes.type.pool ?? {}) - const poolIds = [] - const hosts = Object.values(app.objects.indexes.type.host ?? {}) - const srs = Object.values(app.objects.indexes.type.SR ?? {}) - const vms = Object.values(app.objects.indexes.type.VM ?? {}) - const servers = await app.getAllXenServers() - - const writableSrs = srs.filter(isSrWritable) - const nonReplicaVms = vms.filter(vm => !isReplicaVm(vm)) - const vmIdsProtected = new Set() - const vmIdsUnprotected = new Set() - const resourcesOverview = { nCpus: 0, memorySize: 0, srSize: 0 } - - pools.forEach(pool => { - resourcesOverview.nCpus += pool.cpus.cores - poolIds.push(pool.id) - }) - - hosts.forEach(host => { - if (hvSupportedVersions !== undefined && !semver.satisfies(host.version, hvSupportedVersions[host.productBrand])) { - nHostsEol++ - } - resourcesOverview.memorySize += host.memory.size - }) - - dashboard.nPools = pools.length - dashboard.nHosts = hosts.length - dashboard.nHostsEol = nHostsEol - - if (await app.hasFeatureAuthorization('LIST_MISSING_PATCHES')) { - const poolsWithMissingPatches = new Set() - let nHostsWithMissingPatches = 0 - - await asyncEach(hosts, async host => { - const xapi = app.getXapi(host) - try { - const patches = await xapi.listMissingPatches(host) - if (patches.length > 0) { - nHostsWithMissingPatches++ - poolsWithMissingPatches.add(host.$pool) - } - } catch (error) { - console.error(error) - } - }) - - const missingPatches = { - nHostsWithMissingPatches, - nPoolsWithMissingPatches: poolsWithMissingPatches.size, - } - - dashboard.missingPatches = missingPatches - } - - try { - const brResult = await getFromAsyncCache( - DASHBOARD_CACHE, - 'backupRepositories', - async () => { - const s3Brsize = { backups: 0 } - const otherBrSize = { available: 0, backups: 0, other: 0, total: 0, used: 0 } - - const backupRepositories = await app.getAllRemotes() - const backupRepositoriesInfo = await app.getAllRemotesInfo() - - for (const backupRepository of backupRepositories) { - const { type } = parse(backupRepository.url) - const backupRepositoryInfo = backupRepositoriesInfo[backupRepository.id] - - if (!backupRepository.enabled || backupRepositoryInfo === undefined) { - continue - } - - const totalBackupSize = await app.getTotalBackupSizeOnRemote(backupRepository.id) - - const { available, size, used } = backupRepositoryInfo - - const isS3 = type === 's3' - const target = isS3 ? s3Brsize : otherBrSize - - target.backups += totalBackupSize.onDisk - if (!isS3) { - target.available += available - target.other += used - totalBackupSize.onDisk - target.total += size - target.used += used - } - } - - return { s3: { size: s3Brsize }, other: { size: otherBrSize } } - }, - dashboardCacheOps - ) - - if (brResult.value !== undefined) { - dashboard.backupRepositories = { ...brResult.value, isExpired: brResult.isExpired } - } - } catch (error) { - console.error(error) - } - - function isReplicaVmInVdb($VBDs) { - for (const vbd of $VBDs) { - try { - const vdbObject = app.getObject(vbd, ['VBD']) - const { VM } = vdbObject - const vmObject = app.getObject(VM, ['VM', 'VM-snapshot', 'VM-template']) - if (isReplicaVm(vmObject)) { - return true - } - } catch (err) {} - } - return false - } - - function calculateReplicatedSize(vdi, cache) { - if (cache.has(vdi)) { - return 0 - } - - let vdiObject - try { - vdiObject = app.getObject(vdi, ['VDI', 'VDI-snapshot', 'VDI-unmanaged']) - cache.set(vdi, vdiObject) - } catch (err) { - return 0 - } - - const { parent, usage, $VBDs } = vdiObject - const replicaUsage = isReplicaVmInVdb($VBDs) && usage ? usage : 0 - const parentUsage = parent ? calculateReplicatedSize(parent, cache) : 0 - - return replicaUsage + parentUsage - } - - const storageRepositoriesSize = writableSrs.reduce( - function processSr(acc, sr) { - const cache = new Map() - const { VDIs } = sr - - const replicated = VDIs.reduce((total, vdi) => { - return total + calculateReplicatedSize(vdi, cache) - }, 0) - - return { - replicated: acc.replicated + replicated, - total: acc.total + sr.size, - used: acc.used + sr.physical_usage, - } - }, - { - replicated: 0, - total: 0, - used: 0, - } - ) - - storageRepositoriesSize.available = storageRepositoriesSize.total - storageRepositoriesSize.used - storageRepositoriesSize.other = storageRepositoriesSize.used - storageRepositoriesSize.replicated - resourcesOverview.srSize = storageRepositoriesSize.total - - dashboard.storageRepositories = { size: storageRepositoriesSize } - dashboard.resourcesOverview = resourcesOverview - - async function _jobHasAtLeastOneScheduleEnabled(job) { - for (const maybeScheduleId in job.settings) { - if (maybeScheduleId === '') { - continue - } - - try { - const schedule = await app.getSchedule(maybeScheduleId) - if (schedule.enabled) { - return true - } - } catch (error) { - if (!noSuchObject.is(error, { id: maybeScheduleId, type: 'schedule' })) { - console.error(error) - } - continue - } - } - return false - } - - /** - * Some IDs may not exists anymore - * @param {object} job - * @returns {string[]} - */ - function _extractVmIdsFromBackupJob(job) { - let vmIds - try { - vmIds = extractIdsFromSimplePattern(job.vms) - } catch (_) { - const predicate = createPredicate(job.vms) - vmIds = nonReplicaVms.filter(predicate).map(vm => vm.id) - } - return vmIds - } - - function _updateVmProtection(vmId, isProtected) { - if (vmIdsProtected.has(vmId) || !app.hasObject(vmId, 'VM')) { - return - } - - const vm = app.getObject(vmId, 'VM') - if (vmContainsNoBakTag(vm)) { - return - } - - if (isProtected) { - vmIdsProtected.add(vmId) - vmIdsUnprotected.delete(vmId) - } else { - vmIdsUnprotected.add(vmId) - } - } - - function _processVmsProtection(job, isProtected) { - if (job.type !== 'backup') { - return - } - - _extractVmIdsFromBackupJob(job).forEach(vmId => { - _updateVmProtection(vmId, isProtected) - }) - } - - try { - const backupsResult = await getFromAsyncCache( - DASHBOARD_CACHE, - 'backups', - async () => { - const [logs, jobs] = await Promise.all([ - app.getBackupNgLogsSorted({ - filter: log => log.message === 'backup' || log.message === 'metadata', - }), - Promise.all([ - app.getAllJobs('backup'), - app.getAllJobs('mirrorBackup'), - app.getAllJobs('metadataBackup'), - ]).then(jobs => jobs.flat(1)), - ]) - const logsByJob = groupBy(logs, 'jobId') - - let disabledJobs = 0 - let failedJobs = 0 - let skippedJobs = 0 - let successfulJobs = 0 - const backupJobIssues = [] - - for (const job of jobs) { - if (!(await _jobHasAtLeastOneScheduleEnabled(job))) { - _processVmsProtection(job, false) - disabledJobs++ - continue - } - - // Get only the last 3 runs - const jobLogs = logsByJob[job.id]?.slice(-3).reverse() - if (jobLogs === undefined || jobLogs.length === 0) { - _processVmsProtection(job, false) - continue - } - - if (job.type === 'backup') { - const lastJobLog = jobLogs[0] - const { tasks, status } = lastJobLog - - if (tasks === undefined) { - _processVmsProtection(job, status === 'success') - } else { - tasks.forEach(task => { - _updateVmProtection(task.data.id, task.status === 'success') - }) - } - } - - const failedLog = jobLogs.find(log => log.status !== 'success') - if (failedLog !== undefined) { - const { status } = failedLog - if (status === 'failure' || status === 'interrupted') { - failedJobs++ - } else if (status === 'skipped') { - skippedJobs++ - } - backupJobIssues.push({ logs: jobLogs.map(log => log.status), name: job.name, type: job.type, uuid: job.id }) - } else { - successfulJobs++ - } - } - - const nVmsProtected = vmIdsProtected.size - const nVmsUnprotected = vmIdsUnprotected.size - const nVmsNotInJob = nonReplicaVms.length - (nVmsProtected + nVmsUnprotected) - - return { - jobs: { - disabled: disabledJobs, - failed: failedJobs, - skipped: skippedJobs, - successful: successfulJobs, - total: jobs.length, - }, - issues: backupJobIssues, - vmsProtection: { - protected: nVmsProtected, - unprotected: nVmsUnprotected, - notInJob: nVmsNotInJob, - }, - } - }, - dashboardCacheOps - ) - if (backupsResult.value !== undefined) { - dashboard.backups = { ...backupsResult.value, isExpired: backupsResult.isExpired } - } - } catch (error) { - console.error(error) - } - - let nConnectedServers = 0 - let nUnreachableServers = 0 - let nUnknownServers = 0 - servers.forEach(server => { - // it may happen that some servers are marked as "connected", but no pool matches "server.pool" - // so they are counted as `nUnknownServers` - if (server.status === 'connected' && poolIds.includes(server.poolId)) { - nConnectedServers++ - return - } - - if ( - server.status === 'disconnected' && - server.error !== undefined && - server.error.connectedServerId === undefined - ) { - nUnreachableServers++ - return - } - - if (server.status === 'disconnected') { - return - } - - nUnknownServers++ - }) - - dashboard.poolsStatus = { connected: nConnectedServers, unreachable: nUnreachableServers, unknown: nUnknownServers } - return dashboard -} - const keepNonAlarmMessages = message => message.type === 'message' && !isAlarm(message) export default class RestApi { #api @@ -605,6 +220,7 @@ export default class RestApi { // add migrated collections to maintain their discoverability const swaggerEndpoints = { alarms: {}, + dashboard: {}, docs: {}, messages: {}, networks: {}, @@ -1040,7 +656,6 @@ export default class RestApi { }, }, } - collections.dashboard = {} // normalize collections for (const id of Object.keys(collections)) { @@ -1163,13 +778,6 @@ export default class RestApi { res.redirect(308, req.baseUrl + '/backup/jobs/vm/' + req.params.id) }) - api.get( - '/dashboard', - wrap(async (req, res) => { - res.json(await _getDashboardStats(app)) - }) - ) - api .get( '/restore', diff --git a/yarn.lock b/yarn.lock index bccdd284a1..9d05bac95f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5330,6 +5330,46 @@ resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-13.1.0.tgz#b01ad108f0de3f1b02e0591c6fc54a9ee0029298" integrity sha512-IVS/qRRjhPTZ6C2/AM3jieqXACGwFZwWTdw5sNTSKk2m/ZpkuuN+ri+WCVUP8TqaKwJYt/KuMwmXspMAw8E6ew== +"@xen-orchestra/backups@^0.59.0": + version "0.59.0" + resolved "https://registry.yarnpkg.com/@xen-orchestra/backups/-/backups-0.59.0.tgz#2bc1e30a4b3a35e1a0721219b971844c0d029138" + integrity sha512-doVdKdASPagwa2xyP50FXTaY0lRiL0LB9/COMSOvgDW9Ygc3ulyQO/2P+NCb/FkXGV1sdeUmFhoqN0n4wN5ekw== + dependencies: + "@iarna/toml" "^2.2.5" + "@kldzj/stream-throttle" "^1.1.1" + "@vates/async-each" "^1.0.0" + "@vates/cached-dns.lookup" "^1.0.0" + "@vates/compose" "^2.1.0" + "@vates/decorate-with" "^2.1.0" + "@vates/disposable" "^0.1.6" + "@vates/fuse-vhd" "^2.1.2" + "@vates/nbd-client" "^3.1.2" + "@vates/parse-duration" "^0.1.1" + "@xen-orchestra/async-map" "^0.1.2" + "@xen-orchestra/fs" "^4.5.0" + "@xen-orchestra/log" "^0.7.1" + "@xen-orchestra/template" "^0.1.0" + app-conf "^3.0.0" + compare-versions "^6.0.0" + d3-time-format "^4.1.0" + decorator-synchronized "^0.6.0" + golike-defer "^0.5.1" + human-format "^1.2.0" + limit-concurrency-decorator "^0.6.0" + lodash "^4.17.20" + moment-timezone "^0.5.46" + ms "^2.1.3" + node-zone "^0.4.0" + parse-pairs "^2.0.0" + promise-toolbox "^0.21.0" + proper-lockfile "^4.1.2" + tar "^6.1.15" + uuid "^9.0.0" + value-matcher "^0.2.0" + vhd-lib "^4.11.3" + xen-api "^4.7.1" + yazl "^2.5.1" + "@xen-orchestra/log@^0.6.0": version "0.6.0" resolved "https://registry.yarnpkg.com/@xen-orchestra/log/-/log-0.6.0.tgz#b7341818dbdc3a56facf815e7124867bf39ea89d" @@ -18395,6 +18435,11 @@ semver@^7.0.0, semver@^7.1.1, semver@^7.3.2, semver@^7.3.5, semver@^7.3.6, semve resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.1.tgz#abd5098d82b18c6c81f6074ff2647fd3e7220c9f" integrity sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA== +semver@^7.7.2: + version "7.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" + integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== + send@0.19.0: version "0.19.0" resolved "https://registry.yarnpkg.com/send/-/send-0.19.0.tgz#bbc5a388c8ea6c048967049dbeac0e4a3f09d7f8"