From ada3cf745c2fa56f335cdb233dc06e0022be9d83 Mon Sep 17 00:00:00 2001 From: Florent BEAUCHAMP Date: Wed, 29 Jul 2026 09:48:41 +0200 Subject: [PATCH] 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 --- docs/.eslintrc.js | 53 ++ docs/.gitignore | 1 + docs/babel.config.js | 3 - docs/docs/xo5/calculator.md | 13 + docs/docusaurus.embed.config.ts | 16 + docs/package.json | 1 + docs/sidebars.ts | 157 +++--- .../components/LtrBackupCalculator/index.tsx | 490 ++++++++++++++++++ .../LtrBackupCalculator/styles.module.css | 151 ++++++ docs/src/theme/NotFound/Content/index.tsx | 2 +- package.json | 3 +- packages/xo-server/config.toml | 6 + packages/xo-server/src/index.mjs | 6 +- 13 files changed, 813 insertions(+), 89 deletions(-) create mode 100644 docs/.eslintrc.js create mode 100644 docs/docs/xo5/calculator.md create mode 100644 docs/docusaurus.embed.config.ts create mode 100644 docs/src/components/LtrBackupCalculator/index.tsx create mode 100644 docs/src/components/LtrBackupCalculator/styles.module.css diff --git a/docs/.eslintrc.js b/docs/.eslintrc.js new file mode 100644 index 0000000000..d9a881f347 --- /dev/null +++ b/docs/.eslintrc.js @@ -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', + }, + }, + ], +} diff --git a/docs/.gitignore b/docs/.gitignore index b2d6de3062..397fd204bc 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -3,6 +3,7 @@ # Production /build +/build-embed # Generated files .docusaurus diff --git a/docs/babel.config.js b/docs/babel.config.js index 53d71a356f..9758ef2ab6 100644 --- a/docs/babel.config.js +++ b/docs/babel.config.js @@ -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')], } diff --git a/docs/docs/xo5/calculator.md b/docs/docs/xo5/calculator.md new file mode 100644 index 0000000000..a905490e5d --- /dev/null +++ b/docs/docs/xo5/calculator.md @@ -0,0 +1,13 @@ +--- +title: Backup retention calculator +--- + +import LtrBackupCalculator from '@site/src/components/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**. + ::: diff --git a/docs/docusaurus.embed.config.ts b/docs/docusaurus.embed.config.ts new file mode 100644 index 0000000000..09b594b9ce --- /dev/null +++ b/docs/docusaurus.embed.config.ts @@ -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/', +} diff --git a/docs/package.json b/docs/package.json index e03674ae03..f0da7eee47 100644 --- a/docs/package.json +++ b/docs/package.json @@ -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", diff --git a/docs/sidebars.ts b/docs/sidebars.ts index f0003ff7b7..2b7d5cc566 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -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,16 +92,14 @@ export default { label: 'Security', collapsible: true, collapsed: true, - items:[ + items: [ { - type: 'category', - label: 'User management', - collapsible: true, - collapsed: true, - items:[ - 'xo6/acl-v2', - ], - }, + type: 'category', + label: 'User management', + collapsible: true, + collapsed: true, + 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,90 +121,84 @@ 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', collapsible: true, collapsed: true, items: [ - 'xo5/releases', - 'xo5/supported_hosts', - 'xo5/installation', - 'xo5/configuration', - 'xo5/credential-encryption', - 'xo5/migrate_to_new_xoa', - 'xo5/updater', - 'xo5/architecture', - 'xo5/troubleshooting', - ] + 'xo5/releases', + 'xo5/supported_hosts', + 'xo5/installation', + 'xo5/configuration', + 'xo5/credential-encryption', + 'xo5/migrate_to_new_xoa', + 'xo5/updater', + 'xo5/architecture', + 'xo5/troubleshooting', + ], }, { - type: 'category', - label: 'Management', - collapsible: true, - collapsed: true, - items: [ - 'xo5/manage', - 'xo5/manage_infrastructure', - 'xo5/object-storage-support', - 'xo5/v2v-migration-guide', - 'xo5/users', - 'xo5/vm-templates', - 'xo5/advanced', - 'xo5/load_balancing', - 'xo5/sdn_controller', - 'xo5/restapi', - 'xo5/mcp' + type: 'category', + label: 'Management', + collapsible: true, + collapsed: true, + items: [ + 'xo5/manage', + 'xo5/manage_infrastructure', + 'xo5/object-storage-support', + 'xo5/v2v-migration-guide', + 'xo5/users', + 'xo5/vm-templates', + 'xo5/advanced', + 'xo5/load_balancing', + 'xo5/sdn_controller', + 'xo5/restapi', + 'xo5/mcp', + ], + }, + { + type: 'category', + label: 'Backups', + collapsible: true, + collapsed: true, + items: [ + 'xo5/intro_backup', + 'xo5/backups', + 'xo5/backup_howto', + 'xo5/proxy', + 'xo5/rolling_snapshots', + 'xo5/full_backups', + 'xo5/incremental_backups', + 'xo5/full_replication', + 'xo5/incremental_replication', + 'xo5/mirror_backup', + 'xo5/metadata_backup', + 'xo5/immutability', + 'xo5/backup_reports', + 'xo5/backup_troubleshooting', + 'xo5/calculator', + ], + }, + { + type: 'category', + label: 'Support', + collapsible: true, + collapsed: true, + items: ['xo5/xoa', 'xo5/license_management'], + }, ], }, - { - type: 'category', - label: 'Backups', - collapsible: true, - collapsed: true, - items: [ - 'xo5/intro_backup', - 'xo5/backups', - 'xo5/backup_howto', - 'xo5/proxy', - 'xo5/rolling_snapshots', - 'xo5/full_backups', - 'xo5/incremental_backups', - 'xo5/full_replication', - 'xo5/incremental_replication', - 'xo5/mirror_backup', - 'xo5/metadata_backup', - 'xo5/immutability', - 'xo5/backup_reports', - 'xo5/backup_troubleshooting', - ], - }, - { - type: 'category', - label: 'Support', - collapsible: true, - collapsed: true, - items: [ - 'xo5/xoa', - 'xo5/license_management' - ], - }, - ] - }, { type: 'category', label: 'Project', @@ -223,9 +214,9 @@ export default { 'contributing', 'licenses', { - type: 'link', - label: 'Roadmap', - href: 'https://docs.vates.tech/roadmap/', + type: 'link', + label: 'Roadmap', + href: 'https://docs.vates.tech/roadmap/', }, 'glossary', ], diff --git a/docs/src/components/LtrBackupCalculator/index.tsx b/docs/src/components/LtrBackupCalculator/index.tsx new file mode 100644 index 0000000000..69b143b764 --- /dev/null +++ b/docs/src/components/LtrBackupCalculator/index.tsx @@ -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 string> = { + daily: dayKey, + weekly: isoWeekKey, + monthly: monthKey, + yearly: yearKey, +} + +type Bucket = { remaining: number; lastMatchingBucket: string | null; entries: Map } + +/** Port of getLtrEntries(): keep the N most-recent buckets per duration. */ +function getLtrEntries(entries: Entry[], ltr: LtrConfig) { + const buckets = new Map() + 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() + 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() + 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() + 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 ( + + ) +} + +export default function LtrBackupCalculator(): JSX.Element { + const [mode, setMode] = useState('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 ( +
+
+
+ Schedule + + + +
+
+ VM & data + + +
+
+ Retention + + + + + + + +
+
+ + {immutableError && ( +
+ Immutability can’t be combined with long-term retention. Immutable backups can’t 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. +
+ )} + + {!immutableError && oldestNotMutable && ( +
+ Incremental mode can only work if the oldest backups are mutable. 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. +
+ )} + +
+
+
{fmtSize(result.repoGiB)}
+
estimated repository size
+
+ {immutableEnabled && !immutableError && ( +
+
{oldestNotMutable ? 0 : result.minImmutableChainLen}
+
worst-case immutable chain (restore points)
+
+ )} +
+
+ {result.fullsExample} + {rangeSuffix} +
+
full backups stored on the remote
+
+
+
{result.keptCount}
+
restore points kept
+
+
+
{result.spanDays}
+
days covered (oldest → newest)
+
+
+ +

+ ≈ {fmtSize(result.fullGiB)} of full backups ({result.fullsExample} × {fmtSize(diskUsage)}) +{' '} + {fmtSize(result.deltaGiB)} of deltas. Storing every restore point as a full backup would need{' '} + {fmtSize(result.allFullGiB)}. +

+ + {immutableEnabled && !immutableError && !oldestNotMutable && result.immutabilityFullyMutable && ( +
+ In the worst case the whole chain is mutable. A chain is protected only while its{' '} + full 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{' '} + ≈ {result.fullIntervalDays.toFixed(result.fullIntervalDays < 10 ? 1 : 0)} days old — older + than the {immutableDays}-day 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. +
+ )} + + {immutableEnabled && !immutableError && !oldestNotMutable && !result.immutabilityFullyMutable && ( +

+ The {immutableDays}-day window covers a full chain end-to-end, so even in the worst case a + complete chain of {result.minImmutableChainLen} 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). +

+ )} + +
+ How the kept restore points map to full-backup chains +

+ Each chain 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. +

+ + + + + + + + + {result.blocks.map(b => ( + + + + + ))} + +
Full-backup chainKept restore points (days ago)
#{b.block}{b.daysAgo.join(', ')}
+
+
+ ) +} diff --git a/docs/src/components/LtrBackupCalculator/styles.module.css b/docs/src/components/LtrBackupCalculator/styles.module.css new file mode 100644 index 0000000000..9aece667f0 --- /dev/null +++ b/docs/src/components/LtrBackupCalculator/styles.module.css @@ -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; +} diff --git a/docs/src/theme/NotFound/Content/index.tsx b/docs/src/theme/NotFound/Content/index.tsx index 37661978f6..d199c4732d 100644 --- a/docs/src/theme/NotFound/Content/index.tsx +++ b/docs/src/theme/NotFound/Content/index.tsx @@ -16,7 +16,7 @@ export default function NotFoundContent({ className }: Props): JSX.Element {

- We're sorry, but we couldn’t find the page you were looking for. + We’re sorry, but we couldn’t find the page you were looking for.

diff --git a/package.json b/package.json index b61457c53e..30280c4ad2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/xo-server/config.toml b/packages/xo-server/config.toml index 667c266539..cc56cec804 100644 --- a/packages/xo-server/config.toml +++ b/packages/xo-server/config.toml @@ -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' diff --git a/packages/xo-server/src/index.mjs b/packages/xo-server/src/index.mjs index c6f85bd00b..e55b7ed9f7 100644 --- a/packages/xo-server/src/index.mjs +++ b/packages/xo-server/src/index.mjs @@ -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'] })) }) }) }