feat(xo-server): serve doc from xo + add backup calculator (#10154)

* feat(xo-server): serve doc locally
* the doc is already distributed from the mono repo
* add a build phase to the global yanr build that build a doc with the
right prefix
* add a http mount doc => embed build

* doc(backups): add a space calculator
* estimate the space needed for backups
* compute the duration protected by immutability
This commit is contained in:
Florent BEAUCHAMP
2026-07-29 09:48:41 +02:00
committed by GitHub
parent 6a0332a784
commit ada3cf745c
13 changed files with 813 additions and 89 deletions

53
docs/.eslintrc.js Normal file
View File

@@ -0,0 +1,53 @@
'use strict'
// Self-contained ESLint config for the Docusaurus docs site.
//
// `root: true` stops inheritance from the monorepo-root config: that config
// parses files as CommonJS scripts (`sourceType: 'script'`) and only opts into
// ESM for `.mjs` and a few XO web packages, which breaks the docs' ESM
// TypeScript/TSX sources (docusaurus.config.ts, sidebars.ts, src/**).
//
// The docs project ships no ESLint deps of its own; this reuses the parser and
// plugins already installed at the monorepo root (@typescript-eslint/parser,
// eslint-plugin-react, eslint-config-prettier).
module.exports = {
root: true,
env: {
browser: true,
node: true,
es2022: true,
},
parserOptions: {
ecmaVersion: 2022,
sourceType: 'script',
},
extends: ['eslint:recommended', 'prettier'],
// `yarn test-lint` only passes the monorepo-root `.gitignore` to ESLint via
// `--ignore-path`, so the Docusaurus build outputs listed in `docs/.gitignore`
// are not excluded automatically: linting them would report thousands of
// errors on minified bundles (and CI does build them before linting).
ignorePatterns: ['/build/', '/build-embed/', '/.docusaurus/'],
overrides: [
{
files: ['*.ts', '*.tsx', '*.mjs'],
parser: '@typescript-eslint/parser',
parserOptions: {
sourceType: 'module',
ecmaFeatures: { jsx: true },
},
plugins: ['react'],
extends: ['plugin:react/recommended', 'prettier'],
settings: { react: { version: 'detect' } },
rules: {
// Docusaurus/React 18: the JSX runtime is automatic and props are typed.
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
// The @typescript-eslint *plugin* is not installed here, so core
// no-undef/no-unused-vars misfire on TS type syntax — defer both to
// `yarn typecheck` (tsc), which is the source of truth for types.
'no-undef': 'off',
'no-unused-vars': 'off',
},
},
],
}

1
docs/.gitignore vendored
View File

@@ -3,6 +3,7 @@
# Production
/build
/build-embed
# Generated files
.docusaurus

View File

@@ -1,8 +1,5 @@
'use strict'
module.exports = {
// 2024-11-23 - JFT - Don't know why ESLint reports the next line, disabling it
//
// eslint-disable-next-line n/no-missing-require
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
}

View File

@@ -0,0 +1,13 @@
---
title: Backup retention calculator
---
import LtrBackupCalculator from '@site/src/components/LtrBackupCalculator'
<LtrBackupCalculator />
:::info Assumptions
- One restore point per backup run (the default is one run per day).
- Week and month boundaries are approximated, and the full-backup count depends slightly on where "today" falls inside a full-backup cycle. When that matters, the calculator shows the **range**.
:::

View File

@@ -0,0 +1,16 @@
// Build variant for serving the docs *inside* xo-server under the `/docs` route.
//
// It only overrides baseUrl so every asset/link is resolved under `/docs/`
// (the public site keeps baseUrl `/`). trailingSlash stays false to match how
// the docs' relative links are authored.
//
// Rebuild with:
// yarn build:xo-server
//
// Then it is mounted in packages/xo-server/config.toml under [http.mounts].
import base from './docusaurus.config'
export default {
...base,
baseUrl: '/docs/',
}

View File

@@ -6,6 +6,7 @@
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
"build:xo-server": "docusaurus build --config docusaurus.embed.config.ts --out-dir build-embed",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",

View File

@@ -43,11 +43,10 @@ export default {
label: 'XO 6',
collapsible: true,
collapsed: false,
items:[
items: [
{
type: 'category',
label: 'What\'s new in XO6',
type: 'link',
label: "What's new in XO6",
href: 'https://xen-orchestra.com/blog/',
},
/* These categories are hidden until they no longer only contain blank pages
@@ -93,15 +92,13 @@ export default {
label: 'Security',
collapsible: true,
collapsed: true,
items:[
items: [
{
type: 'category',
label: 'User management',
collapsible: true,
collapsed: true,
items:[
'xo6/acl-v2',
],
items: ['xo6/acl-v2'],
},
],
},
@@ -110,7 +107,7 @@ export default {
label: 'DevOps tools',
collapsible: true,
collapsed: true,
items:[
items: [
'xo6/ansible',
'xo6/kubernetes',
'xo6/packer-provider',
@@ -124,20 +121,16 @@ export default {
label: 'Support',
collapsible: true,
collapsed: true,
items: [
'xo6/intro_support',
'xo6/purchase',
'xo6/community'
],
items: ['xo6/intro_support', 'xo6/purchase', 'xo6/community'],
},
]
],
},
{
type: 'category',
label: 'XO 5',
collapsible: true,
collapsed: false,
items:[
items: [
{
type: 'category',
label: 'Xen Orchestra',
@@ -153,7 +146,7 @@ export default {
'xo5/updater',
'xo5/architecture',
'xo5/troubleshooting',
]
],
},
{
type: 'category',
@@ -171,7 +164,7 @@ export default {
'xo5/load_balancing',
'xo5/sdn_controller',
'xo5/restapi',
'xo5/mcp'
'xo5/mcp',
],
},
{
@@ -194,6 +187,7 @@ export default {
'xo5/immutability',
'xo5/backup_reports',
'xo5/backup_troubleshooting',
'xo5/calculator',
],
},
{
@@ -201,12 +195,9 @@ export default {
label: 'Support',
collapsible: true,
collapsed: true,
items: [
'xo5/xoa',
'xo5/license_management'
],
items: ['xo5/xoa', 'xo5/license_management'],
},
]
],
},
{
type: 'category',

View File

@@ -0,0 +1,490 @@
import React, { useMemo, useState } from 'react'
import styles from './styles.module.css'
/**
* Browser reimplementation of the retention logic from
* `@xen-orchestra/backups/_getOldEntries.mjs`, plus the full-backup-chain
* accounting, so users can explore how LTR + full-backup interval drive the
* number of full backups stored on the remote.
*
* Assumptions (documented on the page):
* - backups run on a fixed schedule (configurable runs/day)
* - one restore point per run
* - week buckets use ISO weeks (close to XO's locale weeks with default settings)
* - times are computed in UTC (bucket boundaries shift by a few hours vs. a
* real timezone, which does not change the counts)
*/
const DAY = 24 * 3600 * 1000
type Entry = { id: string; timestamp: number; globalIndex: number }
type LtrConfig = { daily: number; weekly: number; monthly: number; yearly: number }
type BackupMode = 'incremental' | 'full'
const pad = (n: number, len = 2) => String(n).padStart(len, '0')
// --- bucket key formatters (mirror LTR_DEFINITIONS in _getOldEntries.mjs) ---
function dayKey(d: Date) {
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`
}
function monthKey(d: Date) {
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}`
}
function yearKey(d: Date) {
return `${d.getUTCFullYear()}`
}
function isoWeekKey(d: Date) {
// ISO-8601 week number, matches moment 'gggg-ww' closely with default settings
const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
const dayNum = (date.getUTCDay() + 6) % 7 // Mon=0..Sun=6
date.setUTCDate(date.getUTCDate() - dayNum + 3) // nearest Thursday
const firstThursday = new Date(Date.UTC(date.getUTCFullYear(), 0, 4))
const firstDayNum = (firstThursday.getUTCDay() + 6) % 7
firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3)
const week = 1 + Math.round((date.getTime() - firstThursday.getTime()) / (7 * DAY))
return `${date.getUTCFullYear()}-${pad(week)}`
}
const FORMATTERS: Record<keyof LtrConfig, (d: Date) => string> = {
daily: dayKey,
weekly: isoWeekKey,
monthly: monthKey,
yearly: yearKey,
}
type Bucket = { remaining: number; lastMatchingBucket: string | null; entries: Map<string, Entry> }
/** Port of getLtrEntries(): keep the N most-recent buckets per duration. */
function getLtrEntries(entries: Entry[], ltr: LtrConfig) {
const buckets = new Map<keyof LtrConfig, Bucket>()
for (const duration of Object.keys(FORMATTERS) as (keyof LtrConfig)[]) {
const retention = ltr[duration]
if (retention > 0) {
buckets.set(duration, { remaining: retention, lastMatchingBucket: null, entries: new Map() })
}
}
// newest -> oldest (entries are sorted ascending, so iterate from the end)
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i]
const date = new Date(entry.timestamp)
for (const [duration, bucket] of buckets) {
const key = FORMATTERS[duration](date)
if (bucket.lastMatchingBucket !== key) {
if (bucket.remaining === 0) continue
bucket.lastMatchingBucket = key
bucket.remaining -= 1
}
bucket.entries.set(key, entry) // last write (oldest of the bucket) wins
}
}
return buckets
}
/** Port of getOldEntries(): everything not kept by minRetentionCount or LTR. */
function computeKept(entries: Entry[], minRetentionCount: number, ltr: LtrConfig) {
const kept = new Set<Entry>()
for (let i = Math.max(0, entries.length - minRetentionCount); i < entries.length; i++) {
kept.add(entries[i])
}
const buckets = getLtrEntries(entries, ltr)
for (const bucket of buckets.values()) {
for (const entry of bucket.entries.values()) kept.add(entry)
}
return { kept, buckets }
}
type Result = {
keptCount: number
spanDays: number
fullsExample: number
fullsMin: number
fullsMax: number
blocks: { block: number; daysAgo: number[] }[]
// storage estimate (GiB), for the example alignment
fullGiB: number
deltaGiB: number
repoGiB: number
allFullGiB: number
// immutability (worst case)
minImmutableChainLen: number
immutabilityFullyMutable: boolean
fullIntervalDays: number
}
function compute(
runsPerDay: number,
backupRetention: number,
fullInterval: number,
ltr: LtrConfig,
diskUsageGiB: number,
changeRate: number,
immutableDays: number
): Result {
const f = Math.max(1, Math.round(runsPerDay))
const step = DAY / f
// History long enough to cover the widest LTR bucket, with margin.
const spanNeededDays =
Math.max(backupRetention / f, ltr.daily, ltr.weekly * 7, ltr.monthly * 31, ltr.yearly * 366, 1) + 60
const runs = Math.ceil(spanNeededDays) * f
const last = Date.UTC(2026, 6, 21, 12, 0, 0) // fixed "now" for determinism
const entries: Entry[] = []
for (let k = runs - 1; k >= 0; k--) {
const globalIndex = runs - 1 - k
entries.push({ id: `b${globalIndex}`, timestamp: last - k * step, globalIndex })
}
const { kept } = computeKept(entries, backupRetention, ltr)
const keptArr = [...kept].sort((a, b) => a.timestamp - b.timestamp)
const daysAgo = (e: Entry) => Math.round((last - e.timestamp) / DAY)
const spanDays = keptArr.length ? daysAgo(keptArr[0]) : 0
// full backups on disk = distinct full-interval chains touched by a kept point.
// interval <= 0 means "never take a scheduled full" -> a single chain -> 1 full.
const blockOf = (idx: number, phase: number) => (fullInterval <= 0 ? 0 : Math.floor((idx + phase) / fullInterval))
const countFulls = (phase: number) => new Set(keptArr.map(e => blockOf(e.globalIndex, phase))).size
// sweep every alignment of "today" within a full cycle
const phases = fullInterval <= 0 ? [0] : Array.from({ length: fullInterval }, (_, p) => p)
const counts = phases.map(countFulls)
const fullsMin = Math.min(...counts)
const fullsMax = Math.max(...counts)
const fullsExample = countFulls(0)
// breakdown for the example alignment
const byBlock = new Map<number, number[]>()
for (const e of keptArr) {
const b = blockOf(e.globalIndex, 0)
if (!byBlock.has(b)) byBlock.set(b, [])
byBlock.get(b)!.push(daysAgo(e))
}
const blocks = [...byBlock.entries()]
.sort((a, b) => b[0] - a[0])
.map(([block, daysAgoArr]) => ({ block, daysAgo: daysAgoArr.sort((x, y) => x - y) }))
// --- storage estimate (example alignment) ---
// Each chain's oldest kept point is a full (~= disk usage). Every other kept
// point is a delta whose data is the change accumulated over the gap (in
// backup runs) since the previous kept point in the same chain. Changes are
// accumulated linearly and capped at 100% (a merged delta never exceeds a full);
// this ignores block-overlap dedup, so real usage is usually a bit lower.
const U = Math.max(0, diskUsageGiB)
const p = Math.min(1, Math.max(0, changeRate / 100))
const chains = new Map<number, Entry[]>()
for (const e of keptArr) {
const b = blockOf(e.globalIndex, 0)
if (!chains.has(b)) chains.set(b, [])
chains.get(b)!.push(e)
}
let fullGiB = 0
let deltaGiB = 0
for (const chainEntries of chains.values()) {
chainEntries.sort((a, b) => a.globalIndex - b.globalIndex)
fullGiB += U // oldest point of the chain -> full
for (let i = 1; i < chainEntries.length; i++) {
const gap = chainEntries[i].globalIndex - chainEntries[i - 1].globalIndex
deltaGiB += Math.min(gap * p, 1) * U
}
}
const repoGiB = fullGiB + deltaGiB
const allFullGiB = keptArr.length * U // naive "every restore point is a full"
// --- immutability (worst case) ---
// A chain is protected only while its *full* (oldest backup) is immutable — an
// immutable delta is useless once its base full can be merged/deleted, so once
// the full expires the whole chain is effectively mutable.
//
// As the immutable window grows it covers whole chains further and further
// back, so the *contiguous* run of recent restore points whose full is still
// immutable grows with it. In the worst alignment (a full sitting just past the
// far edge of the window, orphaning its entire chain) that run is
// immutableBackups - fullInterval - 1
// restore points — it only becomes positive once the window is longer than one
// full-backup interval, then grows one-for-one with the window.
//
// Everything is counted in *backup runs* (restore points): the window holds
// `immutableDays * f` of them. `fullInterval <= 0` means "never take a new full"
// → one ever-growing chain whose base full always ages out → nothing protected.
const immutableBackups = immutableDays * f
const minImmutableChainLen = fullInterval <= 0 ? 0 : Math.max(0, immutableBackups - fullInterval - 1)
const immutabilityFullyMutable = immutableDays > 0 && minImmutableChainLen === 0
const fullIntervalDays = fullInterval > 0 ? fullInterval / f : Infinity
return {
keptCount: keptArr.length,
spanDays,
fullsExample,
fullsMin,
fullsMax,
blocks,
fullGiB,
deltaGiB,
repoGiB,
allFullGiB,
minImmutableChainLen,
immutabilityFullyMutable,
fullIntervalDays: Number.isFinite(fullIntervalDays) ? fullIntervalDays : 0,
}
}
/** Human-friendly size: GiB below 1 TiB, TiB above. */
function fmtSize(giB: number): string {
if (giB >= 1024) return `${(giB / 1024).toFixed(2)} TiB`
if (giB >= 100) return `${Math.round(giB)} GiB`
return `${giB.toFixed(1)} GiB`
}
function NumberField({
label,
value,
onChange,
min = 0,
hint,
disabled = false,
}: {
label: string
value: number
onChange: (v: number) => void
min?: number
hint?: string
disabled?: boolean
}): JSX.Element {
return (
<label className={styles.field}>
<span className={styles.fieldLabel}>{label}</span>
<input
type='number'
min={min}
value={value}
disabled={disabled}
onChange={e => onChange(Math.max(min, Number(e.target.value)))}
className={styles.input}
/>
{hint && <span className={styles.hint}>{hint}</span>}
</label>
)
}
export default function LtrBackupCalculator(): JSX.Element {
const [mode, setMode] = useState<BackupMode>('incremental')
const [runsPerDay, setRunsPerDay] = useState(1)
const [backupRetention, setBackupRetention] = useState(10)
const [fullInterval, setFullInterval] = useState(7)
const [daily, setDaily] = useState(5)
const [weekly, setWeekly] = useState(3)
const [monthly, setMonthly] = useState(3)
const [yearly, setYearly] = useState(0)
const [diskUsage, setDiskUsage] = useState(50)
const [changeRate, setChangeRate] = useState(2)
const [immutableDays, setImmutableDays] = useState(0)
const isFull = mode === 'full'
const ltrUsed = daily > 0 || weekly > 0 || monthly > 0 || yearly > 0
const immutableEnabled = immutableDays > 0
const immutableError = immutableEnabled && ltrUsed
// Incremental retention advances by merging/expiring the oldest restore point.
// The backup that rolls out of retention is `backupRetention` runs old; it can
// only be merged once it is mutable, i.e. older than the immutable window. So
// if the window covers the whole retention (immutable backups >= retention),
// the oldest point is still locked and incremental can never advance.
const immutableBackups = immutableDays * Math.max(1, Math.round(runsPerDay))
const oldestNotMutable = immutableEnabled && !isFull && immutableBackups >= backupRetention
const result = useMemo(
() =>
compute(
runsPerDay,
backupRetention,
// full mode = every backup is a full: model it as an interval of 1 so
// each restore point sits alone in its own chain (no deltas).
isFull ? 1 : fullInterval,
{ daily, weekly, monthly, yearly },
diskUsage,
isFull ? 0 : changeRate,
immutableDays
),
[
mode,
runsPerDay,
backupRetention,
fullInterval,
daily,
weekly,
monthly,
yearly,
diskUsage,
changeRate,
immutableDays,
]
)
const rangeSuffix = result.fullsMin === result.fullsMax ? '' : ` (range ${result.fullsMin}${result.fullsMax})`
return (
<div className={styles.calculator}>
<div className={styles.grid}>
<fieldset className={styles.group}>
<legend>Schedule</legend>
<label className={styles.field}>
<span className={styles.fieldLabel}>Backup mode</span>
<select
className={`${styles.input} ${styles.select}`}
value={mode}
onChange={e => setMode(e.target.value as BackupMode)}
>
<option value='incremental'>Incremental (delta)</option>
<option value='full'>Full</option>
</select>
<span className={styles.hint}>
{isFull ? 'Every backup is a full backup.' : 'First backup is full, the following ones are deltas.'}
</span>
</label>
<NumberField label='Backups per day' value={runsPerDay} onChange={setRunsPerDay} min={1} />
<NumberField
label='Full backup interval'
value={fullInterval}
onChange={setFullInterval}
min={0}
hint={isFull ? 'Disabled in full mode (every backup is a full)' : '0 = never take a new full'}
disabled={isFull}
/>
</fieldset>
<fieldset className={styles.group}>
<legend>VM &amp; data</legend>
<NumberField
label='VM disk usage (GiB)'
value={diskUsage}
onChange={setDiskUsage}
min={0}
hint='Used data, not provisioned size'
/>
<NumberField
label='Change between backups (%)'
value={changeRate}
onChange={setChangeRate}
min={0}
hint={isFull ? 'Disabled in full mode (no deltas)' : 'Share of the disk that changes each run'}
disabled={isFull}
/>
</fieldset>
<fieldset className={styles.group}>
<legend>Retention</legend>
<NumberField label='Backup retention' value={backupRetention} onChange={setBackupRetention} min={0} />
<NumberField label='Daily' value={daily} onChange={setDaily} />
<NumberField label='Weekly' value={weekly} onChange={setWeekly} />
<NumberField label='Monthly' value={monthly} onChange={setMonthly} />
<NumberField label='Yearly' value={yearly} onChange={setYearly} />
<NumberField
label='Immutability duration (days)'
value={immutableDays}
onChange={setImmutableDays}
min={0}
hint='0 = no immutability'
/>
</fieldset>
</div>
{immutableError && (
<div className={styles.error} role='alert'>
<strong>Immutability cant be combined with long-term retention.</strong> Immutable backups cant be merged or
deleted while they are locked, so daily / weekly / monthly / yearly slots would pin an ever-growing set of
restore points. Set long-term retention to 0 to use immutable storage.
</div>
)}
{!immutableError && oldestNotMutable && (
<div className={styles.error} role='alert'>
<strong>Incremental mode can only work if the oldest backups are mutable.</strong> The immutable window (
{immutableDays} days {immutableBackups} backups) is at least as long as the backup retention (
{backupRetention}), so the oldest restore point can never be merged or expired incremental retention can
never advance and the backups pile up. Lower the immutable duration below the retention, or switch to full
mode.
</div>
)}
<div className={styles.results}>
<div className={styles.stat}>
<div className={styles.statValue}>{fmtSize(result.repoGiB)}</div>
<div className={styles.statLabel}>estimated repository size</div>
</div>
{immutableEnabled && !immutableError && (
<div className={styles.stat}>
<div className={styles.statValue}>{oldestNotMutable ? 0 : result.minImmutableChainLen}</div>
<div className={styles.statLabel}>worst-case immutable chain (restore points)</div>
</div>
)}
<div className={styles.stat}>
<div className={styles.statValue}>
{result.fullsExample}
{rangeSuffix}
</div>
<div className={styles.statLabel}>full backups stored on the remote</div>
</div>
<div className={styles.stat}>
<div className={styles.statValue}>{result.keptCount}</div>
<div className={styles.statLabel}>restore points kept</div>
</div>
<div className={styles.stat}>
<div className={styles.statValue}>{result.spanDays}</div>
<div className={styles.statLabel}>days covered (oldest newest)</div>
</div>
</div>
<p className={styles.breakdown}>
<strong>{fmtSize(result.fullGiB)}</strong> of full backups ({result.fullsExample} × {fmtSize(diskUsage)}) +{' '}
<strong>{fmtSize(result.deltaGiB)}</strong> of deltas. Storing every restore point as a full backup would need{' '}
<strong>{fmtSize(result.allFullGiB)}</strong>.
</p>
{immutableEnabled && !immutableError && !oldestNotMutable && result.immutabilityFullyMutable && (
<div className={styles.warn} role='alert'>
<strong>In the worst case the whole chain is mutable.</strong> A chain is protected only while its{' '}
<em>full</em> backup is immutable, but the full is the oldest point in the chain: by the time a chain of{' '}
{isFull ? '1 backup' : `${fullInterval} backups`} completes, its full is{' '}
<strong> {result.fullIntervalDays.toFixed(result.fullIntervalDays < 10 ? 1 : 0)} days</strong> old older
than the <strong>{immutableDays}-day</strong> window so its still-immutable deltas are left without an
immutable base. Set immutability to at least the full-backup interval to keep complete chains protected.
</div>
)}
{immutableEnabled && !immutableError && !oldestNotMutable && !result.immutabilityFullyMutable && (
<p className={styles.breakdown}>
The <strong>{immutableDays}-day</strong> window covers a full chain end-to-end, so even in the worst case a
complete chain of <strong>{result.minImmutableChainLen}</strong> restore point
{result.minImmutableChainLen === 1 ? '' : 's'} stays fully immutable (its full backup is still inside the
window, so every delta on top of it is restorable).
</p>
)}
<details className={styles.details}>
<summary>How the kept restore points map to full-backup chains</summary>
<p className={styles.detailsIntro}>
Each <em>chain</em> keeps exactly one full backup: the oldest restore point still in it absorbs its deleted
ancestors when XO merges. So the number of full backups equals the number of chains that still hold a kept
point.
</p>
<table className={styles.table}>
<thead>
<tr>
<th>Full-backup chain</th>
<th>Kept restore points (days ago)</th>
</tr>
</thead>
<tbody>
{result.blocks.map(b => (
<tr key={b.block}>
<td>#{b.block}</td>
<td>{b.daysAgo.join(', ')}</td>
</tr>
))}
</tbody>
</table>
</details>
</div>
)
}

View File

@@ -0,0 +1,151 @@
.calculator {
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: var(--ifm-global-radius);
padding: 1.25rem;
background: var(--ifm-background-surface-color);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
.group {
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: var(--ifm-global-radius);
padding: 0.75rem 1rem 1rem;
margin: 0;
}
.group legend {
font-weight: 600;
font-size: 0.9rem;
padding: 0 0.4rem;
color: var(--ifm-color-emphasis-700);
}
.field {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
margin: 0.5rem 0;
flex-wrap: wrap;
}
.fieldLabel {
font-size: 0.95rem;
}
.input {
width: 6rem;
padding: 0.3rem 0.5rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: var(--ifm-global-radius);
background: var(--ifm-background-color);
color: var(--ifm-font-color-base);
font: inherit;
text-align: right;
}
.hint {
flex-basis: 100%;
font-size: 0.8rem;
color: var(--ifm-color-emphasis-600);
}
.select {
width: auto;
min-width: 9rem;
text-align: left;
}
.input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error {
margin-top: 1rem;
padding: 0.6rem 0.9rem;
border: 1px solid var(--ifm-color-danger);
border-radius: var(--ifm-global-radius);
background: var(--ifm-color-danger-contrast-background);
color: var(--ifm-color-danger-contrast-foreground);
font-size: 0.9rem;
}
.warn {
margin-top: 1rem;
padding: 0.6rem 0.9rem;
border: 1px solid var(--ifm-color-warning);
border-radius: var(--ifm-global-radius);
background: var(--ifm-color-warning-contrast-background);
color: var(--ifm-color-warning-contrast-foreground);
font-size: 0.9rem;
}
.results {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1rem;
margin-top: 1.25rem;
}
.stat {
text-align: center;
padding: 1rem 0.5rem;
border-radius: var(--ifm-global-radius);
background: var(--ifm-color-primary-contrast-background);
}
.stat:first-child {
background: var(--ifm-color-primary);
color: #fff;
}
.statValue {
font-size: 1.9rem;
font-weight: 700;
line-height: 1.1;
}
.statLabel {
font-size: 0.85rem;
margin-top: 0.35rem;
opacity: 0.9;
}
.breakdown {
margin-top: 1rem;
margin-bottom: 0;
font-size: 0.9rem;
color: var(--ifm-color-emphasis-700);
text-align: center;
}
.details {
margin-top: 1.25rem;
font-size: 0.95rem;
}
.details summary {
cursor: pointer;
font-weight: 600;
}
.detailsIntro {
margin: 0.75rem 0;
color: var(--ifm-color-emphasis-700);
}
.table {
width: 100%;
display: table;
}
.table th,
.table td {
text-align: left;
}

View File

@@ -16,7 +16,7 @@ export default function NotFoundContent({ className }: Props): JSX.Element {
</Heading>
<p>
<Translate id='theme.NotFound.p1' description='The first paragraph of the 404 page'>
We're sorry, but we couldnt find the page you were looking for.
Were sorry, but we couldnt find the page you were looking for.
</Translate>
</p>
<p>

View File

@@ -62,7 +62,8 @@
},
"private": true,
"scripts": {
"build": "TURBO_TELEMETRY_DISABLED=1 turbo run build --filter xo-server --filter xo-server-'*' --filter xo-web --filter @xen-orchestra/web --filter @xen-orchestra/backup-archive",
"build": "TURBO_TELEMETRY_DISABLED=1 turbo run build --filter xo-server --filter xo-server-'*' --filter xo-web --filter @xen-orchestra/web --filter @xen-orchestra/backup-archive && yarn build:doc",
"build:doc": "yarn --cwd docs install && yarn --cwd docs build:xo-server",
"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

@@ -191,6 +191,12 @@ requestTimeout = 0
'/v5' = '../xo-web/dist/'
'/v6' = '../../@xen-orchestra/web/dist/'
# Documentation site (Docusaurus), built for the /docs base URL, see
# docs/docusaurus.embed.config.ts. Deep links like /docs/xo5/calculator resolve
# only if serve-static is given `{ extensions: ['html'] }` (see setUpStaticFiles
# in src/index.mjs); otherwise only /docs/ + in-app navigation work.
'/docs' = '../../docs/build-embed/'
[http.proxies]
# [port] is used to reuse the same port declared in [http.listen.0]
'/openmetrics' = 'http://localhost:9004'

View File

@@ -768,7 +768,11 @@ const setUpStaticFiles = (express, opts) => {
ensureArray(paths).forEach(path => {
log.info(`Setting up ${url}${path}`)
express.use(url, serveStatic(path))
// `extensions: ['html']` lets extension-less deep links resolve to their
// `.html` file (e.g. `/docs/xo5/calculator` → `calculator.html`), which
// the Docusaurus docs mount needs since it is built with trailingSlash
// false. Harmless for the SPA mounts (no route-colliding .html files).
express.use(url, serveStatic(path, { extensions: ['html'] }))
})
})
}