feat(@xen-orchestra/rest-api): expose dashboard endpoint (#8580)

This commit is contained in:
Mathieu
2025-06-10 14:41:05 +02:00
committed by GitHub
parent b3145b1034
commit 0c1947aba2
21 changed files with 1095 additions and 620 deletions

View File

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

View File

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

View File

@@ -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: <ReturnType>(...args: unknown[]) => Promise<ReturnType>
callAsync: <ReturnType>(...args: unknown[]) => Promise<ReturnType>
getField<T extends XenApiRecord, K extends keyof T>(
type: Extract<WrappedXenApiRecord, T>['$type'],
ref: T['$ref'],
field: K
): Promise<T[K]>
createNetwork(
params:
| {
name: string
description?: string
mtu?: number
}
| {
name: string
description?: string
pifId: XoPif['id']
mtu?: number
/* between 0 and 4094 */
vlan: number
}
): Promise<XenApiNetworkWrapped>
deleteNetwork(id: XoNetwork['id']): Promise<void>
listMissingPatches(host: XoHost['id']): Promise<XcpPatches[] | XsPatches[]>
pool_emergencyShutdown(): Promise<void>
}

View File

@@ -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, Type extends string, Fn = { (): void }> = T & {
name_label?: string
unplugVusbs?: boolean
}): Promise<XenApiVm['$ref']>
$xapi: {
call: <ReturnType>(...args: unknown[]) => Promise<ReturnType>
callAsync: <ReturnType>(...args: unknown[]) => Promise<ReturnType>
getField<T extends XenApiRecord, K extends keyof T>(type: Type, ref: T['$ref'], field: K): Promise<T[K]>
createNetwork(
params:
| {
name: string
description?: string
mtu?: number
}
| {
name: string
description?: string
pifId: XoPif['id']
mtu?: number
/* between 0 and 4094 */
vlan: number
}
): Promise<XenApiNetworkWrapped>
deleteNetwork(id: XoNetwork['id']): Promise<void>
pool_emergencyShutdown(): Promise<void>
}
$xapi: Xapi
}
export interface XenApiSession {

View File

@@ -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<XoMessage, '$object' | 'body'> & {
}
}
export type XoBackupRepository = {
benchmarks?: { readRate: number; timestamp: number; writeRate: number }[]
enabled: boolean
error?: Record<string, unknown>
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<string, unknown>
}
srs: {
id: XoSr['id'] | { __or: XoSr['id'][] }
}
type: BACKUP_TYPE
settings: {
'': {
cbtDestroySnapshotData?: boolean
concurrency?: number
longTermRetention?: {
daily?: { retention: number; settings: Record<string, unknown> }
weekly?: { retention: number; settings: Record<string, unknown> }
monthly?: { retention: number; settings: Record<string, unknown> }
yearly?: { retention: number; settings: Record<string, unknown> }
}
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<string, string>
physical_usage: number | null
physical_usage: number
shared: boolean
size: number | null
size: number
sm_config: Record<string, string>
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

View File

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

View File

@@ -0,0 +1,78 @@
import { MaybePromise } from './helper.type.mjs'
export type AsyncCacheEntry<T> = {
current: T | Promise<T>
expires?: number
previous?: T | Promise<T>
}
/**
*
* 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<T>(
cache: Map<string, AsyncCacheEntry<MaybePromise<T>>>,
key: string,
fn: () => Promise<T>,
{ 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<never>(
(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
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,7 +25,10 @@ export class RestApi {
return this.#xoApp.getObject(id, type)
}
getObjectsByType<T extends XapiXoRecord>(type: T['type'], opts: Parameters<XoApp['getObjectsByType']>[1]) {
getObjectsByType<T extends XapiXoRecord>(
type: T['type'],
opts?: { filter?: string | ((obj: T) => boolean); limit?: number }
) {
return this.#xoApp.getObjectsByType(type, opts)
}

View File

@@ -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<void>
getAllGroups(): Promise<XoGroup[]>
getAllJobs(type?: BACKUP_TYPE): Promise<AnyXoJob[]>
getAllRemotes(): Promise<XoBackupRepository[]>
getAllRemotesInfo(): Promise<
Record<
XoBackupRepository['id'],
{
size?: number
used: number
available?: number
encryption: {
algorithm: string
isLegacy: boolean
recommanded: string
}
}
>
>
getAllSchedules(): Promise<XoSchedule[]>
getAllUsers(): Promise<XoUser[]>
getAllXenServers(): Promise<XoServer[]>
// @TODO: Correctly type this methods and XoLogs when migrate the endpoint "backup/logs"
getBackupNgLogsSorted(opts: { filter: (log: Record<string, string>) => boolean }): Promise<Record<string, string>[]>
getGroup(id: XoGroup['id']): Promise<XoGroup>
getHVSupportedVersions: undefined | (() => Promise<{ [key: XoHost['productBrand']]: string }>)
getJob(id: XoJob['id']): Promise<XoJob>
getObject: <T extends XapiXoRecord>(id: T['id'], type?: T['type']) => T
getObjectsByType: <T extends XapiXoRecord>(
type: T['type'],
opts?: { filter?: string | ((obj: T) => boolean); limit?: number }
) => Record<T['id'], T>
getTotalBackupSizeOnRemote(id: XoBackupRepository['id']): Promise<{ onDisk: number }>
getSchedule(id: XoSchedule['id']): Promise<XoSchedule>
getUser: (id: XoUser['id']) => Promise<XoUser>
getXapi(maybeId: XapiXoRecord['id'] | XapiXoRecord): Xapi
getXapiHostStats: (hostId: XoHost['id'], granularity?: XapiStatsGranularity) => Promise<XapiHostStats>
getXapiObject: <T extends XapiXoRecord>(maybeId: T['id'] | T, type: T['type']) => XapiRecordByXapiXoRecord[T['type']]
getXapiVmStats: (vmId: XoVm['id'], granularity?: XapiStatsGranularity) => Promise<XapiVmStats>
getXenServer(id: XoServer['id']): Promise<XoServer>
hasFeatureAuthorization(featureCode: string): Promise<boolean>
hasObject<T extends XapiXoRecord>(id: T['id'], type: T['type']): boolean
/** Allow to add a new server in the DB (XCP-ng/XenServer) */
registerXenServer(body: InsertableXoServer): Promise<XoServer>
rollingPoolReboot(pool: XoPool, opts?: { parentTask?: Task }): Promise<void>

View File

@@ -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<XoaDashboard> {
const dashboard = await this.#xoaService.getDashboard()
return dashboard
}
}

View File

@@ -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<DashboardBackupRepositoriesSizeInfo>
backups: MaybePromise<DashboardBackupsInfo>
}
export class XoaService {
#restApi: RestApi
#dashboardAsyncCache = new Map<
keyof DashboardAsyncCache,
AsyncCacheEntry<DashboardAsyncCache[keyof DashboardAsyncCache]>
>()
#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<DashboardAsyncCache['backupRepositories']>(
this.#dashboardAsyncCache as Map<
'backupRepositories',
AsyncCacheEntry<DashboardAsyncCache['backupRepositories']>
>,
'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<XoPool>('pool')
return Object.keys(pools).length
}
#getNumberOfHosts() {
const hosts = this.#restApi.getObjectsByType<XoHost>('host')
return Object.keys(hosts).length
}
#getResourcesOverview(): XoaDashboard['resourcesOverview'] {
const pools = Object.values(this.#restApi.getObjectsByType<XoPool>('pool'))
const hosts = Object.values(this.#restApi.getObjectsByType<XoHost>('host'))
const writableSrs = Object.values(
this.#restApi.getObjectsByType<XoSr>('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<XoaDashboard['poolsStatus']> {
const servers = await this.#restApi.xoApp.getAllXenServers()
const pools = this.#restApi.getObjectsByType<XoPool>('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<XoaDashboard['nHostsEol']> {
const getHVSupportedVersions = this.#restApi.xoApp.getHVSupportedVersions
if (getHVSupportedVersions === undefined) {
return
}
const hvSupportedVersions = await getHVSupportedVersions()
const hosts = this.#restApi.getObjectsByType<XoHost>('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<XoaDashboard['missingPatches']> {
if (!(await this.#restApi.xoApp.hasFeatureAuthorization('LIST_MISSING_PATCHES'))) {
return {
hasAuthorization: false,
}
}
const hosts = Object.values(this.#restApi.getObjectsByType<XoHost>('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<XoVbd>(vbd)
const { VM } = vdbObject
const vmObject = this.#restApi.getObject<AnyXoVm>(VM)
return isReplicaVm(vmObject)
} catch (err) {
if (!noSuchObject.is(err)) {
throw err
}
}
}
return false
}
#calculateReplicatedSize(vdiId: AnyXoVdi['id'], cache: Set<AnyXoVdi['id']>): number {
if (cache.has(vdiId)) {
return 0
}
let vdiObject: AnyXoVdi
try {
vdiObject = this.#restApi.getObject<AnyXoVdi>(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<XoSr>('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<AnyXoVdi['id']>()
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<XoVm['id']>()
const vmIdsUnprotected = new Set<XoVm['id']>()
const nonReplicaVms = Object.values(this.#restApi.getObjectsByType<XoVm>('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<XoVm>(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<DashboardAsyncCache['backups']>(
this.#dashboardAsyncCache as Map<'backups', AsyncCacheEntry<DashboardAsyncCache['backups']>>,
'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<XoBackupJob[]>,
])
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,
}
}
}

View File

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

View File

@@ -19,6 +19,7 @@
- `/rest/v0/pools/<pool-id>/actions/emergency_shutdown` (PR [#8653](https://github.com/vatesfr/xen-orchestra/pull/8653))
- `/rest/v0/pools/<pool-id>/actions/rolling_reboot` (PR [#8653](https://github.com/vatesfr/xen-orchestra/pull/8653))
- `/rest/v0/pools/<pool-id>/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/<pool-id/actions/createNetwork` (PR [#8671](https://github.com/vatesfr/xen-orchestra/pull/8671))
- [REST API] Ability to delete a network `DELETE /rest/v0/networks/<network-id>` (PR [#8671](https://github.com/vatesfr/xen-orchestra/pull/8671))

View File

@@ -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<string, Promise<T>>} cache
* @param {string} key
* @param {() => Promise<T>} 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
}

View File

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

View File

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

View File

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