complete i18n migration to /[locale]/ with EN+ES content

Full rewrite of the docs site under app/[locale]/ with next-intl
in localePrefix:"always" mode. Every page now exists at both
/en/<path> and /es/<path>; the root / shows a meta-refresh + JS
redirect to /<defaultLocale>/ so GitHub Pages serves something
on the apex URL.

Highlights:
- 107 doc pages migrated to file-per-page JSON namespaces under
  messages/en/ and messages/es/. Spanish content is fully
  translated (no copy-of-English placeholders).
- New documentation for the Active Suppressions section in the
  Settings tab and the per-event Dismiss dropdown in the Health
  Monitor modal.
- New screenshots: dismiss-duration-dropdown.png and an updated
  health-suppression-settings.png.
- Pagefind integrated for client-side search; index is built on
  every CI deploy (not committed).
- RSS feeds: per-locale at /<locale>/rss.xml plus root /rss.xml
  for backward compat.
- Removed the dead app/[locale]/guides/[slug]/ route — every
  guide now has its own static page and no markdown source
  remains.
- Fixed orphan link /guides/nvidia -> /guides/nvidia-manual in
  docs/hardware/nvidia-host.
- Removed obsolete components (footer2, calendar, drawer).

Verified locally with `npm ci && npm run build`: 2804 files in
out/, 231 pages indexed by pagefind, root redirect intact, both
locale roots and the new Active Suppressions docs render OK.
This commit is contained in:
MacRimi
2026-05-31 12:41:10 +02:00
parent 875910b4d7
commit 5ca3463bf6
649 changed files with 83958 additions and 11096 deletions

View File

@@ -0,0 +1,317 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import Image from "next/image"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
import CopyableCode from "@/components/CopyableCode"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.diskManager.addControllerNvmeVm.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/disk-manager/add-controller-nvme-vm",
},
}
}
type StepData = {
title: string
body?: string
bodyRich?: string
items?: string[]
outro?: string
img?: string
alt?: string
caption?: string
}
type StringItem = string
type RelatedItem = { href: string; label: string; tail?: string }
export default async function AddControllerNVMeVMPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.diskManager.addControllerNvmeVm" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { diskManager: { addControllerNvmeVm: {
prereqs: { items: StringItem[] }
steps: { list: StepData[] }
related: { items: RelatedItem[] }
} } }
}
const prereqItems = messages.docs.diskManager.addControllerNvmeVm.prereqs.items
const stepList = messages.docs.diskManager.addControllerNvmeVm.steps.list
const relatedItems = messages.docs.diskManager.addControllerNvmeVm.related.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={15}
scriptPath="storage/add_controller_nvme_vm.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { strong, code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howRuns.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("howRuns.body")}</p>
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
{`┌─────────────────────────────────────────────┐
│ PHASE 1 — Detect, validate, plan │
│ (nothing touched yet) │
└──────────────────┬──────────────────────────┘
qm list — user picks target VM
IOMMU status on the running kernel
├─ /sys/kernel/iommu_groups/* exists?
├─ Yes → IOMMU active, continue
└─ No → cmdline check + offer to enable
CPU vendor detect (cat /proc/cpuinfo)
├─ Intel → write intel_iommu=on
└─ AMD → write amd_iommu=on
Into:
├─ /etc/kernel/cmdline (systemd-boot)
└─ /etc/default/grub (GRUB)
+ update-initramfs -u -k all
+ offer reboot now
├─ reboot accepted → reboot
└─ reboot declined → abort
(re-run after reboot)
Enumerate storage-class PCI devices
lspci -Dnn filtered by class:
├─ SATA / SAS / SCSI / NVMe controllers
├─ Resolve IOMMU group via /sys path
└─ For HBAs: list disks currently behind
Conflict / eligibility filter
├─ Already in this VM's hostpci? → hide
├─ Already in another VM's hostpci?
│ → block (shown with owner VM id)
├─ Carries the Proxmox root disk
│ or any disk referenced by an LXC
│ → block
└─ Shared IOMMU group
with non-storage members?
→ show ⚠ warning inline
User selects device(s) via checklist
Summary:
(VM + each PCI device + IOMMU group
membership + reboot status)
┌──────── Cancel OR Confirm ────┐
▼ ▼
Exit, nothing ┌─────────────────┴─────────────────┐
was changed │ PHASE 2 — Apply │
└─────────────────┬─────────────────┘
Host side (once per session):
├─ Add vfio-pci to /etc/modules
├─ Append the device vendor:device
│ IDs to /etc/modprobe.d/vfio.conf
└─ update-initramfs -u -k all
(so the device is bound to vfio-pci
at next boot, not the native driver)
For each selected device:
├─ Find next free hostpciN slot
│ (scans qm config)
└─ qm set <VMID> --hostpciN \\
<BDF>,pcie=1
(e.g. 0000:01:00.0,pcie=1)
Verify: qm config <VMID> shows
the new hostpciN entries
If IOMMU was just enabled:
└─ reminder to reboot before
starting the VM
Guest on next boot sees the
controller directly + every disk
behind it (full SMART, native
firmware features, no Proxmox layer)`}
</pre>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("iommu.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("iommu.body", { strong })}
</p>
<div className="my-6 rounded-md border border-gray-200 bg-gray-50 p-4 overflow-x-auto">
<pre className="text-xs leading-relaxed text-gray-800 whitespace-pre font-mono">
{` Host PCIe bus — grouped by IOMMU
┌─────────────────────────────────────────┐
│ Group 12 │
│ ──────── │
│ 00:17.0 SATA HBA │
│ └── sda sdb sdc sdd │
│ │
│ Pass-through takes: │
│ the HBA + every disk on it │
│ │
│ ✓ clean — no extra members in group │
└──────────────────┬──────────────────────┘
┌─────────────────────────────────────────┐
│ Group 13 │
│ ──────── │
│ 01:00.0 NVMe controller │
│ │
│ Pass-through takes: │
│ the NVMe controller itself │
│ │
│ ✓ clean — NVMe alone in its group │
└──────────────────┬──────────────────────┘
┌─────────────────────────────────────────┐
│ Group 14 │
│ ──────── │
│ 02:00.0 SATA HBA │
│ └── sde sdf │
│ 02:00.1 USB 3.0 controller │
│ │
│ Pass-through takes: │
│ SATA HBA + USB 3.0 controller │
│ (whole group leaves together) │
│ │
│ ⚠ shared group — the USB ports will │
│ also leave the host. Review whether │
│ that is acceptable before confirming.│
└─────────────────────────────────────────┘`}
</pre>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">{t("iommu.outro")}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("prereqs.heading")}</h2>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{prereqItems.map((_, idx) => (
<li key={idx}>{t.rich(`prereqs.items.${idx}`, { strong, em })}</li>
))}
</ul>
<Callout variant="warning" title={t("prereqs.warnTitle")}>
{t("prereqs.warnBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("steps.heading")}</h2>
{stepList.map((step, idx) => (
<section key={idx} className="mt-8 border-b border-gray-200 pb-8">
<div className="flex items-center gap-3 mb-3">
<span className="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-0.5 text-xs font-semibold text-blue-800">
{t("steps.stepLabel")} {idx + 1}
</span>
<h3 className="text-lg font-semibold text-gray-900 m-0">{step.title}</h3>
</div>
<div className="mb-4 text-gray-800 leading-relaxed">
{step.bodyRich ? (
<p className="mb-4">{t.rich(`steps.list.${idx}.bodyRich`, { code, strong })}</p>
) : step.body && <p className="mb-4">{step.body}</p>}
{step.items && (
<ul className="list-disc pl-6 mt-2 space-y-1 mb-4">
{step.items.map((_, i) => (
<li key={i}>{t.rich(`steps.list.${idx}.items.${i}`, { code })}</li>
))}
</ul>
)}
{step.outro && <p className="mb-4">{step.outro}</p>}
</div>
{step.img && (
<div className="flex flex-col items-center w-full max-w-[768px] mx-auto my-4">
<div className="w-full overflow-hidden rounded-md border border-gray-200">
<Image src={step.img} alt={step.alt || step.title} width={768} height={0} style={{ height: "auto" }} className="w-full object-contain" sizes="(max-width: 768px) 100vw, 768px" />
</div>
{step.caption && <span className="mt-2 text-sm text-gray-600">{step.caption}</span>}
</div>
)}
</section>
))}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
<CopyableCode code={`# 1. verify IOMMU is active
dmesg | grep -iE "DMAR|IOMMU" | head
ls /sys/kernel/iommu_groups/
# 2. list IOMMU groups and their members
for d in /sys/kernel/iommu_groups/*/devices/*; do
n=$(basename "$d"); g=$(dirname "$(dirname "$d")")
printf 'group %3d %s %s\\n' "$(basename "$g")" "$n" \\
"$(lspci -s "$n" | cut -d' ' -f2-)"
done | sort -n
# 3. attach a storage controller at PCI 0000:00:17.0 to VM 101
qm set 101 --hostpci0 0000:00:17.0,pcie=1
# 4. verify
qm config 101 | grep ^hostpci`} />
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.noGroupsTitle")}>
{t("troubleshoot.noGroupsBody")}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.busyTitle")}>
{t.rich("troubleshoot.busyBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.noDisksTitle")}>
{t("troubleshoot.noDisksBody")}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.sharedTitle")}>
{t.rich("troubleshoot.sharedBody", { em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{relatedItems.map((item) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,275 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
import CopyableCode from "@/components/CopyableCode"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.diskManager.formatDisk.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/disk-manager/format-disk",
},
}
}
type StringItem = string
type ModeRow = { mode: string; part: string; data: string; useCase: string }
type StepData = { title: string; body?: string; bodyRich?: string }
type RelatedItem = { href: string; label: string; tail?: string }
export default async function FormatDiskPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.diskManager.formatDisk" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { diskManager: { formatDisk: {
visibility: { items: StringItem[]; safetyItems: StringItem[] }
modes: { rows: ModeRow[]; fullFormatItems: StringItem[] }
steps: { list: StepData[] }
related: { items: RelatedItem[] }
} } }
}
const visItems = messages.docs.diskManager.formatDisk.visibility.items
const safetyItems = messages.docs.diskManager.formatDisk.visibility.safetyItems
const modeRows = messages.docs.diskManager.formatDisk.modes.rows
const fullFormatItems = messages.docs.diskManager.formatDisk.modes.fullFormatItems
const stepList = messages.docs.diskManager.formatDisk.steps.list
const relatedItems = messages.docs.diskManager.formatDisk.related.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={5}
scriptPath="storage/format-disk.sh"
/>
<Callout variant="danger" title={t("danger.title")}>
{t("danger.body")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howRuns.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("howRuns.body")}</p>
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
{`┌─────────────────────────────────────────────┐
│ PHASE 1 — Filter, choose, confirm │
│ (nothing touched yet) │
└──────────────────┬──────────────────────────┘
Detect disks on host (lsblk)
Visibility filter
├─ Hidden: root / swap / system-mounted
├─ Hidden: active ZFS / LVM / RAID members
├─ Hidden: referenced by any VM/LXC config
└─ Shown: fully free disks (⚠ for stale sigs)
User picks a disk
Operation mode
├─ 1. Wipe all (partitions + sigs)
├─ 2. Remove FS labels (data preserved)
├─ 3. Zero all data (partitions kept)
└─ 4. Full format (new GPT + mkfs)
Mode = 4? → extra questions
├─ Filesystem: ext4 / xfs / exfat / btrfs
│ └─ if tool missing (mkfs.btrfs,
│ mkfs.exfat) → abort with hint
└─ Optional label
╔════════════════════════════════════╗
║ Double confirmation gate ║
║ (1) yes/no dialog with summary ║
║ (2) type the full disk path exactly║
║ (e.g. /dev/sdc) ║
║ Any mismatch → abort ║
╚══════════════════╤═════════════════╝
┌──────── Cancel OR Confirm ────┐
▼ ▼
Exit, nothing ┌─────────────────┴─────────────────┐
was changed │ PHASE 2 — Execute │
└─────────────────┬─────────────────┘
Pre-execution re-validation
(state may have changed since
Phase 1 — user just confirmed)
├─ Disk now hosts system mount?
│ → hard block, abort
├─ Disk now in root ZFS pool?
│ → hard block, abort
├─ Disk has active swap?
│ → hard block, abort
└─ Data partitions still mounted?
→ auto-unmount; abort if fails
Run the selected mode:
┌─────────────────────────────────┐
│ 1. Wipe all │
│ wipefs -af <disk> │
│ sgdisk --zap-all <disk> │
├─────────────────────────────────┤
│ 2. Remove FS labels │
│ wipefs -af <disk> │
│ + wipefs -af each partition │
│ (partition table PRESERVED) │
├─────────────────────────────────┤
│ 3. Zero all data │
│ For each partition: │
│ dd if=/dev/zero of=<part> │
│ bs=4M │
│ (partition table PRESERVED) │
├─────────────────────────────────┤
│ 4. Full format │
│ wipefs -af <disk> │
│ sgdisk --zap-all <disk> │
│ sgdisk -n 1:0:0 -t 1:8300 │
│ <disk> │
│ mkfs.<fs> [-L <label>] │
│ <disk>1 │
└─────────────────────────────────┘
Final summary (operation + disk +
bytes touched if applicable)`}
</pre>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("visibility.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("visibility.intro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{visItems.map((_, idx) => (
<li key={idx}>{t.rich(`visibility.items.${idx}`, { strong, em })}</li>
))}
</ul>
<Callout variant="warning" title={t("visibility.safetyTitle")}>
<ul className="mt-2 list-disc list-inside space-y-1">
{safetyItems.map((_, idx) => (
<li key={idx}>{t.rich(`visibility.safetyItems.${idx}`, { strong, em })}</li>
))}
</ul>
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("modes.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("modes.intro")}</p>
<div className="overflow-x-auto mb-4 rounded-md border border-gray-200">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 text-left text-gray-700">
<tr>
<th className="px-4 py-2 font-semibold">{t("modes.headerMode")}</th>
<th className="px-4 py-2 font-semibold">{t("modes.headerPart")}</th>
<th className="px-4 py-2 font-semibold">{t("modes.headerData")}</th>
<th className="px-4 py-2 font-semibold">{t("modes.headerUseCase")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-800">
{modeRows.map((row) => (
<tr key={row.mode}>
<td className="px-4 py-2 font-semibold">{row.mode}</td>
<td className="px-4 py-2">{row.part}</td>
<td className="px-4 py-2">{row.data}</td>
<td className="px-4 py-2">{row.useCase}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("modes.fullFormatOutro", { strong })}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{fullFormatItems.map((_, idx) => (
<li key={idx}>{t.rich(`modes.fullFormatItems.${idx}`, { strong, code })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("steps.heading")}</h2>
{stepList.map((step, idx) => (
<section key={idx} className="mt-8 border-b border-gray-200 pb-8">
<div className="flex items-center gap-3 mb-3">
<span className="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-0.5 text-xs font-semibold text-blue-800">
{t("steps.stepLabel")} {idx + 1}
</span>
<h3 className="text-lg font-semibold text-gray-900 m-0">{step.title}</h3>
</div>
<div className="mb-4 text-gray-800 leading-relaxed">
{step.bodyRich ? (
<p>{t.rich(`steps.list.${idx}.bodyRich`, { strong, code })}</p>
) : step.body && <p>{step.body}</p>}
</div>
</section>
))}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
<p className="mb-3 text-gray-800 leading-relaxed">{t("manual.body")}</p>
<CopyableCode code={`# --- mode 1: wipe all (partition table + signatures) ---
wipefs -af /dev/sdX
sgdisk --zap-all /dev/sdX
# --- mode 2: remove FS labels only (keep partitions + data) ---
wipefs -af /dev/sdX # clears superblock signatures
# (do NOT run sgdisk --zap-all; it would wipe the partition table)
# --- mode 3: zero all data (keep partition table) ---
for p in /dev/sdX?*; do
dd if=/dev/zero of="$p" bs=4M status=progress || true
done
# --- mode 4: full format (new GPT + filesystem) ---
wipefs -af /dev/sdX
sgdisk --zap-all /dev/sdX
sgdisk -n 1:0:0 -t 1:8300 /dev/sdX # one partition, Linux filesystem
mkfs.ext4 -L mylabel /dev/sdX1 # pick the fs you want`} />
<Callout variant="troubleshoot" title={t("troubleshoot.notListedTitle")}>
{t.rich("troubleshoot.notListedBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.busyTitle")}>
{t.rich("troubleshoot.busyBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{relatedItems.map((item) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,233 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
import CopyableCode from "@/components/CopyableCode"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.diskManager.importDiskImageVm.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/disk-manager/import-disk-image-vm",
},
}
}
type StepData = { title: string; body?: string; bodyRich?: string; intro?: string; items?: string[] }
type StringItem = string
type RelatedItem = { href: string; label: string; tail?: string }
export default async function ImportDiskImageVMPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.diskManager.importDiskImageVm" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { diskManager: { importDiskImageVm: {
prereqs: { items: StringItem[] }
steps: { list: StepData[] }
related: { items: RelatedItem[] }
} } }
}
const prereqItems = messages.docs.diskManager.importDiskImageVm.prereqs.items
const stepList = messages.docs.diskManager.importDiskImageVm.steps.list
const relatedItems = messages.docs.diskManager.importDiskImageVm.related.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={5}
scriptPath="storage/import-disk-image.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { em, strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howRuns.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("howRuns.body", { code })}
</p>
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
{`┌─────────────────────────────────────────────┐
│ PHASE 1 — Collect every decision │
│ (nothing touched yet) │
└──────────────────┬──────────────────────────┘
qm list — user picks target VM
(target VM should be powered off)
pvesm status -content images
├─ 0 candidates → abort
│ "no storage for disk images"
├─ 1 candidate → auto-select, skip dialog
└─ 2+ → user picks
Source directory
├─ default: /var/lib/vz/template/iso
└─ custom: user types absolute path
└─ not a directory → abort
Scan the directory (maxdepth 1)
for *.img *.qcow2 *.vmdk *.raw
├─ 0 results → abort
│ "no compatible disk images found"
└─ N results → continue
User selects one or several images
(checklist — multiple allowed)
For each image, user picks:
├─ Bus: scsi (default) / virtio / sata / ide
├─ SSD emulation (ssd=1)
│ └─ offered only when bus ≠ virtio
└─ Bootable? (adds to boot order in Phase 2)
Summary of everything Phase 2 will do
┌──────── Cancel OR Confirm ────┐
▼ ▼
Exit, nothing ┌─────────────────┴─────────────────┐
was changed │ PHASE 2 — Import and attach │
└─────────────────┬─────────────────┘
For each selected image:
├─ qm importdisk <VMID> \\
│ <source-file> \\
│ <target-storage>
│ (format conversion is transparent:
│ qcow2/vmdk/img → raw when the
│ target cannot hold the source
│ format natively — LVM, ZFS, …)
├─ Find next free {bus}N slot
│ (scans qm config)
└─ qm set <VMID> -{bus}N \\
<storage>:vm-<VMID>-disk-N[,ssd=1]
If any image was marked bootable:
└─ qm set <VMID> --boot order={bus}N
(first bootable wins; others can be
reordered later in the Proxmox UI)
Verify: qm config <VMID> shows the
new slot(s) and, if applicable, the
new boot order
Source image file on the host is
kept unchanged (copied, not moved)`}
</pre>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("prereqs.heading")}</h2>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{prereqItems.map((_, idx) => (
<li key={idx}>{t.rich(`prereqs.items.${idx}`, { code, strong })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("steps.heading")}</h2>
{stepList.map((step, idx) => (
<section key={idx} className="mt-8 border-b border-gray-200 pb-8">
<div className="flex items-center gap-3 mb-3">
<span className="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-0.5 text-xs font-semibold text-blue-800">
{t("steps.stepLabel")} {idx + 1}
</span>
<h3 className="text-lg font-semibold text-gray-900 m-0">{step.title}</h3>
</div>
<div className="mb-4 text-gray-800 leading-relaxed">
{step.bodyRich ? (
<p>{t.rich(`steps.list.${idx}.bodyRich`, { code, strong })}</p>
) : step.intro ? (
<>
<p>{step.intro}</p>
{step.items && (
<ul className="list-disc pl-6 mt-2 space-y-2">
{step.items.map((_, i) => (
<li key={i}>{t.rich(`steps.list.${idx}.items.${i}`, { strong, code })}</li>
))}
</ul>
)}
</>
) : (
step.body && <p>{step.body}</p>
)}
</div>
</section>
))}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("manual.body", { code })}
</p>
<CopyableCode code={`# 1. import the image file into the target storage (here: local-lvm)
qm importdisk 101 /var/lib/vz/template/iso/server.qcow2 local-lvm
# 2. attach the imported disk as scsi1 with SSD emulation
qm set 101 -scsi1 local-lvm:vm-101-disk-1,ssd=1
# 3. (optional) make it the primary boot device
qm set 101 --boot order=scsi1`} />
<Callout variant="warning" title={t("manual.warnTitle")}>
{t.rich("manual.warnBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.noImagesTitle")}>
{t.rich("troubleshoot.noImagesBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.slowTitle")}>
{t("troubleshoot.slowBody")}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.uefiTitle")}>
{t("troubleshoot.uefiBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{relatedItems.map((item) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,275 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import Image from "next/image"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
import CopyableCode from "@/components/CopyableCode"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.diskManager.importDiskLxc.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/disk-manager/import-disk-lxc",
},
}
}
type StepData = {
title: string
body?: string
bodyRich?: string
intro?: string
items?: string[]
img?: string
caption?: string
extraImg?: string
extraAlt?: string
extraCaption?: string
}
type StringItem = string
type RelatedItem = { href: string; label: string; tail?: string }
export default async function ImportDiskLXCPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.diskManager.importDiskLxc" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { diskManager: { importDiskLxc: {
prereqs: { items: StringItem[] }
steps: { list: StepData[] }
important: { items: StringItem[] }
related: { items: RelatedItem[] }
} } }
}
const prereqItems = messages.docs.diskManager.importDiskLxc.prereqs.items
const stepList = messages.docs.diskManager.importDiskLxc.steps.list
const importantItems = messages.docs.diskManager.importDiskLxc.important.items
const relatedItems = messages.docs.diskManager.importDiskLxc.related.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
const wipeLink = (chunks: React.ReactNode) => (
<a href="/docs/disk-manager/format-disk" className="text-blue-600 hover:underline">{chunks}</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={8}
scriptPath="storage/disk-passthrough_ct.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { strong, em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howRuns.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("howRuns.body")}</p>
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
{`┌─────────────────────────────────────────────┐
│ PHASE 1 — Pick CT, detect disk, plan │
│ (nothing touched yet) │
└──────────────────┬──────────────────────────┘
pct list — user picks target CT
Privileged check
├─ unprivileged: 1 in config
│ → offer to convert now
│ (edits /etc/pve/lxc/<CTID>.conf,
│ writes unprivileged: 0)
│ ├─ accept → continue
│ └─ cancel → abort
└─ privileged → continue
Detect disks on host (lsblk)
Visibility filter
├─ Hidden: root / swap / system-mounted
├─ Hidden: active ZFS / LVM / RAID members
├─ Hidden: already in any VM/CT config
├─ Shown: free disks
└─ Shown with ⚠ label: stale metadata
User selects ONE disk
(only a single disk per run)
Filesystem probe on the first partition
├─ ext4 / xfs / btrfs → reuse as-is
│ (data is preserved)
└─ empty / unsupported → offer to format
├─ pick fs: ext4 / xfs / btrfs
└─ mkfs.<fs> will run in Phase 2
User types mount point path
(e.g. /mnt/data /mnt/disk_passthrough)
Summary: disk → mount point
┌──────── Cancel OR Confirm ────┐
▼ ▼
Exit, nothing ┌─────────────────┴─────────────────┐
was changed │ PHASE 2 — Apply │
└─────────────────┬─────────────────┘
If conversion was accepted:
└─ rewrite CT config line:
unprivileged: 1 → 0
If formatting was chosen:
└─ mkfs.<fs> /dev/disk/by-id/…-part1
Resolve best persistent partition
path (/dev/disk/by-id/...-partN)
Find next free mpN index
(scans pct config output)
pct set <CTID> -mpN \\
<persistent-part-path>, \\
mp=<mount-point>, \\
backup=0,ro=0[,acl=1]
Verify: pct config <CTID> shows
the new mpN entry
Container sees the directory at
the chosen mount point path`}
</pre>
<p className="mb-4 text-gray-800 leading-relaxed">{t("howRuns.summary")}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("prereqs.heading")}</h2>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{prereqItems.map((_, idx) => (
<li key={idx}>{t.rich(`prereqs.items.${idx}`, { strong })}</li>
))}
</ul>
<Callout variant="warning" title={t("prereqs.warnTitle")}>
{t.rich("prereqs.warnBody", { strong, code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("steps.heading")}</h2>
{stepList.map((step, idx) => (
<section key={idx} className="mt-8 border-b border-gray-200 pb-8">
<div className="flex items-center gap-3 mb-3">
<span className="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-0.5 text-xs font-semibold text-blue-800">
{t("steps.stepLabel")} {idx + 1}
</span>
<h3 className="text-lg font-semibold text-gray-900 m-0">{step.title}</h3>
</div>
<div className="mb-4 text-gray-800 leading-relaxed">
{step.bodyRich ? (
<p>{t.rich(`steps.list.${idx}.bodyRich`, { code, strong, em })}</p>
) : step.intro ? (
<>
<p>{step.intro}</p>
{step.items && (
<ul className="list-disc pl-6 mt-2 space-y-1">
{step.items.map((_, i) => (
<li key={i}>{t(`steps.list.${idx}.items.${i}`)}</li>
))}
</ul>
)}
</>
) : (
step.body && <p>{step.body}</p>
)}
</div>
{step.img && (
<div className="flex flex-col items-center">
<div className="w-full max-w-[768px] overflow-hidden rounded-md border border-gray-200">
<Image src={step.img} alt={step.caption || step.title} width={768} height={0} style={{ height: "auto" }} className="w-full object-contain" sizes="(max-width: 768px) 100vw, 768px" />
</div>
{step.caption && <span className="mt-2 text-sm text-gray-600">{step.caption}</span>}
</div>
)}
{step.extraImg && (
<div className="mt-4 flex flex-col items-center">
<div className="w-full max-w-[768px] overflow-hidden rounded-md border border-gray-200">
<Image src={step.extraImg} alt={step.extraAlt || step.title} width={768} height={0} style={{ height: "auto" }} className="w-full object-contain" sizes="(max-width: 768px) 100vw, 768px" />
</div>
{step.extraCaption && <span className="mt-2 text-sm text-gray-600">{step.extraCaption}</span>}
</div>
)}
</section>
))}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
<p className="mb-3 text-gray-800 leading-relaxed">{t.rich("manual.body", { code })}</p>
<CopyableCode code={`# find the partition's persistent path
ls -l /dev/disk/by-id | grep part1 | grep sdb
# format (only if the disk is new or unreadable)
mkfs.ext4 /dev/disk/by-id/ata-WDC_WD40EFAX-68JH4N0_WD-WX11D1234567-part1
# attach to CT 101 as mp0 at /mnt/data
pct set 101 -mp0 /dev/disk/by-id/ata-WDC_WD40EFAX-68JH4N0_WD-WX11D1234567-part1,mp=/mnt/data,backup=0,ro=0
# verify
pct config 101 | grep -E '^mp[0-9]+:'`} />
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("important.heading")}</h2>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{importantItems.map((_, idx) => (
<li key={idx}>{t.rich(`important.items.${idx}`, { strong, code, wipeLink })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.unprivTitle")}>
{t.rich("troubleshoot.unprivBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.permsTitle")}>
{t.rich("troubleshoot.permsBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{relatedItems.map((item) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,256 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import Image from "next/image"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
import CopyableCode from "@/components/CopyableCode"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.diskManager.importDiskVm.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/disk-manager/import-disk-vm",
},
}
}
type StepData = {
title: string
body?: string
bodyRich?: string
intro?: string
items?: string[]
img?: string
caption?: string
}
type StringItem = string
type RelatedItem = { href: string; label: string; tail?: string }
export default async function ImportDiskVMPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.diskManager.importDiskVm" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { diskManager: { importDiskVm: {
prereqs: { items: StringItem[] }
steps: { list: StepData[] }
troubleshoot: { noDisksItems: StringItem[] }
related: { items: RelatedItem[] }
} } }
}
const prereqItems = messages.docs.diskManager.importDiskVm.prereqs.items
const stepList = messages.docs.diskManager.importDiskVm.steps.list
const noDisksItems = messages.docs.diskManager.importDiskVm.troubleshoot.noDisksItems
const relatedItems = messages.docs.diskManager.importDiskVm.related.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
const winLink = (chunks: React.ReactNode) => (
<a href="/docs/create-vm/system-windows" className="text-blue-600 hover:underline">{chunks}</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={5}
scriptPath="storage/disk-passthrough.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howRuns.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("howRuns.body")}</p>
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
{`┌─────────────────────────────────────────────┐
│ PHASE 1 — Pick VM, detect disks, select │
│ (nothing touched yet) │
└──────────────────┬──────────────────────────┘
qm list — user picks target VM
VM status check
├─ running → abort (power off first)
└─ stopped → continue
Detect disks on host (lsblk)
Visibility filter
├─ Hidden: root / swap / system-mounted
├─ Hidden: active ZFS / LVM / RAID members
├─ Hidden: already in this VM's config
├─ Shown: free disks
└─ Shown with ⚠ label: stale ZFS/LVM/RAID
signatures (not active)
User selects disk(s) via checklist
+ picks bus interface:
SATA / SCSI / VirtIO / IDE
Per-disk cross-check
├─ Assigned to a RUNNING VM/CT? → skip disk
├─ Assigned to stopped VM/CT? → ask
│ "continue anyway?" yes/no
└─ NVMe detected? → suggest
using "Add Controller / NVMe"
(user can still add as disk)
Summary of disks to process
┌──────── Cancel OR Confirm ────┐
▼ ▼
Exit, nothing ┌─────────────────┴─────────────────┐
was changed │ PHASE 2 — Attach │
└─────────────────┬─────────────────┘
For each selected disk:
├─ Resolve best persistent path
│ preferred order:
│ 1. /dev/disk/by-id/ata-*
│ 2. /dev/disk/by-id/nvme-*
│ 3. /dev/disk/by-id/scsi-*
│ 4. /dev/disk/by-id/wwn-*
│ fallback: raw /dev/sdX
├─ Find next free {bus}N slot
│ (scans qm config output)
└─ qm set <VMID> -{bus}N <path>
Verify: qm config <VMID> shows
the new slot(s)
Guest sees each disk as a native
block device under its bus
(e.g. /dev/sda, /dev/nvme0n1)`}
</pre>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("howRuns.summary", { em })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("prereqs.heading")}</h2>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{prereqItems.map((_, idx) => (
<li key={idx}>{t.rich(`prereqs.items.${idx}`, { code, strong })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("steps.heading")}</h2>
{stepList.map((step, idx) => (
<section key={idx} className="mt-8 border-b border-gray-200 pb-8">
<div className="flex items-center gap-3 mb-3">
<span className="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-0.5 text-xs font-semibold text-blue-800">
{t("steps.stepLabel")} {idx + 1}
</span>
<h3 className="text-lg font-semibold text-gray-900 m-0">{step.title}</h3>
</div>
<div className="mb-4 text-gray-800 leading-relaxed">
{step.bodyRich ? (
<p>{t.rich(`steps.list.${idx}.bodyRich`, { code, strong })}</p>
) : (
<>
{step.body && <p>{step.body}</p>}
{step.intro && (
<>
<p>{step.intro}</p>
{step.items && (
<ul className="list-disc pl-6 mt-2 space-y-1">
{step.items.map((_, i) => (
<li key={i}>{t.rich(`steps.list.${idx}.items.${i}`, { strong })}</li>
))}
</ul>
)}
</>
)}
</>
)}
</div>
{step.img && (
<div className="flex flex-col items-center">
<div className="w-full max-w-[768px] overflow-hidden rounded-md border border-gray-200">
<Image src={step.img} alt={step.caption || step.title} width={768} height={0} style={{ height: "auto" }} className="w-full object-contain" sizes="(max-width: 768px) 100vw, 768px" />
</div>
{step.caption && <span className="mt-2 text-sm text-gray-600">{step.caption}</span>}
</div>
)}
</section>
))}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("manual.body", { code })}
</p>
<CopyableCode code={`# find the persistent path
ls -l /dev/disk/by-id | grep -v part | grep sdb
# attach to VM 101 as scsi1
qm set 101 -scsi1 /dev/disk/by-id/ata-WDC_WD40EFAX-68JH4N0_WD-WX11D1234567
# verify
qm config 101 | grep -E '^scsi[0-9]+:'`} />
<Callout variant="warning" title={t("manual.migrationTitle")}>
{t.rich("manual.migrationBody", { strong, code })}
</Callout>
<Callout variant="warning" title={t("manual.shareTitle")}>
{t.rich("manual.shareBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.noDisksTitle")}>
{t("troubleshoot.noDisksIntro")}
<ul className="mt-2 list-disc list-inside space-y-1">
{noDisksItems.map((_, idx) => (
<li key={idx}>{t(`troubleshoot.noDisksItems.${idx}`)}</li>
))}
</ul>
{t.rich("troubleshoot.noDisksOutro", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.noVisibleTitle")}>
{t.rich("troubleshoot.noVisibleBody", { strong, winLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{relatedItems.map((item) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,232 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import Image from "next/image"
import { ArrowRight, HardDrive, FileDown, Cpu, Boxes, Eraser, Activity, Server, Wrench } from "lucide-react"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.diskManager.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"proxmox disk passthrough",
"proxmox attach disk to vm",
"proxmox import disk",
"proxmox qm importdisk",
"proxmox lxc bind mount disk",
"proxmox smart test",
"proxmox wipe disk",
"proxmox hba passthrough",
"proxmox nvme passthrough",
"qm set scsi",
],
alternates: { canonical: "https://proxmenux.com/docs/disk-manager" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/disk-manager",
},
twitter: {
card: "summary",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type DiskOption = { icon: string; href: string; title: string; description: string }
type StringItem = string
type RelatedItem = { href: string; label: string; tail?: string }
const ICONS: Record<string, React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>> = {
HardDrive,
FileDown,
Cpu,
Boxes,
Eraser,
Activity,
}
function DiskOptionCard({ option }: { option: DiskOption }) {
const Icon = ICONS[option.icon] || HardDrive
return (
<Link
href={option.href}
className="group flex items-start gap-3 rounded-md border border-gray-200 bg-white p-3 transition-colors hover:border-blue-400 hover:bg-blue-50"
>
<span className="inline-flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-gray-100 text-gray-600 group-hover:bg-blue-100 group-hover:text-blue-700">
<Icon className="h-4 w-4" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1 text-sm font-medium text-gray-900 group-hover:text-blue-700">
{option.title}
<ArrowRight className="h-3.5 w-3.5 text-gray-400 group-hover:text-blue-600 transition-transform group-hover:translate-x-0.5" />
</div>
<div className="mt-0.5 text-xs text-gray-600 leading-snug">{option.description}</div>
</div>
</Link>
)
}
export default async function DiskManagerOverviewPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.diskManager" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { diskManager: {
groups: { vmItems: StringItem[]; lxcItems: StringItem[]; utilitiesItems: StringItem[] }
vm: { options: DiskOption[] }
lxc: { options: DiskOption[] }
utilities: { options: DiskOption[] }
safety: { items: StringItem[] }
related: { items: RelatedItem[] }
} }
}
const vmItems = messages.docs.diskManager.groups.vmItems
const lxcItems = messages.docs.diskManager.groups.lxcItems
const utilitiesItems = messages.docs.diskManager.groups.utilitiesItems
const vmOptions = messages.docs.diskManager.vm.options
const lxcOptions = messages.docs.diskManager.lxc.options
const utilityOptions = messages.docs.diskManager.utilities.options
const safetyItems = messages.docs.diskManager.safety.items
const relatedItems = messages.docs.diskManager.related.items
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={3}
scriptPath="menus/storage_menu.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("opening.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("opening.body", { strong })}
</p>
<Image
src="/disk/disk-manager-menu.png"
alt={t("opening.imageAlt")}
width={900}
height={500}
className="rounded shadow-lg my-6"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("groups.heading")}</h2>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("groups.intro", { strong })}
</p>
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-3 mb-8 not-prose">
<a
href="#vm"
className="rounded-lg border-2 border-blue-300 bg-blue-50 p-5 flex flex-col transition-shadow hover:shadow-md"
>
<div className="flex items-center gap-3 mb-3">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-blue-100 text-blue-700">
<Server className="h-5 w-5" aria-hidden />
</span>
<h3 className="text-lg font-semibold text-gray-900 m-0">{t("groups.vmTitle")}</h3>
</div>
<p className="text-sm text-gray-800 mb-3">{t("groups.vmBody")}</p>
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
{vmItems.map((item, i) => <li key={i}>{item}</li>)}
</ul>
</a>
<a
href="#lxc"
className="rounded-lg border-2 border-emerald-300 bg-emerald-50 p-5 flex flex-col transition-shadow hover:shadow-md"
>
<div className="flex items-center gap-3 mb-3">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
<Boxes className="h-5 w-5" aria-hidden />
</span>
<h3 className="text-lg font-semibold text-gray-900 m-0">{t("groups.lxcTitle")}</h3>
</div>
<p className="text-sm text-gray-800 mb-3">{t("groups.lxcBody")}</p>
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
{lxcItems.map((item, i) => <li key={i}>{item}</li>)}
</ul>
</a>
<a
href="#utilities"
className="rounded-lg border-2 border-amber-300 bg-amber-50 p-5 flex flex-col transition-shadow hover:shadow-md"
>
<div className="flex items-center gap-3 mb-3">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-amber-100 text-amber-700">
<Wrench className="h-5 w-5" aria-hidden />
</span>
<h3 className="text-lg font-semibold text-gray-900 m-0">{t("groups.utilitiesTitle")}</h3>
</div>
<p className="text-sm text-gray-800 mb-3">{t("groups.utilitiesBody")}</p>
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
{utilitiesItems.map((item, i) => <li key={i}>{item}</li>)}
</ul>
</a>
</div>
<h2 id="vm" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24">{t("vm.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("vm.intro")}</p>
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
{vmOptions.map((o) => <DiskOptionCard key={o.href} option={o} />)}
</div>
<h2 id="lxc" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24">{t("lxc.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("lxc.intro")}</p>
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
{lxcOptions.map((o) => <DiskOptionCard key={o.href} option={o} />)}
</div>
<h2 id="utilities" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24">{t("utilities.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("utilities.intro")}</p>
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
{utilityOptions.map((o) => <DiskOptionCard key={o.href} option={o} />)}
</div>
<Callout variant="warning" title={t("safety.title")}>
{t("safety.intro")}
<ul className="mt-3 list-disc list-inside space-y-1">
{safetyItems.map((_, idx) => (
<li key={idx}>{t.rich(`safety.items.${idx}`, { strong })}</li>
))}
</ul>
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{relatedItems.map((item) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,263 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import Image from "next/image"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
import CopyableCode from "@/components/CopyableCode"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.diskManager.smartDiskTest.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/disk-manager/smart-disk-test",
},
}
}
type ActionRow = { action: string; what?: string; whatRich?: string; dur: string }
type StepData = { title: string; body?: string; bodyRich?: string; img?: string; alt?: string; caption?: string }
type RelatedItem = { href: string; label: string; tail?: string }
export default async function SmartDiskTestPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.diskManager.smartDiskTest" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { diskManager: { smartDiskTest: {
actions: { rows: ActionRow[] }
steps: { list: StepData[] }
related: { items: RelatedItem[] }
} } }
}
const actionRows = messages.docs.diskManager.smartDiskTest.actions.rows
const stepList = messages.docs.diskManager.smartDiskTest.steps.list
const relatedItems = messages.docs.diskManager.smartDiskTest.related.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
const br = () => <br />
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={10}
scriptPath="storage/smart-disk-test.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howRuns.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("howRuns.body")}</p>
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
{` Detect dependencies (first run)
├─ smartctl present? (smartmontools)
└─ nvme present? (nvme-cli)
Any missing → apt-get install silently
Enumerate disks on host (lsblk)
(no safety filter — read-only tool,
root / system disks are shown too)
User picks a disk
Detect disk class from path / TRAN
├─ /dev/nvme* → NVMe
└─ anything else → SATA / SAS / SCSI
Action menu (loop — stays open after
each action so you can chain queries)
┌────────────────┬────────────────┬────────────────┬───────────────┐
▼ ▼ ▼ ▼ ▼
Quick Full Short Long Check
status report test test progress
(instant) (instant) (~2 min) (hours) (instant)
│ │ │ │ │
│ │ │ │ │
│ │ │ Long test only: │
│ │ │ confirm "runs in background, │
│ │ │ result saved to JSON" │
│ │ │ │ │
│ │ └───────┬────────┘ │
│ │ │ │
│ │ Queued on drive firmware: │
│ │ ├─ SATA/SAS: smartctl -t short|long │
│ │ └─ NVMe: nvme device-self-test │
│ │ Returns to menu while running │
│ │ │
▼ ▼ ▼
Read: Read: Read status:
SATA/SAS → SATA/SAS → SATA/SAS →
smartctl -H smartctl -x smartctl -c
smartctl -A NVMe →
nvme self-test-log
NVMe → NVMe →
nvme smart- nvme smart-log
log + nvme id-ctrl
│ │ │
└──────┬──────┴──────────────────────────────────────────────────┘
Output to terminal (color-coded when applicable)
+
JSON export to:
/usr/local/share/proxmenux/smart/<disk>/
<YYYY-MM-DD_HHMMSS>_<action>.json
Retention policy: oldest beyond the limit
are trimmed automatically
ProxMenux Monitor reads these files to
render health trends per disk over time`}
</pre>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("deps.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("deps.body", { code })}
</p>
<CopyableCode code={`apt-get install smartmontools nvme-cli`} />
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("actions.heading")}</h2>
<div className="overflow-x-auto mb-4 rounded-md border border-gray-200">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 text-left text-gray-700">
<tr>
<th className="px-4 py-2 font-semibold">{t("actions.headerAction")}</th>
<th className="px-4 py-2 font-semibold">{t("actions.headerWhat")}</th>
<th className="px-4 py-2 font-semibold">{t("actions.headerDur")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-800">
{actionRows.map((row, idx) => (
<tr key={row.action}>
<td className="px-4 py-2 font-semibold">{row.action}</td>
<td className="px-4 py-2">
{row.whatRich ? t.rich(`actions.rows.${idx}.whatRich`, { code, br }) : row.what}
</td>
<td className="px-4 py-2">{row.dur}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="tip" title={t("actions.tipTitle")}>
{t.rich("actions.tipBody", { em, strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("json.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("json.intro", { code })}
</p>
<pre className="bg-gray-100 p-3 rounded-md overflow-x-auto text-sm font-mono mb-4">
<code>{`/usr/local/share/proxmenux/smart/
├── sda/
│ ├── 2026-04-23_145312_status.json
│ ├── 2026-04-23_180041_short.json
│ └── 2026-04-24_020015_long.json
└── nvme0n1/
├── 2026-04-23_145318_status.json
└── 2026-04-24_021407_long.json`}</code>
</pre>
<p className="mb-4 text-gray-800 leading-relaxed">{t("json.outro")}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("steps.heading")}</h2>
{stepList.map((step, idx) => (
<section key={idx} className="mt-8 border-b border-gray-200 pb-8">
<div className="flex items-center gap-3 mb-3">
<span className="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-0.5 text-xs font-semibold text-blue-800">
{t("steps.stepLabel")} {idx + 1}
</span>
<h3 className="text-lg font-semibold text-gray-900 m-0">{step.title}</h3>
</div>
<div className="mb-4 text-gray-800 leading-relaxed">
{step.bodyRich ? (
<p className="mb-4">{t.rich(`steps.list.${idx}.bodyRich`, { strong })}</p>
) : step.body && <p className="mb-4">{step.body}</p>}
</div>
{step.img && (
<div className="flex flex-col items-center w-full max-w-[768px] mx-auto my-4">
<div className="w-full overflow-hidden rounded-md border border-gray-200">
<Image src={step.img} alt={step.alt || step.title} width={768} height={0} style={{ height: "auto" }} className="w-full object-contain" sizes="(max-width: 768px) 100vw, 768px" />
</div>
{step.caption && <span className="mt-2 text-sm text-gray-600">{step.caption}</span>}
</div>
)}
</section>
))}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
<CopyableCode code={`# --- SATA / SAS drives (smartmontools) ---
# quick health
smartctl -H /dev/sdX
smartctl -A /dev/sdX # attribute table
# full report
smartctl -x /dev/sdX
# self-tests
smartctl -t short /dev/sdX
smartctl -t long /dev/sdX
smartctl -c /dev/sdX | head # current test progress
# --- NVMe drives (nvme-cli) ---
nvme smart-log /dev/nvme0n1
nvme id-ctrl /dev/nvme0n1
nvme self-test-log /dev/nvme0n1`} />
<Callout variant="warning" title={t("manual.nvmeWarnTitle")}>
{t.rich("manual.nvmeWarnBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.noSmartTitle")}>
{t.rich("troubleshoot.noSmartBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.longTitle")}>
{t.rich("troubleshoot.longBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{relatedItems.map((item) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tail}
</li>
))}
</ul>
</div>
)
}