mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-09-10 22:14:48 -05:00
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:
committed by
GitHub
parent
6a0332a784
commit
ada3cf745c
53
docs/.eslintrc.js
Normal file
53
docs/.eslintrc.js
Normal 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
1
docs/.gitignore
vendored
@@ -3,6 +3,7 @@
|
||||
|
||||
# Production
|
||||
/build
|
||||
/build-embed
|
||||
|
||||
# Generated files
|
||||
.docusaurus
|
||||
|
||||
@@ -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')],
|
||||
}
|
||||
|
||||
13
docs/docs/xo5/calculator.md
Normal file
13
docs/docs/xo5/calculator.md
Normal 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**.
|
||||
:::
|
||||
16
docs/docusaurus.embed.config.ts
Normal file
16
docs/docusaurus.embed.config.ts
Normal 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/',
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
490
docs/src/components/LtrBackupCalculator/index.tsx
Normal file
490
docs/src/components/LtrBackupCalculator/index.tsx
Normal 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 & 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 can’t be combined with long-term retention.</strong> 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.
|
||||
</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>
|
||||
)
|
||||
}
|
||||
151
docs/src/components/LtrBackupCalculator/styles.module.css
Normal file
151
docs/src/components/LtrBackupCalculator/styles.module.css
Normal 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;
|
||||
}
|
||||
@@ -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 couldn’t find the page you were looking for.
|
||||
We’re sorry, but we couldn’t find the page you were looking for.
|
||||
</Translate>
|
||||
</p>
|
||||
<p>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'] }))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user