mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-09-10 22:14:48 -05:00
feat(@xen-orchestra/rest-api): expose dashboard endpoint (#8580)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
61
@vates/types/src/lib/xen-orchestra-xapi.mts
Normal file
61
@vates/types/src/lib/xen-orchestra-xapi.mts
Normal 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>
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
78
@xen-orchestra/rest-api/src/helpers/cache.helper.mts
Normal file
78
@xen-orchestra/rest-api/src/helpers/cache.helper.mts
Normal 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
|
||||
}
|
||||
89
@xen-orchestra/rest-api/src/helpers/cache.helper.test.mts
Normal file
89
@xen-orchestra/rest-api/src/helpers/cache.helper.test.mts
Normal 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)
|
||||
})
|
||||
})
|
||||
5
@xen-orchestra/rest-api/src/helpers/utils.helper.mts
Normal file
5
@xen-orchestra/rest-api/src/helpers/utils.helper.mts
Normal 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')
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
30
@xen-orchestra/rest-api/src/xoa/xoa.controller.mts
Normal file
30
@xen-orchestra/rest-api/src/xoa/xoa.controller.mts
Normal 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
|
||||
}
|
||||
}
|
||||
506
@xen-orchestra/rest-api/src/xoa/xoa.service.mts
Normal file
506
@xen-orchestra/rest-api/src/xoa/xoa.service.mts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
66
@xen-orchestra/rest-api/src/xoa/xoa.type.mts
Normal file
66
@xen-orchestra/rest-api/src/xoa/xoa.type.mts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
45
yarn.lock
45
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"
|
||||
|
||||
Reference in New Issue
Block a user