From 03cea8b24d48e685a2a5a04a39db70d67d87eca0 Mon Sep 17 00:00:00 2001 From: Mathieu <70369997+MathieuRA@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:29:49 +0100 Subject: [PATCH] feat(xo-server): set XO6 the default page (#9212) --- @xen-orchestra/mixins/Config.mjs | 44 +++++++++++++++++- .../rest-api/src/rest-api/rest-api.type.mts | 18 ++++++++ .../rest-api/src/xoa/xoa.controller.mts | 14 ++++-- .../rest-api/src/xoa/xoa.service.mts | 22 +-------- @xen-orchestra/rest-api/src/xoa/xoa.type.mts | 4 +- CHANGELOG.unreleased.md | 1 + package.json | 2 +- packages/xo-server/config.toml | 12 ++++- packages/xo-server/sample.config.toml | 2 + packages/xo-server/signin.pug | 6 +-- packages/xo-server/src/index.mjs | 46 ++++++++++++++++--- 11 files changed, 132 insertions(+), 39 deletions(-) diff --git a/@xen-orchestra/mixins/Config.mjs b/@xen-orchestra/mixins/Config.mjs index 47563a18e8..6359a52337 100644 --- a/@xen-orchestra/mixins/Config.mjs +++ b/@xen-orchestra/mixins/Config.mjs @@ -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 + } } diff --git a/@xen-orchestra/rest-api/src/rest-api/rest-api.type.mts b/@xen-orchestra/rest-api/src/rest-api/rest-api.type.mts index 97896de9b6..c8f3a3a4ce 100644 --- a/@xen-orchestra/rest-api/src/rest-api/rest-api.type.mts +++ b/@xen-orchestra/rest-api/src/rest-api/rest-api.type.mts @@ -75,6 +75,24 @@ export type XoApp = { config: { getOptional(path: string): Record | 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 & { diff --git a/@xen-orchestra/rest-api/src/xoa/xoa.controller.mts b/@xen-orchestra/rest-api/src/xoa/xoa.controller.mts index 2ddabdc750..7a9ff733dc 100644 --- a/@xen-orchestra/rest-api/src/xoa/xoa.controller.mts +++ b/@xen-orchestra/rest-api/src/xoa/xoa.controller.mts @@ -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 { + const { xo5, xo6 } = await this.#restApi.xoApp.config.getGuiRoutes() + + return { + xo5: xo5.url, + xo6: xo6.url, + } } } diff --git a/@xen-orchestra/rest-api/src/xoa/xoa.service.mts b/@xen-orchestra/rest-api/src/xoa/xoa.service.mts index 39ab7daf29..36afccf6b8 100644 --- a/@xen-orchestra/rest-api/src/xoa/xoa.service.mts +++ b/@xen-orchestra/rest-api/src/xoa/xoa.service.mts @@ -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, - } - } } diff --git a/@xen-orchestra/rest-api/src/xoa/xoa.type.mts b/@xen-orchestra/rest-api/src/xoa/xoa.type.mts index 774a4ffa41..6eb1f3ec14 100644 --- a/@xen-orchestra/rest-api/src/xoa/xoa.type.mts +++ b/@xen-orchestra/rest-api/src/xoa/xoa.type.mts @@ -87,6 +87,6 @@ export type PingResponse = { } export type XoGuiRoutes = { - xo5: string | undefined - xo6: string | undefined + xo5?: string + xo6?: string } diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index c103abdd12..d4591a95a4 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -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 diff --git a/package.json b/package.json index 9f6282c241..b337877004 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/xo-server/config.toml b/packages/xo-server/config.toml index 69d4311848..ac81fb41ad 100644 --- a/packages/xo-server/config.toml +++ b/packages/xo-server/config.toml @@ -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' diff --git a/packages/xo-server/sample.config.toml b/packages/xo-server/sample.config.toml index 48ff7ae686..49db1cb6ac 100644 --- a/packages/xo-server/sample.config.toml +++ b/packages/xo-server/sample.config.toml @@ -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] diff --git a/packages/xo-server/signin.pug b/packages/xo-server/signin.pug index bba8835d7b..29f3492c69 100644 --- a/packages/xo-server/signin.pug +++ b/packages/xo-server/signin.pug @@ -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 diff --git a/packages/xo-server/src/index.mjs b/packages/xo-server/src/index.mjs index 77176a5e97..7ef77d83e7 100644 --- a/packages/xo-server/src/index.mjs +++ b/packages/xo-server/src/index.mjs @@ -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