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

1
web/.npmrc Normal file
View File

@@ -0,0 +1 @@
legacy-peer-deps=true

View File

@@ -0,0 +1,329 @@
# Contributing translations
The ProxMenux documentation site is built with Next.js (App Router) and
serves every page under two URLs:
- `/en/<path>` — English, the source of truth
- `/es/<path>` — Spanish, in progress
We use [`next-intl`](https://next-intl.dev) for the i18n plumbing. Anyone
can translate the docs without writing TypeScript: most of the work is
filling in a JSON file. This guide explains the workflow end to end.
> **Default policy: small, focused PRs.** One page per pull request. Big
> bundles of "I translated 30 pages at once" are hard to review and
> merge cleanly when several contributors are working in parallel.
---
## What's already wired
Out of the box you get:
- Routing under `app/[locale]/...` — every page already renders at both
`/en/...` and `/es/...`.
- Locale-aware navigation via `@/i18n/navigation` (`<Link>`, `useRouter`,
`usePathname`). Use these instead of `next/link` for internal hrefs so
the active `[locale]` prefix is preserved.
- A language switcher in the navbar (`<LanguageSwitcher />`).
- Automatic message discovery: any JSON file under
`messages/<locale>/...` is loaded and merged into a single namespace
tree. **You never need to register a new file anywhere**, the build
picks it up automatically.
- Fallback to English when a translation is missing — pages render in
English instead of breaking with a `MISSING_MESSAGE` placeholder.
---
## File layout
```
web/
├── i18n/
│ ├── routing.ts # supported locales + default
│ ├── request.ts # per-request config (uses loadMessages)
│ ├── loadMessages.ts # walks messages/<locale>/ and builds the tree
│ └── navigation.ts # locale-aware Link, useRouter, etc.
├── messages/
│ ├── en/
│ │ ├── common.json # shared strings (nav, footer, language switcher)
│ │ └── docs/
│ │ └── monitor/
│ │ └── index.json # page-specific strings for /docs/monitor
│ └── es/
│ ├── common.json
│ └── docs/
│ └── monitor/
│ └── index.json
└── app/[locale]/
└── docs/
└── monitor/
└── page.tsx # the page itself
```
### Naming convention
- `common.json` and `index.json` at any folder level → keys are merged
at the current namespace level (no extra nesting). Use these for
"this whole folder" or "this whole section" defaults.
- `<name>.json` at any folder level → keys go under the `<name>`
namespace.
- Sub-directories nest as additional namespaces, with **kebab-case
converted to camelCase** in the JS API. So
`messages/en/docs/monitor/access-auth.json` is consumed as
`getTranslations({ namespace: 'docs.monitor.accessAuth' })`.
---
## Workflow: translate one page
### 1. Pick a page
Browse `app/[locale]/docs/` and find a page that:
- Has no entry yet under `messages/es/<same-path>/` (Spanish), **and**
- Is not already mid-translation by someone else (check open PRs).
If you're translating to a new locale, start with the smallest pages so
you can submit early PRs and get feedback before tackling the big ones.
### 2. Check whether the page is already i18n-ready
There are two cases:
**Case A — the page already uses `getTranslations()`** (look for an
`import` from `next-intl/server` and `t()` calls in JSX). Your job is
straightforward: only create the `messages/<locale>/<path>.json` file
with the translated strings. **You don't touch the `.tsx` file at all**.
**Case B — the page still has hard-coded English strings in JSX.** You
need both:
1. Refactor the page to read its strings from a JSON file (this part
touches the `.tsx`).
2. Provide the English JSON (the source of truth) **and** the
translated JSON.
The pilot page `app/[locale]/docs/monitor/page.tsx` is the reference
implementation for case B — copy its patterns.
### 3. Add the JSON
Create `messages/<locale>/<same-path-as-page>.json` (or `index.json` if
the page is the section index). Mirror the English file's structure
exactly — every key must exist in both, only the values change.
If a key contains inline HTML-style tags (`<code>`, `<strong>`,
`<em>`, `<link>`, `<linkApi>`, etc.), keep them in the same positions
in your translation. They're not real HTML — they're placeholders that
the page renders via `t.rich()` and substitutes for real React nodes.
Example:
```json
{
"intro": "Eight first-class sections, backed by their own API endpoints."
}
```
```json
{
"intro": "Ocho secciones principales, respaldadas por sus propios endpoints de API."
}
```
```json
{
"footer": "See the <link>Architecture</link> page for details."
}
```
```json
{
"footer": "Mira la página de <link>Architecture</link> para más detalles."
}
```
### 4. Test locally
```bash
cd web
npm run dev
```
Open `http://localhost:3000/<locale>/<page-path>` and check that:
- All your translated strings render correctly.
- No `MISSING_MESSAGE` text appears (means a key is in the page's
`.tsx` but missing from your JSON).
- The page still passes `npm run build` cleanly.
### 5. Open the PR
One page per PR is the convention. Title format:
```
docs(i18n/<locale>): translate <route>
```
Examples:
- `docs(i18n/es): translate /docs/monitor`
- `docs(i18n/fr): translate /docs/monitor/notifications`
In the description, mention which page you translated and whether you
also had to refactor the `.tsx` (case B) or only added JSON (case A).
---
## Workflow: convert a page from hard-coded English to i18n (case B)
This is the more involved path. Use the pilot
`app/[locale]/docs/monitor/page.tsx` as the reference.
### High-level changes to the `.tsx`
1. Make the page an **async Server Component** that receives
`params: Promise<{ locale: string }>`.
2. Add `generateStaticParams()` that returns one entry per locale (see
`routing.locales` in `i18n/routing.ts`).
3. If the page exports `metadata`, replace it with `generateMetadata()`
that reads from `getTranslations({ namespace: '<page>.meta' })`.
4. Call `setRequestLocale(locale)` near the top of the component body.
5. Call `await getTranslations({ locale, namespace: 'docs.<section>.<page>' })`
and rename it to `t`.
6. Replace every English string in JSX with `t('key')` (plain text) or
`t.rich('key', { code, strong, em, link, ... })` for strings with
inline tags.
7. For lists / tables of structured items (e.g. table rows, nav items),
pull the array from `getMessages()` and iterate.
### JSON file structure
Use the pilot's `messages/en/docs/monitor/index.json` as the template.
The high-level shape is:
```json
{
"meta": {
"title": "...",
"description": "...",
"ogTitle": "...",
"ogDescription": "...",
"twitterTitle": "...",
"twitterDescription": "..."
},
"header": {
"title": "...",
"description": "...",
"section": "..."
},
"<section1>": { ... },
"<section2>": { ... }
}
```
Group keys by the section they appear in. Keep nesting shallow (2-3
levels max) so the JSON stays readable for translators.
### Rich-text placeholder tags
When a paragraph contains inline elements like `<code>` or `<strong>`,
encode them in the JSON exactly as you want them to appear — but
remember they're **placeholders**, not real HTML. The `.tsx` registers
React renderers for each tag name in the call:
```tsx
t.rich("intro", {
code: (chunks) => <code>{chunks}</code>,
strong: (chunks) => <strong>{chunks}</strong>,
link: (chunks) => (
<Link href="/docs/monitor" className="text-blue-600 hover:underline">
{chunks}
</Link>
),
})
```
Translators just have to keep the tags in roughly the same positions.
Don't introduce new tag names in the JSON unless the `.tsx` also
registers a renderer for them.
---
## What's intentionally NOT translatable
- **Code blocks** (inside `CopyableCode` etc.) — only the comment lines
(`# Comment here`) should be moved to JSON if they're explanatory.
Don't translate code keywords, command names or paths.
- **URLs and paths** (`/docs/monitor`, `https://github.com/...`) —
these stay identical across locales.
- **External link labels** like "GitHub", "ProxMenux" — proper nouns
and product names stay in their original form.
- **Variable names, environment variables, file names** (`auth.json`,
`MONITOR_VERSION`, `/var/log/journal/`) — never translated.
---
## Adding a new locale
If you want to add a language that isn't in the project yet:
1. Add the locale code to `routing.ts`:
```ts
export const routing = defineRouting({
locales: ["en", "es", "fr"], // add your code here
defaultLocale: "en",
localePrefix: "always",
})
```
2. Create the `messages/<locale>/` folder.
3. Copy `messages/en/common.json` over and translate it. **This is
mandatory** — without it the navbar and footer fall back to English
on every page.
4. Start translating individual pages one PR at a time.
5. Mention in your first PR that you're seeding the locale so reviewers
know to expect a follow-up batch.
---
## FAQ
### My translated page still shows English text. Why?
Three common causes:
1. The page file is **case A** (uses `getTranslations()`) and your JSON
path doesn't match the namespace it expects. Check the page's
`getTranslations({ namespace: '...' })` call and mirror it in your
folder structure.
2. The page is **case B** (still hard-coded). It needs the `.tsx`
refactored first — that part is a developer task, not a translator
task.
3. The dev server is serving cached output. Stop it (Ctrl+C), remove
`web/.next/`, and run `npm run dev` again.
### What about translations of the Monitor (the AppImage), not just the docs?
This guide only covers the **public documentation site** in `web/`.
The Monitor's dashboard UI in `AppImage/` is a separate project and
not currently i18n-enabled. Translating the Monitor would require a
parallel effort.
### Where can I see what's missing?
Compare the directory trees:
```bash
diff -rq web/messages/en web/messages/es
```
Anything listed as "Only in en" still needs a Spanish version. (Swap
`es` for your locale.)
### My PR conflicts with another translator's PR.
Because we keep one page per PR, the only realistic conflict zone is
`messages/<locale>/common.json` (shared strings) or
`app/[locale]/docs/<section>/page.tsx` (refactored at the same time).
Rebase on `develop` and re-resolve; ping the other contributor in the
PR thread if the merge is non-obvious.

View File

@@ -0,0 +1,201 @@
import type { Metadata } from "next"
import fs from "fs"
import path from "path"
import { remark } from "remark"
import html from "remark-html"
import * as gfm from "remark-gfm"
import parse from "html-react-parser"
import { getTranslations, setRequestLocale } from "next-intl/server"
import Footer from "@/components/footer"
import RSSLink from "@/components/rss-link"
import CopyableCode from "@/components/CopyableCode"
// Resolve which CHANGELOG.md to read for the given locale. The canonical
// English file lives at the repo root (so GitHub displays it as-is and
// existing RSS / external consumers don't break). Localized versions
// sit under <repo>/lang/<locale>/CHANGELOG.md. Falls back to English if
// the localized file doesn't exist yet — so a partially-translated
// changelog still renders (in EN) instead of 404'ing.
function resolveChangelogPath(locale: string): string {
const repoRoot = path.join(process.cwd(), "..")
if (locale && locale !== "en") {
const localized = path.join(repoRoot, "lang", locale, "CHANGELOG.md")
if (fs.existsSync(localized)) return localized
}
return path.join(repoRoot, "CHANGELOG.md")
}
// Surface the latest changelog entry in the metadata so social previews and
// SERP snippets reflect what was actually shipped. The CHANGELOG.md mixes two
// formats — `## [x.y.z] - YYYY-MM-DD` (older releases) and `## YYYY-MM-DD`
// (newer dated updates). The most recent entry is always the first `##` line
// from the top of the file, regardless of which format it uses. We also try
// to extract a version-looking suffix from the first body paragraph so dated
// updates like `## 2026-04-20` followed by `### New version ProxMenux v1.2.1`
// can still surface the version number.
function readLatestChangelogVersion(locale: string): { version: string; date: string } | null {
try {
const changelogPath = resolveChangelogPath(locale)
if (!fs.existsSync(changelogPath)) return null
const text = fs.readFileSync(changelogPath, "utf8")
const firstHeading = text.match(/^##\s+(.+?)\s*$/m)
if (!firstHeading) return null
const headingText = firstHeading[1].trim()
// Format A: ## [1.1.1] - 2025-03-21
const bracketMatch = headingText.match(/^\[([^\]]+)\]\s*-\s*(\d{4}-\d{2}-\d{2})$/)
if (bracketMatch) return { version: bracketMatch[1], date: bracketMatch[2] }
// Format B: ## 2026-04-20 (use the date and try to find a v-tag in the body)
const dateMatch = headingText.match(/^(\d{4}-\d{2}-\d{2})$/)
if (dateMatch) {
const date = dateMatch[1]
const headingIdx = text.indexOf(firstHeading[0])
const nextHeadingIdx = text.indexOf("\n## ", headingIdx + 1)
const body =
nextHeadingIdx === -1
? text.slice(headingIdx)
: text.slice(headingIdx, nextHeadingIdx)
const vMatch = body.match(/\bProxMenux\s+v?(\d+(?:\.\d+){1,2})\b/i)
return { version: vMatch ? `v${vMatch[1]}` : date, date }
}
return null
} catch {
return null
}
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "changelog.meta" })
const latest = readLatestChangelogVersion(locale)
const versionTag = latest?.version ?? ""
const dateTag = latest?.date ?? ""
const titleSuffix = versionTag ? `${t("latest")}: ${versionTag}` : ""
const descriptionSuffix = versionTag
? `${t("mostRecent")}: ${versionTag}${dateTag && dateTag !== versionTag ? ` (${dateTag})` : ""}.`
: ""
return {
title: `${t("title")}${titleSuffix} | ${t("titleSuffix")}`,
description: `${t("description")} ${descriptionSuffix} ${t("descriptionTail")}`.trim(),
keywords: [
"proxmenux changelog",
"proxmenux release notes",
"proxmenux updates",
"proxmenux versions",
"proxmox script changelog",
"proxmenux history",
"proxmenux roadmap",
],
alternates: {
canonical: `https://proxmenux.com/${locale}/changelog`,
types: {
"application/rss+xml":
locale === "en"
? "https://proxmenux.com/rss.xml"
: `https://proxmenux.com/${locale}/rss.xml`,
},
},
openGraph: {
title: `${t("title")}${titleSuffix}`,
description: `${t("ogDescription")} ${descriptionSuffix} ${t("ogTail")}`.trim(),
type: "article",
url: "https://proxmenux.com/changelog",
siteName: "ProxMenux",
images: [
{
url: "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/main.png",
width: 1363,
height: 735,
alt: "ProxMenux — Interactive Menu and Web Dashboard for Proxmox VE",
},
],
},
twitter: {
card: "summary_large_image",
title: `${t("title")}${titleSuffix}`,
description: `${t("ogDescription")} ${descriptionSuffix}`.trim(),
images: [
"https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/main.png",
],
},
}
}
async function getChangelogContent(locale: string) {
try {
const changelogPath = resolveChangelogPath(locale)
if (!fs.existsSync(changelogPath)) {
console.error("❌ CHANGELOG.md file not found.")
return "<p class='text-red-600'>Error: CHANGELOG.md file not found</p>"
}
const fileContents = fs.readFileSync(changelogPath, "utf8")
// Add remark-gfm to support images, tables and other advanced Markdown elements
const result = await remark()
.use(gfm.default || gfm) // Safe handling of remark-gfm
.use(html)
.process(fileContents)
return result.toString()
} catch (error) {
console.error("❌ Error reading CHANGELOG.md file", error)
return "<p class='text-red-600'>Error: Could not load changelog content.</p>"
}
}
// Clean backticks in inline code fragments
function cleanInlineCode(content: string) {
return content.replace(/<code>(.*?)<\/code>/g, (_, codeContent) => {
return `<code class="bg-gray-200 text-gray-900 px-1 rounded">${codeContent.replace(/^`|`$/g, "")}</code>`
})
}
// Wrap code blocks with CopyableCode component
function wrapCodeBlocksWithCopyable(content: string) {
return parse(content, {
replace: (domNode: any) => {
if (domNode.name === "pre" && domNode.children.length > 0) {
const codeElement = domNode.children.find((child: any) => child.name === "code")
if (codeElement) {
const codeContent = codeElement.children[0]?.data?.trim() || ""
return <CopyableCode code={codeContent} />
}
}
},
})
}
export default async function ChangelogPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "changelog" })
const changelogContent = await getChangelogContent(locale)
const cleanedInlineCode = cleanInlineCode(changelogContent)
const parsedContent = wrapCodeBlocksWithCopyable(cleanedInlineCode)
return (
<div className="min-h-screen bg-white text-gray-900">
<div className="container mx-auto px-4 py-16" style={{ maxWidth: "980px" }}>
<h1 className="text-4xl font-bold mb-8">{t("pageTitle")}</h1>
<RSSLink />
<div className="prose max-w-none text-[16px]">{parsedContent}</div>
</div>
<Footer />
</div>
)
}

View File

@@ -0,0 +1,85 @@
import type { Metadata } from "next"
import { getTranslations, setRequestLocale } from "next-intl/server"
import fs from "fs"
import path from "path"
import { remark } from "remark"
import html from "remark-html"
import * as gfm from "remark-gfm"
import React from "react"
import parse from "html-react-parser"
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.about.codeOfConduct.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/about/code-of-conduct",
},
}
}
async function getCodeOfConductContent(notFoundMsg: string, loadFailedMsg: string) {
try {
const codeOfConductPath = path.join(process.cwd(), "..", "CODE_OF_CONDUCT.md")
if (!fs.existsSync(codeOfConductPath)) {
console.error("CODE_OF_CONDUCT.md file not found.")
return `<p class='text-red-600'>${notFoundMsg}</p>`
}
const fileContents = fs.readFileSync(codeOfConductPath, "utf8")
const result = await remark()
.use(gfm.default || gfm)
.use(html)
.process(fileContents)
return result.toString()
} catch (error) {
console.error("Error reading the CODE_OF_CONDUCT.md file", error)
return `<p class='text-red-600'>${loadFailedMsg}</p>`
}
}
function cleanInlineCode(content: string) {
return content.replace(/<code>(.*?)<\/code>/g, (_, codeContent) => {
return `<code class="bg-gray-200 text-gray-900 px-1 rounded">${codeContent.replace(/^`|`$/g, "")}</code>`
})
}
function wrapCodeBlocksWithCopyable(content: string) {
return parse(content, {
replace: (domNode: any) => {
if (domNode.name === "pre" && domNode.children.length > 0) {
const codeElement = domNode.children.find((child: any) => child.name === "code")
if (codeElement) {
const codeContent = codeElement.children[0]?.data?.trim() || ""
return <CopyableCode code={codeContent} />
}
}
},
})
}
export default async function CodeOfConductPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.about.codeOfConduct" })
const codeOfConductContent = await getCodeOfConductContent(t("errors.notFound"), t("errors.loadFailed"))
const cleanedInlineCode = cleanInlineCode(codeOfConductContent)
const parsedContent = wrapCodeBlocksWithCopyable(cleanedInlineCode)
return (
<div className="min-h-screen bg-white text-gray-900">
<div className="container mx-auto px-4 py-16" style={{ maxWidth: "980px" }}>
<div className="prose max-w-none text-[16px]">{parsedContent}</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,436 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { ExternalLink } from "lucide-react"
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.about.contributing.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"proxmenux contributing",
"proxmenux pull request",
"proxmenux branch model",
"proxmenux develop branch",
"proxmenux script template",
"proxmenux script header",
"proxmox bash contribution",
],
alternates: { canonical: "https://proxmenux.com/docs/about/contributing" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/about/contributing",
},
twitter: {
card: "summary",
title: t("title"),
description: "How to contribute scripts, dialogs and improvements to the ProxMenux project.",
},
}
}
type BranchingRow = { branch: string; purposeRich: string }
type PhaseRow = { phaseRich: string; purposeRich: string; screenRich: string }
type DialogRow = { toolRich: string; whenRich: string; effectRich: string }
type MsgRow = { function: string; whenRich: string; spinner: string }
type WhereNextItem =
| { kind: "external"; url: string; label: string; tail: string }
| { kind: "internal"; href: string; label: string; tail: string }
export default async function ContributingPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.about.contributing" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { about: { contributing: {
branching: { rows: BranchingRow[] }
scriptHeader: { bullets: string[] }
twoPhase: { rows: PhaseRow[]; phase1Rules: string[] }
dialogVsWhiptail: { rows: DialogRow[] }
messageFunctions: { rows: MsgRow[] }
dialogConventions: { bullets: string[] }
translation: { bullets: string[] }
variableStyle: { bullets: string[] }
dosAndDonts: { doBullets: string[]; dontBullets: string[] }
submitting: { steps: string[] }
whereNext: { items: WhereNextItem[] }
} } }
}
const block = messages.docs.about.contributing
const branchingRows = block.branching.rows
const scriptHeaderBullets = block.scriptHeader.bullets
const twoPhaseRows = block.twoPhase.rows
const phase1Rules = block.twoPhase.phase1Rules
const dialogRows = block.dialogVsWhiptail.rows
const msgRows = block.messageFunctions.rows
const dialogBullets = block.dialogConventions.bullets
const translationBullets = block.translation.bullets
const variableStyleBullets = block.variableStyle.bullets
const doBullets = block.dosAndDonts.doBullets
const dontBullets = block.dosAndDonts.dontBullets
const submittingSteps = block.submitting.steps
const whereNextItems = block.whereNext.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 contributorsLink = (chunks: React.ReactNode) => (
<Link href="/docs/about/contributors" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
const cocLink = (chunks: React.ReactNode) => (
<Link href="/docs/about/code-of-conduct" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
const licenseLink = (chunks: React.ReactNode) => (
<a
href="https://github.com/MacRimi/ProxMenux/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
const securityLink = (chunks: React.ReactNode) => (
<a
href="https://github.com/MacRimi/ProxMenux/blob/main/SECURITY.md"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={15}
/>
<Callout variant="info" title={t("twoPagesCallout.title")}>
{t.rich("twoPagesCallout.body", { contributorsLink, em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("branching.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("branching.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("branching.headerBranch")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("branching.headerPurpose")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{branchingRows.map((row, idx) => (
<tr key={row.branch} className={idx < branchingRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong><code>{row.branch}</code></strong></td>
<td className="px-3 py-2 align-top">{t.rich(`branching.rows.${idx}.purposeRich`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="tip" title={t("branching.calloutTitle")}>
{t.rich("branching.calloutBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("workflow.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("workflow.intro")}</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
<li>
{t.rich("workflow.step1Lead", { code, strong })}
<CopyableCode code={t.raw("workflow.step1Code") as string} className="my-3" />
{t.rich("workflow.step1Trail", { code })}
</li>
<li>
{t.rich("workflow.step2Lead", { strong })}
<CopyableCode code={t.raw("workflow.step2Code") as string} className="my-3" />
</li>
<li>{t.rich("workflow.step3", { code, strong })}</li>
<li>{t.rich("workflow.step4", { code, strong })}</li>
<li>{t.rich("workflow.step5", { code, strong })}</li>
</ol>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("scriptHeader.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("scriptHeader.intro", { strong })}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
{scriptHeaderBullets.map((_, idx) => (
<li key={idx}>{t.rich(`scriptHeader.bullets.${idx}`, { strong, em })}</li>
))}
</ul>
<Callout variant="info" title={t("scriptHeader.licenseCalloutTitle")}>
{t.rich("scriptHeader.licenseCalloutBody", { strong, code, licenseLink })}
</Callout>
<CopyableCode code={t.raw("scriptHeader.templateCode") as string} className="my-4" />
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("scriptHeader.optionalNote", { code })}</p>
<Callout variant="tip" title={t("scriptHeader.whyCalloutTitle")}>
{t("scriptHeader.whyCalloutBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("structure.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("structure.intro")}</p>
<CopyableCode code={t.raw("structure.treeCode") as string} className="my-4" />
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("structure.outro", { code })}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("twoPhase.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("twoPhase.intro", { strong })}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("twoPhase.headerPhase")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("twoPhase.headerPurpose")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("twoPhase.headerScreen")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{twoPhaseRows.map((_, idx) => (
<tr key={idx} className={idx < twoPhaseRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap">{t.rich(`twoPhase.rows.${idx}.phaseRich`, { strong })}</td>
<td className="px-3 py-2 align-top">{t.rich(`twoPhase.rows.${idx}.purposeRich`, { code })}</td>
<td className="px-3 py-2 align-top">{t.rich(`twoPhase.rows.${idx}.screenRich`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("twoPhase.principle", { strong })}</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("twoPhase.phase1Heading")}</h3>
<CopyableCode code={t.raw("twoPhase.phase1Code") as string} className="my-4" />
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("twoPhase.phase1RulesIntro", { strong })}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{phase1Rules.map((_, idx) => (
<li key={idx}>{t.rich(`twoPhase.phase1Rules.${idx}`, { code })}</li>
))}
</ul>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("twoPhase.phase2Heading")}</h3>
<CopyableCode code={t.raw("twoPhase.phase2Code") as string} className="my-4" />
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("twoPhase.phase2Rules", { strong, code })}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t.rich("dialogVsWhiptail.headingRich", { code })}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("dialogVsWhiptail.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dialogVsWhiptail.headerTool")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dialogVsWhiptail.headerWhen")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dialogVsWhiptail.headerEffect")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{dialogRows.map((_, idx) => (
<tr key={idx} className={idx < dialogRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap">{t.rich(`dialogVsWhiptail.rows.${idx}.toolRich`, { strong, code })}</td>
<td className="px-3 py-2 align-top">{t.rich(`dialogVsWhiptail.rows.${idx}.whenRich`, { strong, code })}</td>
<td className="px-3 py-2 align-top">{t.rich(`dialogVsWhiptail.rows.${idx}.effectRich`, { code, em })}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="info" title={t("dialogVsWhiptail.calloutTitle")}>
{t.rich("dialogVsWhiptail.calloutBody", { code })}
</Callout>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("dialogVsWhiptail.rebootHeading")}</h3>
<p className="mb-3 text-gray-800 leading-relaxed">{t.rich("dialogVsWhiptail.rebootIntro", { code })}</p>
<CopyableCode code={t.raw("dialogVsWhiptail.rebootCode") as string} className="my-4" />
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("messageFunctions.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("messageFunctions.intro", { code })}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("messageFunctions.headerFunction")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("messageFunctions.headerWhen")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("messageFunctions.headerSpinner")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{msgRows.map((row, idx) => (
<tr key={idx} className={idx < msgRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top font-mono text-xs">{row.function}</td>
<td className="px-3 py-2 align-top">{t.rich(`messageFunctions.rows.${idx}.whenRich`, { em })}</td>
<td className="px-3 py-2 align-top">{row.spinner}</td>
</tr>
))}
</tbody>
</table>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dialogConventions.heading")}</h2>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{dialogBullets.map((_, idx) => (
<li key={idx}>{t.rich(`dialogConventions.bullets.${idx}`, { code })}</li>
))}
</ul>
<p className="mb-3 text-gray-800 leading-relaxed">{t("dialogConventions.exampleIntro")}</p>
<CopyableCode code={t.raw("dialogConventions.exampleCode") as string} className="my-4" />
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("translation.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("translation.intro", { code })}</p>
<CopyableCode code={t.raw("translation.code") as string} className="my-4" />
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{translationBullets.map((_, idx) => (
<li key={idx}>{t.rich(`translation.bullets.${idx}`, { strong })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("variableStyle.heading")}</h2>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{variableStyleBullets.map((_, idx) => (
<li key={idx}>{t.rich(`variableStyle.bullets.${idx}`, { code })}</li>
))}
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">{t("variableStyle.standardNamesIntro")}</p>
<CopyableCode code={t.raw("variableStyle.standardNamesCode") as string} className="my-4" />
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("variableStyle.redirectHeading")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("variableStyle.redirectIntro", { code })}</p>
<p className="mb-3 text-gray-800 leading-relaxed">{t.rich("variableStyle.withoutRedirectIntro", { strong })}</p>
<CopyableCode code={t.raw("variableStyle.withoutRedirectCode") as string} className="my-4" />
<p className="mb-3 text-gray-800 leading-relaxed">{t.rich("variableStyle.withRedirectIntro", { strong })}</p>
<CopyableCode code={t.raw("variableStyle.withRedirectCode") as string} className="my-4" />
<p className="mb-4 text-gray-800 leading-relaxed">{t("variableStyle.twoPatternsIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
<li>
{t.rich("variableStyle.discardLead", { strong })}
<CopyableCode code={t.raw("variableStyle.discardCode") as string} className="my-3" />
</li>
<li>
{t.rich("variableStyle.logLead", { strong })}
<CopyableCode code={t.raw("variableStyle.logCode") as string} className="my-3" />
</li>
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("variableStyle.referenceOutro", { code })}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dosAndDonts.heading")}</h2>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900 text-green-700">{t("dosAndDonts.doHeading")}</h3>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{doBullets.map((_, idx) => (
<li key={idx}>{t.rich(`dosAndDonts.doBullets.${idx}`, { code })}</li>
))}
</ul>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900 text-red-700">{t("dosAndDonts.dontHeading")}</h3>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{dontBullets.map((_, idx) => (
<li key={idx}>{t.rich(`dosAndDonts.dontBullets.${idx}`, { code })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("submitting.heading")}</h2>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
{submittingSteps.map((_, idx) => (
<li key={idx}>{t.rich(`submitting.steps.${idx}`, { code, em, strong, cocLink })}</li>
))}
</ol>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("submitting.securityOutro", { securityLink })}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.map((item, idx) => (
<li key={idx}>
{item.kind === "external" ? (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{item.label}
<ExternalLink className="w-3 h-3" />
</a>
) : (
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
)}
{item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,181 @@
import type { Metadata } from "next"
import Image from "next/image"
import { getTranslations, setRequestLocale } from "next-intl/server"
import { Youtube, FlaskRound, ExternalLink } 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.about.contributors.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/about/contributors",
},
}
}
interface Contributor {
name: string
roleKey: "testing" | "testingReviewer"
avatar: string
youtubeUrl?: string
}
const contributors: Contributor[] = [
{
name: "MALOW",
roleKey: "testing",
avatar: "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/avatars/malow.png",
},
{
name: "Segarra",
roleKey: "testing",
avatar: "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/avatars/segarra.png",
},
{
name: "Aprilia",
roleKey: "testing",
avatar: "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/avatars/aprilia.png",
},
{
name: "Jonatan Castro",
roleKey: "testingReviewer",
avatar: "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/avatars/jonatancastro.png",
youtubeUrl: "https://www.youtube.com/@JonatanCastro",
},
{
name: "Kamunhas",
roleKey: "testing",
avatar: "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/avatars/Kamunhas.png",
},
]
export default async function ContributorsPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.about.contributors" })
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const extlink = (href: string) => (chunks: React.ReactNode) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-700 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
const extlinkBlue = (href: string) => (chunks: React.ReactNode) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
const extlinkEmerald = (href: string) => (chunks: React.ReactNode) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-emerald-700 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={1}
/>
<Callout variant="info" title={t("beyond.title")}>
{t.rich("beyond.body", {
extlink: extlink("https://github.com/MacRimi/ProxMenux/graphs/contributors"),
})}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("testers.heading")}</h2>
<p className="mb-6 text-gray-800 leading-relaxed">{t("testers.intro")}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-6 mt-6 mb-8 not-prose">
{contributors.map((c) => (
<div key={c.name} className="text-center">
<div className="relative inline-block">
<Image
src={c.avatar}
alt={c.name}
width={80}
height={80}
className="w-20 h-20 rounded-full border-2 border-gray-300 object-cover"
unoptimized
/>
<div className="absolute -bottom-1 -right-1 bg-orange-500 rounded-full p-1">
<FlaskRound className="h-4 w-4 text-white" aria-hidden />
</div>
</div>
<h3 className="text-base font-semibold text-gray-900 mt-2 mb-0">{c.name}</h3>
<p className="text-xs text-gray-600 mt-0.5">{t(`testers.roles.${c.roleKey}`)}</p>
{c.youtubeUrl && (
<a
href={c.youtubeUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 mt-1 text-xs text-red-600 hover:text-red-700"
>
<Youtube className="h-3.5 w-3.5" aria-hidden /> {t("testers.youtube")}
</a>
)}
</div>
))}
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("contribute.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("contribute.intro")}</p>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
<li>
{t.rich("contribute.tester", {
strong,
beta: extlinkBlue("https://github.com/MacRimi/ProxMenux/blob/develop/install_proxmenux_beta.sh"),
})}
</li>
<li>
{t.rich("contribute.developer", {
strong,
gh: extlinkBlue("https://github.com/MacRimi/ProxMenux/pulls"),
})}
</li>
<li>{t.rich("contribute.designer", { strong })}</li>
<li>
{t.rich("contribute.ideas", {
strong,
disc: extlinkBlue("https://github.com/MacRimi/ProxMenux/discussions"),
})}
</li>
</ul>
<Callout variant="tip" title={t("coc.title")}>
{t.rich("coc.body", {
coclink: extlinkEmerald("https://github.com/MacRimi/ProxMenux/blob/main/CODE_OF_CONDUCT.md"),
})}
</Callout>
</div>
)
}

View File

@@ -0,0 +1,255 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { ExternalLink } 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.about.faq.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/about/faq",
},
}
}
interface QAProps {
q: string
children: React.ReactNode
}
function QA({ q, children }: QAProps) {
return (
<div className="mb-8">
<h2 className="text-xl font-semibold mb-3 text-gray-900">{q}</h2>
<div className="text-gray-800 leading-relaxed space-y-3">{children}</div>
</div>
)
}
export default async function FaqPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.about.faq" })
const messages = (await getMessages({ locale })) as unknown as {
docs: {
about: {
faq: {
q1: { items: string[] }
}
}
}
}
const q1Items = messages.docs.about.faq.q1.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 installlink = (chunks: React.ReactNode) => (
<Link href="/docs/installation" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
const upgradelink = (chunks: React.ReactNode) => (
<Link href="/docs/utils/upgrade-pve8-pve9" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
const betalink = (chunks: React.ReactNode) => (
<Link href="/docs/settings/beta-program" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
const uninstalllink = (chunks: React.ReactNode) => (
<Link href="/docs/settings/uninstall-proxmenux" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
const contriblink = (chunks: React.ReactNode) => (
<Link href="/docs/about/contributors" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
const issueslink = (chunks: React.ReactNode) => (
<a
href="https://github.com/MacRimi/ProxMenux/issues"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
const coclink = (chunks: React.ReactNode) => (
<a
href="https://github.com/MacRimi/ProxMenux/blob/main/CODE_OF_CONDUCT.md"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
const discusslink = (chunks: React.ReactNode) => (
<a
href="https://github.com/MacRimi/ProxMenux/discussions"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
const scriptlink = (chunks: React.ReactNode) => (
<a
href="https://github.com/MacRimi/ProxMenux/blob/main/install_proxmenux.sh"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
const issuelink = (chunks: React.ReactNode) => (
<a
href="https://github.com/MacRimi/ProxMenux/issues/162"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={5}
/>
<Callout variant="info" title={t("quickLinks.title")}>
<ul className="list-disc pl-6 mb-0 space-y-1">
<li>
<Link href="/docs/installation" className="text-blue-700 hover:underline">
{t("quickLinks.installationLabel")}
</Link>
{t("quickLinks.installationSuffix")}
</li>
<li>
<Link href="/docs/introduction" className="text-blue-700 hover:underline">
{t("quickLinks.introductionLabel")}
</Link>
{t("quickLinks.introductionSuffix")}
</li>
<li>
<Link href="/docs/settings/uninstall-proxmenux" className="text-blue-700 hover:underline">
{t("quickLinks.uninstallLabel")}
</Link>
{t("quickLinks.uninstallSuffix")}
</li>
<li>
<a
href="https://github.com/MacRimi/ProxMenux/issues"
target="_blank"
rel="noopener noreferrer"
className="text-blue-700 hover:underline inline-flex items-center gap-1"
>
{t("quickLinks.issuesLabel")}
<ExternalLink className="w-3 h-3" />
</a>{" "}
·{" "}
<a
href="https://github.com/MacRimi/ProxMenux/discussions"
target="_blank"
rel="noopener noreferrer"
className="text-blue-700 hover:underline inline-flex items-center gap-1"
>
{t("quickLinks.discussionsLabel")}
<ExternalLink className="w-3 h-3" />
</a>
</li>
</ul>
</Callout>
<QA q={t("q1.question")}>
<p>{t.rich("q1.p1Rich", { strong })}</p>
<p>{t("q1.p2")}</p>
<p className="mb-0">{t("q1.p3")}</p>
<ul className="list-disc pl-6 mb-0 space-y-1">
{q1Items.map((_, idx) => (
<li key={idx}>{t(`q1.items.${idx}`)}</li>
))}
</ul>
</QA>
<QA q={t("q2.question")}>
<p>{t.rich("q2.p1Rich", { installlink })}</p>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 border border-gray-200">
{t("q2.stableInstall")}
</pre>
<p>{t("q2.p2")}</p>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 border border-gray-200">
{t("q2.menuCmd")}
</pre>
</QA>
<QA q={t("q3.question")}>
<p>{t.rich("q3.bodyRich", { strong, upgradelink })}</p>
</QA>
<QA q={t("q5.question")}>
<p>{t.rich("q5.p1Rich", { code })}</p>
<p className="mb-0">{t.rich("q5.p2Rich", { betalink })}</p>
</QA>
<QA q={t("q6.question")}>
<p>{t.rich("q6.p1Rich", { issueslink, code })}</p>
<p className="mb-0">{t.rich("q6.p2Rich", { strong, coclink })}</p>
</QA>
<QA q={t("q7.question")}>
<p>{t.rich("q7.p1Rich", { strong })}</p>
<ul className="list-disc pl-6 mb-0 space-y-1">
<li>{t.rich("q7.item1Rich", { discusslink })}</li>
<li>{t.rich("q7.item2Rich", { coclink })}</li>
<li>{t.rich("q7.item3Rich", { contriblink })}</li>
</ul>
</QA>
<QA q={t("q10.question")}>
<p>{t.rich("q10.p1Rich", { strong, code })}</p>
<p className="mb-0">{t.rich("q10.p2Rich", { uninstalllink })}</p>
</QA>
<QA q={t("q11.question")}>
<p>{t.rich("q11.p1Rich", { em, code })}</p>
<p className="mb-0">{t.rich("q11.p2Rich", { scriptlink, issuelink })}</p>
</QA>
</div>
)
}

View File

@@ -0,0 +1,161 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { ArrowRight, Users, HelpCircle, ScrollText, Heart, Star, ExternalLink } 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.about.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/about",
},
}
}
type SectionOption = {
icon: string
href: string
title: string
description: string
}
type InvolvedCard = {
href: string
title: string
description: string
}
const ICONS: Record<string, React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>> = {
HelpCircle,
Users,
ScrollText,
}
function OptionCard({ option }: { option: SectionOption }) {
const Icon = ICONS[option.icon] || HelpCircle
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 AboutOverviewPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.about" })
const messages = (await getMessages({ locale })) as unknown as {
docs: {
about: {
section: { options: SectionOption[] }
involved: { cards: InvolvedCard[] }
}
}
}
const options = messages.docs.about.section.options
const involvedCards = messages.docs.about.involved.cards
const starlink = (chunks: React.ReactNode) => (
<a
href="https://github.com/MacRimi/ProxMenux"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={2}
/>
<Callout variant="info" title={t("callout.title")}>
{t("callout.body")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("section.heading")}</h2>
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
{options.map((o) => (
<OptionCard key={o.href} option={o} />
))}
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("involved.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t("involved.intro")}
</p>
<div className="grid gap-3 md:grid-cols-3 mb-6 not-prose">
{involvedCards.map((card) => (
<a
key={card.href}
href={card.href}
target="_blank"
rel="noopener noreferrer"
className="flex items-start justify-between gap-3 rounded-md border border-gray-200 bg-white p-4 transition-colors hover:border-blue-400 hover:bg-blue-50"
>
<div>
<div className="font-semibold text-gray-900 mb-1">{card.title}</div>
<div className="text-xs text-gray-600">{card.description}</div>
</div>
<ExternalLink className="w-3.5 h-3.5 text-gray-400 mt-0.5 flex-shrink-0" aria-hidden />
</a>
))}
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
<Star className="inline h-5 w-5 mr-1 -mt-1 text-amber-500" aria-hidden />
{t("support.heading")}
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("support.introRich", { starlink })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">
<a
href="https://ko-fi.com/G2G313ECAN"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-pink-600 hover:underline"
>
<Heart className="h-4 w-4" aria-hidden /> {t("support.kofiLabel")}
</a>
{t("support.kofiOutro")}
</p>
</div>
)
}

View File

@@ -0,0 +1,237 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import Image from "next/image"
import { ExternalLink, HardDrive, MonitorCog, Laptop } 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.createVm.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"proxmox create vm",
"proxmox synology vm",
"proxmox windows vm",
"proxmox truenas vm",
"proxmox unraid vm",
"proxmox openmediavault",
"proxmox vm wizard",
"proxmox nas vm",
"proxmox dsm",
"proxmenux create vm",
],
alternates: { canonical: "https://proxmenux.com/docs/create-vm" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/create-vm",
images: [
{
url: "/vm/vm-creation-menu.png",
width: 1200,
height: 630,
alt: t("ogImageAlt"),
},
],
},
twitter: {
card: "summary_large_image",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type Route = {
key: string
title: string
icon: string
href: string
accent: string
iconBg: string
description: string
bullets: string[]
}
type StringItem = string
type ScriptRowData = { path: string; role: string }
type RelatedItem = { href: string; label: string; tail?: string }
const ICONS: Record<string, React.ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }>> = {
HardDrive,
MonitorCog,
Laptop,
}
function ScriptRow({ path, role }: { path: string; role: string }) {
return (
<tr>
<td className="px-4 py-2">
<a
className="inline-flex items-center gap-1.5 text-blue-600 hover:underline"
href={`https://github.com/MacRimi/ProxMenux/blob/main/scripts/${path}`}
target="_blank"
rel="noopener noreferrer"
>
<span className="font-mono">{path}</span>
<ExternalLink className="h-3.5 w-3.5 shrink-0 opacity-70" />
</a>
</td>
<td className="px-4 py-2 text-gray-700">{role}</td>
</tr>
)
}
export default async function CreateVMOverviewPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.createVm" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { createVm: {
families: { routes: Route[] }
afterPick: { items: StringItem[] }
scripts: { rows: ScriptRowData[] }
related: { items: RelatedItem[] }
} }
}
const routes = messages.docs.createVm.families.routes
const afterPickItems = messages.docs.createVm.afterPick.items
const scriptRows = messages.docs.createVm.scripts.rows
const relatedItems = messages.docs.createVm.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 osxLink = (chunks: React.ReactNode) => (
<a
href="https://osx-proxmox.com"
target="_blank"
rel="noopener noreferrer"
className="text-blue-700 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={2}
scriptPath="menus/create_vm_menu.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t("intro.body")}
</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="/vm/vm-creation-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("families.heading")}</h2>
<p className="mb-6 text-gray-800 leading-relaxed">{t("families.intro")}</p>
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-3 mb-8 not-prose">
{routes.map((route) => {
const Icon = ICONS[route.icon] || HardDrive
return (
<Link
key={route.key}
href={route.href}
className={`rounded-lg border-2 p-5 ${route.accent} 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 ${route.iconBg}`}>
<Icon className="h-5 w-5" aria-hidden="true" />
</span>
<h3 className="text-lg font-semibold text-gray-900 m-0">{route.title}</h3>
</div>
<p className="text-sm text-gray-800 mb-3">{route.description}</p>
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
{route.bullets.map((b, i) => (
<li key={i}>{b}</li>
))}
</ul>
</Link>
)
})}
</div>
<Callout variant="info" title={t("community.title")}>
{t.rich("community.intro", { em, strong })}
<ul className="mt-3 list-disc list-inside space-y-1">
<li>{t.rich("community.macosRich", { strong, osxLink })}</li>
<li>{t.rich("community.othersRich", { strong })}</li>
</ul>
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("afterPick.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("afterPick.intro")}</p>
<ol className="list-decimal list-inside mb-4 text-gray-800 leading-relaxed space-y-2">
{afterPickItems.map((_, idx) => (
<li key={idx}>{t.rich(`afterPick.items.${idx}`, { strong, code })}</li>
))}
</ol>
<Callout variant="tip" title={t("afterPick.tipTitle")}>
{t.rich("afterPick.tipBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("scripts.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("scripts.intro")}</p>
<div className="overflow-x-auto rounded-md border border-gray-200">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-gray-700">
<tr>
<th className="px-4 py-2 font-semibold">{t("scripts.headerScript")}</th>
<th className="px-4 py-2 font-semibold">{t("scripts.headerRole")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{scriptRows.map((row) => (
<ScriptRow key={row.path} path={row.path} role={row.role} />
))}
</tbody>
</table>
</div>
<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,363 @@
"use client"
import Image from "next/image"
import {
Wrench,
Target,
CheckCircle,
Github,
Server,
HardDrive,
Download,
Settings,
Cpu,
Zap,
Sliders,
ExternalLink,
} from "lucide-react"
import { useState } from "react"
import { useTranslations, useMessages } from "next-intl"
type LoaderKey = "arc" | "rr" | "tinycore"
type StepMedia = { htmlBefore?: string; src: string; alt: string; caption: string }
type Step = {
id: string
title: string
intro: string
outro?: string
loaders: Record<LoaderKey, StepMedia[]>
}
type LoaderLink = { name: string; url: string }
type ConfigRow = { param: string; value?: string; options?: string }
type DocLink = { label: string; url: string }
function ImageWithCaption({ src, alt, caption }: { src: string; alt: string; caption: string }) {
return (
<div className="flex flex-col items-center w-full max-w-[768px] mx-auto my-4">
<div className="w-full rounded-md overflow-hidden">
<Image
src={src || "/placeholder.svg"}
alt={alt}
width={768}
height={0}
style={{ height: "auto" }}
className="object-contain w-full"
sizes="(max-width: 768px) 100vw, 768px"
/>
</div>
<span className="mt-2 text-sm text-gray-600">{caption}</span>
</div>
)
}
function StepNumber({ number }: { number: number }) {
return (
<div
className="inline-flex items-center justify-center w-8 h-8 mr-3 text-white bg-blue-500 rounded-full"
aria-hidden="true"
>
<span className="text-sm font-bold">{number}</span>
</div>
)
}
export default function Page() {
const [activeLoader, setActiveLoader] = useState<LoaderKey>("arc")
const t = useTranslations("docs.createVm.synology")
const messages = useMessages() as unknown as {
docs: { createVm: { synology: {
intro: { loaders: LoaderLink[]; simplifies: string[] }
config: { defaultRows: ConfigRow[]; advancedRows: ConfigRow[] }
diskSelection: { virtualItems: string[]; physicalItems: string[] }
vmCreation: { items: string[] }
steps: Step[]
tips: { docLinks: DocLink[] }
} } }
}
const introLoaders = messages.docs.createVm.synology.intro.loaders
const simplifies = messages.docs.createVm.synology.intro.simplifies
const defaultRows = messages.docs.createVm.synology.config.defaultRows
const advancedRows = messages.docs.createVm.synology.config.advancedRows
const virtualItems = messages.docs.createVm.synology.diskSelection.virtualItems
const physicalItems = messages.docs.createVm.synology.diskSelection.physicalItems
const vmCreationItems = messages.docs.createVm.synology.vmCreation.items
const steps = messages.docs.createVm.synology.steps
const docLinks = messages.docs.createVm.synology.tips.docLinks
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">{t("title")}</h1>
<section className="mb-10">
<h2 className="text-2xl font-semibold mb-4 flex items-center">
<Server className="h-6 w-6 mr-2 text-blue-500" />
{t("intro.heading")}
</h2>
<p className="mb-4">{t("intro.intro")}</p>
<ul className="list-disc pl-5 mb-4">
{introLoaders.map((l) => (
<li key={l.name}>
<a
href={l.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{l.name}
<ExternalLink className="w-3 h-3" />
</a>{" "}
</li>
))}
<li>{t("intro.customLoader")}</li>
</ul>
<p className="mb-4">{t("intro.simplifiesIntro")}</p>
<ul className="list-disc pl-5 mb-4">
{simplifies.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
<div className="mt-8">
<h3 className="text-xl font-semibold mb-3 flex items-center">
<Settings className="h-5 w-5 mr-2 text-blue-500" />
{t("config.heading")}
</h3>
<p className="mb-3">{t("config.intro")}</p>
<h4 className="text-lg font-medium mt-12 mb-2 flex items-center">
<Zap className="h-5 w-5 mr-2 text-green-500" />
{t("config.defaultHeading")}
</h4>
<p className="mb-3">{t("config.defaultIntro")}</p>
<div className="overflow-x-auto mb-4">
<table className="min-w-full bg-white border border-gray-200">
<thead>
<tr>
<th className="py-2 px-4 border-b border-gray-200 bg-gray-50 text-left">{t("config.headerParam")}</th>
<th className="py-2 px-4 border-b border-gray-200 bg-gray-50 text-left">{t("config.headerValue")}</th>
</tr>
</thead>
<tbody>
{defaultRows.map((row) => (
<tr key={row.param}>
<td className="py-2 px-4 border-b border-gray-200">{row.param}</td>
<td className="py-2 px-4 border-b border-gray-200">{row.value}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-4">{t("config.defaultOutro")}</p>
<h4 className="text-lg font-medium mt-12 mb-2 flex items-center">
<Sliders className="h-5 w-5 mr-2 text-orange-500" />
{t("config.advancedHeading")}
</h4>
<p className="mb-3">{t("config.advancedIntro")}</p>
<div className="overflow-x-auto mb-4">
<table className="min-w-full bg-white border border-gray-200">
<thead>
<tr>
<th className="py-2 px-4 border-b border-gray-200 bg-gray-50 text-left">{t("config.headerParam")}</th>
<th className="py-2 px-4 border-b border-gray-200 bg-gray-50 text-left">{t("config.headerOptions")}</th>
</tr>
</thead>
<tbody>
{advancedRows.map((row) => (
<tr key={row.param}>
<td className="py-2 px-4 border-b border-gray-200">{row.param}</td>
<td className="py-2 px-4 border-b border-gray-200">{row.options}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="mt-8">
<h3 className="text-xl font-semibold mb-3 flex items-center">
<HardDrive className="h-5 w-5 mr-2 text-blue-500" />
{t("diskSelection.heading")}
</h3>
<p className="mb-3">{t("diskSelection.intro")}</p>
<h4 className="text-lg font-medium mt-4 mb-2">{t("diskSelection.virtualHeading")}</h4>
<ul className="list-disc pl-5 mb-4">
{virtualItems.map((_, idx) => (
<li key={idx}>{t.rich(`diskSelection.virtualItems.${idx}`, { strong })}</li>
))}
</ul>
<h4 className="text-lg font-medium mt-4 mb-2">{t("diskSelection.physicalHeading")}</h4>
<ul className="list-disc pl-5 mb-4">
{physicalItems.map((_, idx) => (
<li key={idx}>{t.rich(`diskSelection.physicalItems.${idx}`, { strong })}</li>
))}
</ul>
</div>
<div className="mt-8">
<h3 className="text-xl font-semibold mb-3 flex items-center">
<Download className="h-5 w-5 mr-2 text-blue-500" />
{t("loaderInstall.heading")}
</h3>
<p className="mb-3">{t("loaderInstall.intro1")}</p>
<p className="mb-4">
{t.rich("loaderInstall.intro2Rich", { strong })}
</p>
<p className="mb-4">
{t.rich("loaderInstall.customRich", { strong, code })}
</p>
<p className="mt-12 mb-4"></p>
<p>{t("loaderInstall.uploadIntro")}</p>
<ImageWithCaption
src="https://macrimi.github.io/ProxMenux/vm/synology/add_loader.png"
alt={t("loaderInstall.imageAlt")}
caption={t("loaderInstall.imageCaption")}
/>
</div>
<div className="mt-16">
<h3 className="text-xl font-semibold mb-3 flex items-center">
<Cpu className="h-5 w-5 mr-2 text-blue-500" />
{t("vmCreation.heading")}
</h3>
<p className="mb-3">{t("vmCreation.intro")}</p>
<ul className="list-disc pl-5 mb-4">
{vmCreationItems.map((_, idx) => (
<li key={idx}>{t.rich(`vmCreation.items.${idx}`, { code })}</li>
))}
</ul>
</div>
</section>
<section className="mb-10">
<h2 className="text-2xl font-semibold mt-24 mb-4 flex items-center">
<Wrench className="h-6 w-6 mr-2 text-blue-500" />
{t("stepGuide.heading")}
</h2>
<p className="mb-4">{t("stepGuide.intro")}</p>
<div className="bg-blue-50 p-4 rounded-lg mb-6">
<h3 className="text-lg font-semibold mb-2">{t("stepGuide.selectorHeading")}</h3>
<div className="flex space-x-4">
{(["arc", "rr", "tinycore"] as LoaderKey[]).map((key) => (
<button
key={key}
onClick={() => setActiveLoader(key)}
className={`px-4 py-2 rounded-md font-medium ${
activeLoader === key
? "bg-blue-500 text-white"
: "bg-white border border-gray-300 text-gray-700 hover:bg-gray-50"
}`}
>
{t(`stepGuide.loaderButtons.${key}`)}
</button>
))}
</div>
</div>
</section>
{steps.map((step, stepIdx) => {
const media = step.loaders[activeLoader] || []
return (
<section key={step.id} className="mb-12 border-b pb-8">
<h2 className="text-xl font-semibold mb-4 flex items-center" id={step.id}>
<StepNumber number={stepIdx + 1} />
{step.title}
</h2>
<p className="mb-4">{step.intro}</p>
<div className="mt-6">
<div className="flex flex-col space-y-8">
{media.map((m, idx) => (
<div key={idx}>
{m.htmlBefore && (
<p className="mt-16 mb-2">
{t.rich(`steps.${stepIdx}.loaders.${activeLoader}.${idx}.htmlBefore`, { strong, code })}
</p>
)}
<ImageWithCaption src={m.src} alt={m.alt} caption={m.caption} />
</div>
))}
</div>
</div>
{step.outro && <p className="mt-4">{step.outro}</p>}
</section>
)
})}
<section className="mb-8">
<h2 className="text-xl font-semibold mb-4 flex items-center">
<CheckCircle className="h-6 w-6 mr-3 text-green-500" />
{t("dsmInstall.heading")}
</h2>
<p className="mb-4">{t("dsmInstall.intro")}</p>
<div className="bg-gray-100 p-4 rounded-md overflow-x-auto text-sm mb-4">
<code>https://finds.synology.com</code>
</div>
<p className="mb-6">{t("dsmInstall.afterCode")}</p>
<div className="flex flex-col space-y-8">
<ImageWithCaption
src="https://macrimi.github.io/ProxMenux/vm/synology/install_DSM.png"
alt={t("dsmInstall.setupAlt")}
caption={t("dsmInstall.setupCaption")}
/>
<p className="mt-8 mb-8">{t("dsmInstall.patience")}</p>
<ImageWithCaption
src="https://macrimi.github.io/ProxMenux/vm/synology/finish_install_DSM.png"
alt={t("dsmInstall.finishAlt")}
caption={t("dsmInstall.finishCaption")}
/>
</div>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mt-20 mb-4 flex items-center">
<Target className="h-6 w-6 mr-2 text-blue-500" />
{t("tips.heading")}
</h2>
<ul className="list-disc pl-5 space-y-4">
<li>{t("tips.introItem")}</li>
<div className="flex flex-wrap gap-4 mt-2">
{docLinks.map((dl) => (
<a
key={dl.url}
href={dl.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center px-3 py-2 text-sm font-medium rounded-md bg-gray-100 text-gray-800 hover:bg-gray-200 transition-colors"
>
<Github className="h-5 w-5 mr-2" />
{dl.label}
</a>
))}
</div>
<li>{t("tips.olderModels")}</li>
<div className="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 mb-4">
<p className="font-semibold">{t("tips.updateLabel")}</p>
<p>{t("tips.updateBody")}</p>
</div>
<div className="bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4 mb-4">
<p className="font-semibold">{t("tips.importantLabel")}</p>
<p>{t("tips.importantBody")}</p>
</div>
</ul>
</section>
</div>
)
}

View File

@@ -0,0 +1,333 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import Image from "next/image"
import { Server, HardDrive } from "lucide-react"
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.createVm.systemLinux.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/create-vm/system-linux",
images: [
{
url: "/vm/menu_linux.png",
width: 1200,
height: 630,
alt: t("ogImageAlt"),
},
],
},
}
}
type ConfigRow = { param: string; value?: string; valueRich?: string; options?: string; optionsRich?: string }
type Distro = { name: string; variants: string[] }
type StringItem = string
type RelatedItem = { href: string; label: string; tail?: string }
export default async function SystemLinuxPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.createVm.systemLinux" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { createVm: { systemLinux: {
config: { defaultRows: ConfigRow[]; advancedRows: ConfigRow[] }
storagePlan: { virtualDiskItems: StringItem[]; importDiskItems: StringItem[]; pciItems: StringItem[] }
installOptions: { distros: Distro[] }
endToEnd: { items: StringItem[] }
postInstall: { trimItems: StringItem[] }
related: { items: RelatedItem[] }
} } }
}
const defaultRows = messages.docs.createVm.systemLinux.config.defaultRows
const advancedRows = messages.docs.createVm.systemLinux.config.advancedRows
const virtualDiskItems = messages.docs.createVm.systemLinux.storagePlan.virtualDiskItems
const importDiskItems = messages.docs.createVm.systemLinux.storagePlan.importDiskItems
const pciItems = messages.docs.createVm.systemLinux.storagePlan.pciItems
const distros = messages.docs.createVm.systemLinux.installOptions.distros
const endToEndItems = messages.docs.createVm.systemLinux.endToEnd.items
const trimItems = messages.docs.createVm.systemLinux.postInstall.trimItems
const relatedItems = messages.docs.createVm.systemLinux.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 gpuLink = (chunks: React.ReactNode) => (
<a href="/docs/hardware/gpu-vm-passthrough" className="text-blue-600 hover:underline">{chunks}</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={15}
scriptPath="vm/select_linux_iso.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t("intro.body")}
</Callout>
<div className="flex flex-col items-center my-6">
<div className="w-full max-w-[768px] overflow-hidden rounded-md border border-gray-200">
<Image
src="/vm/menu_linux.png"
alt={t("image.alt")}
width={768}
height={0}
style={{ height: "auto" }}
className="w-full object-contain"
sizes="(max-width: 768px) 100vw, 768px"
/>
</div>
<span className="mt-2 text-sm text-gray-600">{t("image.caption")}</span>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("config.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("config.intro")}</p>
<h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("config.defaultHeading")}</h3>
<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("config.headerParam")}</th>
<th className="px-4 py-2 font-semibold">{t("config.headerValue")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-800">
{defaultRows.map((row, idx) => (
<tr key={row.param}>
<td className="px-4 py-2">{row.param}</td>
<td className="px-4 py-2">
{row.valueRich ? t.rich(`config.defaultRows.${idx}.valueRich`, { code }) : row.value}
</td>
</tr>
))}
</tbody>
</table>
</div>
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("config.advancedHeading")}</h3>
<p className="mb-3 text-gray-800 leading-relaxed">{t("config.advancedIntro")}</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("config.headerParam")}</th>
<th className="px-4 py-2 font-semibold">{t("config.headerOptions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-800">
{advancedRows.map((row, idx) => (
<tr key={row.param}>
<td className="px-4 py-2">{row.param}</td>
<td className="px-4 py-2">
{row.optionsRich ? t.rich(`config.advancedRows.${idx}.optionsRich`, { code }) : row.options}
</td>
</tr>
))}
</tbody>
</table>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("storagePlan.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("storagePlan.body", { strong })}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 my-6">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.virtualDiskTitle")}</h3>
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
{virtualDiskItems.map((_, idx) => (
<li key={idx}>{t.rich(`storagePlan.virtualDiskItems.${idx}`, { code })}</li>
))}
</ul>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.importDiskTitle")}</h3>
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
{importDiskItems.map((_, idx) => (
<li key={idx}>{t.rich(`storagePlan.importDiskItems.${idx}`, { code })}</li>
))}
</ul>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4 md:col-span-2">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.pciTitle")}</h3>
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
{pciItems.map((_, idx) => (
<li key={idx}>{t.rich(`storagePlan.pciItems.${idx}`, { em, code })}</li>
))}
</ul>
</div>
</div>
<Callout variant="info" title={t("storagePlan.resetTitle")}>
{t.rich("storagePlan.resetBody", { strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("gpu.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("gpu.body", { gpuLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("autoFeatures.heading")}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 my-4">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("autoFeatures.efiTitle")}</h3>
<p className="text-sm text-gray-700 leading-relaxed">{t("autoFeatures.efiBody")}</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("autoFeatures.isoTitle")}</h3>
<p className="text-sm text-gray-700 leading-relaxed">{t.rich("autoFeatures.isoBody", { code })}</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("autoFeatures.guestTitle")}</h3>
<p className="text-sm text-gray-700 leading-relaxed">{t("autoFeatures.guestBody")}</p>
</div>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("installOptions.heading")}</h2>
<section className="mt-6">
<div className="flex items-center gap-3 mb-3">
<Server className="h-8 w-8 text-blue-500" />
<h3 className="text-xl font-semibold text-gray-900 m-0">{t("installOptions.officialHeading")}</h3>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("installOptions.officialBody", { code })}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 my-4">
{distros.map((d) => (
<div key={d.name} className="rounded-md border border-gray-200 bg-white p-3">
<div className="text-sm font-semibold text-gray-900">{d.name}</div>
<ul className="mt-1 text-xs text-gray-600 space-y-0.5">
{d.variants.map((v) => (
<li key={v}>{v}</li>
))}
</ul>
</div>
))}
</div>
<div className="mt-4">
<div className="flex flex-col items-center">
<div className="w-full max-w-[768px] overflow-hidden rounded-md border border-gray-200">
<Image src="/vm/distro_linux.png" alt={t("installOptions.officialImageAlt")} width={768} height={0} style={{ height: "auto" }} className="w-full object-contain" sizes="(max-width: 768px) 100vw, 768px" />
</div>
<span className="mt-2 text-sm text-gray-600">{t("installOptions.officialImageCaption")}</span>
</div>
</div>
</section>
<section className="mt-10">
<div className="flex items-center gap-3 mb-3">
<HardDrive className="h-8 w-8 text-blue-500" />
<h3 className="text-xl font-semibold text-gray-900 m-0">{t("installOptions.localHeading")}</h3>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("installOptions.localBody", { code })}
</p>
<div className="flex flex-col items-center">
<div className="w-full max-w-[768px] overflow-hidden rounded-md border border-gray-200">
<Image src="/vm/local-store.png" alt={t("installOptions.localImageAlt")} width={768} height={0} style={{ height: "auto" }} className="w-full object-contain" sizes="(max-width: 768px) 100vw, 768px" />
</div>
<span className="mt-2 text-sm text-gray-600">{t("installOptions.localImageCaption")}</span>
</div>
</section>
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("endToEnd.heading")}</h2>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{endToEndItems.map((_, idx) => (
<li key={idx}>{t.rich(`endToEnd.items.${idx}`, { code })}</li>
))}
</ol>
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("postInstall.heading")}</h2>
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("postInstall.guestAgentHeading")}</h3>
<p className="mb-3 text-gray-800 leading-relaxed">{t("postInstall.guestAgentBody")}</p>
<div className="space-y-4 mb-6">
<div>
<p className="text-sm font-semibold text-gray-900 mb-1">{t("postInstall.debian")}</p>
<CopyableCode code={`sudo apt update && sudo apt install qemu-guest-agent -y
sudo systemctl enable --now qemu-guest-agent`} />
</div>
<div>
<p className="text-sm font-semibold text-gray-900 mb-1">{t("postInstall.fedora")}</p>
<CopyableCode code={`sudo dnf install qemu-guest-agent -y
sudo systemctl enable --now qemu-guest-agent`} />
</div>
<div>
<p className="text-sm font-semibold text-gray-900 mb-1">{t("postInstall.arch")}</p>
<CopyableCode code={`sudo pacman -S qemu-guest-agent
sudo systemctl enable --now qemu-guest-agent`} />
</div>
<div>
<p className="text-sm font-semibold text-gray-900 mb-1">{t("postInstall.opensuse")}</p>
<CopyableCode code={`sudo zypper install qemu-guest-agent
sudo systemctl enable --now qemu-guest-agent`} />
</div>
</div>
<h3 className="text-lg font-semibold mt-8 mb-3 text-gray-900">{t("postInstall.virtioHeading")}</h3>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("postInstall.virtioBody", { code })}
</p>
<Callout variant="warning" title={t("postInstall.virtioWarnTitle")}>
{t("postInstall.virtioWarnBody")}
</Callout>
<h3 className="text-lg font-semibold mt-8 mb-3 text-gray-900">{t("postInstall.trimHeading")}</h3>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("postInstall.trimBody", { code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{trimItems.map((_, idx) => (
<li key={idx}>{t.rich(`postInstall.trimItems.${idx}`, { code })}</li>
))}
</ul>
<h3 className="text-lg font-semibold mt-8 mb-3 text-gray-900">{t("postInstall.balloonHeading")}</h3>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("postInstall.balloonBody", { code })}
</p>
<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,193 @@
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, ExternalLink, HardDrive, Database, Server, MonitorIcon, Github } 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.createVm.systemNas.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/create-vm/system-nas",
images: [
{
url: "/vm/system-nas-menu.png",
width: 1200,
height: 630,
alt: t("ogImageAlt"),
},
],
},
}
}
type NASCard = {
name: string
tagline: string
icon: string
base: string
fileSystem: string
href: string
flow: "loader" | "auto-iso" | "dedicated"
external?: boolean
}
type RelatedItem = { href: string; label: string; tail?: string }
const ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
HardDrive,
Database,
Server,
MonitorIcon,
}
const FLOW_CLS: Record<string, string> = {
loader: "bg-purple-100 text-purple-800 border-purple-200",
"auto-iso": "bg-blue-100 text-blue-800 border-blue-200",
dedicated: "bg-indigo-100 text-indigo-800 border-indigo-200",
}
export default async function SystemNASPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.createVm.systemNas" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { createVm: { systemNas: {
supported: { cards: NASCard[] }
related: { items: RelatedItem[] }
} } }
}
const cards = messages.docs.createVm.systemNas.supported.cards
const relatedItems = messages.docs.createVm.systemNas.related.items
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const umbrelLink = (chunks: React.ReactNode) => (
<a
href="https://community-scripts.github.io/ProxmoxVE/scripts?id=umbrel-os-vm"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1 ml-1"
>
<Github className="h-3.5 w-3.5" /> {chunks}
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={3}
scriptPath="vm/select_nas_iso.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t("intro.body")}
</Callout>
<div className="flex flex-col items-center my-6">
<div className="w-full max-w-[768px] overflow-hidden rounded-md border border-gray-200">
<Image
src="/vm/system-nas-menu.png"
alt={t("image.alt")}
width={768}
height={0}
style={{ height: "auto" }}
className="w-full object-contain"
sizes="(max-width: 768px) 100vw, 768px"
/>
</div>
<span className="mt-2 text-sm text-gray-600">{t("image.caption")}</span>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("supported.heading")}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
{cards.map((card) => {
const Icon = ICONS[card.icon] || HardDrive
const flowLabel = t(`flowBadges.${card.flow}`)
const cls = "group block rounded-lg border border-gray-200 bg-white p-5 transition-all hover:border-blue-300 hover:shadow-md"
const inner = (
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-blue-50 text-blue-600">
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-base font-semibold text-gray-900">{card.name}</h3>
<p className="mt-1 text-sm text-gray-600 leading-relaxed">{card.tagline}</p>
<div className="mt-3 flex flex-wrap gap-1.5">
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${FLOW_CLS[card.flow]}`}>
{flowLabel}
</span>
</div>
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-gray-600">
<div>
<dt className="font-semibold text-gray-700">{t("labels.base")}</dt>
<dd>{card.base}</dd>
</div>
<div>
<dt className="font-semibold text-gray-700">{t("labels.fileSystem")}</dt>
<dd>{card.fileSystem}</dd>
</div>
</dl>
<span className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-blue-600 group-hover:text-blue-700">
{t("labels.viewDetails")}
{card.external ? (
<ExternalLink className="h-3.5 w-3.5" />
) : (
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
)}
</span>
</div>
</div>
)
return card.external ? (
<a key={card.name} href={card.href} target="_blank" rel="noopener noreferrer" className={cls}>
{inner}
</a>
) : (
<Link key={card.name} href={card.href} className={cls}>
{inner}
</Link>
)
})}
</div>
<Callout variant="info" title={t("umbrel.title")}>
{t.rich("umbrel.bodyRich", { strong, umbrelLink })}
</Callout>
<Callout variant="tip" title={t("zfsMem.title")}>
{t.rich("zfsMem.bodyRich", { strong })}
</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,427 @@
"use client"
import Image from "next/image"
import { Link } from "@/i18n/navigation"
import { useState } from "react"
import { Github, ExternalLink } from "lucide-react"
import { useTranslations, useMessages } from "next-intl"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
type LoaderKey = "arc" | "rr" | "tinycore"
type LoaderLink = { name: string; url: string }
type DefaultRow = { param: string; valueRich: string }
type AdvancedRow = { param: string; optionsRich: string }
type ItemAltCaption = { alt: string; caption: string }
type DocLink = { label: string; url: string }
type RelatedItem = { href: string; label: string; tail: string }
export default function SynologyPage() {
const [activeLoader, setActiveLoader] = useState<LoaderKey>("arc")
const t = useTranslations("docs.createVm.systemNas.synology")
const messages = useMessages() as unknown as {
docs: {
createVm: {
systemNas: {
synology: {
supportedLoaders: { loaders: LoaderLink[] }
config: { defaultRowsRich: DefaultRow[]; advancedRowsRich: AdvancedRow[] }
storagePlan: { virtualItemsRich: string[]; importItemsRich: string[]; pciItemsRich: string[] }
vmCreation: { itemsRich: string[] }
step3: { arc: ItemAltCaption[]; rr: ItemAltCaption[]; tinycore: ItemAltCaption[] }
step4: { rr: ItemAltCaption[]; tinycore: ItemAltCaption[] }
tips: { docLinks: DocLink[] }
related: { itemsRich: RelatedItem[] }
}
}
}
}
}
const s = messages.docs.createVm.systemNas.synology
const loaders = s.supportedLoaders.loaders
const defaultRows = s.config.defaultRowsRich
const advancedRows = s.config.advancedRowsRich
const virtualItems = s.storagePlan.virtualItemsRich
const importItems = s.storagePlan.importItemsRich
const pciItems = s.storagePlan.pciItemsRich
const vmCreationItems = s.vmCreation.itemsRich
const step3Arc = s.step3.arc
const step3Rr = s.step3.rr
const step3Tc = s.step3.tinycore
const step4Rr = s.step4.rr
const step4Tc = s.step4.tinycore
const docLinks = s.tips.docLinks
const relatedItems = s.related.itemsRich
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 gpuLink = (chunks: React.ReactNode) => (
<a href="/docs/hardware/gpu-vm-passthrough" className="text-blue-600 hover:underline">
{chunks}
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={30}
scriptPath="vm/synology.sh"
/>
<Callout variant="info" title={t("whatThisDoes.title")}>
{t.rich("whatThisDoes.bodyRich", { strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("supportedLoaders.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("supportedLoaders.intro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{loaders.map((l) => (
<li key={l.url}>
<a
href={l.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{l.name} <ExternalLink className="h-3 w-3" />
</a>
</li>
))}
<li>{t.rich("supportedLoaders.customRich", { strong, code })}</li>
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("config.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("config.intro")}</p>
<h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("config.defaultHeading")}</h3>
<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("config.headerParam")}</th>
<th className="px-4 py-2 font-semibold">{t("config.headerValue")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-800">
{defaultRows.map((row, idx) => (
<tr key={row.param}>
<td className="px-4 py-2">{row.param}</td>
<td className="px-4 py-2">{t.rich(`config.defaultRowsRich.${idx}.valueRich`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("config.advancedHeading")}</h3>
<p className="mb-3 text-gray-800 leading-relaxed">{t("config.advancedIntro")}</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("config.headerParam")}</th>
<th className="px-4 py-2 font-semibold">{t("config.headerOptions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-800">
{advancedRows.map((row, idx) => (
<tr key={row.param}>
<td className="px-4 py-2">{row.param}</td>
<td className="px-4 py-2">{t.rich(`config.advancedRowsRich.${idx}.optionsRich`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("storagePlan.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("storagePlan.introRich", { strong })}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 my-6">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.virtualHeading")}</h3>
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
{virtualItems.map((_, idx) => (
<li key={idx}>{t.rich(`storagePlan.virtualItemsRich.${idx}`, { code })}</li>
))}
</ul>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.importHeading")}</h3>
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
{importItems.map((_, idx) => (
<li key={idx}>{t.rich(`storagePlan.importItemsRich.${idx}`, { code })}</li>
))}
</ul>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4 md:col-span-2">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.pciHeading")}</h3>
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
{pciItems.map((_, idx) => (
<li key={idx}>{t.rich(`storagePlan.pciItemsRich.${idx}`, { code, em })}</li>
))}
</ul>
</div>
</div>
<Callout variant="info" title={t("storagePlan.resetCalloutTitle")}>
{t.rich("storagePlan.resetCalloutBodyRich", { strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("gpu.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("gpu.bodyRich", { link: gpuLink })}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("loaderInstall.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("loaderInstall.intro1Rich", { strong })}</p>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("loaderInstall.intro2Rich", { strong, code })}</p>
<p className="mb-2 text-gray-800 leading-relaxed">{t("loaderInstall.uploadIntro")}</p>
<ImageCaption src="/vm/synology/add_loader.png" alt={t("loaderInstall.imageAlt")} caption={t("loaderInstall.imageCaption")} />
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("vmCreation.heading")}</h2>
<p className="mb-3 text-gray-800 leading-relaxed">{t.rich("vmCreation.introRich", { code })}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{vmCreationItems.map((_, idx) => (
<li key={idx}>{t.rich(`vmCreation.itemsRich.${idx}`, { code, strong })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("stepByStep.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("stepByStep.intro")}</p>
<Callout variant="warning" title={t("stepByStep.warnCalloutTitle")}>
{t("stepByStep.warnCalloutBody")}
</Callout>
<div className="flex flex-wrap gap-2 my-6">
{(["arc", "rr", "tinycore"] as LoaderKey[]).map((k) => (
<button
key={k}
onClick={() => setActiveLoader(k)}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeLoader === k
? "bg-blue-600 text-white"
: "bg-white border border-gray-300 text-gray-700 hover:bg-gray-50"
}`}
>
{t(`loaderLabels.${k}`)}
</button>
))}
</div>
<StepSection n={1} title={t("step1.title")} stepLabel={t("stepBadge")}>
<p className="mb-4 text-gray-800 leading-relaxed">{t("step1.intro")}</p>
{activeLoader === "arc" && (
<div className="flex flex-col space-y-8">
<p className="text-gray-800 leading-relaxed">{t.rich("step1.arc.webRich", { strong, code })}</p>
<ImageCaption src="/vm/synology/arc/arc_1_0_1.png" alt={t("step1.arc.webAlt")} caption={t("step1.arc.webCaption")} />
<p className="text-gray-800 leading-relaxed">{t.rich("step1.arc.termRich", { strong })}</p>
<ImageCaption src="/vm/synology/arc/arc_1_1_1.png" alt={t("step1.arc.termAlt")} caption={t("step1.arc.termCaption")} />
</div>
)}
{activeLoader === "rr" && (
<div className="flex flex-col space-y-8">
<p className="text-gray-800 leading-relaxed">{t.rich("step1.rr.webRich", { strong, code })}</p>
<ImageCaption src="/vm/synology/rr/rr_2_0_2.png" alt={t("step1.rr.webAlt")} caption={t("step1.rr.webCaption")} />
<p className="text-gray-800 leading-relaxed">{t.rich("step1.rr.termRich", { strong, code })}</p>
<ImageCaption src="/vm/synology/rr/rr_2_1_1.png" alt={t("step1.rr.termAlt")} caption={t("step1.rr.termCaption")} />
</div>
)}
{activeLoader === "tinycore" && (
<div className="flex flex-col space-y-8">
<p className="text-gray-800 leading-relaxed">{t.rich("step1.tinycore.webRich", { strong, code })}</p>
<ImageCaption src="/vm/synology/tinycore/tinycore_3_0_1.png" alt={t("step1.tinycore.webAlt")} caption={t("step1.tinycore.webCaption")} />
<p className="text-gray-800 leading-relaxed">{t.rich("step1.tinycore.termRich", { strong })}</p>
<ImageCaption src="/vm/synology/tinycore/tinycore_3_1_1.png" alt={t("step1.tinycore.termAlt")} caption={t("step1.tinycore.termCaption")} />
</div>
)}
</StepSection>
<StepSection n={2} title={t("step2.title")} stepLabel={t("stepBadge")}>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("step2.introRich", { strong })}</p>
{activeLoader === "arc" && (
<ImageCaption src="/vm/synology/arc/arc_1_2_1.png" alt={t("step2.arc.alt")} caption={t("step2.arc.caption")} />
)}
{activeLoader === "rr" && (
<ImageCaption src="/vm/synology/rr/rr_2_2_1.png" alt={t("step2.rr.alt")} caption={t("step2.rr.caption")} />
)}
{activeLoader === "tinycore" && (
<ImageCaption src="/vm/synology/tinycore/tinycore_3_2_1.png" alt={t("step2.tinycore.alt")} caption={t("step2.tinycore.caption")} />
)}
</StepSection>
<StepSection n={3} title={t("step3.title")} stepLabel={t("stepBadge")}>
<p className="mb-4 text-gray-800 leading-relaxed">{t("step3.intro")}</p>
{activeLoader === "arc" && (
<div className="flex flex-col space-y-10">
<ImageCaption src="/vm/synology/arc/arc_1_3_1.png" alt={step3Arc[0].alt} caption={step3Arc[0].caption} />
<ImageCaption src="/vm/synology/arc/arc_1_3_2.png" alt={step3Arc[1].alt} caption={step3Arc[1].caption} />
</div>
)}
{activeLoader === "rr" && (
<div className="flex flex-col space-y-10">
<ImageCaption src="/vm/synology/rr/rr_2_3_1.png" alt={step3Rr[0].alt} caption={step3Rr[0].caption} />
<ImageCaption src="/vm/synology/rr/rr_2_3_2.png" alt={step3Rr[1].alt} caption={step3Rr[1].caption} />
<ImageCaption src="/vm/synology/rr/rr_2_3_3.png" alt={step3Rr[2].alt} caption={step3Rr[2].caption} />
</div>
)}
{activeLoader === "tinycore" && (
<div className="flex flex-col space-y-10">
<ImageCaption src="/vm/synology/tinycore/tinycore_3_3_1.png" alt={step3Tc[0].alt} caption={step3Tc[0].caption} />
<ImageCaption src="/vm/synology/tinycore/tinycore_3_3_2.png" alt={step3Tc[1].alt} caption={step3Tc[1].caption} />
</div>
)}
</StepSection>
<StepSection n={4} title={t("step4.title")} stepLabel={t("stepBadge")}>
<p className="mb-4 text-gray-800 leading-relaxed">{t("step4.intro")}</p>
{activeLoader === "arc" && (
<div className="flex flex-col space-y-10">
<p className="text-gray-800 leading-relaxed">{t.rich("step4.arc.autoRich", { strong })}</p>
<ImageCaption src="/vm/synology/arc/arc_1_4_1.png" alt={t("step4.arc.autoAlt")} caption={t("step4.arc.autoCaption")} />
<p className="text-gray-800 leading-relaxed">{t("step4.arc.manualRich")}</p>
<ImageCaption src="/vm/synology/arc/arc_1_4_2.png" alt={t("step4.arc.manualAlt")} caption={t("step4.arc.manualCaption")} />
<ImageCaption src="/vm/synology/arc/arc_1_4_3.png" alt={t("step4.arc.snMacAlt")} caption={t("step4.arc.snMacCaption")} />
<ImageCaption src="/vm/synology/arc/arc_1_4_4.png" alt={t("step4.arc.portmapAlt")} caption={t("step4.arc.portmapCaption")} />
<ImageCaption src="/vm/synology/arc/arc_1_4_5.png" alt={t("step4.arc.addonsAlt")} caption={t("step4.arc.addonsCaption")} />
</div>
)}
{activeLoader === "rr" && (
<div className="flex flex-col space-y-10">
<ImageCaption src="/vm/synology/rr/rr_2_4_1.png" alt={step4Rr[0].alt} caption={step4Rr[0].caption} />
<ImageCaption src="/vm/synology/rr/rr_2_4_2.png" alt={step4Rr[1].alt} caption={step4Rr[1].caption} />
<ImageCaption src="/vm/synology/rr/rr_2_4_3.png" alt={step4Rr[2].alt} caption={step4Rr[2].caption} />
</div>
)}
{activeLoader === "tinycore" && (
<div className="flex flex-col space-y-10">
<ImageCaption src="/vm/synology/tinycore/tinycore_3_4_1.png" alt={step4Tc[0].alt} caption={step4Tc[0].caption} />
<ImageCaption src="/vm/synology/tinycore/tinycore_3_4_2.png" alt={step4Tc[1].alt} caption={step4Tc[1].caption} />
<ImageCaption src="/vm/synology/tinycore/tinycore_3_4_3.png" alt={step4Tc[2].alt} caption={step4Tc[2].caption} />
<ImageCaption src="/vm/synology/tinycore/tinycore_3_4_4.png" alt={step4Tc[3].alt} caption={step4Tc[3].caption} />
</div>
)}
</StepSection>
<StepSection n={5} title={t("step5.title")} stepLabel={t("stepBadge")}>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("step5.introRich", { strong })}</p>
{activeLoader === "arc" && (
<ImageCaption src="/vm/synology/arc/arc_1_5_1.png" alt={t("step5.arc.alt")} caption={t("step5.arc.caption")} />
)}
{activeLoader === "rr" && (
<ImageCaption src="/vm/synology/rr/rr_2_5_1.png" alt={t("step5.rr.alt")} caption={t("step5.rr.caption")} />
)}
{activeLoader === "tinycore" && (
<ImageCaption src="/vm/synology/tinycore/tinycore_3_5_1.png" alt={t("step5.tinycore.alt")} caption={t("step5.tinycore.caption")} />
)}
</StepSection>
<StepSection n={6} title={t("step6.title")} stepLabel={t("stepBadge")}>
<p className="mb-4 text-gray-800 leading-relaxed">{t("step6.intro")}</p>
{activeLoader === "arc" && (
<ImageCaption src="/vm/synology/arc/arc_1_6_1.png" alt={t("step6.arc.alt")} caption={t("step6.arc.caption")} />
)}
{activeLoader === "rr" && (
<ImageCaption src="/vm/synology/rr/rr_2_6_1.png" alt={t("step6.rr.alt")} caption={t("step6.rr.caption")} />
)}
{activeLoader === "tinycore" && (
<ImageCaption src="/vm/synology/tinycore/tinycore_3_6_1.png" alt={t("step6.tinycore.alt")} caption={t("step6.tinycore.caption")} />
)}
</StepSection>
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("dsmInstall.heading")}</h2>
<p className="mb-3 text-gray-800 leading-relaxed">{t("dsmInstall.intro")}</p>
<pre className="bg-gray-100 p-3 rounded-md overflow-x-auto text-sm font-mono mb-4">
<code>https://finds.synology.com</code>
</pre>
<p className="mb-6 text-gray-800 leading-relaxed">{t("dsmInstall.afterCode")}</p>
<div className="flex flex-col space-y-10">
<ImageCaption src="/vm/synology/install_DSM.png" alt={t("dsmInstall.setupAlt")} caption={t("dsmInstall.setupCaption")} />
<p className="text-gray-800 leading-relaxed">{t("dsmInstall.patience")}</p>
<ImageCaption src="/vm/synology/finish_install_DSM.png" alt={t("dsmInstall.finishAlt")} caption={t("dsmInstall.finishCaption")} />
</div>
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("tips.heading")}</h2>
<Callout variant="tip" title={t("tips.recentTitle")}>
{t("tips.recentBody")}
</Callout>
<Callout variant="info" title={t("tips.updateTitle")}>
{t("tips.updateBody")}
</Callout>
<Callout variant="warning" title={t("tips.warnTitle")}>
{t("tips.warnBody")}
</Callout>
<h3 className="text-lg font-semibold mt-8 mb-3 text-gray-900">{t("tips.docsHeading")}</h3>
<div className="flex flex-wrap gap-2">
{docLinks.map((dl) => (
<a
key={dl.url}
href={dl.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center px-3 py-2 text-sm font-medium rounded-md bg-gray-100 text-gray-800 hover:bg-gray-200 transition-colors"
>
<Github className="h-4 w-4 mr-2" />
{dl.label}
</a>
))}
</div>
<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>
)
}
function ImageCaption({ src, alt, caption }: { src: string; alt: string; caption: string }) {
return (
<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={src}
alt={alt}
width={768}
height={0}
style={{ height: "auto" }}
className="w-full object-contain"
sizes="(max-width: 768px) 100vw, 768px"
/>
</div>
<span className="mt-2 text-sm text-gray-600">{caption}</span>
</div>
)
}
function StepSection({ n, title, stepLabel, children }: { n: number; title: string; stepLabel: string; children: React.ReactNode }) {
return (
<section className="mt-10 border-b border-gray-200 pb-8">
<div className="flex items-center gap-3 mb-4">
<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">
{stepLabel} {n}
</span>
<h3 className="text-xl font-semibold text-gray-900 m-0">{title}</h3>
</div>
{children}
</section>
)
}

View File

@@ -0,0 +1,338 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import Image from "next/image"
import { ExternalLink, Database, Server, HardDrive, MonitorIcon } 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.createVm.systemNas.systemNasOthers.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/create-vm/system-nas/system-nas-others",
images: [
{
url: "/vm/system-nas-menu.png",
width: 1200,
height: 630,
alt: t("ogImageAlt"),
},
],
},
}
}
type DefaultRow = { param: string; valueRich: string }
type AdvancedRow = { param: string; optionsRich: string }
type RelatedItem = { href: string; label: string; tail: string }
type SystemEntry = {
id: string
title: string
icon: string
officialName: string
officialUrl: string
description: string
specs: string[]
shellImg?: string
webImg?: string
shellAlt?: string
webAlt?: string
}
const ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
Database,
Server,
HardDrive,
MonitorIcon,
}
export default async function OtherNASSystemsPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.createVm.systemNas.systemNasOthers" })
const messages = (await getMessages({ locale })) as unknown as {
docs: {
createVm: {
systemNas: {
systemNasOthers: {
config: { defaultRowsRich: DefaultRow[]; advancedRowsRich: AdvancedRow[] }
storagePlan: { virtualItemsRich: string[]; importItemsRich: string[]; pciItemsRich: string[] }
endToEnd: { itemsRich: string[] }
systems: Record<string, SystemEntry>
related: { itemsRich: RelatedItem[] }
}
}
}
}
}
const o = messages.docs.createVm.systemNas.systemNasOthers
const defaultRows = o.config.defaultRowsRich
const advancedRows = o.config.advancedRowsRich
const virtualItems = o.storagePlan.virtualItemsRich
const importItems = o.storagePlan.importItemsRich
const pciItems = o.storagePlan.pciItemsRich
const endToEndItems = o.endToEnd.itemsRich
const systemOrder = ["truenasScale", "truenasCore", "openmediavault", "xigmanas", "rockstor", "zimaos"]
const relatedItems = o.related.itemsRich
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 note = (chunks: React.ReactNode) => <span className="text-xs text-gray-500">{chunks}</span>
const gpuLink = (chunks: React.ReactNode) => (
<a href="/docs/hardware/gpu-vm-passthrough" className="text-blue-600 hover:underline">
{chunks}
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={15}
scriptPath="vm/select_nas_iso.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.bodyRich", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("config.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("config.intro")}</p>
<h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("config.defaultHeading")}</h3>
<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("config.headerParam")}</th>
<th className="px-4 py-2 font-semibold">{t("config.headerValue")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-800">
{defaultRows.map((row, idx) => (
<tr key={row.param}>
<td className="px-4 py-2">{row.param}</td>
<td className="px-4 py-2">{t.rich(`config.defaultRowsRich.${idx}.valueRich`, { code, note })}</td>
</tr>
))}
</tbody>
</table>
</div>
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("config.advancedHeading")}</h3>
<p className="mb-3 text-gray-800 leading-relaxed">{t("config.advancedIntro")}</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("config.headerParam")}</th>
<th className="px-4 py-2 font-semibold">{t("config.headerOptions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-800">
{advancedRows.map((row, idx) => (
<tr key={row.param}>
<td className="px-4 py-2">{row.param}</td>
<td className="px-4 py-2">{t.rich(`config.advancedRowsRich.${idx}.optionsRich`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="tip" title={t("config.zfsCalloutTitle")}>
{t("config.zfsCalloutBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("storagePlan.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("storagePlan.intro")}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 my-6">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.virtualHeading")}</h3>
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
{virtualItems.map((_, idx) => (
<li key={idx}>{t.rich(`storagePlan.virtualItemsRich.${idx}`, { code })}</li>
))}
</ul>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.importHeading")}</h3>
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
{importItems.map((_, idx) => (
<li key={idx}>{t.rich(`storagePlan.importItemsRich.${idx}`, { code })}</li>
))}
</ul>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4 md:col-span-2">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.pciHeading")}</h3>
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
{pciItems.map((_, idx) => (
<li key={idx}>{t.rich(`storagePlan.pciItemsRich.${idx}`, { code, em })}</li>
))}
</ul>
</div>
</div>
<Callout variant="info" title={t("storagePlan.resetCalloutTitle")}>
{t.rich("storagePlan.resetCalloutBodyRich", { strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("gpu.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("gpu.bodyRich", { link: gpuLink })}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("autoFeatures.heading")}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 my-4">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("autoFeatures.efiTitle")}</h3>
<p className="text-sm text-gray-700 leading-relaxed">{t("autoFeatures.efiBody")}</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("autoFeatures.isoTitle")}</h3>
<p className="text-sm text-gray-700 leading-relaxed">{t.rich("autoFeatures.isoBodyRich", { code })}</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("autoFeatures.guestTitle")}</h3>
<p className="text-sm text-gray-700 leading-relaxed">{t("autoFeatures.guestBody")}</p>
</div>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("endToEnd.heading")}</h2>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{endToEndItems.map((_, idx) => (
<li key={idx}>{t.rich(`endToEnd.itemsRich.${idx}`, { code })}</li>
))}
</ol>
<h2 className="text-2xl font-semibold mt-12 mb-6 text-gray-900">{t("perSystem.heading")}</h2>
{systemOrder.map((key) => {
const sys = o.systems[key]
const Icon = ICONS[sys.icon] ?? Database
return (
<NASSection
key={sys.id}
id={sys.id}
title={sys.title}
icon={<Icon className="h-6 w-6 text-blue-500" />}
officialName={sys.officialName}
officialUrl={sys.officialUrl}
description={sys.description}
specs={sys.specs}
shellImg={sys.shellImg}
webImg={sys.webImg}
shellAlt={sys.shellAlt}
webAlt={sys.webAlt}
shellLabel={t("perSystem.shellLabel")}
webLabel={t("perSystem.webLabel")}
/>
)
})}
<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>
)
}
interface NASSectionProps {
id: string
title: string
icon: React.ReactNode
officialName: string
officialUrl: string
description: string
specs: string[]
shellImg?: string
webImg?: string
shellAlt?: string
webAlt?: string
shellLabel: string
webLabel: string
}
function NASSection({
id,
title,
icon,
officialName,
officialUrl,
description,
specs,
shellImg,
webImg,
shellAlt,
webAlt,
shellLabel,
webLabel,
}: NASSectionProps) {
return (
<section id={id} className="mt-10 scroll-mt-24 border-b border-gray-200 pb-8">
<h3 className="text-xl font-semibold mb-3 flex items-center flex-wrap gap-2 text-gray-900">
{icon}
<span>{title}</span>
<a
href={officialUrl}
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-sm font-normal text-blue-600 hover:underline inline-flex items-center gap-1"
>
{officialName} <ExternalLink className="h-3.5 w-3.5" />
</a>
</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{description}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{specs.map((s, i) => (
<li key={i}>{s}</li>
))}
</ul>
{(shellImg || webImg) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
{shellImg && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">{shellLabel}</h4>
<div className="overflow-hidden rounded-md border border-gray-200">
<Image src={shellImg} alt={shellAlt ?? `${title} shell interface`} width={600} height={400} className="w-full object-contain" />
</div>
</div>
)}
{webImg && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">{webLabel}</h4>
<div className="overflow-hidden rounded-md border border-gray-200">
<Image src={webImg} alt={webAlt ?? `${title} web interface`} width={600} height={400} className="w-full object-contain" />
</div>
</div>
)}
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,314 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import Image from "next/image"
import { ExternalLink, Server } 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.createVm.systemWindows.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/create-vm/system-windows",
images: [
{
url: "/vm/menu_windows.png",
width: 1200,
height: 630,
alt: t("ogImageAlt"),
},
],
},
}
}
type ConfigRow = { param: string; value?: string; valueRich?: string; options?: string; optionsRich?: string }
type StringItem = string
type VirtioStep = { title: string; body?: string; bodyRich?: string; img: string; caption: string }
type RelatedItem = { href: string; label: string; tail?: string }
export default async function SystemWindowsPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.createVm.systemWindows" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { createVm: { systemWindows: {
config: { defaultRows: ConfigRow[]; advancedRows: ConfigRow[] }
storagePlan: { virtualDiskItems: StringItem[]; importDiskItems: StringItem[]; pciItems: StringItem[] }
installOptions: { uupItems: StringItem[] }
endToEnd: { items: StringItem[] }
virtio: { steps: VirtioStep[] }
related: { items: RelatedItem[] }
} } }
}
const defaultRows = messages.docs.createVm.systemWindows.config.defaultRows
const advancedRows = messages.docs.createVm.systemWindows.config.advancedRows
const virtualDiskItems = messages.docs.createVm.systemWindows.storagePlan.virtualDiskItems
const importDiskItems = messages.docs.createVm.systemWindows.storagePlan.importDiskItems
const pciItems = messages.docs.createVm.systemWindows.storagePlan.pciItems
const uupItems = messages.docs.createVm.systemWindows.installOptions.uupItems
const endToEndItems = messages.docs.createVm.systemWindows.endToEnd.items
const virtioSteps = messages.docs.createVm.systemWindows.virtio.steps
const relatedItems = messages.docs.createVm.systemWindows.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 gpuLink = (chunks: React.ReactNode) => (
<a href="/docs/hardware/gpu-vm-passthrough" className="text-blue-600 hover:underline">{chunks}</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={20}
scriptPath="vm/select_windows_iso.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code })}
</Callout>
<div className="flex flex-col items-center my-6">
<div className="w-full max-w-[768px] overflow-hidden rounded-md border border-gray-200">
<Image
src="/vm/menu_windows.png"
alt={t("image.alt")}
width={768}
height={0}
style={{ height: "auto" }}
className="w-full object-contain"
sizes="(max-width: 768px) 100vw, 768px"
/>
</div>
<span className="mt-2 text-sm text-gray-600">{t("image.caption")}</span>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("config.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("config.intro")}</p>
<h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("config.defaultHeading")}</h3>
<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("config.headerParam")}</th>
<th className="px-4 py-2 font-semibold">{t("config.headerValue")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-800">
{defaultRows.map((row, idx) => (
<tr key={row.param}>
<td className="px-4 py-2">{row.param}</td>
<td className="px-4 py-2">
{row.valueRich ? t.rich(`config.defaultRows.${idx}.valueRich`, { code }) : row.value}
</td>
</tr>
))}
</tbody>
</table>
</div>
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("config.advancedHeading")}</h3>
<p className="mb-3 text-gray-800 leading-relaxed">{t("config.advancedIntro")}</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("config.headerParam")}</th>
<th className="px-4 py-2 font-semibold">{t("config.headerOptions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-800">
{advancedRows.map((row, idx) => (
<tr key={row.param}>
<td className="px-4 py-2">{row.param}</td>
<td className="px-4 py-2">
{row.optionsRich ? t.rich(`config.advancedRows.${idx}.optionsRich`, { code }) : row.options}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="warning" title={t("config.tpmWarnTitle")}>
{t("config.tpmWarnBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("storagePlan.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("storagePlan.body", { strong })}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 my-6">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.virtualDiskTitle")}</h3>
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
{virtualDiskItems.map((_, idx) => (
<li key={idx}>{t.rich(`storagePlan.virtualDiskItems.${idx}`, { code })}</li>
))}
</ul>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.importDiskTitle")}</h3>
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
{importDiskItems.map((_, idx) => (
<li key={idx}>{t.rich(`storagePlan.importDiskItems.${idx}`, { code })}</li>
))}
</ul>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4 md:col-span-2">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.pciTitle")}</h3>
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
{pciItems.map((_, idx) => (
<li key={idx}>{t.rich(`storagePlan.pciItems.${idx}`, { em, code })}</li>
))}
</ul>
</div>
</div>
<Callout variant="info" title={t("storagePlan.resetTitle")}>
{t.rich("storagePlan.resetBody", { strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("gpu.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("gpu.body", { gpuLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("autoFeatures.heading")}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 my-4">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("autoFeatures.efiTitle")}</h3>
<p className="text-sm text-gray-700 leading-relaxed">{t("autoFeatures.efiBody")}</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("autoFeatures.tpmTitle")}</h3>
<p className="text-sm text-gray-700 leading-relaxed">{t("autoFeatures.tpmBody")}</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("autoFeatures.isoTitle")}</h3>
<p className="text-sm text-gray-700 leading-relaxed">{t.rich("autoFeatures.isoBody", { code, em })}</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("autoFeatures.guestTitle")}</h3>
<p className="text-sm text-gray-700 leading-relaxed">{t.rich("autoFeatures.guestBody", { code })}</p>
</div>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("installOptions.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("installOptions.intro")}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 my-6">
<div className="rounded-lg border border-gray-200 bg-white p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 flex items-center justify-center rounded-md bg-blue-50">
<Image src="https://uupdump.net/static/uupdump/1.1.0/img/logo.svg" alt={t("installOptions.uupLogoAlt")} width={40} height={40} className="object-contain" />
</div>
<h3 className="text-lg font-semibold text-gray-900 m-0">{t("installOptions.uupTitle")}</h3>
</div>
<p className="text-sm text-gray-700 leading-relaxed mb-4">
{t.rich("installOptions.uupBody", { strong })}
</p>
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1 mb-4">
{uupItems.map((_, idx) => (
<li key={idx}>{t(`installOptions.uupItems.${idx}`)}</li>
))}
</ul>
<Link href="/docs/utils/UUp-Dump-ISO-Creator" className="inline-flex items-center gap-1 text-sm text-blue-600 hover:underline">
{t("installOptions.uupLearnMore")}
<ExternalLink className="h-3.5 w-3.5" />
</Link>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 flex items-center justify-center rounded-md bg-blue-50 text-blue-600">
<Server className="h-7 w-7" />
</div>
<h3 className="text-lg font-semibold text-gray-900 m-0">{t("installOptions.localTitle")}</h3>
</div>
<p className="text-sm text-gray-700 leading-relaxed mb-4">
{t.rich("installOptions.localBody", { code })}
</p>
<div className="mt-4 overflow-hidden rounded-md border border-gray-200">
<Image src="/vm/local-store-windows.png" alt={t("installOptions.localImageAlt")} width={600} height={400} className="w-full object-contain" />
</div>
<p className="mt-2 text-xs text-gray-600">{t("installOptions.localImageCaption")}</p>
</div>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("endToEnd.heading")}</h2>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{endToEndItems.map((_, idx) => (
<li key={idx}>{t.rich(`endToEnd.items.${idx}`, { code })}</li>
))}
</ol>
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("virtio.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("virtio.body", { code })}
</p>
<Callout variant="warning" title={t("virtio.warnTitle")}>
{t.rich("virtio.warnBody", { code })}
</Callout>
{virtioSteps.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("virtio.stepLabel")} {idx + 1}
</span>
<h3 className="text-lg font-semibold text-gray-900 m-0">{step.title}</h3>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">
{step.bodyRich ? t.rich(`virtio.steps.${idx}.bodyRich`, { strong, code }) : step.body}
</p>
<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} width={768} height={0} style={{ height: "auto" }} className="w-full object-contain" sizes="(max-width: 768px) 100vw, 768px" />
</div>
<span className="mt-2 text-sm text-gray-600">{step.caption}</span>
</div>
</section>
))}
<Callout variant="tip" title={t("virtio.tipTitle")}>
{t.rich("virtio.tipBody", { 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,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>
)
}

View File

@@ -0,0 +1,119 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
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.externalRepositories.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/external-repositories",
},
}
}
type RepoItem = { name: string; url: string; description: string; usedIn: string }
export default async function ExternalRepositoriesPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.externalRepositories" })
const messages = (await getMessages({ locale })) as unknown as {
docs: {
externalRepositories: {
integrated: { items: RepoItem[] }
attribution: { items: string[] }
candidate: { items: string[] }
}
}
}
const block = messages.docs.externalRepositories
const repos = block.integrated.items
const attributionItems = block.attribution.items
const candidateItems = block.candidate.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}
/>
<Callout variant="info" title={t("practice.title")}>
{t("practice.body")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("integrated.heading")}</h2>
<div className="space-y-4 mb-8">
{repos.map((r) => (
<a
key={r.url}
href={r.url}
target="_blank"
rel="noopener noreferrer"
className="block rounded-md border border-gray-200 bg-white p-4 transition-colors hover:border-blue-400 hover:bg-blue-50"
>
<div className="font-semibold text-gray-900 mb-1">{r.name}</div>
<div className="text-sm text-gray-700 leading-snug mb-2">{r.description}</div>
<div className="text-xs text-gray-500">
<strong>{t("integrated.usedInLabel")}</strong> {r.usedIn}
</div>
</a>
))}
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("attribution.heading")}</h2>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{attributionItems.map((_, idx) => (
<li key={idx}>{t(`attribution.items.${idx}`)}</li>
))}
</ul>
<Callout variant="warning" title={t("report.title")}>
{t.rich("report.body", { strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("suggest.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("suggest.intro")}</p>
<div className="grid gap-3 md:grid-cols-2 mb-6 not-prose">
<a
href="https://github.com/MacRimi/ProxMenux/discussions"
target="_blank"
rel="noopener noreferrer"
className="block rounded-md border border-gray-200 bg-white p-4 transition-colors hover:border-blue-400 hover:bg-blue-50"
>
<div className="font-semibold text-gray-900 mb-1">{t("suggest.discussionTitle")}</div>
<div className="text-xs text-gray-600">{t("suggest.discussionBody")}</div>
</a>
<a
href="https://github.com/MacRimi/ProxMenux/issues"
target="_blank"
rel="noopener noreferrer"
className="block rounded-md border border-gray-200 bg-white p-4 transition-colors hover:border-blue-400 hover:bg-blue-50"
>
<div className="font-semibold text-gray-900 mb-1">{t("suggest.issueTitle")}</div>
<div className="text-xs text-gray-600">{t("suggest.issueBody")}</div>
</a>
</div>
<Callout variant="tip" title={t("candidate.title")}>
<ul className="list-disc pl-6 mb-0 space-y-1">
{candidateItems.map((_, idx) => (
<li key={idx}>{t(`candidate.items.${idx}`)}</li>
))}
</ul>
</Callout>
</div>
)
}

View File

@@ -0,0 +1,175 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { ExternalLink } 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.glossary.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/glossary",
},
}
}
type Category = "Proxmox" | "Virtualization" | "Storage" | "Network" | "Linux" | "ProxMenux"
type SeeAlso = { label: string; href: string }
type GlossaryEntry = {
term: string
aliases?: string[]
category: Category
definitionRich: string
seeAlso?: SeeAlso[]
}
const categoryColor: Record<Category, string> = {
Proxmox: "bg-red-50 text-red-700 border-red-200",
Virtualization: "bg-blue-50 text-blue-700 border-blue-200",
Storage: "bg-amber-50 text-amber-700 border-amber-200",
Network: "bg-emerald-50 text-emerald-700 border-emerald-200",
Linux: "bg-purple-50 text-purple-700 border-purple-200",
ProxMenux: "bg-gray-100 text-gray-700 border-gray-300",
}
function CategoryBadge({ category }: { category: Category }) {
return (
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${categoryColor[category]}`}
>
{category}
</span>
)
}
export default async function GlossaryPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.glossary" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { glossary: { entries: GlossaryEntry[] } }
}
const entries = messages.docs.glossary.entries
const sortedEntries = [...entries].sort((a, b) =>
a.term.localeCompare(b.term, "en", { sensitivity: "base" })
)
const lettersInUse = Array.from(
new Set(sortedEntries.map((e) => e.term[0].toUpperCase()))
).sort()
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 ext = (chunks: React.ReactNode) => (
<a
href="https://github.com/MacRimi/ProxMenux/issues"
target="_blank"
rel="noopener noreferrer"
className="text-emerald-700 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={5}
/>
<Callout variant="info" title={t("callout.title")}>
{t.rich("callout.bodyRich", { em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("jumpHeading")}</h2>
<div className="flex flex-wrap gap-1.5 mb-8 not-prose">
{lettersInUse.map((letter) => (
<a
key={letter}
href={`#letter-${letter}`}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-gray-200 bg-white text-sm font-medium text-gray-700 hover:border-blue-400 hover:bg-blue-50 hover:text-blue-700 transition-colors"
>
{letter}
</a>
))}
</div>
<div className="space-y-6">
{sortedEntries.map((entry, i) => {
const letter = entry.term[0].toUpperCase()
const prevLetter = i > 0 ? sortedEntries[i - 1].term[0].toUpperCase() : null
const isFirstOfLetter = letter !== prevLetter
const entryIdx = entries.findIndex((e) => e.term === entry.term)
return (
<div key={entry.term}>
{isFirstOfLetter && (
<h2
id={`letter-${letter}`}
className="text-3xl font-bold text-gray-900 mt-12 mb-4 scroll-mt-24 border-b border-gray-200 pb-2"
>
{letter}
</h2>
)}
<article id={`term-${entry.term.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`} className="scroll-mt-24">
<div className="flex flex-wrap items-baseline gap-2 mb-2">
<h3 className="text-lg font-semibold text-gray-900 m-0">{entry.term}</h3>
<CategoryBadge category={entry.category} />
{entry.aliases && entry.aliases.length > 0 && (
<span className="text-xs text-gray-500 italic">
{t("aliasesLabel")} {entry.aliases.join(" · ")}
</span>
)}
</div>
<p className="text-sm text-gray-800 leading-relaxed mb-2">
{t.rich(`entries.${entryIdx}.definitionRich`, { code, strong, em })}
</p>
{entry.seeAlso && entry.seeAlso.length > 0 && (
<p className="text-xs text-gray-600 m-0">
<span className="font-medium text-gray-700">{t("seeAlsoLabel")}</span>{" "}
{entry.seeAlso.map((s, idx) => (
<span key={s.href}>
<Link href={s.href} className="text-blue-600 hover:underline">
{s.label}
</Link>
{idx < entry.seeAlso!.length - 1 && " · "}
</span>
))}
</p>
)}
</article>
</div>
)
})}
</div>
<Callout variant="tip" title={t("missingCallout.title")} className="mt-12">
{t.rich("missingCallout.leadRich", { ext, code })}
</Callout>
</div>
)
}

View File

@@ -0,0 +1,377 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { ExternalLink } from "lucide-react"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
import { Prerequisites } from "@/components/ui/prerequisites"
import Image from "next/image"
import { Steps } from "@/components/ui/steps"
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.hardware.coralTpuLxc.meta" })
return {
title: t("title"),
description: t("description"),
}
}
type RelatedItem = { label: string; href: string; tail?: string }
type DriverItem = string
export default async function AddCoralTPUtoLXC({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.hardware.coralTpuLxc" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { hardware: { coralTpuLxc: {
walkthrough: { drivers: { items: DriverItem[] } }
related: { items: RelatedItem[] }
} } }
}
const driverItems = messages.docs.hardware.coralTpuLxc.walkthrough.drivers.items
const relatedItems = messages.docs.hardware.coralTpuLxc.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 frigateLink = (chunks: React.ReactNode) => (
<a
href="https://frigate.video/"
target="_blank"
rel="noopener noreferrer"
className="text-blue-700 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
const hostLink = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/install-coral-tpu-host" className="text-blue-700 hover:underline">{chunks}</Link>
)
const lxcGpuLink = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/igpu-acceleration-lxc" className="text-blue-700 hover:underline">{chunks}</Link>
)
const coralLink = (chunks: React.ReactNode) => (
<a
href="https://coral.ai/docs/accelerator/get-started/"
target="_blank"
rel="noopener noreferrer"
className="text-blue-700 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={8}
scriptPath="gpu_tpu/install_coral_lxc.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code, strong, lxcGpuLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whenUse.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("whenUse.body", { frigateLink })}
</p>
<Prerequisites
title={t("prereqs.title")}
items={[
{
label: <>{t.rich("prereqs.drivers", { strong, code, hostLink })}</>,
check: t("prereqs.driversCheck"),
},
{
label: <>{t.rich("prereqs.container", { strong, code })}</>,
},
{
label: <>{t.rich("prereqs.downtime", { strong })}</>,
},
]}
/>
<Callout variant="warning" title={t("hostPrep.title")}>
{t.rich("hostPrep.body", { code, strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("running.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("running.body", { strong })}
</p>
<Image
src="/gpu-tpu/coral-lxc-01-menu-entry.png"
alt={t("running.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("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">
{`┌────────────────────────────────────────────────┐
│ 1. User picks the LXC container │
│ (pct list → dialog → CTID) │
└────────────────┬───────────────────────────────┘
Stop container if running
┌─────────┴──────────┐
│ │
▼ ▼
Coral M.2/PCIe? Coral USB?
lspci "Global (udev rule creates
Unichip" /dev/coral symlink
│ on the host)
│ │
Yes Yes
│ │
▼ ▼
/dev/apex_0 Write udev rule
exists? /etc/udev/rules.d/
│ 99-coral-usb.rules
├─ Yes → (ATTRS idVendor/idProduct
│ dev<N>: → SYMLINK /dev/coral)
│ /dev/apex_0 │
│ gid=apex ▼
│ Append to LXC config:
└─ No → lxc.cgroup2.devices.allow:
cgroup2 c 189:* rwm
fallback lxc.mount.entry:
(major 245 /dev/bus/usb dev/bus/usb
from /proc/ none bind,optional,
devices) create=dir
│ (bind the WHOLE usb tree,
▼ not /dev/coral — survives
cgroup2 USB replug to other port)
+ mount │
└──────────────┬──────┘
Clean up duplicate entries in the config
┌──────────────┴──────────────┐
│ Start container + wait │
│ up to 15s for readiness │
└──────────────┬──────────────┘
pct exec inside container:
├─ apt-get update
├─ Install Coral deps (gnupg, curl, ca-certificates)
├─ Add Google Coral APT repo
│ /etc/apt/keyrings/coral-edgetpu.gpg
│ /etc/apt/sources.list.d/coral-edgetpu.list
└─ apt install libedgetpu1-std
(or libedgetpu1-max for M.2 if user picks)
Show summary (what was enabled)
Container stays running
Note: iGPU passthrough (Quick Sync / VA-API
/ NVENC) is now handled exclusively by
"Add GPU to LXC" — run it BEFORE this script
if you also want hardware video decode.`}
</pre>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("walkthrough.heading")}</h2>
<Steps>
<Steps.Step title={t("walkthrough.pick.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.pick.body", { code })}</p>
</Steps.Step>
<Steps.Step title={t("walkthrough.gpuHint.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.gpuHint.body", { code, lxcGpuLink })}</p>
</Steps.Step>
<Steps.Step title={t("walkthrough.usb.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.usb.body", { code, strong })}</p>
<CopyableCode
code={`# /etc/udev/rules.d/99-coral-usb.rules (on the host)
SUBSYSTEM=="usb", ATTRS{idVendor}=="1a6e", ATTRS{idProduct}=="089a", \\
SYMLINK+="coral", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="18d1", ATTRS{idProduct}=="9302", \\
SYMLINK+="coral", MODE="0666"
# Appended to /etc/pve/lxc/<ctid>.conf
lxc.cgroup2.devices.allow: c 189:* rwm
lxc.mount.entry: /dev/bus/usb dev/bus/usb none bind,optional,create=dir`}
className="my-4"
/>
<Callout variant="tip" title={t("walkthrough.usb.whyTitle")}>
{t.rich("walkthrough.usb.whyBody", { code })}
</Callout>
</Steps.Step>
<Steps.Step title={t("walkthrough.pcie.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.pcie.body1", { code })}</p>
<CopyableCode
code={`# Appended to /etc/pve/lxc/<ctid>.conf — modern path
dev0: /dev/apex_0,gid=<APEX_GID>`}
className="my-4"
/>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.pcie.body2", { code, hostLink })}</p>
<CopyableCode
code={`# Fallback when /dev/apex_0 isn't yet present on host
lxc.cgroup2.devices.allow: c 245:0 rwm
lxc.mount.entry: /dev/apex_0 dev/apex_0 none bind,optional,create=file`}
className="my-4"
/>
<Callout variant="troubleshoot" title={t("walkthrough.pcie.rebootTitle")}>
{t.rich("walkthrough.pcie.rebootBody", { code })}
</Callout>
</Steps.Step>
<Steps.Step title={t("walkthrough.drivers.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.drivers.body", { code })}</p>
<ol className="list-decimal pl-6 space-y-1 text-gray-800 mb-3">
{driverItems.map((_, idx) => (
<li key={idx}>{t.rich(`walkthrough.drivers.items.${idx}`, { code })}</li>
))}
</ol>
</Steps.Step>
<Steps.Step title={t("walkthrough.summary.title")}>
<p className="mb-3 text-gray-800">{t("walkthrough.summary.body")}</p>
</Steps.Step>
</Steps>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("manual.body")}</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("manual.usbHeading")}</h3>
<CopyableCode
code={`# On the HOST — persistent udev alias
cat > /etc/udev/rules.d/99-coral-usb.rules <<'EOF'
SUBSYSTEM=="usb", ATTRS{idVendor}=="1a6e", ATTRS{idProduct}=="089a", SYMLINK+="coral", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="18d1", ATTRS{idProduct}=="9302", SYMLINK+="coral", MODE="0666"
EOF
udevadm control --reload-rules
udevadm trigger --subsystem-match=usb
# Append to /etc/pve/lxc/<ctid>.conf
# (container must be stopped to apply)
lxc.cgroup2.devices.allow: c 189:* rwm
lxc.mount.entry: /dev/bus/usb dev/bus/usb none bind,optional,create=dir`}
className="my-4"
/>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("manual.pcieHeading")}</h3>
<CopyableCode
code={`# On the HOST — PVE dev API (works in privileged AND unprivileged CTs)
# Append to /etc/pve/lxc/<ctid>.conf
dev0: /dev/apex_0,gid=$(getent group apex | cut -d: -f3)
# ─── OPTIONAL — fallback path ────────────────────────────────────
# Only use this block if /dev/apex_0 doesn't exist yet on the host
# (apex module not loaded — reboot still pending). The PVE dev API
# above is preferred when the device is present.
# ─────────────────────────────────────────────────────────────────
# lxc.cgroup2.devices.allow: c 245:0 rwm
# lxc.mount.entry: /dev/apex_0 dev/apex_0 none bind,optional,create=file`}
className="my-4"
/>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("manual.runtimeHeading")}</h3>
<CopyableCode
code={`# Assumes Debian / Ubuntu
apt-get update
apt-get install -y gnupg curl ca-certificates
# Google Coral APT repo
curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg \\
| gpg --dearmor -o /usr/share/keyrings/coral-edgetpu.gpg
echo 'deb [signed-by=/usr/share/keyrings/coral-edgetpu.gpg] https://packages.cloud.google.com/apt coral-edgetpu-stable main' \\
> /etc/apt/sources.list.d/coral-edgetpu.list
apt-get update
apt-get install -y libedgetpu1-std
# Or for M.2 + maximum performance (runs hotter):
# apt-get install -y libedgetpu1-max`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("verification.heading")}</h2>
<p className="mb-3 text-gray-800 leading-relaxed">{t("verification.body")}</p>
<CopyableCode
code={`pct enter <ctid>
# USB Coral
lsusb | grep -E '1a6e:089a|18d1:9302'
ls /dev/bus/usb/
# M.2 Coral
ls -l /dev/apex_0
# Expect: crw-rw---- 1 root apex ... /dev/apex_0
# Runtime installed
dpkg -l libedgetpu1-std
# Frigate-style test: run a quick Python inference
python3 -c "from pycoral.utils.edgetpu import list_edge_tpus; print(list_edge_tpus())"
# Expect a non-empty list with at least one device`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.apexTitle")}>
{t.rich("troubleshoot.apexBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.replugTitle")}>
{t.rich("troubleshoot.replugBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.alpineTitle")}>
{t.rich("troubleshoot.alpineBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.frigateTitle")}>
{t.rich("troubleshoot.frigateBody", { code })}
</Callout>
<Callout variant="tip" title={t("troubleshoot.logsTitle")}>
{t.rich("troubleshoot.logsBody", { 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,480 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import Image from "next/image"
import { ExternalLink } from "lucide-react"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
import { Prerequisites } from "@/components/ui/prerequisites"
import { Steps } from "@/components/ui/steps"
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.hardware.gpuVmPassthrough.meta" })
return {
title: t("title"),
description: t("description"),
}
}
type StringItem = string
type RelatedItem = { label: string; href: string; tail?: string }
export default async function GpuVmPassthroughPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.hardware.gpuVmPassthrough" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { hardware: { gpuVmPassthrough: {
walkthrough: {
preflight: { items: StringItem[] }
switchMode: { items: StringItem[] }
hostApply: { items: StringItem[] }
}
related: { items: RelatedItem[] }
} } }
}
const preflightItems = messages.docs.hardware.gpuVmPassthrough.walkthrough.preflight.items
const switchModeItems = messages.docs.hardware.gpuVmPassthrough.walkthrough.switchMode.items
const hostApplyItems = messages.docs.hardware.gpuVmPassthrough.walkthrough.hostApply.items
const relatedItems = messages.docs.hardware.gpuVmPassthrough.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 pveLink = (chunks: React.ReactNode) => (
<a
href="https://pve.proxmox.com/wiki/PCI(e)_Passthrough"
target="_blank"
rel="noopener noreferrer"
className="text-blue-700 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
const lxcLink = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/igpu-acceleration-lxc" className="text-blue-700 hover:underline">{chunks}</Link>
)
const nvidiaLink = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/nvidia-host" className="text-blue-700 hover:underline">{chunks}</Link>
)
const postLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/virtualization" className="text-blue-700 hover:underline">{chunks}</Link>
)
const vendorResetLink = (chunks: React.ReactNode) => (
<a
href="https://github.com/gnif/vendor-reset"
target="_blank"
rel="noopener noreferrer"
className="text-blue-700 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
const sriovLink = (chunks: React.ReactNode) => (
<a
href="https://github.com/strongtz/i915-sriov-dkms"
target="_blank"
rel="noopener noreferrer"
className="text-blue-700 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={15}
scriptPath="gpu_tpu/add_gpu_vm.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code, em, pveLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("who.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("who.body", { strong, em, lxcLink })}
</p>
<Prerequisites
title={t("prereqs.title")}
items={[
{ label: <>{t.rich("prereqs.gpu", { strong, code })}</>, check: t("prereqs.gpuCheck") },
{ label: <>{t.rich("prereqs.iommu", { strong })}</> },
{ label: <>{t.rich("prereqs.q35", { strong, code })}</>, check: t("prereqs.q35Check") },
{ label: <>{t.rich("prereqs.moreGpus", { strong, em })}</> },
{ label: <>{t.rich("prereqs.nvidiaInstalled", { nvidiaLink, code, strong })}</> },
]}
/>
<Callout variant="warning" title={t("pickOne.title")}>
{t.rich("pickOne.body", { code })}
</Callout>
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-4">
<li>{t.rich("pickOne.vmItem", { strong })}</li>
<li>{t.rich("pickOne.lxcItem", { strong, lxcLink })}</li>
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("running.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("running.body", { strong })}
</p>
<Image
src="/gpu-tpu/gpu-vm-01-menu-entry.png"
alt={t("running.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("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 — Gather info, validate, confirm │
│ (nothing touched yet) │
└──────────────────┬──────────────────────────┘
┌────────────┴────────────┐
▼ ▼
lspci detects IOMMU enabled?
GPUs (Intel/AMD/ ├─ No → offer to add
NVIDIA) │ intel_iommu=on /
│ │ amd_iommu=on
▼ └─ Yes → continue
User selects GPU
Pre-flight checks
├─ Not in SR-IOV
├─ Not D3cold (AMD)
├─ Has FLR or equivalent reset
├─ Warn if single-GPU host
└─ Resolve IOMMU group
Audio companion
├─ Has .1 sibling? (dGPU: NVIDIA/AMD HDMI)
│ → auto-include (never used by host)
└─ No .1 sibling? (Intel iGPU, split audio)
→ checklist of host audio controllers,
default = none (user opts in)
User selects VM
VM is q35? ── No → abort
Yes
GPU already assigned elsewhere?
├─ To LXC → offer to remove it from LXC
├─ To other VM → offer to remove it there
│ + clean up orphan audio
│ (skips audio whose
│ display sibling stays)
└─ Free → continue
Show confirmation summary
(GPU + IOMMU siblings + audio + target VM)
┌─────── Cancel OR Confirm ────┐
▼ ▼
exit, nothing ┌─────────────┴──────────────┐
was changed │ PHASE 2 — Apply changes │
└─────────────┬──────────────┘
Host:
├─ /etc/modules (vfio_*)
├─ /etc/modprobe.d/vfio.conf (ids=...)
├─ /etc/modprobe.d/blacklist.conf
├─ kernel cmdline (IOMMU if missing)
├─ NVIDIA: disable udev rule + hard blacklist
├─ AMD: dump ROM → /usr/share/kvm/*.bin
└─ update-initramfs -u -k all
VM config (qm set <vmid>):
├─ hostpci0 = GPU (x-vga=1 unless Intel iGPU)
├─ hostpci1..n = IOMMU group siblings
├─ hostpci<last> = audio function(s)
├─ vga = std
└─ NVIDIA: cpu=host,hidden=1
args=... hv_vendor_id=NV43FIX
┌───────────────┴───────────────┐
│ PHASE 3 — Summary + reboot │
└───────────────────────────────┘
Show what changed. If host config
touched → prompt reboot.`}
</pre>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("walkthrough.heading")}</h2>
<Steps>
<Steps.Step title={t("walkthrough.detect.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.detect.body", { code })}</p>
<Callout variant="tip" title={t("walkthrough.detect.tipTitle")}>
{t.rich("walkthrough.detect.tipBody", { postLink })}
</Callout>
<Image
src="/gpu-tpu/gpu-vm-02-gpu-detection.png"
alt={t("walkthrough.detect.imageAlt")}
width={900}
height={500}
className="rounded shadow-lg my-6"
/>
</Steps.Step>
<Steps.Step title={t("walkthrough.preflight.title")}>
<p className="mb-3 text-gray-800">{t("walkthrough.preflight.intro")}</p>
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-3">
{preflightItems.map((_, idx) => (
<li key={idx}>{t.rich(`walkthrough.preflight.items.${idx}`, { strong, code, em })}</li>
))}
<li>
{t.rich("walkthrough.preflight.audioIntro", { strong })}
<ul className="list-disc pl-6 mt-1 space-y-1">
<li>{t.rich("walkthrough.preflight.audioDgpu", { strong, code })}</li>
<li>{t.rich("walkthrough.preflight.audioIgpu", { strong, code })}</li>
</ul>
</li>
</ul>
</Steps.Step>
<Steps.Step title={t("walkthrough.pickVm.title")}>
<p className="mb-3 text-gray-800">{t("walkthrough.pickVm.body")}</p>
<Image
src="/gpu-tpu/gpu-vm-03-vm-select.png"
alt={t("walkthrough.pickVm.imageAlt")}
width={900}
height={500}
className="rounded shadow-lg my-6"
/>
</Steps.Step>
<Steps.Step title={t("walkthrough.switchMode.title")}>
<p className="mb-3 text-gray-800">{t("walkthrough.switchMode.intro")}</p>
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-3">
{switchModeItems.map((_, idx) => (
<li key={idx}>{t.rich(`walkthrough.switchMode.items.${idx}`, { strong, code })}</li>
))}
</ul>
<Image
src="/gpu-tpu/gpu-vm-04-switch-mode.png"
alt={t("walkthrough.switchMode.imageAlt")}
width={900}
height={500}
className="rounded shadow-lg my-6"
/>
<Callout variant="tip" title={t("walkthrough.switchMode.smartTitle")}>
{t.rich("walkthrough.switchMode.smartBody", { strong, code })}
</Callout>
</Steps.Step>
<Steps.Step title={t("walkthrough.audioPick.title")}>
<p className="mb-3 text-gray-800">{t("walkthrough.audioPick.body")}</p>
<Image
src="/gpu-tpu/gpu-vm-07-audio-checklist.png"
alt={t("walkthrough.audioPick.imageAlt")}
width={900}
height={500}
className="rounded shadow-lg my-6"
/>
<Callout variant="warning" title={t("walkthrough.audioPick.warnTitle")}>
{t("walkthrough.audioPick.warnBody")}
</Callout>
</Steps.Step>
<Steps.Step title={t("walkthrough.summary.title")}>
<p className="mb-3 text-gray-800">{t("walkthrough.summary.body")}</p>
<Image
src="/gpu-tpu/gpu-vm-05-summary.png"
alt={t("walkthrough.summary.imageAlt")}
width={900}
height={500}
className="rounded shadow-lg my-6"
/>
</Steps.Step>
<Steps.Step title={t("walkthrough.hostApply.title")}>
<p className="mb-3 text-gray-800">{t("walkthrough.hostApply.intro")}</p>
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-3">
{hostApplyItems.map((_, idx) => (
<li key={idx}>{t.rich(`walkthrough.hostApply.items.${idx}`, { strong, code })}</li>
))}
</ul>
</Steps.Step>
<Steps.Step title={t("walkthrough.vmApply.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.vmApply.body", { code })}</p>
<CopyableCode
code={`# Example — what ends up in the VM config after a GPU + audio passthrough
# (you don't type this, ProxMenux does it for you)
hostpci0: 0000:01:00.0,pcie=1,x-vga=1[,romfile=vbios_card.bin] # GPU video
hostpci1: 0000:01:00.1,pcie=1 # GPU audio
vga: std
# NVIDIA only — hide the hypervisor from the guest driver (Code 43 fix)
cpu: host,hidden=1,flags=+pcid
args: -cpu 'host,+kvm_pv_unhalt,+kvm_pv_eoi,hv_vendor_id=NV43FIX,kvm=off'`}
className="my-4"
/>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.vmApply.after1", { code, strong })}</p>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.vmApply.after2", { code })}</p>
</Steps.Step>
<Steps.Step title={t("walkthrough.reboot.title")}>
<p className="mb-3 text-gray-800">{t("walkthrough.reboot.body")}</p>
<Image
src="/gpu-tpu/gpu-vm-06-reboot.png"
alt={t("walkthrough.reboot.imageAlt")}
width={900}
height={500}
className="rounded shadow-lg my-6"
/>
</Steps.Step>
</Steps>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("vendors.heading")}</h2>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("vendors.nvidiaHeading")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("vendors.nvidiaBody", { em, code })}
</p>
<h4 className="text-base font-semibold mt-4 mb-2 text-gray-900">{t("vendors.nvidiaMultiHeading")}</h4>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("vendors.nvidiaMultiBody", { strong, code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("vendors.amdHeading")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("vendors.amdBody", { em, vendorResetLink })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("vendors.intelHeading")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("vendors.intelBody", { code, sriovLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("verification.heading")}</h2>
<CopyableCode
code={`# After reboot, confirm the GPU is bound to vfio-pci (not to the vendor driver)
lspci -nnk -d <vendor:device>
# Expect: "Kernel driver in use: vfio-pci"
# Start the VM and watch for successful binding
qm start <vmid>
journalctl -u qemu-server@<vmid>.service -f
# Inside the guest, drivers install normally and the GPU works as if it were physical.
# NVIDIA: verify with nvidia-smi inside the VM.
# AMD: verify with Windows Device Manager / DXDiag.
# Intel: verify display output / intel_gpu_top inside the VM.`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.code43Title")}>
{t.rich("troubleshoot.code43Body", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.amdResetTitle")}>
{t.rich("troubleshoot.amdResetBody", { code, em })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.stuckBootTitle")}>
{t.rich("troubleshoot.stuckBootBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.darkTitle")}>
{t.rich("troubleshoot.darkBody", { code })}
</Callout>
<CopyableCode
code={`# Rescue — remove passthrough
# Remove hostpciN lines from the VM config
sed -i '/^hostpci[0-9]*:/d' /etc/pve/qemu-server/<vmid>.conf
# Unblacklist the host driver
rm -f /etc/modprobe.d/blacklist.conf /etc/modprobe.d/vfio.conf
# (if NVIDIA was involved)
mv /etc/udev/rules.d/70-nvidia.rules.proxmenux-disabled /etc/udev/rules.d/70-nvidia.rules 2>/dev/null
update-initramfs -u -k all
reboot`}
className="my-4"
/>
<Callout variant="tip" title={t("troubleshoot.logTitle")}>
{t.rich("troubleshoot.logBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("revert.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("revert.intro")}</p>
<CopyableCode
code={`# Remove hostpci lines from the VM
qm set <vmid> --delete hostpci0
# (repeat for hostpci1, hostpci2, ... if multiple were added)
# ─── OPTIONAL — not required ──────────────────────────────────────
# The steps above already free the VM from the GPU. The lines below
# are only needed if you also want the host to use the GPU again
# (e.g. for LXC sharing or host-side transcoding). Skip this block
# if you simply want to stop passing the GPU to the VM.
# ──────────────────────────────────────────────────────────────────
# Release the GPU back to the host driver:
rm -f /etc/modprobe.d/vfio.conf
rm -f /etc/modprobe.d/blacklist.conf # careful — this file may have other blacklists
# NVIDIA only — re-enable the udev rule + unpin the hard blacklist
mv /etc/udev/rules.d/70-nvidia.rules.proxmenux-disabled /etc/udev/rules.d/70-nvidia.rules 2>/dev/null
rm -f /etc/modprobe.d/nvidia-blacklist.conf
update-initramfs -u -k all
reboot`}
className="my-4"
/>
<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,405 @@
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 { Prerequisites } from "@/components/ui/prerequisites"
import { Steps } from "@/components/ui/steps"
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.hardware.igpuAccelerationLxc.meta" })
return {
title: t("title"),
description: t("description"),
}
}
type CompareRow = { feature: string; lxc: string; vm: string }
type PreflightItem = string
type DistroRow = { distro: string; intel: string; nvidia: string }
type RelatedItem = { label: string; href: string; tail?: string }
export default async function AddGpuToLxcPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.hardware.igpuAccelerationLxc" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { hardware: { igpuAccelerationLxc: {
compare: { rows: CompareRow[] }
walkthrough: {
preflight: { items: PreflightItem[] }
installDrivers: { rows: DistroRow[] }
}
related: { items: RelatedItem[] }
} } }
}
const compareRows = messages.docs.hardware.igpuAccelerationLxc.compare.rows
const preflightItems = messages.docs.hardware.igpuAccelerationLxc.walkthrough.preflight.items
const distroRows = messages.docs.hardware.igpuAccelerationLxc.walkthrough.installDrivers.rows
const relatedItems = messages.docs.hardware.igpuAccelerationLxc.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 vmLink = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/gpu-vm-passthrough" className="text-blue-700 hover:underline">{chunks}</Link>
)
const switchLink = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/switch-gpu-mode" className="text-blue-700 hover:underline">{chunks}</Link>
)
const nvidiaLink = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/nvidia-host" className="text-blue-700 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={10}
scriptPath="gpu_tpu/add_gpu_lxc.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("compare.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("compare.intro", { em, vmLink })}
</p>
<div className="my-4 overflow-x-auto">
<table className="min-w-full border border-gray-200 text-sm">
<thead className="bg-gray-100">
<tr>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("compare.headerFeature")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("compare.headerLxc")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">
<Link href="/docs/hardware/gpu-vm-passthrough" className="text-blue-700 hover:underline">
{t("compare.headerVm")}
</Link>
</th>
</tr>
</thead>
<tbody className="text-gray-800">
{compareRows.map((row, idx) => (
<tr key={idx}>
<td className="border border-gray-200 px-3 py-2">{row.feature}</td>
<td className="border border-gray-200 px-3 py-2">{row.lxc}</td>
<td className="border border-gray-200 px-3 py-2">{row.vm}</td>
</tr>
))}
</tbody>
</table>
</div>
<Prerequisites
title={t("prereqs.title")}
items={[
{
label: <>{t.rich("prereqs.gpu", { strong, code })}</>,
check: t("prereqs.gpuCheck"),
},
{
label: <>{t.rich("prereqs.vfio", { strong, switchLink })}</>,
},
{
label: <>{t.rich("prereqs.nvidia", { strong, nvidiaLink })}</>,
check: t("prereqs.nvidiaCheck"),
},
{
label: <>{t.rich("prereqs.container", { strong })}</>,
},
]}
/>
<Callout variant="warning" title={t("unpriv.title")}>
{t.rich("unpriv.body", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("running.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("running.body", { strong })}
</p>
<Image
src="/gpu-tpu/gpu-lxc-01-menu-entry.png"
alt={t("running.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("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, select, validate │
│ (nothing touched yet) │
└──────────────────┬──────────────────────────┘
lspci detects Intel / AMD / NVIDIA GPU(s)
(NVIDIA: also check nvidia module loaded +
nvidia-smi, capture host driver version)
User picks LXC container from the list
User selects which GPU(s) to add
(checklist; auto-selects if only one)
Pre-flight checks
├─ Not in SR-IOV (VF / active PF) → block
├─ Bound to vfio-pci? → offer Switch Mode, exit
└─ Already configured in this CT? → filter out
(skip duplicates, warn if partial)
┌─────── Cancel OR Confirm ────┐
▼ ▼
Exit, nothing ┌──────────────────┴──────────────────┐
was changed │ PHASE 2 — Configure + install │
└──────────────────┬──────────────────┘
Stop container (if running)
Write LXC config (/etc/pve/lxc/<ctid>.conf):
├─ Intel / AMD iGPU:
│ dev<N>: /dev/dri/card* gid=video
│ dev<N>: /dev/dri/renderD* gid=render
├─ AMD with ROCm (if /dev/kfd):
│ dev<N>: /dev/kfd gid=render
└─ NVIDIA:
dev<N>: /dev/nvidia0..N
dev<N>: /dev/nvidiactl · nvidia-uvm*
dev<N>: /dev/nvidia-modeset
dev<N>: /dev/nvidia-caps/* (if exists)
Install GPU guard hookscript
(same one used by VM passthrough, if
available — prevents conflicts on start/stop)
Start container + wait for readiness
(pct exec — true, up to ~30 s)
Install userspace drivers inside CT
(distro auto-detected)
├─ Intel → apk/pacman/apt
│ (intel-media-driver,
│ libva-utils, opencl-icd)
├─ AMD → Mesa VA drivers
│ (mesa-va-drivers, libva)
└─ NVIDIA →
├─ Alpine: apk add nvidia-utils
├─ Arch: pacman -S nvidia-utils
└─ Debian/Ubuntu/others:
host .run is pre-extracted, packed,
pct push'd into the container, run
with --no-kernel-modules --no-dkms
Align GIDs in /etc/group inside CT
(video=44, render=104 to match host)
Restore container state
(stop if it was stopped before)
Show summary + nvidia-smi output
(if NVIDIA) + log path`}
</pre>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("walkthrough.heading")}</h2>
<Steps>
<Steps.Step title={t("walkthrough.detect.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.detect.body", { code })}</p>
<Callout variant="tip" title={t("walkthrough.detect.tipTitle")}>
{t.rich("walkthrough.detect.tipBody", { nvidiaLink })}
</Callout>
</Steps.Step>
<Steps.Step title={t("walkthrough.pickCt.title")}>
<p className="mb-3 text-gray-800">{t("walkthrough.pickCt.body")}</p>
<Image
src="/gpu-tpu/select-container.png"
alt={t("walkthrough.pickCt.imageAlt")}
width={800}
height={400}
className="rounded shadow-lg my-4"
/>
</Steps.Step>
<Steps.Step title={t("walkthrough.selectGpu.title")}>
<p className="mb-3 text-gray-800">{t("walkthrough.selectGpu.body")}</p>
<Image
src="/gpu-tpu/gpu-lxc-02-gpu-select.png"
alt={t("walkthrough.selectGpu.imageAlt")}
width={900}
height={500}
className="rounded shadow-lg my-4"
/>
</Steps.Step>
<Steps.Step title={t("walkthrough.preflight.title")}>
<Image
src="/gpu-tpu/gpu-lxc-03-switch-mode.png"
alt={t("walkthrough.preflight.imageAlt")}
width={900}
height={500}
className="rounded shadow-lg my-4"
/>
<p className="mb-3 text-gray-800">{t("walkthrough.preflight.intro")}</p>
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-3">
{preflightItems.map((_, idx) => (
<li key={idx}>{t.rich(`walkthrough.preflight.items.${idx}`, { strong, code, switchLink })}</li>
))}
</ul>
</Steps.Step>
<Steps.Step title={t("walkthrough.applyConfig.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.applyConfig.body1", { code })}</p>
<p className="mb-3 text-gray-800">{t("walkthrough.applyConfig.body2")}</p>
<CopyableCode
code={`# in /etc/pve/lxc/<ctid>.conf
dev0: /dev/dri/card0,gid=44
dev1: /dev/dri/renderD128,gid=104
dev2: /dev/nvidia0,gid=44
dev3: /dev/nvidiactl,gid=44
dev4: /dev/nvidia-uvm,gid=44
dev5: /dev/nvidia-uvm-tools,gid=44
dev6: /dev/nvidia-modeset,gid=44`}
className="my-4"
/>
</Steps.Step>
<Steps.Step title={t("walkthrough.installDrivers.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.installDrivers.body", { code })}</p>
<div className="my-4 overflow-x-auto">
<table className="min-w-full border border-gray-200 text-sm">
<thead className="bg-gray-100">
<tr>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("walkthrough.installDrivers.headerDistro")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("walkthrough.installDrivers.headerInt")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("walkthrough.installDrivers.headerNvidia")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{distroRows.map((row, idx) => (
<tr key={idx}>
<td className="border border-gray-200 px-3 py-2">{row.distro}</td>
<td className="border border-gray-200 px-3 py-2"><code>{row.intel}</code></td>
<td className="border border-gray-200 px-3 py-2"><code>{row.nvidia}</code></td>
</tr>
))}
<tr>
<td className="border border-gray-200 px-3 py-2">{t("walkthrough.installDrivers.debianDistro")}</td>
<td className="border border-gray-200 px-3 py-2"><code>{t("walkthrough.installDrivers.debianIntel")}</code></td>
<td className="border border-gray-200 px-3 py-2">{t.rich("walkthrough.installDrivers.debianNvidia", { code })}</td>
</tr>
</tbody>
</table>
</div>
<Callout variant="tip" title={t("walkthrough.installDrivers.whyTitle")}>
{t.rich("walkthrough.installDrivers.whyBody", { code, strong })}
</Callout>
</Steps.Step>
<Steps.Step title={t("walkthrough.alignGids.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.alignGids.body1", { code })}</p>
<p className="mb-3 text-gray-800">{t("walkthrough.alignGids.body2")}</p>
</Steps.Step>
</Steps>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("vendors.heading")}</h2>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("vendors.intelHeading")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("vendors.intelBody", { em, code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("vendors.amdHeading")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("vendors.amdBody", { code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("vendors.nvidiaHeading")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("vendors.nvidiaBody", { code, strong })}
</p>
<Callout variant="warning" title={t("vendors.updateTitle")}>
{t.rich("vendors.updateBody", { code, nvidiaLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("verification.heading")}</h2>
<p className="mb-3 text-gray-800 leading-relaxed">{t("verification.body")}</p>
<CopyableCode
code={`# Enter the container
pct enter <ctid>
# Intel / AMD — check DRI nodes and VA-API
ls -l /dev/dri/
vainfo
# NVIDIA — check nvidia-smi matches the host version
nvidia-smi
nvidia-smi --query-gpu=driver_version --format=csv,noheader
# Check group alignment
getent group video render`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.mismatchTitle")}>
{t.rich("troubleshoot.mismatchBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.denyTitle")}>
{t.rich("troubleshoot.denyBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.vainfoTitle")}>
{t.rich("troubleshoot.vainfoBody", { code })}
</Callout>
<Callout variant="tip" title={t("troubleshoot.logTitle")}>
{t.rich("troubleshoot.logBody", { 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,511 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import Image from "next/image"
import { ExternalLink } from "lucide-react"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
import { Prerequisites } from "@/components/ui/prerequisites"
import { Steps } from "@/components/ui/steps"
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.hardware.installCoralTpuHost.meta" })
return {
title: t("title"),
description: t("description"),
}
}
type StringItem = string
type RelatedItem = { label: string; href: string; tail?: string }
export default async function InstallCoralTPUHostPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.hardware.installCoralTpuHost" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { hardware: { installCoralTpuHost: {
walkthrough: {
detect: { items: StringItem[] }
pcie: { items: StringItem[]; kernelPatches: StringItem[]; afterItems: StringItem[] }
usb: { items: StringItem[] }
}
reinstallUninstall: { uninstallItems: StringItem[] }
related: { items: RelatedItem[] }
} } }
}
const detectItems = messages.docs.hardware.installCoralTpuHost.walkthrough.detect.items
const pcieItems = messages.docs.hardware.installCoralTpuHost.walkthrough.pcie.items
const kernelPatches = messages.docs.hardware.installCoralTpuHost.walkthrough.pcie.kernelPatches
const pcieAfterItems = messages.docs.hardware.installCoralTpuHost.walkthrough.pcie.afterItems
const usbItems = messages.docs.hardware.installCoralTpuHost.walkthrough.usb.items
const uninstallItems = messages.docs.hardware.installCoralTpuHost.reinstallUninstall.uninstallItems
const relatedItems = messages.docs.hardware.installCoralTpuHost.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 lxcLink = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/coral-tpu-lxc" className="text-blue-700 hover:underline">{chunks}</Link>
)
const feranickLink = (chunks: React.ReactNode) => (
<a
href="https://github.com/feranick/gasket-driver"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
const googleLink = (chunks: React.ReactNode) => (
<a
href="https://github.com/google/gasket-driver"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={10}
scriptPath="gpu_tpu/install_coral.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("which.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("which.body")}</p>
<div className="my-4 overflow-x-auto">
<table className="min-w-full border border-gray-200 text-sm">
<thead className="bg-gray-100">
<tr>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("which.headerForm")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("which.headerDetect")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("which.headerInstall")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("which.headerReboot")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
<tr>
<td className="border border-gray-200 px-3 py-2">
<strong>{t("which.pcieForm")}</strong>
<br />
<span className="text-xs text-gray-600">{t("which.pcieFormSub")}</span>
</td>
<td className="border border-gray-200 px-3 py-2">{t.rich("which.pcieDetect", { code })}</td>
<td className="border border-gray-200 px-3 py-2">{t("which.pcieInstall")}</td>
<td className="border border-gray-200 px-3 py-2"><strong>{t("which.pcieReboot")}</strong></td>
</tr>
<tr>
<td className="border border-gray-200 px-3 py-2">
<strong>{t("which.usbForm")}</strong>
<br />
<span className="text-xs text-gray-600">{t("which.usbFormSub")}</span>
</td>
<td className="border border-gray-200 px-3 py-2">{t.rich("which.usbDetect", { code })}</td>
<td className="border border-gray-200 px-3 py-2">{t.rich("which.usbInstall", { code })}</td>
<td className="border border-gray-200 px-3 py-2">{t("which.usbReboot")}</td>
</tr>
</tbody>
</table>
</div>
<Prerequisites
title={t("prereqs.title")}
items={[
{ label: <>{t.rich("prereqs.coral", { strong })}</>, check: t("prereqs.coralCheck") },
{ label: <>{t.rich("prereqs.internet", { strong, code })}</> },
{ label: <>{t.rich("prereqs.headers", { strong, code })}</> },
{ label: <>{t.rich("prereqs.reboot", { strong })}</> },
]}
/>
<Callout variant="tip" title={t("hostPrepTip.title")}>
{t.rich("hostPrepTip.body", { em, lxcLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("running.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("running.body", { strong })}
</p>
<Image
src="/gpu-tpu/coral-host-01-menu-entry.png"
alt={t("running.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("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">
{`┌────────────────────────────────────────────────┐
│ 1. detect_coral_hardware() │
│ → count PCIe (vendor 1ac1) + USB (IDs) │
└────────────────┬───────────────────────────────┘
┌───────────┴───────────┐
│ │
▼ ▼
None At least one
│ │
▼ ▼
Dialog pre_install_prompt()
"No Coral" → shows what was detected
exit 0 and what will be installed
┌────────────────┴────────────────┐
│ │
▼ ▼
PCIe detected? USB detected?
│ │
Yes Yes
▼ ▼
install_gasket_apex_dkms install_libedgetpu_runtime
├─ cleanup_broken_gasket_dkms ├─ add Google GPG keyring
├─ apt install deps │ /etc/apt/keyrings/...
│ (git, dkms, build-essential, ├─ add APT repo (signed-by)
│ proxmox-headers-$(uname-r)) │ /etc/apt/sources.list.d/
├─ clone feranick/gasket-driver │ coral-edgetpu.list
│ (google fallback + patches) ├─ apt install libedgetpu1-std
├─ copy src/ → /usr/src/ └─ udev reload + trigger
│ gasket-1.0/
├─ generate dkms.conf
├─ dkms add / build / install
└─ modprobe gasket + apex
+ ensure_apex_group_and_udev
│ │
└────────────────┬────────────────┘
┌────────────────┴────────────────┐
│ │
PCIe ran? USB only
│ │
▼ ▼
restart_prompt() "No reboot required"
(reboot required to (runtime + udev rules
load fresh kernel are already active)
module cleanly)`}
</pre>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("walkthrough.heading")}</h2>
<Steps>
<Steps.Step title={t("walkthrough.detect.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.detect.body", { code, strong })}</p>
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-3">
{detectItems.map((_, idx) => (
<li key={idx}>{t.rich(`walkthrough.detect.items.${idx}`, { code, em })}</li>
))}
</ul>
<p className="mb-3 text-gray-800">{t("walkthrough.detect.outro")}</p>
</Steps.Step>
<Steps.Step title={t("walkthrough.prompt.title")}>
<p className="mb-3 text-gray-800">{t("walkthrough.prompt.body")}</p>
<Image
src="/gpu-tpu/coral-host-02-pre-install.png"
alt={t("walkthrough.prompt.imageAlt")}
width={900}
height={500}
className="rounded shadow-lg my-4"
/>
</Steps.Step>
<Steps.Step title={t("walkthrough.pcie.title")}>
<p className="mb-3 text-gray-800">{t("walkthrough.pcie.body")}</p>
<ol className="list-decimal pl-6 space-y-1 text-gray-800 mb-3">
{pcieItems.map((_, idx) => (
<li key={idx}>{t.rich(`walkthrough.pcie.items.${idx}`, { strong, code })}</li>
))}
<li>
{t.rich("walkthrough.pcie.cloneIntro", { strong, feranickLink, googleLink })}
<ul className="list-disc pl-6 mt-1">
{kernelPatches.map((_, idx) => (
<li key={idx}>{t.rich(`walkthrough.pcie.kernelPatches.${idx}`, { code })}</li>
))}
</ul>
</li>
{pcieAfterItems.map((_, idx) => (
<li key={`after-${idx}`}>{t.rich(`walkthrough.pcie.afterItems.${idx}`, { strong, code })}</li>
))}
</ol>
<Image
src="/gpu-tpu/coral-host-03-dkms-build.png"
alt={t("walkthrough.pcie.imageAlt")}
width={900}
height={500}
className="rounded shadow-lg my-4"
/>
</Steps.Step>
<Steps.Step title={t("walkthrough.usb.title")}>
<p className="mb-3 text-gray-800">{t("walkthrough.usb.body")}</p>
<ol className="list-decimal pl-6 space-y-1 text-gray-800 mb-3">
{usbItems.map((_, idx) => (
<li key={idx}>{t.rich(`walkthrough.usb.items.${idx}`, { strong, code })}</li>
))}
</ol>
<Callout variant="tip" title={t("walkthrough.usb.stdTitle")}>
{t.rich("walkthrough.usb.stdBody", { code })}
</Callout>
</Steps.Step>
<Steps.Step title={t("walkthrough.reboot.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.reboot.body", { code })}</p>
<Image
src="/gpu-tpu/coral-host-04-summary.png"
alt={t("walkthrough.reboot.imageAlt")}
width={900}
height={500}
className="rounded shadow-lg my-4"
/>
</Steps.Step>
</Steps>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("reinstallUninstall.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("reinstallUninstall.intro", { code })}
</p>
<figure className="my-4">
<Image
src="/gpu-tpu/coral-host-05-action-menu.png"
alt={t("reinstallUninstall.imageAlt")}
width={1200}
height={680}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("reinstallUninstall.imageCaption", { code })}
</figcaption>
</figure>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("reinstallUninstall.reinstallHeading")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("reinstallUninstall.reinstallBody", { code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("reinstallUninstall.uninstallHeading")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("reinstallUninstall.uninstallIntro", { code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{uninstallItems.map((_, idx) => (
<li key={idx}>{t.rich(`reinstallUninstall.uninstallItems.${idx}`, { code, strong })}</li>
))}
</ul>
<Callout variant="warning" title={t("reinstallUninstall.lxcWarnTitle")}>
{t("reinstallUninstall.lxcWarnBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("updates.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("updates.intro")}</p>
<div className="my-4 overflow-x-auto">
<table className="min-w-full border border-gray-200 text-sm">
<thead className="bg-gray-100">
<tr>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("updates.headerVariant")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("updates.headerTracked")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("updates.headerUpstream")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
<tr>
<td className="border border-gray-200 px-3 py-2">{t("updates.pcieVariant")}</td>
<td className="border border-gray-200 px-3 py-2">{t.rich("updates.pcieTracked", { code })}</td>
<td className="border border-gray-200 px-3 py-2">{t.rich("updates.pcieUpstream", { code })}</td>
</tr>
<tr>
<td className="border border-gray-200 px-3 py-2">{t("updates.usbVariant")}</td>
<td className="border border-gray-200 px-3 py-2">{t.rich("updates.usbTracked", { code })}</td>
<td className="border border-gray-200 px-3 py-2">{t.rich("updates.usbUpstream", { code })}</td>
</tr>
</tbody>
</table>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("updates.outro", { code, strong })}
</p>
<Callout variant="info" title={t("updates.antiTitle")}>
{t("updates.antiBody")}
</Callout>
<Callout variant="info" title={t("updates.rebootTitle")}>
{t("updates.rebootBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("manual.intro")}</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("manual.pcieHeading")}</h3>
<CopyableCode
code={`# Build deps
apt-get update
apt-get install -y git dkms build-essential "proxmox-headers-$(uname -r)"
# Clone the (maintained) fork
cd /tmp
rm -rf gasket-driver
git clone --depth=1 https://github.com/feranick/gasket-driver.git
# Stage under /usr/src for DKMS
cp -a /tmp/gasket-driver/src/. /usr/src/gasket-1.0/
# Tell DKMS what it is
cat > /usr/src/gasket-1.0/dkms.conf <<'EOF'
PACKAGE_NAME="gasket"
PACKAGE_VERSION="1.0"
BUILT_MODULE_NAME[0]="gasket"
BUILT_MODULE_NAME[1]="apex"
DEST_MODULE_LOCATION[0]="/updates/dkms"
DEST_MODULE_LOCATION[1]="/updates/dkms"
MAKE[0]="make KVERSION=\${kernelver}"
CLEAN="make clean"
AUTOINSTALL="yes"
EOF
# Register + build + install
dkms add /usr/src/gasket-1.0
dkms build gasket/1.0 -k "$(uname -r)"
dkms install gasket/1.0 -k "$(uname -r)"
# Load it
modprobe gasket
modprobe apex
# Group + udev so /dev/apex_* end up with sane perms
groupadd --system apex 2>/dev/null || true
cat > /etc/udev/rules.d/99-coral-apex.rules <<'EOF'
KERNEL=="apex_*", GROUP="apex", MODE="0660"
SUBSYSTEM=="apex", GROUP="apex", MODE="0660"
EOF
udevadm control --reload-rules
udevadm trigger --subsystem-match=apex || true
# (Reboot recommended)`}
className="my-4"
/>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("manual.usbHeading")}</h3>
<CopyableCode
code={`# GPG key + repo (modern signed-by layout)
mkdir -p /etc/apt/keyrings
curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg \\
| gpg --dearmor -o /etc/apt/keyrings/coral-edgetpu.gpg
chmod 0644 /etc/apt/keyrings/coral-edgetpu.gpg
echo 'deb [signed-by=/etc/apt/keyrings/coral-edgetpu.gpg] https://packages.cloud.google.com/apt coral-edgetpu-stable main' \\
> /etc/apt/sources.list.d/coral-edgetpu.list
# Install the runtime
apt-get update
apt-get install -y libedgetpu1-std
# Reload udev so shipped rules apply to anything already plugged in
udevadm control --reload-rules
udevadm trigger --subsystem-match=usb || true`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("verification.heading")}</h2>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("verification.pcieHeading")}</h3>
<CopyableCode
code={`# Module loaded?
lsmod | grep apex
# Expect: apex, gasket (gasket as dependency)
# Device node present with apex group?
ls -l /dev/apex_*
# Expect: crw-rw---- 1 root apex ... /dev/apex_0
# Kernel sees the device cleanly?
dmesg | grep -i apex | tail -10
# Expect: "Apex chip ID ..." / "apex 0000:xx:00.0: Apex performance changed to ..."`}
className="my-4"
/>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("verification.usbHeading")}</h3>
<CopyableCode
code={`# Is it seen by USB?
lsusb | grep -E '1a6e:089a|18d1:9302'
# Runtime installed?
dpkg -l libedgetpu1-std | grep ii
# Quick smoke test — run the Coral classification example from any Python env
# pip install pycoral tflite_runtime pillow
# (Ran from the container that will use the TPU, not the host.)`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.dkmsFailTitle")}>
{t.rich("troubleshoot.dkmsFailBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.apexMissTitle")}>
{t.rich("troubleshoot.apexMissBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.lxcMissTitle")}>
{t.rich("troubleshoot.lxcMissBody", { lxcLink })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.usbUnreachTitle")}>
{t.rich("troubleshoot.usbUnreachBody", { code })}
</Callout>
<Callout variant="tip" title={t("troubleshoot.logTitle")}>
{t.rich("troubleshoot.logBody", { 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,463 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import Image from "next/image"
import { ExternalLink } from "lucide-react"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
import { Prerequisites } from "@/components/ui/prerequisites"
import { Steps } from "@/components/ui/steps"
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.hardware.nvidiaHost.meta" })
return {
title: t("title"),
description: t("description"),
}
}
type MatrixRow = { kernel: string; pve: string; minCode: string; minTail: string }
type StringItem = string
type RelatedItem = { label: string; href: string; tail?: string }
export default async function NvidiaHostPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.hardware.nvidiaHost" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { hardware: { nvidiaHost: {
walkthrough: {
version: { rows: MatrixRow[] }
prepare: { items: StringItem[] }
}
reinstallUninstall: { uninstallItems: StringItem[] }
updates: { kindsItems: StringItem[] }
related: { items: RelatedItem[] }
} } }
}
const matrixRows = messages.docs.hardware.nvidiaHost.walkthrough.version.rows
const prepareItems = messages.docs.hardware.nvidiaHost.walkthrough.prepare.items
const uninstallItems = messages.docs.hardware.nvidiaHost.reinstallUninstall.uninstallItems
const kindsItems = messages.docs.hardware.nvidiaHost.updates.kindsItems
const relatedItems = messages.docs.hardware.nvidiaHost.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 persistLink = (chunks: React.ReactNode) => (
<a
href="https://github.com/NVIDIA/nvidia-persistenced"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
const patchLink = (chunks: React.ReactNode) => (
<a
href="https://github.com/keylase/nvidia-patch"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
const patchTableLink = (chunks: React.ReactNode) => (
<a
href="https://github.com/keylase/nvidia-patch#patch-list"
target="_blank"
rel="noopener noreferrer"
className="text-blue-700 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
const guideLink = (chunks: React.ReactNode) => (
<Link href="/guides/nvidia-manual" className="text-blue-600 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={20}
scriptPath="gpu_tpu/nvidia_installer.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("who.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("who.body", { strong, em })}
</p>
<Prerequisites
title={t("prereqs.title")}
items={[
{ label: <>{t.rich("prereqs.gpu", { strong })}</>, check: t("prereqs.gpuCheck") },
{ label: <>{t.rich("prereqs.notVm", { strong })}</> },
{ label: <>{t.rich("prereqs.internet", { code })}</> },
{ label: <>{t.rich("prereqs.space", { strong, code })}</> },
]}
/>
<Callout variant="warning" title={t("vmWarn.title")}>
{t.rich("vmWarn.body", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("running.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("running.body", { strong })}
</p>
<Image
src="/gpu-tpu/nvidia-host-01-menu-entry.png"
alt={t("running.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("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, pick version │
│ (nothing touched yet) │
└──────────────────┬──────────────────────────┘
┌────────────┴────────────┐
▼ ▼
lspci detects GPU bound to
NVIDIA GPU(s) vfio-pci? ──→ Abort
│ │ (remove VM
│ No passthrough first)
nvidia-smi: driver already installed?
├─ No → continue (fresh install)
└─ Yes → ask: Reinstall/Update OR Remove
├─ Remove → complete uninstall
│ + reboot prompt
└─ Reinstall → continue
Show install overview
(GPU list + current driver +
LXC containers with NVIDIA passthrough)
Kernel-compat filter:
├─ Kernel 6.17+ → driver 580.82.07+
├─ Kernel 6.816 → driver 550+
├─ Kernel 6.27 → driver 535+
└─ Kernel 5.15+ → driver 470+
User picks version (or "Latest")
┌─────── Cancel OR Confirm ────┐
▼ ▼
Exit, nothing ┌─────────────┴──────────────┐
was changed │ PHASE 2 — Install driver │
└─────────────┬──────────────┘
Prepare host:
├─ Install pve-headers-$(uname -r)
├─ Install build-essential + dkms
├─ Blacklist nouveau + unload
│ └ /etc/modprobe.d/nouveau-blacklist.conf
├─ Write modules-load config
│ └ /etc/modules-load.d/nvidia-vfio.conf
├─ Stop/disable nvidia services
└─ Unload residual nvidia modules
If different version already present:
└─ clean uninstall first (apt purge,
remove DKMS entries)
Download NVIDIA .run installer
to /opt/nvidia (validate size +
executable signature)
Run installer with --dkms
--disable-nouveau --no-nouveau-check
Install udev rules
└─ /etc/udev/rules.d/70-nvidia.rules
+ clone NVIDIA/nvidia-persistenced
update-initramfs -u -k all
nvidia-smi — verify driver loaded
┌───────────────┴───────────────┐
│ PHASE 3 — Optional extras │
└───────────────┬───────────────┘
LXC containers with NVIDIA?
├─ Yes → offer driver propagation
│ (Alpine: apk · Arch: pacman ·
│ Debian/others: extract .run)
└─ No → skip
keylase/nvidia-patch (NVENC limit)?
├─ Yes → clone + apply
└─ No → skip
Reboot prompt — required to finalize
nouveau blacklist + load new module`}
</pre>
<Steps>
<Steps.Step title={t("walkthrough.detect.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.detect.body1", { em })}</p>
<p className="mb-3 text-gray-800">{t("walkthrough.detect.body2")}</p>
<Image
src="/gpu-tpu/nvidia-host-02-overview.png"
alt={t("walkthrough.detect.imageAlt")}
width={900}
height={500}
className="rounded shadow-lg my-6"
/>
</Steps.Step>
<Steps.Step title={t("walkthrough.version.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.version.body1", { strong, em })}</p>
<p className="mb-3 text-gray-800">{t("walkthrough.version.body2")}</p>
<div className="my-4 overflow-x-auto">
<table className="min-w-full border border-gray-200 text-sm">
<thead className="bg-gray-100">
<tr>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("walkthrough.version.headerKernel")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("walkthrough.version.headerPve")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("walkthrough.version.headerMin")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{matrixRows.map((row, idx) => (
<tr key={idx}>
<td className="border border-gray-200 px-3 py-2">{row.kernel}</td>
<td className="border border-gray-200 px-3 py-2">{row.pve}</td>
<td className="border border-gray-200 px-3 py-2">
<code>{row.minCode}</code>{row.minTail}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="tip" title={t("walkthrough.version.whyTitle")}>
{t("walkthrough.version.whyBody")}
</Callout>
<Image
src="/gpu-tpu/nvidia-host-03-version-menu.png"
alt={t("walkthrough.version.imageAlt")}
width={900}
height={500}
className="rounded shadow-lg my-6"
/>
</Steps.Step>
<Steps.Step title={t("walkthrough.uninstall.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.uninstall.body", { code })}</p>
</Steps.Step>
<Steps.Step title={t("walkthrough.prepare.title")}>
<p className="mb-3 text-gray-800">{t("walkthrough.prepare.body")}</p>
<ul className="list-disc pl-6 space-y-1 text-gray-800">
{prepareItems.map((_, idx) => (
<li key={idx}>{t.rich(`walkthrough.prepare.items.${idx}`, { code })}</li>
))}
</ul>
</Steps.Step>
<Steps.Step title={t("walkthrough.download.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.download.body", { code })}</p>
<CopyableCode
code={`# What ProxMenux runs under the hood (you don't have to type this):
sh NVIDIA-Linux-x86_64-<version>.run \\
--no-questions \\
--ui=none \\
--disable-nouveau \\
--no-nouveau-check \\
--dkms`}
className="my-4"
/>
<Image
src="/gpu-tpu/nvidia-host-04-install-progress.png"
alt={t("walkthrough.download.imageAlt")}
width={900}
height={500}
className="rounded shadow-lg my-6"
/>
</Steps.Step>
<Steps.Step title={t("walkthrough.persist.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.persist.body", { code, persistLink })}</p>
</Steps.Step>
<Steps.Step title={t("walkthrough.nvenc.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.nvenc.body", { strong, patchLink })}</p>
<Callout variant="warning" title={t("walkthrough.nvenc.supportTitle")}>
{t.rich("walkthrough.nvenc.supportBody", { patchTableLink })}
</Callout>
</Steps.Step>
<Steps.Step title={t("walkthrough.propagate.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.propagate.body1", { code, strong })}</p>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.propagate.body2", { code })}</p>
<Image
src="/gpu-tpu/nvidia-host-05-lxc-update.png"
alt={t("walkthrough.propagate.imageAlt")}
width={900}
height={500}
className="rounded shadow-lg my-6"
/>
</Steps.Step>
<Steps.Step title={t("walkthrough.reboot.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.reboot.body", { code, strong })}</p>
</Steps.Step>
</Steps>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("reinstallUninstall.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("reinstallUninstall.intro", { code })}
</p>
<figure className="my-4">
<Image
src="/gpu-tpu/nvidia-host-06-action-menu.png"
alt={t("reinstallUninstall.imageAlt")}
width={1200}
height={680}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("reinstallUninstall.imageCaption")}
</figcaption>
</figure>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("reinstallUninstall.reinstallHeading")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("reinstallUninstall.reinstallBody")}</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("reinstallUninstall.uninstallHeading")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("reinstallUninstall.uninstallIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{uninstallItems.map((_, idx) => (
<li key={idx}>{t.rich(`reinstallUninstall.uninstallItems.${idx}`, { code })}</li>
))}
</ul>
<Callout variant="warning" title={t("reinstallUninstall.lxcWarnTitle")}>
{t("reinstallUninstall.lxcWarnBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("updates.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("updates.body", { code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("updates.kindsHeading")}</h3>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{kindsItems.map((_, idx) => (
<li key={idx}>{t.rich(`updates.kindsItems.${idx}`, { strong })}</li>
))}
</ul>
<Callout variant="info" title={t("updates.antiTitle")}>
{t("updates.antiBody")}
</Callout>
<Callout variant="info" title={t("updates.applyTitle")}>
{t.rich("updates.applyBody", { strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("verify.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("verify.intro")}</p>
<CopyableCode
code={`nvidia-smi`}
className="my-4"
/>
<p className="mb-4 text-gray-800 leading-relaxed">{t("verify.after")}</p>
<CopyableCode
code={`systemctl status nvidia-persistenced`}
className="my-4"
/>
<Image
src="/gpu-tpu/nvidia-host-06-nvidia-smi-ok.png"
alt={t("verify.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("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.smiFailTitle")}>
{t.rich("troubleshoot.smiFailBody", { strong, code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.lxcMissTitle")}>
{t.rich("troubleshoot.lxcMissBody", { code })}
</Callout>
<Callout variant="tip" title={t("troubleshoot.logTitle")}>
{t.rich("troubleshoot.logBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manualSteps.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("manualSteps.body", { code, guideLink })}
</p>
<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,450 @@
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 { Prerequisites } from "@/components/ui/prerequisites"
import { Steps } from "@/components/ui/steps"
import { SwitchModeGraphic } from "@/components/ui/switch-mode-graphic"
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.hardware.switchGpuMode.meta" })
return {
title: t("title"),
description: t("description"),
}
}
type WhenRow = { situation?: string; situationRich?: string; use?: string; useRich?: string }
type DirectionItem = string
type RelatedItem = { label: string; href: string; tail?: string }
export default async function SwitchGpuModePage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.hardware.switchGpuMode" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { hardware: { switchGpuMode: {
when: { rows: WhenRow[] }
walkthrough: { direction: { items: DirectionItem[] } }
related: { items: RelatedItem[] }
} } }
}
const whenRows = messages.docs.hardware.switchGpuMode.when.rows
const directionItems = messages.docs.hardware.switchGpuMode.walkthrough.direction.items
const relatedItems = messages.docs.hardware.switchGpuMode.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 vmLink = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/gpu-vm-passthrough" className="text-blue-700 hover:underline">{chunks}</Link>
)
const lxcLink = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/igpu-acceleration-lxc" className="text-blue-700 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={10}
scriptPath="gpu_tpu/switch_gpu_mode.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code, em, strong })}
</Callout>
<div className="grid gap-4 md:grid-cols-2 my-6 not-prose">
<SwitchModeGraphic
mode="lxc"
title={t("graphics.lxcTitle")}
description={t("graphics.lxcDesc")}
/>
<SwitchModeGraphic
mode="vm"
title={t("graphics.vmTitle")}
description={t("graphics.vmDesc")}
/>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("when.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("when.intro", { strong })}
</p>
<div className="my-4 overflow-x-auto">
<table className="min-w-full border border-gray-200 text-sm">
<thead className="bg-gray-100">
<tr>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("when.headerSituation")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("when.headerUse")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{whenRows.map((row, idx) => (
<tr key={idx}>
<td className="border border-gray-200 px-3 py-2">
{row.situationRich
? t.rich(`when.rows.${idx}.situationRich`, { code })
: row.situation}
</td>
<td className="border border-gray-200 px-3 py-2">
{row.useRich
? t.rich(`when.rows.${idx}.useRich`, { vmLink, lxcLink, strong })
: row.use}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Prerequisites
title={t("prereqs.title")}
items={[
{ label: <>{t.rich("prereqs.assigned", { strong })}</> },
{
label: <>{t.rich("prereqs.iommu", { strong, em })}</>,
check: t("prereqs.iommuCheck"),
},
{ label: <>{t.rich("prereqs.reboot", { strong })}</> },
{ label: <>{t.rich("prereqs.knowList", { strong })}</> },
]}
/>
<Callout variant="warning" title={t("blocklist.title")}>
{t.rich("blocklist.body", { code, em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("running.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("running.body", { strong })}
</p>
<Image
src="/gpu-tpu/gpu-switch-01-menu-entry.png"
alt={t("running.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("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, select, plan │
│ (nothing touched yet) │
└──────────────────┬──────────────────────────┘
lspci detects every GPU + current driver
(vfio-pci, nvidia, amdgpu, i915, …)
User selects GPU(s) to switch
(checklist; auto-selects if only one)
Uniform current mode check
├─ All in VM mode → target = LXC
├─ All in LXC mode → target = VM
└─ Mixed → reject, reselect
Validations
├─ SR-IOV VF / active PF? → block
├─ Target = VM and blocked ID? → block
└─ IOMMU parameter present? → warn if missing
Find affected workloads
├─ LXC configs referencing the GPU
└─ VM configs with hostpci for the GPU
(precise BDF regex, no substring false-positives)
Conflict policy per affected workload
┌──────────────────────────────────────┐
│ Keep config, disable onboot │
│ └─ safest; workload stays defined │
│ but won't auto-start broken │
│ Remove GPU lines from config │
│ └─ clean; workload works without │
│ the GPU after the switch │
└──────────────────────────────────────┘
If target = LXC (leaving VM mode):
└─ Orphan audio cascade
(offer to remove companion audio
hostpci + clean vfio.conf if the
audio ID isn't used by any other VM)
Confirmation summary
(target mode + affected workloads +
host changes about to happen)
┌─────── Cancel OR Confirm ────┐
▼ ▼
Exit, nothing ┌──────────────────┴──────────────────┐
was changed │ PHASE 2 — Apply │
└──────────────────┬──────────────────┘
Target = VM (bind to vfio-pci):
├─ /etc/modprobe.d/vfio.conf
│ add vendor:device + disable_vga=1
├─ /etc/modprobe.d/blacklist.conf
│ add type-specific blacklists
├─ /etc/modules
│ add vfio-pci, vfio
├─ NVIDIA: sanitize host stack
│ (disable udev rule, hard-blacklist)
└─ AMD: softdep vfio-pci
Target = LXC (back to native driver):
├─ /etc/modprobe.d/vfio.conf
│ drop vendor:device IDs for this GPU
│ (delete line if now empty)
├─ /etc/modprobe.d/blacklist.conf
│ drop type blacklists if no GPU of
│ that type remains in vfio.conf
├─ /etc/modules
│ drop vfio-pci if no GPU in vfio.conf
└─ NVIDIA: restore host stack
(re-enable udev, drop hard-blacklist)
Apply workload conflict policy
(pct set onboot=0 OR sed hostpci/dev
lines out of VM/LXC configs)
update-initramfs -u -k all
(only if host config actually changed)
Reboot prompt — required for the new
binding to take effect`}
</pre>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("walkthrough.heading")}</h2>
<Steps>
<Steps.Step title={t("walkthrough.detect.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.detect.body", { code })}</p>
<Image
src="/gpu-tpu/gpu-switch-02-gpu-select.png"
alt={t("walkthrough.detect.imageAlt")}
width={900}
height={500}
className="rounded shadow-lg my-6"
/>
</Steps.Step>
<Steps.Step title={t("walkthrough.pickGpu.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.pickGpu.body", { em })}</p>
<Callout variant="tip" title={t("walkthrough.pickGpu.tipTitle")}>
{t("walkthrough.pickGpu.tipBody")}
</Callout>
</Steps.Step>
<Steps.Step title={t("walkthrough.direction.title")}>
<p className="mb-3 text-gray-800">{t("walkthrough.direction.intro")}</p>
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-3">
{directionItems.map((_, idx) => (
<li key={idx}>{t.rich(`walkthrough.direction.items.${idx}`, { strong, code })}</li>
))}
</ul>
<p className="mb-3 text-gray-800">{t("walkthrough.direction.outro")}</p>
</Steps.Step>
<Steps.Step title={t("walkthrough.conflict.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.conflict.body", { code })}</p>
<div className="my-4 overflow-x-auto">
<table className="min-w-full border border-gray-200 text-sm">
<thead className="bg-gray-100">
<tr>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("walkthrough.conflict.headerPolicy")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("walkthrough.conflict.headerEffect")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("walkthrough.conflict.headerWhen")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
<tr>
<td className="border border-gray-200 px-3 py-2"><strong>{t("walkthrough.conflict.keepPolicy")}</strong></td>
<td className="border border-gray-200 px-3 py-2">{t.rich("walkthrough.conflict.keepEffect", { code })}</td>
<td className="border border-gray-200 px-3 py-2">{t("walkthrough.conflict.keepWhen")}</td>
</tr>
<tr>
<td className="border border-gray-200 px-3 py-2"><strong>{t("walkthrough.conflict.removePolicy")}</strong></td>
<td className="border border-gray-200 px-3 py-2">{t.rich("walkthrough.conflict.removeEffect", { code })}</td>
<td className="border border-gray-200 px-3 py-2">{t("walkthrough.conflict.removeWhen")}</td>
</tr>
</tbody>
</table>
</div>
<Image
src="/gpu-tpu/gpu-switch-03-conflict-policy.png"
alt={t("walkthrough.conflict.imageAlt")}
width={900}
height={500}
className="rounded shadow-lg my-6"
/>
</Steps.Step>
<Steps.Step title={t("walkthrough.audio.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.audio.body1", { code })}</p>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.audio.body2", { code })}</p>
</Steps.Step>
<Steps.Step title={t("walkthrough.apply.title")}>
<p className="mb-3 text-gray-800">{t.rich("walkthrough.apply.body", { code })}</p>
</Steps.Step>
<Steps.Step title={t("walkthrough.reboot.title")}>
<p className="mb-3 text-gray-800">{t("walkthrough.reboot.body")}</p>
<Image
src="/gpu-tpu/gpu-switch-04-summary.png"
alt={t("walkthrough.reboot.imageAlt")}
width={900}
height={500}
className="rounded shadow-lg my-6"
/>
</Steps.Step>
</Steps>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("manual.intro", { strong, code })}
</p>
<CopyableCode
code={`# Drop the vendor:device from vfio.conf — keep other GPUs intact
sed -i 's/10de:2204,//; s/,10de:2204//; s/=10de:2204 /=/' /etc/modprobe.d/vfio.conf
# Remove the NVIDIA hard-blacklist and nouveau blacklist
sed -i '/^blacklist nouveau$/d; /^blacklist nvidia$/d; /^blacklist nvidia_drm$/d; /^blacklist nvidia_modeset$/d; /^blacklist nvidia_uvm$/d; /^blacklist nvidiafb$/d' /etc/modprobe.d/blacklist.conf
rm -f /etc/modprobe.d/nvidia-blacklist.conf
# Re-enable NVIDIA udev rule + modules-load config (if disabled by VM-mode switch)
[ -f /etc/udev/rules.d/70-nvidia.rules.proxmenux-disabled-vfio ] && \\
mv /etc/udev/rules.d/70-nvidia.rules.proxmenux-disabled-vfio \\
/etc/udev/rules.d/70-nvidia.rules
[ -f /etc/modules-load.d/nvidia-vfio.conf.proxmenux-disabled-vfio ] && \\
mv /etc/modules-load.d/nvidia-vfio.conf.proxmenux-disabled-vfio \\
/etc/modules-load.d/nvidia-vfio.conf
# Clean up the VM config — precise BDF regex, no substring collisions
# (replace 0000:01:00 with your GPU's slot)
sed -E -i '/^hostpci[0-9]+:[[:space:]]*(0000:)?01:00\\.[0-7]([,[:space:]]|$)/d' \\
/etc/pve/qemu-server/<vmid>.conf
# Rebuild initramfs and reboot
update-initramfs -u -k all
reboot`}
className="my-4"
/>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("manual.lxcToVm", { strong })}
</p>
<CopyableCode
code={`# Add the vendor:device to vfio.conf (create the line if missing)
grep -q '^options vfio-pci ids=' /etc/modprobe.d/vfio.conf && \\
sed -i '/^options vfio-pci ids=/ s/$/,10de:2204/' /etc/modprobe.d/vfio.conf || \\
echo 'options vfio-pci ids=10de:2204 disable_vga=1' >> /etc/modprobe.d/vfio.conf
# Blacklist the native driver so vfio-pci can claim the card
cat >> /etc/modprobe.d/blacklist.conf <<'EOF'
blacklist nouveau
blacklist nvidia
blacklist nvidia_drm
blacklist nvidia_modeset
blacklist nvidia_uvm
blacklist nvidiafb
options nouveau modeset=0
EOF
# Make sure vfio-pci loads at boot
grep -q '^vfio-pci$' /etc/modules || echo 'vfio-pci' >> /etc/modules
# Rebuild initramfs and reboot
update-initramfs -u -k all
reboot`}
className="my-4"
/>
<Callout variant="warning" title={t("manual.oneVmTitle")}>
{t.rich("manual.oneVmBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("verification.heading")}</h2>
<CopyableCode
code={`# Confirm the GPU is bound to the driver you expect
lspci -nnk -d <vendor:device>
# Expected (LXC mode): "Kernel driver in use: nvidia" (or amdgpu, i915)
# Expected (VM mode): "Kernel driver in use: vfio-pci"
# LXC mode — is the host tool happy?
nvidia-smi # if NVIDIA
intel_gpu_top # if Intel iGPU
# VM mode — ready to be claimed by a VM start
lsmod | grep vfio`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.stillVfioTitle")}>
{t.rich("troubleshoot.stillVfioBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.vmFailTitle")}>
{t.rich("troubleshoot.vmFailBody", { code, em })}
</Callout>
<CopyableCode
code={`# Delete every hostpci line for the GPU slot
sed -E -i '/^hostpci[0-9]+:[[:space:]]*(0000:)?<slot>\\.[0-7]([,[:space:]]|$)/d' \\
/etc/pve/qemu-server/<vmid>.conf`}
className="my-4"
/>
<Callout variant="troubleshoot" title={t("troubleshoot.smiFailTitle")}>
{t.rich("troubleshoot.smiFailBody", { code })}
</Callout>
<Callout variant="tip" title={t("troubleshoot.logTitle")}>
{t.rich("troubleshoot.logBody", { 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,100 @@
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 { CommandTable, type CommandGroup } from "@/components/ui/command-table"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.helpInfo.backupCommands.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"vzdump",
"qmrestore",
"pct restore",
"proxmox backup commands",
"proxmox vm backup",
"proxmox lxc backup",
"proxmox scheduled backup",
"vzdump exclude",
"proxmox restore command",
],
alternates: { canonical: "https://proxmenux.com/docs/help-info/backup-commands" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/help-info/backup-commands",
},
twitter: {
card: "summary",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type RelatedItem = { href: string; label: string; tail?: string }
export default async function BackupRestorePage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.helpInfo.backupCommands" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { helpInfo: { backupCommands: {
commandGroups: CommandGroup[]
related: { items: RelatedItem[] }
} } }
}
const commandGroups = messages.docs.helpInfo.backupCommands.commandGroups
const relatedItems = messages.docs.helpInfo.backupCommands.related.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={5}
scriptPath="help_info_menu.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { strong })}
</Callout>
<CommandTable groups={commandGroups} />
<Callout variant="tip" title={t("testRestores.title")}>
{t.rich("testRestores.bodyRich", { 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,100 @@
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 { CommandTable, type CommandGroup } from "@/components/ui/command-table"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.helpInfo.gpuCommands.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"proxmox gpu passthrough",
"qm hostpci",
"vfio proxmox",
"iommu proxmox",
"lspci proxmox",
"proxmox nvidia passthrough",
"proxmox amd passthrough",
"proxmox intel iommu",
"intel_iommu proxmox",
],
alternates: { canonical: "https://proxmenux.com/docs/help-info/gpu-commands" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/help-info/gpu-commands",
},
twitter: {
card: "summary",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type RelatedItem = { href: string; label: string; tail?: string }
export default async function GPUPassthroughPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.helpInfo.gpuCommands" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { helpInfo: { gpuCommands: {
commandGroups: CommandGroup[]
related: { items: RelatedItem[] }
} } }
}
const commandGroups = messages.docs.helpInfo.gpuCommands.commandGroups
const relatedItems = messages.docs.helpInfo.gpuCommands.related.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
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="help_info_menu.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { em, code })}
</Callout>
<CommandTable groups={commandGroups} />
<Callout variant="tip" title={t("afterChangesTip.title")}>
{t.rich("afterChangesTip.bodyRich", { 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,100 @@
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 { CommandTable, type CommandGroup } from "@/components/ui/command-table"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.helpInfo.networkCommands.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"proxmox network commands",
"ip command proxmox",
"pve-firewall",
"proxmox iptables",
"proxmox bridge",
"ss command",
"ping proxmox",
"proxmox networking cli",
"ethtool proxmox",
],
alternates: { canonical: "https://proxmenux.com/docs/help-info/network-commands" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/help-info/network-commands",
},
twitter: {
card: "summary",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type RelatedItem = { href: string; label: string; tail?: string }
export default async function NetworkCommandsPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.helpInfo.networkCommands" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { helpInfo: { networkCommands: {
commandGroups: CommandGroup[]
related: { items: RelatedItem[] }
} } }
}
const commandGroups = messages.docs.helpInfo.networkCommands.commandGroups
const relatedItems = messages.docs.helpInfo.networkCommands.related.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={4}
scriptPath="help_info_menu.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code })}
</Callout>
<CommandTable groups={commandGroups} />
<Callout variant="tip" title={t("iptablesTip.title")}>
{t.rich("iptablesTip.bodyRich", { strong, 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,145 @@
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,
Terminal,
HardDrive,
Network,
Package,
Cpu,
Database,
Archive,
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.helpInfo.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"proxmox commands",
"proxmox cli",
"proxmox cheatsheet",
"qm command",
"pct command",
"pveversion",
"vzdump",
"zpool",
"proxmox reference",
"proxmox commands list",
],
alternates: { canonical: "https://proxmenux.com/docs/help-info" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/help-info",
},
twitter: {
card: "summary_large_image",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type Option = { icon: string; href: string; title: string; description: string }
const ICONS: Record<string, React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>> = {
Terminal,
HardDrive,
Network,
Package,
Cpu,
Database,
Archive,
Wrench,
}
function OptionCard({ option }: { option: Option }) {
const Icon = ICONS[option.icon] || Terminal
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 HelpAndInfoPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.helpInfo" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { helpInfo: { categories: { options: Option[] } } }
}
const options = messages.docs.helpInfo.categories.options
const kbd = (chunks: React.ReactNode) => <kbd>{chunks}</kbd>
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={2}
scriptPath="help_info_menu.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t("intro.body")}
</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", { kbd })}
</p>
<Image
src="/help/help-info-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("categories.heading")}</h2>
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
{options.map((o) => (
<OptionCard key={o.href} option={o} />
))}
</div>
<Callout variant="tip" title={t("tip.title")}>
{t("tip.body")}
</Callout>
</div>
)
}

View File

@@ -0,0 +1,108 @@
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 { CommandTable, type CommandGroup } from "@/components/ui/command-table"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.helpInfo.storageCommands.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"proxmox storage commands",
"lsblk proxmox",
"pvesm",
"qm importdisk",
"qemu-img convert",
"proxmox lvm commands",
"proxmox disk commands",
"lvdisplay",
"pvs proxmox",
],
alternates: { canonical: "https://proxmenux.com/docs/help-info/storage-commands" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/help-info/storage-commands",
},
twitter: {
card: "summary",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type RelatedItem = { href: string; label: string; tail?: string }
export default async function StorageCommandsPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.helpInfo.storageCommands" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { helpInfo: { storageCommands: {
commandGroups: CommandGroup[]
related: { items: RelatedItem[] }
} } }
}
const commandGroups = messages.docs.helpInfo.storageCommands.commandGroups
const relatedItems = messages.docs.helpInfo.storageCommands.related.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={4}
scriptPath="help_info_menu.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code })}
</Callout>
<CommandTable groups={commandGroups} />
<Callout variant="tip" title={t("lvmTip.title")}>
{t.rich("lvmTip.bodyRich", { code })}
</Callout>
<Callout variant="info" title={t("smartInfo.title")}>
{t.rich("smartInfo.bodyRich", { strong, code })}
</Callout>
<Callout variant="warning" title={t("selfTestWarn.title")}>
{t.rich("selfTestWarn.bodyRich", { 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,94 @@
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 { CommandTable, type CommandGroup } from "@/components/ui/command-table"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.helpInfo.systemCommands.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"proxmox commands",
"pveversion",
"proxmox system commands",
"journalctl proxmox",
"systemctl proxmox",
"proxmox linux commands",
"pveperf",
"proxmox cli",
],
alternates: { canonical: "https://proxmenux.com/docs/help-info/system-commands" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/help-info/system-commands",
},
twitter: {
card: "summary",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type RelatedItem = { href: string; label: string; tail?: string }
export default async function SystemCommandsPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.helpInfo.systemCommands" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { helpInfo: { systemCommands: {
commandGroups: CommandGroup[]
related: { items: RelatedItem[] }
} } }
}
const commandGroups = messages.docs.helpInfo.systemCommands.commandGroups
const relatedItems = messages.docs.helpInfo.systemCommands.related.items
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={3}
scriptPath="help_info_menu.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { em })}
</Callout>
<CommandTable groups={commandGroups} />
<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,104 @@
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 { CommandTable, type CommandGroup } from "@/components/ui/command-table"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.helpInfo.toolsCommands.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"htop proxmox",
"iftop proxmox",
"rsync proxmox",
"tmux",
"journalctl",
"linux cli tools",
"proxmox cli tools",
"iotop",
"lsof",
"nmap proxmox",
"mtr",
],
alternates: { canonical: "https://proxmenux.com/docs/help-info/tools-commands" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/help-info/tools-commands",
},
twitter: {
card: "summary",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type RelatedItem = { href: string; label: string; tail?: string }
export default async function SystemCLIToolsPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.helpInfo.toolsCommands" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { helpInfo: { toolsCommands: {
commandGroups: CommandGroup[]
related: { items: RelatedItem[] }
} } }
}
const commandGroups = messages.docs.helpInfo.toolsCommands.commandGroups
const relatedItems = messages.docs.helpInfo.toolsCommands.related.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const utilsLink = (chunks: React.ReactNode) => (
<Link href="/docs/utils/system-utils" className="text-blue-700 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={5}
scriptPath="help_info_menu.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { utilsLink })}
</Callout>
<CommandTable groups={commandGroups} />
<Callout variant="tip" title={t("tmuxTip.title")}>
{t.rich("tmuxTip.bodyRich", { 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,102 @@
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 { CommandTable, type CommandGroup } from "@/components/ui/command-table"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.helpInfo.updateCommands.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"proxmox apt update",
"proxmox apt upgrade",
"pveupgrade",
"proxmox dist-upgrade",
"dpkg proxmox",
"proxmox repository",
"proxmox package commands",
"apt pveversion",
],
alternates: { canonical: "https://proxmenux.com/docs/help-info/update-commands" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/help-info/update-commands",
},
twitter: {
card: "summary",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type RelatedItem = { href: string; label: string; tail?: string }
export default async function UpdateCommandsPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.helpInfo.updateCommands" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { helpInfo: { updateCommands: {
commandGroups: CommandGroup[]
related: { items: RelatedItem[] }
} } }
}
const commandGroups = messages.docs.helpInfo.updateCommands.commandGroups
const relatedItems = messages.docs.helpInfo.updateCommands.related.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const upgradeLink = (chunks: React.ReactNode) => (
<Link href="/docs/utils/upgrade-pve8-pve9" className="text-blue-700 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={3}
scriptPath="help_info_menu.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code })}
</Callout>
<CommandTable groups={commandGroups} />
<Callout variant="warning" title={t("backupWarn.title")}>
{t.rich("backupWarn.bodyRich", { code, strong, upgradeLink })}
</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,108 @@
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 { CommandTable, type CommandGroup } from "@/components/ui/command-table"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.helpInfo.vmCtCommands.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"qm command proxmox",
"pct command proxmox",
"proxmox vm commands",
"proxmox lxc commands",
"qm start",
"qm stop",
"pct create",
"pct enter",
"proxmox container commands",
"qm clone",
"qm migrate",
],
alternates: { canonical: "https://proxmenux.com/docs/help-info/vm-ct-commands" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/help-info/vm-ct-commands",
},
twitter: {
card: "summary",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type RelatedItem = { href: string; label: string; tail?: string }
export default async function VMCTCommandsPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.helpInfo.vmCtCommands" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { helpInfo: { vmCtCommands: {
commandGroups: CommandGroup[]
related: { items: RelatedItem[] }
} } }
}
const commandGroups = messages.docs.helpInfo.vmCtCommands.commandGroups
const relatedItems = messages.docs.helpInfo.vmCtCommands.related.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const backupLink = (chunks: React.ReactNode) => (
<Link href="/docs/help-info/backup-commands" className="text-blue-700 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={3}
scriptPath="help_info_menu.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code })}
</Callout>
<CommandTable groups={commandGroups} />
<Callout variant="warning" title={t("destroyWarn.title")}>
{t.rich("destroyWarn.bodyRich", { code, backupLink })}
</Callout>
<Callout variant="tip" title={t("qmConfigTip.title")}>
{t.rich("qmConfigTip.bodyRich", { 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,99 @@
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 { CommandTable, type CommandGroup } from "@/components/ui/command-table"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.helpInfo.zfsCommands.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"zfs proxmox",
"zpool proxmox",
"zfs snapshot",
"zfs send receive",
"zpool scrub",
"zfs replication proxmox",
"zfs commands",
"zpool status",
"proxmox zfs cli",
],
alternates: { canonical: "https://proxmenux.com/docs/help-info/zfs-commands" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/help-info/zfs-commands",
},
twitter: {
card: "summary",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type RelatedItem = { href: string; label: string; tail?: string }
export default async function ZFSManagementPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.helpInfo.zfsCommands" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { helpInfo: { zfsCommands: {
commandGroups: CommandGroup[]
related: { items: RelatedItem[] }
} } }
}
const commandGroups = messages.docs.helpInfo.zfsCommands.commandGroups
const relatedItems = messages.docs.helpInfo.zfsCommands.related.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={5}
scriptPath="help_info_menu.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code })}
</Callout>
<CommandTable groups={commandGroups} />
<Callout variant="tip" title={t("bestPractices.title")}>
{t.rich("bestPractices.bodyRich", { 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,205 @@
import type { Metadata } from "next"
import Image from "next/image"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { Terminal, FileCode, ShieldCheck, ExternalLink } from "lucide-react"
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.installation.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/installation",
},
}
}
type DuringRow = { package: string; purpose: string }
export default async function InstallationPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.installation" })
const messages = (await getMessages({ locale })) as unknown as {
docs: {
installation: {
during: { rows: DuringRow[] }
}
}
}
const duringRows = messages.docs.installation.during.rows
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 internalLink = (href: string, className = "text-blue-600 hover:underline") =>
(chunks: React.ReactNode) => (
<Link href={href} className={className}>
{chunks}
</Link>
)
const extlink = (href: string, className = "text-blue-600 hover:underline inline-flex items-center gap-1") =>
(chunks: React.ReactNode) => (
<a href={href} target="_blank" rel="noopener noreferrer" className={className}>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={3}
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
<Terminal className="inline h-5 w-5 mr-1 -mt-1 text-blue-600" aria-hidden />
{t("stable.heading")}
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("stable.intro")}</p>
<CopyableCode code={t.raw("stable.code") as string} />
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("during.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("during.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("during.tablePackage")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("during.tablePurpose")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{duringRows.map((row, idx) => (
<tr key={row.package} className={idx < duringRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.package}</td>
<td className="px-3 py-2 align-top">{row.purpose}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("during.outro", { code, strong })}</p>
<Image
src="https://macrimi.github.io/ProxMenux/install/install.png"
alt={t("during.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("first.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("first.intro")}</p>
<CopyableCode code={t.raw("first.code") as string} />
<p className="mt-4 mb-4 text-gray-800 leading-relaxed">
{t.rich("first.outro", { postlink: internalLink("/docs/post-install") })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("beta.heading")}</h2>
<Callout variant="info" title={t("beta.calloutTitle")}>
{t.rich("beta.calloutBody", { code })}
</Callout>
<p className="mb-4 text-gray-800 leading-relaxed">{t("beta.intro")}</p>
<CopyableCode code={t.raw("beta.code") as string} />
<p className="mt-4 mb-4 text-gray-800 leading-relaxed">
{t.rich("beta.outro", { code, betalink: internalLink("/docs/settings/beta-program") })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("updating.heading")}</h2>
<p className="mb-6 text-gray-800 leading-relaxed">{t.rich("updating.body", { code })}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("uninstall.heading")}</h2>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("uninstall.body", {
strong,
code,
uninstalllink: internalLink("/docs/settings/uninstall-proxmenux"),
})}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.virustotalTitle")}>
{t.rich("troubleshoot.virustotalBody", {
code,
em,
issuelink: extlink("https://github.com/MacRimi/ProxMenux/issues/162"),
})}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.aptTitle")}>
{t.rich("troubleshoot.aptBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.menuTitle")}>
{t.rich("troubleshoot.menuBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.stuckTitle")}>
{t.rich("troubleshoot.stuckBody", { code, strong })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.otherTitle")}>
{t.rich("troubleshoot.otherBody", {
code,
issueslink: extlink("https://github.com/MacRimi/ProxMenux/issues"),
})}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("next.heading")}</h2>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
<li>{t.rich("next.postInstall", { postlink: internalLink("/docs/post-install") })}</li>
<li>{t.rich("next.introduction", { introlink: internalLink("/docs/introduction") })}</li>
<li>{t.rich("next.monitor", { monitorlink: internalLink("/docs/settings/proxmenux-monitor") })}</li>
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("requirements.heading")}</h2>
<Callout variant="info" title={t("requirements.reqTitle")}>
{t.rich("requirements.reqBody", { strong })}
</Callout>
<Callout variant="info" title={t("requirements.inspectTitle")}>
<ul className="list-disc pl-6 mt-1 mb-0 space-y-1">
<li>
<FileCode className="inline h-4 w-4 mr-1 -mt-0.5 text-blue-600" aria-hidden />{" "}
{t.rich("requirements.inspectReview", {
sourcelink: extlink(
"https://github.com/MacRimi/ProxMenux/blob/main/install_proxmenux.sh",
"text-blue-700 hover:underline inline-flex items-center gap-1",
),
})}
</li>
<li>
<ShieldCheck className="inline h-4 w-4 mr-1 -mt-0.5 text-emerald-600" aria-hidden />{" "}
{t.rich("requirements.inspectCoc", {
coclink: extlink(
"https://github.com/MacRimi/ProxMenux?tab=coc-ov-file#-2-security--code-responsibility",
"text-blue-700 hover:underline inline-flex items-center gap-1",
),
})}
</li>
</ul>
</Callout>
</div>
)
}

View File

@@ -0,0 +1,339 @@
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,
Server,
Cpu,
HardDrive,
Network,
Shield,
Activity,
Wrench,
Boxes,
BookOpen,
Bell,
Sparkles,
Terminal,
Code2,
Plug,
ExternalLink,
} 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.introduction.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"proxmenux",
"proxmox menu script",
"proxmox management tool",
"proxmox tui",
"proxmox cli",
"proxmox dashboard",
"proxmox open source",
"proxmox automation",
"proxmox helper script",
"proxmox post install",
"proxmox community tool",
],
alternates: { canonical: "https://proxmenux.com/docs/introduction" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/introduction",
siteName: "ProxMenux",
images: [
{
url: "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/main.png",
width: 1363,
height: 735,
alt: "ProxMenux — Menu-Driven Tool for Proxmox VE",
},
],
},
twitter: {
card: "summary_large_image",
title: t("twitterTitle"),
description: t("twitterDescription"),
images: [
"https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/main.png",
],
},
}
}
const iconMap: Record<string, React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>> = {
Server,
Cpu,
HardDrive,
Network,
Shield,
Activity,
Wrench,
Boxes,
BookOpen,
Bell,
Sparkles,
Terminal,
Code2,
Plug,
}
type FeatureItem = {
title: string
description: string
icon: string
href: string
}
type InstallRow = { pathRich: string; bundles: string; when: string }
type NextItem = {
lead: string
linkHref: string
linkLabel: string
tail?: string
tailRich?: string
}
function FeatureCard({
title,
description,
Icon,
href,
}: {
title: string
description: string
Icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>
href: string
}) {
return (
<Link
href={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">
{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">{description}</div>
</div>
</Link>
)
}
export default async function IntroductionPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.introduction" })
const messages = (await getMessages({ locale })) as unknown as {
docs: {
introduction: {
twoProducts: { layers: string[] }
scriptsSection: { items: FeatureItem[] }
monitorSection: { items: FeatureItem[] }
installPaths: { rows: InstallRow[] }
next: { items: NextItem[] }
}
}
}
const block = messages.docs.introduction
const layers = block.twoProducts.layers
const scriptItems = block.scriptsSection.items
const monitorItems = block.monitorSection.items
const installRows = block.installPaths.rows
const nextItems = block.next.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 github = (chunks: React.ReactNode) => (
<a
href="https://github.com/MacRimi/ProxMenux"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
const monitorOverviewLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
const installationLink = (chunks: React.ReactNode) => (
<Link href="/docs/installation" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={5}
/>
<div className="my-6 not-prose flex flex-col items-center gap-4 rounded-lg border border-gray-200 bg-gradient-to-br from-blue-50 to-white p-6 sm:flex-row sm:items-start">
<Image
src="https://macrimi.github.io/ProxMenux/logo.png"
alt="ProxMenux Logo"
width={96}
height={96}
className="flex-shrink-0"
/>
<div className="text-center sm:text-left">
<p className="text-base text-gray-800 leading-relaxed mb-3">
{t.rich("hero.tagline", { strong })}
</p>
<p className="text-sm text-gray-700 m-0">
{t.rich("hero.audience", { em, github })}
</p>
</div>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("twoProducts.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("twoProducts.intro")}</p>
<div className="grid gap-4 md:grid-cols-2 mb-6 not-prose">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex items-center gap-2 mb-2">
<Terminal className="h-5 w-5 text-blue-600" aria-hidden />
<h3 className="text-base font-semibold text-gray-900 m-0">{t("twoProducts.scripts.title")}</h3>
</div>
<p className="text-sm text-gray-700 leading-relaxed m-0">
{t.rich("twoProducts.scripts.body", { code })}
</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex items-center gap-2 mb-2">
<Activity className="h-5 w-5 text-blue-600" aria-hidden />
<h3 className="text-base font-semibold text-gray-900 m-0">{t("twoProducts.monitor.title")}</h3>
</div>
<p className="text-sm text-gray-700 leading-relaxed m-0">
{t("twoProducts.monitor.body")}
</p>
</div>
</div>
<Callout variant="info" title={t("twoProducts.calloutTitle")}>
{t("twoProducts.calloutIntro")}
<ul className="list-disc pl-6 mt-2 mb-0 space-y-1">
{layers.map((_, idx) => (
<li key={idx}>{t.rich(`twoProducts.layers.${idx}`, { strong })}</li>
))}
</ul>
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("scriptsSection.heading")}</h2>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("scriptsSection.intro", { code })}
</p>
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
{scriptItems.map((f) => {
const Icon = iconMap[f.icon] ?? Server
return <FeatureCard key={f.href} title={f.title} description={f.description} Icon={Icon} href={f.href} />
})}
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("monitorSection.heading")}</h2>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("monitorSection.intro", { link: monitorOverviewLink })}
</p>
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
{monitorItems.map((f) => {
const Icon = iconMap[f.icon] ?? Activity
return <FeatureCard key={f.href} title={f.title} description={f.description} Icon={Icon} href={f.href} />
})}
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("installPaths.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("installPaths.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("installPaths.headerPath")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("installPaths.headerBundles")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{installRows.map((row, idx) => (
<tr key={idx} className={idx < installRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap">
{t.rich(`installPaths.rows.${idx}.pathRich`, { strong })}
</td>
<td className="px-3 py-2 align-top">{row.bundles}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("installPaths.outro", { link: installationLink })}
</p>
<Callout variant="warning" title={t("warnSource.title")}>
{t("warnSource.body")}{" "}
<a
href="https://github.com/MacRimi/ProxMenux/tree/main/scripts"
target="_blank"
rel="noopener noreferrer"
className="text-amber-700 hover:underline inline-flex items-center gap-1"
>
{t("warnSource.sourceLabel")}
<ExternalLink className="w-3 h-3" />
</a>
{" "}·{" "}
<a
href="https://github.com/MacRimi/ProxMenux?tab=coc-ov-file#-2-security--code-responsibility"
target="_blank"
rel="noopener noreferrer"
className="text-amber-700 hover:underline inline-flex items-center gap-1"
>
{t("warnSource.cocLabel")}
<ExternalLink className="w-3 h-3" />
</a>
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("next.heading")}</h2>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{nextItems.map((item, idx) => (
<li key={idx}>
{t.rich(`next.items.${idx}.lead`, { strong })}
<Link href={item.linkHref} className="text-blue-600 hover:underline">
{item.linkLabel}
</Link>
{item.tailRich ? t.rich(`next.items.${idx}.tailRich`, { code }) : item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,43 @@
import type React from "react"
import DocSidebar from "@/components/DocSidebar"
import Footer from "@/components/footer"
import { DocNavigation } from "@/components/ui/doc-navigation"
import { DocBreadcrumb } from "@/components/DocBreadcrumb"
import { DocTableOfContents } from "@/components/DocTableOfContents"
/**
* Docs layout — three-column shell matching the Hermes / Docusaurus
* pattern the user asked for:
*
* ┌─────────┬───────────────────────┬─────────┐
* │ Sidebar │ Breadcrumb + Article │ ToC │
* │ (fixed) │ (scrollable) │ (sticky)│
* └─────────┴───────────────────────┴─────────┘
*
* - Sidebar: 18 rem, fixed left on lg+, slide-down drawer on mobile.
* - Main: max width capped at ~980 px for comfortable line length.
* - ToC: 14 rem, sticky right rail, only shown on xl+ where there is
* enough horizontal room to display it without crowding the article.
*/
export default function DocsLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex flex-col min-h-screen bg-white text-gray-900">
<DocSidebar />
<div className="flex flex-col flex-1 pt-16 lg:pt-0 lg:pl-72">
<div className="flex flex-1">
<main className="flex-1 min-w-0 p-4 lg:p-6 pt-6 lg:pt-6">
<div className="max-w-3xl mx-auto" style={{ maxWidth: "980px" }}>
<DocBreadcrumb />
{children}
<DocNavigation />
</div>
</main>
<aside className="hidden xl:block w-56 shrink-0 py-6 pr-6">
<DocTableOfContents />
</aside>
</div>
<Footer />
</div>
</div>
)
}

View File

@@ -0,0 +1,710 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { ExternalLink } from "lucide-react"
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.monitor.accessAuth.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"proxmox 2fa",
"proxmox totp",
"proxmox dashboard authentication",
"proxmox user profile",
"proxmox dashboard avatar",
"proxmox api tokens",
"proxmox reverse proxy",
"proxmox nginx",
"proxmox caddy",
"proxmox traefik",
"proxmox fail2ban dashboard",
],
alternates: { canonical: "https://proxmenux.com/docs/monitor/access-auth" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/monitor/access-auth",
},
twitter: {
card: "summary",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type Row2 = { button: string; what: string; api: string }
type FieldRow = { field: string; required: string; notes: string }
type EndpointRow = { endpoint: string; what: string }
type CryptoRow = { asset: string; algorithm: string; where: string }
type AppRow = { name: string; href: string; platforms: string; notes: string }
type WhereNextItem = { label: string; href: string; tail?: string; tailRich?: string }
export default async function MonitorAccessAuthPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.accessAuth" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { accessAuth: {
firstLaunch: {
rows: Row2[]
fieldRows: FieldRow[]
endpointRows: EndpointRow[]
}
password: {
items: string[]
publicItems: string[]
cryptoRows: CryptoRow[]
}
twofa: {
apps: AppRow[]
setupSteps: string[]
setupStep4Sub: string[]
lostItems: string[]
rejectedItems: string[]
}
apiTokens: { generateSteps: string[]; cheatItems: string[] }
https: { items: string[] }
fail2ban: { items: string[] }
whereNext: { items: WhereNextItem[] }
} } }
}
const aa = messages.docs.monitor.accessAuth
const firstLaunchRows = aa.firstLaunch.rows
const fieldRows = aa.firstLaunch.fieldRows
const endpointRows = aa.firstLaunch.endpointRows
const passwordItems = aa.password.items
const publicItems = aa.password.publicItems
const cryptoRows = aa.password.cryptoRows
const apps = aa.twofa.apps
const setupSteps = aa.twofa.setupSteps
const setupStep4Sub = aa.twofa.setupStep4Sub
const lostItems = aa.twofa.lostItems
const rejectedItems = aa.twofa.rejectedItems
const generateSteps = aa.apiTokens.generateSteps
const cheatItems = aa.apiTokens.cheatItems
const httpsItems = aa.https.items
const fail2banItems = aa.fail2ban.items
const whereNextItems = aa.whereNext.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 apiLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor" className="text-blue-600 hover:underline">{chunks}</Link>
)
const intLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor" className="text-blue-600 hover:underline">{chunks}</Link>
)
const gatewayLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor" className="text-blue-600 hover:underline">{chunks}</Link>
)
const fail2banLink = (chunks: React.ReactNode) => (
<Link href="/docs/security/fail2ban" className="text-blue-700 hover:underline">{chunks}</Link>
)
const tailscaleAnchor = (chunks: React.ReactNode) => (
<a href="https://tailscale.com" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
{chunks}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
)
const tsKeysAnchor = (chunks: React.ReactNode) => (
<a href="https://login.tailscale.com/admin/settings/keys" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
{chunks}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={15}
/>
<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("reaching.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("reaching.intro", { code })}
</p>
<CopyableCode
code={`# 1) Direct on the LAN
http://<proxmox-ip>:8008
# 2) Behind a reverse proxy with a dedicated host name (recommended off-LAN)
https://monitor.example.com
# 3) Through Secure Gateway (Tailscale) — same LAN URL, from anywhere
http://<proxmox-lan-ip>:8008 # works from any device on your tailnet`}
className="my-4"
/>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("reaching.outro", { code })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("firstLaunch.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("firstLaunch.intro", { code, em })}
</p>
<figure className="my-6">
<img src="/monitor/auth-setup.png" alt={t("firstLaunch.imageAlt")} className="rounded-lg border border-gray-200 shadow-sm w-full" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("firstLaunch.imageCaption")}</figcaption>
</figure>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerButton")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerWhat")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerApi")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{firstLaunchRows.map((row, idx) => (
<tr key={row.button} className={idx < firstLaunchRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.button}</strong></td>
<td className="px-3 py-2 align-top">{t.rich(`firstLaunch.rows.${idx}.what`, { em, code })}</td>
<td className="px-3 py-2 align-top font-mono text-xs">{row.api}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="info" title={t("firstLaunch.twofaCalloutTitle")}>
{t.rich("firstLaunch.twofaCalloutBody", { strong })}
</Callout>
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("firstLaunch.createTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("firstLaunch.createIntro", { em })}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerField")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerRequired")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerNotes")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{fieldRows.map((row, idx) => (
<tr key={row.field} className={idx < fieldRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.field}</strong></td>
<td className="px-3 py-2 align-top">{row.required}</td>
<td className="px-3 py-2 align-top">{t.rich(`firstLaunch.fieldRows.${idx}.notes`, { code, strong })}</td>
</tr>
))}
</tbody>
</table>
</div>
<figure className="my-6">
<img src="/monitor/security/create-user-form.png" alt={t("firstLaunch.createImageAlt")} className="rounded-lg border border-gray-200 shadow-sm w-full" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("firstLaunch.createImageCaption")}</figcaption>
</figure>
<Callout variant="info" title={t("firstLaunch.saveCalloutTitle")}>
{t.rich("firstLaunch.saveCalloutBody", { code })}
</Callout>
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("firstLaunch.avatarTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("firstLaunch.avatarBody1", { strong })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">{t("firstLaunch.avatarBody2")}</p>
<figure className="my-6">
<img src="/monitor/security/profile-page.png" alt={t("firstLaunch.profileImageAlt")} className="rounded-lg border border-gray-200 shadow-sm w-full" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("firstLaunch.profileImageCaption")}</figcaption>
</figure>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerEndpoint")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerEpWhat")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{endpointRows.map((row, idx) => (
<tr key={row.endpoint} className={idx < endpointRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
<td className="px-3 py-2 align-top">{t.rich(`firstLaunch.endpointRows.${idx}.what`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="warning" title={t("firstLaunch.reversibleTitle")}>
{t.rich("firstLaunch.reversibleBody", { em, strong, code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("password.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("password.intro", { code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{passwordItems.map((_, idx) => (
<li key={idx}>{t.rich(`password.items.${idx}`, { strong, code })}</li>
))}
</ul>
<figure className="my-6">
<img src="/monitor/login-screen.png" alt={t("password.loginImageAlt")} className="rounded-lg border border-gray-200 shadow-sm w-full" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("password.loginImageCaption")}</figcaption>
</figure>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("password.loginFlowTitle")}</h3>
<CopyableCode
code={`# Without 2FA
curl -X POST http://<host>:8008/api/auth/login \\
-H "Content-Type: application/json" \\
-d '{"username":"<user>","password":"<password>"}'
# Response
{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIs..."
}`}
className="my-4"
/>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("password.twofaIntro", { code })}
</p>
<CopyableCode
code={`curl -X POST http://<host>:8008/api/auth/login \\
-H "Content-Type: application/json" \\
-d '{"username":"<user>","password":"<password>","totp_token":"123456"}'`}
className="my-4"
/>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("password.publicTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("password.publicIntro")}</p>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{publicItems.map((_, idx) => (
<li key={idx}>{t.rich(`password.publicItems.${idx}`, { code })}</li>
))}
</ul>
<h3 id="security-model" className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("password.cryptoTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("password.cryptoIntro", { code })}
</p>
<div className="overflow-x-auto mb-4">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("password.headerAsset")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("password.headerAlgo")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("password.headerWhere")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{cryptoRows.map((row, idx) => (
<tr key={row.asset} className={idx < cryptoRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top"><strong>{row.asset}</strong></td>
<td className="px-3 py-2 align-top">{t.rich(`password.cryptoRows.${idx}.algorithm`, { code })}</td>
<td className="px-3 py-2 align-top">{t.rich(`password.cryptoRows.${idx}.where`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="info" title={t("password.authJsonTitle")}>
{t.rich("password.authJsonBody", { code, em })}
</Callout>
<Callout variant="warning" title={t("password.rotateTitle")}>
{t.rich("password.rotateBody", { code, strong })}
</Callout>
<h3 id="recovering-password" className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("password.recoverTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("password.recoverIntro", { code })}
</p>
<CopyableCode
code={`# 1. Run the ProxMenux menu as root
menu
# 2. Settings → Reset ProxMenux Monitor Password
# The menu will:
# - Back up auth.json to auth.json.bak-<UTC timestamp>
# - Stop the proxmenux-monitor service
# - Clear username / password_hash / TOTP secret / backup codes
# - Keep jwt_secret and api_tokens intact
# - Restart the service
# 3. Open the dashboard at http://<host>:8008
# The setup wizard appears — create a new admin account.`}
className="my-4"
/>
<Callout variant="info" title={t("password.survivesTitle")}>
{t.rich("password.survivesBody", { code })}
</Callout>
<Callout variant="warning" title={t("password.physicalTitle")}>
{t.rich("password.physicalBody", { strong, code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("twofa.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("twofa.intro", { strong })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("twofa.pickTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("twofa.pickIntro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("twofa.headerApp")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("twofa.headerPlatforms")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("twofa.headerAppNotes")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{apps.map((row, idx) => (
<tr key={row.name} className={idx < apps.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap">
<a
href={row.href}
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-blue-600 hover:underline inline-flex items-center gap-1"
>
{row.name}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</td>
<td className="px-3 py-2 align-top">{row.platforms}</td>
<td className="px-3 py-2 align-top">{row.notes}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="tip" title={t("twofa.backupTitle")}>
{t("twofa.backupBody")}
</Callout>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("twofa.setupTitle")}</h3>
<figure className="my-6">
<img src="/monitor/2fa-setup.png" alt={t("twofa.setupImageAlt")} className="rounded-lg border border-gray-200 shadow-sm w-full" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("twofa.setupImageCaption")}</figcaption>
</figure>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-3">
{setupSteps.map((_, idx) => (
<li key={idx}>
{t.rich(`twofa.setupSteps.${idx}`, { strong, em, code })}
{idx === 3 && (
<ul className="list-disc pl-6 mt-2 space-y-1">
{setupStep4Sub.map((_, sIdx) => (
<li key={sIdx}>{t.rich(`twofa.setupStep4Sub.${sIdx}`, { em, code })}</li>
))}
</ul>
)}
</li>
))}
</ol>
<Callout variant="warning" title={t("twofa.testTitle")}>
{t.rich("twofa.testBody", { em, code })}
</Callout>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("twofa.lostTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("twofa.lostIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{lostItems.map((_, idx) => (
<li key={idx}>
{t.rich(`twofa.lostItems.${idx}`, { strong, code })}
{idx === 2 && (
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{`systemctl restart proxmenux-monitor.service`}</pre>
)}
</li>
))}
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">{t("twofa.lostShellOutro")}</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("twofa.disableTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("twofa.disableBody", { strong, code })}
</p>
<Callout variant="troubleshoot" title={t("twofa.rejectedTitle")}>
{t("twofa.rejectedIntro")}
<ul className="list-disc pl-5 mt-2 space-y-1">
{rejectedItems.map((_, idx) => (
<li key={idx}>{t.rich(`twofa.rejectedItems.${idx}`, { strong, code })}</li>
))}
</ul>
{t("twofa.rejectedOutro")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("apiTokens.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("apiTokens.intro", { strong, code })}
</p>
<figure className="my-6">
<img src="/monitor/api-tokens.png" alt={t("apiTokens.imageAlt")} className="rounded-lg border border-gray-200 shadow-sm w-full" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("apiTokens.imageCaption")}</figcaption>
</figure>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("apiTokens.generateTitle")}</h3>
<p className="mb-3 text-gray-800 leading-relaxed">{t("apiTokens.generateIntro")}</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{generateSteps.map((_, idx) => (
<li key={idx}>{t.rich(`apiTokens.generateSteps.${idx}`, { strong, em })}</li>
))}
</ol>
<p className="mb-3 text-gray-800 leading-relaxed">{t("apiTokens.generateCli")}</p>
<CopyableCode
code={`curl -X POST http://<host>:8008/api/auth/generate-api-token \\
-H "Authorization: Bearer <session-token>" \\
-H "Content-Type: application/json" \\
-d '{
"password": "<your-password>",
"totp_token": "123456",
"token_name": "Home Assistant"
}'
# Response — the "token" field is the only place the token appears.
{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_name": "Home Assistant",
"expires_in": "365 days"
}`}
className="my-4"
/>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("apiTokens.useTitle")}</h3>
<CopyableCode
code={`curl -H "Authorization: Bearer <api-token>" \\
http://<host>:8008/api/system`}
className="my-4"
/>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("apiTokens.revokeTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("apiTokens.revokeBody", { strong, code })}
</p>
<CopyableCode
code={`# Same operation via API
curl -X DELETE http://<host>:8008/api/auth/api-tokens/<token-id> \\
-H "Authorization: Bearer <session-token>"`}
className="my-4"
/>
<Callout variant="tip" title={t("apiTokens.cheatTitle")}>
<ul className="list-disc pl-5 mt-2 space-y-1">
{cheatItems.map((_, idx) => (
<li key={idx}>{t.rich(`apiTokens.cheatItems.${idx}`, { code })}</li>
))}
</ul>
</Callout>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("apiTokens.outro", { apiLink, intLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("https.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("https.intro")}</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{httpsItems.map((_, idx) => (
<li key={idx}>{t.rich(`https.items.${idx}`, { strong, code })}</li>
))}
</ol>
<Callout variant="warning" title={t("https.calloutTitle")}>
{t("https.calloutBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("gateway.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("gateway.intro", { strong, a: tailscaleAnchor })}
</p>
<Callout variant="tip" title={t("gateway.calloutTitle")}>
{t.rich("gateway.calloutBody", { code })}
</Callout>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("gateway.deployBody", { a: tsKeysAnchor })}
</p>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("gateway.outro", { link: gatewayLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("proxy.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("proxy.intro", { strong, code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("proxy.nginxTitle")}</h3>
<CopyableCode
code={`# /etc/nginx/sites-available/proxmenux-monitor.conf
server {
listen 443 ssl http2;
server_name monitor.example.com;
ssl_certificate /etc/letsencrypt/live/monitor.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/monitor.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8008;
proxy_http_version 1.1;
# WebSocket upgrade (terminal tab)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Real client IP — required for the auth log + Fail2Ban hook
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# Long-running terminal sessions
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}`}
className="my-4"
/>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("proxy.caddyTitle")}</h3>
<CopyableCode
code={`# Caddyfile
monitor.example.com {
reverse_proxy 127.0.0.1:8008 {
# Caddy auto-handles WebSocket upgrades and forwards X-Forwarded-* by default.
header_up Host {host}
header_up X-Real-IP {remote}
}
}`}
className="my-4"
/>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("proxy.traefikTitle")}</h3>
<CopyableCode
code={`# docker-compose snippet, or equivalent IngressRoute on Kubernetes
labels:
- "traefik.enable=true"
- "traefik.http.routers.proxmenux.rule=Host(\`monitor.example.com\`)"
- "traefik.http.routers.proxmenux.tls=true"
- "traefik.http.routers.proxmenux.tls.certresolver=letsencrypt"
- "traefik.http.services.proxmenux.loadbalancer.server.port=8008"
# WebSocket and forwarded headers are on by default in Traefik.`}
className="my-4"
/>
<Callout variant="tip" title={t("proxy.subPathTitle")}>
{t.rich("proxy.subPathBody", { code, strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("audit.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("audit.intro", { code })}
</p>
<CopyableCode
code={`# Failed login from 192.0.2.10 (real IP recovered from X-Forwarded-For)
2026-04-24 14:32:11 WARNING proxmenux.auth: authentication failure; rhost=192.0.2.10 user=admin
# Successful login
2026-04-24 14:32:18 INFO proxmenux.auth: authentication success; rhost=192.0.2.10 user=admin`}
className="my-4"
/>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("audit.outro", { code })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("fail2ban.heading")}</h2>
<Callout variant="info" title={t("fail2ban.calloutTitle")}>
{t.rich("fail2ban.calloutBody", { strong, link: fail2banLink })}
</Callout>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("fail2ban.intro", { code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{fail2banItems.map((_, idx) => (
<li key={idx}>{t.rich(`fail2ban.items.${idx}`, { code })}</li>
))}
</ul>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("fail2ban.outro", { link: fail2banLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.noScreenTitle")}>
{t.rich("troubleshoot.noScreenBody", { code })}
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{`rm /root/.config/proxmenux-monitor/auth.json
systemctl restart proxmenux-monitor.service`}</pre>
{t.rich("troubleshoot.noScreenOutro", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.tokenTitle")}>
{t.rich("troubleshoot.tokenBody", { code })}
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{`curl -H "Authorization: Bearer <token>" \\
http://<host>:8008/api/system | jq .`}</pre>
{t.rich("troubleshoot.tokenOutro", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.no2faTitle")}>
{t.rich("troubleshoot.no2faBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.wsTitle")}>
{t.rich("troubleshoot.wsBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.map((item, idx) => (
<li key={item.href + idx}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,726 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import Image from "next/image"
import { ExternalLink } from "lucide-react"
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.monitor.aiAssistant.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"proxmox ai",
"proxmox openai integration",
"proxmox claude",
"proxmox gemini",
"proxmox ollama",
"proxmox local ai",
"proxmox groq",
"proxmox openrouter",
"proxmox notification rewrite",
"proxmox llm",
"proxmenux ai assistant",
"proxmox ai prompt",
],
alternates: { canonical: "https://proxmenux.com/docs/monitor/ai-assistant" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/monitor/ai-assistant",
},
twitter: {
card: "summary_large_image",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
const DEFAULT_SYSTEM_PROMPT = `You are a notification FORMATTER for ProxMenux Monitor (Proxmox VE).
Your job: translate alerts into {language} and enrich them with context when provided.
═══ ABSOLUTE CONSTRAINTS (NO EXCEPTIONS) ═══
- NO HALLUCINATIONS: Do not invent causes, solutions, or facts not present in the provided data
- NO SPECULATION: If something is unclear, state what IS known, not what MIGHT be
- NO CONVERSATIONAL TEXT: Never write "Here is...", "I've translated...", "Let me explain..."
- ONLY use information from: the message, journal context, and known error database (if provided)
═══ WHAT TO TRANSLATE ═══
Translate: labels, descriptions, status words, units (GB→Go in French, etc.)
DO NOT translate: hostnames, IPs, paths, VM/CT IDs, device names (/dev/sdX), technical identifiers
═══ CORE RULES ═══
1. Plain text only — NO markdown, no **bold**, no \`code\`, no bullet lists (use "• " for packages only)
2. Preserve severity: "failed" stays "failed", "warning" stays "warning" — never soften errors
3. Preserve structure: keep same fields and line order, only translate content
4. Detail level "{detail_level}" - controls AMOUNT OF EVENT INFO (not tips/suggestions):
- brief: 1-2 lines max. Only: what happened + where
- standard: 3-6 lines. Include: what, where, cause, affected devices
- detailed: Full report with ALL info: what, where, cause, affected, logs, SMART data, history
5. DEDUPLICATION: merge duplicate facts from multiple sources into one clear statement
6. EMPTY LISTS: write translated "none" after label, never leave blank
7. Keep "hostname:" prefix in title — translate only the descriptive part
8. DO NOT add recommendations or suggestions UNLESS AI Suggestions mode is enabled below
9. ENRICHED CONTEXT: You may receive additional context data including:
- "System uptime: X days (stable system)" → helps distinguish startup issues from runtime failures
- "Event frequency: N occurrences, first seen X ago" → indicates recurring vs one-time issues
- "SMART Health: PASSED/FAILED" with disk attributes → critical for disk errors
- "KNOWN PROXMOX ERROR DETECTED" with cause/solution → YOU MUST USE this exact information
How to use enriched context:
- If uptime is <10min and error is service-related → mention "occurred shortly after boot"
- If frequency shows recurring pattern → mention "recurring issue (N times in X hours)"
- If SMART shows FAILED → treat as CRITICAL: "Disk failing - immediate attention required"
- If KNOWN ERROR is provided → YOU MUST incorporate its Cause and Solution (translate, don't copy verbatim)
10. JOURNAL CONTEXT EXTRACTION: When journal logs are provided:
- Extract specific IDs (VM/CT numbers, disk devices, service names)
- Include relevant timestamps if they help explain the timeline
- Identify root cause when logs clearly show it (e.g., "exit-code 255" -> "process crashed")
- Translate technical terms: "Emask 0x10" -> "ATA bus error", "DRDY ERR" -> "drive not ready"
- If logs show the same error repeating, state frequency: "occurred 15 times in 10 minutes"
- IGNORE journal entries unrelated to the main event
11. OUTPUT ONLY the final result — no "Original:", no before/after comparisons
12. Unknown input: preserve as closely as possible, translate what you can
13. REDUNDANCY: Never repeat the same information twice. If title says "CT 103 failed", body should not start with "Container 103 failed"
{suggestions_addon}
═══ PROXMOX MAPPINGS (use directly, never explain) ═══
pve-container@XXXX → "CT XXXX" | qemu-server@XXXX → "VM XXXX" | vzdump → "backup"
pveproxy/pvedaemon/pvestatd → "Proxmox service" | corosync → "cluster service"
"ata8.00: exception Emask..." → "ATA error on port 8"
"blk_update_request: I/O error, dev sdX" → "I/O error on /dev/sdX"
{emoji_instructions}
═══ MESSAGE FORMATS ═══
BACKUP: List each VM/CT with status/size/duration/storage. End with summary.
- Partial failure (some OK, some failed) = "Backup partially failed", not "failed"
- NEVER collapse multi-VM backup into one line — show each VM separately
- ALWAYS include storage path and summary line
UPDATES: Counts on own lines. Packages use "• " under header. No redundant summary.
DISK/SMART: Device + specific error. Deduplicate repeated info.
HEALTH: Category + severity + what changed. Duration if resolved.
VM/CT LIFECYCLE: Confirm event with key facts (1-2 lines).
═══ OUTPUT FORMAT (CRITICAL - MUST FOLLOW EXACTLY) ═══
Your response MUST have EXACTLY this structure:
[TITLE]
your translated title text
[BODY]
your translated body text
ABSOLUTE RULES (violations break the parser):
1. [TITLE] and [BODY] are INVISIBLE PARSING MARKERS — they separate title from body
2. Your actual title/body content must NEVER contain the words "[TITLE]" or "[BODY]"
3. Your actual title/body content must NEVER contain "Title:" or "Body:" prefixes
4. Line 1: write exactly [TITLE]
5. Line 2: write your title text (emoji + hostname: description)
6. Line 3: write exactly [BODY]
7. Line 4+: write your body text
- Output ONLY the formatted result — no explanations, no "Original:", no commentary`
const SUGGESTIONS_ADDON = `═══ AI SUGGESTIONS MODE (ENABLED) ═══
You MAY add ONE brief, actionable tip at the END of the body using this exact format:
💡 Tip: [your concise suggestion here]
Rules for the tip:
- ONLY include if the log context or Known Error database clearly points to a specific fix
- Keep under 100 characters
- Be specific: "Run 'pvecm status' to check quorum" NOT "Check cluster status"
- If Known Error provides a solution, YOU MUST USE IT (don't invent your own)
- Never guess — skip the tip if the cause/solution is unclear`
const EXAMPLE_CUSTOM_PROMPT = `You are a notification formatter for ProxMenux Monitor.
Your task is to translate and format server notifications.
RULES:
1. Translate to the user's preferred language
2. Use plain text only (no markdown, no bold, no italic)
3. Be concise and factual
4. Do not add recommendations or suggestions
5. Present only the facts from the input
6. Keep hostname prefix in titles (e.g., "pve01: ")
OUTPUT FORMAT:
[TITLE]
your translated title here
[BODY]
your translated message here
Detail levels:
- brief: 2-3 lines, essential only
- standard: short paragraph with key details
- detailed: full technical breakdown`
type ContextRow = { block: string; when: string; what: string }
type CapRow = { level: string; cap: string; consumption: string }
type DetailRow = { level: string; label: string; cap: string; produce: string }
type PrivacyRow = { provider: string; destination: string }
type WhereNextItem = { label: string; href: string; tail: string }
export default async function AIAssistantPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.aiAssistant" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { aiAssistant: {
howItWorks: { steps: string[]; notes: string[] }
context: { rows: ContextRow[] }
tokens: { items: string[]; capRows: CapRow[] }
providers: {
groq: { items: string[] }
openai: { items: string[] }
anthropic: { items: string[] }
gemini: { items: string[] }
openrouter: { items: string[] }
ollama: { items: string[] }
}
models: { consequences: string[] }
defaultPrompt: { passages: string[] }
customPrompt: { changes: string[] }
suggestions: { rules: string[] }
detailLevel: { rows: DetailRow[]; defaults: string[] }
language: { rules: string[] }
privacy: { rows: PrivacyRow[] }
whereNext: { items: WhereNextItem[] }
} } }
}
const ai = messages.docs.monitor.aiAssistant
const howSteps = ai.howItWorks.steps
const howNotes = ai.howItWorks.notes
const contextRows = ai.context.rows
const tokensItems = ai.tokens.items
const tokensCapRows = ai.tokens.capRows
const groqItems = ai.providers.groq.items
const openaiItems = ai.providers.openai.items
const anthropicItems = ai.providers.anthropic.items
const geminiItems = ai.providers.gemini.items
const openrouterItems = ai.providers.openrouter.items
const ollamaItems = ai.providers.ollama.items
const modelsConsequences = ai.models.consequences
const defaultPassages = ai.defaultPrompt.passages
const customChanges = ai.customPrompt.changes
const suggestionsRules = ai.suggestions.rules
const detailLevelRows = ai.detailLevel.rows
const detailLevelDefaults = ai.detailLevel.defaults
const languageRules = ai.language.rules
const privacyRows = ai.privacy.rows
const whereNextItems = ai.whereNext.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 detailLink = (chunks: React.ReactNode) => (
<Link href="#detail-level-per-channel" className="text-blue-600 hover:underline">{chunks}</Link>
)
const notifLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/notifications" className="text-blue-600 hover:underline">{chunks}</Link>
)
const providerLink = (href: string) => (chunks: React.ReactNode) =>
(
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={25}
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howItWorks.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("howItWorks.intro")}</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
{howSteps.map((_, idx) => (
<li key={idx}>{t.rich(`howItWorks.steps.${idx}`, { strong, code, em })}</li>
))}
</ol>
<p className="mb-4 text-gray-800 leading-relaxed">{t("howItWorks.notesIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{howNotes.map((_, idx) => (
<li key={idx}>{t.rich(`howItWorks.notes.${idx}`, { strong })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("enabling.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("enabling.intro", { em })}
</p>
<figure className="my-4">
<Image src="/monitor/settings/ai-enhancement-collapsed.png" alt={t("enabling.collapsedAlt")} width={2000} height={244} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("enabling.collapsedCaption")}</figcaption>
</figure>
<figure className="my-4">
<Image src="/monitor/settings/ai-enhancement-panel.png" alt={t("enabling.panelAlt")} width={2000} height={1854} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("enabling.panelCaption")}</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("enabling.outro", { em })}
</p>
<h2 id="what-context-the-ai-receives" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
{t("context.heading")}
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("context.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("context.headerBlock")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("context.headerWhen")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("context.headerWhat")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{contextRows.map((row, idx) => (
<tr key={row.block} className={idx < contextRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.block}</strong></td>
<td className="px-3 py-2 align-top">{t.rich(`context.rows.${idx}.when`, { code })}</td>
<td className="px-3 py-2 align-top">{t.rich(`context.rows.${idx}.what`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">{t("context.afterBlocks")}</p>
<CopyableCode
code={`Severity: WARNING
Title: pve01: Disk I/O error
Message:
I/O error on /dev/sda — 1 sector pending reallocation.
Journal log context:
Event frequency: 5 occurrences, first seen 2h ago, recurring
SMART Health: PASSED
SMART attribute Reallocated_Sector_Ct: 1 (raw 1)
SMART attribute Current_Pending_Sector: 1 (raw 1)
Journal logs:
ata8.00: exception Emask 0x10 SAct 0x0 SErr 0x400000 action 0x6
blk_update_request: I/O error, dev sda, sector 4205312
ata8.00: error: { ICRC ABRT }`}
className="my-4"
/>
<Callout variant="info" title={t("context.calloutTitle")}>
{t("context.calloutBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("tokens.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("tokens.intro1", { em })}</p>
<p className="mb-4 text-gray-800 leading-relaxed">{t("tokens.intro2")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{tokensItems.map((_, idx) => (
<li key={idx}>{t.rich(`tokens.items.${idx}`, { strong, em, code })}</li>
))}
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("tokens.capsIntro", { code })}
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("tokens.headerLevel")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("tokens.headerCap")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("tokens.headerConsumption")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{tokensCapRows.map((row, idx) => (
<tr key={row.level} className={idx < tokensCapRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><code>{row.level}</code></td>
<td className="px-3 py-2 align-top">{row.cap}</td>
<td className="px-3 py-2 align-top">{row.consumption}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">{t("tokens.customNote")}</p>
<Callout variant="tip" title={t("tokens.sizingTitle")}>
{t.rich("tokens.sizingBody", { code, link: detailLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("providers.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("providers.intro")}</p>
<figure className="my-4">
<Image src="/monitor/settings/ai-providers-information.png" alt={t("providers.imageAlt")} width={1602} height={2138} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto mx-auto max-w-2xl" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("providers.imageCaption")}</figcaption>
</figure>
<h3 id="provider-groq" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("providers.groq.heading")}</h3>
<p className="mb-2 text-gray-800 leading-relaxed"><em>{t("providers.groq.tagline")}</em></p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{groqItems.map((_, idx) => (
<li key={idx}>{t.rich(`providers.groq.items.${idx}`, { code, strong, a: providerLink("https://console.groq.com/keys") })}</li>
))}
</ul>
<h3 id="provider-openai" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("providers.openai.heading")}</h3>
<p className="mb-2 text-gray-800 leading-relaxed"><em>{t("providers.openai.tagline")}</em></p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{openaiItems.map((_, idx) => (
<li key={idx}>{t.rich(`providers.openai.items.${idx}`, { code, strong, a: providerLink("https://platform.openai.com/api-keys") })}</li>
))}
</ul>
<Callout variant="tip" title={t("providers.openai.baseUrlTitle")}>
{t.rich("providers.openai.baseUrlBody", { em, strong, code })}
</Callout>
<h3 id="provider-anthropic" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("providers.anthropic.heading")}</h3>
<p className="mb-2 text-gray-800 leading-relaxed"><em>{t("providers.anthropic.tagline")}</em></p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{anthropicItems.map((_, idx) => (
<li key={idx}>{t.rich(`providers.anthropic.items.${idx}`, { code, strong, a: providerLink("https://console.anthropic.com/settings/keys") })}</li>
))}
</ul>
<h3 id="provider-gemini" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("providers.gemini.heading")}</h3>
<p className="mb-2 text-gray-800 leading-relaxed"><em>{t("providers.gemini.tagline")}</em></p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{geminiItems.map((_, idx) => (
<li key={idx}>{t.rich(`providers.gemini.items.${idx}`, { code, strong, a: providerLink("https://aistudio.google.com/app/apikey") })}</li>
))}
</ul>
<h3 id="provider-openrouter" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("providers.openrouter.heading")}</h3>
<p className="mb-2 text-gray-800 leading-relaxed"><em>{t("providers.openrouter.tagline")}</em></p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{openrouterItems.map((_, idx) => (
<li key={idx}>{t.rich(`providers.openrouter.items.${idx}`, { code, strong, a: providerLink("https://openrouter.ai/keys") })}</li>
))}
</ul>
<h3 id="provider-ollama" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("providers.ollama.heading")}</h3>
<p className="mb-2 text-gray-800 leading-relaxed"><em>{t("providers.ollama.tagline")}</em></p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{ollamaItems.map((_, idx) => (
<li key={idx}>{t.rich(`providers.ollama.items.${idx}`, { code, strong, em, a: providerLink("https://ollama.com/download") })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("models.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("models.intro", { code })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">{t("models.consequencesIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{modelsConsequences.map((_, idx) => (
<li key={idx}>{t.rich(`models.consequences.${idx}`, { strong })}</li>
))}
</ul>
<Callout variant="info" title={t("models.ollamaTitle")}>
{t("models.ollamaBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("defaultPrompt.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("defaultPrompt.intro", { em, code })}
</p>
<details className="mb-4 rounded-md border border-gray-200 bg-gray-50">
<summary className="cursor-pointer px-4 py-3 font-medium text-gray-900 hover:bg-gray-100 rounded-md">
{t("defaultPrompt.showFullSummary")}
</summary>
<div className="px-4 pb-4">
<pre className="text-xs font-mono text-gray-800 whitespace-pre-wrap leading-relaxed bg-white border border-gray-200 rounded p-3 overflow-x-auto">
{DEFAULT_SYSTEM_PROMPT}
</pre>
</div>
</details>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("defaultPrompt.passagesIntro", { em })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{defaultPassages.map((_, idx) => (
<li key={idx}>{t.rich(`defaultPrompt.passages.${idx}`, { strong })}</li>
))}
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("defaultPrompt.suggestionsPlaceholder", { code })}
</p>
<details className="mb-4 rounded-md border border-gray-200 bg-gray-50">
<summary className="cursor-pointer px-4 py-3 font-medium text-gray-900 hover:bg-gray-100 rounded-md">
{t("defaultPrompt.showAddonSummary")}
</summary>
<div className="px-4 pb-4">
<pre className="text-xs font-mono text-gray-800 whitespace-pre-wrap leading-relaxed bg-white border border-gray-200 rounded p-3 overflow-x-auto">
{SUGGESTIONS_ADDON}
</pre>
</div>
</details>
<h2 id="custom-prompt-mode" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("customPrompt.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("customPrompt.intro", { em })}
</p>
<figure className="my-4">
<Image src="/monitor/settings/ai-custom-prompt.png" alt={t("customPrompt.imageAlt")} width={2000} height={987} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("customPrompt.imageCaption", { em })}</figcaption>
</figure>
<h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("customPrompt.changesTitle")}</h3>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
{customChanges.map((_, idx) => (
<li key={idx}>{t.rich(`customPrompt.changes.${idx}`, { strong, em, code })}</li>
))}
</ul>
<h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("customPrompt.starterTitle")}</h3>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("customPrompt.starterIntro", { em })}
</p>
<details className="mb-4 rounded-md border border-gray-200 bg-gray-50">
<summary className="cursor-pointer px-4 py-3 font-medium text-gray-900 hover:bg-gray-100 rounded-md">
{t("customPrompt.showStarterSummary")}
</summary>
<div className="px-4 pb-4">
<pre className="text-xs font-mono text-gray-800 whitespace-pre-wrap leading-relaxed bg-white border border-gray-200 rounded p-3 overflow-x-auto">
{EXAMPLE_CUSTOM_PROMPT}
</pre>
</div>
</details>
<h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("customPrompt.shareTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("customPrompt.shareIntro", { em, code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
<li>
<a
href="https://github.com/MacRimi/ProxMenux/discussions/categories/share-custom-prompts-for-ai-notifications"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{t("customPrompt.shareLinkLabel")}
<ExternalLink className="w-3 h-3" />
</a>
</li>
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">{t("customPrompt.shareOutro")}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("suggestions.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("suggestions.intro", { strong, em })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">{t("suggestions.formatIntro")}</p>
<CopyableCode
code={`💡 Tip: Run 'pvecm status' to check quorum`}
className="my-4"
/>
<p className="mb-4 text-gray-800 leading-relaxed">{t("suggestions.rulesIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{suggestionsRules.map((_, idx) => (
<li key={idx}>{t.rich(`suggestions.rules.${idx}`, { em })}</li>
))}
</ul>
<Callout variant="warning" title={t("suggestions.betaTitle")}>
{t("suggestions.betaBody")}
</Callout>
<h2 id="detail-level-per-channel" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
{t("detailLevel.heading")}
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("detailLevel.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("detailLevel.headerLevel")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("detailLevel.headerLabel")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("detailLevel.headerCap")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("detailLevel.headerProduce")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{detailLevelRows.map((row, idx) => (
<tr key={row.level} className={idx < detailLevelRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><code>{row.level}</code></td>
<td className="px-3 py-2 align-top">{row.label}</td>
<td className="px-3 py-2 align-top">{row.cap}</td>
<td className="px-3 py-2 align-top">{row.produce}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">{t("detailLevel.defaultsIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{detailLevelDefaults.map((_, idx) => (
<li key={idx}>{t.rich(`detailLevel.defaults.${idx}`, { strong, code })}</li>
))}
</ul>
<Callout variant="info" title={t("detailLevel.emailTitle")}>
{t.rich("detailLevel.emailBody", { code })}
</Callout>
<h2 id="language" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("language.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("language.intro", { code, em })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("language.list", { code })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">{t("language.rulesIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{languageRules.map((_, idx) => (
<li key={idx}>{t.rich(`language.rules.${idx}`, { strong, code })}</li>
))}
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("language.customNote", { strong })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("templates.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("templates.body1", { code })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("templates.body2", { link: notifLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("privacy.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("privacy.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("privacy.headerProvider")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("privacy.headerDestination")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{privacyRows.map((row, idx) => (
<tr key={row.provider} className={idx < privacyRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.provider}</strong></td>
<td className="px-3 py-2 align-top">{t.rich(`privacy.rows.${idx}.destination`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="warning" title={t("privacy.calloutTitle")}>
{t("privacy.calloutBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.map((item) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tail}
</li>
))}
<li>
<a
href="https://github.com/MacRimi/ProxMenux/discussions/categories/share-custom-prompts-for-ai-notifications"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{t("whereNext.communityLabel")}
<ExternalLink className="w-3 h-3" />
</a>
{t("whereNext.communityTail")}
</li>
</ul>
</div>
)
}

View File

@@ -0,0 +1,299 @@
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.monitor.apiReference.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"proxmox api",
"proxmox rest api",
"proxmox monitor api",
"proxmox integration",
"proxmox home assistant",
"proxmox homepage",
"proxmox grafana",
"proxmox prometheus endpoint",
"proxmox n8n",
"proxmox bearer token",
"proxmox curl example",
"proxmenux api",
],
alternates: { canonical: "https://proxmenux.com/docs/monitor/api" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/monitor/api",
},
twitter: {
card: "summary",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type EndpointRow = { endpoint: string; method: string; use: string }
type WhereNextItem = { label: string; href: string; tail?: string; tailRich?: string }
type MetricRow = { metric: string; desc: string }
type MetricGroup = { group: string; metrics: MetricRow[] }
export default async function MonitorApiPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.apiReference" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { apiReference: {
auth: { rows: EndpointRow[]; items: string[] }
conventions: { items: string[] }
system: { rows: EndpointRow[] }
health: { rows: EndpointRow[] }
storage: { rows: EndpointRow[] }
network: { rows: EndpointRow[] }
vms: { rows: EndpointRow[] }
backups: { rows: EndpointRow[] }
logs: { rows: EndpointRow[] }
notifications: { rows: EndpointRow[] }
security: { rows: EndpointRow[] }
proxmenuxIntegration: { rows: EndpointRow[] }
prometheus: { groups: MetricGroup[] }
whereNext: { items: WhereNextItem[] }
} } }
}
const api = messages.docs.monitor.apiReference
const authRows = api.auth.rows
const authItems = api.auth.items
const conventionsItems = api.conventions.items
const systemRows = api.system.rows
const healthRows = api.health.rows
const storageRows = api.storage.rows
const networkRows = api.network.rows
const vmsRows = api.vms.rows
const backupsRows = api.backups.rows
const logsRows = api.logs.rows
const notifRows = api.notifications.rows
const securityRows = api.security.rows
const proxmenuxRows = api.proxmenuxIntegration.rows
const metricGroups = api.prometheus.groups
const whereNextItems = api.whereNext.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const accessLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/access-auth" className="text-blue-600 hover:underline">{chunks}</Link>
)
const healthLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/health-monitor" className="text-blue-600 hover:underline">{chunks}</Link>
)
const notifLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/notifications" className="text-blue-600 hover:underline">{chunks}</Link>
)
const aiLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/ai-assistant" className="text-blue-600 hover:underline">{chunks}</Link>
)
const integrationsLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/integrations" className="text-blue-600 hover:underline">{chunks}</Link>
)
const endpointTable = (rows: EndpointRow[], pathPrefix: string) => (
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("headerEndpoint")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("headerMethod")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("headerUse")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{rows.map((row, idx) => (
<tr key={`${row.endpoint}-${row.method}-${idx}`} className={idx < rows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
<td className="px-3 py-2 align-top">{row.method}</td>
<td className="px-3 py-2 align-top">{t.rich(`${pathPrefix}.${idx}.use`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={22}
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { strong, link: accessLink })}
</Callout>
<h2 id="authentication" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("auth.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("auth.intro")}</p>
<CopyableCode
code={`curl -H "Authorization: Bearer <token>" http://<host>:8008/api/system | jq`}
className="my-4"
/>
<p className="mb-4 text-gray-800 leading-relaxed">{t("auth.tokensIntro")}</p>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1 mb-4">
{authItems.map((_, idx) => (
<li key={idx}>{t.rich(`auth.items.${idx}`, { strong, code })}</li>
))}
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("auth.flowLink", { link: accessLink })}
</p>
{endpointTable(authRows, "auth.rows")}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("conventions.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1 mb-6">
{conventionsItems.map((_, idx) => (
<li key={idx}>{t.rich(`conventions.items.${idx}`, { code })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("system.heading")}</h2>
{endpointTable(systemRows, "system.rows")}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("health.heading")}</h2>
{endpointTable(healthRows, "health.rows")}
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("health.outro", { link: healthLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("storage.heading")}</h2>
{endpointTable(storageRows, "storage.rows")}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("network.heading")}</h2>
{endpointTable(networkRows, "network.rows")}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("vms.heading")}</h2>
{endpointTable(vmsRows, "vms.rows")}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("backups.heading")}</h2>
{endpointTable(backupsRows, "backups.rows")}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("logs.heading")}</h2>
{endpointTable(logsRows, "logs.rows")}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("notifications.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("notifications.intro", { notifLink, aiLink })}
</p>
{endpointTable(notifRows, "notifications.rows")}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("security.heading")}</h2>
{endpointTable(securityRows, "security.rows")}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("proxmenuxIntegration.heading")}</h2>
{endpointTable(proxmenuxRows, "proxmenuxIntegration.rows")}
<h2 id="prometheus" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("prometheus.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("prometheus.intro", { code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("prometheus.exportedTitle")}</h3>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("prometheus.headerGroup")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("prometheus.headerMetric")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("prometheus.headerDesc")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{metricGroups.flatMap((group, gIdx) =>
group.metrics.map((m, mIdx) => (
<tr key={`${group.group}-${m.metric}`} className="border-b border-gray-100">
{mIdx === 0 && (
<td className="px-3 py-2 align-top whitespace-nowrap" rowSpan={group.metrics.length}>
<strong>{group.group}</strong>
</td>
)}
<td className="px-3 py-2 align-top font-mono text-xs">{m.metric}</td>
<td className="px-3 py-2 align-top">
{t.rich(`prometheus.groups.${gIdx}.metrics.${mIdx}.desc`, { code })}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("prometheus.scrapeTitle")}</h3>
<p className="mb-3 text-gray-800 leading-relaxed">{t("prometheus.scrapeIntro")}</p>
<CopyableCode
code={`# /etc/prometheus/prometheus.yml
scrape_configs:
- job_name: 'proxmenux'
metrics_path: /api/prometheus
scheme: https # or http if TLS isn't enabled in ProxMenux
scrape_interval: 30s
authorization:
type: Bearer
credentials: '<your-api-token>'
static_configs:
- targets:
- 'pve01.lan:8008'
- 'pve02.lan:8008'
- 'pve03.lan:8008'`}
className="my-4"
/>
<Callout variant="tip" title={t("prometheus.perHostTitle")}>
{t.rich("prometheus.perHostBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("puttingItTogether.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("puttingItTogether.body", { link: integrationsLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.map((item, idx) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,396 @@
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 { DataFlowDiagram } from "@/components/ui/data-flow-diagram"
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.monitor.architecture.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"proxmenux architecture",
"proxmox monitor flask",
"proxmox dashboard sqlite",
"proxmox appimage dashboard",
"proxmox websocket terminal",
"proxmox monitor blueprints",
],
alternates: { canonical: "https://proxmenux.com/docs/monitor/architecture" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/monitor/architecture",
},
twitter: {
card: "summary",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type ThreadRow = { thread: string; cadence: string; job: string }
type BlueprintRow = { blueprint: string; prefix: string[]; owns: string }
type DataRow = { source: string; usedFor: string }
type PersistenceRow = { path: string; owner: string; contents: string }
type WhereNextItem = { label: string; href: string; tail: string }
export default async function MonitorArchitecturePage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.architecture" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { architecture: {
requestFlow: { rows: ThreadRow[] }
systemd: { items: string[] }
appimage: { consequences: string[] }
flask: { rows: BlueprintRow[] }
dataSources: { rows: DataRow[] }
persistence: { rows: PersistenceRow[] }
health: { items: string[] }
notifications: { items: string[] }
websocket: { items: string[] }
proxy: { items: string[] }
whereNext: { items: WhereNextItem[] }
} } }
}
const arch = messages.docs.monitor.architecture
const threadRows = arch.requestFlow.rows
const systemdItems = arch.systemd.items
const consequences = arch.appimage.consequences
const blueprintRows = arch.flask.rows
const dataRows = arch.dataSources.rows
const persistenceRows = arch.persistence.rows
const healthItems = arch.health.items
const notificationItems = arch.notifications.items
const websocketItems = arch.websocket.items
const proxyItems = arch.proxy.items
const whereNextItems = arch.whereNext.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 link = (chunks: React.ReactNode) => (
<Link href="/docs/monitor" className="text-blue-600 hover:underline">{chunks}</Link>
)
const notifLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/notifications" className="text-blue-600 hover:underline">{chunks}</Link>
)
const aiLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/ai-assistant" className="text-blue-600 hover:underline">{chunks}</Link>
)
const accessLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/access-auth" className="text-blue-600 hover:underline">{chunks}</Link>
)
const fail2banLink = (chunks: React.ReactNode) => (
<Link href="/docs/security/fail2ban" className="text-blue-600 hover:underline">{chunks}</Link>
)
const fail2banWarnLink = (chunks: React.ReactNode) => (
<Link href="/docs/security/fail2ban" className="text-blue-700 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={10}
/>
<Callout variant="info" title={t("intro.title")}>
{t("intro.body")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("requestFlow.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("requestFlow.intro")}</p>
<DataFlowDiagram
nodes={[
{ variant: "source", label: t("requestFlow.nodes.clientLabel"), detail: t("requestFlow.nodes.clientDetail") },
{ variant: "bridge", label: t("requestFlow.nodes.flaskLabel"), detail: t("requestFlow.nodes.flaskDetail") },
{ variant: "bridge", label: t("requestFlow.nodes.hostLabel"), detail: t("requestFlow.nodes.hostDetail") },
{ variant: "target", label: t("requestFlow.nodes.stateLabel"), detail: t("requestFlow.nodes.stateDetail") },
]}
arrowLabel={t("requestFlow.diagramArrowLabel")}
caption={t("requestFlow.diagramCaption")}
/>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("requestFlow.threadsIntro", { strong })}
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("requestFlow.headerThread")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("requestFlow.headerCadence")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("requestFlow.headerJob")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{threadRows.map((row, idx) => (
<tr key={row.thread} className={idx < threadRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.thread}</td>
<td className="px-3 py-2 align-top whitespace-nowrap">{row.cadence}</td>
<td className="px-3 py-2 align-top">
{t.rich(`requestFlow.rows.${idx}.job`, { code })}
</td>
</tr>
))}
</tbody>
</table>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("systemd.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("systemd.intro", { code })}
</p>
<CopyableCode
code={`[Unit]
Description=ProxMenux Monitor - Web Dashboard
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/proxmenux-monitor
ExecStart=/opt/proxmenux-monitor/ProxMenux-Monitor.AppImage
Restart=on-failure
RestartSec=10
Environment="PORT=8008"
[Install]
WantedBy=multi-user.target`}
className="my-4"
/>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{systemdItems.map((_, idx) => (
<li key={idx}>{t.rich(`systemd.items.${idx}`, { strong, code })}</li>
))}
</ul>
<Callout variant="tip" title={t("systemd.inspectTitle")}>
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{`systemctl cat proxmenux-monitor.service # show the unit content
systemctl status proxmenux-monitor.service # state + recent log
journalctl -u proxmenux-monitor.service -f # follow live`}</pre>
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("appimage.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("appimage.intro", { code })}
</p>
<CopyableCode
code={`AppDir/
├── AppRun # entrypoint: sets PATH/LD_LIBRARY_PATH, exec flask_server
├── usr/
│ ├── bin/
│ │ ├── flask_server.py # main process
│ │ ├── flask_*_routes.py # Flask blueprints (auth, health, terminal, …)
│ │ ├── auth_manager.py # JWT + TOTP + API tokens
│ │ ├── health_monitor.py # 10-category checker
│ │ ├── health_persistence.py # SQLite layer
│ │ ├── notification_manager.py # orchestrator
│ │ ├── notification_channels.py # Telegram, Discord, Email, …
│ │ ├── notification_templates.py # message rendering + AI hook
│ │ ├── notification_events.py # JournalWatcher, TaskWatcher, …
│ │ ├── ai_providers/ # OpenAI · Anthropic · Gemini · Groq · Ollama · OpenRouter
│ │ ├── proxmox_storage_monitor.py # storage pool inspection
│ │ ├── hardware_monitor.py # CPU/PCIe/GPU enumeration
│ │ ├── ipmitool, sensors, upsc # vendored hardware tools
│ │ └── …
│ ├── lib/python3/ # vendored Python deps (Flask, JWT, psutil, …)
│ └── share/ # icons + .desktop file
└── web/ # Next.js static export
├── index.html
├── _next/ # JS / CSS chunks
└── manifest.json # PWA manifest`}
className="my-4"
/>
<p className="mb-4 text-gray-800 leading-relaxed">
{t("appimage.consequencesIntro")}
</p>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{consequences.map((_, idx) => (
<li key={idx}>{t.rich(`appimage.consequences.${idx}`, { strong, code })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("flask.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("flask.intro", { code })}
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("flask.headerBlueprint")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("flask.headerPrefix")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("flask.headerOwns")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{blueprintRows.map((row, idx) => (
<tr key={row.blueprint} className={idx < blueprintRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top font-mono text-xs">{row.blueprint}</td>
<td className="px-3 py-2 align-top font-mono text-xs leading-6">
{row.prefix.map((p, pidx) => (
<span key={pidx}>
{p}
{pidx < row.prefix.length - 1 && <br />}
</span>
))}
</td>
<td className="px-3 py-2 align-top">
{t.rich(`flask.rows.${idx}.owns`, { code })}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("flask.endpointsLink", { link })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataSources.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("dataSources.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataSources.headerSource")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataSources.headerUsedFor")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{dataRows.map((row, idx) => (
<tr key={row.source} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.source}</td>
<td className="px-3 py-2 align-top">{row.usedFor}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="tip" title={t("dataSources.cacheTitle")}>
{t.rich("dataSources.cacheBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("persistence.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("persistence.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("persistence.headerPath")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("persistence.headerOwner")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("persistence.headerContents")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{persistenceRows.map((row, idx) => (
<tr key={row.path} className={idx < persistenceRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top font-mono text-xs">{row.path}</td>
<td className="px-3 py-2 align-top">{t.rich(`persistence.rows.${idx}.owner`, { code })}</td>
<td className="px-3 py-2 align-top">{t.rich(`persistence.rows.${idx}.contents`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="warning" title={t("persistence.backupTitle")}>
{t.rich("persistence.backupBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("health.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("health.intro", { code })}
</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{healthItems.map((_, idx) => (
<li key={idx}>{t.rich(`health.items.${idx}`, { code })}</li>
))}
</ol>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("health.afterIntro", { code })}
</p>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("health.cycleEnd", { em, code, link })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("notifications.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("notifications.intro", { code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{notificationItems.map((_, idx) => (
<li key={idx}>{t.rich(`notifications.items.${idx}`, { strong, code })}</li>
))}
</ul>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("notifications.linksFooter", { notifLink, aiLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("websocket.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("websocket.intro", { em, code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{websocketItems.map((_, idx) => (
<li key={idx}>{t.rich(`websocket.items.${idx}`, { strong, code })}</li>
))}
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("websocket.outro", { code })}
</p>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("websocket.proxyNote", { code })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("proxy.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("proxy.intro")}</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{proxyItems.map((_, idx) => (
<li key={idx}>{t.rich(`proxy.items.${idx}`, { strong, code })}</li>
))}
</ol>
<Callout variant="info" title={t("proxy.calloutTitle")}>
{t.rich("proxy.calloutBody", { strong, code, link: fail2banWarnLink })}
</Callout>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("proxy.outro", { accessLink, fail2banLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.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,518 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { ExternalLink } from "lucide-react"
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.monitor.dashboard.hardware.meta" })
return { title: t("title"), description: t("description") }
}
type GpuTool = { vendor: string; tool: string; projectLabel: string; projectHref?: string }
type DataRow = { section: string; endpoint: string; source: string }
type WhereNextItem = { label: string; href: string; tail: string }
export default async function HardwareTabPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.hardware" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { dashboard: { hardware: {
thresholds: { items: string[] }
sections: { systemInfoItems: string[]; thermalItems: string[] }
graphics: { tools: GpuTool[]; whereGoItems: string[] }
coral: { pathsItems: string[] }
power: { items: string[] }
dataCollected: { rows: DataRow[] }
whereNext: { items: WhereNextItem[] }
} } } }
}
const hw = messages.docs.monitor.dashboard.hardware
const thresholdsItems = hw.thresholds.items
const systemInfoItems = hw.sections.systemInfoItems
const thermalItems = hw.sections.thermalItems
const gpuTools = hw.graphics.tools
const whereGoItems = hw.graphics.whereGoItems
const coralPathsItems = hw.coral.pathsItems
const powerItems = hw.power.items
const dataRows = hw.dataCollected.rows
const whereNextItems = hw.whereNext.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 green = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-green-500 align-middle mr-1" />
const amber = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-amber-500 align-middle mr-1" />
const red = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-red-500 align-middle mr-1" />
const thresholdsLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard/settings#status-colours" className="text-blue-700 hover:underline">
{chunks}
</Link>
)
const switchModeLink = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/switch-gpu-mode" className="text-blue-600 hover:underline">{chunks}</Link>
)
const nvidiaHostLink = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/nvidia-host" className="text-blue-600 hover:underline">{chunks}</Link>
)
const nvidiaAnchor = (chunks: React.ReactNode) => (
<a
href="https://www.nvidia.com/Download/index.aspx"
target="_blank"
rel="noopener noreferrer"
className="text-amber-900 underline hover:no-underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
</a>
)
const link1 = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/nvidia-host" className="text-blue-600 hover:underline">{chunks}</Link>
)
const link2 = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/switch-gpu-mode" className="text-blue-600 hover:underline">{chunks}</Link>
)
const link3 = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/gpu-vm-passthrough" className="text-blue-600 hover:underline">{chunks}</Link>
)
const link4 = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/igpu-acceleration-lxc" className="text-blue-600 hover:underline">{chunks}</Link>
)
const installLink = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/install-coral-tpu-host" className="text-blue-600 hover:underline">{chunks}</Link>
)
const lxcLink = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/coral-tpu-lxc" className="text-blue-600 hover:underline">{chunks}</Link>
)
const coralAnchor = (chunks: React.ReactNode) => (
<a
href="https://coral.ai/docs/m2/get-started/"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
</a>
)
const storageLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard/storage" className="text-blue-600 hover:underline">{chunks}</Link>
)
const smartLink = (chunks: React.ReactNode) => (
<Link href="/docs/disk-manager/smart-disk-test" className="text-blue-600 hover:underline">{chunks}</Link>
)
const pciSwitchLink = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/switch-gpu-mode" className="text-blue-600 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={14}
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code })}
</Callout>
<Callout variant="tip" title={t("thresholds.title")}>
{t.rich("thresholds.intro", { strong, green, amber, red })}
<ul className="list-disc pl-6 mt-2 space-y-0.5">
{thresholdsItems.map((_, idx) => (
<li key={idx}>{t.rich(`thresholds.items.${idx}`, { strong })}</li>
))}
</ul>
{t.rich("thresholds.outro", { link: thresholdsLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("sections.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("sections.intro", { em })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("sections.systemInfoTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("sections.systemInfoIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{systemInfoItems.map((_, idx) => (
<li key={idx}>{t.rich(`sections.systemInfoItems.${idx}`, { strong })}</li>
))}
</ul>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("sections.memoryTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("sections.memoryBody", { code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("sections.thermalTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("sections.thermalIntro", { code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{thermalItems.map((_, idx) => (
<li key={idx}>{t.rich(`sections.thermalItems.${idx}`, { strong, code })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("graphics.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("graphics.intro", { em, strong, code, link: switchModeLink })}
</p>
<figure className="my-6">
<img
src="/monitor/hardware/hw-graphics-cards-vfio.png"
alt={t("graphics.vfioImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("graphics.vfioImageCaption", { code })}
</figcaption>
</figure>
<figure className="my-6">
<img
src="/monitor/hardware/hw-graphics-cards-lxc.png"
alt={t("graphics.lxcImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("graphics.lxcImageCaption")}
</figcaption>
</figure>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("graphics.realtimeTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("graphics.realtimeBody", { code })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">{t("graphics.toolsIntro")}</p>
<div className="overflow-x-auto mb-6 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("graphics.headerVendor")}</th>
<th className="px-4 py-2 font-semibold">{t("graphics.headerTool")}</th>
<th className="px-4 py-2 font-semibold">{t("graphics.headerProject")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-800">
{gpuTools.map((row) => (
<tr key={row.vendor}>
<td className="px-4 py-2 align-top whitespace-nowrap"><strong>{row.vendor}</strong></td>
<td className="px-4 py-2 align-top"><code>{row.tool}</code></td>
<td className="px-4 py-2 align-top">
{row.projectHref ? (
<a
href={row.projectHref}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{row.projectLabel}
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
</a>
) : (
row.projectLabel
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<figure className="my-6">
<img
src="/monitor/hardware/hw-gpu-nvidia-modal.png"
alt={t("graphics.nvidiaImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("graphics.nvidiaImageCaption")}
</figcaption>
</figure>
<figure className="my-6">
<img
src="/monitor/hardware/hw-gpu-intel-modal.png"
alt={t("graphics.intelImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("graphics.intelImageCaption", { code })}
</figcaption>
</figure>
<figure className="my-6">
<img
src="/monitor/hardware/hw-gpu-amd-modal.png"
alt={t("graphics.amdImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("graphics.amdImageCaption", { code })}
</figcaption>
</figure>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("graphics.installTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("graphics.installBody", { code, strong, link: nvidiaHostLink })}
</p>
<figure className="my-6">
<img
src="/monitor/hardware/hw-gpu-nvidia-no-driver.png"
alt={t("graphics.noDriverAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("graphics.noDriverCaption")}
</figcaption>
</figure>
<figure className="my-6">
<img
src="/monitor/hardware/hw-gpu-nvidia-install-prompt.png"
alt={t("graphics.promptAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("graphics.promptCaption")}
</figcaption>
</figure>
<figure className="my-6">
<img
src="/monitor/hardware/hw-gpu-nvidia-install-success.png"
alt={t("graphics.successAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("graphics.successCaption", { code })}
</figcaption>
</figure>
<Callout variant="warning" title={t("graphics.warningTitle")}>
{t.rich("graphics.warningBody", { code, em, a: nvidiaAnchor })}
</Callout>
<p className="mt-4 mb-2 text-gray-800 leading-relaxed">{t("graphics.whereGoIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{whereGoItems.map((_, idx) => (
<li key={idx}>{t.rich(`graphics.whereGoItems.${idx}`, { em, link1, link2, link3, link4 })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
{t("coral.heading")} <em>{t("coral.subHeading")}</em>
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("coral.intro", { code })}
</p>
<figure className="my-6">
<img
src="/monitor/hardware/hw-coral-tpu-modal.png"
alt={t("coral.imageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("coral.imageCaption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("coral.pathsIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{coralPathsItems.map((_, idx) => (
<li key={idx}>{t.rich(`coral.pathsItems.${idx}`, { strong, code })}</li>
))}
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("coral.outro", { installLink, lxcLink, a: coralAnchor })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("storage.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("storage.intro", { code, em })}
</p>
<figure className="my-6">
<img
src="/monitor/hardware/hw-storage-summary.png"
alt={t("storage.imageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("storage.imageCaption", { em })}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("storage.nvmeBody", { strong })}
</p>
<figure className="my-6">
<img
src="/monitor/hardware/hw-storage-modal-nvme.png"
alt={t("storage.nvmeModalAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("storage.nvmeModalCaption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("storage.outro", { em, storageLink, smartLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("pci.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("pci.intro", { strong, em, code })}
</p>
<figure className="my-6">
<img
src="/monitor/hardware/hw-pci-devices.png"
alt={t("pci.imageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("pci.imageCaption", { code })}
</figcaption>
</figure>
<Callout variant="tip" title={t("pci.bdfTitle")}>
{t.rich("pci.bdfBody", { code, link: pciSwitchLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("usb.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("usb.intro", { code, em })}
</p>
<figure className="my-6">
<img
src="/monitor/hardware/hw-usb-devices.png"
alt={t("usb.imageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("usb.imageCaption", { code })}
</figcaption>
</figure>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
{t("power.heading")} <em>{t("power.subHeading")}</em>
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("power.intro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{powerItems.map((_, idx) => (
<li key={idx}>{t.rich(`power.items.${idx}`, { strong })}</li>
))}
</ul>
<figure className="my-6">
<img
src="/monitor/hardware/hw-power-supplies.png"
alt={t("power.supplyImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("power.supplyImageCaption")}
</figcaption>
</figure>
<figure className="my-6">
<img
src="/monitor/hardware/hw-cpu-power.png"
alt={t("power.cpuImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("power.cpuImageCaption")}
</figcaption>
</figure>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
{t("psu.heading")} <em>{t("psu.subHeading")}</em>
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("psu.body")}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
{t("fans.heading")} <em>{t("fans.subHeading")}</em>
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("fans.body")}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
{t("ups.heading")} <em>{t("ups.subHeading")}</em>
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("ups.body", { em })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSection")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{dataRows.map((row, idx) => (
<tr key={row.section} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top">{row.section}</td>
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
<td className="px-3 py-2 align-top">{t.rich(`dataCollected.rows.${idx}.source`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<CopyableCode
code={`${t("dataCollected.codeComment1")}
lspci -nnk | grep -A2 -E 'VGA|Audio|Network|3D'
sensors
${t("dataCollected.codeComment2")}
curl -H "Authorization: Bearer <token>" \\
http://<host>:8008/api/hardware | jq '.gpus'`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.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,325 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { Download } from "lucide-react"
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.monitor.dashboard.network.meta" })
return { title: t("title"), description: t("description") }
}
type TopRow = { card: string; what: string }
type DrillRow = { block: string; contents: string }
type ThresholdRow = { status: string; range: string; impact: string }
type DataRow = { section: string; endpoint: string; source: string }
type WhereNextItem = { label: string; href: string; tail: string }
export default async function NetworkTabPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.network" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { dashboard: { network: {
topRow: { rows: TopRow[] }
groups: { badges: string[] }
drillIn: { rows: DrillRow[] }
latency: {
targets: string[]
mode2Items: string[]
thresholdRows: ThresholdRow[]
sections: string[]
}
dataCollected: { rows: DataRow[] }
whereNext: { items: WhereNextItem[] }
} } } }
}
const net = messages.docs.monitor.dashboard.network
const topRows = net.topRow.rows
const badges = net.groups.badges
const drillRows = net.drillIn.rows
const targets = net.latency.targets
const mode2Items = net.latency.mode2Items
const thresholdRows = net.latency.thresholdRows
const sections = net.latency.sections
const dataRows = net.dataCollected.rows
const whereNextItems = net.whereNext.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={13}
/>
<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("topRow.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerCard")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerWhat")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{topRows.map((row, idx) => (
<tr key={row.card} className={idx < topRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.card}</strong></td>
<td className="px-3 py-2 align-top">
{t.rich(`topRow.rows.${idx}.what`, { em, strong })}
</td>
</tr>
))}
</tbody>
</table>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("groups.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("groups.intro", { strong })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{badges.map((_, idx) => (
<li key={idx}>{t.rich(`groups.badges.${idx}`, { strong, code })}</li>
))}
</ul>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("groups.clickable", { strong })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("groups.physicalTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("groups.physicalBody", { code, strong, em })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("groups.bridgeTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("groups.bridgeBody", { code, strong })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("groups.vmTitle")}</h3>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("groups.vmBody", { code, em })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("drillIn.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("drillIn.headerBlock")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("drillIn.headerContents")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{drillRows.map((row, idx) => (
<tr key={row.block} className={idx < drillRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.block}</strong></td>
<td className="px-3 py-2 align-top">
{t.rich(`drillIn.rows.${idx}.contents`, { code })}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="tip" title={t("drillIn.inactiveTitle")}>
{t.rich("drillIn.inactiveBody", { em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("latency.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("latency.intro", { em })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("latency.targetsTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("latency.targetsIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{targets.map((_, idx) => (
<li key={idx}>{t.rich(`latency.targets.${idx}`, { strong })}</li>
))}
</ul>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("latency.mode1Title")}</h3>
<figure className="my-4">
<img
src="/monitor/network-latency-historical.png"
alt={t("latency.mode1Alt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("latency.mode1Caption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("latency.mode1Body1", { em })}
</p>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("latency.mode1Body2", { code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("latency.mode2Title")}</h3>
<figure className="my-4">
<img
src="/monitor/network-latency-realtime.png"
alt={t("latency.mode2Alt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("latency.mode2Caption", { em })}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("latency.mode2Intro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{mode2Items.map((_, idx) => (
<li key={idx}>{t.rich(`latency.mode2Items.${idx}`, { strong, code })}</li>
))}
</ul>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("latency.thresholdsTitle")}</h3>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("latency.headerStatus")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("latency.headerRange")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("latency.headerImpact")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{thresholdRows.map((row, idx) => (
<tr key={row.status} className={idx < thresholdRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.status}</strong></td>
<td className="px-3 py-2 align-top">{row.range}</td>
<td className="px-3 py-2 align-top">{row.impact}</td>
</tr>
))}
</tbody>
</table>
</div>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("latency.reportTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("latency.reportIntro", { strong })}
</p>
<figure className="my-6">
<img
src="/monitor/network-latency-report-preview.png"
alt={t("latency.reportPreviewAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("latency.reportPreviewCaption")}
</figcaption>
</figure>
<div className="my-6">
<a
href="/monitor/sample-network-latency-report.pdf"
download
className="inline-flex items-center gap-2 px-4 py-2 rounded-md border border-blue-200 bg-blue-50 text-blue-700 font-medium hover:bg-blue-100 transition-colors"
>
<Download className="h-4 w-4" aria-hidden="true" />
{t("latency.downloadLabel")}
</a>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">{t("latency.sectionsIntro")}</p>
<ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{sections.map((_, idx) => (
<li key={idx}>{t.rich(`latency.sections.${idx}`, { strong })}</li>
))}
</ol>
<Callout variant="tip" title={t("latency.useCaseTitle")}>
{t.rich("latency.useCaseBody", { em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("excluding.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("excluding.body1", { code })}
</p>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("excluding.body2", { strong, em })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSection")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{dataRows.map((row, idx) => (
<tr key={row.section} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top">{row.section}</td>
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
<td className="px-3 py-2 align-top">
{t.rich(`dataCollected.rows.${idx}.source`, { code })}
</td>
</tr>
))}
</tbody>
</table>
</div>
<CopyableCode
code={`${t("dataCollected.codeComment1")}
ip -br link
ip -br addr
${t("dataCollected.codeComment2")}
curl -H "Authorization: Bearer <token>" \\
http://<host>:8008/api/network/latency/current | jq`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.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,120 @@
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"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.meta" })
return {
title: t("title"),
description: t("description"),
}
}
type TabRow = { name: string; linksTo?: string; owns: string }
type WhereNextItem = { label: string; href: string; tail: string }
export default async function DashboardIndexPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { dashboard: {
tabs: { rows: TabRow[] }
headerAnatomy: { items: string[] }
whereNext: { items: WhereNextItem[] }
} } }
}
const tabRows = messages.docs.monitor.dashboard.tabs.rows
const headerAnatomyItems = messages.docs.monitor.dashboard.headerAnatomy.items
const whereNextItems = messages.docs.monitor.dashboard.whereNext.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 link = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/health-monitor" className="text-blue-700 hover:underline">
{chunks}
</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={3}
/>
<Callout variant="info" title={t("oneHeader.title")}>
{t.rich("oneHeader.body", { link })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("tabs.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("tabs.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("tabs.headerTab")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("tabs.headerOwns")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{tabRows.map((row, idx) => (
<tr
key={row.name}
className={idx < tabRows.length - 1 ? "border-b border-gray-100" : ""}
>
<td className="px-3 py-2 align-top whitespace-nowrap">
{row.linksTo ? (
<Link href={row.linksTo} className="text-blue-600 hover:underline font-semibold">
{row.name}
</Link>
) : (
<strong>{row.name}</strong>
)}
</td>
<td className="px-3 py-2 align-top">
{t.rich(`tabs.rows.${idx}.owns`, { code })}
</td>
</tr>
))}
</tbody>
</table>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("headerAnatomy.heading")}</h2>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{headerAnatomyItems.map((_, idx) => (
<li key={idx}>{t.rich(`headerAnatomy.items.${idx}`, { code, strong, em })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.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,489 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import Image from "next/image"
import { ExternalLink } from "lucide-react"
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.monitor.dashboard.security.meta" })
return { title: t("title"), description: t("description") }
}
type DataRow = { card: string; endpoint: string; source: string }
type WhereNextItem = { label: string; href: string; tail?: string; tailRich?: string }
export default async function SecurityTabPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.security" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { dashboard: { security: {
auth: { items: string[] }
ssl: { items: string[] }
gateway: { step3Items: string[]; step4Items: string[] }
firewall: { items: string[] }
lynis: { scoreItems: string[] }
dataCollected: { rows: DataRow[] }
whereNext: { items: WhereNextItem[] }
} } } }
}
const sec = messages.docs.monitor.dashboard.security
const authItems = sec.auth.items
const sslItems = sec.ssl.items
const step3Items = sec.gateway.step3Items
const step4Items = sec.gateway.step4Items
const firewallItems = sec.firewall.items
const lynisScoreItems = sec.lynis.scoreItems
const dataRows = sec.dataCollected.rows
const whereNextItems = sec.whereNext.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 authLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/access-auth" className="text-blue-600 hover:underline">{chunks}</Link>
)
const sslPageLink = (chunks: React.ReactNode) => (
<Link href="/docs/security/ssl-letsencrypt" className="text-blue-600 hover:underline">{chunks}</Link>
)
const integrationsLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/integrations" className="text-blue-600 hover:underline">{chunks}</Link>
)
const tailscaleHomeAnchor = (chunks: React.ReactNode) => (
<a href="https://tailscale.com" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
{chunks}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
)
const tailscaleKeysAnchor = (chunks: React.ReactNode) => (
<a href="https://login.tailscale.com/admin/settings/keys" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
{chunks}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
)
const tailscaleMachinesAnchor = (chunks: React.ReactNode) => (
<a href="https://login.tailscale.com/admin/machines" target="_blank" rel="noopener noreferrer" className="text-amber-700 hover:underline inline-flex items-center gap-1">
{chunks}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
)
const fail2banLink = (chunks: React.ReactNode) => (
<Link href="/docs/security/fail2ban" className="text-blue-600 hover:underline">{chunks}</Link>
)
const lynisLink = (chunks: React.ReactNode) => (
<Link href="/docs/security/lynis" className="text-blue-600 hover:underline">{chunks}</Link>
)
const lynisSampleAnchor = (chunks: React.ReactNode) => (
<a href="/monitor/security/lynis-sample-report.pdf" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
{chunks}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={18}
/>
<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("monitor.heading")}</h2>
<p className="mb-6 text-gray-800 leading-relaxed">{t("monitor.intro")}</p>
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("auth.heading")}</h3>
<figure className="my-4">
<Image src="/monitor/security/auth-card.png" alt={t("auth.imageAlt")} width={2000} height={956} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("auth.imageCaption")}</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("auth.intro", { link: authLink })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{authItems.map((_, idx) => (
<li key={idx}>{t.rich(`auth.items.${idx}`, { strong, em })}</li>
))}
</ul>
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("ssl.heading")}</h3>
<figure className="my-4">
<Image src="/monitor/security/ssl-https-card.png" alt={t("ssl.imageAlt")} width={2000} height={1124} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("ssl.imageCaption", { em })}</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("ssl.intro", { code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{sslItems.map((_, idx) => (
<li key={idx}>{t.rich(`ssl.items.${idx}`, { strong, code })}</li>
))}
</ul>
<figure className="my-4">
<Image src="/monitor/security/ssl-https-enabled.png" alt={t("ssl.enabledAlt")} width={2000} height={889} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("ssl.enabledCaption", { em })}</figcaption>
</figure>
<Callout variant="info" title={t("ssl.acmeTitle")}>
{t.rich("ssl.acmeBody", { em })}
</Callout>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("ssl.walkthroughLink", { code, link: sslPageLink })}
</p>
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("apiTokens.heading")}</h3>
<figure className="my-4">
<Image src="/monitor/security/api-tokens-empty.png" alt={t("apiTokens.emptyAlt")} width={2000} height={855} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("apiTokens.emptyCaption", { em, code })}</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("apiTokens.intro")}</p>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("apiTokens.generateBody", { strong, em })}
</p>
<figure className="my-4">
<Image src="/monitor/security/api-tokens-generate.png" alt={t("apiTokens.generateAlt")} width={2000} height={1124} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("apiTokens.generateCaption")}</figcaption>
</figure>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("apiTokens.saveBody", { strong })}
</p>
<figure className="my-4">
<Image src="/monitor/security/api-tokens-generated.png" alt={t("apiTokens.generatedAlt")} width={2000} height={1468} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("apiTokens.generatedCaption", { code })}</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("apiTokens.outro", { em, link: integrationsLink })}
</p>
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("gateway.heading")}</h3>
<figure className="my-4">
<Image src="/monitor/security/secure-gateway-card.png" alt={t("gateway.cardAlt")} width={2000} height={434} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gateway.cardCaption")}</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("gateway.intro", { code, a: tailscaleHomeAnchor })}
</p>
<h4 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("gateway.wizardTitle")}</h4>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("gateway.wizardIntro", { em })}
</p>
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step0Title")}</h5>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("gateway.step0Body", { em, a: tailscaleKeysAnchor })}
</p>
<figure className="my-4">
<Image src="/monitor/security/tailscale-auth-key-page.png" alt={t("gateway.step0Alt")} width={2000} height={1115} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("gateway.step0Caption", { em })}</figcaption>
</figure>
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step1Title")}</h5>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("gateway.step1Body", { em })}
</p>
<figure className="my-4">
<Image src="/monitor/security/gateway-step-1-intro.png" alt={t("gateway.step1Alt")} width={1589} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gateway.step1Caption")}</figcaption>
</figure>
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step2Title")}</h5>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("gateway.step2Body", { code })}
</p>
<figure className="my-4">
<Image src="/monitor/security/gateway-step-3-auth.png" alt={t("gateway.step2Alt")} width={1985} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gateway.step2Caption")}</figcaption>
</figure>
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step3Title")}</h5>
<p className="mb-3 text-gray-800 leading-relaxed">{t("gateway.step3Intro")}</p>
<ul className="list-disc pl-6 mb-3 text-gray-800 leading-relaxed space-y-1">
{step3Items.map((_, idx) => (
<li key={idx}>{t.rich(`gateway.step3Items.${idx}`, { strong })}</li>
))}
</ul>
<figure className="my-4">
<Image src="/monitor/security/gateway-step-2-scope.png" alt={t("gateway.step3Alt")} width={1934} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("gateway.step3Caption", { em })}</figcaption>
</figure>
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step4Title")}</h5>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("gateway.step4Intro", { strong })}
</p>
<ul className="list-disc pl-6 mb-3 text-gray-800 leading-relaxed space-y-1">
{step4Items.map((_, idx) => (
<li key={idx}>{t.rich(`gateway.step4Items.${idx}`, { strong, em })}</li>
))}
</ul>
<figure className="my-4">
<Image src="/monitor/security/gateway-step-4-advanced.png" alt={t("gateway.step4Alt")} width={1847} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gateway.step4Caption")}</figcaption>
</figure>
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step5Title")}</h5>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("gateway.step5Body", { strong })}
</p>
<figure className="my-4">
<Image src="/monitor/security/gateway-step-5-review.png" alt={t("gateway.step5Alt")} width={1860} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gateway.step5Caption")}</figcaption>
</figure>
<Callout variant="warning" title={t("gateway.approvalTitle")}>
{t.rich("gateway.approvalBody", { em, a: tailscaleMachinesAnchor })}
</Callout>
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("pve.heading")}</h2>
<p className="mb-6 text-gray-800 leading-relaxed">{t("pve.intro")}</p>
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("firewall.heading")}</h3>
<figure className="my-4">
<Image src="/monitor/security/firewall-card.png" alt={t("firewall.imageAlt")} width={2000} height={1256} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("firewall.imageCaption", { em })}</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("firewall.intro", { code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{firewallItems.map((_, idx) => (
<li key={idx}>{t.rich(`firewall.items.${idx}`, { strong, em, code })}</li>
))}
</ul>
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">
{t("fail2ban.heading")} <em>{t("fail2ban.subHeading")}</em>
</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("fail2ban.whatIs", { strong })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("fail2ban.notBundled", { strong })}
</p>
<figure className="my-4">
<Image src="/monitor/security/fail2ban-not-installed.png" alt={t("fail2ban.notInstalledAlt")} width={2000} height={968} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("fail2ban.notInstalledCaption")}</figcaption>
</figure>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("fail2ban.clickBody", { em })}
</p>
<figure className="my-4">
<Image src="/monitor/security/fail2ban-install-confirm.png" alt={t("fail2ban.confirmAlt")} width={1808} height={1678} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("fail2ban.confirmCaption", { code })}</figcaption>
</figure>
<p className="mb-3 text-gray-800 leading-relaxed">{t("fail2ban.confirmIntro")}</p>
<figure className="my-4">
<Image src="/monitor/security/fail2ban-install-progress.png" alt={t("fail2ban.progressAlt")} width={2000} height={1512} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("fail2ban.progressCaption")}</figcaption>
</figure>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("fail2ban.afterInstall", { em })}
</p>
<figure className="my-4">
<Image src="/monitor/security/fail2ban-active.png" alt={t("fail2ban.activeAlt")} width={2000} height={1614} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("fail2ban.activeCaption", { code })}</figcaption>
</figure>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("fail2ban.tuneBody", { strong, em })}
</p>
<figure className="my-4">
<Image src="/monitor/security/fail2ban-sshd-config.png" alt={t("fail2ban.configAlt")} width={2000} height={919} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("fail2ban.configCaption", { em })}</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("fail2ban.outro", { em, code, link: fail2banLink })}
</p>
<Callout variant="info" title={t("fail2ban.calloutTitle")}>
{t.rich("fail2ban.calloutBody", { em, code })}
</Callout>
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">
{t("lynis.heading")} <em>{t("lynis.subHeading")}</em>
</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("lynis.whatIs", { strong })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("lynis.whyUseful", { strong, code })}
</p>
<figure className="my-4">
<Image src="/monitor/security/lynis-not-installed.png" alt={t("lynis.notInstalledAlt")} width={2000} height={919} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("lynis.notInstalledCaption")}</figcaption>
</figure>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("lynis.notBundled", { strong })}
</p>
<figure className="my-4">
<Image src="/monitor/security/lynis-install-confirm.png" alt={t("lynis.confirmAlt")} width={1985} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("lynis.confirmCaption")}</figcaption>
</figure>
<figure className="my-4">
<Image src="/monitor/security/lynis-install-progress.png" alt={t("lynis.progressAlt")} width={1856} height={972} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("lynis.progressCaption")}</figcaption>
</figure>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("lynis.afterInstall", { em })}
</p>
<figure className="my-4">
<Image src="/monitor/security/lynis-installed-empty.png" alt={t("lynis.installedAlt")} width={2000} height={1131} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("lynis.installedCaption")}</figcaption>
</figure>
<figure className="my-4">
<Image src="/monitor/security/lynis-audit-running.png" alt={t("lynis.runningAlt")} width={2000} height={1131} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("lynis.runningCaption")}</figcaption>
</figure>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("lynis.finishedBody", { em })}
</p>
<figure className="my-4">
<Image src="/monitor/security/lynis-audit-results.png" alt={t("lynis.resultsAlt")} width={2000} height={1183} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("lynis.resultsCaption", { strong })}</figcaption>
</figure>
<Callout variant="info" title={t("lynis.scoreTitle")}>
{t.rich("lynis.scoreIntro", { em, code })}
<ul className="list-disc pl-6 mt-2 mb-0 space-y-1">
{lynisScoreItems.map((_, idx) => (
<li key={idx}>{t.rich(`lynis.scoreItems.${idx}`, { em })}</li>
))}
</ul>
</Callout>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("lynis.reportBody", { strong, em })}
</p>
<figure className="my-4">
<Image src="/monitor/security/lynis-report-pdf.png" alt={t("lynis.reportAlt")} width={1414} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("lynis.reportCaption", { a: lynisSampleAnchor })}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("lynis.runPeriodically")}</p>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("lynis.outro", { em, link: lynisLink })}
</p>
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerCard")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{dataRows.map((row, idx) => (
<tr key={row.card} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top">{row.card}</td>
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
<td className="px-3 py-2 align-top">{t.rich(`dataCollected.rows.${idx}.source`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<CopyableCode
code={`# Confirm the auth log on the host (used by Fail2Ban + audit)
journalctl -t proxmenux-auth --since '7 days ago' | tail
# Cross-check the firewall rules the dashboard sees
pve-firewall status
cat /etc/pve/firewall/host.fw
# Verify Fail2Ban (only if installed)
fail2ban-client status
fail2ban-client status sshd
# Verify Lynis (only if installed)
lynis show version
ls -lh /var/log/lynis-report.dat`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.map((item, idx) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,486 @@
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"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.settings.meta" })
return { title: t("title"), description: t("description") }
}
type ColourRow = { colour: string; range: string; meaning: string }
type ThresholdRow = { section: string; warning: string; critical: string; gates: string }
type DataRow = { card: string; endpoint: string; source: string }
type WhereNextItem = { label: string; href: string; tail?: string; tailRich?: string }
export default async function SettingsTabPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.settings" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { dashboard: { settings: {
health: { items: string[]; activeItems: string[] }
thresholds: {
whatForItems: string[]
colourRows: ColourRow[]
thresholdRows: ThresholdRow[]
}
lxcDetection: { whatRunsItems: string[] }
storageExclusions: { items: string[] }
interfaceExclusions: { items: string[] }
notifications: { items: string[] }
optimizations: { dotsItems: string[] }
dataCollected: { rows: DataRow[] }
whereNext: { items: WhereNextItem[] }
} } } }
}
const s = messages.docs.monitor.dashboard.settings
const healthItems = s.health.items
const activeSuppressionItems = s.health.activeItems
const whatForItems = s.thresholds.whatForItems
const colourRows = s.thresholds.colourRows
const thresholdRows = s.thresholds.thresholdRows
const whatRunsItems = s.lxcDetection.whatRunsItems
const storageItems = s.storageExclusions.items
const interfaceItems = s.interfaceExclusions.items
const notificationItems = s.notifications.items
const dotsItems = s.optimizations.dotsItems
const dataRows = s.dataCollected.rows
const whereNextItems = s.whereNext.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 green = () => <span className="inline-block w-2 h-2 rounded-full bg-green-500 align-middle mr-1" />
const amber = () => <span className="inline-block w-2 h-2 rounded-full bg-amber-500 align-middle mr-1" />
const healthLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/health-monitor#dismissing-alerts-and-the-suppression-duration" className="text-blue-700 hover:underline">{chunks}</Link>
)
const storageTabLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard/storage" className="text-blue-600 hover:underline">{chunks}</Link>
)
const networkTabLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard/network" className="text-blue-600 hover:underline">{chunks}</Link>
)
const notifLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/notifications" className="text-blue-600 hover:underline">{chunks}</Link>
)
const aiLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/ai-assistant" className="text-blue-600 hover:underline">{chunks}</Link>
)
const autoLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/automated" className="text-blue-600 hover:underline">{chunks}</Link>
)
const customLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/customizable" className="text-blue-600 hover:underline">{chunks}</Link>
)
const updatesLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/updates" className="text-blue-600 hover:underline">{chunks}</Link>
)
const uninstallLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/uninstall" className="text-blue-600 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={9}
/>
<Callout variant="info" title={t("intro.title")}>
{t("intro.body")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("networkUnits.heading")}</h2>
<figure className="my-4">
<Image
src="/monitor/settings/network-units.png"
alt={t("networkUnits.imageAlt")}
width={2000}
height={374}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("networkUnits.imageCaption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("networkUnits.body", { strong })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("health.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("health.intro", { strong })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{healthItems.map((_, idx) => (
<li key={idx}>{t.rich(`health.items.${idx}`, { strong })}</li>
))}
</ul>
<figure className="my-6">
<Image
src="/monitor/health-suppression-settings.png"
alt={t("health.imageAlt")}
width={2010}
height={1816}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("health.imageCaption")}
</figcaption>
</figure>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("health.editTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("health.editBody", { strong })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("health.activeTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("health.activeIntro", { strong, em })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{activeSuppressionItems.map((_, idx) => (
<li key={idx}>{t.rich(`health.activeItems.${idx}`, { strong, em, code })}</li>
))}
</ul>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("health.activeReenableTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("health.activeReenableBody", { strong, code })}
</p>
<Callout variant="info" title={t("health.activeAutoRefreshTitle")}>
{t("health.activeAutoRefreshBody")}
</Callout>
<p className="mt-4 mb-4 text-gray-800 leading-relaxed">
{t.rich("health.activePermanentNote", { strong, em, code })}
</p>
<Callout variant="info" title={t("health.calloutTitle")}>
{t.rich("health.calloutBody", { link: healthLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("thresholds.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("thresholds.intro", { em, strong })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("thresholds.whatForTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("thresholds.whatForIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{whatForItems.map((_, idx) => (
<li key={idx}>{t(`thresholds.whatForItems.${idx}`)}</li>
))}
</ul>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("thresholds.whatForOutro", { strong, code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900" id="status-colours">{t("thresholds.coloursTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("thresholds.coloursIntro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("thresholds.headerColour")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("thresholds.headerRange")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("thresholds.headerMeaning")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{colourRows.map((row, idx) => {
// Color tier is identified positionally so the dot stays
// correct in any locale (Spanish: Verde / Ámbar / Rojo).
const dotClass = ["bg-green-500", "bg-amber-500", "bg-red-500"][idx] ?? "bg-red-500"
return (
<tr key={row.colour} className={idx < colourRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap">
<span className={`inline-block w-3 h-3 rounded-full ${dotClass} align-middle mr-2`} />
<strong>{row.colour}</strong>
</td>
<td className="px-3 py-2 align-top">{row.range}</td>
<td className="px-3 py-2 align-top">{row.meaning}</td>
</tr>
)
})}
</tbody>
</table>
</div>
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("thresholds.sectionsTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("thresholds.sectionsIntro", { em })}
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("thresholds.headerSection")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200 whitespace-nowrap bg-amber-100/60">{t("thresholds.headerWarning")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200 whitespace-nowrap bg-red-100/60">{t("thresholds.headerCritical")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("thresholds.headerGates")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{thresholdRows.map((row, idx) => (
<tr key={row.section} className={idx < thresholdRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top"><strong>{row.section}</strong></td>
<td className={`px-3 py-2 align-top font-mono whitespace-nowrap ${row.warning === "—" ? "text-gray-400" : ""} bg-amber-100/40`}>{row.warning}</td>
<td className="px-3 py-2 align-top font-mono whitespace-nowrap bg-red-100/40">{row.critical}</td>
<td className="px-3 py-2 align-top">{t.rich(`thresholds.thresholdRows.${idx}.gates`, { code, em })}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="info" title={t("thresholds.defaultsTitle")}>
{t.rich("thresholds.defaultsBody", { em, strong })}
</Callout>
<Callout variant="tip" title={t("thresholds.validationTitle")}>
{t("thresholds.validationBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("lxcDetection.heading")}</h2>
<figure className="my-4">
<Image
src="/monitor/settings/lxc-update-detection.png"
alt={t("lxcDetection.imageAlt")}
width={2000}
height={620}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("lxcDetection.imageCaption", { code })}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("lxcDetection.intro", { code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("lxcDetection.whatRunsTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("lxcDetection.whatRunsIntro", { code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{whatRunsItems.map((_, idx) => (
<li key={idx}>{t.rich(`lxcDetection.whatRunsItems.${idx}`, { strong, code })}</li>
))}
</ul>
<Callout variant="info" title={t("lxcDetection.selfUpdateTitle")}>
{t.rich("lxcDetection.selfUpdateBody", { code })}
</Callout>
<Callout variant="tip" title={t("lxcDetection.refreshTitle")}>
{t.rich("lxcDetection.refreshBody", { code })}
</Callout>
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("lxcDetection.toggleTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("lxcDetection.toggleBody", { code, strong })}
</p>
<Callout variant="warning" title={t("lxcDetection.purgeTitle")}>
{t.rich("lxcDetection.purgeBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("storageExclusions.heading")}</h2>
<figure className="my-4">
<Image
src="/monitor/settings/storage-exclusions.png"
alt={t("storageExclusions.imageAlt")}
width={2000}
height={1120}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("storageExclusions.imageCaption", { em })}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("storageExclusions.intro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{storageItems.map((_, idx) => (
<li key={idx}>{t.rich(`storageExclusions.items.${idx}`, { strong })}</li>
))}
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("storageExclusions.outro", { em, code, link: storageTabLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("interfaceExclusions.heading")}</h2>
<figure className="my-4">
<Image
src="/monitor/settings/interface-exclusions.png"
alt={t("interfaceExclusions.imageAlt")}
width={2000}
height={1142}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("interfaceExclusions.imageCaption", { em })}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("interfaceExclusions.intro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{interfaceItems.map((_, idx) => (
<li key={idx}>{t.rich(`interfaceExclusions.items.${idx}`, { code })}</li>
))}
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("interfaceExclusions.outro", { code, em, link: networkTabLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("notifications.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("notifications.body1", { em })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">{t("notifications.body2")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{notificationItems.map((_, idx) => (
<li key={idx}>{t.rich(`notifications.items.${idx}`, { notifLink, aiLink })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("optimizations.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("optimizations.intro", { code, autoLink, customLink })}
</p>
<figure className="my-4">
<Image
src="/monitor/settings/proxmenux-optimizations.png"
alt={t("optimizations.imageAlt")}
width={2000}
height={1146}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("optimizations.imageCaption", { em })}
</figcaption>
</figure>
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("optimizations.dotsTitle")}</h3>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{dotsItems.map((_, idx) => (
<li key={idx}>{t.rich(`optimizations.dotsItems.${idx}`, { strong, em, green, amber })}</li>
))}
</ul>
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("optimizations.clickTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("optimizations.clickBody", { code })}
</p>
<figure className="my-4">
<Image
src="/monitor/settings/optimization-detail.png"
alt={t("optimizations.detailAlt")}
width={2000}
height={1040}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("optimizations.detailCaption", { em, code })}
</figcaption>
</figure>
<Callout variant="info" title={t("optimizations.whyTitle")}>
{t("optimizations.whyBody")}
</Callout>
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("optimizations.updatesTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("optimizations.updatesBody", { strong, em })}
</p>
<figure className="my-4">
<Image
src="/monitor/settings/proxmenux-optimizations-update-banner.png"
alt={t("optimizations.updatesAlt")}
width={2000}
height={1146}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("optimizations.updatesCaption", { link: updatesLink })}
</figcaption>
</figure>
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("optimizations.revertTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("optimizations.revertBody", { code, link: uninstallLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerCard")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{dataRows.map((row, idx) => (
<tr key={row.card} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top">{row.card}</td>
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
<td className="px-3 py-2 align-top">{t.rich(`dataCollected.rows.${idx}.source`, { code, notifLink, aiLink })}</td>
</tr>
))}
</tbody>
</table>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.map((item, idx) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { customLink }) : item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,474 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { Download } from "lucide-react"
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.monitor.dashboard.storage.meta" })
return { title: t("title"), description: t("description") }
}
type DataRow = { section: string; endpoint: string; source: string }
type WhereNextItem = { label: string; href: string; tail?: string; tailRich?: string }
export default async function StorageTabPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.storage" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { dashboard: { storage: {
thresholds: { items: string[] }
topRow: { disksItems: string[] }
pveStorage: { items: string[] }
zfs: { items: string[] }
physical: { items: string[] }
drillIn: {
overviewItems: string[]
smartItems: string[]
pdfSections: string[]
historyItems: string[]
scheduleItems: string[]
tempShowsItems: string[]
tempDiskTypes: string[]
tempWhyItems: string[]
obsWhatItems: string[]
obsWhyItems: string[]
}
dataCollected: { rows: DataRow[] }
whereNext: { items: WhereNextItem[] }
} } } }
}
const s = messages.docs.monitor.dashboard.storage
const thresholdsItems = s.thresholds.items
const disksItems = s.topRow.disksItems
const pveItems = s.pveStorage.items
const zfsItems = s.zfs.items
const physicalItems = s.physical.items
const overviewItems = s.drillIn.overviewItems
const smartItems = s.drillIn.smartItems
const pdfSections = s.drillIn.pdfSections
const historyItems = s.drillIn.historyItems
const scheduleItems = s.drillIn.scheduleItems
const tempShowsItems = s.drillIn.tempShowsItems
const tempDiskTypes = s.drillIn.tempDiskTypes
const tempWhyItems = s.drillIn.tempWhyItems
const obsWhatItems = s.drillIn.obsWhatItems
const obsWhyItems = s.drillIn.obsWhyItems
const dataRows = s.dataCollected.rows
const whereNextItems = s.whereNext.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 green = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-green-500 align-middle mr-1" />
const amber = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-amber-500 align-middle mr-1" />
const red = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-red-500 align-middle mr-1" />
const thresholdsLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard/settings#status-colours" className="text-blue-700 hover:underline">{chunks}</Link>
)
const zfsHmLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/health-monitor" className="text-blue-600 hover:underline">{chunks}</Link>
)
const physicalWarnLink = (chunks: React.ReactNode) => (
<Link href="/docs/disk-manager/format-disk" className="text-amber-700 hover:underline">{chunks}</Link>
)
const hmLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/health-monitor" className="text-blue-600 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={14}
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code })}
</Callout>
<Callout variant="tip" title={t("thresholds.title")}>
{t.rich("thresholds.intro", { strong, green, amber, red })}
<ul className="list-disc pl-6 mt-2 space-y-0.5">
{thresholdsItems.map((_, idx) => (
<li key={idx}>{t.rich(`thresholds.items.${idx}`, { strong })}</li>
))}
</ul>
{t.rich("thresholds.outro", { link: thresholdsLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("topRow.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("topRow.intro")}</p>
<figure className="my-6">
<img
src="/monitor/storage-top-row.png"
alt={t("topRow.imageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("topRow.imageCaption")}
</figcaption>
</figure>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerCard")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerWhat")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
<tr className="border-b border-gray-100">
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.totalLabel")}</strong></td>
<td className="px-3 py-2 align-top">{t("topRow.totalWhat")}</td>
</tr>
<tr className="border-b border-gray-100">
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.localLabel")}</strong></td>
<td className="px-3 py-2 align-top">{t.rich("topRow.localWhat", { em })}</td>
</tr>
<tr className="border-b border-gray-100">
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.remoteLabel")}</strong></td>
<td className="px-3 py-2 align-top">{t("topRow.remoteWhat")}</td>
</tr>
<tr>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.disksLabel")}</strong></td>
<td className="px-3 py-2 align-top">
{t("topRow.disksIntro")}
<ul className="list-disc pl-5 mt-2 space-y-0.5">
{disksItems.map((_, idx) => (
<li key={idx}>{t.rich(`topRow.disksItems.${idx}`, { strong, em })}</li>
))}
</ul>
</td>
</tr>
</tbody>
</table>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("pveStorage.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("pveStorage.intro", { code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{pveItems.map((_, idx) => (
<li key={idx}>{t.rich(`pveStorage.items.${idx}`, { strong })}</li>
))}
</ul>
<Callout variant="tip" title={t("pveStorage.calloutTitle")}>
{t.rich("pveStorage.calloutBody", { em, code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("zfs.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("zfs.intro")}</p>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{zfsItems.map((_, idx) => (
<li key={idx}>{t.rich(`zfs.items.${idx}`, { strong })}</li>
))}
</ul>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("zfs.outro", { em, link: zfsHmLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("physical.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("physical.intro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{physicalItems.map((_, idx) => (
<li key={idx}>{t.rich(`physical.items.${idx}`, { strong, em, code })}</li>
))}
</ul>
<p className="mb-6 text-gray-800 leading-relaxed">{t("physical.clickHint")}</p>
<Callout variant="warning" title={t("physical.warningTitle")}>
{t.rich("physical.warningBody", { strong, link: physicalWarnLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("external.heading")}</h2>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("external.body", { strong })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("drillIn.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("drillIn.intro", { strong, em })}
</p>
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.overviewTitle")}</h3>
<figure className="my-4">
<img
src="/monitor/disk-modal-overview.png"
alt={t("drillIn.overviewImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("drillIn.overviewImageCaption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.overviewIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{overviewItems.map((_, idx) => (
<li key={idx}>{t.rich(`drillIn.overviewItems.${idx}`, { strong, em })}</li>
))}
</ul>
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.smartTitle")}</h3>
<figure className="my-4">
<img
src="/monitor/disk-modal-smart.png"
alt={t("drillIn.smartImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("drillIn.smartImageCaption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.smartIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{smartItems.map((_, idx) => (
<li key={idx}>{t.rich(`drillIn.smartItems.${idx}`, { strong, em, code })}</li>
))}
</ul>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.pdfTitle")}</h4>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("drillIn.pdfIntro", { strong })}
</p>
<figure className="my-6">
<img
src="/monitor/smart-report-preview.png"
alt={t("drillIn.pdfPreviewAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("drillIn.pdfPreviewCaption")}
</figcaption>
</figure>
<div className="my-6">
<a
href="/monitor/sample-smart-report.pdf"
download
className="inline-flex items-center gap-2 px-4 py-2 rounded-md border border-blue-200 bg-blue-50 text-blue-700 font-medium hover:bg-blue-100 transition-colors"
>
<Download className="h-4 w-4" aria-hidden="true" />
{t("drillIn.pdfDownloadLabel")}
</a>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.pdfSectionsIntro")}</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{pdfSections.map((_, idx) => (
<li key={idx}>{t.rich(`drillIn.pdfSections.${idx}`, { strong, em })}</li>
))}
</ol>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("drillIn.pdfOutro", { code })}
</p>
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.historyTitle")}</h3>
<figure className="my-4">
<img
src="/monitor/disk-modal-history.png"
alt={t("drillIn.historyImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("drillIn.historyImageCaption", { code })}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("drillIn.historyIntro", { code })}
</p>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{historyItems.map((_, idx) => (
<li key={idx}>{t.rich(`drillIn.historyItems.${idx}`, { strong, em, code })}</li>
))}
</ul>
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.scheduleTitle")}</h3>
<figure className="my-4">
<img
src="/monitor/disk-modal-schedule.png"
alt={t("drillIn.scheduleImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("drillIn.scheduleImageCaption", { code })}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.scheduleIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{scheduleItems.map((_, idx) => (
<li key={idx}>{t.rich(`drillIn.scheduleItems.${idx}`, { strong, em })}</li>
))}
</ul>
<p className="mb-6 text-gray-800 leading-relaxed">{t("drillIn.scheduleOutro")}</p>
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.tempTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.tempIntro")}</p>
<figure className="my-4">
<img
src="/monitor/disk-modal-temperature.png"
alt={t("drillIn.tempImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("drillIn.tempImageCaption")}
</figcaption>
</figure>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.tempShowsTitle")}</h4>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{tempShowsItems.map((_, idx) => (
<li key={idx}>
{t.rich(`drillIn.tempShowsItems.${idx}`, { strong, em })}
{idx === 2 && (
<ul className="list-disc pl-6 mt-1">
{tempDiskTypes.map((_, didx) => (
<li key={didx}>{t.rich(`drillIn.tempDiskTypes.${didx}`, { strong })}</li>
))}
</ul>
)}
</li>
))}
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("drillIn.tempConfigurable", { em })}
</p>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.tempWhyTitle")}</h4>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{tempWhyItems.map((_, idx) => (
<li key={idx}>{t.rich(`drillIn.tempWhyItems.${idx}`, { strong, em })}</li>
))}
</ul>
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.obsTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("drillIn.obsIntro", { strong, em })}
</p>
<figure className="my-4">
<img
src="/monitor/disk-modal-observations.png"
alt={t("drillIn.obsImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("drillIn.obsImageCaption", { strong })}
</figcaption>
</figure>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.obsWhatTitle")}</h4>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("drillIn.obsWhatIntro", { strong })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{obsWhatItems.map((_, idx) => (
<li key={idx}>{t.rich(`drillIn.obsWhatItems.${idx}`, { strong, code })}</li>
))}
</ul>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.obsWhyTitle")}</h4>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{obsWhyItems.map((_, idx) => (
<li key={idx}>{t.rich(`drillIn.obsWhyItems.${idx}`, { strong, em })}</li>
))}
</ul>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.obsDedupTitle")}</h4>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("drillIn.obsDedupBody1", { strong, code })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">
{t("drillIn.obsDedupBody2")}
</p>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.obsDismissTitle")}</h4>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("drillIn.obsDismissBody1", { strong })}
</p>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("drillIn.obsDismissBody2", { link: hmLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSection")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{dataRows.map((row, idx) => (
<tr key={row.section} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top">{row.section}</td>
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
<td className="px-3 py-2 align-top">{t.rich(`dataCollected.rows.${idx}.source`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">{t("dataCollected.outro")}</p>
<CopyableCode
code={`${t("dataCollected.codeComment1")}
curl -H "Authorization: Bearer <api-token>" \\
http://<host>:8008/api/storage | jq '.disks[] | {name,model,smart_status}'
${t("dataCollected.codeComment2")}
lsblk -O
zpool status
journalctl -t smartd --since '1 day ago' | tail`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.map((item, idx) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,178 @@
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.monitor.dashboard.systemLogs.meta" })
return { title: t("title"), description: t("description") }
}
type DataRow = { subtab: string; endpoint: string; source: string }
type WhereNextItem = { label: string; href: string; tail: string }
export default async function SystemLogsTabPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.systemLogs" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { dashboard: { systemLogs: {
topRow: { items: string[] }
subtabs: { logsFilters: string[]; fields: string[] }
dataCollected: { rows: DataRow[] }
whereNext: { items: WhereNextItem[] }
} } } }
}
const sl = messages.docs.monitor.dashboard.systemLogs
const topRowItems = sl.topRow.items
const logsFilters = sl.subtabs.logsFilters
const fields = sl.subtabs.fields
const dataRows = sl.dataCollected.rows
const whereNextItems = sl.whereNext.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 link = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/notifications" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={7}
/>
<Callout variant="info" title={t("readOnly.title")}>
{t.rich("readOnly.body", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("topRow.heading")}</h2>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{topRowItems.map((_, idx) => (
<li key={idx}>{t.rich(`topRow.items.${idx}`, { code, strong })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("subtabs.heading")}</h2>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("subtabs.logsTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("subtabs.logsIntro", { code })}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{logsFilters.map((_, idx) => (
<li key={idx}>{t.rich(`subtabs.logsFilters.${idx}`, { code, strong })}</li>
))}
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("subtabs.logsRowsAfter", { code, strong })}
</p>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("subtabs.logDetailsModalTitle")}</h4>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("subtabs.logDetailsBody", { code, strong })}
</p>
<figure className="my-6">
<img
src="/monitor/log-details-modal.png"
alt={t("subtabs.logDetailsImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("subtabs.logDetailsImageCaption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("subtabs.fieldsIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{fields.map((_, idx) => (
<li key={idx}>{t.rich(`subtabs.fields.${idx}`, { code, strong })}</li>
))}
</ul>
<Callout variant="tip" title={t("subtabs.maxLevelStoreTitle")}>
{t.rich("subtabs.maxLevelStoreBody", { code })}
</Callout>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("subtabs.backupsTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("subtabs.backupsBody", { code, em })}</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("subtabs.notificationsTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("subtabs.notificationsBody1")}</p>
<p className="mb-6 text-gray-800 leading-relaxed">{t.rich("subtabs.notificationsBody2", { link })}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSubtab")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{dataRows.map((row, idx) => (
<tr
key={row.endpoint}
className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}
>
<td className="px-3 py-2 align-top">{row.subtab}</td>
<td className="px-3 py-2 align-top font-mono text-xs" dangerouslySetInnerHTML={{ __html: row.endpoint }} />
<td className="px-3 py-2 align-top">
{t.rich(`dataCollected.rows.${idx}.source`, { code })}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">{t("dataCollected.apiIntro")}</p>
<CopyableCode
code={`${t("dataCollected.codeComment1")}
curl -H "Authorization: Bearer <token>" \\
"http://<host>:8008/api/logs?severity=error&since=1h&search=zfs"
${t("dataCollected.codeComment2")}
curl -H "Authorization: Bearer <token>" \\
-o pmx-journal.txt \\
"http://<host>:8008/api/logs/download?since=6h"
${t("dataCollected.codeComment3")}
curl -H "Authorization: Bearer <token>" \\
"http://<host>:8008/api/task-log/<upid>"`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.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,225 @@
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.monitor.dashboard.systemOverview.meta" })
return { title: t("title"), description: t("description") }
}
type TopRow = { card: string; what: string; source: string }
type DataRow = { card: string; endpoint: string; source: string }
type WhereNextItem = { label: string; href: string; tail: string }
export default async function SystemOverviewTabPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.systemOverview" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { dashboard: { systemOverview: {
topRow: { rows: TopRow[]; thresholdsItems: string[] }
bottom: { storageItems: string[] }
refresh: { items: string[] }
dataCollected: { rows: DataRow[] }
whereNext: { items: WhereNextItem[] }
} } } }
}
const so = messages.docs.monitor.dashboard.systemOverview
const topRows = so.topRow.rows
const thresholdsItems = so.topRow.thresholdsItems
const storageItems = so.bottom.storageItems
const refreshItems = so.refresh.items
const dataRows = so.dataCollected.rows
const whereNextItems = so.whereNext.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 green = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-green-500 align-middle mr-1" />
const amber = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-amber-500 align-middle mr-1" />
const red = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-red-500 align-middle mr-1" />
const thresholdsLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard/settings#status-colours" className="text-blue-700 hover:underline">
{chunks}
</Link>
)
const storageLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
const networkLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={6}
/>
<Callout variant="info" title={t("readOnly.title")}>
{t("readOnly.body")}
</Callout>
<figure className="my-8">
<img
src="/monitor/dashboard-home.png"
alt={t("captureAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("captureCaption")}
</figcaption>
</figure>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("topRow.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("topRow.intro", { code })}
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerCard")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerWhat")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerSource")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{topRows.map((row, idx) => (
<tr key={row.card} className={idx < topRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap">
<strong>{row.card}</strong>
</td>
<td className="px-3 py-2 align-top">
{t.rich(`topRow.rows.${idx}.what`, { code, em })}
</td>
<td className="px-3 py-2 align-top font-mono text-xs">{row.source}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="tip" title={t("topRow.thresholdsTitle")}>
{t.rich("topRow.thresholdsIntro", { strong, green, amber, red })}
<ul className="list-disc pl-6 mt-2 space-y-0.5">
{thresholdsItems.map((_, idx) => (
<li key={idx}>{t.rich(`topRow.thresholdsItems.${idx}`, { strong })}</li>
))}
</ul>
{t.rich("topRow.thresholdsOutro", { link: thresholdsLink })}
</Callout>
<Callout variant="tip" title={t("topRow.sparklineTitle")}>
{t("topRow.sparklineBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("middle.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("middle.body1", { code, em })}
</p>
<p className="mb-6 text-gray-800 leading-relaxed">
{t("middle.body2")}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("bottom.heading")}</h2>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("bottom.storageTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("bottom.storageIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{storageItems.map((_, idx) => (
<li key={idx}>{t.rich(`bottom.storageItems.${idx}`, { strong })}</li>
))}
</ul>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("bottom.storageDrillIn", { link: storageLink })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("bottom.networkTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("bottom.networkBody1", { code })}
</p>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("bottom.networkBody2", { link: networkLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("refresh.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("refresh.intro", { code, em })}
</p>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{refreshItems.map((_, idx) => (
<li key={idx}>{t.rich(`refresh.items.${idx}`, { strong })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerCard")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{dataRows.map((row, idx) => (
<tr key={row.card} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top">{row.card}</td>
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
<td className="px-3 py-2 align-top">
{t.rich(`dataCollected.rows.${idx}.source`, { code })}
</td>
</tr>
))}
</tbody>
</table>
</div>
<CopyableCode
code={`${t("dataCollected.codeComment1")}
curl http://<host>:8008/api/health ${t("dataCollected.codeComment2")}
${t("dataCollected.codeComment3")}
curl -H "Authorization: Bearer <token>" \\
http://<host>:8008/api/system | jq '.cpu,.memory,.uptime'`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.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,319 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import Image from "next/image"
import { ExternalLink } 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.monitor.dashboard.terminal.meta" })
return { title: t("title"), description: t("description") }
}
type KeyboardRow = { button: string; sends: string; use?: string; useRich?: boolean }
type DisconnectRow = { cause: string; fix: string }
type WhereNextItem = { label: string; href: string; tail: string }
export default async function TerminalTabPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.terminal" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { dashboard: { terminal: {
keyboard: { rows: KeyboardRow[]; ctrlItems: string[] }
auth: { items: string[] }
clipboard: { items: string[] }
disconnect: { rows: DisconnectRow[] }
fourTerminals: { items: string[] }
whereNext: { items: WhereNextItem[] }
} } } }
}
const term = messages.docs.monitor.dashboard.terminal
const kbRows = term.keyboard.rows
const ctrlItems = term.keyboard.ctrlItems
const authItems = term.auth.items
const clipboardItems = term.clipboard.items
const disconnectRows = term.disconnect.rows
const fourTerminalsItems = term.fourTerminals.items
const whereNextItems = term.whereNext.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 green = (chunks: React.ReactNode) => <span className="text-green-600 font-semibold">{chunks}</span>
const red = (chunks: React.ReactNode) => <span className="text-red-600 font-semibold">{chunks}</span>
const vmsLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard/vms-lxcs" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
const vmsLinkAmber = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard/vms-lxcs" className="text-blue-700 hover:underline">
{chunks}
</Link>
)
const authLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/access-auth" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
const authLinkWarn = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/access-auth" className="text-amber-700 hover:underline">
{chunks}
</Link>
)
const gatewayLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/integrations" className="text-amber-700 hover:underline">
{chunks}
</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={7}
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code })}
</Callout>
<figure className="my-6">
<Image
src="/monitor/terminal/single-terminal.png"
alt={t("singleAlt")}
width={1600}
height={1000}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("singleCaption", { em })}
</figcaption>
</figure>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("target.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("target.body1", { strong })}
</p>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("target.body2", { strong, em, code, link: vmsLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("fourTerminals.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("fourTerminals.intro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{fourTerminalsItems.map((_, idx) => (
<li key={idx}>{t.rich(`fourTerminals.items.${idx}`, { strong, em, code })}</li>
))}
</ul>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("fourTerminals.outro", { strong, em, code })}
</p>
<figure className="my-6">
<Image
src="/monitor/terminal/grid-4-terminals.png"
alt={t("gridAlt")}
width={1600}
height={1000}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("gridCaption", { code })}
</figcaption>
</figure>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("keyboard.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("keyboard.intro", { code })}
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("keyboard.headerButton")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("keyboard.headerSends")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("keyboard.headerUse")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{kbRows.map((row, idx) => (
<tr key={row.button} className={idx < kbRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.button}</strong></td>
<td className="px-3 py-2 align-top font-mono text-xs">{row.sends}</td>
<td className="px-3 py-2 align-top">
{row.useRich ? (
<>
{t("keyboard.ctrlIntro")}
<ul className="list-disc pl-5 mt-1 space-y-0.5">
{ctrlItems.map((_, cidx) => (
<li key={cidx}>{t.rich(`keyboard.ctrlItems.${cidx}`, { code })}</li>
))}
</ul>
</>
) : (
t.rich(`keyboard.rows.${idx}.use`, { code })
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="info" title={t("keyboard.modalTitle")}>
{t.rich("keyboard.modalBody", { code, link: vmsLinkAmber })}
</Callout>
<figure className="my-6">
<Image
src="/monitor/terminal/lxc-console-modal.png"
alt={t("lxcAlt")}
width={1600}
height={1200}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("lxcCaption", { em })}
</figcaption>
</figure>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("search.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("search.intro", { code, strong, em })}
</p>
<figure className="my-6">
<Image
src="/monitor/terminal/search-commands.png"
alt={t("search.modalAlt")}
width={1600}
height={1200}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("search.modalCaption", { code, em })}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
<strong>{t("search.aboutLabel")}</strong>{" "}
<a
href="https://cheat.sh"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
cheat.sh
<ExternalLink className="w-3 h-3" />
</a>{" "}
{t.rich("search.aboutBody", { code })}
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("search.headerSource")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("search.headerWhen")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("search.headerWhat")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
<tr className="border-b border-gray-100">
<td className="px-3 py-2 align-top whitespace-nowrap">
<a
href="https://cheat.sh"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1 font-semibold"
>
cheat.sh
<ExternalLink className="w-3 h-3" />
</a>{" "}
{t("search.onlineLabel")}
</td>
<td className="px-3 py-2 align-top">{t("search.onlineWhen")}</td>
<td className="px-3 py-2 align-top">{t.rich("search.onlineWhat", { green })}</td>
</tr>
<tr>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("search.fallbackLabel")}</strong></td>
<td className="px-3 py-2 align-top">{t("search.fallbackWhen")}</td>
<td className="px-3 py-2 align-top">{t.rich("search.fallbackWhat", { red })}</td>
</tr>
</tbody>
</table>
</div>
<p className="mb-6 text-gray-800 leading-relaxed text-sm">
{t.rich("search.sendingNote", { strong })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("auth.heading")}</h2>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{authItems.map((_, idx) => (
<li key={idx}>{t.rich(`auth.items.${idx}`, { code, link: authLink })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("clipboard.heading")}</h2>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{clipboardItems.map((_, idx) => (
<li key={idx}>{t.rich(`clipboard.items.${idx}`, { strong, code })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("disconnect.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("disconnect.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("disconnect.headerCause")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("disconnect.headerFix")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{disconnectRows.map((row, idx) => (
<tr key={row.cause} className={idx < disconnectRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top">{row.cause}</td>
<td className="px-3 py-2 align-top">{t.rich(`disconnect.rows.${idx}.fix`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="warning" title={t("warning.title")}>
{t.rich("warning.body", { code, authLink: authLinkWarn, gatewayLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.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,437 @@
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.monitor.dashboard.vmsLxcs.meta" })
return { title: t("title"), description: t("description") }
}
type LifecycleRow = { button: string; color: string; enabled: string; action: string }
type DataRow = { section: string; endpoint: string; source: string }
type WhereNextItem = { label: string; href: string; tailRich: string }
export default async function VmsLxcsTabPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.vmsLxcs" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { dashboard: { vmsLxcs: {
topRow: { memoryItems: string[] }
inventory: { rows: string[] }
drillIn: {
liveItems: string[]
ioItems: string[]
resourcesItems: string[]
mountTypesItems: string[]
mountStateItems: string[]
backupsItems: string[]
updatesPanelItems: string[]
firewallItems: string[]
lifecycleRows: LifecycleRow[]
}
dataCollected: { rows: DataRow[] }
whereNext: { items: WhereNextItem[] }
} } } }
}
const v = messages.docs.monitor.dashboard.vmsLxcs
const memoryItems = v.topRow.memoryItems
const inventoryRows = v.inventory.rows
const liveItems = v.drillIn.liveItems
const ioItems = v.drillIn.ioItems
const resourcesItems = v.drillIn.resourcesItems
const mountTypesItems = v.drillIn.mountTypesItems
const mountStateItems = v.drillIn.mountStateItems
const backupsItems = v.drillIn.backupsItems
const updatesPanelItems = v.drillIn.updatesPanelItems
const firewallItems = v.drillIn.firewallItems
const lifecycleRows = v.drillIn.lifecycleRows
const dataRows = v.dataCollected.rows
const whereNextItems = v.whereNext.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 green = () => <span className="inline-block w-2 h-2 rounded-full bg-green-500 align-middle mr-1" />
const amber = () => <span className="inline-block w-2 h-2 rounded-full bg-amber-500 align-middle mr-1" />
const red = () => <span className="inline-block w-2 h-2 rounded-full bg-red-500 align-middle mr-1" />
const greenText = (chunks: React.ReactNode) => <span className="text-green-600 font-semibold">{chunks}</span>
const amberText = (chunks: React.ReactNode) => <span className="text-amber-600 font-semibold">{chunks}</span>
const redText = (chunks: React.ReactNode) => <span className="text-red-600 font-semibold">{chunks}</span>
const orangeText = (chunks: React.ReactNode) => <span className="text-orange-600 font-semibold">{chunks}</span>
const link = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard/terminal" className="text-blue-600 hover:underline">{chunks}</Link>
)
const buttonColorClass = (color: string) => {
switch (color) {
case "green":
return "text-green-600 font-semibold"
case "blue":
return "text-blue-600 font-semibold"
case "red":
return "text-red-600 font-semibold"
default:
return "font-semibold"
}
}
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={12}
/>
<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("topRow.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("topRow.intro")}</p>
<figure className="my-6">
<img
src="/monitor/vms-top-row.png"
alt={t("topRow.imageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("topRow.imageCaption")}
</figcaption>
</figure>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerCard")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerWhat")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
<tr className="border-b border-gray-100">
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.totalLabel")}</strong></td>
<td className="px-3 py-2 align-top">{t.rich("topRow.totalWhat", { em })}</td>
</tr>
<tr className="border-b border-gray-100">
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.cpuLabel")}</strong></td>
<td className="px-3 py-2 align-top">{t.rich("topRow.cpuWhat", { em })}</td>
</tr>
<tr className="border-b border-gray-100">
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.memoryLabel")}</strong></td>
<td className="px-3 py-2 align-top">
{t("topRow.memoryIntro")}
<ul className="list-disc pl-5 mt-2 space-y-0.5">
{memoryItems.map((_, idx) => (
<li key={idx}>{t.rich(`topRow.memoryItems.${idx}`, { strong, em, code })}</li>
))}
</ul>
</td>
</tr>
<tr>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.diskLabel")}</strong></td>
<td className="px-3 py-2 align-top">{t.rich("topRow.diskWhat", { em })}</td>
</tr>
</tbody>
</table>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("inventory.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("inventory.intro", { code })}
</p>
<figure className="my-6">
<img
src="/monitor/vms-inventory-mobile.png"
alt={t("inventory.imageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full max-w-md mx-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("inventory.imageCaption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("inventory.rowsIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{inventoryRows.map((_, idx) => (
<li key={idx}>{t.rich(`inventory.rows.${idx}`, { strong, em })}</li>
))}
</ul>
<p className="mb-6 text-gray-800 leading-relaxed">{t("inventory.clickHint")}</p>
<Callout variant="tip" title={t("inventory.mobileTitle")}>
{t("inventory.mobileBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("drillIn.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("drillIn.intro", { strong, em })}
</p>
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.statusTitle")}</h3>
<figure className="my-4">
<img
src="/monitor/vms-modal-status.png"
alt={t("drillIn.statusImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("drillIn.statusImageCaption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.statusIntro")}</p>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.liveTitle")}</h4>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{liveItems.map((_, idx) => (
<li key={idx}>{t.rich(`drillIn.liveItems.${idx}`, { strong, em })}</li>
))}
</ul>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.ioTitle")}</h4>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{ioItems.map((_, idx) => (
<li key={idx}>{t.rich(`drillIn.ioItems.${idx}`, { strong })}</li>
))}
</ul>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.resourcesTitle")}</h4>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("drillIn.resourcesIntro", { code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{resourcesItems.map((_, idx) => (
<li key={idx}>{t.rich(`drillIn.resourcesItems.${idx}`, { strong, code })}</li>
))}
</ul>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.ipsTitle")}</h4>
<p className="mb-6 text-gray-800 leading-relaxed">{t("drillIn.ipsBody")}</p>
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.mountsTitle")}</h3>
<figure className="my-4">
<img
src="/monitor/vms-modal-mounts.png"
alt={t("drillIn.mountsImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("drillIn.mountsImageCaption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("drillIn.mountsIntro", { strong, code })}
</p>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.mountTypesTitle")}</h4>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{mountTypesItems.map((_, idx) => (
<li key={idx}>{t.rich(`drillIn.mountTypesItems.${idx}`, { strong, em, code })}</li>
))}
</ul>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.mountStateTitle")}</h4>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{mountStateItems.map((_, idx) => (
<li key={idx}>
{t.rich(`drillIn.mountStateItems.${idx}`, { strong, em, code, green, amber, red })}
</li>
))}
</ul>
<Callout variant="info" title={t("drillIn.mountsCalloutTitle")}>
{t("drillIn.mountsCalloutBody")}
</Callout>
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.backupsTitle")}</h3>
<figure className="my-4">
<img
src="/monitor/vms-modal-backups.png"
alt={t("drillIn.backupsImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("drillIn.backupsImageCaption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.backupsIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{backupsItems.map((_, idx) => (
<li key={idx}>{t.rich(`drillIn.backupsItems.${idx}`, { strong })}</li>
))}
</ul>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("drillIn.backupsOutro", { strong })}
</p>
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.updatesTitle")}</h3>
<figure className="my-4">
<img
src="/monitor/vms-modal-lxc-updates.png"
alt={t("drillIn.updatesImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("drillIn.updatesImageCaption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("drillIn.updatesIntro", { strong, code })}
</p>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.updatesPanelTitle")}</h4>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{updatesPanelItems.map((_, idx) => (
<li key={idx}>{t.rich(`drillIn.updatesPanelItems.${idx}`, { strong })}</li>
))}
</ul>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.updatesScopeTitle")}</h4>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("drillIn.updatesScopeBody", { strong, em, code })}
</p>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.updatesToggleTitle")}</h4>
<Callout variant="info" title={t("drillIn.updatesToggleCalloutTitle")}>
{t.rich("drillIn.updatesToggleCalloutBody", { strong, code })}
</Callout>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.updatesApplyTitle")}</h4>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("drillIn.updatesApplyBody", { code })}
</p>
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.firewallTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.firewallIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{firewallItems.map((_, idx) => (
<li key={idx}>
{t.rich(`drillIn.firewallItems.${idx}`, {
strong,
em,
code,
green: greenText,
orange: orangeText,
red: redText,
})}
</li>
))}
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("drillIn.firewallRefresh", { em, code })}
</p>
<Callout variant="info" title={t("drillIn.firewallCalloutTitle")}>
{t("drillIn.firewallCalloutBody")}
</Callout>
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.actionBarTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.actionBarIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
<li>{t.rich("drillIn.consoleItem", { strong, code, link })}</li>
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("drillIn.lifecycleIntro", { code })}
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("drillIn.headerButton")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("drillIn.headerEnabled")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("drillIn.headerAction")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{lifecycleRows.map((row, idx) => (
<tr key={row.button} className={idx < lifecycleRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap">
<strong className={buttonColorClass(row.color)}>{row.button}</strong>
</td>
<td className="px-3 py-2 align-top">{row.enabled}</td>
<td className="px-3 py-2 align-top font-mono text-xs">{row.action}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="warning" title={t("drillIn.forceStopTitle")}>
{t.rich("drillIn.forceStopBody", { strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSection")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{dataRows.map((row, idx) => (
<tr key={row.section} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top">{row.section}</td>
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
<td className="px-3 py-2 align-top">{t.rich(`dataCollected.rows.${idx}.source`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<CopyableCode
code={`${t("dataCollected.codeComment1")}
pvesh get /cluster/resources --type vm --output-format=json | jq
${t("dataCollected.codeComment2")}
qm config 100 ${t("dataCollected.codeComment3")}
pct config 100 ${t("dataCollected.codeComment4")}`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.map((item, idx) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{t.rich(`whereNext.items.${idx}.tailRich`, { code })}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,432 @@
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 { DataFlowDiagram } from "@/components/ui/data-flow-diagram"
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.monitor.healthMonitor.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"proxmox health monitor",
"proxmox health check",
"proxmox smart monitoring",
"proxmox zfs monitoring",
"proxmox alerts",
"proxmox proactive monitoring",
"proxmox disk monitoring",
"proxmox memory monitor",
"proxmox cpu monitor",
"proxmenux health monitor",
],
alternates: { canonical: "https://proxmenux.com/docs/monitor/health-monitor" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/monitor/health-monitor",
},
twitter: {
card: "summary_large_image",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type CategoryRow = { category: string; checks: string; events: string }
type SeverityRow = { status: string; colour: string; meaning: string; notification: string }
type DismissRow = { finding: string; why: string }
type AutoresolveRow = { trigger: string; action: string }
type ObservationsRow = { property: string; errors: string; obs: string }
type RestRow = { endpoint: string; method: string; use: string }
type WhereNextItem = { label: string; href: string; tail?: string; tailRich?: string }
export default async function HealthMonitorPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.healthMonitor" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { healthMonitor: {
categories: { rows: CategoryRow[] }
severity: { rows: SeverityRow[] }
dashboardView: { items: string[] }
dismiss: { rows: DismissRow[] }
autoresolve: { rows: AutoresolveRow[] }
observations: { rows: ObservationsRow[] }
notification: { items: string[] }
rest: { rows: RestRow[] }
whereNext: { items: WhereNextItem[] }
} } }
}
const hm = messages.docs.monitor.healthMonitor
const categoryRows = hm.categories.rows
const severityRows = hm.severity.rows
const dashboardItems = hm.dashboardView.items
const dismissRows = hm.dismiss.rows
const autoresolveRows = hm.autoresolve.rows
const observationsRows = hm.observations.rows
const notificationItems = hm.notification.items
const restRows = hm.rest.rows
const whereNextItems = hm.whereNext.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 notifLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/notifications" className="text-blue-600 hover:underline">{chunks}</Link>
)
const aiLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/ai-assistant" className="text-blue-600 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={18}
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code, strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howItWorks.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("howItWorks.intro", { strong })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("howItWorks.scannerTitle")}</h3>
<DataFlowDiagram
nodes={[
{ variant: "source", label: t("howItWorks.scannerNodes.samplerLabel"), detail: t("howItWorks.scannerNodes.samplerDetail") },
{ variant: "bridge", label: t("howItWorks.scannerNodes.cycleLabel"), detail: t("howItWorks.scannerNodes.cycleDetail") },
{ variant: "bridge", label: t("howItWorks.scannerNodes.checksLabel"), detail: t("howItWorks.scannerNodes.checksDetail") },
{ variant: "target", label: t("howItWorks.scannerNodes.sqliteLabel"), detail: t("howItWorks.scannerNodes.sqliteDetail") },
]}
arrowLabel={t("howItWorks.scannerArrowLabel")}
caption={t("howItWorks.scannerCaption")}
/>
<h3 className="text-lg font-semibold mt-8 mb-3 text-gray-900">{t("howItWorks.notifTitle")}</h3>
<DataFlowDiagram
nodes={[
{ variant: "source", label: t("howItWorks.notifNodes.errorsLabel"), detail: t("howItWorks.notifNodes.errorsDetail") },
{ variant: "bridge", label: t("howItWorks.notifNodes.dispatcherLabel"), detail: t("howItWorks.notifNodes.dispatcherDetail") },
{ variant: "bridge", label: t("howItWorks.notifNodes.templatesLabel"), detail: t("howItWorks.notifNodes.templatesDetail") },
{ variant: "target", label: t("howItWorks.notifNodes.channelsLabel"), detail: t("howItWorks.notifNodes.channelsDetail") },
]}
arrowLabel={t("howItWorks.notifArrowLabel")}
caption={t("howItWorks.notifCaption")}
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("categories.heading")}</h2>
<figure className="my-6">
<Image
src="/monitor/health-monitor.png"
alt={t("categories.imageAlt")}
width={1608}
height={1752}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto mx-auto max-w-2xl"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("categories.imageCaption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("categories.intro", { strong })}
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("categories.headerCategory")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("categories.headerChecks")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("categories.headerEvents")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{categoryRows.map((row, idx) => (
<tr key={row.category} className={idx < categoryRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.category}</strong></td>
<td className="px-3 py-2 align-top">{t.rich(`categories.rows.${idx}.checks`, { code })}</td>
<td className="px-3 py-2 align-top">{t.rich(`categories.rows.${idx}.events`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("severity.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("severity.headerStatus")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("severity.headerColour")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("severity.headerMeaning")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("severity.headerNotification")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{severityRows.map((row, idx) => (
<tr key={row.status} className={idx < severityRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.status}</strong></td>
<td className="px-3 py-2 align-top">{row.colour}</td>
<td className="px-3 py-2 align-top">{t.rich(`severity.rows.${idx}.meaning`, { em })}</td>
<td className="px-3 py-2 align-top">{row.notification}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("severity.infoNote", { strong })}
</p>
<Callout variant="info" title={t("severity.unknownTitle")}>
{t.rich("severity.unknownBody", { code, strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dashboardView.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("dashboardView.intro", { strong })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{dashboardItems.map((_, idx) => (
<li key={idx}>{t.rich(`dashboardView.items.${idx}`, { strong, em, code })}</li>
))}
</ul>
<Callout variant="tip" title={t("dashboardView.pillTitle")}>
{t("dashboardView.pillBody")}
</Callout>
<h2 id="dismissing-alerts-and-the-suppression-duration" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24">{t("dismiss.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("dismiss.intro", { em })}
</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
<li>{t.rich("dismiss.step1", { strong, code })}</li>
<li>
{t.rich("dismiss.step2", { strong, code })}
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{`24 hours (default)
72 hours
168 hours (one week)
720 hours (one month)
8760 hours (one year)
-1 (permanent — never re-fires)
<custom> (any positive integer of hours)`}</pre>
</li>
</ol>
<figure className="my-6">
<Image
src="/monitor/dismiss-duration-dropdown.png"
alt={t("dismiss.dropdownImageAlt")}
width={1540}
height={1072}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("dismiss.dropdownImageCaption", { em })}
</figcaption>
</figure>
<figure className="my-6">
<Image
src="/monitor/health-suppression-settings.png"
alt={t("dismiss.imageAlt")}
width={2010}
height={1816}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("dismiss.imageCaption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("dismiss.outro", { code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("dismiss.activeSuppressionsTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("dismiss.activeSuppressionsBody", { strong, em })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("dismiss.autoTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("dismiss.autoBody", { strong })}
</p>
<Callout variant="warning" title={t("dismiss.tempTitle")}>
{t.rich("dismiss.tempBody", { strong })}
</Callout>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("dismiss.nonDismissableTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("dismiss.nonDismissableBody")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dismiss.headerFinding")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dismiss.headerWhy")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{dismissRows.map((row, idx) => (
<tr key={row.finding} className={idx < dismissRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.finding}</strong></td>
<td className="px-3 py-2 align-top">{row.why}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">{t("dismiss.principle")}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("autoresolve.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("autoresolve.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("autoresolve.headerTrigger")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("autoresolve.headerAction")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{autoresolveRows.map((row, idx) => (
<tr key={idx} className={idx < autoresolveRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top">{t.rich(`autoresolve.rows.${idx}.trigger`, { code })}</td>
<td className="px-3 py-2 align-top">{row.action}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="tip" title={t("autoresolve.permanentTitle")}>
{t.rich("autoresolve.permanentBody", { code, em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("observations.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("observations.intro", { code, strong })}
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("observations.headerProperty")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t.rich("observations.headerErrors", { code })}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t.rich("observations.headerObs", { code })}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{observationsRows.map((row, idx) => (
<tr key={row.property} className={idx < observationsRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.property}</strong></td>
<td className="px-3 py-2 align-top">{t.rich(`observations.rows.${idx}.errors`, { em, code, strong })}</td>
<td className="px-3 py-2 align-top">{t.rich(`observations.rows.${idx}.obs`, { em, code, strong })}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">{t("observations.outro")}</p>
<Callout variant="warning" title={t("observations.renameTitle")}>
{t.rich("observations.renameBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("notification.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("notification.intro")}</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{notificationItems.map((_, idx) => (
<li key={idx}>{t.rich(`notification.items.${idx}`, { strong, code })}</li>
))}
</ol>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("notification.outro", { notifLink, aiLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("rest.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("rest.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("rest.headerEndpoint")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("rest.headerMethod")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("rest.headerUse")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{restRows.map((row, idx) => (
<tr key={row.endpoint} className={idx < restRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
<td className="px-3 py-2 align-top">{row.method}</td>
<td className="px-3 py-2 align-top">{t.rich(`rest.rows.${idx}.use`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<CopyableCode
code={`${t("rest.codeComment1")}
curl -H "Authorization: Bearer <api-token>" \\
http://<host>:8008/api/health/full | jq '.health.overall'
${t("rest.codeComment2")}
curl -X POST http://<host>:8008/api/health/acknowledge \\
-H "Authorization: Bearer <api-token>" \\
-H "Content-Type: application/json" \\
-d '{"error_key":"smart_sdh"}'
${t("rest.codeComment3")}
curl -X POST http://<host>:8008/api/health/settings \\
-H "Authorization: Bearer <api-token>" \\
-H "Content-Type: application/json" \\
-d '{"suppress_disks":"168"}'`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.map((item, idx) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail}
</li>
))}
</ul>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,769 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import Image from "next/image"
import { ExternalLink } from "lucide-react"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
import { DataFlowDiagram } from "@/components/ui/data-flow-diagram"
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.monitor.notifications.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"proxmox notifications",
"proxmox telegram",
"proxmox discord",
"proxmox email alerts",
"proxmox gotify",
"proxmox apprise",
"proxmox ntfy",
"proxmox matrix notifications",
"proxmox alerts",
"proxmox notification webhook",
],
alternates: { canonical: "https://proxmenux.com/docs/monitor/notifications" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/monitor/notifications",
},
twitter: {
card: "summary_large_image",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type SourceRow = { collector: string; watches: string; events: string }
type DispatchRow = { stage: string; what: string; tunable: string }
type CatalogueRow = { group: string; events: string }
type ApiRow = { endpoint: string; method: string; use: string }
type WhereNextItem = { label: string; href: string; tail?: string; tailRich?: string }
export default async function NotificationsPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.notifications" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { notifications: {
enabling: { steps: string[] }
sources: { rows: SourceRow[] }
telegram: {
step1Items: string[]
privateItems: string[]
groupItems: string[]
}
discord: { items: string[] }
gotify: { items: string[] }
email: { gmailItems: string[]; outlookItems: string[] }
apprise: { listItems: string[]; steps: string[] }
rich: { togglesItems: string[] }
quiet: { purposeItems: string[]; howItems: string[] }
digest: {
purposeItems: string[]
howItems: string[]
neverDelayedSub: string[]
}
dispatch: { rows: DispatchRow[] }
pveWebhook: {
registeredItems: string[]
securityItems: string[]
actionsItems: string[]
}
catalogue: { rows: CatalogueRow[] }
api: { rows: ApiRow[] }
whereNext: { items: WhereNextItem[] }
} } }
}
const n = messages.docs.monitor.notifications
const enablingSteps = n.enabling.steps
const sourceRows = n.sources.rows
const tgStep1 = n.telegram.step1Items
const tgPriv = n.telegram.privateItems
const tgGroup = n.telegram.groupItems
const discordItems = n.discord.items
const gotifyItems = n.gotify.items
const gmailItems = n.email.gmailItems
const outlookItems = n.email.outlookItems
const appriseListItems = n.apprise.listItems
const appriseSteps = n.apprise.steps
const togglesItems = n.rich.togglesItems
const quietPurpose = n.quiet.purposeItems
const quietHow = n.quiet.howItems
const digestPurpose = n.digest.purposeItems
const digestHow = n.digest.howItems
const digestNeverSub = n.digest.neverDelayedSub
const dispatchRows = n.dispatch.rows
const pveRegistered = n.pveWebhook.registeredItems
const pveSecurity = n.pveWebhook.securityItems
const pveActions = n.pveWebhook.actionsItems
const catalogueRows = n.catalogue.rows
const apiRows = n.api.rows
const whereNextItems = n.whereNext.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 hmLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/health-monitor" className="text-blue-600 hover:underline">{chunks}</Link>
)
const pveLink = (chunks: React.ReactNode) => (
<a href="#pve-webhook-integration" className="text-blue-600 hover:underline">{chunks}</a>
)
const aiLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/ai-assistant#what-context-the-ai-receives" className="text-blue-600 hover:underline">{chunks}</Link>
)
const aiPageLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/ai-assistant" className="text-blue-600 hover:underline">{chunks}</Link>
)
const catalogueLink = (chunks: React.ReactNode) => (
<Link href="#event-catalogue" className="text-blue-600 hover:underline">{chunks}</Link>
)
const quietLink = (chunks: React.ReactNode) => (
<Link href="#quiet-hours" className="text-blue-600 hover:underline">{chunks}</Link>
)
const ext = (href: string) => (chunks: React.ReactNode) =>
(
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={18}
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { link: hmLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howItWorks.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("howItWorks.intro")}</p>
<DataFlowDiagram
nodes={[
{ variant: "source", label: t("howItWorks.nodes.sourcesLabel"), detail: t("howItWorks.nodes.sourcesDetail") },
{ variant: "bridge", label: t("howItWorks.nodes.dispatchLabel"), detail: t("howItWorks.nodes.dispatchDetail") },
{ variant: "bridge", label: t("howItWorks.nodes.aiLabel"), detail: t("howItWorks.nodes.aiDetail") },
{ variant: "target", label: t("howItWorks.nodes.channelsLabel"), detail: t("howItWorks.nodes.channelsDetail") },
]}
arrowLabel={t("howItWorks.arrowLabel")}
caption={t("howItWorks.caption")}
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("enabling.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("enabling.intro", { em })}
</p>
<figure className="my-4">
<Image src="/monitor/settings/notifications-disabled.png" alt={t("enabling.disabledAlt")} width={2000} height={552} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("enabling.disabledCaption")}</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("enabling.stepsIntro")}</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{enablingSteps.map((_, idx) => (
<li key={idx}>{t.rich(`enabling.steps.${idx}`, { em, code, pvelink: pveLink })}</li>
))}
</ol>
<figure className="my-4">
<Image src="/monitor/settings/notifications-active.png" alt={t("enabling.activeAlt")} width={2000} height={1142} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("enabling.activeCaption", { em })}</figcaption>
</figure>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("sources.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("sources.intro", { code })}
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("sources.headerCollector")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("sources.headerWatches")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("sources.headerEvents")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{sourceRows.map((row, idx) => (
<tr key={row.collector} className={idx < sourceRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.collector}</strong></td>
<td className="px-3 py-2 align-top">{t.rich(`sources.rows.${idx}.watches`, { code, pvelink: pveLink })}</td>
<td className="px-3 py-2 align-top">{t.rich(`sources.rows.${idx}.events`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("sources.after1", { code })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("sources.after2", { code, ailink: aiLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("channels.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("channels.intro", { em })}
</p>
<Callout variant="warning" title={t("channels.credsTitle")}>
{t.rich("channels.credsBody", { code })}
</Callout>
<h3 id="telegram" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("telegram.heading")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("telegram.intro", { strong, em })}
</p>
<figure className="my-4">
<Image src="/monitor/settings/telegram-setup-guide.png" alt={t("telegram.guideAlt")} width={1923} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("telegram.guideCaption", { em })}</figcaption>
</figure>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("telegram.step1Title")}</h4>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{tgStep1.map((_, idx) => (
<li key={idx}>{t.rich(`telegram.step1Items.${idx}`, { em, code, a: ext("https://t.me/BotFather") })}</li>
))}
</ol>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("telegram.step2Title")}</h4>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("telegram.step2Intro", { em })}
</p>
<p className="mb-2 text-gray-800 leading-relaxed">
<strong>{t("telegram.privateLabel")}</strong>
</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{tgPriv.map((_, idx) => (
<li key={idx}>
{t.rich(`telegram.privateItems.${idx}`, {
code,
a1: ext("https://t.me/userinfobot"),
a2: ext("https://t.me/myidbot"),
a: ext("https://t.me/userinfobot"),
})}
</li>
))}
</ol>
<figure className="my-4">
<Image src="/monitor/settings/telegram-private-chat.png" alt={t("telegram.privateAlt")} width={2000} height={768} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("telegram.privateCaption")}</figcaption>
</figure>
<p className="mb-2 text-gray-800 leading-relaxed">
<strong>{t("telegram.groupLabel")}</strong>
</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{tgGroup.map((_, idx) => (
<li key={idx}>{t.rich(`telegram.groupItems.${idx}`, { code, em })}</li>
))}
</ol>
<figure className="my-4">
<Image src="/monitor/settings/telegram-group-chat.png" alt={t("telegram.groupAlt")} width={2000} height={768} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("telegram.groupCaption", { code, em })}</figcaption>
</figure>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("telegram.step3Title")}</h4>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("telegram.step3Body", { em })}
</p>
<h3 id="discord" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("discord.heading")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("discord.intro", { em })}
</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{discordItems.map((_, idx) => (
<li key={idx}>{t.rich(`discord.items.${idx}`, { em, code })}</li>
))}
</ol>
<figure className="my-4">
<Image src="/monitor/settings/discord-channel.png" alt={t("discord.imageAlt")} width={2000} height={435} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("discord.imageCaption", { em })}</figcaption>
</figure>
<h3 id="gotify" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("gotify.heading")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("gotify.intro", { em })}
</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{gotifyItems.map((_, idx) => (
<li key={idx}>{t.rich(`gotify.items.${idx}`, { em, code, a: ext("https://gotify.net/docs/install") })}</li>
))}
</ol>
<figure className="my-4">
<Image src="/monitor/settings/gotify-channel.png" alt={t("gotify.imageAlt")} width={2000} height={573} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gotify.imageCaption")}</figcaption>
</figure>
<h3 id="email" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("email.heading")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("email.intro")}</p>
<figure className="my-4">
<Image src="/monitor/settings/email-channel.png" alt={t("email.imageAlt")} width={2000} height={1248} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("email.imageCaption")}</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("email.appNote", { strong })}
</p>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("email.gmailTitle")}</h4>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("email.gmailIntro", { strong, em })}
</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{gmailItems.map((_, idx) => (
<li key={idx}>
{t.rich(`email.gmailItems.${idx}`, {
em,
code,
a:
idx === 0
? ext("https://myaccount.google.com/security")
: ext("https://myaccount.google.com/apppasswords"),
})}
</li>
))}
</ol>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("email.outlookTitle")}</h4>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("email.outlookIntro", { strong })}
</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{outlookItems.map((_, idx) => (
<li key={idx}>{t.rich(`email.outlookItems.${idx}`, { em, code, a: ext("https://account.microsoft.com/security") })}</li>
))}
</ol>
<Callout variant="tip" title={t("email.relayTitle")}>
{t("email.relayBody")}
</Callout>
<h3 id="apprise" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("apprise.heading")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("apprise.intro")}</p>
<p className="mb-4 text-gray-800 leading-relaxed">{t("apprise.listIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{appriseListItems.map((_, idx) => (
<li key={idx}>
{t.rich(`apprise.listItems.${idx}`, {
a: idx === 0 ? ext("https://github.com/caronc/apprise/wiki") : ext("https://github.com/caronc/apprise/wiki/URLBasics"),
})}
</li>
))}
</ul>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("apprise.stepsTitle")}</h4>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{appriseSteps.map((_, idx) => (
<li key={idx}>{t.rich(`apprise.steps.${idx}`, { em, code, a: ext("https://github.com/caronc/apprise/wiki") })}</li>
))}
</ol>
<Callout variant="info" title={t("apprise.deliveredTitle")}>
{t("apprise.deliveredBody")}
</Callout>
<Callout variant="tip" title={t("apprise.fanoutTitle")}>
{t.rich("apprise.fanoutBody", { a: ext("https://github.com/caronc/apprise-api") })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("rich.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("rich.intro", { em })}
</p>
<figure className="my-4">
<Image src="/monitor/settings/notification-categories.png" alt={t("rich.imageAlt")} width={2000} height={1668} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("rich.imageCaption", { em })}</figcaption>
</figure>
<h3 id="rich-messages" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("rich.richTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("rich.richIntro", { em })}
</p>
<div className="grid md:grid-cols-2 gap-4 mb-6 not-prose">
<div className="rounded-lg border-2 border-gray-200 bg-gray-50 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-700 mb-2">
{t("rich.plainHeader")}
</div>
<pre className="text-sm font-mono text-gray-800 whitespace-pre-wrap leading-relaxed m-0">
{`[INFO] vm_start
VM 101 (homeassistant) started
on node pve-01
host: home-lab`}
</pre>
</div>
<div className="rounded-lg border-2 border-blue-300 bg-blue-50 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-blue-800 mb-2">
{t("rich.richHeader")}
</div>
<pre className="text-sm font-mono text-gray-800 whitespace-pre-wrap leading-relaxed m-0">
{`🟢 VM started
VM 101 (homeassistant) is now
running on node pve-01
🏠 home-lab`}
</pre>
</div>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">{t("rich.richOutro")}</p>
<h3 id="event-toggles" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("rich.togglesTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("rich.togglesIntro")}</p>
<ol className="list-decimal pl-6 text-gray-800 leading-relaxed space-y-2 mb-4">
{togglesItems.map((_, idx) => (
<li key={idx}>{t.rich(`rich.togglesItems.${idx}`, { strong, em, code })}</li>
))}
</ol>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("rich.togglesOutro", { em, code })}
</p>
<h2 id="quiet-hours" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("quiet.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("quiet.intro", { strong })}
</p>
<figure className="my-4">
<Image src="/monitor/settings/quiet-hours-and-digest-config.png" alt={t("quiet.imageAlt")} width={1600} height={1200} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("quiet.imageCaption")}</figcaption>
</figure>
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("quiet.purposeTitle")}</h3>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{quietPurpose.map((_, idx) => (
<li key={idx}>{t.rich(`quiet.purposeItems.${idx}`, { strong })}</li>
))}
</ul>
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("quiet.howTitle")}</h3>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{quietHow.map((_, idx) => (
<li key={idx}>{t.rich(`quiet.howItems.${idx}`, { strong, code })}</li>
))}
</ol>
<Callout variant="info" title={t("quiet.criticalTitle")}>
{t.rich("quiet.criticalBody", { link: catalogueLink })}
</Callout>
<h2 id="daily-digest" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("digest.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("digest.intro1", { strong })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("digest.intro2", { link: quietLink })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("digest.purposeTitle")}</h3>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{digestPurpose.map((_, idx) => (
<li key={idx}>{t.rich(`digest.purposeItems.${idx}`, { strong })}</li>
))}
</ul>
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("digest.howTitle")}</h3>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{digestHow.map((_, idx) => (
<li key={idx}>
{t.rich(`digest.howItems.${idx}`, { strong, em, code })}
{idx === 3 && (
<ul className="list-disc pl-6 mt-1">
{digestNeverSub.map((_, sIdx) => (
<li key={sIdx}>{t.rich(`digest.neverDelayedSub.${sIdx}`, { strong })}</li>
))}
</ul>
)}
</li>
))}
</ol>
<Callout variant="tip" title={t("digest.comboTitle")}>
{t.rich("digest.comboBody", { em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("displayName.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("displayName.intro", { em, code })}
</p>
<figure className="my-4">
<Image src="/monitor/settings/display-name.png" alt={t("displayName.imageAlt")} width={2000} height={256} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("displayName.imageCaption")}</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("displayName.outro", { em, code })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dispatch.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("dispatch.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dispatch.headerStage")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dispatch.headerWhat")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dispatch.headerTunable")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{dispatchRows.map((row, idx) => (
<tr key={row.stage} className={idx < dispatchRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.stage}</strong></td>
<td className="px-3 py-2 align-top">{t.rich(`dispatch.rows.${idx}.what`, { code })}</td>
<td className="px-3 py-2 align-top">{row.tunable}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="info" title={t("dispatch.calloutTitle")}>
{t("dispatch.calloutBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("aiRewrite.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("aiRewrite.body1")}</p>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("aiRewrite.body2", { code, link: aiPageLink })}
</p>
<Callout variant="warning" title={t("aiRewrite.privacyTitle")}>
{t("aiRewrite.privacyBody")}
</Callout>
<h2 id="pve-webhook-integration" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("pveWebhook.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("pveWebhook.intro1", { em, code })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("pveWebhook.intro2", { em })}
</p>
<figure className="my-4">
<Image src="/monitor/settings/pve-webhook-target.png" alt={t("pveWebhook.imageAlt")} width={1452} height={1360} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto mx-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("pveWebhook.imageCaption")}</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("pveWebhook.registeredIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{pveRegistered.map((_, idx) => (
<li key={idx}>
{t.rich(`pveWebhook.registeredItems.${idx}`, { strong, em, code })}
{idx === 1 && (
<code className="block mt-2 bg-gray-50 border border-gray-200 rounded p-2 text-xs whitespace-pre-wrap">{`{ "title": "{{ escape title }}",
"message": "{{ escape message }}",
"severity": "{{ severity }}",
"timestamp": "{{ timestamp }}",
"fields": {{ json fields }} }`}</code>
)}
</li>
))}
</ul>
<h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("pveWebhook.securityTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("pveWebhook.securityIntro", { code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{pveSecurity.map((_, idx) => (
<li key={idx}>{t.rich(`pveWebhook.securityItems.${idx}`, { strong, code })}</li>
))}
</ul>
<Callout variant="info" title={t("pveWebhook.practiceTitle")}>
{t.rich("pveWebhook.practiceBody", { code })}
</Callout>
<p className="mb-4 text-gray-800 leading-relaxed">{t("pveWebhook.actionsIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{pveActions.map((_, idx) => (
<li key={idx}>{t.rich(`pveWebhook.actionsItems.${idx}`, { strong, code })}</li>
))}
</ul>
<Callout variant="info" title={t("pveWebhook.clusterTitle")}>
{t.rich("pveWebhook.clusterBody", { code, em })}
</Callout>
<h2 id="event-catalogue" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("catalogue.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("catalogue.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("catalogue.headerGroup")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("catalogue.headerEvents")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{catalogueRows.map((row, idx) => (
<tr key={row.group} className={idx < catalogueRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.group}</strong></td>
<td className="px-3 py-2 align-top">{t.rich(`catalogue.rows.${idx}.events`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("catalogue.burstNote", { code })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("history.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("history.body1", { em, code })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("history.body2", { em })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("history.body3", { code })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("api.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("api.headerEndpoint")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("api.headerMethod")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("api.headerUse")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{apiRows.map((row, idx) => (
<tr key={row.endpoint} className={idx < apiRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
<td className="px-3 py-2 align-top">{row.method}</td>
<td className="px-3 py-2 align-top">{t.rich(`api.rows.${idx}.use`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<CopyableCode
code={`# Send a test notification to Discord
curl -X POST http://<host>:8008/api/notifications/test \\
-H "Authorization: Bearer <api-token>" \\
-H "Content-Type: application/json" \\
-d '{"channel":"discord"}'
# Emit a custom event from a script
curl -X POST http://<host>:8008/api/notifications/send \\
-H "Authorization: Bearer <api-token>" \\
-H "Content-Type: application/json" \\
-d '{"event_type":"custom","severity":"warning","data":{"message":"Cron job took >10 min"}}'
# Pull the last 50 history entries for one channel
curl -H "Authorization: Bearer <api-token>" \\
'http://<host>:8008/api/notifications/history?channel=telegram&limit=50' | jq
# Test an AI provider connection (verifies the API key and model)
curl -X POST http://<host>:8008/api/notifications/test-ai \\
-H "Authorization: Bearer <api-token>" \\
-H "Content-Type: application/json" \\
-d '{"provider":"openai","api_key":"sk-...","model":"gpt-4o-mini"}'`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.map((item, idx) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,322 @@
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"
import { routing } from "@/i18n/routing"
/**
* Pilot page for the i18n migration. Pattern used here is the same one
* contributors should follow for every other docs page:
*
* - Translatable strings live under `messages/<locale>/docs/<section>/<page>.json`
* (or `index.json` when the file represents the section's index page,
* like this one).
* - The page is an async Server Component that calls
* `getTranslations({ namespace: '<namespace>' })` once and uses
* `t()` for plain text and `t.rich()` for paragraphs containing
* <code>, <strong>, <em> or <link> markers.
* - Arrays of structured items (table rows, lists, etc.) are pulled
* with `getMessages({ locale })` and iterated; that keeps the JSON readable
* for translators.
* - generateMetadata uses the same namespace so <title> and OG tags
* translate with the locale.
*/
export async function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }))
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.monitor.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"proxmox monitor",
"proxmox dashboard",
"proxmox ve dashboard",
"proxmox web dashboard",
"proxmox notifications",
"proxmox health monitor",
"proxmox smart monitoring",
"proxmox prometheus",
"proxmox homepage integration",
"proxmenux monitor",
],
alternates: { canonical: "https://proxmenux.com/docs/monitor" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/monitor",
},
twitter: {
card: "summary_large_image",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type CoverageSection = { name: string; description: string }
type NextStepItem = { label: string; description: string }
export default async function MonitorOverviewPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor" })
// Arrays of objects can't be expressed via t(), so pull them straight
// from the message tree. This is the recommended pattern in the
// next-intl docs for repeating structured items like table rows.
const messages = (await getMessages({ locale })) as unknown as {
docs: {
monitor: {
coverage: { sections: CoverageSection[] }
nextSteps: { items: NextStepItem[] }
howItRuns: { bullets: string[] }
}
}
}
const coverageSections = messages.docs.monitor.coverage.sections
const nextStepsItems = messages.docs.monitor.nextSteps.items
const howItRunsBullets = messages.docs.monitor.howItRuns.bullets
// Inline tag renderers shared across every t.rich() call on this page.
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
// <link>...</link> defaults to "/docs/monitor" — the section index.
// Individual t.rich() calls override this to point elsewhere when
// the source string demands it (api section, etc.).
const link = (chunks: React.ReactNode) => (
<Link href="/docs/monitor" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={6}
/>
<Callout variant="info" title={t("atGlance.title")}>
{t.rich("atGlance.body", { code })}
</Callout>
{/* Hero screenshot */}
<figure className="my-8">
<img
src="/monitor/dashboard-home.png"
alt={t("hero.alt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("hero.caption")}
</figcaption>
</figure>
{/* ─────────────────────────── What it covers ─────────────────────────── */}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("coverage.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("coverage.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("coverage.tableSection")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("coverage.tableWhat")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{coverageSections.map((row, idx) => (
<tr
key={row.name}
className={idx < coverageSections.length - 1 ? "border-b border-gray-100" : ""}
>
<td className="px-3 py-2 align-top whitespace-nowrap">
<strong>{row.name}</strong>
</td>
<td className="px-3 py-2 align-top">
{t.rich(`coverage.sections.${idx}.description`, { code })}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("coverage.footer", { link })}
</p>
{/* ─────────────────────────── How it runs ─────────────────────────── */}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howItRuns.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("howItRuns.intro", { code })}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{howItRunsBullets.map((_, idx) => (
<li key={idx}>{t.rich(`howItRuns.bullets.${idx}`, { code, strong })}</li>
))}
</ul>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("howItRuns.footer", { link })}
</p>
<Callout variant="tip" title={t("noAgent.title")}>
{t("noAgent.body")}
</Callout>
{/* ─────────────────────────── Access ─────────────────────────── */}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("access.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("access.intro")}</p>
<CopyableCode
code={`${t("access.codeComment1")}
http://<your-proxmox-ip>:8008
${t("access.codeComment2")}
https://<your-domain>/proxmenux-monitor/`}
className="my-4"
/>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("access.afterCode", { code })}
</p>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("access.footer", { link })}
</p>
{/* ─────────────────────────── Mobile / PWA ─────────────────────────── */}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("mobile.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("mobile.intro", { code })}</p>
<div className="grid md:grid-cols-2 gap-6 my-6 items-start">
<figure>
<img
src="/monitor/mobile-home.png"
alt={t("mobile.phoneAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("mobile.phoneCaption")}
</figcaption>
</figure>
<div>
<h3 className="text-lg font-semibold mb-2 text-gray-900">{t("mobile.addHeading")}</h3>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
<li>
<strong>{t("mobile.iosLabel")}</strong> {t.rich("mobile.iosBody", { code, em })}
</li>
<li>
<strong>{t("mobile.androidLabel")}</strong>{" "}
{t.rich("mobile.androidBody", { em })}
</li>
<li>{t("mobile.afterInstall")}</li>
</ul>
<Callout variant="warning" title={t("mobile.onlineOnlyTitle")} className="mt-4">
{t.rich("mobile.onlineOnlyBody", { strong })}
</Callout>
</div>
</div>
{/* ─────────────────────────── Health Monitor ─────────────────────────── */}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("health.heading")}</h2>
<figure className="my-6">
<img
src="/monitor/health-monitor.png"
alt={t("health.alt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("health.caption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("health.body1", { code, strong })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">{t("health.feedsIntro")}</p>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
<li>{t.rich("health.feedsHealth", { strong })}</li>
<li>{t.rich("health.feedsChannels", { strong })}</li>
<li>{t.rich("health.feedsAI", { strong })}</li>
</ul>
<Callout variant="tip" title={t("health.suppressionTitle")}>
{t.rich("health.suppressionBody", { em })}
</Callout>
{/* ─────────────────────────── API & integrations ─────────────────────────── */}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("api.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("api.intro", { code })}
</p>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
<li>{t.rich("api.tokens", { code, strong })}</li>
<li>{t.rich("api.bearer", { code })}</li>
<li>
{t.rich("api.catalog", {
linkApi: (chunks) => (
<Link href="/docs/monitor" className="text-blue-600 hover:underline">
{chunks}
</Link>
),
linkIntegrations: (chunks) => (
<Link href="/docs/monitor" className="text-blue-600 hover:underline">
{chunks}
</Link>
),
})}
</li>
</ul>
{/* ─────────────────────────── Service control ─────────────────────────── */}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("serviceControl.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("serviceControl.intro", { em })}
</p>
<CopyableCode
code={`${t("serviceControl.codeComment")}
systemctl status proxmenux-monitor.service
systemctl is-active proxmenux-monitor.service
systemctl enable --now proxmenux-monitor.service
systemctl disable --now proxmenux-monitor.service
journalctl -u proxmenux-monitor.service -f`}
className="my-4"
/>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("serviceControl.footer", {
link: (chunks) => (
<Link href="/docs/settings/proxmenux-monitor" className="text-blue-600 hover:underline">
{chunks}
</Link>
),
})}
</p>
{/* ─────────────────────────── Where to next ─────────────────────────── */}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("nextSteps.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{nextStepsItems.map((item) => (
<li key={item.label}>
<Link href="/docs/monitor" className="text-blue-600 hover:underline">
{item.label}
</Link>{" "}
{item.description}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,167 @@
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"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.network.backupRestore.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/network/backup-restore",
},
}
}
type Step = { title: string; body: string; tone: "blue" | "amber" | "emerald" }
type RelatedItem = { label: string; href: string; tail?: string; tailRich?: string }
const TONE_CLASSES: Record<string, string> = {
blue: "border-blue-400 bg-blue-50",
amber: "border-amber-400 bg-amber-50",
emerald: "border-emerald-400 bg-emerald-50",
}
export default async function BackupRestorePage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.network.backupRestore" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { network: { backupRestore: {
restore: { steps: Step[] }
restart: { warnItems: string[] }
related: { items: RelatedItem[] }
} } }
}
const restoreSteps = messages.docs.network.backupRestore.restore.steps
const warnItems = messages.docs.network.backupRestore.restart.warnItems
const relatedItems = messages.docs.network.backupRestore.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 bridgeLink = (chunks: React.ReactNode) => (
<Link href="/docs/network/bridge-analysis" className="text-blue-600 hover:underline">{chunks}</Link>
)
const configLink = (chunks: React.ReactNode) => (
<Link href="/docs/network/config-analysis" className="text-blue-600 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={4}
scriptPath="menus/network_menu.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("shared.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("shared.intro")}</p>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`/var/backups/proxmenux/
├── interfaces_backup_2026-04-26_14-30-12 ← from a guided repair
├── interfaces_backup_2026-04-26_15-08-44 ← from "Create Network Backup" (this page)
├── interfaces_backup_2026-04-26_18-22-09 ← auto-taken before a restore
└── …`}</pre>
<p className="mt-4 mb-6 text-gray-800 leading-relaxed">{t("shared.outro")}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("show.heading")}</h2>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("show.body", { code })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("create.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("create.body", { code })}
</p>
<Callout variant="tip" title={t("create.whenTitle")}>
{t.rich("create.whenBody", { code, em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("restore.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("restore.intro", { code })}
</p>
<div className="space-y-4 mb-6">
{restoreSteps.map((step, idx) => (
<div key={idx} className={`border-l-4 ${TONE_CLASSES[step.tone]} p-4 rounded-r-md`}>
<div className="font-semibold text-gray-900 mb-1">{step.title}</div>
<p className="text-sm text-gray-800 m-0">{t.rich(`restore.steps.${idx}.body`, { code, strong })}</p>
</div>
))}
</div>
<Callout variant="warning" title={t("restore.autoBackupTitle")}>
{t.rich("restore.autoBackupBody", { em, code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("restart.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("restart.body", { code })}
</p>
<Callout variant="danger" title={t("restart.warnTitle")}>
{t.rich("restart.warnBody", { code })}
<ul className="list-disc pl-6 mt-2 mb-0 space-y-1">
{warnItems.map((_, idx) => (
<li key={idx}>{t.rich(`restart.warnItems.${idx}`, { em })}</li>
))}
</ul>
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manualRollback.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("manualRollback.intro")}</p>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`ls -lt /var/backups/proxmenux/interfaces_backup_* # newest first
cp /var/backups/proxmenux/interfaces_backup_<TIMESTAMP> /etc/network/interfaces
systemctl restart networking`}</pre>
<p className="mt-4 mb-6 text-gray-800 leading-relaxed">
{t.rich("manualRollback.outro", { em, code })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.noneTitle")}>
{t.rich("troubleshoot.noneBody", { code, em })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.unreachTitle")}>
{t.rich("troubleshoot.unreachBody", { bridgeLink, configLink })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.emptyTitle")}>
{t.rich("troubleshoot.emptyBody", { 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, idx) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tailRich ? t.rich(`related.items.${idx}.tailRich`, { code }) : item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,224 @@
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 { DataFlowDiagram } from "@/components/ui/data-flow-diagram"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.network.bridgeAnalysis.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/network/bridge-analysis",
},
}
}
type Step = { title: string; body: string; tone: "blue" | "amber" | "emerald" }
type RelatedItem = { label: string; href: string; tail?: string; tailRich?: string }
const TONE_CLASSES: Record<string, string> = {
blue: "border-blue-400 bg-blue-50",
amber: "border-amber-400 bg-amber-50",
emerald: "border-emerald-400 bg-emerald-50",
}
export default async function BridgeAnalysisPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.network.bridgeAnalysis" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { network: { bridgeAnalysis: {
when: { items: string[] }
step1: { items: string[] }
step2: { steps: Step[] }
related: { items: RelatedItem[] }
} } }
}
const whenItems = messages.docs.network.bridgeAnalysis.when.items
const step1Items = messages.docs.network.bridgeAnalysis.step1.items
const repairSteps = messages.docs.network.bridgeAnalysis.step2.steps
const relatedItems = messages.docs.network.bridgeAnalysis.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 persistentLink = (chunks: React.ReactNode) => (
<Link href="/docs/network/persistent-names" className="text-blue-600 hover:underline">{chunks}</Link>
)
const configLink = (chunks: React.ReactNode) => (
<Link href="/docs/network/config-analysis" className="text-blue-600 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={6}
scriptPath="menus/network_menu.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code, strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("when.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("when.intro", { code })}
</p>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{whenItems.map((_, idx) => (
<li key={idx}>{t.rich(`when.items.${idx}`, { code })}</li>
))}
</ul>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("when.outro", { link: persistentLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("bigPicture.heading")}</h2>
<DataFlowDiagram
nodes={[
{
label: t("bigPicture.diagram1.nodes.sourceLabel"),
detail: t("bigPicture.diagram1.nodes.sourceDetail"),
variant: "source",
},
{
label: t("bigPicture.diagram1.nodes.bridgeLabel"),
detail: t("bigPicture.diagram1.nodes.bridgeDetail"),
variant: "bridge",
},
{
label: t("bigPicture.diagram1.nodes.targetLabel"),
detail: t("bigPicture.diagram1.nodes.targetDetail"),
variant: "target",
},
]}
arrowLabel={t("bigPicture.diagram1.arrowLabel")}
/>
<DataFlowDiagram
nodes={[
{
label: t("bigPicture.diagram2.nodes.sourceLabel"),
detail: t("bigPicture.diagram2.nodes.sourceDetail"),
variant: "source",
},
{
label: t("bigPicture.diagram2.nodes.targetLabel"),
detail: t("bigPicture.diagram2.nodes.targetDetail"),
variant: "target",
},
]}
arrowLabel={t("bigPicture.diagram2.arrowLabel")}
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("step1.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("step1.intro", { strong })}
</p>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{step1Items.map((_, idx) => (
<li key={idx}>{t.rich(`step1.items.${idx}`, { code })}</li>
))}
</ul>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`🔍 BRIDGE CONFIGURATION ANALYSIS
==================================================
🌉 Bridge: vmbr0
📍 Status: DOWN
🌐 IP: No IP assigned
🔌 Configured Ports: enp3s0
❌ Port enp3s0: NOT FOUND
🔧 SUGGESTION FOR vmbr0:
Replace invalid port(s) 'enp3s0' with: eno1
Command: sed -i 's/bridge-ports.*/bridge-ports eno1/' /etc/network/interfaces
📊 ANALYSIS SUMMARY
=========================
Bridges analyzed: 1
Issues found: 1
Physical interfaces available: 2
⚠️ IMPORTANT: No changes have been made to your system
Use the Guided Repair option to fix issues safely`}</pre>
<Callout variant="success" title={t("step1.readonlyTitle")}>
{t.rich("step1.readonlyBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("step2.heading")}</h2>
<p className="mb-6 text-gray-800 leading-relaxed">{t("step2.intro")}</p>
<div className="space-y-4 mb-6">
{repairSteps.map((step, idx) => (
<div key={idx} className={`border-l-4 ${TONE_CLASSES[step.tone]} p-4 rounded-r-md`}>
<div className="font-semibold text-gray-900 mb-1">{step.title}</div>
<p className="text-sm text-gray-800 m-0">{t.rich(`step2.steps.${idx}.body`, { code, em, strong })}</p>
</div>
))}
</div>
<Callout variant="warning" title={t("step2.restartTitle")}>
{t.rich("step2.restartBody", { code, em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("edits.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("edits.body", { code, link: configLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.unsupportedTitle")}>
{t.rich("troubleshoot.unsupportedBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.noSuggestTitle")}>
{t.rich("troubleshoot.noSuggestBody", { code, em })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.stillDownTitle")}>
{t.rich("troubleshoot.stillDownBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.lostSshTitle")}>
{t("troubleshoot.lostSshIntro")}
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{`cp /var/backups/proxmenux/interfaces_backup_<TIMESTAMP> /etc/network/interfaces
systemctl restart networking`}</pre>
{t("troubleshoot.lostSshOutro")}
</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, idx) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tailRich ? t.rich(`related.items.${idx}.tailRich`, { code }) : item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,201 @@
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"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.network.configAnalysis.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/network/config-analysis",
},
}
}
type DiffersRow = { aspect: string; bridge: string; config: string }
type Step = { title: string; body: string; tone: "blue" | "amber" | "emerald" }
type RelatedItem = { label: string; href: string; tail?: string; tailRich?: string }
const TONE_CLASSES: Record<string, string> = {
blue: "border-blue-400 bg-blue-50",
amber: "border-amber-400 bg-amber-50",
emerald: "border-emerald-400 bg-emerald-50",
}
export default async function ConfigAnalysisPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.network.configAnalysis" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { network: { configAnalysis: {
differs: { rows: DiffersRow[] }
step2: { steps: Step[] }
related: { items: RelatedItem[] }
} } }
}
const differsRows = messages.docs.network.configAnalysis.differs.rows
const cleanupSteps = messages.docs.network.configAnalysis.step2.steps
const relatedItems = messages.docs.network.configAnalysis.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 bridgeLink = (chunks: React.ReactNode) => (
<Link href="/docs/network/bridge-analysis" className="text-blue-600 hover:underline">{chunks}</Link>
)
const persistentLink = (chunks: React.ReactNode) => (
<Link href="/docs/network/persistent-names" className="text-blue-600 hover:underline">{chunks}</Link>
)
const link = bridgeLink
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={5}
scriptPath="menus/network_menu.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code, em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("differs.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("differs.headerAspect")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("differs.headerBridge")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("differs.headerConfig")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{differsRows.map((row, idx) => (
<tr key={idx} className={idx < differsRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap">
<strong>{t.rich(`differs.rows.${idx}.aspect`, { code })}</strong>
</td>
<td className="px-3 py-2 align-top">{t.rich(`differs.rows.${idx}.bridge`, { code })}</td>
<td className="px-3 py-2 align-top">{t.rich(`differs.rows.${idx}.config`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("differs.outro", { strong, link })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("step1.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("step1.intro")}</p>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`🔍 NETWORK CONFIGURATION ANALYSIS
==================================================
📋 CONFIGURED INTERFACES
==============================
🔌 Interface: enp3s0
❌ Status: NOT FOUND
⚠️ Issue: Configured but doesn't exist
🔌 Interface: eno1
✅ Status: EXISTS (UP)
🌐 IP: 192.168.1.10/24
Type: Physical interface
🔌 Interface: vmbr0
✅ Status: EXISTS (UP)
🌐 IP: 192.168.1.10/24
Type: Virtual interface (normal)
🔧 SUGGESTION FOR enp3s0:
This interface is configured but doesn't exist physically
Consider removing its configuration
Command: sed -i '/iface enp3s0/,/^$/d' /etc/network/interfaces
📊 ANALYSIS SUMMARY
=========================
Interfaces configured: 3
Issues found: 1
⚠️ IMPORTANT: No changes have been made to your system
Use the Guided Cleanup option to fix issues safely`}</pre>
<Callout variant="success" title={t("step1.virtTitle")}>
{t.rich("step1.virtBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("step2.heading")}</h2>
<p className="mb-6 text-gray-800 leading-relaxed">{t("step2.intro")}</p>
<div className="space-y-4 mb-6">
{cleanupSteps.map((step, idx) => (
<div key={idx} className={`border-l-4 ${TONE_CLASSES[step.tone]} p-4 rounded-r-md`}>
<div className="font-semibold text-gray-900 mb-1">{step.title}</div>
<p className="text-sm text-gray-800 m-0">{t.rich(`step2.steps.${idx}.body`, { code, em, strong })}</p>
</div>
))}
</div>
<Callout variant="warning" title={t("step2.noRestartTitle")}>
{t.rich("step2.noRestartBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("caveats.heading")}</h2>
<Callout variant="warning" title={t("caveats.boundaryTitle")}>
{t.rich("caveats.boundaryBody", { code, strong })}
</Callout>
<Callout variant="info" title={t("caveats.tandemTitle")}>
{t.rich("caveats.tandemBody", { code, link: bridgeLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.notFoundTitle")}>
{t.rich("troubleshoot.notFoundBody", { code, link: persistentLink })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.tooMuchTitle")}>
{t("troubleshoot.tooMuchBody")}
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{`cp /var/backups/proxmenux/interfaces_backup_<TIMESTAMP> /etc/network/interfaces`}</pre>
{t("troubleshoot.tooMuchOutro")}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.bridgeBreakTitle")}>
{t.rich("troubleshoot.bridgeBreakBody", { code, link: bridgeLink })}
</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,148 @@
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"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.network.diagnostics.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/network/diagnostics",
},
}
}
type ConnRow = { test: string; target: string; confirms: string }
type RelatedItem = { label: string; href: string; tail: string }
export default async function NetworkDiagnosticsPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.network.diagnostics" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { network: { diagnostics: {
connectivity: { rows: ConnRow[] }
advanced: { items: string[] }
related: { items: RelatedItem[] }
} } }
}
const connRows = messages.docs.network.diagnostics.connectivity.rows
const advancedItems = messages.docs.network.diagnostics.advanced.items
const relatedItems = messages.docs.network.diagnostics.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 monitoringLink = (chunks: React.ReactNode) => (
<Link href="/docs/network/monitoring" className="text-blue-700 hover:underline">{chunks}</Link>
)
const backupLink = (chunks: React.ReactNode) => (
<Link href="/docs/network/backup-restore" className="text-blue-700 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={3}
scriptPath="menus/network_menu.sh"
/>
<Callout variant="success" title={t("intro.title")}>
{t.rich("intro.body", { strong, monitoringLink, backupLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("routing.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("routing.body", { code })}
</p>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`Total routes: 4
➡ default via 192.168.1.1 dev vmbr0 onlink
• 10.10.10.0/24 dev vmbr1 proto kernel scope link src 10.10.10.1
• 169.254.0.0/16 dev vmbr0 scope link metric 1000
• 192.168.1.0/24 dev vmbr0 proto kernel scope link src 192.168.1.10
🌍 Default Gateway: 192.168.1.1`}</pre>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("connectivity.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("connectivity.intro", { code })}
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("connectivity.headerTest")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("connectivity.headerTarget")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("connectivity.headerConfirms")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{connRows.map((row, idx) => (
<tr key={row.test} className={idx < connRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.test}</strong></td>
<td className="px-3 py-2 align-top whitespace-nowrap font-mono">{row.target}</td>
<td className="px-3 py-2 align-top">{row.confirms}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="info" title={t("connectivity.readingTitle")}>
{t.rich("connectivity.readingBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("advanced.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("advanced.intro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
{advancedItems.map((_, idx) => (
<li key={idx}>{t.rich(`advanced.items.${idx}`, { strong, code, em })}</li>
))}
</ul>
<Callout variant="warning" title={t("advanced.nmTitle")}>
{t.rich("advanced.nmBody", { 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.gwTitle")}>
{t.rich("troubleshoot.gwBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.dupTitle")}>
{t.rich("troubleshoot.dupBody", { 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,238 @@
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"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.network.monitoring.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/network/monitoring",
},
}
}
type WhenRow = { question: string; use: string }
type IptrafRow = { mode: string; useFor: string }
type IperfRow = { mode: string; behaviour: string; cli: string }
type RelatedItem = { label: string; href: string; tail?: string; tailRich?: string }
export default async function NetworkMonitoringPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.network.monitoring" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { network: { monitoring: {
when: { rows: WhenRow[] }
iptraf: { rows: IptrafRow[] }
iperf3: { rows: IperfRow[]; workflow: string[] }
related: { items: RelatedItem[] }
} } }
}
const whenRows = messages.docs.network.monitoring.when.rows
const iptrafRows = messages.docs.network.monitoring.iptraf.rows
const iperfRows = messages.docs.network.monitoring.iperf3.rows
const workflow = messages.docs.network.monitoring.iperf3.workflow
const relatedItems = messages.docs.network.monitoring.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 kbd = (chunks: React.ReactNode) => <kbd>{chunks}</kbd>
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={4}
scriptPath="menus/network_menu.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("when.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("when.headerQuestion")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("when.headerUse")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{whenRows.map((row, idx) => (
<tr key={idx} className={idx < whenRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top">{t.rich(`when.rows.${idx}.question`, { em })}</td>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.use}</strong></td>
</tr>
))}
</tbody>
</table>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("iftop.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("iftop.body", { code, em })}
</p>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`12.5Kb 25.0Kb 37.5Kb 50.0Kb 62.5Kb
└─────────────┴─────────────┴─────────────┴──────────────┴──────────
proxmox.lan => 192.168.1.50 14.2Kb 9.8Kb 7.1Kb
<= 2.3Kb 1.7Kb 1.4Kb
proxmox.lan => 1.1.1.1 0b 145b 38b
<= 128b 96b 24b
─────────────────────────────────────────────────────────────────
TX: 14.2Kb 9.9Kb 7.1Kb
RX: 2.4Kb 1.8Kb 1.4Kb
TOTAL: 16.6Kb 11.7Kb 8.5Kb`}</pre>
<p className="mt-4 mb-4 text-gray-800 leading-relaxed">
{t.rich("iftop.exit", { strong, kbd })}
</p>
<Callout variant="tip" title={t("iftop.keysTitle")}>
{t.rich("iftop.keysBody", { code, kbd })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("iptraf.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("iptraf.intro", { em })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">{t("iptraf.menuIntro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("iptraf.headerMode")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("iptraf.headerUseFor")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{iptrafRows.map((row, idx) => (
<tr key={idx} className={idx < iptrafRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.mode}</strong></td>
<td className="px-3 py-2 align-top">{row.useFor}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("iptraf.exit", { strong, kbd })}
</p>
<Callout variant="tip" title={t("iptraf.logTitle")}>
{t.rich("iptraf.logBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("iperf3.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("iperf3.intro1", { em })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("iperf3.intro2", { strong })}
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("iperf3.headerMode")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("iperf3.headerBehaviour")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("iperf3.headerCli")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{iperfRows.map((row, idx) => (
<tr key={idx} className={idx < iperfRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.mode}</strong></td>
<td className="px-3 py-2 align-top">{row.behaviour}</td>
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.cli}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">{t("iperf3.workflowIntro")}</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{workflow.map((_, idx) => (
<li key={idx}>{t.rich(`iperf3.workflow.${idx}`, { em, strong })}</li>
))}
</ol>
<p className="mb-4 text-gray-800 leading-relaxed">{t("iperf3.sample")}</p>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`Connecting to host 10.0.0.10, port 5201
[ 5] local 10.0.0.20 port 53994 connected to 10.0.0.10 port 5201
[ ID] Interval Transfer Bitrate Retr Cwnd
[ 5] 0.00-1.00 sec 1.10 GBytes 9.45 Gbits/sec 0 1.55 MBytes
[ 5] 1.00-2.00 sec 1.10 GBytes 9.45 Gbits/sec 0 1.55 MBytes
[ 5] 2.00-3.00 sec 1.10 GBytes 9.45 Gbits/sec 0 1.55 MBytes
...
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval Transfer Bitrate Retr
[ 5] 0.00-10.00 sec 11.0 GBytes 9.45 Gbits/sec 0 sender
[ 5] 0.00-10.00 sec 11.0 GBytes 9.44 Gbits/sec receiver
iperf Done.`}</pre>
<Callout variant="tip" title={t("iperf3.flagsTitle")}>
{t.rich("iperf3.flagsBody", { code })}
</Callout>
<Callout variant="warning" title={t("iperf3.firewallTitle")}>
{t.rich("iperf3.firewallBody", { code, strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("install.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("install.body", { code })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.hangTitle")}>
{t.rich("troubleshoot.hangBody", { code, kbd })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.refusedTitle")}>
{t.rich("troubleshoot.refusedBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.slowTitle")}>
{t.rich("troubleshoot.slowBody", { code, em })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.noTrafficTitle")}>
{t.rich("troubleshoot.noTrafficBody", { 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,293 @@
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,
Activity,
LineChart,
Wrench,
ListChecks,
Tag,
Archive,
ShieldCheck,
ShieldAlert,
Eye,
} 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.network.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"proxmox network management",
"proxmox bridge configuration",
"proxmox bond",
"proxmox vlan",
"proxmox network repair",
"proxmox /etc/network/interfaces",
"proxmox network diagnostics",
"proxmox persistent interface names",
"proxmox network backup",
],
alternates: { canonical: "https://proxmenux.com/docs/network" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/network",
},
twitter: {
card: "summary",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type SectionOption = { title: string; description: string }
const READ_ONLY_CONFIG = [
{ Icon: Activity, href: "/docs/network/diagnostics" },
{ Icon: LineChart, href: "/docs/network/monitoring" },
]
const ANALYZE_CONFIG = [
{ Icon: Wrench, href: "/docs/network/bridge-analysis" },
{ Icon: ListChecks, href: "/docs/network/config-analysis" },
]
const APPLY_CONFIG = [
{ Icon: Tag, href: "/docs/network/persistent-names" },
{ Icon: Archive, href: "/docs/network/backup-restore" },
]
function OptionCard({
title,
description,
Icon,
href,
}: SectionOption & {
Icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>
href: string
}) {
return (
<Link
href={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">
{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">{description}</div>
</div>
</Link>
)
}
export default async function NetworkOverviewPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.network" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { network: {
tiers: {
readOnly: { items: string[] }
analyze: { items: string[] }
apply: { items: string[] }
}
readOnlySection: { options: SectionOption[] }
analyzeSection: { options: SectionOption[] }
applySection: { options: SectionOption[] }
} }
}
const readOnlyTierItems = messages.docs.network.tiers.readOnly.items
const analyzeTierItems = messages.docs.network.tiers.analyze.items
const applyTierItems = messages.docs.network.tiers.apply.items
const readOnlyOptions = messages.docs.network.readOnlySection.options
const analyzeOptions = messages.docs.network.analyzeSection.options
const applyOptions = messages.docs.network.applySection.options
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="menus/network_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("openingMenu.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("openingMenu.intro", { strong })}
</p>
<Image
src="/network/network-menu.png"
alt={t("openingMenu.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("safety.heading")}</h2>
<p className="mb-6 text-gray-800 leading-relaxed">{t("safety.body")}</p>
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-3 mb-8 not-prose">
<a
href="#read-only"
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">
<Eye className="h-5 w-5" aria-hidden />
</span>
<h3 className="text-lg font-semibold text-gray-900 m-0">{t("tiers.readOnly.title")}</h3>
</div>
<p className="text-sm text-gray-800 mb-3">{t("tiers.readOnly.body")}</p>
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
{readOnlyTierItems.map((item, idx) => (
<li key={idx}>{item}</li>
))}
</ul>
</a>
<a
href="#analyze"
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">
<ShieldAlert className="h-5 w-5" aria-hidden />
</span>
<h3 className="text-lg font-semibold text-gray-900 m-0">{t("tiers.analyze.title")}</h3>
</div>
<p className="text-sm text-gray-800 mb-3">{t("tiers.analyze.body")}</p>
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
{analyzeTierItems.map((item, idx) => (
<li key={idx}>{item}</li>
))}
</ul>
</a>
<a
href="#apply"
className="rounded-lg border-2 border-red-300 bg-red-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-red-100 text-red-700">
<ShieldCheck className="h-5 w-5" aria-hidden />
</span>
<h3 className="text-lg font-semibold text-gray-900 m-0">{t("tiers.apply.title")}</h3>
</div>
<p className="text-sm text-gray-800 mb-3">{t("tiers.apply.body")}</p>
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
{applyTierItems.map((item, idx) => (
<li key={idx}>{item}</li>
))}
</ul>
</a>
</div>
<Callout variant="warning" title={t("classicTitle")}>
{t.rich("classicBody", { strong, code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("backups.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("backups.intro", { code })}
</p>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`/var/backups/proxmenux/
├── interfaces_backup_2026-04-26_14-30-12
├── interfaces_backup_2026-04-26_15-08-44
└── interfaces_backup_2026-04-26_18-22-09`}</pre>
<p className="mt-4 mb-4 text-gray-800 leading-relaxed">{t("backups.rollbackIntro")}</p>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`cp /var/backups/proxmenux/interfaces_backup_<TIMESTAMP> /etc/network/interfaces
systemctl restart networking`}</pre>
<h2 id="read-only" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24">
{t("readOnlySection.heading")}
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("readOnlySection.body", { code })}
</p>
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
{readOnlyOptions.map((o, idx) => (
<OptionCard
key={READ_ONLY_CONFIG[idx].href}
title={o.title}
description={o.description}
Icon={READ_ONLY_CONFIG[idx].Icon}
href={READ_ONLY_CONFIG[idx].href}
/>
))}
</div>
<h2 id="analyze" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24">
{t("analyzeSection.heading")}
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("analyzeSection.body", { code, strong })}
</p>
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
{analyzeOptions.map((o, idx) => (
<OptionCard
key={ANALYZE_CONFIG[idx].href}
title={o.title}
description={o.description}
Icon={ANALYZE_CONFIG[idx].Icon}
href={ANALYZE_CONFIG[idx].href}
/>
))}
</div>
<h2 id="apply" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24">
{t("applySection.heading")}
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("applySection.body", { em })}
</p>
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
{applyOptions.map((o, idx) => (
<OptionCard
key={APPLY_CONFIG[idx].href}
title={o.title}
description={o.description}
Icon={APPLY_CONFIG[idx].Icon}
href={APPLY_CONFIG[idx].href}
/>
))}
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("consoleTitle")}</h2>
<Callout variant="danger" title={t("consoleSubTitle")}>
{t("consoleBody")}
</Callout>
</div>
)
}

View File

@@ -0,0 +1,174 @@
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 { DataFlowDiagram } from "@/components/ui/data-flow-diagram"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.network.persistentNames.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/network/persistent-names",
},
}
}
type ScopeRow = { type: string; behaviour: string; why: string }
type RelatedItem = { label: string; href: string; tail?: string; tailRich?: string }
export default async function PersistentNamesPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.network.persistentNames" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { network: { persistentNames: {
problem: { items: string[] }
scope: { rows: ScopeRow[] }
afterReboot: { items: string[] }
related: { items: RelatedItem[] }
} } }
}
const problemItems = messages.docs.network.persistentNames.problem.items
const scopeRows = messages.docs.network.persistentNames.scope.rows
const afterRebootItems = messages.docs.network.persistentNames.afterReboot.items
const relatedItems = messages.docs.network.persistentNames.related.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={4}
scriptPath="menus/network_menu.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code, em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("problem.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("problem.intro", { code, em })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{problemItems.map((_, idx) => (
<li key={idx}>{t.rich(`problem.items.${idx}`, { code })}</li>
))}
</ul>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("problem.outro", { code, em })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howWorks.heading")}</h2>
<DataFlowDiagram
nodes={[
{ label: t("howWorks.nodes.detectLabel"), detail: t("howWorks.nodes.detectDetail"), variant: "source" },
{ label: t("howWorks.nodes.readLabel"), detail: t("howWorks.nodes.readDetail"), variant: "bridge" },
{ label: t("howWorks.nodes.writeLabel"), detail: t("howWorks.nodes.writeDetail"), variant: "target" },
]}
arrowLabel={t("howWorks.arrowLabel")}
/>
<p className="mt-6 mb-4 text-gray-800 leading-relaxed">{t("howWorks.minimalIntro")}</p>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`# /etc/systemd/network/10-eno1.link
[Match]
MACAddress=aa:bb:cc:dd:ee:ff
[Link]
Name=eno1`}</pre>
<p className="mt-4 mb-6 text-gray-800 leading-relaxed">
{t.rich("howWorks.minimalOutro", { em, code })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("scope.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("scope.headerType")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("scope.headerBehaviour")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("scope.headerWhy")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{scopeRows.map((row, idx) => (
<tr key={row.type} className={idx < scopeRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.type}</strong></td>
<td className="px-3 py-2 align-top">{t.rich(`scope.rows.${idx}.behaviour`, { code })}</td>
<td className="px-3 py-2 align-top">{t.rich(`scope.rows.${idx}.why`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("safety.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("safety.intro", { code })}
</p>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`/etc/systemd/network/backup-20260426-143012/
├── 10-eno1.link (previous version)
└── 10-enp3s0.link (previous version)`}</pre>
<p className="mt-4 mb-6 text-gray-800 leading-relaxed">{t("safety.outro")}</p>
<Callout variant="warning" title={t("safety.rebootTitle")}>
{t.rich("safety.rebootBody", { code, em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("afterReboot.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("afterReboot.intro")}</p>
<ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-2">
{afterRebootItems.map((_, idx) => (
<li key={idx}>{t.rich(`afterReboot.items.${idx}`, { code })}</li>
))}
</ol>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.emptyTitle")}>
{t.rich("troubleshoot.emptyBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.noChangeTitle")}>
{t.rich("troubleshoot.noChangeBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.undoTitle")}>
{t.rich("troubleshoot.undoBody", { 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, idx) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tailRich ? t.rich(`related.items.${idx}.tailRich`, { em }) : item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,18 @@
import { redirect } from "@/i18n/navigation"
import { routing } from "@/i18n/routing"
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }))
}
// Docs root has no content of its own — bounce to the canonical entry
// page (Introduction). Using next-intl's redirect keeps the locale prefix
// in the resulting URL.
export default async function DocsRoot({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
redirect({ href: "/docs/introduction", locale })
}

View File

@@ -0,0 +1,196 @@
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.postInstall.automated.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/post-install/automated",
},
}
}
type OptimizationRow = { tool: string; what: string; category: string; categorySlug: string }
type RelatedItem = { label: string; href: string; tail: string }
export default async function AutomatedPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.postInstall.automated" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { postInstall: { automated: {
optimizations: OptimizationRow[]
upgrade: { items: string[] }
notDoes: { items: string[] }
related: { items: RelatedItem[] }
} } }
}
const optimizations = messages.docs.postInstall.automated.optimizations
const upgradeItems = messages.docs.postInstall.automated.upgrade.items
const notDoesItems = messages.docs.postInstall.automated.notDoes.items
const relatedItems = messages.docs.postInstall.automated.related.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const customLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/customizable" className="text-blue-700 hover:underline">{chunks}</Link>
)
const upgradeLink = (chunks: React.ReactNode) => (
<Link href="/docs/utils/system-update" className="text-blue-600 hover:underline">{chunks}</Link>
)
const secLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/security" className="text-blue-600 hover:underline">{chunks}</Link>
)
const virtLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/virtualization" className="text-blue-600 hover:underline">{chunks}</Link>
)
const optLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/optional" className="text-blue-600 hover:underline">{chunks}</Link>
)
const perfLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/performance" className="text-blue-600 hover:underline">{chunks}</Link>
)
const storLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/storage" className="text-blue-600 hover:underline">{chunks}</Link>
)
const overviewLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install" className="text-blue-700 hover:underline">{chunks}</Link>
)
const uninstallLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/uninstall" className="text-blue-600 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={10}
scriptPath="post_install/auto_post_install.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { strong, link: customLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("applies.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("applies.intro", {
em: (chunks) => <em>{chunks}</em>,
})}
</p>
<div className="overflow-x-auto rounded-md border border-gray-200 mb-6">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-gray-700">
<tr>
<th className="px-4 py-2 font-semibold">{t("applies.headerNum")}</th>
<th className="px-4 py-2 font-semibold">{t("applies.headerTool")}</th>
<th className="px-4 py-2 font-semibold">{t("applies.headerWhat")}</th>
<th className="px-4 py-2 font-semibold">{t("applies.headerCategory")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-800">
{optimizations.map((o, i) => (
<tr key={i}>
<td className="px-4 py-2 text-gray-500 font-mono">{i + 1}</td>
<td className="px-4 py-2 font-semibold">{o.tool}</td>
<td className="px-4 py-2 text-gray-700 leading-relaxed">{o.what}</td>
<td className="px-4 py-2">
<Link
href={`/docs/post-install/${o.categorySlug}`}
className="text-blue-600 hover:underline whitespace-nowrap"
>
{o.category}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="tip" title={t("hardwareTitle")}>
{t.rich("hardwareBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("upgrade.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("upgrade.intro")}</p>
<CopyableCode
code={`apt update && apt full-upgrade -y`}
language="bash"
/>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("upgrade.after", { strong, link: upgradeLink })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{upgradeItems.map((_, idx) => (
<li key={idx}>{t.rich(`upgrade.items.${idx}`, { code })}</li>
))}
</ul>
<Callout variant="info" title={t("upgrade.sameTitle")}>
{t.rich("upgrade.sameBody", { code, link: upgradeLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("endResult.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("endResult.body")}</p>
<Image
src="/post-install/automated-result.png"
alt={t("endResult.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("notDoes.heading")}</h2>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{notDoesItems.map((_, idx) => (
<li key={idx}>{t.rich(`notDoes.items.${idx}`, { secLink, virtLink, optLink, perfLink, storLink })}</li>
))}
</ul>
<Callout variant="warning" title={t("xshokTitle")}>
{t.rich("xshokBody", { code, link: overviewLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("revert.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("revert.body", { code, link: uninstallLink })}
</p>
<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,250 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import Image from "next/image"
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.postInstall.basicSettings.meta" })
return { title: t("title"), description: t("description") }
}
type UpgradeRow = { version: string; script: string; codename: string }
type UtilityItem = { pkg: string; desc: string }
type UtilityGroup = { group: string; items: UtilityItem[] }
type RelatedItem = { label: string; href: string; tail: string }
const SCREENSHOTS = [
{ pkg: "htop", alt: "htop interactive process viewer", src: "/basic/htop.png" },
{ pkg: "btop", alt: "btop resource monitor", src: "/basic/btop.png" },
{ pkg: "iftop", alt: "iftop bandwidth per connection", src: "/basic/iftop.png" },
{ pkg: "iotop", alt: "iotop disk I/O per process", src: "/basic/iotop.png" },
{ pkg: "iptraf-ng", alt: "iptraf-ng IP LAN monitor", src: "/basic/iptraf-ng.png" },
{ pkg: "tmux", alt: "tmux terminal multiplexer", src: "/basic/tmux.png" },
]
export default async function PostInstallBasicSettingsPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.postInstall.basicSettings" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { postInstall: { basicSettings: {
upgrade: { rows: UpgradeRow[]; doesItems: string[] }
utilities: { groups: UtilityGroup[] }
related: { items: RelatedItem[] }
} } }
}
const upgradeRows = messages.docs.postInstall.basicSettings.upgrade.rows
const doesItems = messages.docs.postInstall.basicSettings.upgrade.doesItems
const utilityGroups = messages.docs.postInstall.basicSettings.utilities.groups
const relatedItems = messages.docs.postInstall.basicSettings.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 updateLink = (chunks: React.ReactNode) => (
<Link href="/docs/utils/system-update" className="text-blue-600 hover:underline">{chunks}</Link>
)
const uninstallLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/uninstall" className="text-blue-700 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
section={t("header.section")}
/>
<Callout variant="info" title={t("intro.title")}>
{t("intro.body")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("upgrade.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("upgrade.intro", { em })}
</p>
<div className="my-4 overflow-x-auto">
<table className="min-w-full border border-gray-200 text-sm">
<thead className="bg-gray-100">
<tr>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("upgrade.headerVersion")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("upgrade.headerScript")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("upgrade.headerCodename")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{upgradeRows.map((row) => (
<tr key={row.version}>
<td className="border border-gray-200 px-3 py-2">{row.version}</td>
<td className="border border-gray-200 px-3 py-2"><code>{row.script}</code></td>
<td className="border border-gray-200 px-3 py-2">{row.codename}</td>
</tr>
))}
</tbody>
</table>
</div>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("upgrade.officialTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("upgrade.officialBody")}</p>
<CopyableCode
code={`apt update && apt full-upgrade -y`}
language="bash"
/>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("upgrade.officialOutro", { em })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("upgrade.doesTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("upgrade.doesIntro", { link: updateLink })}
</p>
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-4">
{doesItems.map((_, idx) => (
<li key={idx}>{t.rich(`upgrade.doesItems.${idx}`, { strong, em, code })}</li>
))}
</ul>
<Callout variant="info" title={t("upgrade.shortTitle")}>
{t.rich("upgrade.shortBody", { code, link: updateLink })}
</Callout>
<Callout variant="warning" title={t("upgrade.subTitle")}>
{t("upgrade.subBody")}
</Callout>
<Callout variant="tip" title={t("upgrade.safetyTitle")}>
{t.rich("upgrade.safetyBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("time.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("time.intro", { code })}
</p>
<Callout variant="warning" title={t("time.depTitle")}>
{t.rich("time.depBody", { code })}
</Callout>
<CopyableCode
code={`# Manual alternative — pick your IANA zone
timedatectl list-timezones | grep -i europe # e.g. Europe/Madrid
timedatectl set-timezone Europe/Madrid
timedatectl set-ntp true
timedatectl # verify`}
className="my-4"
/>
<Callout variant="tip" title={t("time.revertTitle")}>
{t.rich("time.revertBody", { link: uninstallLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("languages.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("languages.intro", { code, strong })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("languages.writtenTitle")}</h3>
<CopyableCode
code={`# /etc/apt/apt.conf.d/99-disable-translations
Acquire::Languages "none";`}
className="my-4"
/>
<Callout variant="tip" title={t("languages.revertTitle")}>
{t.rich("languages.revertBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("utilities.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("utilities.intro", { strong, code })}
</p>
<Image
src="/basic/menu_utilities.png"
alt={t("utilities.imageAlt")}
width={900}
height={500}
className="rounded shadow-lg my-6"
/>
<Callout variant="tip" title={t("utilities.reuseTitle")}>
{t.rich("utilities.reuseBody", { code, em })}
</Callout>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("utilities.listTitle")}</h3>
{utilityGroups.map((group) => (
<div key={group.group} className="mb-6">
<h4 className="text-base font-semibold text-gray-900 mb-2">{group.group}</h4>
<dl className="divide-y divide-gray-200 border border-gray-200 rounded-md overflow-hidden">
{group.items.map((item) => (
<div key={item.pkg} className="grid grid-cols-1 sm:grid-cols-[150px_1fr] gap-2 px-4 py-3 bg-white">
<dt className="font-mono text-sm text-gray-900">{item.pkg}</dt>
<dd className="text-sm text-gray-700 m-0 leading-relaxed">{item.desc}</dd>
</div>
))}
</dl>
</div>
))}
<h3 className="text-lg font-semibold mt-8 mb-3 text-gray-900">{t("utilities.actionTitle")}</h3>
<div className="grid gap-4 sm:grid-cols-2 my-4">
{SCREENSHOTS.map((s) => (
<figure key={s.pkg} className="m-0">
<Image
src={s.src}
alt={s.alt}
width={450}
height={280}
className="rounded shadow border border-gray-200 w-full h-auto"
/>
<figcaption className="text-xs text-gray-600 mt-1 text-center">
<code>{s.pkg}</code>
</figcaption>
</figure>
))}
</div>
<Callout variant="warning" title={t("utilities.noBulkTitle")}>
{t.rich("utilities.noBulkBody", { strong })}
</Callout>
<CopyableCode
code={`# Remove a utility you no longer want
apt purge htop
apt autoremove --purge`}
className="my-4"
/>
<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,136 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { ArrowRight } 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.postInstall.customizable.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/post-install/customizable",
},
}
}
type Category = { name: string; description: string }
type RelatedItem = { label: string; href: string; tail?: string; tailRich?: string }
const CATEGORY_SLUGS = [
"basic-settings",
"system",
"virtualization",
"network",
"storage",
"security",
"customization",
"monitoring",
"performance",
"optional",
]
export default async function CustomizablePage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.postInstall.customizable" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { postInstall: { customizable: {
categories: Category[]
related: { items: RelatedItem[] }
} } }
}
const categories = messages.docs.postInstall.customizable.categories
const relatedItems = messages.docs.postInstall.customizable.related.items
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const uninstallLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/uninstall" className="text-blue-700 hover:underline">{chunks}</Link>
)
const autoLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/automated" className="text-blue-600 hover:underline">{chunks}</Link>
)
const storageLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/storage" className="text-blue-600 hover:underline">{chunks}</Link>
)
const networkLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/network" className="text-blue-600 hover:underline">{chunks}</Link>
)
const customLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/customization" className="text-blue-600 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={15}
scriptPath="post_install/customizable_post_install.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { link: uninstallLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("compare.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("compare.body", { link: autoLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("categoriesSection.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("categoriesSection.body")}</p>
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
{categories.map((category, idx) => (
<Link
key={CATEGORY_SLUGS[idx]}
href={`/docs/post-install/${CATEGORY_SLUGS[idx]}`}
className="group flex items-start gap-2 rounded-md border border-gray-200 bg-white p-3 transition-colors hover:border-blue-400 hover:bg-blue-50"
>
<ArrowRight
className="h-4 w-4 mt-0.5 text-gray-400 group-hover:text-blue-600 flex-shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<div className="font-medium text-sm text-gray-900 group-hover:text-blue-700">{category.name}</div>
<div className="text-xs text-gray-600 mt-0.5 leading-snug">{category.description}</div>
</div>
</Link>
))}
</div>
<Callout variant="tip" title={t("mixTip.title")}>
{t.rich("mixTip.body", { strong })}
</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, idx) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tailRich ? t.rich(`related.items.${idx}.tailRich`, { storageLink, networkLink, customLink }) : item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,161 @@
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.postInstall.customization.meta" })
return { title: t("title"), description: t("description") }
}
type RelatedItem = { label: string; href: string; tail: string }
export default async function PostInstallCustomizationPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.postInstall.customization" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { postInstall: { customization: {
banner: { versionItems: string[] }
related: { items: RelatedItem[] }
} } }
}
const versionItems = messages.docs.postInstall.customization.banner.versionItems
const relatedItems = messages.docs.postInstall.customization.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 uninstallLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/uninstall" className="text-blue-700 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
/>
<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("bashrc.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("bashrc.intro", { code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("bashrc.writesTitle")}</h3>
<CopyableCode
code={`# BEGIN PMX_CORE_BASHRC
# ProxMenux core customizations
export HISTTIMEFORMAT="%d/%m/%y %T "
export PS1="\\[\\e[31m\\][\\[\\e[m\\]\\[\\e[38;5;172m\\]\\u\\[\\e[m\\]@\\[\\e[38;5;153m\\]\\h\\[\\e[m\\] \\[\\e[38;5;214m\\]\\W\\[\\e[m\\]\\[\\e[31m\\]]\\[\\e[m\\]\\$ "
alias l='ls -CF'
alias la='ls -A'
alias ll='ls -alF'
alias ls='ls --color=auto'
alias grep='grep --color=auto'
alias fgrep='fgrep --color=auto'
alias egrep='egrep --color=auto'
source /etc/profile.d/bash_completion.sh
# END PMX_CORE_BASHRC`}
className="my-4"
/>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("bashrc.writesOutro", { code })}
</p>
<Callout variant="tip" title={t("bashrc.rootTitle")}>
{t("bashrc.rootBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("motd.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("motd.intro", { em, code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("motd.writesTitle")}</h3>
<CopyableCode
code={` This system is optimised by: ProxMenux
<original /etc/motd content follows here>`}
className="my-4"
/>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("motd.writesOutro", { code })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("banner.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("banner.intro", { em })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("banner.versionTitle")}</h3>
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-4">
{versionItems.map((_, idx) => (
<li key={idx}>{t.rich(`banner.versionItems.${idx}`, { strong, code })}</li>
))}
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("banner.versionOutro", { code })}
</p>
<Callout variant="warning" title={t("banner.breakTitle")}>
{t.rich("banner.breakBody", { code, link: uninstallLink })}
</Callout>
<Callout variant="danger" title={t("banner.legalTitle")}>
{t.rich("banner.legalBody", { strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("verify.heading")}</h2>
<p className="mb-3 text-gray-800 leading-relaxed">{t("verify.intro")}</p>
<CopyableCode
code={`# bashrc: the prompt becomes colored, ll / la aliases work
exec bash # reload current shell
ll
# MOTD: log out and SSH back in — the ProxMenux banner shows above the default message
cat /etc/motd
# Subscription banner: log out of the web UI, then log back in — no popup`}
className="my-4"
/>
<Callout variant="tip" title={t("verify.reversibleTitle")}>
{t.rich("verify.reversibleBody", { code, link: uninstallLink })}
</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,134 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { ExternalLink } from "lucide-react"
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.postInstall.monitoring.meta" })
return { title: t("title"), description: t("description") }
}
type RelatedItem = { label: string; href: string; tail: string }
export default async function PostInstallMonitoringPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.postInstall.monitoring" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { postInstall: { monitoring: {
ovh: { decisionsItems: string[] }
related: { items: RelatedItem[] }
} } }
}
const decisionsItems = messages.docs.postInstall.monitoring.ovh.decisionsItems
const relatedItems = messages.docs.postInstall.monitoring.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 ovhAnchor = (chunks: React.ReactNode) => (
<a href="https://www.ovhcloud.com" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
const rtmAnchor = (chunks: React.ReactNode) => (
<a href="https://www.ovhcloud.com/en/bare-metal/monitoring/" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
/>
<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("ovh.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("ovh.intro", { a: ovhAnchor })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("ovh.decisionsTitle")}</h3>
<ol className="list-decimal pl-6 space-y-1 text-gray-800 mb-4">
{decisionsItems.map((_, idx) => (
<li key={idx}>{t.rich(`ovh.decisionsItems.${idx}`, { code, em })}</li>
))}
</ol>
<Callout variant="danger" title={t("ovh.remoteTitle")}>
{t.rich("ovh.remoteBody", { code })}
</Callout>
<Callout variant="warning" title={t("ovh.noOpTitle")}>
{t.rich("ovh.noOpBody", { em })}
</Callout>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("ovh.runsTitle")}</h3>
<CopyableCode
code={`# Detect + conditionally install
public_ip=$(curl -s ipinfo.io/ip)
is_ovh=$(whois -h v4.whois.cymru.com " -t $public_ip" | tail -n 1 | cut -d'|' -f3 | grep -i "ovh")
if [ -n "$is_ovh" ]; then
wget -qO - https://last-public-ovh-infra-yak.snap.mirrors.ovh.net/yak/archives/apply.sh \\
| OVH_PUPPET_MANIFEST=distribyak/catalog/master/puppet/manifests/common/rtmv2.pp bash
fi`}
className="my-4"
/>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("ovh.verifyTitle")}</h3>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("ovh.verifyBody", { a: rtmAnchor })}
</p>
<CopyableCode
code={`systemctl status ovh-rtm # or grep the unit name from your install log
journalctl -u ovh-rtm --since "10 min ago"`}
className="my-4"
/>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("ovh.troubleTitle")}</h3>
<Callout variant="tip" title={t("ovh.spuriousTitle")}>
{t.rich("ovh.spuriousBody", { em, code })}
</Callout>
<Callout variant="tip" title={t("ovh.revertTitle")}>
{t.rich("ovh.revertBody", { 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,221 @@
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.postInstall.network.meta" })
return { title: t("title"), description: t("description") }
}
type AreaRow = { area: string; settings: string }
type RelatedItem = { label: string; href: string; tail: string }
export default async function PostInstallNetworkPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.postInstall.network" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { postInstall: { network: {
sysctl: { rows: AreaRow[] }
names: { whyItems: string[] }
related: { items: RelatedItem[] }
} } }
}
const sysctlRows = messages.docs.postInstall.network.sysctl.rows
const whyItems = messages.docs.postInstall.network.names.whyItems
const relatedItems = messages.docs.postInstall.network.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 uninstallLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/uninstall" className="text-blue-700 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
section={t("header.section")}
/>
<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("ipv4.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("ipv4.intro", { code })}
</p>
<Callout variant="tip" title={t("ipv4.tipTitle")}>
{t("ipv4.tipBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("sysctl.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("sysctl.intro", { code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("sysctl.tunedTitle")}</h3>
<div className="my-4 overflow-x-auto">
<table className="min-w-full border border-gray-200 text-sm">
<thead className="bg-gray-100">
<tr>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("sysctl.headerArea")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("sysctl.headerSettings")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{sysctlRows.map((row, idx) => (
<tr key={row.area}>
<td className="border border-gray-200 px-3 py-2">{row.area}</td>
<td className="border border-gray-200 px-3 py-2">{t.rich(`sysctl.rows.${idx}.settings`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("sysctl.sourceOutro", { code })}
</p>
<Callout variant="warning" title={t("sysctl.rpFilterTitle")}>
{t.rich("sysctl.rpFilterBody", { em, code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("ovs.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("ovs.intro", { code, em })}
</p>
<Callout variant="tip" title={t("ovs.tipTitle")}>
{t.rich("ovs.tipBody", { strong, code })}
</Callout>
<Callout variant="warning" title={t("ovs.revertTitle")}>
{t("ovs.revertBody")}
</Callout>
<CopyableCode
code={`# After moving bridges off OVS:
apt purge openvswitch-switch openvswitch-common
apt autoremove --purge`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("bbr.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("bbr.intro")}</p>
<CopyableCode
code={`# /etc/sysctl.d/99-kernel-bbr.conf
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
# /etc/sysctl.d/99-tcp-fastopen.conf
net.ipv4.tcp_fastopen = 3 # enable TFO for both client and server sockets`}
className="my-4"
/>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("bbr.verifyTitle")}</h3>
<CopyableCode
code={`# BBR is active
sysctl net.ipv4.tcp_congestion_control
# Expected: net.ipv4.tcp_congestion_control = bbr
# Qdisc is fair queuing (required for BBR to work well)
tc qdisc show | head
# TFO enabled (value 3 = client + server)
sysctl net.ipv4.tcp_fastopen`}
className="my-4"
/>
<Callout variant="tip" title={t("bbr.impactTitle")}>
{t("bbr.impactBody")}
</Callout>
<Callout variant="warning" title={t("bbr.revertTitle")}>
{t("bbr.revertBody")}
</Callout>
<CopyableCode
code={`rm /etc/sysctl.d/99-kernel-bbr.conf /etc/sysctl.d/99-tcp-fastopen.conf
sysctl --system`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("names.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("names.intro", { code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("names.whyTitle")}</h3>
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-4">
{whyItems.map((_, idx) => (
<li key={idx}>{t.rich(`names.whyItems.${idx}`, { code })}</li>
))}
</ul>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("names.writtenTitle")}</h3>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("names.writtenIntro", { code })}
</p>
<CopyableCode
code={`[Match]
MACAddress=aa:bb:cc:dd:ee:ff
[Link]
Name=enp3s0`}
className="my-4"
/>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("names.writtenOutro", { code })}
</p>
<Callout variant="tip" title={t("names.pveTitle")}>
{t.rich("names.pveBody", { code })}
</Callout>
<Callout variant="warning" title={t("names.reviewTitle")}>
{t.rich("names.reviewBody", { code, em })}
</Callout>
<Callout variant="tip" title={t("names.revertTitle")}>
{t.rich("names.revertBody", { code, link: uninstallLink })}
</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,337 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Plus } from "lucide-react"
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.postInstall.optional.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/post-install/optional",
images: [
{
url: "https://macrimi.github.io/ProxMenux/optional-settings-image.png",
width: 1200,
height: 630,
alt: t("ogImageAlt"),
},
],
},
twitter: {
card: "summary_large_image",
title: t("ogTitle"),
description: t("ogDescription"),
images: ["https://macrimi.github.io/ProxMenux/optional-settings-image.png"],
},
}
}
type Logo = { name: string; alt: string; src: string }
function StepNumber({ number }: { number: number }) {
return (
<div className="inline-flex items-center justify-center w-8 h-8 mr-3 text-white bg-blue-500 rounded-full">
<span className="text-sm font-bold">{number}</span>
</div>
)
}
export default async function OptionalSettingsPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.postInstall.optional" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { postInstall: { optional: {
ceph: { doesItems: string[] }
amd: { doesItems: string[] }
ha: { doesItems: string[] }
testing: { doesItems: string[] }
fastfetch: { doesItems: string[]; customItems: string[]; logos: Logo[] }
figurine: { doesItems: string[] }
} } }
}
const cephItems = messages.docs.postInstall.optional.ceph.doesItems
const amdItems = messages.docs.postInstall.optional.amd.doesItems
const haItems = messages.docs.postInstall.optional.ha.doesItems
const testingItems = messages.docs.postInstall.optional.testing.doesItems
const fastfetchItems = messages.docs.postInstall.optional.fastfetch.doesItems
const fastfetchCustomItems = messages.docs.postInstall.optional.fastfetch.customItems
const fastfetchLogos = messages.docs.postInstall.optional.fastfetch.logos
const figurineItems = messages.docs.postInstall.optional.figurine.doesItems
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 className="container mx-auto px-4 py-8">
<div className="flex items-center mb-6">
<Plus className="h-8 w-8 mr-2 text-blue-500" />
<h1 className="text-3xl font-bold">{t("title")}</h1>
</div>
<p className="mb-4">
{t.rich("intro", { strong })}
</p>
<h2 className="text-2xl font-semibold mt-8 mb-4">{t("available")}</h2>
<h3 className="text-xl font-semibold mt-16 mb-4 flex items-center">
<StepNumber number={1} />
{t("ceph.title")}
</h3>
<p className="mb-4">{t("ceph.intro")}</p>
<p className="mb-4">{t("ceph.doesIntro")}</p>
<ul className="list-disc pl-5 mb-4">
{cephItems.map((_, idx) => (
<li key={idx}>{t(`ceph.doesItems.${idx}`)}</li>
))}
</ul>
<p className="mb-4">{t("ceph.howUse")}</p>
<p className="text-lg mb-2">{t("ceph.automates")}</p>
<CopyableCode
code={`
# Add Ceph repository
echo "deb https://download.proxmox.com/debian/ceph-squid $(lsb_release -cs) no-subscription" > /etc/apt/sources.list.d/ceph-squid.list
# Update package lists
apt-get update
# Install Ceph
pveceph install
# Verify installation
pveceph status
`}
/>
<h3 className="text-xl font-semibold mt-16 mb-4 flex items-center">
<StepNumber number={2} />
{t("amd.title")}
</h3>
<p className="mb-4">{t("amd.intro")}</p>
<p className="mb-4">{t("amd.doesIntro")}</p>
<ul className="list-disc pl-5 mb-4">
{amdItems.map((_, idx) => (
<li key={idx}>{t(`amd.doesItems.${idx}`)}</li>
))}
</ul>
<p className="mb-4">{t("amd.howUse")}</p>
<p className="text-lg mb-2">{t("amd.automates")}</p>
<CopyableCode
code={`
# Set kernel parameter
sed -i 's/GRUB_CMDLINE_LINUX_DEFAULT="/GRUB_CMDLINE_LINUX_DEFAULT="idle=nomwait /g' /etc/default/grub
update-grub
# Configure KVM
echo "options kvm ignore_msrs=Y" >> /etc/modprobe.d/kvm.conf
echo "options kvm report_ignored_msrs=N" >> /etc/modprobe.d/kvm.conf
# Install latest Proxmox VE kernel
apt-get install pve-kernel-$(uname -r | cut -d'-' -f1-2)
`}
/>
<h3 className="text-xl font-semibold mt-16 mb-4 flex items-center">
<StepNumber number={3} />
{t("ha.title")}
</h3>
<p className="mb-4">{t("ha.intro")}</p>
<p className="mb-4">{t("ha.doesIntro")}</p>
<ul className="list-disc pl-5 mb-4">
{haItems.map((_, idx) => (
<li key={idx}>{t(`ha.doesItems.${idx}`)}</li>
))}
</ul>
<p className="mb-4">{t("ha.howUse")}</p>
<p className="text-lg mb-2">{t("ha.automates")}</p>
<CopyableCode
code={`
systemctl enable --now pve-ha-lrm pve-ha-crm corosync
`}
/>
<h3 className="text-xl font-semibold mt-16 mb-4 flex items-center">
<StepNumber number={4} />
{t("testing.title")}
</h3>
<p className="mb-4">{t("testing.intro")}</p>
<p className="mb-4">{t("testing.doesIntro")}</p>
<ul className="list-disc pl-5 mb-4">
{testingItems.map((_, idx) => (
<li key={idx}>{t(`testing.doesItems.${idx}`)}</li>
))}
</ul>
<p className="mb-4">{t("testing.howUse")}</p>
<p className="text-lg mb-2">{t("testing.manualIntro")}</p>
<CopyableCode
code={`
# Add Proxmox testing repository
echo "deb http://download.proxmox.com/debian/pve $(lsb_release -cs) pvetest" | sudo tee /etc/apt/sources.list.d/pve-testing-repo.list
# Update package lists
sudo apt update
`}
/>
<p className="mt-4 text-sm text-gray-600">
<strong>{t("testing.noteLabel")}</strong> {t("testing.noteBody")}
</p>
<p className="mt-4 text-yellow-600">
<strong>{t("testing.warnLabel")}</strong> {t("testing.warnBody")}
</p>
<h3 className="text-xl font-semibold mt-16 mb-4 flex items-center">
<StepNumber number={5} />
{t("fastfetch.title")}
</h3>
<p className="mb-4">{t("fastfetch.intro")}</p>
<p className="mb-4">
<strong>{t("fastfetch.doesLabel")}</strong>
</p>
<ul className="list-disc pl-5 mb-4">
{fastfetchItems.map((_, idx) => (
<li key={idx}>{t.rich(`fastfetch.doesItems.${idx}`, { strong, em })}</li>
))}
</ul>
<div className="bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4 mb-4">
<p className="font-semibold">{t("fastfetch.importantLabel")}</p>
<p>
{t.rich("fastfetch.importantBody", { strong, code })}
</p>
</div>
<div className="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 mb-4">
<p className="font-semibold">{t("fastfetch.customLabel")}</p>
<p>
{t.rich("fastfetch.customBody1", { code })}
</p>
<p>
{t.rich("fastfetch.customBody2", { code })}
</p>
<p>{t("fastfetch.customBody3")}</p>
<ul className="list-disc pl-5 mt-2">
{fastfetchCustomItems.map((_, idx) => (
<li key={idx}>{t.rich(`fastfetch.customItems.${idx}`, { code })}</li>
))}
</ul>
</div>
<p className="mb-4">
<strong>{t("fastfetch.examplesLabel")}</strong>
</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{fastfetchLogos.map((logo) => (
<div key={logo.name}>
<p className="font-semibold text-center">{logo.name}</p>
<img
src={logo.src}
alt={logo.alt}
className="rounded shadow-lg"
/>
</div>
))}
</div>
<p className="text-lg mb-2">{t("fastfetch.automates")}</p>
<CopyableCode
code={`
# Download and install the latest version of Fastfetch
FASTFETCH_URL=$(curl -s https://api.github.com/repos/fastfetch-cli/fastfetch/releases/latest | grep "browser_download_url.*fastfetch-linux-amd64.deb" | cut -d '"' -f 4)
wget -q -O /tmp/fastfetch.deb "$FASTFETCH_URL"
dpkg -i /tmp/fastfetch.deb
apt-get install -f -y
# Configure Fastfetch (logo selection remains interactive)
# The configuration is done through a series of jq commands
# Set Fastfetch to run at login
echo "clear && fastfetch" >> ~/.bashrc
`}
/>
<h3 className="text-xl font-semibold mt-16 mb-4 flex items-center">
<StepNumber number={6} />
{t("figurine.title")}
</h3>
<p className="mb-4">{t("figurine.intro")}</p>
<p className="mb-4">
<strong>{t("figurine.doesLabel")}</strong>
</p>
<ul className="list-disc pl-5 mb-4">
{figurineItems.map((_, idx) => (
<li key={idx}>{t(`figurine.doesItems.${idx}`)}</li>
))}
</ul>
<div className="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 mb-4">
<p className="font-semibold">{t("figurine.practicalLabel")}</p>
<p>{t("figurine.practicalBody")}</p>
</div>
<p className="mb-4">
<strong>{t("figurine.exampleLabel")}</strong>
</p>
<div className="mb-6 flex justify-center">
<img
src="https://macrimi.github.io/ProxMenux/figurine/figurine.png"
alt={t("figurine.imageAlt")}
className="rounded-md shadow-lg border border-gray-200"
style={{ maxWidth: "100%" }}
/>
</div>
<p className="text-lg mb-2">{t("figurine.automates")}</p>
<CopyableCode
code={`
# Check for previous installation and remove if found
if command -v figurine &> /dev/null; then
rm -f "/usr/local/bin/figurine"
fi
# Download and install Figurine
version="2.0.0"
file="figurine_linux_amd64_v\${version}.tar.gz"
url="https://github.com/arsham/figurine/releases/download/v\${version}/\${file}"
wget -qO "/tmp/\${file}" "\${url}"
tar -xf "/tmp/\${file}" -C "/tmp"
mv "/tmp/deploy/figurine" "/usr/local/bin/figurine"
chmod +x "/usr/local/bin/figurine"
# Create welcome message script
cat << 'EOF' > "/etc/profile.d/figurine.sh"
/usr/local/bin/figurine -f "3d.flf" $(hostname)
EOF
chmod +x "/etc/profile.d/figurine.sh"
`}
/>
<p className="mt-4">{t("figurine.outro")}</p>
<section className="mt-12 p-4 bg-blue-100 rounded-md">
<h2 className="text-xl font-semibold mb-2">{t("autoApplication.title")}</h2>
<p>{t("autoApplication.body")}</p>
</section>
</div>
)
}

View File

@@ -0,0 +1,226 @@
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 { Zap, SlidersHorizontal, Undo2, ExternalLink, RefreshCw } from "lucide-react"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.postInstall.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"proxmox post install",
"proxmox post-install script",
"proxmox optimizations",
"proxmox tweaks",
"proxmox automated setup",
"proxmox customization",
"proxmox no subscription repository",
"proxmox tuning",
"proxmenux post install",
],
alternates: { canonical: "https://proxmenux.com/docs/post-install" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/post-install",
},
twitter: {
card: "summary_large_image",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type Route = { title: string; description: string; bullets: string[] }
type RelatedItem = { label: string; href: string; tail: string }
const ROUTE_CONFIG = [
{
key: "automated",
href: "/docs/post-install/automated",
Icon: Zap,
accent: "border-emerald-300 bg-emerald-50",
iconBg: "bg-emerald-100 text-emerald-700",
},
{
key: "customizable",
href: "/docs/post-install/customizable",
Icon: SlidersHorizontal,
accent: "border-amber-300 bg-amber-50",
iconBg: "bg-amber-100 text-amber-700",
},
{
key: "updates",
href: "/docs/post-install/updates",
Icon: RefreshCw,
accent: "border-violet-300 bg-violet-50",
iconBg: "bg-violet-100 text-violet-700",
},
{
key: "uninstall",
href: "/docs/post-install/uninstall",
Icon: Undo2,
accent: "border-blue-300 bg-blue-50",
iconBg: "bg-blue-100 text-blue-700",
},
]
export default async function PostInstallPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.postInstall" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { postInstall: {
routes: Route[]
related: { items: RelatedItem[] }
} }
}
const routes = messages.docs.postInstall.routes
const relatedItems = messages.docs.postInstall.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 autoLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/automated" className="text-blue-700 hover:underline">{chunks}</Link>
)
const customLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/customizable" className="text-blue-700 hover:underline">{chunks}</Link>
)
const updatesLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/updates" className="text-blue-700 hover:underline">{chunks}</Link>
)
const uninstallLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/uninstall" className="text-blue-700 hover:underline">{chunks}</Link>
)
const xshokAnchor = (chunks: React.ReactNode) => (
<a
href="https://github.com/extremeshok/xshok-proxmox"
target="_blank"
rel="noopener noreferrer"
className="text-blue-700 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
const communityAnchor = (chunks: React.ReactNode) => (
<a
href="https://github.com/community-scripts/ProxmoxVE"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
const externalRepoLink = (chunks: React.ReactNode) => (
<Link href="/docs/external-repositories" className="text-blue-600 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={3}
scriptPath="menus/menu_post_install.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t("intro.body")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("openingMenu.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("openingMenu.body", { strong })}
</p>
<Image
src="/post-install/post-install-menu.png"
alt={t("openingMenu.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("threeWays.heading")}</h2>
<p className="mb-6 text-gray-800 leading-relaxed">{t("threeWays.body")}</p>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-8 not-prose">
{ROUTE_CONFIG.map(({ key, href, Icon, accent, iconBg }, idx) => {
const route = routes[idx]
return (
<Link
key={key}
href={href}
className={`rounded-lg border-2 p-5 ${accent} 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 ${iconBg}`}>
<Icon className="h-5 w-5" aria-hidden="true" />
</span>
<h3 className="text-lg font-semibold text-gray-900 m-0">{route.title}</h3>
</div>
<p className="text-sm text-gray-800 mb-3">{route.description}</p>
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
{route.bullets.map((b, i) => (
<li key={i}>{b}</li>
))}
</ul>
</Link>
)
})}
</div>
<Callout variant="tip" title={t("whichTitle")}>
{t.rich("whichBody", { strong, autoLink, customLink, updatesLink, uninstallLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("mixing.heading")}</h2>
<Callout variant="warning" title={t("mixing.stackTitle")}>
{t.rich("mixing.stackBody", { strong })}
</Callout>
<Callout variant="info" title={t("mixing.xshokTitle")}>
{t.rich("mixing.xshokBody", { a: xshokAnchor })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("community.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("community.body", { em, code, a: communityAnchor, link: externalRepoLink })}
</p>
<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,148 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { ExternalLink } from "lucide-react"
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.postInstall.performance.meta" })
return { title: t("title"), description: t("description") }
}
type RelatedItem = { label: string; href: string; tail?: string; tailRich?: string }
export default async function PostInstallPerformancePage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.postInstall.performance" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { postInstall: { performance: {
pigz: { doesItems: string[] }
related: { items: RelatedItem[] }
} } }
}
const doesItems = messages.docs.postInstall.performance.pigz.doesItems
const relatedItems = messages.docs.postInstall.performance.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 pigzAnchor = (chunks: React.ReactNode) => (
<a href="https://zlib.net/pigz/" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("pigz.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("pigz.intro", { code, strong, a: pigzAnchor })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("pigz.doesTitle")}</h3>
<p className="mb-3 text-gray-800 leading-relaxed">{t("pigz.doesIntro")}</p>
<ol className="list-decimal pl-6 space-y-2 text-gray-800 mb-4">
{doesItems.map((_, idx) => (
<li key={idx}>{t.rich(`pigz.doesItems.${idx}`, { code, em })}</li>
))}
</ol>
<CopyableCode
code={`# What ProxMenux runs under the hood
sed -i "s/#pigz:.*/pigz: 1/" /etc/vzdump.conf
apt-get -y install pigz
cat > /bin/pigzwrapper <<'EOF'
#!/bin/sh
PATH=/bin:$PATH
GZIP="-1"
exec /usr/bin/pigz "$@"
EOF
chmod +x /bin/pigzwrapper
# Only replaces gzip if not already replaced (idempotent)
[ ! -f /bin/gzip.original ] && mv /bin/gzip /bin/gzip.original \\
&& cp /bin/pigzwrapper /bin/gzip && chmod +x /bin/gzip`}
className="my-4"
/>
<Callout variant="warning" title={t("pigz.replacesTitle")}>
{t.rich("pigz.replacesBody", { code })}
</Callout>
<Callout variant="danger" title={t("pigz.revertTitle")}>
{t.rich("pigz.revertBody", { strong })}
</Callout>
<CopyableCode
code={`# Manual rollback of pigz
mv /bin/gzip.original /bin/gzip # restore original binary
rm /bin/pigzwrapper
sed -i 's/^pigz: 1/#pigz: 1/' /etc/vzdump.conf
# Optional: remove the package
apt purge pigz`}
className="my-4"
/>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("pigz.verifyTitle")}</h3>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("pigz.verifyBody", { code })}
</p>
<CopyableCode
code={`# Confirm gzip now points to pigz
gzip --version
# Expected first line: "pigz 2.x … by Mark Adler"
# Compare throughput (create a 1GB file of random data and compress it)
dd if=/dev/urandom of=/tmp/test.bin bs=1M count=1024 status=none
time gzip -k /tmp/test.bin # uses pigz — parallel
rm /tmp/test.bin.gz
time /bin/gzip.original -k /tmp/test.bin # original single-threaded gzip
rm /tmp/test.bin /tmp/test.bin.gz`}
className="my-4"
/>
<Callout variant="tip" title={t("pigz.whenTitle")}>
{t.rich("pigz.whenBody", { strong })}
</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, idx) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tailRich ? t.rich(`related.items.${idx}.tailRich`, { code }) : item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,110 @@
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.postInstall.security.meta" })
return { title: t("title"), description: t("description") }
}
type RelatedItem = { label: string; href: string; tail: string }
export default async function PostInstallSecurityPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.postInstall.security" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { postInstall: { security: {
rpcbind: { whyItems: string[] }
related: { items: RelatedItem[] }
} } }
}
const whyItems = messages.docs.postInstall.security.rpcbind.whyItems
const relatedItems = messages.docs.postInstall.security.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 uninstallLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/uninstall" className="text-blue-700 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
section={t("header.section")}
/>
<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("rpcbind.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("rpcbind.intro", { code, strong })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("rpcbind.whyTitle")}</h3>
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-4">
{whyItems.map((_, idx) => (
<li key={idx}>{t.rich(`rpcbind.whyItems.${idx}`, { code })}</li>
))}
</ul>
<Callout variant="warning" title={t("rpcbind.nfsTitle")}>
{t.rich("rpcbind.nfsBody", { strong, em, code })}
</Callout>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("rpcbind.runsTitle")}</h3>
<CopyableCode
code={`# Stop and disable the rpcbind service
systemctl stop rpcbind
systemctl disable rpcbind`}
className="my-4"
/>
<p className="mb-4 text-gray-800 leading-relaxed">{t("rpcbind.runsOutro")}</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("rpcbind.verifyTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("rpcbind.verifyBody", { code })}
</p>
<CopyableCode
code={`systemctl is-active rpcbind # should report: inactive
systemctl is-enabled rpcbind # should report: disabled
ss -tulpn | grep ':111 ' # should return nothing`}
className="my-4"
/>
<Callout variant="tip" title={t("rpcbind.reversibleTitle")}>
{t.rich("rpcbind.reversibleBody", { em, link: uninstallLink })}
</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,315 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { ExternalLink } from "lucide-react"
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.postInstall.storage.meta" })
return { title: t("title"), description: t("description") }
}
type ArcRow = { ram: string; min: string; max: string }
type SnapRow = { label: string; runs: string; kept: string }
type RelatedItem = { label: string; href: string; tail: string }
export default async function PostInstallStoragePage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.postInstall.storage" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { postInstall: { storage: {
arc: { rows: ArcRow[] }
autoSnap: { rows: SnapRow[] }
autotrim: {
practicalItems: string[]
whenSkipItems: string[]
}
related: { items: RelatedItem[] }
} } }
}
const arcRows = messages.docs.postInstall.storage.arc.rows
const snapRows = messages.docs.postInstall.storage.autoSnap.rows
const practicalItems = messages.docs.postInstall.storage.autotrim.practicalItems
const whenSkipItems = messages.docs.postInstall.storage.autotrim.whenSkipItems
const relatedItems = messages.docs.postInstall.storage.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 zfsAutoAnchor = (chunks: React.ReactNode) => (
<a href="https://github.com/zfsonlinux/zfs-auto-snapshot" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
section={t("header.section")}
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { strong })}
</Callout>
<Callout variant="warning" title={t("notTrackedTitle")}>
{t.rich("notTrackedBody", { strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("arc.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("arc.intro", { strong })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("arc.sizingTitle")}</h3>
<div className="my-4 overflow-x-auto">
<table className="min-w-full border border-gray-200 text-sm">
<thead className="bg-gray-100">
<tr>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("arc.headerRam")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("arc.headerMin")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("arc.headerMax")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{arcRows.map((row) => (
<tr key={row.ram}>
<td className="border border-gray-200 px-3 py-2">{row.ram}</td>
<td className="border border-gray-200 px-3 py-2">{row.min}</td>
<td className="border border-gray-200 px-3 py-2">{row.max}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("arc.after", { code })}
</p>
<Callout variant="warning" title={t("arc.rebootTitle")}>
{t.rich("arc.rebootBody", { code, strong })}
</Callout>
<Callout variant="tip" title={t("arc.safeTitle")}>
{t.rich("arc.safeBody", { code })}
</Callout>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("arc.verifyTitle")}</h3>
<CopyableCode
code={`# Check the config file is in place
cat /etc/modprobe.d/99-zfsarc.conf
# After reboot, check actual ARC limits (in bytes)
cat /sys/module/zfs/parameters/zfs_arc_min
cat /sys/module/zfs/parameters/zfs_arc_max
# Manual rollback
rm /etc/modprobe.d/99-zfsarc.conf
update-initramfs -u -k all
# (reboot for ZFS to load with defaults again)`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("autoSnap.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("autoSnap.intro", { a: zfsAutoAnchor })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("autoSnap.cadenceTitle")}</h3>
<div className="my-4 overflow-x-auto">
<table className="min-w-full border border-gray-200 text-sm">
<thead className="bg-gray-100">
<tr>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("autoSnap.headerLabel")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("autoSnap.headerRuns")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("autoSnap.headerKept")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{snapRows.map((row) => (
<tr key={row.label}>
<td className="border border-gray-200 px-3 py-2">{row.label}</td>
<td className="border border-gray-200 px-3 py-2">{row.runs}</td>
<td className="border border-gray-200 px-3 py-2">{row.kept}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="tip" title={t("autoSnap.conservativeTitle")}>
{t.rich("autoSnap.conservativeBody", { code })}
</Callout>
<Callout variant="warning" title={t("autoSnap.onlyZfsTitle")}>
{t("autoSnap.onlyZfsBody")}
</Callout>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("autoSnap.verifyTitle")}</h3>
<CopyableCode
code={`# List existing auto-snapshots across all ZFS datasets
zfs list -t snapshot | grep zfs-auto-snap
# Check the schedules
grep . /etc/cron.d/zfs-auto-snapshot /etc/cron.hourly/zfs-auto-snapshot \\
/etc/cron.daily/zfs-auto-snapshot /etc/cron.weekly/zfs-auto-snapshot \\
/etc/cron.monthly/zfs-auto-snapshot
# Manual rollback (removes the package + destroys no snapshots)
apt purge zfs-auto-snapshot
# Existing snapshots remain on your pools unless you destroy them explicitly`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("autotrim.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("autotrim.intro", { code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("autotrim.trimTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("autotrim.trimBody1", { strong })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">
{t("autotrim.trimBody2")}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("autotrim.trimBody3", { code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("autotrim.practicalTitle")}</h3>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{practicalItems.map((_, idx) => (
<li key={idx}>{t.rich(`autotrim.practicalItems.${idx}`, { strong, code })}</li>
))}
</ul>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("autotrim.whenTitle")}</h3>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
<li>
{t.rich("autotrim.whenIntro1", { strong })}
</li>
<li>
{t.rich("autotrim.whenIntro2", { strong })}
<ul className="list-disc pl-6 mt-1">
{whenSkipItems.map((_, idx) => (
<li key={idx}>{t.rich(`autotrim.whenSkipItems.${idx}`, { code })}</li>
))}
</ul>
</li>
<li>
{t.rich("autotrim.whenIntro3", { strong })}
</li>
</ul>
<Callout variant="info" title={t("autotrim.recordedTitle")}>
{t.rich("autotrim.recordedBody", { code })}
</Callout>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("autotrim.manualTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("autotrim.manualIntro")}</p>
<CopyableCode
code={`# 1. List your ZFS pools
zpool list -H -o name
# 2. Check the current autotrim setting on a pool
zpool get autotrim <pool>
# 3. Verify the pool is backed by SSD/NVMe with TRIM support
# For each vdev (use the device path you see in 'zpool status -P <pool>'):
DEV=sda # replace with the actual short name (sda, nvme0n1, ...)
cat /sys/block/\${DEV}/queue/rotational # must be 0 (SSD/NVMe, not HDD)
cat /sys/block/\${DEV}/queue/discard_granularity # must be > 0 (TRIM supported)
# 4. Turn it on
zpool set autotrim=on <pool>
# 5. Confirm
zpool get autotrim <pool>`}
className="my-4"
/>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("autotrim.verifyTitle")}</h3>
<CopyableCode
code={`# Verify autotrim is active on every pool ProxMenux touched
cat /usr/local/share/proxmenux/zfs_autotrim_pools
while read -r p; do
zpool get autotrim "$p"
done < /usr/local/share/proxmenux/zfs_autotrim_pools
# Manual rollback — disable autotrim on a specific pool
zpool set autotrim=off <pool>
# Or revert all pools ProxMenux changed (manual equivalent of the Uninstall option)
while read -r p; do
zpool set autotrim=off "$p"
done < /usr/local/share/proxmenux/zfs_autotrim_pools`}
className="my-4"
/>
<Callout variant="tip" title={t("autotrim.oneShotTitle")}>
{t.rich("autotrim.oneShotBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("vzdump.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("vzdump.intro")}</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("vzdump.changedTitle")}</h3>
<CopyableCode
code={`bwlimit: 0 # No bandwidth limit (was capped by default)
ionice: 5 # Lower I/O priority (5 = best-effort class, lowest priority in that class)`}
className="my-4"
/>
<Callout variant="warning" title={t("vzdump.noBackupTitle")}>
{t.rich("vzdump.noBackupBody", { strong, code, em })}
</Callout>
<Callout variant="tip" title={t("vzdump.skipTitle")}>
{t.rich("vzdump.skipBody", { code })}
</Callout>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("vzdump.verifyTitle")}</h3>
<CopyableCode
code={`# Check current vzdump config
grep -E "^(bwlimit|ionice):" /etc/vzdump.conf
# Manual rollback (comment out the two lines — restores Proxmox defaults)
sed -i 's/^bwlimit: 0/#bwlimit: KBPS/' /etc/vzdump.conf
sed -i 's/^ionice: 5/#ionice: PRI/' /etc/vzdump.conf`}
className="my-4"
/>
<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,238 @@
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.postInstall.system.meta" })
return { title: t("title"), description: t("description") }
}
type LimitRow = { file: string; sets: string }
type RelatedItem = { label: string; href: string; tail: string }
export default async function PostInstallSystemPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.postInstall.system" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { postInstall: { system: {
journald: { keyItems: string[] }
limits: { rows: LimitRow[] }
kexec: { installsItems: string[] }
related: { items: RelatedItem[] }
} } }
}
const keyItems = messages.docs.postInstall.system.journald.keyItems
const limitRows = messages.docs.postInstall.system.limits.rows
const installsItems = messages.docs.postInstall.system.kexec.installsItems
const relatedItems = messages.docs.postInstall.system.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 kexecLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/system" className="text-blue-700 hover:underline">{chunks}</Link>
)
const uninstallLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/uninstall" className="text-blue-700 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
section={t("header.section")}
/>
<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("journald.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("journald.intro", { code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("journald.keyTitle")}</h3>
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-4">
{keyItems.map((_, idx) => (
<li key={idx}>{t.rich(`journald.keyItems.${idx}`, { code })}</li>
))}
</ul>
<Callout variant="tip" title={t("journald.tipTitle")}>
{t.rich("journald.tipBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("logrotate.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("logrotate.intro", { code })}
</p>
<CopyableCode
code={`# /etc/logrotate.conf — ProxMenux-optimized
daily
su root adm
rotate 7
create
compress
size 10M
delaycompress
copytruncate
include /etc/logrotate.d`}
className="my-4"
/>
<Callout variant="tip" title={t("logrotate.tipTitle")}>
{t.rich("logrotate.tipBody", { code, em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("limits.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("limits.intro")}</p>
<div className="my-4 overflow-x-auto">
<table className="min-w-full border border-gray-200 text-sm">
<thead className="bg-gray-100">
<tr>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("limits.headerFile")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("limits.headerSets")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{limitRows.map((row, idx) => (
<tr key={row.file}>
<td className="border border-gray-200 px-3 py-2"><code>{row.file}</code></td>
<td className="border border-gray-200 px-3 py-2">{t.rich(`limits.rows.${idx}.sets`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="tip" title={t("limits.tipTitle")}>
{t.rich("limits.tipBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("memory.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("memory.intro", { code })}
</p>
<CopyableCode
code={`# /etc/sysctl.d/99-memory.conf
vm.swappiness = 10 # Avoid swapping unless truly necessary
vm.dirty_ratio = 15 # Start writeback sooner (default 20)
vm.dirty_background_ratio = 5 # Start async writeback earlier (default 10)
vm.overcommit_memory = 1 # Allow overcommit (needed by many applications)
vm.max_map_count = 262144 # Enough for modern apps (ES, Docker, some games)
vm.compaction_proactiveness = 20 # Only on kernels that support it`}
className="my-4"
/>
<Callout variant="warning" title={t("memory.warnTitle")}>
{t.rich("memory.warnBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("kexec.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("kexec.intro", { code, em })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("kexec.installsTitle")}</h3>
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-4">
{installsItems.map((_, idx) => (
<li key={idx}>{t.rich(`kexec.installsItems.${idx}`, { code })}</li>
))}
</ul>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("kexec.usageIntro", { code })}
</p>
<CopyableCode
code={`reboot-quick # kexec into the already-loaded kernel
# Equivalent:
systemctl kexec`}
className="my-4"
/>
<Callout variant="warning" title={t("kexec.warnTitle")}>
{t.rich("kexec.warnBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("panic.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("panic.intro", { strong })}
</p>
<CopyableCode
code={`# /etc/sysctl.d/99-kernelpanic.conf
kernel.core_pattern = /var/crash/core.%t.%p # where to drop core dumps
kernel.panic = 10 # reboot 10s after a panic
kernel.panic_on_oops = 1 # oops → treated as panic
kernel.hardlockup_panic = 1 # hard lockup → panic → reboot`}
className="my-4"
/>
<Callout variant="tip" title={t("panic.tipTitle")}>
{t.rich("panic.tipBody", { em, link: kexecLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("verify.heading")}</h2>
<p className="mb-3 text-gray-800 leading-relaxed">{t("verify.intro")}</p>
<CopyableCode
code={`# journald: actual size in use and limit
journalctl --disk-usage
# logrotate: check config is active (no errors)
logrotate -d /etc/logrotate.conf 2>&1 | head -20
# System limits: check a few effective values
sysctl fs.inotify.max_user_watches fs.file-max vm.swappiness vm.dirty_ratio
ulimit -n # inside a new root shell
# kexec: service enabled
systemctl is-enabled kexec-pve
# kernel panic config
sysctl kernel.panic kernel.panic_on_oops kernel.hardlockup_panic`}
className="my-4"
/>
<Callout variant="tip" title={t("verify.tipTitle")}>
{t.rich("verify.tipBody", { code, link: uninstallLink })}
</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,161 @@
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 { Steps } from "@/components/ui/steps"
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.postInstall.uninstall.meta" })
return { title: t("title"), description: t("description") }
}
type Step = {
title: string
body1: string
body2?: string
items?: string[]
}
type ReversibleItem = { tool: string; restores: string }
type ReversibleGroup = { title: string; items: ReversibleItem[] }
type RelatedItem = { label: string; href: string; tail: string }
export default async function UninstallOptimizationsPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.postInstall.uninstall" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { postInstall: { uninstall: {
howWorks: { steps: Step[] }
reversible: { groups: ReversibleGroup[] }
related: { items: RelatedItem[] }
} } }
}
const steps = messages.docs.postInstall.uninstall.howWorks.steps
const groups = messages.docs.postInstall.uninstall.reversible.groups
const relatedItems = messages.docs.postInstall.uninstall.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 postInstallLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install" className="text-blue-700 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
/>
<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("openMenu.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("openMenu.body", { strong, em })}
</p>
<Image
src="/post-install/post-install-uninstall.png"
alt={t("openMenu.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("howWorks.heading")}</h2>
<Steps>
{steps.map((step, idx) => (
<Steps.Step key={idx} title={step.title}>
<p className="mb-3 text-gray-800">
{t.rich(`howWorks.steps.${idx}.body1`, { code, em, strong })}
</p>
{step.items && (
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-3">
{step.items.map((_, iIdx) => (
<li key={iIdx}>{t.rich(`howWorks.steps.${idx}.items.${iIdx}`, { strong, code })}</li>
))}
</ul>
)}
{step.body2 && (
<p className="text-gray-800">
{t.rich(`howWorks.steps.${idx}.body2`, { code })}
</p>
)}
</Steps.Step>
))}
</Steps>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("reversible.heading")}</h2>
<p className="mb-6 text-gray-800 leading-relaxed">{t("reversible.intro")}</p>
{groups.map((group) => (
<div key={group.title} className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-2">{group.title}</h3>
<dl className="divide-y divide-gray-200 border border-gray-200 rounded-md overflow-hidden">
{group.items.map((item) => (
<div key={item.tool} className="grid grid-cols-1 sm:grid-cols-3 gap-2 px-4 py-3 bg-white">
<dt className="font-medium text-gray-900 text-sm">{item.tool}</dt>
<dd className="sm:col-span-2 text-sm text-gray-700 leading-relaxed m-0">{item.restores}</dd>
</div>
))}
</dl>
</div>
))}
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("edge.heading")}</h2>
<Callout variant="warning" title={t("edge.packageTitle")}>
{t.rich("edge.packageBody", { strong, code })}
</Callout>
<Callout variant="warning" title={t("edge.rebootTitle")}>
{t.rich("edge.rebootBody", { code, em })}
</Callout>
<Callout variant="tip" title={t("edge.perItemTitle")}>
{t.rich("edge.perItemBody", { em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("inspect.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("inspect.intro")}</p>
<CopyableCode code={`cat /usr/local/share/proxmenux/installed_tools.json | jq`} className="my-4" />
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("inspect.outro", { code })}
</p>
<Callout variant="info" title={t("inspect.reinstallTitle")}>
{t.rich("inspect.reinstallBody", { link: postInstallLink })}
</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,190 @@
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 { Steps } from "@/components/ui/steps"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.postInstall.updates.meta" })
return {
title: t("title"),
description: t("description"),
alternates: { canonical: "https://proxmenux.com/docs/post-install/updates" },
}
}
type Step = { title: string; body: string }
type DiffRow = { pathLabel: string; pathHref: string | null; scope: string; when: string }
export default async function PostInstallUpdatesPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.postInstall.updates" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { postInstall: { updates: {
detection: { steps: Step[] }
applying: { steps: Step[] }
differs: { rows: DiffRow[] }
} } }
}
const detectionSteps = messages.docs.postInstall.updates.detection.steps
const applyingSteps = messages.docs.postInstall.updates.applying.steps
const diffRows = messages.docs.postInstall.updates.differs.rows
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 optimizationsLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard/settings#proxmenux-optimizations" className="text-blue-600 hover:underline">{chunks}</Link>
)
const settingsLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard/settings" className="text-blue-600 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={4}
scriptPath="scripts/post_install/update_post_install_function.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("why.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("why.body", { em })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("detection.heading")}</h2>
<Steps>
{detectionSteps.map((step, idx) => (
<Steps.Step key={idx} title={step.title}>
<p className="mb-2 text-gray-800">
{t.rich(`detection.steps.${idx}.body`, { code, em })}
</p>
</Steps.Step>
))}
</Steps>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("pathA.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("pathA.intro", { strong, em })}
</p>
<figure className="my-4">
<Image
src="/post-install/post-install-updates-menu.png"
alt={t("pathA.menuAlt")}
width={1200}
height={680}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("pathA.menuCaption", { em })}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("pathA.checklistBody", { code })}
</p>
<figure className="my-4">
<Image
src="/post-install/post-install-updates-checklist.png"
alt={t("pathA.checklistAlt")}
width={1200}
height={680}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("pathA.checklistCaption")}
</figcaption>
</figure>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("pathB.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("pathB.intro", { strong, link: optimizationsLink })}
</p>
<figure className="my-4">
<Image
src="/monitor/settings/proxmenux-optimizations-update-banner.png"
alt={t("pathB.imageAlt")}
width={2000}
height={1146}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("pathB.imageCaption")}
</figcaption>
</figure>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("applying.heading")}</h2>
<Steps>
{applyingSteps.map((step, idx) => (
<Steps.Step key={idx} title={step.title}>
<p className="mb-2 text-gray-800">
{t.rich(`applying.steps.${idx}.body`, { code, strong })}
</p>
</Steps.Step>
))}
</Steps>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("differs.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("differs.headerPath")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("differs.headerScope")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("differs.headerWhen")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{diffRows.map((row, idx) => (
<tr key={row.pathLabel} className={idx < diffRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top">
{row.pathHref ? (
<Link href={row.pathHref} className="text-blue-600 hover:underline">
{row.pathLabel}
</Link>
) : (
<strong>{row.pathLabel}</strong>
)}
</td>
<td className="px-3 py-2 align-top">{t.rich(`differs.rows.${idx}.scope`, { em })}</td>
<td className="px-3 py-2 align-top">{row.when}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="info" title={t("notifTitle")}>
{t.rich("notifBody", { em, link: settingsLink })}
</Callout>
</div>
)
}

View File

@@ -0,0 +1,221 @@
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.postInstall.virtualization.meta" })
return { title: t("title"), description: t("description") }
}
type GuestRow = { detected: string; package: string }
type BootRow = { boot: string; file: string; post: string }
type RelatedItem = { label: string; href: string; tail: string }
export default async function PostInstallVirtualizationPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.postInstall.virtualization" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { postInstall: { virtualization: {
guestAgent: { rows: GuestRow[] }
vfio: {
whoItems: string[]
bootRows: BootRow[]
pathItems: string[]
}
related: { items: RelatedItem[] }
} } }
}
const v = messages.docs.postInstall.virtualization
const guestRows = v.guestAgent.rows
const whoItems = v.vfio.whoItems
const bootRows = v.vfio.bootRows
const pathItems = v.vfio.pathItems
const relatedItems = v.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 nvidiaHostLink = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/nvidia-host" className="text-blue-700 hover:underline">{chunks}</Link>
)
const uninstallLink = (chunks: React.ReactNode) => (
<Link href="/docs/post-install/uninstall" className="text-blue-700 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
section={t("header.section")}
/>
<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("guestAgent.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("guestAgent.intro", { code })}
</p>
<div className="my-4 overflow-x-auto">
<table className="min-w-full border border-gray-200 text-sm">
<thead className="bg-gray-100">
<tr>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("guestAgent.headerDetected")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("guestAgent.headerPackage")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{guestRows.map((row) => (
<tr key={row.detected}>
<td className="border border-gray-200 px-3 py-2">{row.detected}</td>
<td className="border border-gray-200 px-3 py-2"><code>{row.package}</code></td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="tip" title={t("guestAgent.skipTitle")}>
{t.rich("guestAgent.skipBody", { code, em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("vfio.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("vfio.intro", { code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("vfio.whoTitle")}</h3>
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-4">
{whoItems.map((_, idx) => (
<li key={idx}>{t.rich(`vfio.whoItems.${idx}`, { em })}</li>
))}
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("vfio.whoOutro", { strong, em })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("vfio.doesTitle")}</h3>
<p className="mb-3 text-gray-800 leading-relaxed">{t("vfio.doesIntro")}</p>
<div className="my-4 overflow-x-auto">
<table className="min-w-full border border-gray-200 text-sm">
<thead className="bg-gray-100">
<tr>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("vfio.headerBoot")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("vfio.headerFile")}</th>
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("vfio.headerPost")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{bootRows.map((row) => (
<tr key={row.boot}>
<td className="border border-gray-200 px-3 py-2">{row.boot}</td>
<td className="border border-gray-200 px-3 py-2"><code>{row.file}</code></td>
<td className="border border-gray-200 px-3 py-2"><code>{row.post}</code></td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-3 text-gray-800 leading-relaxed">{t("vfio.kernelIntro")}</p>
<CopyableCode
code={`# Intel CPU → intel_iommu=on
# AMD CPU → amd_iommu=on
# Plus these in both cases:
iommu=pt
pcie_acs_override=downstream,multifunction`}
className="my-4"
/>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("vfio.modulesIntro", { code })}
</p>
<CopyableCode
code={`vfio
vfio_iommu_type1
vfio_pci
vfio_virqfd # only on kernels < 6.2 (merged into vfio in 6.2+)`}
className="my-4"
/>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("vfio.blacklistIntro", { code })}
</p>
<CopyableCode
code={`nouveau
lbm-nouveau
radeon
nvidia
nvidiafb
options nouveau modeset=0`}
className="my-4"
/>
<Callout variant="warning" title={t("vfio.blacklistTitle")}>
{t.rich("vfio.blacklistBody", { em, strong, link: nvidiaHostLink })}
</Callout>
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-4">
{pathItems.map((_, idx) => (
<li key={idx}>{t.rich(`vfio.pathItems.${idx}`, { strong, code })}</li>
))}
</ul>
<Callout variant="warning" title={t("vfio.rebootTitle")}>
{t.rich("vfio.rebootBody", { code })}
</Callout>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("vfio.verifyTitle")}</h3>
<CopyableCode
code={`# IOMMU is actually on
dmesg | grep -E "DMAR|IOMMU" | head
# Expect lines like "IOMMU enabled" / "DMAR: IOMMU enabled"
# VFIO modules loaded
lsmod | grep vfio
# See your IOMMU groups — each "Group N" can be passed independently
for d in /sys/kernel/iommu_groups/*/devices/*; do
n=${"${d#*/iommu_groups/*}"}; n=${"${n%%/*}"}
printf 'Group %s ' "$n"; lspci -nns "${"${d##*/}"}"
done | sort -V`}
className="my-4"
/>
<Callout variant="tip" title={t("vfio.revertTitle")}>
{t.rich("vfio.revertBody", { code, link: uninstallLink })}
</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,282 @@
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 { DataFlowDiagram } from "@/components/ui/data-flow-diagram"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.security.fail2ban.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/security/fail2ban",
},
}
}
type JailRow = { jail: string; protects: string; retries: string; ban: string }
type LoggerRow = { service: string; source: string; output: string }
type ManageRow = { action: string; what: string }
export default async function Fail2BanPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.security.fail2ban" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { security: { fail2ban: {
jails: { rows: JailRow[] }
loggers: { rows: LoggerRow[] }
manage: { rows: ManageRow[] }
hardening: { items: string[] }
} } }
}
const block = messages.docs.security.fail2ban
const jailRows = block.jails.rows
const loggerRows = block.loggers.rows
const manageRows = block.manage.rows
const hardeningItems = block.hardening.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 codeNw = (chunks: React.ReactNode) => <code className="whitespace-nowrap">{chunks}</code>
const codeXs = (chunks: React.ReactNode) => <code className="text-xs">{chunks}</code>
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={8}
scriptPath="security/fail2ban_installer.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t("intro.body")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("firstLaunch.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t("firstLaunch.body")}
</p>
<Image
src="/security/fail2ban-install.png"
alt={t("firstLaunch.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("jails.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("jails.headerJail")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("jails.headerProtects")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("jails.headerRetries")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("jails.headerBan")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{jailRows.map((row, idx) => (
<tr key={row.jail} className={idx < jailRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.jail}</strong></td>
<td className="px-3 py-2 align-top">{row.protects}</td>
<td className="px-3 py-2 align-top whitespace-nowrap">{row.retries}</td>
<td className="px-3 py-2 align-top whitespace-nowrap">{row.ban}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("jails.outro", { code })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("journald.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("journald.intro", { code, codeNw, em })}
</p>
<DataFlowDiagram
nodes={[
{
label: t("journald.diagram.sshLabel"),
detail: t("journald.diagram.sshDetail"),
variant: "source",
},
{
label: t("journald.diagram.journaldLabel"),
detail: t("journald.diagram.journaldDetail"),
variant: "bridge",
},
{
label: t("journald.diagram.fail2banLabel"),
detail: t("journald.diagram.fail2banDetail"),
variant: "target",
},
]}
arrowLabel={t("journald.diagram.arrowLabel")}
/>
<p className="mt-6 mb-4 text-gray-800 leading-relaxed">
{t.rich("journald.afterDiagram", { code, codeXs })}
</p>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("journald.code") as string}</pre>
<p className="mt-4 mb-6 text-gray-800 leading-relaxed">
{t.rich("journald.outro", { code })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("loggers.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("loggers.intro1", { code })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("loggers.intro2", { code })}
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("loggers.headerService")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("loggers.headerSource")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("loggers.headerOutput")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{loggerRows.map((row, idx) => (
<tr key={row.service} className={idx < loggerRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs"><strong>{row.service}</strong></td>
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.source}</td>
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.output}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("loggers.outro", { code })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("backend.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("backend.intro", { code })}
</p>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("backend.code") as string}</pre>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("hardening.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("hardening.intro", { code, strong })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">
{t("hardening.installerIntro")}
</p>
<ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{hardeningItems.map((_, idx) => (
<li key={idx}>{t.rich(`hardening.items.${idx}`, { code, strong })}</li>
))}
</ol>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("hardening.outro", { code })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manage.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t("manage.intro")}
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("manage.headerAction")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("manage.headerWhat")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{manageRows.map((row, idx) => (
<tr key={row.action} className={idx < manageRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.action}</strong></td>
<td className="px-3 py-2 align-top">{row.what}</td>
</tr>
))}
</tbody>
</table>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("verify.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t("verify.intro")}
</p>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("verify.code") as string}</pre>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.neverBansTitle")}>
{t.rich("troubleshoot.neverBansBody", { code, em })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.monitorEmptyTitle")}>
{t.rich("troubleshoot.monitorEmptyBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.selfBanTitle")}>
{t("troubleshoot.selfBanIntro")}
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{t.raw("troubleshoot.selfBanCode") as string}</pre>
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.aptFailTitle")}>
{t.rich("troubleshoot.aptFailBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.lockoutTitle")}>
{t.rich("troubleshoot.lockoutBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("files.heading")}</h2>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("files.code") as string}</pre>
<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">
<li>
<Link href="/docs/monitor/dashboard/security" className="text-blue-600 hover:underline">
{t("related.monitorLabel")}
</Link>
{t("related.monitorTail")}
</li>
<li>
<Link href="/docs/security/lynis" className="text-blue-600 hover:underline">
{t("related.lynisLabel")}
</Link>
{t("related.lynisTail")}
</li>
<li>
<Link href="/docs/security" className="text-blue-600 hover:underline">
{t("related.securityLabel")}
</Link>
{t("related.securityTail")}
</li>
</ul>
</div>
)
}

View File

@@ -0,0 +1,280 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import Image from "next/image"
import { ExternalLink } from "lucide-react"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
import { DataFlowDiagram } from "@/components/ui/data-flow-diagram"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.security.lynis.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/security/lynis",
},
}
}
type WhyRow = { sourceRich: string; path: string; update: string; fresh: string }
type ReportRow = { markerRich: string; meaning: string; action: string }
type ReinstallRow = { actionRich: string; whatRich: string }
type RelatedItem = { href: string; label: string; tail?: string; tailRich?: string }
export default async function LynisPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.security.lynis" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { security: { lynis: {
detection: { items: string[] }
whyUpstream: { rows: WhyRow[] }
report: { rows: ReportRow[] }
reinstall: { rows: ReinstallRow[] }
related: { items: RelatedItem[] }
} } }
}
const block = messages.docs.security.lynis
const detectionItems = block.detection.items
const whyRows = block.whyUpstream.rows
const reportRows = block.report.rows
const reinstallRows = block.reinstall.rows
const relatedItems = block.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 ok = (chunks: React.ReactNode) => <code className="text-emerald-700">{chunks}</code>
const warn = (chunks: React.ReactNode) => <code className="text-amber-700">{chunks}</code>
const sugg = (chunks: React.ReactNode) => <code className="text-red-700">{chunks}</code>
const linkFail2ban = (chunks: React.ReactNode) => (
<Link href="/docs/security/fail2ban" className="text-blue-600 hover:underline">{chunks}</Link>
)
const linkSecurityTab = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard/security" className="text-blue-600 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={5}
scriptPath="security/lynis_installer.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("manageMenu.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("manageMenu.intro")}</p>
<Image
src="/security/lynis-menu.png"
alt={t("manageMenu.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("whyUpstream.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("whyUpstream.intro", { code })}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("whyUpstream.headerSource")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("whyUpstream.headerPath")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("whyUpstream.headerUpdate")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("whyUpstream.headerFresh")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{whyRows.map((row, idx) => (
<tr key={idx} className={idx < whyRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap">{t.rich(`whyUpstream.rows.${idx}.sourceRich`, { strong })}</td>
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.path}</td>
<td className="px-3 py-2 align-top">{row.update}</td>
<td className="px-3 py-2 align-top">{row.fresh}</td>
</tr>
))}
</tbody>
</table>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("install.heading")}</h2>
<DataFlowDiagram
nodes={[
{
label: t("install.node1Label"),
detail: t("install.node1Detail"),
variant: "source",
},
{
label: t("install.node2Label"),
detail: t("install.node2Detail"),
variant: "bridge",
},
{
label: t("install.node3Label"),
detail: t("install.node3Detail"),
variant: "target",
},
]}
/>
<p className="mt-6 mb-4 text-gray-800 leading-relaxed">{t.rich("install.outro", { code })}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("detection.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("detection.intro")}</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{detectionItems.map((_, idx) => (
<li key={idx}>{t.rich(`detection.items.${idx}`, { code })}</li>
))}
</ol>
<p className="mb-6 text-gray-800 leading-relaxed">{t.rich("detection.outro", { code, strong })}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("audit.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("audit.intro", { strong })}</p>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("audit.code") as string}</pre>
<p className="mt-4 mb-4 text-gray-800 leading-relaxed">{t.rich("audit.outro", { code, ok, warn, sugg })}</p>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("audit.summary") as string}</pre>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("report.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("report.intro", { code, strong })}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("report.headerMarker")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("report.headerMeaning")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("report.headerAction")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{reportRows.map((row, idx) => (
<tr key={idx} className={idx < reportRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap">{t.rich(`report.rows.${idx}.markerRich`, { strong })}</td>
<td className="px-3 py-2 align-top">{row.meaning}</td>
<td className="px-3 py-2 align-top">{row.action}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-6 text-gray-800 leading-relaxed">{t.rich("report.outro", { code })}</p>
<Callout variant="tip" title={t("pairFail2ban.title")}>
{t.rich("pairFail2ban.body", { code, link: linkFail2ban })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("update.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("update.body", { code, strong })}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("reinstall.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("reinstall.headerAction")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("reinstall.headerWhat")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{reinstallRows.map((_, idx) => (
<tr key={idx} className={idx < reinstallRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap">{t.rich(`reinstall.rows.${idx}.actionRich`, { strong })}</td>
<td className="px-3 py-2 align-top">{t.rich(`reinstall.rows.${idx}.whatRich`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("cli.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("cli.intro")}</p>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("cli.code") as string}</pre>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.cloneTitle")}>
{t.rich("troubleshoot.cloneBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.notFoundTitle")}>
{t.rich("troubleshoot.notFoundIntro", { code })}
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{t.raw("troubleshoot.notFoundCode") as string}</pre>
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.sshTitle")}>
{t.rich("troubleshoot.sshIntro", { code, link: linkFail2ban })}
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{t.raw("troubleshoot.sshCode") as string}</pre>
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.scoreTitle")}>
{t.rich("troubleshoot.scoreBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("files.heading")}</h2>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("files.code") as string}</pre>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("sample.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("sample.intro", { link: linkSecurityTab })}</p>
<figure className="my-4">
<Image
src="/monitor/security/lynis-report-pdf.png"
alt={t("sample.imageAlt")}
width={1414}
height={2000}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("sample.captionPrefix")}
<a
href="/monitor/security/lynis-sample-report.pdf"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{t("sample.captionLink")}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
{t("sample.captionSuffix")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed text-sm">{t.rich("sample.cli", { code })}</p>
<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, idx) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tailRich ? t.rich(`related.items.${idx}.tailRich`, { code }) : item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,200 @@
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, Ban, ShieldCheck, ScanLine } 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.security.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"proxmox fail2ban",
"proxmox lynis",
"proxmox security",
"proxmox hardening",
"proxmox intrusion prevention",
"proxmox security audit",
"proxmox ssh fail2ban",
"proxmox web ui fail2ban",
],
alternates: { canonical: "https://proxmenux.com/docs/security" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/security",
},
twitter: {
card: "summary",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type StringItem = string
interface OptionProps {
title: string
description: string
Icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>
href: string
}
function OptionCard({ title, description, Icon, href }: OptionProps) {
return (
<Link
href={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">
{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">{description}</div>
</div>
</Link>
)
}
export default async function SecurityOverviewPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.security" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { security: {
cards: {
fail2ban: { bullets: StringItem[] }
lynis: { bullets: StringItem[] }
}
} }
}
const fail2banBullets = messages.docs.security.cards.fail2ban.bullets
const lynisBullets = messages.docs.security.cards.lynis.bullets
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={4}
scriptPath="menus/security_menu.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("opening.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("opening.body", { strong })}</p>
<Image
src="/security/security-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("pick.heading")}</h2>
<p className="mb-6 text-gray-800 leading-relaxed">{t("pick.body")}</p>
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-2 mb-8 not-prose">
<a
href="#fail2ban"
className="rounded-lg border-2 border-red-300 bg-red-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-red-100 text-red-700">
<Ban className="h-5 w-5" aria-hidden />
</span>
<h3 className="text-lg font-semibold text-gray-900 m-0">{t("cards.fail2ban.title")}</h3>
</div>
<p className="text-sm text-gray-800 mb-3">{t("cards.fail2ban.body")}</p>
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
{fail2banBullets.map((_, idx) => (
<li key={idx}>{t(`cards.fail2ban.bullets.${idx}`)}</li>
))}
</ul>
</a>
<a
href="#lynis"
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">
<ScanLine className="h-5 w-5" aria-hidden />
</span>
<h3 className="text-lg font-semibold text-gray-900 m-0">{t("cards.lynis.title")}</h3>
</div>
<p className="text-sm text-gray-800 mb-3">{t("cards.lynis.body")}</p>
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
{lynisBullets.map((_, idx) => (
<li key={idx}>{t(`cards.lynis.bullets.${idx}`)}</li>
))}
</ul>
</a>
</div>
<Callout variant="tip" title={t("workflowTip.title")}>
{t.rich("workflowTip.body", { code })}
</Callout>
<h2 id="fail2ban" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24">
{t("fail2banSection.heading")}
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("fail2banSection.body")}</p>
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
<OptionCard
title={t("fail2banSection.optionTitle")}
description={t("fail2banSection.optionDescription")}
Icon={Ban}
href="/docs/security/fail2ban"
/>
</div>
<h2 id="lynis" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24">
{t("lynisSection.heading")}
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("lynisSection.body", { code })}</p>
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
<OptionCard
title={t("lynisSection.optionTitle")}
description={t("lynisSection.optionDescription")}
Icon={ScanLine}
href="/docs/security/lynis"
/>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
<ShieldCheck className="inline h-5 w-5 mr-1 -mt-1 text-emerald-600" aria-hidden />
{t("componentStatus.heading")}
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("componentStatus.body", { code })}</p>
</div>
)
}

View File

@@ -0,0 +1,360 @@
import type { Metadata } from "next"
import Image from "next/image"
import { ExternalLink } from "lucide-react"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
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.security.sslLetsencrypt.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/security/ssl-letsencrypt",
},
alternates: { canonical: "https://proxmenux.com/docs/security/ssl-letsencrypt" },
}
}
type StringItem = string
type TableRow = {
fileRich: string
originRich?: string
origin?: string
when?: string
whenRich?: string
}
export default async function SslLetsEncryptPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.security.sslLetsencrypt" })
const messages = (await getMessages({ locale })) as unknown as {
docs: {
security: {
sslLetsencrypt: {
twoways: {
proxmox: { items: StringItem[] }
custom: { items: StringItem[] }
}
proxmoxCert: { table: { rows: TableRow[] } }
letsencrypt: { prereqs: { items: StringItem[] } }
custom: { items: StringItem[] }
trustCa: { items: StringItem[] }
}
}
}
}
const block = messages.docs.security.sslLetsencrypt
const proxmoxItems = block.twoways.proxmox.items
const customItems = block.twoways.custom.items
const tableRows = block.proxmoxCert.table.rows
const prereqItems = block.letsencrypt.prereqs.items
const customListItems = block.custom.items
const trustCaItems = block.trustCa.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 />
const extlink1 = (chunks: React.ReactNode) => (
<a
href="https://pve.proxmox.com/wiki/Certificate_Management"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
const extlink2 = (chunks: React.ReactNode) => (
<a
href="https://github.com/acmesh-official/acme.sh/wiki/dnsapi"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={5}
scriptPath="AppImage/scripts/auth_manager.py"
/>
<Callout variant="info" title={t("intro.title")}>
{t("intro.body")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("wheresetting.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("wheresetting.body", { strong })}
</p>
<figure className="my-6">
<Image
src="/monitor/security/ssl-https-card.png"
alt={t("wheresetting.imageAlt")}
width={2000}
height={1124}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("wheresetting.caption")}
</figcaption>
</figure>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("twoways.heading")}</h2>
<div className="grid gap-4 md:grid-cols-2 mb-8 not-prose">
<div className="rounded-lg border-2 border-green-300 bg-green-50 p-5">
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t("twoways.proxmox.title")}</h3>
<p className="text-sm text-gray-800 mb-3">{t("twoways.proxmox.summary")}</p>
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
{proxmoxItems.map((_, idx) => (
<li key={idx}>{t(`twoways.proxmox.items.${idx}`)}</li>
))}
</ul>
</div>
<div className="rounded-lg border-2 border-blue-300 bg-blue-50 p-5">
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t("twoways.custom.title")}</h3>
<p className="text-sm text-gray-800 mb-3">
{t.rich("twoways.custom.summaryRich", { code })}
</p>
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
{customItems.map((_, idx) => (
<li key={idx}>{t.rich(`twoways.custom.items.${idx}`, { code })}</li>
))}
</ul>
</div>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
{t("proxmoxCert.heading")}
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("proxmoxCert.intro", { code })}
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">
{t("proxmoxCert.table.headers.file")}
</th>
<th className="text-left px-3 py-2 border-b border-gray-200">
{t("proxmoxCert.table.headers.origin")}
</th>
<th className="text-left px-3 py-2 border-b border-gray-200">
{t("proxmoxCert.table.headers.when")}
</th>
</tr>
</thead>
<tbody className="text-gray-800">
{tableRows.map((row, idx) => (
<tr
key={idx}
className={idx < tableRows.length - 1 ? "border-b border-gray-100" : ""}
>
<td className="px-3 py-2 align-top whitespace-nowrap">
{t.rich(`proxmoxCert.table.rows.${idx}.fileRich`, { code, br })}
</td>
<td className="px-3 py-2 align-top">
{row.originRich
? t.rich(`proxmoxCert.table.rows.${idx}.originRich`, { code, strong, em })
: t(`proxmoxCert.table.rows.${idx}.origin`)}
</td>
<td className="px-3 py-2 align-top">
{row.whenRich
? t.rich(`proxmoxCert.table.rows.${idx}.whenRich`, { code })
: t(`proxmoxCert.table.rows.${idx}.when`)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="info" title={t("proxmoxCert.callout.title")}>
{t.rich("proxmoxCert.callout.bodyRich", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
{t("letsencrypt.heading")}
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("letsencrypt.intro", { code, em, extlink1 })}
</p>
<Callout variant="info" title={t("letsencrypt.prereqs.title")}>
<ul className="list-disc pl-5 space-y-1 mb-0">
{prereqItems.map((_, idx) => (
<li key={idx}>{t.rich(`letsencrypt.prereqs.items.${idx}`, { strong })}</li>
))}
</ul>
</Callout>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">
{t("letsencrypt.step1.heading")}
</h3>
<p className="mb-2 text-gray-800 leading-relaxed">
{t.rich("letsencrypt.step1.introRich", { code })}
</p>
<pre className="rounded-md bg-gray-900 text-gray-100 p-4 overflow-x-auto text-xs font-mono mb-4">
{t("letsencrypt.step1.code")}
</pre>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("letsencrypt.step1.afterRich", { code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">
{t("letsencrypt.step2.heading")}
</h3>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("letsencrypt.step2.http01Rich", { code, strong })}
</p>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("letsencrypt.step2.dns01Rich", { strong })}
</p>
<pre className="rounded-md bg-gray-900 text-gray-100 p-4 overflow-x-auto text-xs font-mono mb-4">
{t("letsencrypt.step2.code")}
</pre>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("letsencrypt.step2.outroRich", { code, extlink2 })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">
{t("letsencrypt.step3.heading")}
</h3>
<p className="mb-2 text-gray-800 leading-relaxed">
{t.rich("letsencrypt.step3.http01Rich", { code })}
</p>
<pre className="rounded-md bg-gray-900 text-gray-100 p-4 overflow-x-auto text-xs font-mono mb-3">
{t("letsencrypt.step3.code1")}
</pre>
<p className="mb-2 text-gray-800 leading-relaxed">{t("letsencrypt.step3.dns01")}</p>
<pre className="rounded-md bg-gray-900 text-gray-100 p-4 overflow-x-auto text-xs font-mono mb-4">
{t("letsencrypt.step3.code2")}
</pre>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("letsencrypt.step3.wildcardRich", { code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">
{t("letsencrypt.step4.heading")}
</h3>
<pre className="rounded-md bg-gray-900 text-gray-100 p-4 overflow-x-auto text-xs font-mono mb-3">
{t("letsencrypt.step4.code")}
</pre>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("letsencrypt.step4.afterRich", { code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">
{t("letsencrypt.step5.heading")}
</h3>
<pre className="rounded-md bg-gray-900 text-gray-100 p-4 overflow-x-auto text-xs font-mono mb-3">
{t("letsencrypt.step5.code")}
</pre>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("letsencrypt.step5.afterRich", { code })}
</p>
<Callout variant="tip" title={t("letsencrypt.gui.title")}>
{t.rich("letsencrypt.gui.bodyRich", { em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
{t("switchToHttps.heading")}
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("switchToHttps.bodyRich", { code, strong, em })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("custom.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("custom.intro", { strong })}
</p>
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-4">
{customListItems.map((_, idx) => (
<li key={idx}>{t.rich(`custom.items.${idx}`, { code, strong })}</li>
))}
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">{t("custom.outro")}</p>
<pre className="rounded-md bg-gray-900 text-gray-100 p-4 overflow-x-auto text-xs font-mono mb-4">
{t("custom.code")}
</pre>
<Callout variant="warning" title={t("custom.symlinkCallout.title")}>
{t.rich("custom.symlinkCallout.bodyRich", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("afterHttps.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("afterHttps.bodyRich", { code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">
{t("afterHttps.reverse.heading")}
</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("afterHttps.reverse.bodyRich", { code })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("trustCa.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("trustCa.intro1Rich", { code })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("trustCa.intro2Rich", { code })}
</p>
<pre className="rounded-md bg-gray-900 text-gray-100 p-4 overflow-x-auto text-xs font-mono mb-4">
{t("trustCa.code")}
</pre>
<p className="mb-3 text-gray-800 leading-relaxed">{t("trustCa.thenImport")}</p>
<ul className="list-disc pl-6 space-y-2 text-gray-800 mb-4">
{trustCaItems.map((_, idx) => (
<li key={idx}>{t.rich(`trustCa.items.${idx}`, { code, strong, em })}</li>
))}
</ul>
<Callout variant="info" title={t("trustCa.standalone.title")}>
{t.rich("trustCa.standalone.bodyRich", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("disable.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("disable.bodyRich", { strong })}
</p>
<Callout variant="info" title={t("disable.stateCallout.title")}>
{t.rich("disable.stateCallout.bodyRich", { code })}
</Callout>
</div>
)
}

View File

@@ -0,0 +1,191 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { ExternalLink } 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.settings.betaProgram.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/settings/beta-program",
},
}
}
type StringItem = string
type RelatedItem = { href: string; label: string; tail?: string }
export default async function BetaProgramPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.settings.betaProgram" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { settings: { betaProgram: {
why: { items: StringItem[] }
dialog: { options: StringItem[]; directions: StringItem[] }
confirm: { items: StringItem[] }
switching: { items: StringItem[] }
feedback: { items: StringItem[] }
related: { items: RelatedItem[] }
} } }
}
const block = messages.docs.settings.betaProgram
const whyItems = block.why.items
const dialogOptions = block.dialog.options
const dialogDirections = block.dialog.directions
const confirmItems = block.confirm.items
const switchingItems = block.switching.items
const feedbackItems = block.feedback.items
const relatedItems = block.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 ul = (chunks: React.ReactNode) => <ul className="list-disc pl-6 mt-1">{chunks}</ul>
const li = (chunks: React.ReactNode) => <li>{chunks}</li>
const link = (chunks: React.ReactNode) => (
<Link href="/docs/settings/show-version-information" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
const ghlink = (chunks: React.ReactNode) => (
<a
href="https://github.com/MacRimi/ProxMenux/issues"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="w-3 h-3" />
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={3}
scriptPath="menus/config_menu.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code, strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("why.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("why.intro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{whyItems.map((_, idx) => (
<li key={idx}>{t.rich(`why.items.${idx}`, { code, strong })}</li>
))}
</ul>
<p className="mb-6 text-gray-800 leading-relaxed">{t.rich("why.outro", { code })}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dialog.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("dialog.intro", { strong })}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{dialogOptions.map((_, idx) => (
<li key={idx}>{t.rich(`dialog.options.${idx}`, { code })}</li>
))}
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">{t("dialog.behaviour")}</p>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{dialogDirections.map((_, idx) => (
<li key={idx}>{t.rich(`dialog.directions.${idx}`, { strong })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("confirm.heading")}</h2>
<ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{confirmItems.map((_, idx) => (
<li key={idx}>{t.rich(`confirm.items.${idx}`, { code, strong, em, ul, li })}</li>
))}
</ol>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("switching.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("switching.intro", { strong })}</p>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{switchingItems.map((_, idx) => (
<li key={idx}>{t.rich(`switching.items.${idx}`, { code })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("feedback.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("feedback.intro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{feedbackItems.map((_, idx) => (
<li key={idx}>{t(`feedback.items.${idx}`)}</li>
))}
<li>
<pre className="mt-2 rounded-md bg-gray-100 p-3 overflow-x-auto text-xs font-mono text-gray-800 border border-gray-200">{t.raw("feedback.logsCommand") as string}</pre>
</li>
<li>{t.rich("feedback.versionLine", { link })}</li>
</ul>
<p className="mb-6 text-gray-800 leading-relaxed">{t.rich("feedback.issueLine", { ghlink })}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("manual.intro")}</p>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`# Check current channel (returns "beta" or "stable")
jq -r '.beta_program.status // "stable"' /usr/local/share/proxmenux/config.json
# Switch to Stable
bash -c "$(wget -qLO - https://raw.githubusercontent.com/MacRimi/ProxMenux/main/install_proxmenux.sh)"
# Switch to Beta
bash -c "$(wget -qLO - https://raw.githubusercontent.com/MacRimi/ProxMenux/develop/install_proxmenux_beta.sh)"`}</pre>
<Callout variant="info" title={t("unifiedCallout.title")}>
{t.rich("unifiedCallout.body", { code, strong, em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.downloadTitle")}>
{t.rich("troubleshoot.downloadBody", { code })}
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{t.raw("troubleshoot.downloadCmd") as string}</pre>
{t("troubleshoot.downloadOutro")}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.errorsTitle")}>
{t("troubleshoot.errorsBody")}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.configTitle")}>
{t.rich("troubleshoot.configBody", { code })}
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{t.raw("troubleshoot.configCmd") as string}</pre>
{t("troubleshoot.configOutro")}
</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,136 @@
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"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.settings.changeLanguage.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/settings/change-language",
},
}
}
type LangRow = { code: string; lang: string; notes: string }
type StringItem = string
type RelatedItem = { href: string; label: string; tail?: string }
export default async function ChangeLanguagePage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.settings.changeLanguage" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { settings: { changeLanguage: {
supported: { rows: LangRow[] }
underHood: { items: StringItem[] }
related: { items: RelatedItem[] }
} } }
}
const langRows = messages.docs.settings.changeLanguage.supported.rows
const underHoodItems = messages.docs.settings.changeLanguage.underHood.items
const relatedItems = messages.docs.settings.changeLanguage.related.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={1}
scriptPath="menus/config_menu.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code })}
</Callout>
<Callout variant="warning" title={t("warn.title")}>
{t.rich("warn.body", { code, em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("supported.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("supported.headerCode")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("supported.headerLang")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("supported.headerNotes")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{langRows.map((row, idx) => (
<tr key={row.code} className={idx < langRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap font-mono">{row.code}</td>
<td className="px-3 py-2 align-top">{row.lang}</td>
<td className="px-3 py-2 align-top">{row.notes}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="tip" title={t("englishTip.title")}>
{t("englishTip.body")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("underHood.heading")}</h2>
<ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{underHoodItems.map((_, idx) => (
<li key={idx}>{t.rich(`underHood.items.${idx}`, { code, em })}</li>
))}
</ol>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`# Set language to Spanish
tmp=$(mktemp)
jq --arg lang "es" '.language = $lang' /usr/local/share/proxmenux/config.json > "$tmp" \\
&& mv "$tmp" /usr/local/share/proxmenux/config.json
# Verify
jq -r '.language' /usr/local/share/proxmenux/config.json`}</pre>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.noOptionTitle")}>
{t("troubleshoot.noOptionBody")}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.stillEnglishTitle")}>
{t.rich("troubleshoot.stillEnglishBody", { 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,189 @@
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,
Activity,
TestTube,
Languages,
Info,
Trash2,
} 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.settings.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"proxmenux settings",
"proxmenux monitor activation",
"proxmenux beta program",
"proxmenux language",
"proxmenux uninstall",
"proxmenux version",
"proxmenux configuration",
],
alternates: { canonical: "https://proxmenux.com/docs/settings" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/settings",
},
twitter: {
card: "summary",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type Option = {
icon: string
href: string
title: string
description: string
badge?: string
}
type InstallRow = { type: string; bundles: string; menu?: string; menuRich?: string }
const ICONS: Record<string, React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>> = {
Activity,
TestTube,
Languages,
Info,
Trash2,
}
function OptionCard({ option }: { option: Option }) {
const Icon = ICONS[option.icon] || Activity
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-2 text-sm font-medium text-gray-900 group-hover:text-blue-700">
<span className="flex items-center gap-1">
{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" />
</span>
{option.badge && (
<span className="inline-flex items-center rounded-full border border-gray-300 bg-gray-50 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-gray-600">
{option.badge}
</span>
)}
</div>
<div className="mt-0.5 text-xs text-gray-600 leading-snug">{option.description}</div>
</div>
</Link>
)
}
export default async function SettingsOverviewPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.settings" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { settings: {
installTypes: { rows: InstallRow[] }
options: { list: Option[] }
} }
}
const installRows = messages.docs.settings.installTypes.rows
const optionsList = messages.docs.settings.options.list
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 kbd = (chunks: React.ReactNode) => <kbd>{chunks}</kbd>
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={3}
scriptPath="menus/config_menu.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t("intro.body")}
</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", { kbd })}
</p>
<Image
src="/settings/settings-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("installTypes.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("installTypes.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("installTypes.headerType")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("installTypes.headerBundles")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("installTypes.headerMenu")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{installRows.map((row, idx) => (
<tr key={row.type} className={idx < installRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.type}</strong></td>
<td className="px-3 py-2 align-top">{row.bundles}</td>
<td className="px-3 py-2 align-top">
{row.menuRich ? t.rich(`installTypes.rows.${idx}.menuRich`, { strong }) : row.menu}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="info" title={t("installTypes.detectionTitle")}>
{t.rich("installTypes.detectionBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("options.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("options.intro", { em })}
</p>
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
{optionsList.map((o) => (
<OptionCard key={o.href} option={o} />
))}
</div>
<Callout variant="tip" title={t("configTip.title")}>
{t.rich("configTip.bodyRich", { code })}
</Callout>
</div>
)
}

View File

@@ -0,0 +1,181 @@
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"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.settings.proxmenuxMonitor.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/settings/proxmenux-monitor",
},
}
}
type StringItem = string
type ToggleRow = { state: string; label: string; action: string }
type RelatedItem = { href: string; label: string; tail?: string; tailRich?: string }
export default async function ProxmenuxMonitorPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.settings.proxmenuxMonitor" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { settings: { proxmenuxMonitor: {
offers: { items: StringItem[] }
toggle: { rows: ToggleRow[] }
status: { items: StringItem[] }
reset: { items: StringItem[] }
related: { items: RelatedItem[] }
} } }
}
const block = messages.docs.settings.proxmenuxMonitor
const offersItems = block.offers.items
const toggleRows = block.toggle.rows
const statusItems = block.status.items
const resetItems = block.reset.items
const relatedItems = block.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 link = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/access-auth#recovering-password" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={3}
scriptPath="menus/config_menu.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t("intro.body")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("offers.heading")}</h2>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{offersItems.map((_, idx) => (
<li key={idx}>{t(`offers.items.${idx}`)}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("access.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("access.intro")}</p>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("access.url") as string}</pre>
<p className="mt-4 mb-6 text-gray-800 leading-relaxed">{t.rich("access.outro", { code })}</p>
<Callout variant="warning" title={t("warnConditional.title")}>
{t.rich("warnConditional.body", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("toggle.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("toggle.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("toggle.headerState")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("toggle.headerLabel")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("toggle.headerAction")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{toggleRows.map((row, idx) => (
<tr key={row.state} className={idx < toggleRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.state}</strong></td>
<td className="px-3 py-2 align-top">{row.label}</td>
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.action}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-6 text-gray-800 leading-relaxed">{t.rich("toggle.outro", { em })}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("status.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("status.intro", { em })}</p>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{statusItems.map((_, idx) => (
<li key={idx}>{t.rich(`status.items.${idx}`, { code, strong })}</li>
))}
</ul>
<Callout variant="tip" title={t("manual.title")}>
{t("manual.intro")}
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{t.raw("manual.code") as string}</pre>
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("reset.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("reset.intro")}</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{resetItems.map((_, idx) => (
<li key={idx}>{t.rich(`reset.items.${idx}`, { code, strong, em })}</li>
))}
</ol>
<Callout variant="info" title={t("reset.preservedTitle")}>
{t.rich("reset.preservedBody", { code, strong })}
</Callout>
<Callout variant="warning" title={t("reset.trustTitle")}>
{t.rich("reset.trustBody", { code })}
</Callout>
<p className="mb-6 text-gray-800 leading-relaxed">{t.rich("reset.seeAlso", { link })}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("files.heading")}</h2>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("files.code") as string}</pre>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.missingTitle")}>
{t.rich("troubleshoot.missingBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.unreachableTitle")}>
{t("troubleshoot.unreachableBody")}
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{t.raw("troubleshoot.unreachableCmd") as string}</pre>
{t.rich("troubleshoot.unreachableOutro", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.stopsTitle")}>
{t.rich("troubleshoot.stopsBody", { 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, idx) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tailRich ? t.rich(`related.items.${idx}.tailRich`, { code }) : item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,156 @@
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"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.settings.showVersionInformation.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/settings/show-version-information",
},
}
}
type ReportRow = { section: string; source: string; content?: string; contentRich?: string }
type RelatedItem = { href: string; label: string; tail?: string }
export default async function ShowVersionInformationPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.settings.showVersionInformation" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { settings: { showVersionInformation: {
reports: { rows: ReportRow[] }
related: { items: RelatedItem[] }
} } }
}
const reportRows = messages.docs.settings.showVersionInformation.reports.rows
const relatedItems = messages.docs.settings.showVersionInformation.related.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={2}
scriptPath="menus/config_menu.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t("intro.body")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("reports.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("reports.headerSection")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("reports.headerSource")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("reports.headerContent")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{reportRows.map((row, idx) => (
<tr key={row.section} className={idx < reportRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.section}</strong></td>
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.source}</td>
<td className="px-3 py-2 align-top">
{row.contentRich ? t.rich(`reports.rows.${idx}.contentRich`, { code }) : row.content}
</td>
</tr>
))}
</tbody>
</table>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("sampleHeading")}</h2>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`Current ProxMenux version: 1.2.3
Installation type:
✓ Translation Version (Multi-language support)
Installed components:
✓ post_install_settings: installed
✓ post_install_system: installed
✓ post_install_security: installed
✓ proxmenux_monitor: installed
✓ fail2ban: installed
✗ lynis: removed
ProxMenux files:
✓ menu → /usr/local/bin/menu
✓ utils.sh → /usr/local/share/proxmenux/utils.sh
✓ config.json → /usr/local/share/proxmenux/config.json
✓ version.txt → /usr/local/share/proxmenux/version.txt
✓ cache.json → /usr/local/share/proxmenux/cache.json
Virtual Environment:
✓ Installed → /opt/googletrans-env
✓ pip: Installed → /opt/googletrans-env/bin/pip
Current language:
es`}</pre>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manualHeading")}</h2>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`# Version
cat /usr/local/share/proxmenux/version.txt
# Install type detection (replicates the script's logic)
[[ -d /opt/googletrans-env ]] && echo "venv: yes" || echo "venv: no"
jq -r '.language // "missing"' /usr/local/share/proxmenux/config.json
# All component statuses
jq 'to_entries[] | "\\(.key): \\(.value)"' /usr/local/share/proxmenux/config.json
# Current language
jq -r '.language' /usr/local/share/proxmenux/config.json`}</pre>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.unknownTitle")}>
{t.rich("troubleshoot.unknownBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.noConfigTitle")}>
{t.rich("troubleshoot.noConfigBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.wrongStatusTitle")}>
{t.rich("troubleshoot.wrongStatusBody", { 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,153 @@
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"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.settings.uninstallProxmenux.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/settings/uninstall-proxmenux",
},
}
}
type StringItem = string
type DepRow = { type: string; offered?: string; offeredRich?: string }
type RelatedItem = { href: string; label: string; tail?: string }
export default async function UninstallProxMenuxPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.settings.uninstallProxmenux" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { settings: { uninstallProxmenux: {
flow: { items: StringItem[] }
deps: { rows: DepRow[] }
restored: { items: StringItem[] }
related: { items: RelatedItem[] }
} } }
}
const block = messages.docs.settings.uninstallProxmenux
const flowItems = block.flow.items
const depRows = block.deps.rows
const restoredItems = block.restored.items
const relatedItems = block.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={3}
scriptPath="menus/config_menu.sh"
/>
<Callout variant="warning" title={t("scopeWarn.title")}>
{t.rich("scopeWarn.body", { strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("flow.heading")}</h2>
<ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-2">
{flowItems.map((_, idx) => (
<li key={idx}>{t.rich(`flow.items.${idx}`, { code, strong })}</li>
))}
</ol>
<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.intro", { strong })}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("deps.headerType")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("deps.headerOffered")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{depRows.map((row, idx) => (
<tr key={row.type} className={idx < depRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.type}</strong></td>
<td className="px-3 py-2 align-top">
{row.offeredRich ? t.rich(`deps.rows.${idx}.offeredRich`, { code }) : row.offered}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="warning" title={t("deps.warnTitle")}>
{t.rich("deps.warnBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("removed.heading")}</h2>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("removed.code") as string}</pre>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("restored.heading")}</h2>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{restoredItems.map((_, idx) => (
<li key={idx}>{t.rich(`restored.items.${idx}`, { code, em })}</li>
))}
</ul>
<Callout variant="info" title={t("othersCallout.title")}>
{t.rich("othersCallout.body", { strong, em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("manual.intro", { code })}</p>
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("manual.code") as string}</pre>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("reinstall.heading")}</h2>
<p className="mb-6 text-gray-800 leading-relaxed">{t("reinstall.body")}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.hangTitle")}>
{t.rich("troubleshoot.hangBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.aptTitle")}>
{t.rich("troubleshoot.aptBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.motdTitle")}>
{t.rich("troubleshoot.motdBody", { 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,265 @@
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.storageShare.hostIscsi.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/storage-share/host-iscsi",
},
}
}
type VocabRow = { term: string; meaningRich: string }
type StringItem = string
type RelatedItem = { href: string; label: string; tail?: string }
export default async function HostIscsiPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.storageShare.hostIscsi" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { storageShare: { hostIscsi: {
vocab: { rows: VocabRow[] }
add: { items: StringItem[] }
troubleshoot: { discoverItems: StringItem[] }
related: { items: RelatedItem[] }
} } }
}
const vocabRows = messages.docs.storageShare.hostIscsi.vocab.rows
const addItems = messages.docs.storageShare.hostIscsi.add.items
const discoverItems = messages.docs.storageShare.hostIscsi.troubleshoot.discoverItems
const relatedItems = messages.docs.storageShare.hostIscsi.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={10}
scriptPath="share/iscsi_host.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("vocab.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("vocab.headerTerm")}</th>
<th className="px-4 py-2 font-semibold">{t("vocab.headerMeaning")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-800">
{vocabRows.map((row, idx) => (
<tr key={row.term}>
<td className="px-4 py-2 font-mono">{row.term}</td>
<td className="px-4 py-2">{t.rich(`vocab.rows.${idx}.meaningRich`, { code, em })}</td>
</tr>
))}
</tbody>
</table>
</div>
<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="/share/host-iscsi-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("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 — Prepare initiator, discover │
│ (nothing touched yet in storage.cfg) │
└──────────────────┬──────────────────────────┘
Dependency check
└─ iscsiadm present? (open-iscsi package)
If missing → apt-get install open-iscsi
+ systemctl enable --now iscsid
Portal entry (manual only)
└─ user types <ip> or <ip>:<port>
If no ':' → ProxMenux appends ":3260"
Reachability validation
├─ ping -c 1 -W 3 <host> ── fail → abort
└─ nc -z -w 3 <host> <port> ── warn but continue
(iSCSI over alternative ports may block nc)
Target discovery
iscsiadm --mode discovery --type sendtargets \\
--portal <ip:port>
Extracts IQNs from stdout (lines matching ^iqn\\.)
Target selection
├─ 1 target found → auto-selected
└─ 2+ targets → menu
Storage ID
(default derived from last ':' segment of the IQN:
"iscsi-<suffix-up-to-20-chars>")
Content type (fixed — not a checklist)
└─ images iSCSI exposes block devices, so
only 'images' makes sense. No
backup/iso/vztmpl/rootdir/snippets.
┌──────── Cancel OR Confirm ────┐
▼ ▼
Exit, nothing ┌─────────────────┴─────────────────┐
was changed │ PHASE 2 — Register in Proxmox │
└─────────────────┬─────────────────┘
If storage ID already exists:
└─ ask "remove and recreate?"
└─ yes → pvesm remove <id>
└─ no → abort
pvesm add iscsi <id> \\
--portal <ip:port> \\
--target <iqn> \\
--content images
iscsid opens a persistent session to
the target; LUNs appear in /dev/disk/
by-path/ip-<ip>:<port>-iscsi-<iqn>-lun-N
Proxmox auto-connects on every boot
via the node.startup=automatic flag
written by pvesm`}
</pre>
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("add.heading")}</h2>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
{addItems.map((_, idx) => (
<li key={idx}>{t.rich(`add.items.${idx}`, { strong, code })}</li>
))}
</ol>
<Callout variant="warning" title={t("add.authTitle")}>
{t("add.authBody1")}
<pre className="mt-2 p-2 rounded bg-white/50 text-xs overflow-x-auto"><code>cat /etc/iscsi/initiatorname.iscsi</code></pre>
{t.rich("add.authBody2", { em, code })}
</Callout>
<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={`apt-get install -y open-iscsi
systemctl enable --now iscsid
# 1. discover targets on a portal
iscsiadm --mode discovery --type sendtargets \\
--portal 10.0.0.60:3260
# 2. register it in Proxmox
pvesm add iscsi myiscsi \\
--portal 10.0.0.60:3260 \\
--target iqn.2024-08.com.truenas:proxmox-pool \\
--content images
# 3. verify + see the block devices
pvesm status myiscsi
ls -la /dev/disk/by-path/ | grep iscsi`} />
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("view.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("view.body", { code })}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("remove.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("remove.body", { code, strong })}</p>
<Callout variant="warning" title={t("remove.warnTitle")}>
{t("remove.warnBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("test.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("test.body", { code })}</p>
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.portalTitle")}>
{t.rich("troubleshoot.portalBody", { em })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.discoverTitle")}>
{t("troubleshoot.discoverIntro")}
<ul className="mt-2 list-disc list-inside space-y-1">
{discoverItems.map((_, idx) => (
<li key={idx}>{t.rich(`troubleshoot.discoverItems.${idx}`, { strong })}</li>
))}
</ul>
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.noTargetTitle")}>
{t("troubleshoot.noTargetBody")}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.noLunTitle")}>
{t.rich("troubleshoot.noLunBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.chapTitle")}>
{t.rich("troubleshoot.chapBody", { 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 mb-4 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,351 @@
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.storageShare.hostLocalDisk.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/storage-share/host-local-disk",
},
}
}
type CompareRow = { label: string; dir?: string; zfs?: string; dirRich?: string; zfsRich?: string }
type StringItem = string
type PresetRow = { preset: string; content: string; use?: string; useRich?: string }
type RelatedItem = { href: string; label: string; tail?: string }
export default async function HostLocalDiskPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.storageShare.hostLocalDisk" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { storageShare: { hostLocalDisk: {
compare: { rows: CompareRow[] }
format: { items: StringItem[] }
reuse: { items: StringItem[] }
presets: { rows: PresetRow[] }
troubleshoot: { noDisksItems: StringItem[] }
related: { items: RelatedItem[] }
} } }
}
const compareRows = messages.docs.storageShare.hostLocalDisk.compare.rows
const formatItems = messages.docs.storageShare.hostLocalDisk.format.items
const reuseItems = messages.docs.storageShare.hostLocalDisk.reuse.items
const presetRows = messages.docs.storageShare.hostLocalDisk.presets.rows
const noDisksItems = messages.docs.storageShare.hostLocalDisk.troubleshoot.noDisksItems
const relatedItems = messages.docs.storageShare.hostLocalDisk.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={10}
scriptPath="share/disk_host.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { em, strong })}
</Callout>
<Callout variant="danger" title={t("destructive.title")}>
{t.rich("destructive.body", { em, strong, code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("compare.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">&nbsp;</th>
<th className="px-4 py-2 font-semibold">{t("compare.headerDir")}</th>
<th className="px-4 py-2 font-semibold">{t("compare.headerZfs")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-800">
{compareRows.map((row, idx) => (
<tr key={row.label}>
<td className="px-4 py-2 font-semibold">{row.label}</td>
<td className="px-4 py-2">{row.dirRich ? t.rich(`compare.rows.${idx}.dirRich`, { code }) : row.dir}</td>
<td className="px-4 py-2">{row.zfsRich ? t.rich(`compare.rows.${idx}.zfsRich`, { code }) : row.zfs}</td>
</tr>
))}
</tbody>
</table>
</div>
<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="/share/host-local-disk-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("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, inspect, plan │
│ (nothing touched yet) │
└──────────────────┬──────────────────────────┘
Dependency check
└─ parted / mkfs.ext4 / mkfs.xfs / blkid /
lsblk / sgdisk present?
If any missing → apt-get install
parted e2fsprogs util-linux
xfsprogs gdisk btrfs-progs
Disk detection (lsblk -dn -e 7,11)
Safety filter
├─ Hidden: type != "disk" (skip partitions)
├─ Hidden: read-only (ro=1)
├─ Hidden: /dev/zd* (ZFS volumes, not disks)
├─ Hidden: used by host storage
│ (root pool, mounted paths, ZFS/LVM)
└─ Hidden: referenced by any VM/LXC config
User selects a disk
(menu shows disk path + size + model)
Disk inspection (blkid / lsblk)
├─ Has data → offer 2 actions:
│ ├─ Format disk (ERASE all)
│ └─ Use existing filesystem
└─ Empty → only "Format disk"
If "Format" was chosen:
Filesystem picker
├─ ext4 → dir storage (recommended general use)
├─ xfs → dir storage (large files / VMs)
├─ btrfs → dir storage (snapshots / compression)
└─ zfs → ZFS POOL storage (different path)
Storage ID (default: "disk-<device>")
Mount path (default: "/mnt/<storage-id>")
Content types (4 presets + custom):
├─ 1. VM Storage → images,backup
├─ 2. Standard NAS → backup,iso,vztmpl
├─ 3. All types → images,backup,iso,vztmpl,snippets
└─ 4. Custom → free CSV input
┌──────── Cancel OR Confirm ────┐
▼ ▼
Exit, nothing ┌─────────────────┴─────────────────┐
was changed │ PHASE 2 — Execute │
└─────────────────┬─────────────────┘
FORMAT PATH (destructive):
├─ Final "ERASE confirmation" dialog
│ → Cancel exits here
├─ wipefs + sgdisk --zap-all
├─ parted/sgdisk: create partition
├─ ZFS pre-flight:
│ • zpool command present?
│ • pool name not already in use?
├─ mkfs.<fs> / zpool create
│ (mkfs.ext4 / xfs / btrfs / zfs pool)
├─ Non-ZFS: mount -t <fs> + UUID
│ entry in /etc/fstab with
│ defaults,nofail
└─ ZFS: zpool manages its own mount
REUSE PATH (existing fs):
├─ blkid detects filesystem type
├─ mkdir mount point
├─ mount <disk> <path>
└─ UUID entry in /etc/fstab
Register in Proxmox:
├─ filesystem == zfs →
│ pvesm add zfspool <id> \\
│ --pool <id> \\
│ --content <csv>
└─ otherwise →
pvesm add dir <id> \\
--path <mount-path> \\
--content <csv>
Summary + "visible in Datacenter →
Storage" confirmation`}
</pre>
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("format.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("format.intro")}</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
{formatItems.map((_, idx) => (
<li key={idx}>{t.rich(`format.items.${idx}`, { code, strong })}</li>
))}
</ol>
<Callout variant="tip" title={t("format.tipTitle")}>
{t.rich("format.tipBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("reuse.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("reuse.intro")}</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
{reuseItems.map((_, idx) => (
<li key={idx}>{t.rich(`reuse.items.${idx}`, { code, strong })}</li>
))}
</ol>
<Callout variant="warning" title={t("reuse.warnTitle")}>
{t.rich("reuse.warnBody", { em, code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("presets.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("presets.intro", { code })}</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("presets.headerPreset")}</th>
<th className="px-4 py-2 font-semibold">{t("presets.headerContent")}</th>
<th className="px-4 py-2 font-semibold">{t("presets.headerUse")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-800">
{presetRows.map((row, idx) => (
<tr key={row.preset}>
<td className="px-4 py-2 font-semibold">{row.preset}</td>
<td className="px-4 py-2 font-mono">{row.content}</td>
<td className="px-4 py-2">
{row.useRich ? t.rich(`presets.rows.${idx}.useRich`, { code }) : row.use}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="info" title={t("presets.zfsTitle")}>
{t.rich("presets.zfsBody", { strong, code })}
</Callout>
<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.extIntro")}</p>
<CopyableCode code={`# 1. prerequisites (one-time)
apt-get install -y parted e2fsprogs xfsprogs gdisk btrfs-progs
# 2. wipe + partition
wipefs -af /dev/sdX
sgdisk --zap-all /dev/sdX
sgdisk -n 1:0:0 -t 1:8300 /dev/sdX
# 3. format
mkfs.ext4 -L mydisk /dev/sdX1
# 4. mount + fstab (by UUID, nofail)
mkdir -p /mnt/mydisk
UUID=$(blkid -s UUID -o value /dev/sdX1)
echo "UUID=$UUID /mnt/mydisk ext4 defaults,nofail 0 2" >> /etc/fstab
mount /mnt/mydisk
# 5. register in Proxmox
pvesm add dir mydisk \\
--path /mnt/mydisk \\
--content images,backup`} />
<p className="mb-3 mt-6 text-gray-800 leading-relaxed">{t("manual.zfsIntro")}</p>
<CopyableCode code={`# 1. create the pool on the raw disk (no partition step needed)
zpool create -o ashift=12 tank /dev/sdX
# 2. register in Proxmox
pvesm add zfspool tank \\
--pool tank \\
--content images,rootdir
pvesm status tank`} />
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("view.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("view.body", { code })}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("remove.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("remove.body", { code, strong })}</p>
<Callout variant="warning" title={t("remove.warnTitle")}>
{t("remove.warnBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("list.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("list.body", { code })}</p>
<h2 className="text-2xl font-semibold mt-12 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", { em, code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.mountedTitle")}>
{t.rich("troubleshoot.mountedBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.zpoolTitle")}>
{t.rich("troubleshoot.zpoolBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.inactiveTitle")}>
{t.rich("troubleshoot.inactiveBody", { 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 mb-4 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,261 @@
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.storageShare.hostLocalShared.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/storage-share/host-local-shared",
},
}
}
type StringItem = string
type BitsRow = { bit: string; effect: string; why: string }
type RelatedItem = { href: string; label: string; tail?: string; tailRich?: string }
export default async function HostLocalSharedPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.storageShare.hostLocalShared" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { storageShare: { hostLocalShared: {
why: { items: StringItem[] }
bits: { rows: BitsRow[] }
related: { items: RelatedItem[] }
} } }
}
const whyItems = messages.docs.storageShare.hostLocalShared.why.items
const bitsRows = messages.docs.storageShare.hostLocalShared.bits.rows
const relatedItems = messages.docs.storageShare.hostLocalShared.related.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const kbd = (chunks: React.ReactNode) => <kbd>{chunks}</kbd>
const mountLink = (chunks: React.ReactNode) => (
<Link href="/docs/storage-share/lxc-mount-points" className="text-blue-700 hover:underline">{chunks}</Link>
)
const diskLink = (chunks: React.ReactNode) => (
<Link href="/docs/storage-share/host-local-disk" className="text-blue-600 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={3}
scriptPath="share/local-shared-manager.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { strong, code, mountLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("why.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("why.intro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{whyItems.map((_, idx) => (
<li key={idx}>{t.rich(`why.items.${idx}`, { strong })}</li>
))}
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("why.outro", { strong })}</p>
<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", { strong })}</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 the target path │
│ (nothing touched yet) │
└──────────────────┬──────────────────────────┘
Location picker (4 options)
├─ 1. Create new folder in /mnt
│ ProxMenux suggests a free name
│ ("shared", "shared2", "shared3"…)
├─ 2. Enter custom path
│ Any absolute path on the host
├─ 3. View existing folders in /mnt
│ Read-only summary (perms, owner,
│ free space) then back to menu
└─ 4. Cancel
Path validation
└─ Must start with "/" (absolute path)
Non-absolute → reject, re-ask
Existing directory?
└─ If /mnt/<name> already exists, ask
"Continue with permission setup?"
(adjusting existing dir is allowed)
┌──────── Cancel OR Confirm ────┐
▼ ▼
Exit, nothing ┌─────────────────┴─────────────────┐
was changed │ PHASE 2 — Create + set perms │
└─────────────────┬─────────────────┘
mkdir -p <target>
chown root:root <target>
chmod 1777 <target>
(sticky bit + world-rwx)
chmod -R a+rwX <target>
(existing content stays accessible;
X = execute only on directories)
find <target> -type d \\
-exec chmod 1777 {} +
(propagate sticky bit to subdirs)
setfacl -b -R <target>
(remove any restrictive ACLs)
setfacl -R -m u::rwx,g::rwx,o::rwx,m::rwx
(explicit rwx for user/group/other/mask)
setfacl -R -m d:u::rwx,d:g::rwx,...
(default ACLs so NEW files inherit rwx)
Register in ProxMenux share map
(pmx_share_map_set <dir> "open")
Summary:
• directory path
• permissions: 1777 (rwxrwxrwt)
• owner: root:root
• ACL: open rwx + default inheritance
• profile: works with priv and
unprivileged LXCs`}
</pre>
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("bits.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("bits.intro", { strong, code })}
</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("bits.headerBit")}</th>
<th className="px-4 py-2 font-semibold">{t("bits.headerEffect")}</th>
<th className="px-4 py-2 font-semibold">{t("bits.headerWhy")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-800">
{bitsRows.map((row) => (
<tr key={row.bit}>
<td className="px-4 py-2 font-mono">{row.bit}</td>
<td className="px-4 py-2">{row.effect}</td>
<td className="px-4 py-2">{row.why}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="info" title={t("bits.privTitle")}>
{t.rich("bits.privBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("where.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("where.intro")}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 my-6">
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("where.opt1Title")}</h3>
<p className="text-sm text-gray-700 leading-relaxed">{t.rich("where.opt1Body", { code })}</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("where.opt2Title")}</h3>
<p className="text-sm text-gray-700 leading-relaxed">{t.rich("where.opt2Body", { code })}</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("where.opt3Title")}</h3>
<p className="text-sm text-gray-700 leading-relaxed">{t.rich("where.opt3Body", { code })}</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("where.opt4Title")}</h3>
<p className="text-sm text-gray-700 leading-relaxed">{t.rich("where.opt4Body", { kbd })}</p>
</div>
</div>
<Callout variant="tip" title={t("where.tipTitle")}>
{t.rich("where.tipBody", { code, diskLink })}
</Callout>
<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={`# 1. create the directory
mkdir -p /mnt/shared
# 2. apply 1777 (sticky + world-rwx) + open existing content
chown root:root /mnt/shared
chmod 1777 /mnt/shared
chmod -R a+rwX /mnt/shared
find /mnt/shared -type d -exec chmod 1777 {} +
# 3. ACLs: explicit rwx + default inheritance for new files
setfacl -b -R /mnt/shared
setfacl -R -m u::rwx,g::rwx,o::rwx,m::rwx /mnt/shared
setfacl -R -m d:u::rwx,d:g::rwx,d:o::rwx,d:m::rwx /mnt/shared`} />
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("next.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("next.body", { strong, code, mountLink })}
</p>
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.mkdirTitle")}>
{t.rich("troubleshoot.mkdirBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.writeTitle")}>
{t.rich("troubleshoot.writeBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.aclTitle")}>
{t.rich("troubleshoot.aclBody", { 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 mb-4 text-gray-800 leading-relaxed space-y-1">
{relatedItems.map((item, idx) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tailRich ? t.rich(`related.items.${idx}.tailRich`, { code }) : item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,332 @@
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.storageShare.hostNfs.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/storage-share/host-nfs",
},
}
}
type StringItem = string
type ModesRow = { method: string; mount?: string; mountRich?: string; ui: string; useCase?: string; useCaseRich?: string }
type ContentRow = { type: string; allows?: string; allowsRich?: string }
type RelatedItem = { href: string; label: string; tail?: string; tailRich?: string }
export default async function HostNfsPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.storageShare.hostNfs" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { storageShare: { hostNfs: {
modes: { rows: ModesRow[] }
pvesmBranch: { items: StringItem[]; rows: ContentRow[] }
fstabBranch: { items: StringItem[]; applies: StringItem[] }
related: { items: RelatedItem[] }
} } }
}
const modesRows = messages.docs.storageShare.hostNfs.modes.rows
const pvesmItems = messages.docs.storageShare.hostNfs.pvesmBranch.items
const contentRows = messages.docs.storageShare.hostNfs.pvesmBranch.rows
const fstabItems = messages.docs.storageShare.hostNfs.fstabBranch.items
const fstabAppliesItems = messages.docs.storageShare.hostNfs.fstabBranch.applies
const relatedItems = messages.docs.storageShare.hostNfs.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 mountLink = (chunks: React.ReactNode) => (
<Link href="/docs/storage-share/lxc-mount-points" className="text-blue-700 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={6}
scriptPath="share/nfs_host.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code, 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="/share/host-nfs-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("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 — Discover, validate, choose │
│ (nothing touched yet) │
└──────────────────┬──────────────────────────┘
Dependency check
└─ nfs-common present? (showmount)
If missing → apt-get install nfs-common
Server selection
├─ Auto-discover (nmap -p 2049 on /24)
└─ Manual (type IP or hostname)
Reachability + showmount validation
Export selection
╔═════════════════════════════════════╗
║ MOUNT METHOD PICKER (checklist) ║
║ [ ] As Proxmox storage (pvesm) ║
║ [ ] As host fstab mount only ║
║ (mark one or both — re-prompts ║
║ if you press OK without marks) ║
╚════════════════╤════════════════════╝
┌──────────────┴──────────────┐
▼ ▼
pvesm branch fstab branch
├─ storage ID ├─ mount path
├─ content types └─ mount options
▼ ▼
┌─────────────────────────────────────────┐
│ PHASE 2 — Apply (only marked methods) │
└──────────────────┬──────────────────────┘
pvesm add nfs <id> ... + mkdir -p <path>
(auto-mount at mount -t nfs ...
/mnt/pve/<id>) append /etc/fstab
systemctl daemon-reload
chmod 1777 + setfacl
(best-effort, NFS server-side)
Summary printed`}
</pre>
<h2 className="text-2xl font-semibold mt-12 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.headerMethod")}</th>
<th className="px-4 py-2 font-semibold">{t("modes.headerMount")}</th>
<th className="px-4 py-2 font-semibold">{t("modes.headerUi")}</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 align-top">
{modesRows.map((row, idx) => (
<tr key={idx}>
<td className="px-4 py-2">{t.rich(`modes.rows.${idx}.method`, { strong })}</td>
<td className="px-4 py-2">
{row.mountRich ? t.rich(`modes.rows.${idx}.mountRich`, { code }) : row.mount}
</td>
<td className="px-4 py-2">{row.ui}</td>
<td className="px-4 py-2">
{row.useCaseRich ? t.rich(`modes.rows.${idx}.useCaseRich`, { em }) : row.useCase}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="info" title={t("modes.bothTitle")}>
{t.rich("modes.bothBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("pvesmBranch.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("pvesmBranch.intro", { em })}
</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
{pvesmItems.map((_, idx) => (
<li key={idx}>{t.rich(`pvesmBranch.items.${idx}`, { strong, code })}</li>
))}
</ol>
<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("pvesmBranch.headerType")}</th>
<th className="px-4 py-2 font-semibold">{t("pvesmBranch.headerAllows")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-800">
{contentRows.map((row, idx) => (
<tr key={row.type}>
<td className="px-4 py-2 font-mono">{row.type}</td>
<td className="px-4 py-2">
{row.allowsRich
? t.rich(`pvesmBranch.rows.${idx}.allowsRich`, { em, code })
: row.allows}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="warning" title={t("pvesmBranch.warnTitle")}>
{t.rich("pvesmBranch.warnBody", { code })}
</Callout>
<p className="mb-4 mt-4 text-gray-800 leading-relaxed">
{t.rich("pvesmBranch.result", { code })}
</p>
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("fstabBranch.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("fstabBranch.intro", { em, code })}
</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
{fstabItems.map((_, idx) => (
<li key={idx}>{t.rich(`fstabBranch.items.${idx}`, { strong, em, code })}</li>
))}
</ol>
<p className="mb-3 text-gray-800 leading-relaxed">{t("fstabBranch.appliesIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{fstabAppliesItems.map((_, idx) => (
<li key={idx}>{t.rich(`fstabBranch.applies.${idx}`, { code })}</li>
))}
</ul>
<Callout variant="info" title={t("fstabBranch.lxcTitle")}>
{t.rich("fstabBranch.lxcBody", { code, strong, mountLink })}
</Callout>
<Callout variant="warning" title={t("fstabBranch.noUiTitle")}>
{t.rich("fstabBranch.noUiBody", { em })}
</Callout>
<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.pvesmIntro")}</p>
<CopyableCode code={`apt-get install -y nfs-common # one-time: NFS client tools
pvesm add nfs mynfs \\
--server 10.0.0.50 \\
--export /export/proxmox \\
--content import,backup,iso
pvesm status mynfs # verify it's active
ls -la /mnt/pve/mynfs # Proxmox auto-mounts here`} />
<p className="mb-3 mt-6 text-gray-800 leading-relaxed">{t("manual.fstabIntro")}</p>
<CopyableCode code={`apt-get install -y nfs-common # one-time
mkdir -p /mnt/data
mount -t nfs -o "rw,hard,nofail,_netdev,rsize=131072,wsize=131072,timeo=600,retrans=2" \\
10.0.0.50:/export/proxmox /mnt/data
# Persist
echo "10.0.0.50:/export/proxmox /mnt/data nfs rw,hard,nofail,_netdev,rsize=131072,wsize=131072,timeo=600,retrans=2 0 0" \\
>> /etc/fstab
systemctl daemon-reload
# Best-effort open perms for LXC bind-mount writes (server permitting)
chmod 1777 /mnt/data 2>/dev/null || true
setfacl -m o::rwx /mnt/data 2>/dev/null || true
# Bind into an unprivileged LXC (host-side perms only — no changes inside CT)
pct set <ctid> -mp0 /mnt/data,mp=/mnt/data,shared=1,backup=0
pct reboot <ctid>`} />
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("view.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("view.body", { code, strong })}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("remove.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("remove.body", { code, strong })}</p>
<Callout variant="warning" title={t("remove.warnTitle")}>
{t("remove.warnBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("test.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("test.body", { code, em })}</p>
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.noServersTitle")}>
{t.rich("troubleshoot.noServersBody", { code, em })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.portTitle")}>
{t.rich("troubleshoot.portBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.showmountTitle")}>
{t.rich("troubleshoot.showmountBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.inactiveTitle")}>
{t.rich("troubleshoot.inactiveBody", { em, code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.lxcNoWriteTitle")}>
{t.rich("troubleshoot.lxcNoWriteBody", { code, strong })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.fstabBootTitle")}>
{t.rich("troubleshoot.fstabBootBody", { 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 mb-4 text-gray-800 leading-relaxed space-y-1">
{relatedItems.map((item, idx) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tailRich ? t.rich(`related.items.${idx}.tailRich`, { code }) : item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,355 @@
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.storageShare.hostSamba.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/storage-share/host-samba",
},
}
}
type StringItem = string
type ModesRow = { method: string; mount?: string; mountRich?: string; ui: string; useCase?: string; useCaseRich?: string }
type ContentRow = { type: string; allows?: string; allowsRich?: string }
type RelatedItem = { href: string; label: string; tail?: string; tailRich?: string }
export default async function HostSambaPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.storageShare.hostSamba" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { storageShare: { hostSamba: {
modes: { rows: ModesRow[] }
pvesmBranch: { items: StringItem[]; rows: ContentRow[] }
fstabBranch: { items: StringItem[]; applies: StringItem[] }
related: { items: RelatedItem[] }
} } }
}
const modesRows = messages.docs.storageShare.hostSamba.modes.rows
const pvesmItems = messages.docs.storageShare.hostSamba.pvesmBranch.items
const contentRows = messages.docs.storageShare.hostSamba.pvesmBranch.rows
const fstabItems = messages.docs.storageShare.hostSamba.fstabBranch.items
const fstabAppliesItems = messages.docs.storageShare.hostSamba.fstabBranch.applies
const relatedItems = messages.docs.storageShare.hostSamba.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 mountLink = (chunks: React.ReactNode) => (
<Link href="/docs/storage-share/lxc-mount-points" className="text-blue-700 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={7}
scriptPath="share/samba_host.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code, 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="/share/host-samba-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("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 — Discover, validate, choose │
│ (nothing touched yet) │
└──────────────────┬──────────────────────────┘
Server discovery (nmap 139/445 + nmblookup)
Authentication (User or Guest)
Share selection (smbclient -L)
╔═════════════════════════════════════╗
║ MOUNT METHOD PICKER (checklist) ║
║ [ ] As Proxmox storage (pvesm) ║
║ [ ] As host fstab mount only ║
║ (mark one or both — re-prompts ║
║ if you press OK without marks) ║
╚════════════════╤════════════════════╝
┌──────────────┴──────────────┐
▼ ▼
pvesm branch fstab branch
├─ storage ID ├─ mount path
├─ content types ├─ mount options
└─ (User) write
/etc/samba/credentials/...cred
(mode 0600)
▼ ▼
┌─────────────────────────────────────────┐
│ PHASE 2 — Apply (only marked methods) │
└──────────────────┬──────────────────────┘
pvesm add cifs <id> ... + mkdir -p <path>
(auto-mount at mount -t cifs ...
/mnt/pve/<id> with (uid=0,gid=0,
default options) file_mode=0777,
dir_mode=0777)
append /etc/fstab
systemctl daemon-reload
Summary printed`}
</pre>
<h2 className="text-2xl font-semibold mt-12 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.headerMethod")}</th>
<th className="px-4 py-2 font-semibold">{t("modes.headerMount")}</th>
<th className="px-4 py-2 font-semibold">{t("modes.headerUi")}</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 align-top">
{modesRows.map((row, idx) => (
<tr key={idx}>
<td className="px-4 py-2">{t.rich(`modes.rows.${idx}.method`, { strong })}</td>
<td className="px-4 py-2">
{row.mountRich ? t.rich(`modes.rows.${idx}.mountRich`, { code }) : row.mount}
</td>
<td className="px-4 py-2">{row.ui}</td>
<td className="px-4 py-2">
{row.useCaseRich ? t.rich(`modes.rows.${idx}.useCaseRich`, { em }) : row.useCase}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="info" title={t("modes.bothTitle")}>
{t.rich("modes.bothBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("pvesmBranch.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("pvesmBranch.intro", { em })}
</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
{pvesmItems.map((_, idx) => (
<li key={idx}>{t.rich(`pvesmBranch.items.${idx}`, { strong, em, code })}</li>
))}
</ol>
<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("pvesmBranch.headerType")}</th>
<th className="px-4 py-2 font-semibold">{t("pvesmBranch.headerAllows")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-800">
{contentRows.map((row, idx) => (
<tr key={row.type}>
<td className="px-4 py-2 font-mono">{row.type}</td>
<td className="px-4 py-2">
{row.allowsRich
? t.rich(`pvesmBranch.rows.${idx}.allowsRich`, { code, strong })
: row.allows}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="warning" title={t("pvesmBranch.warnTitle")}>
{t.rich("pvesmBranch.warnBody", { code })}
</Callout>
<Callout variant="info" title={t("pvesmBranch.credsTitle")}>
{t.rich("pvesmBranch.credsBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("fstabBranch.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("fstabBranch.intro", { em, code })}
</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
{fstabItems.map((_, idx) => (
<li key={idx}>{t.rich(`fstabBranch.items.${idx}`, { strong, em, code })}</li>
))}
</ol>
<Callout variant="info" title={t("fstabBranch.credsTitle")}>
{t.rich("fstabBranch.credsBody", { code })}
</Callout>
<p className="mb-3 text-gray-800 leading-relaxed">{t("fstabBranch.appliesIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{fstabAppliesItems.map((_, idx) => (
<li key={idx}>{t.rich(`fstabBranch.applies.${idx}`, { code })}</li>
))}
</ul>
<Callout variant="info" title={t("fstabBranch.lxcTitle")}>
{t.rich("fstabBranch.lxcBody", { code, strong, mountLink })}
</Callout>
<Callout variant="warning" title={t("fstabBranch.noUiTitle")}>
{t.rich("fstabBranch.noUiBody", { em })}
</Callout>
<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.pvesmIntro")}</p>
<CopyableCode code={`apt-get install -y cifs-utils smbclient # one-time: SMB client tools
# with user authentication
pvesm add cifs mycifs \\
--server 10.0.0.50 \\
--share proxmox \\
--username backup_user \\
--password 's3cret' \\
--content backup,iso
# guest access (no credentials)
pvesm add cifs mycifs-guest \\
--server 10.0.0.50 \\
--share public \\
--content iso,vztmpl
pvesm status mycifs # verify it's active
ls -la /mnt/pve/mycifs # Proxmox auto-mounts here`} />
<p className="mb-3 mt-6 text-gray-800 leading-relaxed">{t("manual.fstabUserIntro")}</p>
<CopyableCode code={`# 1. credentials file (root-only)
mkdir -p /etc/samba/credentials && chmod 0700 /etc/samba/credentials
cat > /etc/samba/credentials/nas01_share.cred <<'EOF'
username=admin
password=s3cret
EOF
chmod 0600 /etc/samba/credentials/nas01_share.cred
# 2. mount with open uid/gid/file_mode (for unpriv LXC bind-mounts)
mkdir -p /mnt/data
mount -t cifs //10.0.0.50/share /mnt/data \\
-o "rw,uid=0,gid=0,file_mode=0777,dir_mode=0777,iocharset=utf8,nofail,_netdev,credentials=/etc/samba/credentials/nas01_share.cred"
# 3. persist
echo "//10.0.0.50/share /mnt/data cifs rw,uid=0,gid=0,file_mode=0777,dir_mode=0777,iocharset=utf8,nofail,_netdev,credentials=/etc/samba/credentials/nas01_share.cred 0 0" \\
>> /etc/fstab
systemctl daemon-reload
# 4. bind into an unpriv LXC (no changes inside the CT)
pct set <ctid> -mp0 /mnt/data,mp=/mnt/data,shared=1,backup=0
pct reboot <ctid>`} />
<p className="mb-3 mt-6 text-gray-800 leading-relaxed">{t("manual.fstabGuestIntro")}</p>
<CopyableCode code={`mkdir -p /mnt/public
mount -t cifs //10.0.0.50/public /mnt/public \\
-o "rw,uid=0,gid=0,file_mode=0777,dir_mode=0777,iocharset=utf8,nofail,_netdev,guest"
echo "//10.0.0.50/public /mnt/public cifs rw,uid=0,gid=0,file_mode=0777,dir_mode=0777,iocharset=utf8,nofail,_netdev,guest 0 0" \\
>> /etc/fstab
systemctl daemon-reload`} />
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("view.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("view.body", { code, em, strong })}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("remove.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("remove.body", { code, strong })}</p>
<Callout variant="warning" title={t("remove.warnTitle")}>
{t("remove.warnBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("test.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("test.body", { code })}</p>
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.noServersTitle")}>
{t.rich("troubleshoot.noServersBody", { code, em })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.noSharesTitle")}>
{t.rich("troubleshoot.noSharesBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.denyTitle")}>
{t.rich("troubleshoot.denyBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.sleepTitle")}>
{t.rich("troubleshoot.sleepBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.lxcNoWriteTitle")}>
{t.rich("troubleshoot.lxcNoWriteBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.fstabBootTitle")}>
{t.rich("troubleshoot.fstabBootBody", { 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 mb-4 text-gray-800 leading-relaxed space-y-1">
{relatedItems.map((item, idx) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tailRich ? t.rich(`related.items.${idx}.tailRich`, { code }) : item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,422 @@
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 { DataFlowDiagram } from "@/components/ui/data-flow-diagram"
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.storageShare.lxcMountPoints.meta" })
return {
title: t("title"),
description: t("description"),
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://macrimi.github.io/ProxMenux/docs/storage-share/lxc-mount-points",
},
}
}
type StringItem = string
type SourceRow = { source: string; where?: string; whereRich?: string; labelRich: string }
type StringList = string[]
type RelatedItem = {
href: string
label: string
extraHref?: string
extraLabel?: string
joiner?: string
tail?: string
tailRich?: string
}
export default async function LxcMountPointsPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.storageShare.lxcMountPoints" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { storageShare: { lxcMountPoints: {
bigPicture: { items: StringItem[] }
sources: { rows: SourceRow[] }
troubleshoot: { nfsItems: StringList }
related: { items: RelatedItem[] }
} } }
}
const bigPictureItems = messages.docs.storageShare.lxcMountPoints.bigPicture.items
const sourceRows = messages.docs.storageShare.lxcMountPoints.sources.rows
const nfsItems = messages.docs.storageShare.lxcMountPoints.troubleshoot.nfsItems
const relatedItems = messages.docs.storageShare.lxcMountPoints.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={10}
scriptPath="share/lxc-mount-manager_minimal.sh"
/>
<Callout variant="info" title={t("intro.title")}>
{t("intro.body")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("bigPicture.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("bigPicture.intro", { code, em })}
</p>
<DataFlowDiagram
nodes={[
{ label: t("bigPicture.sourceLabel"), detail: t("bigPicture.sourceDetail"), variant: "source" },
{ label: t("bigPicture.targetLabel"), detail: t("bigPicture.targetDetail"), variant: "target" },
]}
arrowLabel={t("bigPicture.arrowLabel")}
bidirectional
command={`# What the script writes:
pct set <ctid> -mpN /mnt/data, mp=/mnt/data, shared=1, backup=0`}
/>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("bigPicture.outro", { code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{bigPictureItems.map((_, idx) => (
<li key={idx}>{t.rich(`bigPicture.items.${idx}`, { code })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("perms.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("perms.intro", { strong, em })}
</p>
<div className="overflow-x-auto my-6 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 align-top">{t("perms.headerType")}</th>
<th className="px-4 py-2 font-semibold align-top">{t("perms.headerAction")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-800 align-top">
<tr>
<td className="px-4 py-3">
<div className="font-semibold whitespace-nowrap">{t("perms.localType")}</div>
<div className="text-xs text-gray-600 mt-1 font-mono">{t("perms.localTypeSub")}</div>
</td>
<td className="px-4 py-3">{t.rich("perms.localActionRich", { code })}</td>
</tr>
<tr>
<td className="px-4 py-3">
<div className="font-semibold whitespace-nowrap">{t("perms.cifsType")}</div>
<div className="text-xs text-gray-600 mt-1 font-mono">{t("perms.cifsTypeSub")}</div>
</td>
<td className="px-4 py-3">{t.rich("perms.cifsActionRich", { code })}</td>
</tr>
<tr>
<td className="px-4 py-3">
<div className="font-semibold whitespace-nowrap">{t("perms.nfsType")}</div>
<div className="text-xs text-gray-600 mt-1 font-mono">{t("perms.nfsTypeSub")}</div>
</td>
<td className="px-4 py-3">{t.rich("perms.nfsActionRich", { code })}</td>
</tr>
</tbody>
</table>
</div>
<Callout variant="info" title={t("perms.privTitle")}>
{t("perms.privBody")}
</Callout>
<Callout variant="warning" title={t("perms.noCtTitle")}>
{t.rich("perms.noCtBody", { strong, code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("writes.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("writes.intro", { code, strong })}
</p>
<CopyableCode
code={`# /etc/pve/lxc/545.conf — single line added by the script
mp0: /mnt/NAS/hdd_cache,mp=/mnt/NAS/hdd_cache,shared=1,backup=0`}
className="my-4"
/>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("writes.outro", { em })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("writes.twoWaysHeading")}</h3>
<div className="overflow-x-auto my-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 align-top">{t("writes.headerApproach")}</th>
<th className="px-4 py-2 font-semibold align-top">{t("writes.headerChanges")}</th>
<th className="px-4 py-2 font-semibold align-top">{t("writes.headerWhen")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-800 align-top">
<tr>
<td className="px-4 py-3 whitespace-nowrap">
<div className="font-semibold">{t("writes.hostType")}</div>
<div className="text-xs text-gray-600 mt-1">{t("writes.hostTypeSub")}</div>
</td>
<td className="px-4 py-3">{t.rich("writes.hostChangesRich", { code, em })}</td>
<td className="px-4 py-3">{t("writes.hostWhen")}</td>
</tr>
<tr>
<td className="px-4 py-3 whitespace-nowrap">
<div className="font-semibold">{t.rich("writes.idmapTypeRich", { code })}</div>
<div className="text-xs text-gray-600 mt-1">{t("writes.idmapTypeSub")}</div>
</td>
<td className="px-4 py-3">{t.rich("writes.idmapChangesRich", { code })}</td>
<td className="px-4 py-3">{t.rich("writes.idmapWhenRich", { em, code })}</td>
</tr>
</tbody>
</table>
</div>
<Callout variant="tip" title={t("writes.idmapTipTitle")}>
{t.rich("writes.idmapTipBody", { code })}
</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="/share/lxc-mount-points-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("addFlow.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("addFlow.intro")}</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, host dir, mount point │
│ (nothing touched yet) │
└──────────────────┬──────────────────────────┘
pct list — pick the target container
Unified host-directory picker
Lists every candidate the script can detect:
├─ Mounted CIFS / NFS shares (/proc/mounts)
├─ fstab-inactive network mounts (defined
│ but not currently mounted) — labelled
│ "fstab(off)-"
├─ Local /mnt/* directories
├─ Proxmox-managed storages under /mnt/pve/*
│ (NFS / CIFS shares registered via pvesm)
│ — labelled "PVE-"
└─ "Enter path manually" for anything else
Detect the host directory TYPE
└─ local / cifs / nfs
(drives the permission-fix branch later)
Container mount point picker
├─ Create new directory in /mnt
│ (auto-suggests basename of host dir)
├─ Enter manual path (must be absolute)
└─ Cancel
Validates the path is not already used as
a mount point in this CT.
Detect CT type:
├─ Privileged → no UID shift
└─ Unprivileged → +100000 (default idmap)
ACTIVE FIX FOR THE HOST DIRECTORY
(depends on the type detected earlier)
├─ cifs → offer remount with open uid/gid
├─ nfs → offer chmod + setfacl on share
└─ local → handled AFTER the bind mount
(only if CT is unprivileged)
┌──────── Cancel OR Confirm ────┐
▼ ▼
Exit, nothing ┌─────────────────┴─────────────────┐
was changed │ PHASE 2 — Apply │
└─────────────────┬─────────────────┘
Find next free mpN slot
(scans /etc/pve/lxc/<ctid>.conf)
pct set <ctid> -mpN \\
<host-dir>,
mp=<container-path>,
shared=1, backup=0
For local + unprivileged:
└─ lmm_offer_host_permissions
(chmod o+rwx + ACL on host dir,
only if perms were insufficient)
Offer to restart the container
└─ pct reboot <ctid>
(mounts only become active on
the next CT start)
Verify: pct exec <ctid> -- test -d
<container-path> → "accessible"`}
</pre>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("sources.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("sources.intro", { em })}
</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("sources.headerSource")}</th>
<th className="px-4 py-2 font-semibold">{t("sources.headerWhere")}</th>
<th className="px-4 py-2 font-semibold">{t("sources.headerLabel")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-800">
{sourceRows.map((row, idx) => (
<tr key={row.source}>
<td className="px-4 py-2 font-semibold">{row.source}</td>
<td className="px-4 py-2">
{row.whereRich ? t.rich(`sources.rows.${idx}.whereRich`, { code }) : row.where}
</td>
<td className="px-4 py-2">{t.rich(`sources.rows.${idx}.labelRich`, { code, em })}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="tip" title={t("sources.tipTitle")}>
{t.rich("sources.tipBody", { code })}
</Callout>
<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.privIntro")}</p>
<CopyableCode code={`# 1. add the bind mount to the CT config
pct set 101 -mp0 /mnt/data,mp=/mnt/data,shared=1,backup=0
# 2. restart the CT to activate the mount
pct reboot 101
# 3. verify from inside
pct exec 101 -- ls -la /mnt/data`} />
<p className="mb-3 mt-6 text-gray-800 leading-relaxed">{t("manual.unprivLocalIntro")}</p>
<CopyableCode code={`# host: open the directory for any mapped UID
chmod o+rwx /mnt/data
setfacl -m o::rwx /mnt/data
setfacl -m d:o::rwx /mnt/data # default ACL = applies to new files
# add the bind mount + restart
pct set 102 -mp0 /mnt/data,mp=/mnt/data,shared=1,backup=0
pct reboot 102`} />
<p className="mb-3 mt-6 text-gray-800 leading-relaxed">{t("manual.unprivCifsIntro")}</p>
<CopyableCode code={`# host: remount the CIFS with open uid/gid
umount /mnt/pve/cifs-nas
mount -t cifs //10.0.0.50/share /mnt/pve/cifs-nas \\
-o "username=user,password=pass,uid=0,gid=0,file_mode=0777,dir_mode=0777"
# update /etc/fstab if the mount is persistent
sed -i 's|^\\(//10.0.0.50/share .*cifs \\).*|\\1username=user,password=pass,uid=0,gid=0,file_mode=0777,dir_mode=0777 0 0|' /etc/fstab
# bind mount + restart
pct set 102 -mp0 /mnt/pve/cifs-nas,mp=/mnt/nas,shared=1,backup=0
pct reboot 102`} />
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("view.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("view.body", { code })}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("remove.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("remove.body", { code, strong })}</p>
<Callout variant="warning" title={t("remove.warnTitle")}>
{t.rich("remove.warnBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
<Callout variant="troubleshoot" title={t("troubleshoot.noMountTitle")}>
{t.rich("troubleshoot.noMountBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.noWriteTitle")}>
{t.rich("troubleshoot.noWriteBody", { code })}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.alreadyTitle")}>
{t("troubleshoot.alreadyBody")}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.nfsTitle")}>
{t("troubleshoot.nfsIntro")}
<ul className="mt-2 list-disc list-inside space-y-1">
{nfsItems.map((_, idx) => (
<li key={idx}>{t.rich(`troubleshoot.nfsItems.${idx}`, { code })}</li>
))}
</ul>
{t("troubleshoot.nfsOutro")}
</Callout>
<Callout variant="troubleshoot" title={t("troubleshoot.fstabOffTitle")}>
{t.rich("troubleshoot.fstabOffBody", { 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 mb-4 text-gray-800 leading-relaxed space-y-1">
{relatedItems.map((item, idx) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.extraHref && item.extraLabel && (
<>
{item.joiner}
<Link href={item.extraHref} className="text-blue-600 hover:underline">
{item.extraLabel}
</Link>
</>
)}
{item.tailRich ? t.rich(`related.items.${idx}.tailRich`, { code }) : item.tail}
</li>
))}
</ul>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More