feat(xo-server): set XO6 the default page (#9212)

This commit is contained in:
Mathieu
2025-12-10 14:29:49 +01:00
committed by GitHub
parent 4b4c9b91f3
commit 03cea8b24d
11 changed files with 132 additions and 39 deletions

View File

@@ -4,14 +4,17 @@ import isEqual from 'lodash/isEqual.js'
import { createLogger } from '@xen-orchestra/log'
import { parseDuration } from '@vates/parse-duration'
import { watch } from 'app-conf'
import { resolve } from 'node:path'
const { warn } = createLogger('xo:mixins:config')
const { warn, info } = createLogger('xo:mixins:config')
// if path is undefined, an empty string or an empty array, returns the root value
const niceGet = (value, path) => (path === undefined || path.length === 0 ? value : get(value, path))
export default class Config {
constructor(app, { appDir, appName, config }) {
this._app = app
this._appDir = appDir
this._config = config
const watchers = (this._watchers = new Set())
@@ -90,4 +93,43 @@ export default class Config {
watchDuration(path, cb) {
return this.watch(path, cb, parseDuration)
}
async getGuiRoutes() {
const mounts = this.getOptional('http.mounts') ?? {}
let channel = 'latest'
try {
channel = await this._app.getCurrentChannel?.()
} catch (error) {
info('Unable to get current channel, fallback to latest', error)
}
const guiRoutes = {}
guiRoutes.xo5 = { url: '/v5', path: `${resolve(this._appDir, '..')}/xo-web/dist` }
guiRoutes.xo6 = { url: '/v6', path: `${resolve(this._appDir, '../..')}/@xen-orchestra/web/dist` }
if (channel === 'stable') {
guiRoutes.default = { ...guiRoutes.xo5, url: '/' }
} else {
guiRoutes.default = { ...guiRoutes.xo6, url: '/' }
}
for (let [url, path] of Object.entries(mounts)) {
url = url.replace(/(.+)\/$/, '$1')
const conflictRoute = Object.entries(guiRoutes).find(([_, route]) => route.url === url)
if (conflictRoute) {
const [key] = conflictRoute
guiRoutes[key] = { url, path }
} else {
const key = `xo${url
.split('/')
.map(s => s.charAt(0).toUpperCase() + s.slice(1))
.join('')}`
guiRoutes[key] = { url, path }
}
}
return guiRoutes
}
}

View File

@@ -75,6 +75,24 @@ export type XoApp = {
config: {
getOptional(path: string): Record<string, string> | undefined
getOptionalDuration(path: string): number | undefined
getGuiRoutes(): Promise<{
default: {
url: string
path: string
}
xo5: {
url: string
path: string
}
xo6: {
url: string
path: string
}
[key: string]: {
url: string
path: string
}
}>
}
objects: EventEmitter & {

View File

@@ -12,16 +12,19 @@ import { badRequestResp, unauthorizedResp } from '../open-api/common/response.co
import { xoaDashboard } from '../open-api/oa-examples/xoa.oa-example.mjs'
import { XoaService } from './xoa.service.mjs'
import { NDJSON_CONTENT_TYPE } from '../helpers/utils.helper.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
@Route('')
@Security('*')
@Tags('xoa')
@provide(XoaController)
export class XoaController extends Controller {
#restApi: RestApi
#xoaService: XoaService
constructor(@inject(XoaService) xoaService: XoaService) {
constructor(@inject(RestApi) restApi: RestApi, @inject(XoaService) xoaService: XoaService) {
super()
this.#restApi = restApi
this.#xoaService = xoaService
}
@@ -60,7 +63,12 @@ export class XoaController extends Controller {
@Security('none')
@Example(guiRoutes)
@Get('gui-routes')
getGuiRoutes(): XoGuiRoutes {
return this.#xoaService.getGuiRoutes()
async getGuiRoutes(): Promise<XoGuiRoutes> {
const { xo5, xo6 } = await this.#restApi.xoApp.config.getGuiRoutes()
return {
xo5: xo5.url,
xo6: xo6.url,
}
}
}

View File

@@ -22,7 +22,7 @@ import { parse } from 'xo-remote-parser'
import { Writable } from 'node:stream'
import { type AsyncCacheEntry, getFromAsyncCache } from '../helpers/cache.helper.mjs'
import { DashboardBackupRepositoriesSizeInfo, DashboardBackupsInfo, XoaDashboard, XoGuiRoutes } from './xoa.type.mjs'
import { DashboardBackupRepositoriesSizeInfo, DashboardBackupsInfo, XoaDashboard } from './xoa.type.mjs'
import { isReplicaVm, isSrWritableOrIso, promiseWriteInStream, vmContainsNoBakTag } from '../helpers/utils.helper.mjs'
import type { MaybePromise } from '../helpers/helper.type.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
@@ -606,24 +606,4 @@ export class XoaService {
vmsStatus,
}
}
getGuiRoutes(): XoGuiRoutes {
const mounts = this.#restApi.xoApp.config.getOptional('http.mounts') ?? {}
let xo5Mount: string | undefined
let xo6Mount: string | undefined
for (const [key, value] of Object.entries(mounts)) {
if (value.includes('xo-web/dist')) {
xo5Mount = key
} else if (value.includes('@xen-orchestra/web/dist')) {
xo6Mount = key
}
}
return {
xo5: xo5Mount,
xo6: xo6Mount,
}
}
}

View File

@@ -87,6 +87,6 @@ export type PingResponse = {
}
export type XoGuiRoutes = {
xo5: string | undefined
xo6: string | undefined
xo5?: string
xo6?: string
}

View File

@@ -16,6 +16,7 @@
- [Backups/s3] Update filesystem handling to use DeleteObjectsCommand in order to improve performance (PR [#9281](https://github.com/vatesfr/xen-orchestra/pull/9281))
- [REST API] Add link to the openAPI JSON directly in the swagger description (PR [#9285](https://github.com/vatesfr/xen-orchestra/pull/9285))
- [XO] XO6 is now the default page (PR [#9212](https://github.com/vatesfr/xen-orchestra/pull/9212))
### Bug fixes

View File

@@ -59,7 +59,7 @@
},
"private": true,
"scripts": {
"build": "TURBO_TELEMETRY_DISABLED=1 turbo run build --filter xo-server --filter xo-server-'*' --filter xo-web",
"build": "TURBO_TELEMETRY_DISABLED=1 turbo run build --filter xo-server --filter xo-server-'*' --filter xo-web --filter @xen-orchestra/web",
"build:xo-lite": "turbo run build --filter @xen-orchestra/lite",
"clean": "scripts/run-script.js --parallel clean",
"dev": "scripts/run-script.js --parallel --concurrency 0 --verbose dev",

View File

@@ -161,9 +161,19 @@ requestTimeout = 0
# These mount points are exposed only when authenticated.
[http.mounts]
'/' = '../xo-web/dist/'
# Uncomment to setup a default version.
# Otherwise, XO5 will be the default for stable channel and XO6 for latest and source
# '/' = '../xo-web/dist/'
'/v5' = '../xo-web/dist/'
'/v6' = '../../@xen-orchestra/web/dist/'
[http.proxies]
# [port] is used to reuse the same port declared in [http.listen.0]
'/v5/api' = 'ws://localhost:[port]/api'
'/v5/api/updater' = 'ws://localhost:9001'
'/v5/rest' = 'http://localhost:[port]/rest'
[logs]
# Display all logs matching this filter, regardless of their level
#filter = 'xo:load-balancer'

View File

@@ -152,6 +152,8 @@ port = 80
# List of files/directories which will be served.
[http.mounts]
#'/any/url' = '/path/to/directory'
#'/' = '../xo-web/dist/' # Uncomment this line to get v5 by default
#'/' = '../../@xen-orchestra/web/dist/' # Uncomment this line to get v6 by default
# List of proxied URLs (HTTP & WebSockets).
[http.proxies]

View File

@@ -6,8 +6,8 @@ html
meta(name = 'viewport' content = 'width=device-width, initial-scale=1.0')
title Xen Orchestra
meta(name = 'author' content = 'Vates SAS')
link(rel = 'stylesheet' href = 'index.css')
link(rel = 'icon' href = 'assets/favicon.svg' type = 'image/svg+xml')
link(rel = 'stylesheet' href = `${xo5Mount}index.css`)
link(rel = 'icon' href = `${xo5Mount}assets/favicon.svg` type = 'image/svg+xml')
style.
body {
color: #F8F8F8;
@@ -31,7 +31,7 @@ html
body(style = 'display: flex; height: 100vh;')
div(style = 'margin: auto; width: 20em;')
div(style = 'display: flex;')
img(src = 'assets/logo.svg' style = 'margin: auto; max-width: 100%;')
img(src = `${xo5Mount}assets/logo.svg` style = 'margin: auto; max-width: 100%;')
h2.text-xs-center.mb-2 Welcome to
svg(viewbox='65 622 735 64' xmlns='http://www.w3.org/2000/svg' fill='currentColor')
g

View File

@@ -188,10 +188,16 @@ async function setUpPassport(express, xo, { authentication: authCfg, http: { coo
} else {
errorMsg = req.flash('error')[0]
}
// TODO:
// The login page uses certain files from the xo-web package (css,svg)
// remove once XO5 is no more used and update `signin.pug` to use `public/logo.svg`, ...
const { xo5 } = await xo.config.getGuiRoutes()
res.send(
signInPage({
error: errorMsg,
strategies,
xo5Mount: xo5.url + '/',
})
)
} catch (error) {
@@ -544,6 +550,8 @@ const setUpProxies = (express, opts, xo) => {
return
}
const userPort = xo.config.get('http.listen.0.port')
const proxy = httpProxy
.createServer({
changeOrigin: true,
@@ -566,6 +574,25 @@ const setUpProxies = (express, opts, xo) => {
})
})
/**
*
* @param {string} url
* @returns {URL} targetUrl
*/
function getTargetUrl(url) {
let target = url
if (url.includes('[port]')) {
target = url.replace(/\[port\]/g, userPort)
}
const targetUrl = new URL(target)
if (targetUrl.port === 443) {
targetUrl.protocol = targetUrl.protocol === 'ws:' ? 'wss:' : 'https:'
}
return targetUrl
}
// TODO: sort proxies by descending prefix length.
// HTTP request proxy.
@@ -574,11 +601,11 @@ const setUpProxies = (express, opts, xo) => {
for (const prefix in opts) {
if (url.startsWith(prefix)) {
const target = opts[prefix]
const target = getTargetUrl(opts[prefix])
proxy.web(req, res, {
agent: new URL(target).hostname === 'localhost' ? undefined : xo.httpAgent,
target: target + url.slice(prefix.length),
agent: target.hostname === 'localhost' ? undefined : xo.httpAgent,
target: target.href + url.slice(prefix.length),
})
return
@@ -599,11 +626,11 @@ const setUpProxies = (express, opts, xo) => {
for (const prefix in opts) {
if (url.startsWith(prefix)) {
const target = opts[prefix]
const target = getTargetUrl(opts[prefix])
proxy.ws(req, socket, head, {
agent: new URL(target).hostname === 'localhost' ? undefined : xo.httpAgent,
target: target + url.slice(prefix.length),
target: target.href + url.slice(prefix.length),
})
return
@@ -935,13 +962,18 @@ export default async function main(args) {
setUpProxies(express, config.http.proxies, xo)
setUpStaticFiles(express, config.http.mounts)
if (!safeMode) {
await registerPlugins(xo)
xo.emit('plugins:registered')
}
const mounts = {}
for (const guiRoute of Object.values(await xo.config.getGuiRoutes())) {
mounts[guiRoute.url] = guiRoute.path
}
setUpStaticFiles(express, mounts)
// Gracefully shutdown on signals.
//
// TODO: implements a timeout? (or maybe it is the services launcher