diff --git a/web/.npmrc b/web/.npmrc new file mode 100644 index 00000000..521a9f7c --- /dev/null +++ b/web/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/web/CONTRIBUTING-TRANSLATIONS.md b/web/CONTRIBUTING-TRANSLATIONS.md new file mode 100644 index 00000000..d73ccda0 --- /dev/null +++ b/web/CONTRIBUTING-TRANSLATIONS.md @@ -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/` — English, the source of truth +- `/es/` — 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` (``, `useRouter`, + `usePathname`). Use these instead of `next/link` for internal hrefs so + the active `[locale]` prefix is preserved. +- A language switcher in the navbar (``). +- Automatic message discovery: any JSON file under + `messages//...` 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// 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. +- `.json` at any folder level → keys go under the `` + 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//` (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//.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//.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 (``, ``, +``, ``, ``, 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 Architecture page for details." +} +``` + +```json +{ + "footer": "Mira la página de Architecture para más detalles." +} +``` + +### 4. Test locally + +```bash +cd web +npm run dev +``` + +Open `http://localhost:3000//` 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/): translate +``` + +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: '.meta' })`. +4. Call `setRequestLocale(locale)` near the top of the component body. +5. Call `await getTranslations({ locale, namespace: 'docs.
.' })` + 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": "..." + }, + "": { ... }, + "": { ... } +} +``` + +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 `` or ``, +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) => {chunks}, + strong: (chunks) => {chunks}, + link: (chunks) => ( + + {chunks} + + ), +}) +``` + +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//` 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//common.json` (shared strings) or +`app/[locale]/docs/
/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. diff --git a/web/app/[locale]/changelog/page.tsx b/web/app/[locale]/changelog/page.tsx new file mode 100644 index 00000000..485e50a6 --- /dev/null +++ b/web/app/[locale]/changelog/page.tsx @@ -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 /lang//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 { + 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 "

Error: CHANGELOG.md file not found

" + } + + 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 "

Error: Could not load changelog content.

" + } +} + +// Clean backticks in inline code fragments +function cleanInlineCode(content: string) { + return content.replace(/(.*?)<\/code>/g, (_, codeContent) => { + return `${codeContent.replace(/^`|`$/g, "")}` + }) +} + +// 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 + } + } + }, + }) +} + +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 ( +
+
+

{t("pageTitle")}

+ +
{parsedContent}
+
+
+
+ ) +} diff --git a/web/app/[locale]/docs/about/code-of-conduct/page.tsx b/web/app/[locale]/docs/about/code-of-conduct/page.tsx new file mode 100644 index 00000000..de18716b --- /dev/null +++ b/web/app/[locale]/docs/about/code-of-conduct/page.tsx @@ -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 { + 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 `

${notFoundMsg}

` + } + + 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 `

${loadFailedMsg}

` + } +} + +function cleanInlineCode(content: string) { + return content.replace(/(.*?)<\/code>/g, (_, codeContent) => { + return `${codeContent.replace(/^`|`$/g, "")}` + }) +} + +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 + } + } + }, + }) +} + +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 ( +
+
+
{parsedContent}
+
+
+ ) +} diff --git a/web/app/[locale]/docs/about/contributing/page.tsx b/web/app/[locale]/docs/about/contributing/page.tsx new file mode 100644 index 00000000..7b240e15 --- /dev/null +++ b/web/app/[locale]/docs/about/contributing/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const contributorsLink = (chunks: React.ReactNode) => ( + + {chunks} + + ) + const cocLink = (chunks: React.ReactNode) => ( + + {chunks} + + ) + const licenseLink = (chunks: React.ReactNode) => ( + + {chunks} + + + ) + const securityLink = (chunks: React.ReactNode) => ( + + {chunks} + + + ) + + return ( +
+ + + + {t.rich("twoPagesCallout.body", { contributorsLink, em })} + + +

{t("branching.heading")}

+ +

{t("branching.intro")}

+ +
+ + + + + + + + + {branchingRows.map((row, idx) => ( + + + + + ))} + +
{t("branching.headerBranch")}{t("branching.headerPurpose")}
{row.branch}{t.rich(`branching.rows.${idx}.purposeRich`, { code })}
+
+ + + {t.rich("branching.calloutBody", { code })} + + +

{t("workflow.heading")}

+ +

{t("workflow.intro")}

+ +
    +
  1. + {t.rich("workflow.step1Lead", { code, strong })} + + {t.rich("workflow.step1Trail", { code })} +
  2. +
  3. + {t.rich("workflow.step2Lead", { strong })} + +
  4. +
  5. {t.rich("workflow.step3", { code, strong })}
  6. +
  7. {t.rich("workflow.step4", { code, strong })}
  8. +
  9. {t.rich("workflow.step5", { code, strong })}
  10. +
+ +

{t("scriptHeader.heading")}

+ +

{t.rich("scriptHeader.intro", { strong })}

+ +
    + {scriptHeaderBullets.map((_, idx) => ( +
  • {t.rich(`scriptHeader.bullets.${idx}`, { strong, em })}
  • + ))} +
+ + + {t.rich("scriptHeader.licenseCalloutBody", { strong, code, licenseLink })} + + + + +

{t.rich("scriptHeader.optionalNote", { code })}

+ + + {t("scriptHeader.whyCalloutBody")} + + +

{t("structure.heading")}

+ +

{t("structure.intro")}

+ + + +

{t.rich("structure.outro", { code })}

+ +

{t("twoPhase.heading")}

+ +

{t.rich("twoPhase.intro", { strong })}

+ +
+ + + + + + + + + + {twoPhaseRows.map((_, idx) => ( + + + + + + ))} + +
{t("twoPhase.headerPhase")}{t("twoPhase.headerPurpose")}{t("twoPhase.headerScreen")}
{t.rich(`twoPhase.rows.${idx}.phaseRich`, { strong })}{t.rich(`twoPhase.rows.${idx}.purposeRich`, { code })}{t.rich(`twoPhase.rows.${idx}.screenRich`, { code })}
+
+ +

{t.rich("twoPhase.principle", { strong })}

+ +

{t("twoPhase.phase1Heading")}

+ + + +

{t.rich("twoPhase.phase1RulesIntro", { strong })}

+ +
    + {phase1Rules.map((_, idx) => ( +
  • {t.rich(`twoPhase.phase1Rules.${idx}`, { code })}
  • + ))} +
+ +

{t("twoPhase.phase2Heading")}

+ + + +

{t.rich("twoPhase.phase2Rules", { strong, code })}

+ +

{t.rich("dialogVsWhiptail.headingRich", { code })}

+ +

{t("dialogVsWhiptail.intro")}

+ +
+ + + + + + + + + + {dialogRows.map((_, idx) => ( + + + + + + ))} + +
{t("dialogVsWhiptail.headerTool")}{t("dialogVsWhiptail.headerWhen")}{t("dialogVsWhiptail.headerEffect")}
{t.rich(`dialogVsWhiptail.rows.${idx}.toolRich`, { strong, code })}{t.rich(`dialogVsWhiptail.rows.${idx}.whenRich`, { strong, code })}{t.rich(`dialogVsWhiptail.rows.${idx}.effectRich`, { code, em })}
+
+ + + {t.rich("dialogVsWhiptail.calloutBody", { code })} + + +

{t("dialogVsWhiptail.rebootHeading")}

+ +

{t.rich("dialogVsWhiptail.rebootIntro", { code })}

+ + + +

{t("messageFunctions.heading")}

+ +

{t.rich("messageFunctions.intro", { code })}

+ +
+ + + + + + + + + + {msgRows.map((row, idx) => ( + + + + + + ))} + +
{t("messageFunctions.headerFunction")}{t("messageFunctions.headerWhen")}{t("messageFunctions.headerSpinner")}
{row.function}{t.rich(`messageFunctions.rows.${idx}.whenRich`, { em })}{row.spinner}
+
+ +

{t("dialogConventions.heading")}

+ +
    + {dialogBullets.map((_, idx) => ( +
  • {t.rich(`dialogConventions.bullets.${idx}`, { code })}
  • + ))} +
+ +

{t("dialogConventions.exampleIntro")}

+ + + +

{t("translation.heading")}

+ +

{t.rich("translation.intro", { code })}

+ + + +
    + {translationBullets.map((_, idx) => ( +
  • {t.rich(`translation.bullets.${idx}`, { strong })}
  • + ))} +
+ +

{t("variableStyle.heading")}

+ +
    + {variableStyleBullets.map((_, idx) => ( +
  • {t.rich(`variableStyle.bullets.${idx}`, { code })}
  • + ))} +
+ +

{t("variableStyle.standardNamesIntro")}

+ + + +

{t("variableStyle.redirectHeading")}

+ +

{t.rich("variableStyle.redirectIntro", { code })}

+ +

{t.rich("variableStyle.withoutRedirectIntro", { strong })}

+ + + +

{t.rich("variableStyle.withRedirectIntro", { strong })}

+ + + +

{t("variableStyle.twoPatternsIntro")}

+ +
    +
  • + {t.rich("variableStyle.discardLead", { strong })} + +
  • +
  • + {t.rich("variableStyle.logLead", { strong })} + +
  • +
+ +

{t.rich("variableStyle.referenceOutro", { code })}

+ +

{t("dosAndDonts.heading")}

+ +

{t("dosAndDonts.doHeading")}

+ +
    + {doBullets.map((_, idx) => ( +
  • {t.rich(`dosAndDonts.doBullets.${idx}`, { code })}
  • + ))} +
+ +

{t("dosAndDonts.dontHeading")}

+ +
    + {dontBullets.map((_, idx) => ( +
  • {t.rich(`dosAndDonts.dontBullets.${idx}`, { code })}
  • + ))} +
+ +

{t("submitting.heading")}

+ +
    + {submittingSteps.map((_, idx) => ( +
  1. {t.rich(`submitting.steps.${idx}`, { code, em, strong, cocLink })}
  2. + ))} +
+ +

{t.rich("submitting.securityOutro", { securityLink })}

+ +

{t("whereNext.heading")}

+
    + {whereNextItems.map((item, idx) => ( +
  • + {item.kind === "external" ? ( + + {item.label} + + + ) : ( + + {item.label} + + )} + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/about/contributors/page.tsx b/web/app/[locale]/docs/about/contributors/page.tsx new file mode 100644 index 00000000..e5cecf75 --- /dev/null +++ b/web/app/[locale]/docs/about/contributors/page.tsx @@ -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 { + 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) => {chunks} + const extlink = (href: string) => (chunks: React.ReactNode) => ( + + {chunks} + + + ) + const extlinkBlue = (href: string) => (chunks: React.ReactNode) => ( + + {chunks} + + + ) + const extlinkEmerald = (href: string) => (chunks: React.ReactNode) => ( + + {chunks} + + + ) + + return ( +
+ + + + {t.rich("beyond.body", { + extlink: extlink("https://github.com/MacRimi/ProxMenux/graphs/contributors"), + })} + + +

{t("testers.heading")}

+ +

{t("testers.intro")}

+ +
+ {contributors.map((c) => ( +
+
+ {c.name} +
+ +
+
+

{c.name}

+

{t(`testers.roles.${c.roleKey}`)}

+ {c.youtubeUrl && ( + + {t("testers.youtube")} + + )} +
+ ))} +
+ +

{t("contribute.heading")}

+

{t("contribute.intro")}

+
    +
  • + {t.rich("contribute.tester", { + strong, + beta: extlinkBlue("https://github.com/MacRimi/ProxMenux/blob/develop/install_proxmenux_beta.sh"), + })} +
  • +
  • + {t.rich("contribute.developer", { + strong, + gh: extlinkBlue("https://github.com/MacRimi/ProxMenux/pulls"), + })} +
  • +
  • {t.rich("contribute.designer", { strong })}
  • +
  • + {t.rich("contribute.ideas", { + strong, + disc: extlinkBlue("https://github.com/MacRimi/ProxMenux/discussions"), + })} +
  • +
+ + + {t.rich("coc.body", { + coclink: extlinkEmerald("https://github.com/MacRimi/ProxMenux/blob/main/CODE_OF_CONDUCT.md"), + })} + +
+ ) +} diff --git a/web/app/[locale]/docs/about/faq/page.tsx b/web/app/[locale]/docs/about/faq/page.tsx new file mode 100644 index 00000000..dadc9081 --- /dev/null +++ b/web/app/[locale]/docs/about/faq/page.tsx @@ -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 { + 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 ( +
+

{q}

+
{children}
+
+ ) +} + +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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + + const installlink = (chunks: React.ReactNode) => ( + + {chunks} + + ) + const upgradelink = (chunks: React.ReactNode) => ( + + {chunks} + + ) + const betalink = (chunks: React.ReactNode) => ( + + {chunks} + + ) + const uninstalllink = (chunks: React.ReactNode) => ( + + {chunks} + + ) + const contriblink = (chunks: React.ReactNode) => ( + + {chunks} + + ) + const issueslink = (chunks: React.ReactNode) => ( + + {chunks} + + + ) + const coclink = (chunks: React.ReactNode) => ( + + {chunks} + + + ) + const discusslink = (chunks: React.ReactNode) => ( + + {chunks} + + + ) + const scriptlink = (chunks: React.ReactNode) => ( + + {chunks} + + + ) + const issuelink = (chunks: React.ReactNode) => ( + + {chunks} + + + ) + + return ( +
+ + + + + + + +

{t.rich("q1.p1Rich", { strong })}

+

{t("q1.p2")}

+

{t("q1.p3")}

+
    + {q1Items.map((_, idx) => ( +
  • {t(`q1.items.${idx}`)}
  • + ))} +
+
+ + +

{t.rich("q2.p1Rich", { installlink })}

+
+          {t("q2.stableInstall")}
+        
+

{t("q2.p2")}

+
+          {t("q2.menuCmd")}
+        
+
+ + +

{t.rich("q3.bodyRich", { strong, upgradelink })}

+
+ + +

{t.rich("q5.p1Rich", { code })}

+

{t.rich("q5.p2Rich", { betalink })}

+
+ + +

{t.rich("q6.p1Rich", { issueslink, code })}

+

{t.rich("q6.p2Rich", { strong, coclink })}

+
+ + +

{t.rich("q7.p1Rich", { strong })}

+
    +
  • {t.rich("q7.item1Rich", { discusslink })}
  • +
  • {t.rich("q7.item2Rich", { coclink })}
  • +
  • {t.rich("q7.item3Rich", { contriblink })}
  • +
+
+ + +

{t.rich("q10.p1Rich", { strong, code })}

+

{t.rich("q10.p2Rich", { uninstalllink })}

+
+ + +

{t.rich("q11.p1Rich", { em, code })}

+

{t.rich("q11.p2Rich", { scriptlink, issuelink })}

+
+
+ ) +} diff --git a/web/app/[locale]/docs/about/page.tsx b/web/app/[locale]/docs/about/page.tsx new file mode 100644 index 00000000..b837809b --- /dev/null +++ b/web/app/[locale]/docs/about/page.tsx @@ -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 { + 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> = { + HelpCircle, + Users, + ScrollText, +} + +function OptionCard({ option }: { option: SectionOption }) { + const Icon = ICONS[option.icon] || HelpCircle + return ( + + + + +
+
+ {option.title} + +
+
{option.description}
+
+ + ) +} + +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) => ( + + {chunks} + + + ) + + return ( +
+ + + + {t("callout.body")} + + +

{t("section.heading")}

+
+ {options.map((o) => ( + + ))} +
+ +

{t("involved.heading")}

+

+ {t("involved.intro")} +

+
+ {involvedCards.map((card) => ( + +
+
{card.title}
+
{card.description}
+
+ +
+ ))} +
+ +

+ + {t("support.heading")} +

+

+ {t.rich("support.introRich", { starlink })} +

+

+ + {t("support.kofiLabel")} + + {t("support.kofiOutro")} +

+
+ ) +} diff --git a/web/app/[locale]/docs/create-vm/page.tsx b/web/app/[locale]/docs/create-vm/page.tsx new file mode 100644 index 00000000..6c4fb101 --- /dev/null +++ b/web/app/[locale]/docs/create-vm/page.tsx @@ -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 { + 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> = { + HardDrive, + MonitorCog, + Laptop, +} + +function ScriptRow({ path, role }: { path: string; role: string }) { + return ( + + + + {path} + + + + {role} + + ) +} + +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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const osxLink = (chunks: React.ReactNode) => ( + + {chunks} + + + ) + + return ( +
+ + + + {t("intro.body")} + + +

{t("opening.heading")}

+

+ {t.rich("opening.body", { strong })} +

+ + {t("opening.imageAlt")} + +

{t("families.heading")}

+

{t("families.intro")}

+ +
+ {routes.map((route) => { + const Icon = ICONS[route.icon] || HardDrive + return ( + +
+ + +

{route.title}

+
+

{route.description}

+
    + {route.bullets.map((b, i) => ( +
  • {b}
  • + ))} +
+ + ) + })} +
+ + + {t.rich("community.intro", { em, strong })} +
    +
  • {t.rich("community.macosRich", { strong, osxLink })}
  • +
  • {t.rich("community.othersRich", { strong })}
  • +
+
+ +

{t("afterPick.heading")}

+

{t("afterPick.intro")}

+
    + {afterPickItems.map((_, idx) => ( +
  1. {t.rich(`afterPick.items.${idx}`, { strong, code })}
  2. + ))} +
+ + + {t.rich("afterPick.tipBody", { code })} + + +

{t("scripts.heading")}

+

{t("scripts.intro")}

+
+ + + + + + + + + {scriptRows.map((row) => ( + + ))} + +
{t("scripts.headerScript")}{t("scripts.headerRole")}
+
+ +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/create-vm/synology/page.tsx b/web/app/[locale]/docs/create-vm/synology/page.tsx new file mode 100644 index 00000000..7d6bf5c5 --- /dev/null +++ b/web/app/[locale]/docs/create-vm/synology/page.tsx @@ -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 +} +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 ( +
+
+ {alt} +
+ {caption} +
+ ) +} + +function StepNumber({ number }: { number: number }) { + return ( + + ) +} + +export default function Page() { + const [activeLoader, setActiveLoader] = useState("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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + + return ( +
+

{t("title")}

+ +
+

+ + {t("intro.heading")} +

+

{t("intro.intro")}

+
    + {introLoaders.map((l) => ( +
  • + + {l.name} + + {" "} +
  • + ))} +
  • {t("intro.customLoader")}
  • +
+ +

{t("intro.simplifiesIntro")}

+
    + {simplifies.map((item, i) => ( +
  • {item}
  • + ))} +
+ +
+

+ + {t("config.heading")} +

+

{t("config.intro")}

+ +

+ + {t("config.defaultHeading")} +

+

{t("config.defaultIntro")}

+ +
+ + + + + + + + + {defaultRows.map((row) => ( + + + + + ))} + +
{t("config.headerParam")}{t("config.headerValue")}
{row.param}{row.value}
+
+

{t("config.defaultOutro")}

+ +

+ + {t("config.advancedHeading")} +

+

{t("config.advancedIntro")}

+ +
+ + + + + + + + + {advancedRows.map((row) => ( + + + + + ))} + +
{t("config.headerParam")}{t("config.headerOptions")}
{row.param}{row.options}
+
+
+ +
+

+ + {t("diskSelection.heading")} +

+

{t("diskSelection.intro")}

+ +

{t("diskSelection.virtualHeading")}

+
    + {virtualItems.map((_, idx) => ( +
  • {t.rich(`diskSelection.virtualItems.${idx}`, { strong })}
  • + ))} +
+ +

{t("diskSelection.physicalHeading")}

+
    + {physicalItems.map((_, idx) => ( +
  • {t.rich(`diskSelection.physicalItems.${idx}`, { strong })}
  • + ))} +
+
+ +
+

+ + {t("loaderInstall.heading")} +

+

{t("loaderInstall.intro1")}

+

+ {t.rich("loaderInstall.intro2Rich", { strong })} +

+

+ {t.rich("loaderInstall.customRich", { strong, code })} +

+

+

{t("loaderInstall.uploadIntro")}

+ + +
+ +
+

+ + {t("vmCreation.heading")} +

+

{t("vmCreation.intro")}

+
    + {vmCreationItems.map((_, idx) => ( +
  • {t.rich(`vmCreation.items.${idx}`, { code })}
  • + ))} +
+
+
+ +
+

+ + {t("stepGuide.heading")} +

+

{t("stepGuide.intro")}

+ +
+

{t("stepGuide.selectorHeading")}

+
+ {(["arc", "rr", "tinycore"] as LoaderKey[]).map((key) => ( + + ))} +
+
+
+ + {steps.map((step, stepIdx) => { + const media = step.loaders[activeLoader] || [] + return ( +
+

+ + {step.title} +

+

{step.intro}

+ +
+
+ {media.map((m, idx) => ( +
+ {m.htmlBefore && ( +

+ {t.rich(`steps.${stepIdx}.loaders.${activeLoader}.${idx}.htmlBefore`, { strong, code })} +

+ )} + +
+ ))} +
+
+ + {step.outro &&

{step.outro}

} +
+ ) + })} + +
+

+ + {t("dsmInstall.heading")} +

+

{t("dsmInstall.intro")}

+
+ https://finds.synology.com +
+

{t("dsmInstall.afterCode")}

+
+ +

{t("dsmInstall.patience")}

+ +
+
+ +
+

+ + {t("tips.heading")} +

+
    +
  • {t("tips.introItem")}
  • + +
    + {docLinks.map((dl) => ( + + + {dl.label} + + ))} +
    + +
  • {t("tips.olderModels")}
  • + +
    +

    {t("tips.updateLabel")}

    +

    {t("tips.updateBody")}

    +
    + +
    +

    {t("tips.importantLabel")}

    +

    {t("tips.importantBody")}

    +
    +
+
+
+ ) +} diff --git a/web/app/[locale]/docs/create-vm/system-linux/page.tsx b/web/app/[locale]/docs/create-vm/system-linux/page.tsx new file mode 100644 index 00000000..a2383c7e --- /dev/null +++ b/web/app/[locale]/docs/create-vm/system-linux/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const gpuLink = (chunks: React.ReactNode) => ( + {chunks} + ) + + return ( +
+ + + + {t("intro.body")} + + +
+
+ {t("image.alt")} +
+ {t("image.caption")} +
+ +

{t("config.heading")}

+

{t("config.intro")}

+ +

{t("config.defaultHeading")}

+
+ + + + + + + + + {defaultRows.map((row, idx) => ( + + + + + ))} + +
{t("config.headerParam")}{t("config.headerValue")}
{row.param} + {row.valueRich ? t.rich(`config.defaultRows.${idx}.valueRich`, { code }) : row.value} +
+
+ +

{t("config.advancedHeading")}

+

{t("config.advancedIntro")}

+
+ + + + + + + + + {advancedRows.map((row, idx) => ( + + + + + ))} + +
{t("config.headerParam")}{t("config.headerOptions")}
{row.param} + {row.optionsRich ? t.rich(`config.advancedRows.${idx}.optionsRich`, { code }) : row.options} +
+
+ +

{t("storagePlan.heading")}

+

+ {t.rich("storagePlan.body", { strong })} +

+ +
+
+

{t("storagePlan.virtualDiskTitle")}

+
    + {virtualDiskItems.map((_, idx) => ( +
  • {t.rich(`storagePlan.virtualDiskItems.${idx}`, { code })}
  • + ))} +
+
+
+

{t("storagePlan.importDiskTitle")}

+
    + {importDiskItems.map((_, idx) => ( +
  • {t.rich(`storagePlan.importDiskItems.${idx}`, { code })}
  • + ))} +
+
+
+

{t("storagePlan.pciTitle")}

+
    + {pciItems.map((_, idx) => ( +
  • {t.rich(`storagePlan.pciItems.${idx}`, { em, code })}
  • + ))} +
+
+
+ + + {t.rich("storagePlan.resetBody", { strong })} + + +

{t("gpu.heading")}

+

+ {t.rich("gpu.body", { gpuLink })} +

+ +

{t("autoFeatures.heading")}

+
+
+

{t("autoFeatures.efiTitle")}

+

{t("autoFeatures.efiBody")}

+
+
+

{t("autoFeatures.isoTitle")}

+

{t.rich("autoFeatures.isoBody", { code })}

+
+
+

{t("autoFeatures.guestTitle")}

+

{t("autoFeatures.guestBody")}

+
+
+ +

{t("installOptions.heading")}

+ +
+
+ +

{t("installOptions.officialHeading")}

+
+

+ {t.rich("installOptions.officialBody", { code })} +

+ +
+ {distros.map((d) => ( +
+
{d.name}
+
    + {d.variants.map((v) => ( +
  • {v}
  • + ))} +
+
+ ))} +
+ +
+
+
+ {t("installOptions.officialImageAlt")} +
+ {t("installOptions.officialImageCaption")} +
+
+
+ +
+
+ +

{t("installOptions.localHeading")}

+
+

+ {t.rich("installOptions.localBody", { code })} +

+
+
+ {t("installOptions.localImageAlt")} +
+ {t("installOptions.localImageCaption")} +
+
+ +

{t("endToEnd.heading")}

+
    + {endToEndItems.map((_, idx) => ( +
  1. {t.rich(`endToEnd.items.${idx}`, { code })}
  2. + ))} +
+ +

{t("postInstall.heading")}

+ +

{t("postInstall.guestAgentHeading")}

+

{t("postInstall.guestAgentBody")}

+ +
+
+

{t("postInstall.debian")}

+ +
+
+

{t("postInstall.fedora")}

+ +
+
+

{t("postInstall.arch")}

+ +
+
+

{t("postInstall.opensuse")}

+ +
+
+ +

{t("postInstall.virtioHeading")}

+

+ {t.rich("postInstall.virtioBody", { code })} +

+ + {t("postInstall.virtioWarnBody")} + + +

{t("postInstall.trimHeading")}

+

+ {t.rich("postInstall.trimBody", { code })} +

+
    + {trimItems.map((_, idx) => ( +
  • {t.rich(`postInstall.trimItems.${idx}`, { code })}
  • + ))} +
+ +

{t("postInstall.balloonHeading")}

+

+ {t.rich("postInstall.balloonBody", { code })} +

+ +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/create-vm/system-nas/page.tsx b/web/app/[locale]/docs/create-vm/system-nas/page.tsx new file mode 100644 index 00000000..f1cb39f6 --- /dev/null +++ b/web/app/[locale]/docs/create-vm/system-nas/page.tsx @@ -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 { + 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> = { + HardDrive, + Database, + Server, + MonitorIcon, +} + +const FLOW_CLS: Record = { + 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) => {chunks} + const umbrelLink = (chunks: React.ReactNode) => ( + + {chunks} + + ) + + return ( +
+ + + + {t("intro.body")} + + +
+
+ {t("image.alt")} +
+ {t("image.caption")} +
+ +

{t("supported.heading")}

+
+ {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 = ( +
+
+ +
+
+

{card.name}

+

{card.tagline}

+
+ + {flowLabel} + +
+
+
+
{t("labels.base")}
+
{card.base}
+
+
+
{t("labels.fileSystem")}
+
{card.fileSystem}
+
+
+ + {t("labels.viewDetails")} + {card.external ? ( + + ) : ( + + )} + +
+
+ ) + return card.external ? ( + + {inner} + + ) : ( + + {inner} + + ) + })} +
+ + + {t.rich("umbrel.bodyRich", { strong, umbrelLink })} + + + + {t.rich("zfsMem.bodyRich", { strong })} + + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/create-vm/system-nas/synology/page.tsx b/web/app/[locale]/docs/create-vm/system-nas/synology/page.tsx new file mode 100644 index 00000000..6334883c --- /dev/null +++ b/web/app/[locale]/docs/create-vm/system-nas/synology/page.tsx @@ -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("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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const gpuLink = (chunks: React.ReactNode) => ( + + {chunks} + + ) + + return ( +
+ + + + {t.rich("whatThisDoes.bodyRich", { strong })} + + +

{t("supportedLoaders.heading")}

+

{t("supportedLoaders.intro")}

+
    + {loaders.map((l) => ( +
  • + + {l.name} + +
  • + ))} +
  • {t.rich("supportedLoaders.customRich", { strong, code })}
  • +
+ +

{t("config.heading")}

+

{t("config.intro")}

+ +

{t("config.defaultHeading")}

+
+ + + + + + + + + {defaultRows.map((row, idx) => ( + + + + + ))} + +
{t("config.headerParam")}{t("config.headerValue")}
{row.param}{t.rich(`config.defaultRowsRich.${idx}.valueRich`, { code })}
+
+ +

{t("config.advancedHeading")}

+

{t("config.advancedIntro")}

+
+ + + + + + + + + {advancedRows.map((row, idx) => ( + + + + + ))} + +
{t("config.headerParam")}{t("config.headerOptions")}
{row.param}{t.rich(`config.advancedRowsRich.${idx}.optionsRich`, { code })}
+
+ +

{t("storagePlan.heading")}

+

{t.rich("storagePlan.introRich", { strong })}

+ +
+
+

{t("storagePlan.virtualHeading")}

+
    + {virtualItems.map((_, idx) => ( +
  • {t.rich(`storagePlan.virtualItemsRich.${idx}`, { code })}
  • + ))} +
+
+
+

{t("storagePlan.importHeading")}

+
    + {importItems.map((_, idx) => ( +
  • {t.rich(`storagePlan.importItemsRich.${idx}`, { code })}
  • + ))} +
+
+
+

{t("storagePlan.pciHeading")}

+
    + {pciItems.map((_, idx) => ( +
  • {t.rich(`storagePlan.pciItemsRich.${idx}`, { code, em })}
  • + ))} +
+
+
+ + + {t.rich("storagePlan.resetCalloutBodyRich", { strong })} + + +

{t("gpu.heading")}

+

{t.rich("gpu.bodyRich", { link: gpuLink })}

+ +

{t("loaderInstall.heading")}

+

{t.rich("loaderInstall.intro1Rich", { strong })}

+

{t.rich("loaderInstall.intro2Rich", { strong, code })}

+

{t("loaderInstall.uploadIntro")}

+ + +

{t("vmCreation.heading")}

+

{t.rich("vmCreation.introRich", { code })}

+
    + {vmCreationItems.map((_, idx) => ( +
  • {t.rich(`vmCreation.itemsRich.${idx}`, { code, strong })}
  • + ))} +
+ +

{t("stepByStep.heading")}

+

{t("stepByStep.intro")}

+ + + {t("stepByStep.warnCalloutBody")} + + +
+ {(["arc", "rr", "tinycore"] as LoaderKey[]).map((k) => ( + + ))} +
+ + +

{t("step1.intro")}

+ + {activeLoader === "arc" && ( +
+

{t.rich("step1.arc.webRich", { strong, code })}

+ +

{t.rich("step1.arc.termRich", { strong })}

+ +
+ )} + + {activeLoader === "rr" && ( +
+

{t.rich("step1.rr.webRich", { strong, code })}

+ +

{t.rich("step1.rr.termRich", { strong, code })}

+ +
+ )} + + {activeLoader === "tinycore" && ( +
+

{t.rich("step1.tinycore.webRich", { strong, code })}

+ +

{t.rich("step1.tinycore.termRich", { strong })}

+ +
+ )} +
+ + +

{t.rich("step2.introRich", { strong })}

+ {activeLoader === "arc" && ( + + )} + {activeLoader === "rr" && ( + + )} + {activeLoader === "tinycore" && ( + + )} +
+ + +

{t("step3.intro")}

+ {activeLoader === "arc" && ( +
+ + +
+ )} + {activeLoader === "rr" && ( +
+ + + +
+ )} + {activeLoader === "tinycore" && ( +
+ + +
+ )} +
+ + +

{t("step4.intro")}

+ {activeLoader === "arc" && ( +
+

{t.rich("step4.arc.autoRich", { strong })}

+ +

{t("step4.arc.manualRich")}

+ + + + +
+ )} + {activeLoader === "rr" && ( +
+ + + +
+ )} + {activeLoader === "tinycore" && ( +
+ + + + +
+ )} +
+ + +

{t.rich("step5.introRich", { strong })}

+ {activeLoader === "arc" && ( + + )} + {activeLoader === "rr" && ( + + )} + {activeLoader === "tinycore" && ( + + )} +
+ + +

{t("step6.intro")}

+ {activeLoader === "arc" && ( + + )} + {activeLoader === "rr" && ( + + )} + {activeLoader === "tinycore" && ( + + )} +
+ +

{t("dsmInstall.heading")}

+

{t("dsmInstall.intro")}

+
+        https://finds.synology.com
+      
+

{t("dsmInstall.afterCode")}

+
+ +

{t("dsmInstall.patience")}

+ +
+ +

{t("tips.heading")}

+ + + {t("tips.recentBody")} + + + + {t("tips.updateBody")} + + + + {t("tips.warnBody")} + + +

{t("tips.docsHeading")}

+
+ {docLinks.map((dl) => ( + + + {dl.label} + + ))} +
+ +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} + +function ImageCaption({ src, alt, caption }: { src: string; alt: string; caption: string }) { + return ( +
+
+ {alt} +
+ {caption} +
+ ) +} + +function StepSection({ n, title, stepLabel, children }: { n: number; title: string; stepLabel: string; children: React.ReactNode }) { + return ( +
+
+ + {stepLabel} {n} + +

{title}

+
+ {children} +
+ ) +} diff --git a/web/app/[locale]/docs/create-vm/system-nas/system-nas-others/page.tsx b/web/app/[locale]/docs/create-vm/system-nas/system-nas-others/page.tsx new file mode 100644 index 00000000..8f605cd7 --- /dev/null +++ b/web/app/[locale]/docs/create-vm/system-nas/system-nas-others/page.tsx @@ -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 { + 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> = { + 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 + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const note = (chunks: React.ReactNode) => {chunks} + const gpuLink = (chunks: React.ReactNode) => ( + + {chunks} + + ) + + return ( +
+ + + + {t.rich("intro.bodyRich", { code })} + + +

{t("config.heading")}

+

{t("config.intro")}

+ +

{t("config.defaultHeading")}

+
+ + + + + + + + + {defaultRows.map((row, idx) => ( + + + + + ))} + +
{t("config.headerParam")}{t("config.headerValue")}
{row.param}{t.rich(`config.defaultRowsRich.${idx}.valueRich`, { code, note })}
+
+ +

{t("config.advancedHeading")}

+

{t("config.advancedIntro")}

+
+ + + + + + + + + {advancedRows.map((row, idx) => ( + + + + + ))} + +
{t("config.headerParam")}{t("config.headerOptions")}
{row.param}{t.rich(`config.advancedRowsRich.${idx}.optionsRich`, { code })}
+
+ + + {t("config.zfsCalloutBody")} + + +

{t("storagePlan.heading")}

+

{t("storagePlan.intro")}

+ +
+
+

{t("storagePlan.virtualHeading")}

+
    + {virtualItems.map((_, idx) => ( +
  • {t.rich(`storagePlan.virtualItemsRich.${idx}`, { code })}
  • + ))} +
+
+
+

{t("storagePlan.importHeading")}

+
    + {importItems.map((_, idx) => ( +
  • {t.rich(`storagePlan.importItemsRich.${idx}`, { code })}
  • + ))} +
+
+
+

{t("storagePlan.pciHeading")}

+
    + {pciItems.map((_, idx) => ( +
  • {t.rich(`storagePlan.pciItemsRich.${idx}`, { code, em })}
  • + ))} +
+
+
+ + + {t.rich("storagePlan.resetCalloutBodyRich", { strong })} + + +

{t("gpu.heading")}

+

{t.rich("gpu.bodyRich", { link: gpuLink })}

+ +

{t("autoFeatures.heading")}

+
+
+

{t("autoFeatures.efiTitle")}

+

{t("autoFeatures.efiBody")}

+
+
+

{t("autoFeatures.isoTitle")}

+

{t.rich("autoFeatures.isoBodyRich", { code })}

+
+
+

{t("autoFeatures.guestTitle")}

+

{t("autoFeatures.guestBody")}

+
+
+ +

{t("endToEnd.heading")}

+
    + {endToEndItems.map((_, idx) => ( +
  1. {t.rich(`endToEnd.itemsRich.${idx}`, { code })}
  2. + ))} +
+ +

{t("perSystem.heading")}

+ + {systemOrder.map((key) => { + const sys = o.systems[key] + const Icon = ICONS[sys.icon] ?? Database + return ( + } + 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")} + /> + ) + })} + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} + +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 ( +
+

+ {icon} + {title} + + {officialName} + +

+

{description}

+
    + {specs.map((s, i) => ( +
  • {s}
  • + ))} +
+ {(shellImg || webImg) && ( +
+ {shellImg && ( +
+

{shellLabel}

+
+ {shellAlt +
+
+ )} + {webImg && ( +
+

{webLabel}

+
+ {webAlt +
+
+ )} +
+ )} +
+ ) +} diff --git a/web/app/[locale]/docs/create-vm/system-windows/page.tsx b/web/app/[locale]/docs/create-vm/system-windows/page.tsx new file mode 100644 index 00000000..fe589d33 --- /dev/null +++ b/web/app/[locale]/docs/create-vm/system-windows/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const gpuLink = (chunks: React.ReactNode) => ( + {chunks} + ) + + return ( +
+ + + + {t.rich("intro.body", { code })} + + +
+
+ {t("image.alt")} +
+ {t("image.caption")} +
+ +

{t("config.heading")}

+

{t("config.intro")}

+ +

{t("config.defaultHeading")}

+
+ + + + + + + + + {defaultRows.map((row, idx) => ( + + + + + ))} + +
{t("config.headerParam")}{t("config.headerValue")}
{row.param} + {row.valueRich ? t.rich(`config.defaultRows.${idx}.valueRich`, { code }) : row.value} +
+
+ +

{t("config.advancedHeading")}

+

{t("config.advancedIntro")}

+
+ + + + + + + + + {advancedRows.map((row, idx) => ( + + + + + ))} + +
{t("config.headerParam")}{t("config.headerOptions")}
{row.param} + {row.optionsRich ? t.rich(`config.advancedRows.${idx}.optionsRich`, { code }) : row.options} +
+
+ + + {t("config.tpmWarnBody")} + + +

{t("storagePlan.heading")}

+

+ {t.rich("storagePlan.body", { strong })} +

+ +
+
+

{t("storagePlan.virtualDiskTitle")}

+
    + {virtualDiskItems.map((_, idx) => ( +
  • {t.rich(`storagePlan.virtualDiskItems.${idx}`, { code })}
  • + ))} +
+
+
+

{t("storagePlan.importDiskTitle")}

+
    + {importDiskItems.map((_, idx) => ( +
  • {t.rich(`storagePlan.importDiskItems.${idx}`, { code })}
  • + ))} +
+
+
+

{t("storagePlan.pciTitle")}

+
    + {pciItems.map((_, idx) => ( +
  • {t.rich(`storagePlan.pciItems.${idx}`, { em, code })}
  • + ))} +
+
+
+ + + {t.rich("storagePlan.resetBody", { strong })} + + +

{t("gpu.heading")}

+

+ {t.rich("gpu.body", { gpuLink })} +

+ +

{t("autoFeatures.heading")}

+
+
+

{t("autoFeatures.efiTitle")}

+

{t("autoFeatures.efiBody")}

+
+
+

{t("autoFeatures.tpmTitle")}

+

{t("autoFeatures.tpmBody")}

+
+
+

{t("autoFeatures.isoTitle")}

+

{t.rich("autoFeatures.isoBody", { code, em })}

+
+
+

{t("autoFeatures.guestTitle")}

+

{t.rich("autoFeatures.guestBody", { code })}

+
+
+ +

{t("installOptions.heading")}

+

{t("installOptions.intro")}

+ +
+
+
+
+ {t("installOptions.uupLogoAlt")} +
+

{t("installOptions.uupTitle")}

+
+

+ {t.rich("installOptions.uupBody", { strong })} +

+
    + {uupItems.map((_, idx) => ( +
  • {t(`installOptions.uupItems.${idx}`)}
  • + ))} +
+ + {t("installOptions.uupLearnMore")} + + +
+ +
+
+
+ +
+

{t("installOptions.localTitle")}

+
+

+ {t.rich("installOptions.localBody", { code })} +

+
+ {t("installOptions.localImageAlt")} +
+

{t("installOptions.localImageCaption")}

+
+
+ +

{t("endToEnd.heading")}

+
    + {endToEndItems.map((_, idx) => ( +
  1. {t.rich(`endToEnd.items.${idx}`, { code })}
  2. + ))} +
+ +

{t("virtio.heading")}

+

+ {t.rich("virtio.body", { code })} +

+ + + {t.rich("virtio.warnBody", { code })} + + + {virtioSteps.map((step, idx) => ( +
+
+ + {t("virtio.stepLabel")} {idx + 1} + +

{step.title}

+
+

+ {step.bodyRich ? t.rich(`virtio.steps.${idx}.bodyRich`, { strong, code }) : step.body} +

+
+
+ {step.caption} +
+ {step.caption} +
+
+ ))} + + + {t.rich("virtio.tipBody", { code })} + + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/disk-manager/add-controller-nvme-vm/page.tsx b/web/app/[locale]/docs/disk-manager/add-controller-nvme-vm/page.tsx new file mode 100644 index 00000000..c012a953 --- /dev/null +++ b/web/app/[locale]/docs/disk-manager/add-controller-nvme-vm/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + + return ( +
+ + + + {t.rich("intro.body", { strong, code })} + + +

{t("howRuns.heading")}

+

{t("howRuns.body")}

+ +
+{`┌─────────────────────────────────────────────┐
+│  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  --hostpciN \\
+                             ,pcie=1
+                             (e.g. 0000:01:00.0,pcie=1)
+                                       │
+                                       ▼
+                       Verify: qm config  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)`}
+      
+ +

{t("iommu.heading")}

+

+ {t.rich("iommu.body", { strong })} +

+
+
+{`          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.│
+    └─────────────────────────────────────────┘`}
+        
+
+

{t("iommu.outro")}

+ +

{t("prereqs.heading")}

+
    + {prereqItems.map((_, idx) => ( +
  • {t.rich(`prereqs.items.${idx}`, { strong, em })}
  • + ))} +
+ + + {t("prereqs.warnBody")} + + +

{t("steps.heading")}

+ + {stepList.map((step, idx) => ( +
+
+ + {t("steps.stepLabel")} {idx + 1} + +

{step.title}

+
+
+ {step.bodyRich ? ( +

{t.rich(`steps.list.${idx}.bodyRich`, { code, strong })}

+ ) : step.body &&

{step.body}

} + {step.items && ( +
    + {step.items.map((_, i) => ( +
  • {t.rich(`steps.list.${idx}.items.${i}`, { code })}
  • + ))} +
+ )} + {step.outro &&

{step.outro}

} +
+ {step.img && ( +
+
+ {step.alt +
+ {step.caption && {step.caption}} +
+ )} +
+ ))} + +

{t("manual.heading")}

+ + +

{t("troubleshoot.heading")}

+ + {t("troubleshoot.noGroupsBody")} + + + {t.rich("troubleshoot.busyBody", { code })} + + + {t("troubleshoot.noDisksBody")} + + + {t.rich("troubleshoot.sharedBody", { em })} + + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/disk-manager/format-disk/page.tsx b/web/app/[locale]/docs/disk-manager/format-disk/page.tsx new file mode 100644 index 00000000..fea19c7a --- /dev/null +++ b/web/app/[locale]/docs/disk-manager/format-disk/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + + return ( +
+ + + + {t("danger.body")} + + +

{t("howRuns.heading")}

+

{t("howRuns.body")}

+ +
+{`┌─────────────────────────────────────────────┐
+│  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             │
+                       │    sgdisk --zap-all       │
+                       ├─────────────────────────────────┤
+                       │ 2. Remove FS labels             │
+                       │    wipefs -af             │
+                       │    + wipefs -af each partition  │
+                       │    (partition table PRESERVED)  │
+                       ├─────────────────────────────────┤
+                       │ 3. Zero all data                │
+                       │    For each partition:          │
+                       │      dd if=/dev/zero of=  │
+                       │         bs=4M                   │
+                       │    (partition table PRESERVED)  │
+                       ├─────────────────────────────────┤
+                       │ 4. Full format                  │
+                       │    wipefs -af             │
+                       │    sgdisk --zap-all       │
+                       │    sgdisk -n 1:0:0 -t 1:8300    │
+                       │                           │
+                       │    mkfs. [-L 
+ +

{t("visibility.heading")}

+

{t("visibility.intro")}

+
    + {visItems.map((_, idx) => ( +
  • {t.rich(`visibility.items.${idx}`, { strong, em })}
  • + ))} +
+ + +
    + {safetyItems.map((_, idx) => ( +
  • {t.rich(`visibility.safetyItems.${idx}`, { strong, em })}
  • + ))} +
+
+ +

{t("modes.heading")}

+

{t("modes.intro")}

+
+ + + + + + + + + + + {modeRows.map((row) => ( + + + + + + + ))} + +
{t("modes.headerMode")}{t("modes.headerPart")}{t("modes.headerData")}{t("modes.headerUseCase")}
{row.mode}{row.part}{row.data}{row.useCase}
+
+ +

{t.rich("modes.fullFormatOutro", { strong })}

+
    + {fullFormatItems.map((_, idx) => ( +
  • {t.rich(`modes.fullFormatItems.${idx}`, { strong, code })}
  • + ))} +
+ +

{t("steps.heading")}

+ + {stepList.map((step, idx) => ( +
+
+ + {t("steps.stepLabel")} {idx + 1} + +

{step.title}

+
+
+ {step.bodyRich ? ( +

{t.rich(`steps.list.${idx}.bodyRich`, { strong, code })}

+ ) : step.body &&

{step.body}

} +
+
+ ))} + +

{t("manual.heading")}

+

{t("manual.body")}

+ + + + {t.rich("troubleshoot.notListedBody", { code })} + + + {t.rich("troubleshoot.busyBody", { code })} + + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/disk-manager/import-disk-image-vm/page.tsx b/web/app/[locale]/docs/disk-manager/import-disk-image-vm/page.tsx new file mode 100644 index 00000000..8108b1f8 --- /dev/null +++ b/web/app/[locale]/docs/disk-manager/import-disk-image-vm/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + + return ( +
+ + + + {t.rich("intro.body", { em, strong })} + + +

{t("howRuns.heading")}

+

+ {t.rich("howRuns.body", { code })} +

+ +
+{`┌─────────────────────────────────────────────┐
+│  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  \\
+                       │       \\
+                       │      
+                       │    (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  -{bus}N \\
+                             :vm--disk-N[,ssd=1]
+                                       │
+                                       ▼
+                       If any image was marked bootable:
+                       └─ qm set  --boot order={bus}N
+                          (first bootable wins; others can be
+                           reordered later in the Proxmox UI)
+                                       │
+                                       ▼
+                       Verify: qm config  shows the
+                       new slot(s) and, if applicable, the
+                       new boot order
+                                       │
+                                       ▼
+                       Source image file on the host is
+                       kept unchanged (copied, not moved)`}
+      
+ +

{t("prereqs.heading")}

+
    + {prereqItems.map((_, idx) => ( +
  • {t.rich(`prereqs.items.${idx}`, { code, strong })}
  • + ))} +
+ +

{t("steps.heading")}

+ + {stepList.map((step, idx) => ( +
+
+ + {t("steps.stepLabel")} {idx + 1} + +

{step.title}

+
+
+ {step.bodyRich ? ( +

{t.rich(`steps.list.${idx}.bodyRich`, { code, strong })}

+ ) : step.intro ? ( + <> +

{step.intro}

+ {step.items && ( +
    + {step.items.map((_, i) => ( +
  • {t.rich(`steps.list.${idx}.items.${i}`, { strong, code })}
  • + ))} +
+ )} + + ) : ( + step.body &&

{step.body}

+ )} +
+
+ ))} + +

{t("manual.heading")}

+

+ {t.rich("manual.body", { code })} +

+ + + + {t.rich("manual.warnBody", { code })} + + +

{t("troubleshoot.heading")}

+ + {t.rich("troubleshoot.noImagesBody", { code })} + + + {t("troubleshoot.slowBody")} + + + {t("troubleshoot.uefiBody")} + + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/disk-manager/import-disk-lxc/page.tsx b/web/app/[locale]/docs/disk-manager/import-disk-lxc/page.tsx new file mode 100644 index 00000000..7bc62be1 --- /dev/null +++ b/web/app/[locale]/docs/disk-manager/import-disk-lxc/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const wipeLink = (chunks: React.ReactNode) => ( + {chunks} + ) + + return ( +
+ + + + {t.rich("intro.body", { strong, em })} + + +

{t("howRuns.heading")}

+

{t("howRuns.body")}

+ +
+{`┌─────────────────────────────────────────────┐
+│  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/.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. 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. /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  -mpN \\
+                          , \\
+                          mp=, \\
+                          backup=0,ro=0[,acl=1]
+                                       │
+                                       ▼
+                       Verify: pct config  shows
+                       the new mpN entry
+                                       │
+                                       ▼
+                       Container sees the directory at
+                       the chosen mount point path`}
+      
+

{t("howRuns.summary")}

+ +

{t("prereqs.heading")}

+
    + {prereqItems.map((_, idx) => ( +
  • {t.rich(`prereqs.items.${idx}`, { strong })}
  • + ))} +
+ + + {t.rich("prereqs.warnBody", { strong, code })} + + +

{t("steps.heading")}

+ + {stepList.map((step, idx) => ( +
+
+ + {t("steps.stepLabel")} {idx + 1} + +

{step.title}

+
+
+ {step.bodyRich ? ( +

{t.rich(`steps.list.${idx}.bodyRich`, { code, strong, em })}

+ ) : step.intro ? ( + <> +

{step.intro}

+ {step.items && ( +
    + {step.items.map((_, i) => ( +
  • {t(`steps.list.${idx}.items.${i}`)}
  • + ))} +
+ )} + + ) : ( + step.body &&

{step.body}

+ )} +
+ {step.img && ( +
+
+ {step.caption +
+ {step.caption && {step.caption}} +
+ )} + {step.extraImg && ( +
+
+ {step.extraAlt +
+ {step.extraCaption && {step.extraCaption}} +
+ )} +
+ ))} + +

{t("manual.heading")}

+

{t.rich("manual.body", { code })}

+ + +

{t("important.heading")}

+
    + {importantItems.map((_, idx) => ( +
  • {t.rich(`important.items.${idx}`, { strong, code, wipeLink })}
  • + ))} +
+ +

{t("troubleshoot.heading")}

+ + {t.rich("troubleshoot.unprivBody", { code })} + + + {t.rich("troubleshoot.permsBody", { code })} + + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/disk-manager/import-disk-vm/page.tsx b/web/app/[locale]/docs/disk-manager/import-disk-vm/page.tsx new file mode 100644 index 00000000..3574579f --- /dev/null +++ b/web/app/[locale]/docs/disk-manager/import-disk-vm/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const winLink = (chunks: React.ReactNode) => ( + {chunks} + ) + + return ( +
+ + + + {t.rich("intro.body", { code })} + + +

{t("howRuns.heading")}

+

{t("howRuns.body")}

+ +
+{`┌─────────────────────────────────────────────┐
+│  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  -{bus}N 
+                                       │
+                                       ▼
+                       Verify: qm config  shows
+                       the new slot(s)
+                                       │
+                                       ▼
+                       Guest sees each disk as a native
+                       block device under its bus
+                       (e.g. /dev/sda, /dev/nvme0n1)`}
+      
+

+ {t.rich("howRuns.summary", { em })} +

+ +

{t("prereqs.heading")}

+
    + {prereqItems.map((_, idx) => ( +
  • {t.rich(`prereqs.items.${idx}`, { code, strong })}
  • + ))} +
+ +

{t("steps.heading")}

+ + {stepList.map((step, idx) => ( +
+
+ + {t("steps.stepLabel")} {idx + 1} + +

{step.title}

+
+
+ {step.bodyRich ? ( +

{t.rich(`steps.list.${idx}.bodyRich`, { code, strong })}

+ ) : ( + <> + {step.body &&

{step.body}

} + {step.intro && ( + <> +

{step.intro}

+ {step.items && ( +
    + {step.items.map((_, i) => ( +
  • {t.rich(`steps.list.${idx}.items.${i}`, { strong })}
  • + ))} +
+ )} + + )} + + )} +
+ {step.img && ( +
+
+ {step.caption +
+ {step.caption && {step.caption}} +
+ )} +
+ ))} + +

{t("manual.heading")}

+

+ {t.rich("manual.body", { code })} +

+ + + + {t.rich("manual.migrationBody", { strong, code })} + + + + {t.rich("manual.shareBody", { code })} + + +

{t("troubleshoot.heading")}

+ + {t("troubleshoot.noDisksIntro")} +
    + {noDisksItems.map((_, idx) => ( +
  • {t(`troubleshoot.noDisksItems.${idx}`)}
  • + ))} +
+ {t.rich("troubleshoot.noDisksOutro", { code })} +
+ + {t.rich("troubleshoot.noVisibleBody", { strong, winLink })} + + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/disk-manager/page.tsx b/web/app/[locale]/docs/disk-manager/page.tsx new file mode 100644 index 00000000..43ed549a --- /dev/null +++ b/web/app/[locale]/docs/disk-manager/page.tsx @@ -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 { + 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> = { + HardDrive, + FileDown, + Cpu, + Boxes, + Eraser, + Activity, +} + +function DiskOptionCard({ option }: { option: DiskOption }) { + const Icon = ICONS[option.icon] || HardDrive + return ( + + + + +
+
+ {option.title} + +
+
{option.description}
+
+ + ) +} + +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) => {chunks} + + return ( +
+ + + + {t.rich("intro.body", { strong })} + + +

{t("opening.heading")}

+

+ {t.rich("opening.body", { strong })} +

+ + {t("opening.imageAlt")} + +

{t("groups.heading")}

+

+ {t.rich("groups.intro", { strong })} +

+ + + +

{t("vm.heading")}

+

{t("vm.intro")}

+
+ {vmOptions.map((o) => )} +
+ +

{t("lxc.heading")}

+

{t("lxc.intro")}

+
+ {lxcOptions.map((o) => )} +
+ +

{t("utilities.heading")}

+

{t("utilities.intro")}

+
+ {utilityOptions.map((o) => )} +
+ + + {t("safety.intro")} +
    + {safetyItems.map((_, idx) => ( +
  • {t.rich(`safety.items.${idx}`, { strong })}
  • + ))} +
+
+ +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/disk-manager/smart-disk-test/page.tsx b/web/app/[locale]/docs/disk-manager/smart-disk-test/page.tsx new file mode 100644 index 00000000..27f8fd71 --- /dev/null +++ b/web/app/[locale]/docs/disk-manager/smart-disk-test/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const br = () =>
+ + return ( +
+ + + + {t.rich("intro.body", { code })} + + +

{t("howRuns.heading")}

+

{t("howRuns.body")}

+ +
+{`        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//
+       _.json
+         │
+         ▼
+   Retention policy: oldest beyond the limit
+   are trimmed automatically
+         │
+         ▼
+   ProxMenux Monitor reads these files to
+   render health trends per disk over time`}
+      
+ +

{t("deps.heading")}

+

+ {t.rich("deps.body", { code })} +

+ + +

{t("actions.heading")}

+
+ + + + + + + + + + {actionRows.map((row, idx) => ( + + + + + + ))} + +
{t("actions.headerAction")}{t("actions.headerWhat")}{t("actions.headerDur")}
{row.action} + {row.whatRich ? t.rich(`actions.rows.${idx}.whatRich`, { code, br }) : row.what} + {row.dur}
+
+ + + {t.rich("actions.tipBody", { em, strong })} + + +

{t("json.heading")}

+

+ {t.rich("json.intro", { 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`}
+      
+

{t("json.outro")}

+ +

{t("steps.heading")}

+ + {stepList.map((step, idx) => ( +
+
+ + {t("steps.stepLabel")} {idx + 1} + +

{step.title}

+
+
+ {step.bodyRich ? ( +

{t.rich(`steps.list.${idx}.bodyRich`, { strong })}

+ ) : step.body &&

{step.body}

} +
+ {step.img && ( +
+
+ {step.alt +
+ {step.caption && {step.caption}} +
+ )} +
+ ))} + +

{t("manual.heading")}

+ + + + {t.rich("manual.nvmeWarnBody", { code })} + + +

{t("troubleshoot.heading")}

+ + {t.rich("troubleshoot.noSmartBody", { code })} + + + {t.rich("troubleshoot.longBody", { code })} + + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/external-repositories/page.tsx b/web/app/[locale]/docs/external-repositories/page.tsx new file mode 100644 index 00000000..392274bf --- /dev/null +++ b/web/app/[locale]/docs/external-repositories/page.tsx @@ -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 { + 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) => {chunks} + + return ( +
+ + + + {t("practice.body")} + + +

{t("integrated.heading")}

+ + +

{t("attribution.heading")}

+
    + {attributionItems.map((_, idx) => ( +
  • {t(`attribution.items.${idx}`)}
  • + ))} +
+ + + {t.rich("report.body", { strong })} + + +

{t("suggest.heading")}

+

{t("suggest.intro")}

+ + + +
    + {candidateItems.map((_, idx) => ( +
  • {t(`candidate.items.${idx}`)}
  • + ))} +
+
+
+ ) +} diff --git a/web/app/[locale]/docs/glossary/page.tsx b/web/app/[locale]/docs/glossary/page.tsx new file mode 100644 index 00000000..97989fa3 --- /dev/null +++ b/web/app/[locale]/docs/glossary/page.tsx @@ -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 { + 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 = { + 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 ( + + {category} + + ) +} + +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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const ext = (chunks: React.ReactNode) => ( + + {chunks} + + + ) + + return ( +
+ + + + {t.rich("callout.bodyRich", { em })} + + +

{t("jumpHeading")}

+
+ {lettersInUse.map((letter) => ( + + {letter} + + ))} +
+ +
+ {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 ( +
+ {isFirstOfLetter && ( +

+ {letter} +

+ )} +
+
+

{entry.term}

+ + {entry.aliases && entry.aliases.length > 0 && ( + + {t("aliasesLabel")} {entry.aliases.join(" · ")} + + )} +
+

+ {t.rich(`entries.${entryIdx}.definitionRich`, { code, strong, em })} +

+ {entry.seeAlso && entry.seeAlso.length > 0 && ( +

+ {t("seeAlsoLabel")}{" "} + {entry.seeAlso.map((s, idx) => ( + + + {s.label} + + {idx < entry.seeAlso!.length - 1 && " · "} + + ))} +

+ )} +
+
+ ) + })} +
+ + + {t.rich("missingCallout.leadRich", { ext, code })} + +
+ ) +} diff --git a/web/app/[locale]/docs/hardware/coral-tpu-lxc/page.tsx b/web/app/[locale]/docs/hardware/coral-tpu-lxc/page.tsx new file mode 100644 index 00000000..e3c88677 --- /dev/null +++ b/web/app/[locale]/docs/hardware/coral-tpu-lxc/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const frigateLink = (chunks: React.ReactNode) => ( + + {chunks} + + + ) + const hostLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const lxcGpuLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const coralLink = (chunks: React.ReactNode) => ( + + {chunks} + + + ) + + return ( +
+ + + + {t.rich("intro.body", { code, strong, lxcGpuLink })} + + +

{t("whenUse.heading")}

+

+ {t.rich("whenUse.body", { frigateLink })} +

+ + {t.rich("prereqs.drivers", { strong, code, hostLink })}, + check: t("prereqs.driversCheck"), + }, + { + label: <>{t.rich("prereqs.container", { strong, code })}, + }, + { + label: <>{t.rich("prereqs.downtime", { strong })}, + }, + ]} + /> + + + {t.rich("hostPrep.body", { code, strong })} + + +

{t("running.heading")}

+

+ {t.rich("running.body", { strong })} +

+ + {t("running.imageAlt")} + +

{t("howRuns.heading")}

+

{t("howRuns.body")}

+ +
+{`┌────────────────────────────────────────────────┐
+│ 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:       → 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.`}
+      
+ +

{t("walkthrough.heading")}

+ + + +

{t.rich("walkthrough.pick.body", { code })}

+
+ + +

{t.rich("walkthrough.gpuHint.body", { code, lxcGpuLink })}

+
+ + +

{t.rich("walkthrough.usb.body", { code, strong })}

+ .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" + /> + + {t.rich("walkthrough.usb.whyBody", { code })} + +
+ + +

{t.rich("walkthrough.pcie.body1", { code })}

+ .conf — modern path +dev0: /dev/apex_0,gid=`} + className="my-4" + /> +

{t.rich("walkthrough.pcie.body2", { code, hostLink })}

+ + + {t.rich("walkthrough.pcie.rebootBody", { code })} + +
+ + +

{t.rich("walkthrough.drivers.body", { code })}

+
    + {driverItems.map((_, idx) => ( +
  1. {t.rich(`walkthrough.drivers.items.${idx}`, { code })}
  2. + ))} +
+
+ + +

{t("walkthrough.summary.body")}

+
+
+ +

{t("manual.heading")}

+

{t("manual.body")}

+ +

{t("manual.usbHeading")}

+ /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/.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" + /> + +

{t("manual.pcieHeading")}

+ .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" + /> + +

{t("manual.runtimeHeading")}

+ /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" + /> + +

{t("verification.heading")}

+

{t("verification.body")}

+ + +# 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" + /> + +

{t("troubleshoot.heading")}

+ + + {t.rich("troubleshoot.apexBody", { code })} + + + + {t.rich("troubleshoot.replugBody", { code })} + + + + {t.rich("troubleshoot.alpineBody", { code })} + + + + {t.rich("troubleshoot.frigateBody", { code })} + + + + {t.rich("troubleshoot.logsBody", { code })} + + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/hardware/gpu-vm-passthrough/page.tsx b/web/app/[locale]/docs/hardware/gpu-vm-passthrough/page.tsx new file mode 100644 index 00000000..144ca864 --- /dev/null +++ b/web/app/[locale]/docs/hardware/gpu-vm-passthrough/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const pveLink = (chunks: React.ReactNode) => ( + + {chunks} + + + ) + const lxcLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const nvidiaLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const postLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const vendorResetLink = (chunks: React.ReactNode) => ( + + {chunks} + + + ) + const sriovLink = (chunks: React.ReactNode) => ( + + {chunks} + + + ) + + return ( +
+ + + + {t.rich("intro.body", { code, em, pveLink })} + + +

{t("who.heading")}

+

+ {t.rich("who.body", { strong, em, lxcLink })} +

+ + {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 })} }, + ]} + /> + + + {t.rich("pickOne.body", { code })} + +
    +
  • {t.rich("pickOne.vmItem", { strong })}
  • +
  • {t.rich("pickOne.lxcItem", { strong, lxcLink })}
  • +
+ +

{t("running.heading")}

+

+ {t.rich("running.body", { strong })} +

+ + {t("running.imageAlt")} + +

{t("howRuns.heading")}

+

{t("howRuns.body")}

+ +
+{`┌─────────────────────────────────────────────┐
+│  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 ):
+                          ├─ hostpci0 = GPU (x-vga=1 unless Intel iGPU)
+                          ├─ hostpci1..n = IOMMU group siblings
+                          ├─ hostpci = 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.`}
+      
+ +

{t("walkthrough.heading")}

+ + + +

{t.rich("walkthrough.detect.body", { code })}

+ + {t.rich("walkthrough.detect.tipBody", { postLink })} + + {t("walkthrough.detect.imageAlt")} +
+ + +

{t("walkthrough.preflight.intro")}

+
    + {preflightItems.map((_, idx) => ( +
  • {t.rich(`walkthrough.preflight.items.${idx}`, { strong, code, em })}
  • + ))} +
  • + {t.rich("walkthrough.preflight.audioIntro", { strong })} +
      +
    • {t.rich("walkthrough.preflight.audioDgpu", { strong, code })}
    • +
    • {t.rich("walkthrough.preflight.audioIgpu", { strong, code })}
    • +
    +
  • +
+
+ + +

{t("walkthrough.pickVm.body")}

+ {t("walkthrough.pickVm.imageAlt")} +
+ + +

{t("walkthrough.switchMode.intro")}

+
    + {switchModeItems.map((_, idx) => ( +
  • {t.rich(`walkthrough.switchMode.items.${idx}`, { strong, code })}
  • + ))} +
+ {t("walkthrough.switchMode.imageAlt")} + + + {t.rich("walkthrough.switchMode.smartBody", { strong, code })} + +
+ + +

{t("walkthrough.audioPick.body")}

+ {t("walkthrough.audioPick.imageAlt")} + + {t("walkthrough.audioPick.warnBody")} + +
+ + +

{t("walkthrough.summary.body")}

+ {t("walkthrough.summary.imageAlt")} +
+ + +

{t("walkthrough.hostApply.intro")}

+
    + {hostApplyItems.map((_, idx) => ( +
  • {t.rich(`walkthrough.hostApply.items.${idx}`, { strong, code })}
  • + ))} +
+
+ + +

{t.rich("walkthrough.vmApply.body", { code })}

+ +

{t.rich("walkthrough.vmApply.after1", { code, strong })}

+

{t.rich("walkthrough.vmApply.after2", { code })}

+
+ + +

{t("walkthrough.reboot.body")}

+ {t("walkthrough.reboot.imageAlt")} +
+
+ +

{t("vendors.heading")}

+ +

{t("vendors.nvidiaHeading")}

+

+ {t.rich("vendors.nvidiaBody", { em, code })} +

+ +

{t("vendors.nvidiaMultiHeading")}

+

+ {t.rich("vendors.nvidiaMultiBody", { strong, code })} +

+ +

{t("vendors.amdHeading")}

+

+ {t.rich("vendors.amdBody", { em, vendorResetLink })} +

+ +

{t("vendors.intelHeading")}

+

+ {t.rich("vendors.intelBody", { code, sriovLink })} +

+ +

{t("verification.heading")}

+ + +# Expect: "Kernel driver in use: vfio-pci" + +# Start the VM and watch for successful binding +qm start +journalctl -u qemu-server@.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" + /> + +

{t("troubleshoot.heading")}

+ + + {t.rich("troubleshoot.code43Body", { code })} + + + + {t.rich("troubleshoot.amdResetBody", { code, em })} + + + + {t.rich("troubleshoot.stuckBootBody", { code })} + + + + {t.rich("troubleshoot.darkBody", { code })} + + + .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" + /> + + + {t.rich("troubleshoot.logBody", { code })} + + +

{t("revert.heading")}

+

{t("revert.intro")}

+ --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" + /> + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/hardware/igpu-acceleration-lxc/page.tsx b/web/app/[locale]/docs/hardware/igpu-acceleration-lxc/page.tsx new file mode 100644 index 00000000..1b4d6c20 --- /dev/null +++ b/web/app/[locale]/docs/hardware/igpu-acceleration-lxc/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const vmLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const switchLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const nvidiaLink = (chunks: React.ReactNode) => ( + {chunks} + ) + + return ( +
+ + + + {t.rich("intro.body", { code })} + + +

{t("compare.heading")}

+

+ {t.rich("compare.intro", { em, vmLink })} +

+
+ + + + + + + + + + {compareRows.map((row, idx) => ( + + + + + + ))} + +
{t("compare.headerFeature")}{t("compare.headerLxc")} + + {t("compare.headerVm")} + +
{row.feature}{row.lxc}{row.vm}
+
+ + {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 })}, + }, + ]} + /> + + + {t.rich("unpriv.body", { code })} + + +

{t("running.heading")}

+

+ {t.rich("running.body", { strong })} +

+ + {t("running.imageAlt")} + +

{t("howRuns.heading")}

+

{t("howRuns.body")}

+ +
+{`┌─────────────────────────────────────────────┐
+│  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/.conf):
+                       ├─ Intel / AMD iGPU:
+                       │    dev: /dev/dri/card*   gid=video
+                       │    dev: /dev/dri/renderD* gid=render
+                       ├─ AMD with ROCm (if /dev/kfd):
+                       │    dev: /dev/kfd  gid=render
+                       └─ NVIDIA:
+                            dev: /dev/nvidia0..N
+                            dev: /dev/nvidiactl · nvidia-uvm*
+                            dev: /dev/nvidia-modeset
+                            dev: /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`}
+      
+ +

{t("walkthrough.heading")}

+ + + +

{t.rich("walkthrough.detect.body", { code })}

+ + {t.rich("walkthrough.detect.tipBody", { nvidiaLink })} + +
+ + +

{t("walkthrough.pickCt.body")}

+ {t("walkthrough.pickCt.imageAlt")} +
+ + +

{t("walkthrough.selectGpu.body")}

+ {t("walkthrough.selectGpu.imageAlt")} +
+ + + {t("walkthrough.preflight.imageAlt")} +

{t("walkthrough.preflight.intro")}

+
    + {preflightItems.map((_, idx) => ( +
  • {t.rich(`walkthrough.preflight.items.${idx}`, { strong, code, switchLink })}
  • + ))} +
+
+ + +

{t.rich("walkthrough.applyConfig.body1", { code })}

+

{t("walkthrough.applyConfig.body2")}

+ .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" + /> +
+ + +

{t.rich("walkthrough.installDrivers.body", { code })}

+
+ + + + + + + + + + {distroRows.map((row, idx) => ( + + + + + + ))} + + + + + + +
{t("walkthrough.installDrivers.headerDistro")}{t("walkthrough.installDrivers.headerInt")}{t("walkthrough.installDrivers.headerNvidia")}
{row.distro}{row.intel}{row.nvidia}
{t("walkthrough.installDrivers.debianDistro")}{t("walkthrough.installDrivers.debianIntel")}{t.rich("walkthrough.installDrivers.debianNvidia", { code })}
+
+ + {t.rich("walkthrough.installDrivers.whyBody", { code, strong })} + +
+ + +

{t.rich("walkthrough.alignGids.body1", { code })}

+

{t("walkthrough.alignGids.body2")}

+
+
+ +

{t("vendors.heading")}

+ +

{t("vendors.intelHeading")}

+

+ {t.rich("vendors.intelBody", { em, code })} +

+ +

{t("vendors.amdHeading")}

+

+ {t.rich("vendors.amdBody", { code })} +

+ +

{t("vendors.nvidiaHeading")}

+

+ {t.rich("vendors.nvidiaBody", { code, strong })} +

+ + + {t.rich("vendors.updateBody", { code, nvidiaLink })} + + +

{t("verification.heading")}

+

{t("verification.body")}

+ + +# 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" + /> + +

{t("troubleshoot.heading")}

+ + + {t.rich("troubleshoot.mismatchBody", { code })} + + + + {t.rich("troubleshoot.denyBody", { code })} + + + + {t.rich("troubleshoot.vainfoBody", { code })} + + + + {t.rich("troubleshoot.logBody", { code })} + + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/hardware/install-coral-tpu-host/page.tsx b/web/app/[locale]/docs/hardware/install-coral-tpu-host/page.tsx new file mode 100644 index 00000000..58bc1d94 --- /dev/null +++ b/web/app/[locale]/docs/hardware/install-coral-tpu-host/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const lxcLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const feranickLink = (chunks: React.ReactNode) => ( + + {chunks} + + + ) + const googleLink = (chunks: React.ReactNode) => ( + + {chunks} + + + ) + + return ( +
+ + + + {t.rich("intro.body", { strong, code })} + + +

{t("which.heading")}

+

{t("which.body")}

+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
{t("which.headerForm")}{t("which.headerDetect")}{t("which.headerInstall")}{t("which.headerReboot")}
+ {t("which.pcieForm")} +
+ {t("which.pcieFormSub")} +
{t.rich("which.pcieDetect", { code })}{t("which.pcieInstall")}{t("which.pcieReboot")}
+ {t("which.usbForm")} +
+ {t("which.usbFormSub")} +
{t.rich("which.usbDetect", { code })}{t.rich("which.usbInstall", { code })}{t("which.usbReboot")}
+
+ + {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 })} }, + ]} + /> + + + {t.rich("hostPrepTip.body", { em, lxcLink })} + + +

{t("running.heading")}

+

+ {t.rich("running.body", { strong })} +

+ + {t("running.imageAlt")} + +

{t("howRuns.heading")}

+

{t("howRuns.body")}

+ +
+{`┌────────────────────────────────────────────────┐
+│ 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)`}
+      
+ +

{t("walkthrough.heading")}

+ + + +

{t.rich("walkthrough.detect.body", { code, strong })}

+
    + {detectItems.map((_, idx) => ( +
  • {t.rich(`walkthrough.detect.items.${idx}`, { code, em })}
  • + ))} +
+

{t("walkthrough.detect.outro")}

+
+ + +

{t("walkthrough.prompt.body")}

+ {t("walkthrough.prompt.imageAlt")} +
+ + +

{t("walkthrough.pcie.body")}

+
    + {pcieItems.map((_, idx) => ( +
  1. {t.rich(`walkthrough.pcie.items.${idx}`, { strong, code })}
  2. + ))} +
  3. + {t.rich("walkthrough.pcie.cloneIntro", { strong, feranickLink, googleLink })} +
      + {kernelPatches.map((_, idx) => ( +
    • {t.rich(`walkthrough.pcie.kernelPatches.${idx}`, { code })}
    • + ))} +
    +
  4. + {pcieAfterItems.map((_, idx) => ( +
  5. {t.rich(`walkthrough.pcie.afterItems.${idx}`, { strong, code })}
  6. + ))} +
+ {t("walkthrough.pcie.imageAlt")} +
+ + +

{t("walkthrough.usb.body")}

+
    + {usbItems.map((_, idx) => ( +
  1. {t.rich(`walkthrough.usb.items.${idx}`, { strong, code })}
  2. + ))} +
+ + {t.rich("walkthrough.usb.stdBody", { code })} + +
+ + +

{t.rich("walkthrough.reboot.body", { code })}

+ {t("walkthrough.reboot.imageAlt")} +
+
+ +

{t("reinstallUninstall.heading")}

+ +

+ {t.rich("reinstallUninstall.intro", { code })} +

+ +
+ {t("reinstallUninstall.imageAlt")} +
+ {t.rich("reinstallUninstall.imageCaption", { code })} +
+
+ +

{t("reinstallUninstall.reinstallHeading")}

+

+ {t.rich("reinstallUninstall.reinstallBody", { code })} +

+ +

{t("reinstallUninstall.uninstallHeading")}

+

+ {t.rich("reinstallUninstall.uninstallIntro", { code })} +

+
    + {uninstallItems.map((_, idx) => ( +
  • {t.rich(`reinstallUninstall.uninstallItems.${idx}`, { code, strong })}
  • + ))} +
+ + + {t("reinstallUninstall.lxcWarnBody")} + + +

{t("updates.heading")}

+ +

{t("updates.intro")}

+ +
+ + + + + + + + + + + + + + + + + + + + +
{t("updates.headerVariant")}{t("updates.headerTracked")}{t("updates.headerUpstream")}
{t("updates.pcieVariant")}{t.rich("updates.pcieTracked", { code })}{t.rich("updates.pcieUpstream", { code })}
{t("updates.usbVariant")}{t.rich("updates.usbTracked", { code })}{t.rich("updates.usbUpstream", { code })}
+
+ +

+ {t.rich("updates.outro", { code, strong })} +

+ + + {t("updates.antiBody")} + + + + {t("updates.rebootBody")} + + +

{t("manual.heading")}

+

{t("manual.intro")}

+ +

{t("manual.pcieHeading")}

+ /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" + /> + +

{t("manual.usbHeading")}

+ /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" + /> + +

{t("verification.heading")}

+ +

{t("verification.pcieHeading")}

+ + +

{t("verification.usbHeading")}

+ + +

{t("troubleshoot.heading")}

+ + + {t.rich("troubleshoot.dkmsFailBody", { code })} + + + + {t.rich("troubleshoot.apexMissBody", { code })} + + + + {t.rich("troubleshoot.lxcMissBody", { lxcLink })} + + + + {t.rich("troubleshoot.usbUnreachBody", { code })} + + + + {t.rich("troubleshoot.logBody", { code })} + + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/hardware/nvidia-host/page.tsx b/web/app/[locale]/docs/hardware/nvidia-host/page.tsx new file mode 100644 index 00000000..a28639e8 --- /dev/null +++ b/web/app/[locale]/docs/hardware/nvidia-host/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const persistLink = (chunks: React.ReactNode) => ( + + {chunks} + + + ) + const patchLink = (chunks: React.ReactNode) => ( + + {chunks} + + + ) + const patchTableLink = (chunks: React.ReactNode) => ( + + {chunks} + + + ) + const guideLink = (chunks: React.ReactNode) => ( + {chunks} + ) + + return ( +
+ + + + {t.rich("intro.body", { code })} + + +

{t("who.heading")}

+

+ {t.rich("who.body", { strong, em })} +

+ + {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 })} }, + ]} + /> + + + {t.rich("vmWarn.body", { code })} + + +

{t("running.heading")}

+

+ {t.rich("running.body", { strong })} +

+ + {t("running.imageAlt")} + +

{t("howRuns.heading")}

+

{t("howRuns.body")}

+ +
+{`┌─────────────────────────────────────────────┐
+│  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.8–16  → driver 550+
+  ├─ Kernel 6.2–7   → 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`}
+      
+ + + +

{t.rich("walkthrough.detect.body1", { em })}

+

{t("walkthrough.detect.body2")}

+ {t("walkthrough.detect.imageAlt")} +
+ + +

{t.rich("walkthrough.version.body1", { strong, em })}

+

{t("walkthrough.version.body2")}

+ +
+ + + + + + + + + + {matrixRows.map((row, idx) => ( + + + + + + ))} + +
{t("walkthrough.version.headerKernel")}{t("walkthrough.version.headerPve")}{t("walkthrough.version.headerMin")}
{row.kernel}{row.pve} + {row.minCode}{row.minTail} +
+
+ + + {t("walkthrough.version.whyBody")} + + + {t("walkthrough.version.imageAlt")} +
+ + +

{t.rich("walkthrough.uninstall.body", { code })}

+
+ + +

{t("walkthrough.prepare.body")}

+
    + {prepareItems.map((_, idx) => ( +
  • {t.rich(`walkthrough.prepare.items.${idx}`, { code })}
  • + ))} +
+
+ + +

{t.rich("walkthrough.download.body", { code })}

+ .run \\ + --no-questions \\ + --ui=none \\ + --disable-nouveau \\ + --no-nouveau-check \\ + --dkms`} + className="my-4" + /> + {t("walkthrough.download.imageAlt")} +
+ + +

{t.rich("walkthrough.persist.body", { code, persistLink })}

+
+ + +

{t.rich("walkthrough.nvenc.body", { strong, patchLink })}

+ + {t.rich("walkthrough.nvenc.supportBody", { patchTableLink })} + +
+ + +

{t.rich("walkthrough.propagate.body1", { code, strong })}

+

{t.rich("walkthrough.propagate.body2", { code })}

+ {t("walkthrough.propagate.imageAlt")} +
+ + +

{t.rich("walkthrough.reboot.body", { code, strong })}

+
+
+ +

{t("reinstallUninstall.heading")}

+ +

+ {t.rich("reinstallUninstall.intro", { code })} +

+ +
+ {t("reinstallUninstall.imageAlt")} +
+ {t("reinstallUninstall.imageCaption")} +
+
+ +

{t("reinstallUninstall.reinstallHeading")}

+

{t("reinstallUninstall.reinstallBody")}

+ +

{t("reinstallUninstall.uninstallHeading")}

+

{t("reinstallUninstall.uninstallIntro")}

+
    + {uninstallItems.map((_, idx) => ( +
  • {t.rich(`reinstallUninstall.uninstallItems.${idx}`, { code })}
  • + ))} +
+ + + {t("reinstallUninstall.lxcWarnBody")} + + +

{t("updates.heading")}

+ +

+ {t.rich("updates.body", { code })} +

+ +

{t("updates.kindsHeading")}

+
    + {kindsItems.map((_, idx) => ( +
  • {t.rich(`updates.kindsItems.${idx}`, { strong })}
  • + ))} +
+ + + {t("updates.antiBody")} + + + + {t.rich("updates.applyBody", { strong })} + + +

{t("verify.heading")}

+

{t("verify.intro")}

+ +

{t("verify.after")}

+ + + {t("verify.imageAlt")} + +

{t("troubleshoot.heading")}

+ + + {t.rich("troubleshoot.smiFailBody", { strong, code })} + + + + {t.rich("troubleshoot.lxcMissBody", { code })} + + + + {t.rich("troubleshoot.logBody", { code })} + + +

{t("manualSteps.heading")}

+

+ {t.rich("manualSteps.body", { code, guideLink })} +

+ +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/hardware/switch-gpu-mode/page.tsx b/web/app/[locale]/docs/hardware/switch-gpu-mode/page.tsx new file mode 100644 index 00000000..5f704c24 --- /dev/null +++ b/web/app/[locale]/docs/hardware/switch-gpu-mode/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const vmLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const lxcLink = (chunks: React.ReactNode) => ( + {chunks} + ) + + return ( +
+ + + + {t.rich("intro.body", { code, em, strong })} + + +
+ + +
+ +

{t("when.heading")}

+

+ {t.rich("when.intro", { strong })} +

+
+ + + + + + + + + {whenRows.map((row, idx) => ( + + + + + ))} + +
{t("when.headerSituation")}{t("when.headerUse")}
+ {row.situationRich + ? t.rich(`when.rows.${idx}.situationRich`, { code }) + : row.situation} + + {row.useRich + ? t.rich(`when.rows.${idx}.useRich`, { vmLink, lxcLink, strong }) + : row.use} +
+
+ + {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 })} }, + ]} + /> + + + {t.rich("blocklist.body", { code, em })} + + +

{t("running.heading")}

+

+ {t.rich("running.body", { strong })} +

+ + {t("running.imageAlt")} + +

{t("howRuns.heading")}

+

{t("howRuns.body")}

+ +
+{`┌─────────────────────────────────────────────┐
+│  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`}
+      
+ +

{t("walkthrough.heading")}

+ + + +

{t.rich("walkthrough.detect.body", { code })}

+ {t("walkthrough.detect.imageAlt")} +
+ + +

{t.rich("walkthrough.pickGpu.body", { em })}

+ + {t("walkthrough.pickGpu.tipBody")} + +
+ + +

{t("walkthrough.direction.intro")}

+
    + {directionItems.map((_, idx) => ( +
  • {t.rich(`walkthrough.direction.items.${idx}`, { strong, code })}
  • + ))} +
+

{t("walkthrough.direction.outro")}

+
+ + +

{t.rich("walkthrough.conflict.body", { code })}

+
+ + + + + + + + + + + + + + + + + + + + +
{t("walkthrough.conflict.headerPolicy")}{t("walkthrough.conflict.headerEffect")}{t("walkthrough.conflict.headerWhen")}
{t("walkthrough.conflict.keepPolicy")}{t.rich("walkthrough.conflict.keepEffect", { code })}{t("walkthrough.conflict.keepWhen")}
{t("walkthrough.conflict.removePolicy")}{t.rich("walkthrough.conflict.removeEffect", { code })}{t("walkthrough.conflict.removeWhen")}
+
+ {t("walkthrough.conflict.imageAlt")} +
+ + +

{t.rich("walkthrough.audio.body1", { code })}

+

{t.rich("walkthrough.audio.body2", { code })}

+
+ + +

{t.rich("walkthrough.apply.body", { code })}

+
+ + +

{t("walkthrough.reboot.body")}

+ {t("walkthrough.reboot.imageAlt")} +
+
+ +

{t("manual.heading")}

+

+ {t.rich("manual.intro", { strong, code })} +

+ .conf + +# Rebuild initramfs and reboot +update-initramfs -u -k all +reboot`} + className="my-4" + /> + +

+ {t.rich("manual.lxcToVm", { strong })} +

+ > /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" + /> + + + {t.rich("manual.oneVmBody", { code })} + + +

{t("verification.heading")}

+ +# 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" + /> + +

{t("troubleshoot.heading")}

+ + + {t.rich("troubleshoot.stillVfioBody", { code })} + + + + {t.rich("troubleshoot.vmFailBody", { code, em })} + + \\.[0-7]([,[:space:]]|$)/d' \\ + /etc/pve/qemu-server/.conf`} + className="my-4" + /> + + + {t.rich("troubleshoot.smiFailBody", { code })} + + + + {t.rich("troubleshoot.logBody", { code })} + + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/help-info/backup-commands/page.tsx b/web/app/[locale]/docs/help-info/backup-commands/page.tsx new file mode 100644 index 00000000..1c627cd2 --- /dev/null +++ b/web/app/[locale]/docs/help-info/backup-commands/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + + return ( +
+ + + + {t.rich("intro.body", { strong })} + + + + + + {t.rich("testRestores.bodyRich", { code })} + + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/help-info/gpu-commands/page.tsx b/web/app/[locale]/docs/help-info/gpu-commands/page.tsx new file mode 100644 index 00000000..4f722aa1 --- /dev/null +++ b/web/app/[locale]/docs/help-info/gpu-commands/page.tsx @@ -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 { + 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) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + + return ( +
+ + + + {t.rich("intro.body", { em, code })} + + + + + + {t.rich("afterChangesTip.bodyRich", { code })} + + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/help-info/network-commands/page.tsx b/web/app/[locale]/docs/help-info/network-commands/page.tsx new file mode 100644 index 00000000..a5eaa138 --- /dev/null +++ b/web/app/[locale]/docs/help-info/network-commands/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + + return ( +
+ + + + {t.rich("intro.body", { code })} + + + + + + {t.rich("iptablesTip.bodyRich", { strong, code })} + + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/help-info/page.tsx b/web/app/[locale]/docs/help-info/page.tsx new file mode 100644 index 00000000..4d492c6e --- /dev/null +++ b/web/app/[locale]/docs/help-info/page.tsx @@ -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 { + 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> = { + Terminal, + HardDrive, + Network, + Package, + Cpu, + Database, + Archive, + Wrench, +} + +function OptionCard({ option }: { option: Option }) { + const Icon = ICONS[option.icon] || Terminal + return ( + + + + +
+
+ {option.title} + +
+
{option.description}
+
+ + ) +} + +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) => {chunks} + + return ( +
+ + + + {t("intro.body")} + + +

{t("opening.heading")}

+

+ {t.rich("opening.body", { kbd })} +

+ + {t("opening.imageAlt")} + +

{t("categories.heading")}

+
+ {options.map((o) => ( + + ))} +
+ + + {t("tip.body")} + +
+ ) +} diff --git a/web/app/[locale]/docs/help-info/storage-commands/page.tsx b/web/app/[locale]/docs/help-info/storage-commands/page.tsx new file mode 100644 index 00000000..c309c5b2 --- /dev/null +++ b/web/app/[locale]/docs/help-info/storage-commands/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + + return ( +
+ + + + {t.rich("intro.body", { code })} + + + + + + {t.rich("lvmTip.bodyRich", { code })} + + + + {t.rich("smartInfo.bodyRich", { strong, code })} + + + + {t.rich("selfTestWarn.bodyRich", { code })} + + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/help-info/system-commands/page.tsx b/web/app/[locale]/docs/help-info/system-commands/page.tsx new file mode 100644 index 00000000..2486e0a2 --- /dev/null +++ b/web/app/[locale]/docs/help-info/system-commands/page.tsx @@ -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 { + 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) => {chunks} + + return ( +
+ + + + {t.rich("intro.body", { em })} + + + + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/help-info/tools-commands/page.tsx b/web/app/[locale]/docs/help-info/tools-commands/page.tsx new file mode 100644 index 00000000..4a02afd7 --- /dev/null +++ b/web/app/[locale]/docs/help-info/tools-commands/page.tsx @@ -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 { + 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) => {chunks} + const utilsLink = (chunks: React.ReactNode) => ( + {chunks} + ) + + return ( +
+ + + + {t.rich("intro.body", { utilsLink })} + + + + + + {t.rich("tmuxTip.bodyRich", { code })} + + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/help-info/update-commands/page.tsx b/web/app/[locale]/docs/help-info/update-commands/page.tsx new file mode 100644 index 00000000..d9d888ab --- /dev/null +++ b/web/app/[locale]/docs/help-info/update-commands/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const upgradeLink = (chunks: React.ReactNode) => ( + {chunks} + ) + + return ( +
+ + + + {t.rich("intro.body", { code })} + + + + + + {t.rich("backupWarn.bodyRich", { code, strong, upgradeLink })} + + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/help-info/vm-ct-commands/page.tsx b/web/app/[locale]/docs/help-info/vm-ct-commands/page.tsx new file mode 100644 index 00000000..9f1453c5 --- /dev/null +++ b/web/app/[locale]/docs/help-info/vm-ct-commands/page.tsx @@ -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 { + 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) => {chunks} + const backupLink = (chunks: React.ReactNode) => ( + {chunks} + ) + + return ( +
+ + + + {t.rich("intro.body", { code })} + + + + + + {t.rich("destroyWarn.bodyRich", { code, backupLink })} + + + + {t.rich("qmConfigTip.bodyRich", { code })} + + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/help-info/zfs-commands/page.tsx b/web/app/[locale]/docs/help-info/zfs-commands/page.tsx new file mode 100644 index 00000000..9337c71e --- /dev/null +++ b/web/app/[locale]/docs/help-info/zfs-commands/page.tsx @@ -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 { + 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) => {chunks} + + return ( +
+ + + + {t.rich("intro.body", { code })} + + + + + + {t.rich("bestPractices.bodyRich", { code })} + + +

{t("related.heading")}

+
    + {relatedItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/installation/page.tsx b/web/app/[locale]/docs/installation/page.tsx new file mode 100644 index 00000000..4a3d2118 --- /dev/null +++ b/web/app/[locale]/docs/installation/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + + const internalLink = (href: string, className = "text-blue-600 hover:underline") => + (chunks: React.ReactNode) => ( + + {chunks} + + ) + + const extlink = (href: string, className = "text-blue-600 hover:underline inline-flex items-center gap-1") => + (chunks: React.ReactNode) => ( + + {chunks} + + + ) + + return ( +
+ + +

+ + {t("stable.heading")} +

+

{t("stable.intro")}

+ + +

{t("during.heading")}

+

{t("during.intro")}

+
+ + + + + + + + + {duringRows.map((row, idx) => ( + + + + + ))} + +
{t("during.tablePackage")}{t("during.tablePurpose")}
{row.package}{row.purpose}
+
+ +

{t.rich("during.outro", { code, strong })}

+ + {t("during.imageAlt")} + +

{t("first.heading")}

+

{t("first.intro")}

+ + +

+ {t.rich("first.outro", { postlink: internalLink("/docs/post-install") })} +

+ +

{t("beta.heading")}

+ + {t.rich("beta.calloutBody", { code })} + + +

{t("beta.intro")}

+ + +

+ {t.rich("beta.outro", { code, betalink: internalLink("/docs/settings/beta-program") })} +

+ +

{t("updating.heading")}

+

{t.rich("updating.body", { code })}

+ +

{t("uninstall.heading")}

+

+ {t.rich("uninstall.body", { + strong, + code, + uninstalllink: internalLink("/docs/settings/uninstall-proxmenux"), + })} +

+ +

{t("troubleshoot.heading")}

+ + + {t.rich("troubleshoot.virustotalBody", { + code, + em, + issuelink: extlink("https://github.com/MacRimi/ProxMenux/issues/162"), + })} + + + + {t.rich("troubleshoot.aptBody", { code })} + + + + {t.rich("troubleshoot.menuBody", { code })} + + + + {t.rich("troubleshoot.stuckBody", { code, strong })} + + + + {t.rich("troubleshoot.otherBody", { + code, + issueslink: extlink("https://github.com/MacRimi/ProxMenux/issues"), + })} + + +

{t("next.heading")}

+
    +
  • {t.rich("next.postInstall", { postlink: internalLink("/docs/post-install") })}
  • +
  • {t.rich("next.introduction", { introlink: internalLink("/docs/introduction") })}
  • +
  • {t.rich("next.monitor", { monitorlink: internalLink("/docs/settings/proxmenux-monitor") })}
  • +
+ +

{t("requirements.heading")}

+ + + {t.rich("requirements.reqBody", { strong })} + + + +
    +
  • + {" "} + {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", + ), + })} +
  • +
  • + {" "} + {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", + ), + })} +
  • +
+
+
+ ) +} diff --git a/web/app/[locale]/docs/introduction/page.tsx b/web/app/[locale]/docs/introduction/page.tsx new file mode 100644 index 00000000..de01beed --- /dev/null +++ b/web/app/[locale]/docs/introduction/page.tsx @@ -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 { + 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> = { + 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 ( + + + + +
+
+ {title} + +
+
{description}
+
+ + ) +} + +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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const github = (chunks: React.ReactNode) => ( + + {chunks} + + + ) + const monitorOverviewLink = (chunks: React.ReactNode) => ( + + {chunks} + + ) + const installationLink = (chunks: React.ReactNode) => ( + + {chunks} + + ) + + return ( +
+ + +
+ ProxMenux Logo +
+

+ {t.rich("hero.tagline", { strong })} +

+

+ {t.rich("hero.audience", { em, github })} +

+
+
+ +

{t("twoProducts.heading")}

+

{t("twoProducts.intro")}

+ +
+
+
+ +

{t("twoProducts.scripts.title")}

+
+

+ {t.rich("twoProducts.scripts.body", { code })} +

+
+
+
+ +

{t("twoProducts.monitor.title")}

+
+

+ {t("twoProducts.monitor.body")} +

+
+
+ + + {t("twoProducts.calloutIntro")} +
    + {layers.map((_, idx) => ( +
  • {t.rich(`twoProducts.layers.${idx}`, { strong })}
  • + ))} +
+
+ +

{t("scriptsSection.heading")}

+

+ {t.rich("scriptsSection.intro", { code })} +

+ +
+ {scriptItems.map((f) => { + const Icon = iconMap[f.icon] ?? Server + return + })} +
+ +

{t("monitorSection.heading")}

+

+ {t.rich("monitorSection.intro", { link: monitorOverviewLink })} +

+ +
+ {monitorItems.map((f) => { + const Icon = iconMap[f.icon] ?? Activity + return + })} +
+ +

{t("installPaths.heading")}

+

{t("installPaths.intro")}

+
+ + + + + + + + + {installRows.map((row, idx) => ( + + + + + ))} + +
{t("installPaths.headerPath")}{t("installPaths.headerBundles")}
+ {t.rich(`installPaths.rows.${idx}.pathRich`, { strong })} + {row.bundles}
+
+

+ {t.rich("installPaths.outro", { link: installationLink })} +

+ + + {t("warnSource.body")}{" "} + + {t("warnSource.sourceLabel")} + + + {" "}·{" "} + + {t("warnSource.cocLabel")} + + + + +

{t("next.heading")}

+
    + {nextItems.map((item, idx) => ( +
  • + {t.rich(`next.items.${idx}.lead`, { strong })} + + {item.linkLabel} + + {item.tailRich ? t.rich(`next.items.${idx}.tailRich`, { code }) : item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/layout.tsx b/web/app/[locale]/docs/layout.tsx new file mode 100644 index 00000000..cf109d65 --- /dev/null +++ b/web/app/[locale]/docs/layout.tsx @@ -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 ( +
+ +
+
+
+
+ + {children} + +
+
+ +
+
+
+
+ ) +} diff --git a/web/app/[locale]/docs/monitor/access-auth/page.tsx b/web/app/[locale]/docs/monitor/access-auth/page.tsx new file mode 100644 index 00000000..ee2a4469 --- /dev/null +++ b/web/app/[locale]/docs/monitor/access-auth/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const apiLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const intLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const gatewayLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const fail2banLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const tailscaleAnchor = (chunks: React.ReactNode) => ( + + {chunks} + + ) + const tsKeysAnchor = (chunks: React.ReactNode) => ( + + {chunks} + + ) + + return ( +
+ + + + {t.rich("intro.body", { em, strong })} + + +

{t("reaching.heading")}

+

+ {t.rich("reaching.intro", { code })} +

+ :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://:8008 # works from any device on your tailnet`} + className="my-4" + /> +

+ {t.rich("reaching.outro", { code })} +

+ +

{t("firstLaunch.heading")}

+

+ {t.rich("firstLaunch.intro", { code, em })} +

+ +
+ {t("firstLaunch.imageAlt")} +
{t("firstLaunch.imageCaption")}
+
+ +
+ + + + + + + + + + {firstLaunchRows.map((row, idx) => ( + + + + + + ))} + +
{t("firstLaunch.headerButton")}{t("firstLaunch.headerWhat")}{t("firstLaunch.headerApi")}
{row.button}{t.rich(`firstLaunch.rows.${idx}.what`, { em, code })}{row.api}
+
+ + + {t.rich("firstLaunch.twofaCalloutBody", { strong })} + + +

{t("firstLaunch.createTitle")}

+ +

{t.rich("firstLaunch.createIntro", { em })}

+ +
+ + + + + + + + + + {fieldRows.map((row, idx) => ( + + + + + + ))} + +
{t("firstLaunch.headerField")}{t("firstLaunch.headerRequired")}{t("firstLaunch.headerNotes")}
{row.field}{row.required}{t.rich(`firstLaunch.fieldRows.${idx}.notes`, { code, strong })}
+
+ +
+ {t("firstLaunch.createImageAlt")} +
{t("firstLaunch.createImageCaption")}
+
+ + + {t.rich("firstLaunch.saveCalloutBody", { code })} + + +

{t("firstLaunch.avatarTitle")}

+ +

+ {t.rich("firstLaunch.avatarBody1", { strong })} +

+ +

{t("firstLaunch.avatarBody2")}

+ +
+ {t("firstLaunch.profileImageAlt")} +
{t("firstLaunch.profileImageCaption")}
+
+ +
+ + + + + + + + + {endpointRows.map((row, idx) => ( + + + + + ))} + +
{t("firstLaunch.headerEndpoint")}{t("firstLaunch.headerEpWhat")}
{row.endpoint}{t.rich(`firstLaunch.endpointRows.${idx}.what`, { code })}
+
+ + + {t.rich("firstLaunch.reversibleBody", { em, strong, code })} + + +

{t("password.heading")}

+

+ {t.rich("password.intro", { code })} +

+
    + {passwordItems.map((_, idx) => ( +
  • {t.rich(`password.items.${idx}`, { strong, code })}
  • + ))} +
+ +
+ {t("password.loginImageAlt")} +
{t("password.loginImageCaption")}
+
+ +

{t("password.loginFlowTitle")}

+ :8008/api/auth/login \\ + -H "Content-Type: application/json" \\ + -d '{"username":"","password":""}' + +# Response +{ + "success": true, + "token": "eyJhbGciOiJIUzI1NiIs..." +}`} + className="my-4" + /> +

+ {t.rich("password.twofaIntro", { code })} +

+ :8008/api/auth/login \\ + -H "Content-Type: application/json" \\ + -d '{"username":"","password":"","totp_token":"123456"}'`} + className="my-4" + /> + +

{t("password.publicTitle")}

+

{t("password.publicIntro")}

+
    + {publicItems.map((_, idx) => ( +
  • {t.rich(`password.publicItems.${idx}`, { code })}
  • + ))} +
+ +

{t("password.cryptoTitle")}

+

+ {t.rich("password.cryptoIntro", { code })} +

+ +
+ + + + + + + + + + {cryptoRows.map((row, idx) => ( + + + + + + ))} + +
{t("password.headerAsset")}{t("password.headerAlgo")}{t("password.headerWhere")}
{row.asset}{t.rich(`password.cryptoRows.${idx}.algorithm`, { code })}{t.rich(`password.cryptoRows.${idx}.where`, { code })}
+
+ + + {t.rich("password.authJsonBody", { code, em })} + + + + {t.rich("password.rotateBody", { code, strong })} + + +

{t("password.recoverTitle")}

+

+ {t.rich("password.recoverIntro", { code })} +

+ + +# - 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://:8008 +# The setup wizard appears — create a new admin account.`} + className="my-4" + /> + + + {t.rich("password.survivesBody", { code })} + + + + {t.rich("password.physicalBody", { strong, code })} + + +

{t("twofa.heading")}

+

+ {t.rich("twofa.intro", { strong })} +

+ +

{t("twofa.pickTitle")}

+

{t("twofa.pickIntro")}

+ +
+ + + + + + + + + + {apps.map((row, idx) => ( + + + + + + ))} + +
{t("twofa.headerApp")}{t("twofa.headerPlatforms")}{t("twofa.headerAppNotes")}
+ + {row.name} + + {row.platforms}{row.notes}
+
+ + + {t("twofa.backupBody")} + + +

{t("twofa.setupTitle")}

+ +
+ {t("twofa.setupImageAlt")} +
{t("twofa.setupImageCaption")}
+
+ +
    + {setupSteps.map((_, idx) => ( +
  1. + {t.rich(`twofa.setupSteps.${idx}`, { strong, em, code })} + {idx === 3 && ( +
      + {setupStep4Sub.map((_, sIdx) => ( +
    • {t.rich(`twofa.setupStep4Sub.${sIdx}`, { em, code })}
    • + ))} +
    + )} +
  2. + ))} +
+ + + {t.rich("twofa.testBody", { em, code })} + + +

{t("twofa.lostTitle")}

+

{t("twofa.lostIntro")}

+
    + {lostItems.map((_, idx) => ( +
  • + {t.rich(`twofa.lostItems.${idx}`, { strong, code })} + {idx === 2 && ( +
    {`systemctl restart proxmenux-monitor.service`}
    + )} +
  • + ))} +
+

{t("twofa.lostShellOutro")}

+ +

{t("twofa.disableTitle")}

+

+ {t.rich("twofa.disableBody", { strong, code })} +

+ + + {t("twofa.rejectedIntro")} +
    + {rejectedItems.map((_, idx) => ( +
  • {t.rich(`twofa.rejectedItems.${idx}`, { strong, code })}
  • + ))} +
+ {t("twofa.rejectedOutro")} +
+ +

{t("apiTokens.heading")}

+

+ {t.rich("apiTokens.intro", { strong, code })} +

+ +
+ {t("apiTokens.imageAlt")} +
{t("apiTokens.imageCaption")}
+
+ +

{t("apiTokens.generateTitle")}

+

{t("apiTokens.generateIntro")}

+
    + {generateSteps.map((_, idx) => ( +
  1. {t.rich(`apiTokens.generateSteps.${idx}`, { strong, em })}
  2. + ))} +
+ +

{t("apiTokens.generateCli")}

+ :8008/api/auth/generate-api-token \\ + -H "Authorization: Bearer " \\ + -H "Content-Type: application/json" \\ + -d '{ + "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" + /> + +

{t("apiTokens.useTitle")}

+ " \\ + http://:8008/api/system`} + className="my-4" + /> + +

{t("apiTokens.revokeTitle")}

+

+ {t.rich("apiTokens.revokeBody", { strong, code })} +

+ :8008/api/auth/api-tokens/ \\ + -H "Authorization: Bearer "`} + className="my-4" + /> + + +
    + {cheatItems.map((_, idx) => ( +
  • {t.rich(`apiTokens.cheatItems.${idx}`, { code })}
  • + ))} +
+
+

+ {t.rich("apiTokens.outro", { apiLink, intLink })} +

+ +

{t("https.heading")}

+

{t("https.intro")}

+
    + {httpsItems.map((_, idx) => ( +
  1. {t.rich(`https.items.${idx}`, { strong, code })}
  2. + ))} +
+ + {t("https.calloutBody")} + + +

{t("gateway.heading")}

+

+ {t.rich("gateway.intro", { strong, a: tailscaleAnchor })} +

+ + {t.rich("gateway.calloutBody", { code })} + +

+ {t.rich("gateway.deployBody", { a: tsKeysAnchor })} +

+

+ {t.rich("gateway.outro", { link: gatewayLink })} +

+ +

{t("proxy.heading")}

+

+ {t.rich("proxy.intro", { strong, code })} +

+ +

{t("proxy.nginxTitle")}

+ + +

{t("proxy.caddyTitle")}

+ + +

{t("proxy.traefikTitle")}

+ + + + {t.rich("proxy.subPathBody", { code, strong })} + + +

{t("audit.heading")}

+

+ {t.rich("audit.intro", { code })} +

+ +

+ {t.rich("audit.outro", { code })} +

+ +

{t("fail2ban.heading")}

+ + {t.rich("fail2ban.calloutBody", { strong, link: fail2banLink })} + +

+ {t.rich("fail2ban.intro", { code })} +

+
    + {fail2banItems.map((_, idx) => ( +
  • {t.rich(`fail2ban.items.${idx}`, { code })}
  • + ))} +
+

+ {t.rich("fail2ban.outro", { link: fail2banLink })} +

+ +

{t("troubleshoot.heading")}

+ + + {t.rich("troubleshoot.noScreenBody", { code })} +
{`rm /root/.config/proxmenux-monitor/auth.json
+systemctl restart proxmenux-monitor.service`}
+ {t.rich("troubleshoot.noScreenOutro", { code })} +
+ + + {t.rich("troubleshoot.tokenBody", { code })} +
{`curl -H "Authorization: Bearer " \\
+  http://:8008/api/system | jq .`}
+ {t.rich("troubleshoot.tokenOutro", { code })} +
+ + + {t.rich("troubleshoot.no2faBody", { code })} + + + + {t.rich("troubleshoot.wsBody", { code })} + + +

{t("whereNext.heading")}

+
    + {whereNextItems.map((item, idx) => ( +
  • + + {item.label} + + {item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/monitor/ai-assistant/page.tsx b/web/app/[locale]/docs/monitor/ai-assistant/page.tsx new file mode 100644 index 00000000..9ec052a4 --- /dev/null +++ b/web/app/[locale]/docs/monitor/ai-assistant/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const detailLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const notifLink = (chunks: React.ReactNode) => ( + {chunks} + ) + + const providerLink = (href: string) => (chunks: React.ReactNode) => + ( + + {chunks} + + + ) + + return ( +
+ + + + {t.rich("intro.body", { em })} + + +

{t("howItWorks.heading")}

+ +

{t("howItWorks.intro")}

+ +
    + {howSteps.map((_, idx) => ( +
  1. {t.rich(`howItWorks.steps.${idx}`, { strong, code, em })}
  2. + ))} +
+ +

{t("howItWorks.notesIntro")}

+ +
    + {howNotes.map((_, idx) => ( +
  • {t.rich(`howItWorks.notes.${idx}`, { strong })}
  • + ))} +
+ +

{t("enabling.heading")}

+ +

+ {t.rich("enabling.intro", { em })} +

+ +
+ {t("enabling.collapsedAlt")} +
{t("enabling.collapsedCaption")}
+
+ +
+ {t("enabling.panelAlt")} +
{t("enabling.panelCaption")}
+
+ +

+ {t.rich("enabling.outro", { em })} +

+ +

+ {t("context.heading")} +

+ +

{t("context.intro")}

+ +
+ + + + + + + + + + {contextRows.map((row, idx) => ( + + + + + + ))} + +
{t("context.headerBlock")}{t("context.headerWhen")}{t("context.headerWhat")}
{row.block}{t.rich(`context.rows.${idx}.when`, { code })}{t.rich(`context.rows.${idx}.what`, { code })}
+
+ +

{t("context.afterBlocks")}

+ + + + + {t("context.calloutBody")} + + +

{t("tokens.heading")}

+ +

{t.rich("tokens.intro1", { em })}

+ +

{t("tokens.intro2")}

+ +
    + {tokensItems.map((_, idx) => ( +
  • {t.rich(`tokens.items.${idx}`, { strong, em, code })}
  • + ))} +
+ +

+ {t.rich("tokens.capsIntro", { code })} +

+ +
+ + + + + + + + + + {tokensCapRows.map((row, idx) => ( + + + + + + ))} + +
{t("tokens.headerLevel")}{t("tokens.headerCap")}{t("tokens.headerConsumption")}
{row.level}{row.cap}{row.consumption}
+
+ +

{t("tokens.customNote")}

+ + + {t.rich("tokens.sizingBody", { code, link: detailLink })} + + +

{t("providers.heading")}

+ +

{t("providers.intro")}

+ +
+ {t("providers.imageAlt")} +
{t("providers.imageCaption")}
+
+ +

{t("providers.groq.heading")}

+

{t("providers.groq.tagline")}

+
    + {groqItems.map((_, idx) => ( +
  • {t.rich(`providers.groq.items.${idx}`, { code, strong, a: providerLink("https://console.groq.com/keys") })}
  • + ))} +
+ +

{t("providers.openai.heading")}

+

{t("providers.openai.tagline")}

+
    + {openaiItems.map((_, idx) => ( +
  • {t.rich(`providers.openai.items.${idx}`, { code, strong, a: providerLink("https://platform.openai.com/api-keys") })}
  • + ))} +
+ + + {t.rich("providers.openai.baseUrlBody", { em, strong, code })} + + +

{t("providers.anthropic.heading")}

+

{t("providers.anthropic.tagline")}

+
    + {anthropicItems.map((_, idx) => ( +
  • {t.rich(`providers.anthropic.items.${idx}`, { code, strong, a: providerLink("https://console.anthropic.com/settings/keys") })}
  • + ))} +
+ +

{t("providers.gemini.heading")}

+

{t("providers.gemini.tagline")}

+
    + {geminiItems.map((_, idx) => ( +
  • {t.rich(`providers.gemini.items.${idx}`, { code, strong, a: providerLink("https://aistudio.google.com/app/apikey") })}
  • + ))} +
+ +

{t("providers.openrouter.heading")}

+

{t("providers.openrouter.tagline")}

+
    + {openrouterItems.map((_, idx) => ( +
  • {t.rich(`providers.openrouter.items.${idx}`, { code, strong, a: providerLink("https://openrouter.ai/keys") })}
  • + ))} +
+ +

{t("providers.ollama.heading")}

+

{t("providers.ollama.tagline")}

+
    + {ollamaItems.map((_, idx) => ( +
  • {t.rich(`providers.ollama.items.${idx}`, { code, strong, em, a: providerLink("https://ollama.com/download") })}
  • + ))} +
+ +

{t("models.heading")}

+ +

+ {t.rich("models.intro", { code })} +

+ +

{t("models.consequencesIntro")}

+ +
    + {modelsConsequences.map((_, idx) => ( +
  • {t.rich(`models.consequences.${idx}`, { strong })}
  • + ))} +
+ + + {t("models.ollamaBody")} + + +

{t("defaultPrompt.heading")}

+ +

+ {t.rich("defaultPrompt.intro", { em, code })} +

+ +
+ + {t("defaultPrompt.showFullSummary")} + +
+
+{DEFAULT_SYSTEM_PROMPT}
+          
+
+
+ +

+ {t.rich("defaultPrompt.passagesIntro", { em })} +

+ +
    + {defaultPassages.map((_, idx) => ( +
  • {t.rich(`defaultPrompt.passages.${idx}`, { strong })}
  • + ))} +
+ +

+ {t.rich("defaultPrompt.suggestionsPlaceholder", { code })} +

+ +
+ + {t("defaultPrompt.showAddonSummary")} + +
+
+{SUGGESTIONS_ADDON}
+          
+
+
+ +

{t("customPrompt.heading")}

+ +

+ {t.rich("customPrompt.intro", { em })} +

+ +
+ {t("customPrompt.imageAlt")} +
{t.rich("customPrompt.imageCaption", { em })}
+
+ +

{t("customPrompt.changesTitle")}

+ +
    + {customChanges.map((_, idx) => ( +
  • {t.rich(`customPrompt.changes.${idx}`, { strong, em, code })}
  • + ))} +
+ +

{t("customPrompt.starterTitle")}

+ +

+ {t.rich("customPrompt.starterIntro", { em })} +

+ +
+ + {t("customPrompt.showStarterSummary")} + +
+
+{EXAMPLE_CUSTOM_PROMPT}
+          
+
+
+ +

{t("customPrompt.shareTitle")}

+ +

+ {t.rich("customPrompt.shareIntro", { em, code })} +

+ + + +

{t("customPrompt.shareOutro")}

+ +

{t("suggestions.heading")}

+ +

+ {t.rich("suggestions.intro", { strong, em })} +

+ +

{t("suggestions.formatIntro")}

+ + + +

{t("suggestions.rulesIntro")}

+ +
    + {suggestionsRules.map((_, idx) => ( +
  • {t.rich(`suggestions.rules.${idx}`, { em })}
  • + ))} +
+ + + {t("suggestions.betaBody")} + + +

+ {t("detailLevel.heading")} +

+ +

{t("detailLevel.intro")}

+ +
+ + + + + + + + + + + {detailLevelRows.map((row, idx) => ( + + + + + + + ))} + +
{t("detailLevel.headerLevel")}{t("detailLevel.headerLabel")}{t("detailLevel.headerCap")}{t("detailLevel.headerProduce")}
{row.level}{row.label}{row.cap}{row.produce}
+
+ +

{t("detailLevel.defaultsIntro")}

+ +
    + {detailLevelDefaults.map((_, idx) => ( +
  • {t.rich(`detailLevel.defaults.${idx}`, { strong, code })}
  • + ))} +
+ + + {t.rich("detailLevel.emailBody", { code })} + + +

{t("language.heading")}

+ +

+ {t.rich("language.intro", { code, em })} +

+ +

+ {t.rich("language.list", { code })} +

+ +

{t("language.rulesIntro")}

+ +
    + {languageRules.map((_, idx) => ( +
  • {t.rich(`language.rules.${idx}`, { strong, code })}
  • + ))} +
+ +

+ {t.rich("language.customNote", { strong })} +

+ +

{t("templates.heading")}

+ +

+ {t.rich("templates.body1", { code })} +

+ +

+ {t.rich("templates.body2", { link: notifLink })} +

+ +

{t("privacy.heading")}

+ +

{t("privacy.intro")}

+ +
+ + + + + + + + + {privacyRows.map((row, idx) => ( + + + + + ))} + +
{t("privacy.headerProvider")}{t("privacy.headerDestination")}
{row.provider}{t.rich(`privacy.rows.${idx}.destination`, { code })}
+
+ + + {t("privacy.calloutBody")} + + +

{t("whereNext.heading")}

+ +
+ ) +} diff --git a/web/app/[locale]/docs/monitor/api/page.tsx b/web/app/[locale]/docs/monitor/api/page.tsx new file mode 100644 index 00000000..f7d1506f --- /dev/null +++ b/web/app/[locale]/docs/monitor/api/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const accessLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const healthLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const notifLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const aiLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const integrationsLink = (chunks: React.ReactNode) => ( + {chunks} + ) + + const endpointTable = (rows: EndpointRow[], pathPrefix: string) => ( +
+ + + + + + + + + + {rows.map((row, idx) => ( + + + + + + ))} + +
{t("headerEndpoint")}{t("headerMethod")}{t("headerUse")}
{row.endpoint}{row.method}{t.rich(`${pathPrefix}.${idx}.use`, { code })}
+
+ ) + + return ( +
+ + + + {t.rich("intro.body", { strong, link: accessLink })} + + +

{t("auth.heading")}

+ +

{t("auth.intro")}

+ + " http://:8008/api/system | jq`} + className="my-4" + /> + +

{t("auth.tokensIntro")}

+ +
    + {authItems.map((_, idx) => ( +
  • {t.rich(`auth.items.${idx}`, { strong, code })}
  • + ))} +
+ +

+ {t.rich("auth.flowLink", { link: accessLink })} +

+ + {endpointTable(authRows, "auth.rows")} + +

{t("conventions.heading")}

+ +
    + {conventionsItems.map((_, idx) => ( +
  • {t.rich(`conventions.items.${idx}`, { code })}
  • + ))} +
+ +

{t("system.heading")}

+ {endpointTable(systemRows, "system.rows")} + +

{t("health.heading")}

+ {endpointTable(healthRows, "health.rows")} +

+ {t.rich("health.outro", { link: healthLink })} +

+ +

{t("storage.heading")}

+ {endpointTable(storageRows, "storage.rows")} + +

{t("network.heading")}

+ {endpointTable(networkRows, "network.rows")} + +

{t("vms.heading")}

+ {endpointTable(vmsRows, "vms.rows")} + +

{t("backups.heading")}

+ {endpointTable(backupsRows, "backups.rows")} + +

{t("logs.heading")}

+ {endpointTable(logsRows, "logs.rows")} + +

{t("notifications.heading")}

+

+ {t.rich("notifications.intro", { notifLink, aiLink })} +

+ {endpointTable(notifRows, "notifications.rows")} + +

{t("security.heading")}

+ {endpointTable(securityRows, "security.rows")} + +

{t("proxmenuxIntegration.heading")}

+ {endpointTable(proxmenuxRows, "proxmenuxIntegration.rows")} + +

{t("prometheus.heading")}

+ +

+ {t.rich("prometheus.intro", { code })} +

+ +

{t("prometheus.exportedTitle")}

+ +
+ + + + + + + + + + {metricGroups.flatMap((group, gIdx) => + group.metrics.map((m, mIdx) => ( + + {mIdx === 0 && ( + + )} + + + + )) + )} + +
{t("prometheus.headerGroup")}{t("prometheus.headerMetric")}{t("prometheus.headerDesc")}
+ {group.group} + {m.metric} + {t.rich(`prometheus.groups.${gIdx}.metrics.${mIdx}.desc`, { code })} +
+
+ +

{t("prometheus.scrapeTitle")}

+ +

{t("prometheus.scrapeIntro")}

+ + ' + static_configs: + - targets: + - 'pve01.lan:8008' + - 'pve02.lan:8008' + - 'pve03.lan:8008'`} + className="my-4" + /> + + + {t.rich("prometheus.perHostBody", { code })} + + +

{t("puttingItTogether.heading")}

+ +

+ {t.rich("puttingItTogether.body", { link: integrationsLink })} +

+ +

{t("whereNext.heading")}

+
    + {whereNextItems.map((item, idx) => ( +
  • + + {item.label} + + {item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/monitor/architecture/page.tsx b/web/app/[locale]/docs/monitor/architecture/page.tsx new file mode 100644 index 00000000..1002abe5 --- /dev/null +++ b/web/app/[locale]/docs/monitor/architecture/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const link = (chunks: React.ReactNode) => ( + {chunks} + ) + const notifLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const aiLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const accessLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const fail2banLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const fail2banWarnLink = (chunks: React.ReactNode) => ( + {chunks} + ) + + return ( +
+ + + + {t("intro.body")} + + +

{t("requestFlow.heading")}

+

{t("requestFlow.intro")}

+ + + +

+ {t.rich("requestFlow.threadsIntro", { strong })} +

+ +
+ + + + + + + + + + {threadRows.map((row, idx) => ( + + + + + + ))} + +
{t("requestFlow.headerThread")}{t("requestFlow.headerCadence")}{t("requestFlow.headerJob")}
{row.thread}{row.cadence} + {t.rich(`requestFlow.rows.${idx}.job`, { code })} +
+
+ +

{t("systemd.heading")}

+

+ {t.rich("systemd.intro", { code })} +

+ +
    + {systemdItems.map((_, idx) => ( +
  • {t.rich(`systemd.items.${idx}`, { strong, code })}
  • + ))} +
+ +
{`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`}
+
+ +

{t("appimage.heading")}

+

+ {t.rich("appimage.intro", { code })} +

+ +

+ {t("appimage.consequencesIntro")} +

+
    + {consequences.map((_, idx) => ( +
  • {t.rich(`appimage.consequences.${idx}`, { strong, code })}
  • + ))} +
+ +

{t("flask.heading")}

+

+ {t.rich("flask.intro", { code })} +

+
+ + + + + + + + + + {blueprintRows.map((row, idx) => ( + + + + + + ))} + +
{t("flask.headerBlueprint")}{t("flask.headerPrefix")}{t("flask.headerOwns")}
{row.blueprint} + {row.prefix.map((p, pidx) => ( + + {p} + {pidx < row.prefix.length - 1 &&
} +
+ ))} +
+ {t.rich(`flask.rows.${idx}.owns`, { code })} +
+
+ +

+ {t.rich("flask.endpointsLink", { link })} +

+ +

{t("dataSources.heading")}

+

{t("dataSources.intro")}

+
+ + + + + + + + + {dataRows.map((row, idx) => ( + + + + + ))} + +
{t("dataSources.headerSource")}{t("dataSources.headerUsedFor")}
{row.source}{row.usedFor}
+
+ + + {t.rich("dataSources.cacheBody", { code })} + + +

{t("persistence.heading")}

+

{t("persistence.intro")}

+
+ + + + + + + + + + {persistenceRows.map((row, idx) => ( + + + + + + ))} + +
{t("persistence.headerPath")}{t("persistence.headerOwner")}{t("persistence.headerContents")}
{row.path}{t.rich(`persistence.rows.${idx}.owner`, { code })}{t.rich(`persistence.rows.${idx}.contents`, { code })}
+
+ + + {t.rich("persistence.backupBody", { code })} + + +

{t("health.heading")}

+

+ {t.rich("health.intro", { code })} +

+
    + {healthItems.map((_, idx) => ( +
  1. {t.rich(`health.items.${idx}`, { code })}
  2. + ))} +
+

+ {t.rich("health.afterIntro", { code })} +

+

+ {t.rich("health.cycleEnd", { em, code, link })} +

+ +

{t("notifications.heading")}

+

+ {t.rich("notifications.intro", { code })} +

+
    + {notificationItems.map((_, idx) => ( +
  • {t.rich(`notifications.items.${idx}`, { strong, code })}
  • + ))} +
+

+ {t.rich("notifications.linksFooter", { notifLink, aiLink })} +

+ +

{t("websocket.heading")}

+

+ {t.rich("websocket.intro", { em, code })} +

+
    + {websocketItems.map((_, idx) => ( +
  • {t.rich(`websocket.items.${idx}`, { strong, code })}
  • + ))} +
+

+ {t.rich("websocket.outro", { code })} +

+

+ {t.rich("websocket.proxyNote", { code })} +

+ +

{t("proxy.heading")}

+

{t("proxy.intro")}

+
    + {proxyItems.map((_, idx) => ( +
  1. {t.rich(`proxy.items.${idx}`, { strong, code })}
  2. + ))} +
+ + {t.rich("proxy.calloutBody", { strong, code, link: fail2banWarnLink })} + +

+ {t.rich("proxy.outro", { accessLink, fail2banLink })} +

+ +

{t("whereNext.heading")}

+
    + {whereNextItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/monitor/dashboard/hardware/page.tsx b/web/app/[locale]/docs/monitor/dashboard/hardware/page.tsx new file mode 100644 index 00000000..b9593dfc --- /dev/null +++ b/web/app/[locale]/docs/monitor/dashboard/hardware/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const green = () => + const amber = () => + const red = () => + const thresholdsLink = (chunks: React.ReactNode) => ( + + {chunks} + + ) + const switchModeLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const nvidiaHostLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const nvidiaAnchor = (chunks: React.ReactNode) => ( + + {chunks} + + ) + const link1 = (chunks: React.ReactNode) => ( + {chunks} + ) + const link2 = (chunks: React.ReactNode) => ( + {chunks} + ) + const link3 = (chunks: React.ReactNode) => ( + {chunks} + ) + const link4 = (chunks: React.ReactNode) => ( + {chunks} + ) + const installLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const lxcLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const coralAnchor = (chunks: React.ReactNode) => ( + + {chunks} + + ) + const storageLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const smartLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const pciSwitchLink = (chunks: React.ReactNode) => ( + {chunks} + ) + + return ( +
+ + + + {t.rich("intro.body", { code })} + + + + {t.rich("thresholds.intro", { strong, green, amber, red })} +
    + {thresholdsItems.map((_, idx) => ( +
  • {t.rich(`thresholds.items.${idx}`, { strong })}
  • + ))} +
+ {t.rich("thresholds.outro", { link: thresholdsLink })} +
+ +

{t("sections.heading")}

+

+ {t.rich("sections.intro", { em })} +

+ +

{t("sections.systemInfoTitle")}

+

{t("sections.systemInfoIntro")}

+
    + {systemInfoItems.map((_, idx) => ( +
  • {t.rich(`sections.systemInfoItems.${idx}`, { strong })}
  • + ))} +
+ +

{t("sections.memoryTitle")}

+

+ {t.rich("sections.memoryBody", { code })} +

+ +

{t("sections.thermalTitle")}

+

+ {t.rich("sections.thermalIntro", { code })} +

+
    + {thermalItems.map((_, idx) => ( +
  • {t.rich(`sections.thermalItems.${idx}`, { strong, code })}
  • + ))} +
+ +

{t("graphics.heading")}

+

+ {t.rich("graphics.intro", { em, strong, code, link: switchModeLink })} +

+ +
+ {t("graphics.vfioImageAlt")} +
+ {t.rich("graphics.vfioImageCaption", { code })} +
+
+ +
+ {t("graphics.lxcImageAlt")} +
+ {t("graphics.lxcImageCaption")} +
+
+ +

{t("graphics.realtimeTitle")}

+

+ {t.rich("graphics.realtimeBody", { code })} +

+ +

{t("graphics.toolsIntro")}

+ +
+ + + + + + + + + + {gpuTools.map((row) => ( + + + + + + ))} + +
{t("graphics.headerVendor")}{t("graphics.headerTool")}{t("graphics.headerProject")}
{row.vendor}{row.tool} + {row.projectHref ? ( + + {row.projectLabel} + + ) : ( + row.projectLabel + )} +
+
+ +
+ {t("graphics.nvidiaImageAlt")} +
+ {t("graphics.nvidiaImageCaption")} +
+
+ +
+ {t("graphics.intelImageAlt")} +
+ {t.rich("graphics.intelImageCaption", { code })} +
+
+ +
+ {t("graphics.amdImageAlt")} +
+ {t.rich("graphics.amdImageCaption", { code })} +
+
+ +

{t("graphics.installTitle")}

+

+ {t.rich("graphics.installBody", { code, strong, link: nvidiaHostLink })} +

+ +
+ {t("graphics.noDriverAlt")} +
+ {t("graphics.noDriverCaption")} +
+
+ +
+ {t("graphics.promptAlt")} +
+ {t("graphics.promptCaption")} +
+
+ +
+ {t("graphics.successAlt")} +
+ {t.rich("graphics.successCaption", { code })} +
+
+ + + {t.rich("graphics.warningBody", { code, em, a: nvidiaAnchor })} + + +

{t("graphics.whereGoIntro")}

+
    + {whereGoItems.map((_, idx) => ( +
  • {t.rich(`graphics.whereGoItems.${idx}`, { em, link1, link2, link3, link4 })}
  • + ))} +
+ +

+ {t("coral.heading")} {t("coral.subHeading")} +

+

+ {t.rich("coral.intro", { code })} +

+ +
+ {t("coral.imageAlt")} +
+ {t("coral.imageCaption")} +
+
+ +

{t("coral.pathsIntro")}

+
    + {coralPathsItems.map((_, idx) => ( +
  • {t.rich(`coral.pathsItems.${idx}`, { strong, code })}
  • + ))} +
+

+ {t.rich("coral.outro", { installLink, lxcLink, a: coralAnchor })} +

+ +

{t("storage.heading")}

+

+ {t.rich("storage.intro", { code, em })} +

+ +
+ {t("storage.imageAlt")} +
+ {t.rich("storage.imageCaption", { em })} +
+
+ +

+ {t.rich("storage.nvmeBody", { strong })} +

+ +
+ {t("storage.nvmeModalAlt")} +
+ {t("storage.nvmeModalCaption")} +
+
+ +

+ {t.rich("storage.outro", { em, storageLink, smartLink })} +

+ +

{t("pci.heading")}

+

+ {t.rich("pci.intro", { strong, em, code })} +

+ +
+ {t("pci.imageAlt")} +
+ {t.rich("pci.imageCaption", { code })} +
+
+ + + {t.rich("pci.bdfBody", { code, link: pciSwitchLink })} + + +

{t("usb.heading")}

+

+ {t.rich("usb.intro", { code, em })} +

+ +
+ {t("usb.imageAlt")} +
+ {t.rich("usb.imageCaption", { code })} +
+
+ +

+ {t("power.heading")} {t("power.subHeading")} +

+

{t("power.intro")}

+
    + {powerItems.map((_, idx) => ( +
  • {t.rich(`power.items.${idx}`, { strong })}
  • + ))} +
+ +
+ {t("power.supplyImageAlt")} +
+ {t("power.supplyImageCaption")} +
+
+ +
+ {t("power.cpuImageAlt")} +
+ {t("power.cpuImageCaption")} +
+
+ +

+ {t("psu.heading")} {t("psu.subHeading")} +

+

{t("psu.body")}

+ +

+ {t("fans.heading")} {t("fans.subHeading")} +

+

{t("fans.body")}

+ +

+ {t("ups.heading")} {t("ups.subHeading")} +

+

+ {t.rich("ups.body", { em })} +

+ +

{t("dataCollected.heading")}

+ +
+ + + + + + + + + + {dataRows.map((row, idx) => ( + + + + + + ))} + +
{t("dataCollected.headerSection")}{t("dataCollected.headerEndpoint")}{t("dataCollected.headerSource")}
{row.section}{row.endpoint}{t.rich(`dataCollected.rows.${idx}.source`, { code })}
+
+ + " \\ + http://:8008/api/hardware | jq '.gpus'`} + className="my-4" + /> + +

{t("whereNext.heading")}

+
    + {whereNextItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/monitor/dashboard/network/page.tsx b/web/app/[locale]/docs/monitor/dashboard/network/page.tsx new file mode 100644 index 00000000..f4c7c021 --- /dev/null +++ b/web/app/[locale]/docs/monitor/dashboard/network/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + + return ( +
+ + + + {t.rich("intro.body", { code })} + + +

{t("topRow.heading")}

+
+ + + + + + + + + {topRows.map((row, idx) => ( + + + + + ))} + +
{t("topRow.headerCard")}{t("topRow.headerWhat")}
{row.card} + {t.rich(`topRow.rows.${idx}.what`, { em, strong })} +
+
+ +

{t("groups.heading")}

+

+ {t.rich("groups.intro", { strong })} +

+
    + {badges.map((_, idx) => ( +
  • {t.rich(`groups.badges.${idx}`, { strong, code })}
  • + ))} +
+

+ {t.rich("groups.clickable", { strong })} +

+ +

{t("groups.physicalTitle")}

+

+ {t.rich("groups.physicalBody", { code, strong, em })} +

+ +

{t("groups.bridgeTitle")}

+

+ {t.rich("groups.bridgeBody", { code, strong })} +

+ +

{t("groups.vmTitle")}

+

+ {t.rich("groups.vmBody", { code, em })} +

+ +

{t("drillIn.heading")}

+

{t("drillIn.intro")}

+ +
+ + + + + + + + + {drillRows.map((row, idx) => ( + + + + + ))} + +
{t("drillIn.headerBlock")}{t("drillIn.headerContents")}
{row.block} + {t.rich(`drillIn.rows.${idx}.contents`, { code })} +
+
+ + + {t.rich("drillIn.inactiveBody", { em })} + + +

{t("latency.heading")}

+

+ {t.rich("latency.intro", { em })} +

+ +

{t("latency.targetsTitle")}

+

{t("latency.targetsIntro")}

+
    + {targets.map((_, idx) => ( +
  • {t.rich(`latency.targets.${idx}`, { strong })}
  • + ))} +
+ +

{t("latency.mode1Title")}

+ +
+ {t("latency.mode1Alt")} +
+ {t("latency.mode1Caption")} +
+
+ +

+ {t.rich("latency.mode1Body1", { em })} +

+

+ {t.rich("latency.mode1Body2", { code })} +

+ +

{t("latency.mode2Title")}

+ +
+ {t("latency.mode2Alt")} +
+ {t.rich("latency.mode2Caption", { em })} +
+
+ +

{t("latency.mode2Intro")}

+
    + {mode2Items.map((_, idx) => ( +
  • {t.rich(`latency.mode2Items.${idx}`, { strong, code })}
  • + ))} +
+ +

{t("latency.thresholdsTitle")}

+
+ + + + + + + + + + {thresholdRows.map((row, idx) => ( + + + + + + ))} + +
{t("latency.headerStatus")}{t("latency.headerRange")}{t("latency.headerImpact")}
{row.status}{row.range}{row.impact}
+
+ +

{t("latency.reportTitle")}

+

+ {t.rich("latency.reportIntro", { strong })} +

+ +
+ {t("latency.reportPreviewAlt")} +
+ {t("latency.reportPreviewCaption")} +
+
+ + + +

{t("latency.sectionsIntro")}

+
    + {sections.map((_, idx) => ( +
  1. {t.rich(`latency.sections.${idx}`, { strong })}
  2. + ))} +
+ + + {t.rich("latency.useCaseBody", { em })} + + +

{t("excluding.heading")}

+

+ {t.rich("excluding.body1", { code })} +

+

+ {t.rich("excluding.body2", { strong, em })} +

+ +

{t("dataCollected.heading")}

+ +
+ + + + + + + + + + {dataRows.map((row, idx) => ( + + + + + + ))} + +
{t("dataCollected.headerSection")}{t("dataCollected.headerEndpoint")}{t("dataCollected.headerSource")}
{row.section}{row.endpoint} + {t.rich(`dataCollected.rows.${idx}.source`, { code })} +
+
+ + " \\ + http://:8008/api/network/latency/current | jq`} + className="my-4" + /> + +

{t("whereNext.heading")}

+
    + {whereNextItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/monitor/dashboard/page.tsx b/web/app/[locale]/docs/monitor/dashboard/page.tsx new file mode 100644 index 00000000..0051a33d --- /dev/null +++ b/web/app/[locale]/docs/monitor/dashboard/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const link = (chunks: React.ReactNode) => ( + + {chunks} + + ) + + return ( +
+ + + + {t.rich("oneHeader.body", { link })} + + +

{t("tabs.heading")}

+

{t("tabs.intro")}

+ +
+ + + + + + + + + {tabRows.map((row, idx) => ( + + + + + ))} + +
{t("tabs.headerTab")}{t("tabs.headerOwns")}
+ {row.linksTo ? ( + + {row.name} + + ) : ( + {row.name} + )} + + {t.rich(`tabs.rows.${idx}.owns`, { code })} +
+
+ +

{t("headerAnatomy.heading")}

+
    + {headerAnatomyItems.map((_, idx) => ( +
  • {t.rich(`headerAnatomy.items.${idx}`, { code, strong, em })}
  • + ))} +
+ +

{t("whereNext.heading")}

+
    + {whereNextItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/monitor/dashboard/security/page.tsx b/web/app/[locale]/docs/monitor/dashboard/security/page.tsx new file mode 100644 index 00000000..288cfed3 --- /dev/null +++ b/web/app/[locale]/docs/monitor/dashboard/security/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const authLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const sslPageLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const integrationsLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const tailscaleHomeAnchor = (chunks: React.ReactNode) => ( + + {chunks} + + ) + const tailscaleKeysAnchor = (chunks: React.ReactNode) => ( + + {chunks} + + ) + const tailscaleMachinesAnchor = (chunks: React.ReactNode) => ( + + {chunks} + + ) + const fail2banLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const lynisLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const lynisSampleAnchor = (chunks: React.ReactNode) => ( + + {chunks} + + ) + + return ( +
+ + + + {t.rich("intro.body", { strong })} + + +

{t("monitor.heading")}

+

{t("monitor.intro")}

+ +

{t("auth.heading")}

+ +
+ {t("auth.imageAlt")} +
{t("auth.imageCaption")}
+
+ +

+ {t.rich("auth.intro", { link: authLink })} +

+
    + {authItems.map((_, idx) => ( +
  • {t.rich(`auth.items.${idx}`, { strong, em })}
  • + ))} +
+ +

{t("ssl.heading")}

+ +
+ {t("ssl.imageAlt")} +
{t.rich("ssl.imageCaption", { em })}
+
+ +

+ {t.rich("ssl.intro", { code })} +

+
    + {sslItems.map((_, idx) => ( +
  • {t.rich(`ssl.items.${idx}`, { strong, code })}
  • + ))} +
+ +
+ {t("ssl.enabledAlt")} +
{t.rich("ssl.enabledCaption", { em })}
+
+ + + {t.rich("ssl.acmeBody", { em })} + + +

+ {t.rich("ssl.walkthroughLink", { code, link: sslPageLink })} +

+ +

{t("apiTokens.heading")}

+ +
+ {t("apiTokens.emptyAlt")} +
{t.rich("apiTokens.emptyCaption", { em, code })}
+
+ +

{t("apiTokens.intro")}

+ +

+ {t.rich("apiTokens.generateBody", { strong, em })} +

+ +
+ {t("apiTokens.generateAlt")} +
{t("apiTokens.generateCaption")}
+
+ +

+ {t.rich("apiTokens.saveBody", { strong })} +

+ +
+ {t("apiTokens.generatedAlt")} +
{t.rich("apiTokens.generatedCaption", { code })}
+
+ +

+ {t.rich("apiTokens.outro", { em, link: integrationsLink })} +

+ +

{t("gateway.heading")}

+ +
+ {t("gateway.cardAlt")} +
{t("gateway.cardCaption")}
+
+ +

+ {t.rich("gateway.intro", { code, a: tailscaleHomeAnchor })} +

+ +

{t("gateway.wizardTitle")}

+ +

+ {t.rich("gateway.wizardIntro", { em })} +

+ +
{t("gateway.step0Title")}
+

+ {t.rich("gateway.step0Body", { em, a: tailscaleKeysAnchor })} +

+ +
+ {t("gateway.step0Alt")} +
{t.rich("gateway.step0Caption", { em })}
+
+ +
{t("gateway.step1Title")}
+

+ {t.rich("gateway.step1Body", { em })} +

+ +
+ {t("gateway.step1Alt")} +
{t("gateway.step1Caption")}
+
+ +
{t("gateway.step2Title")}
+

+ {t.rich("gateway.step2Body", { code })} +

+ +
+ {t("gateway.step2Alt")} +
{t("gateway.step2Caption")}
+
+ +
{t("gateway.step3Title")}
+

{t("gateway.step3Intro")}

+
    + {step3Items.map((_, idx) => ( +
  • {t.rich(`gateway.step3Items.${idx}`, { strong })}
  • + ))} +
+ +
+ {t("gateway.step3Alt")} +
{t.rich("gateway.step3Caption", { em })}
+
+ +
{t("gateway.step4Title")}
+

+ {t.rich("gateway.step4Intro", { strong })} +

+
    + {step4Items.map((_, idx) => ( +
  • {t.rich(`gateway.step4Items.${idx}`, { strong, em })}
  • + ))} +
+ +
+ {t("gateway.step4Alt")} +
{t("gateway.step4Caption")}
+
+ +
{t("gateway.step5Title")}
+

+ {t.rich("gateway.step5Body", { strong })} +

+ +
+ {t("gateway.step5Alt")} +
{t("gateway.step5Caption")}
+
+ + + {t.rich("gateway.approvalBody", { em, a: tailscaleMachinesAnchor })} + + +

{t("pve.heading")}

+

{t("pve.intro")}

+ +

{t("firewall.heading")}

+ +
+ {t("firewall.imageAlt")} +
{t.rich("firewall.imageCaption", { em })}
+
+ +

+ {t.rich("firewall.intro", { code })} +

+
    + {firewallItems.map((_, idx) => ( +
  • {t.rich(`firewall.items.${idx}`, { strong, em, code })}
  • + ))} +
+ +

+ {t("fail2ban.heading")} {t("fail2ban.subHeading")} +

+ +

+ {t.rich("fail2ban.whatIs", { strong })} +

+ +

+ {t.rich("fail2ban.notBundled", { strong })} +

+ +
+ {t("fail2ban.notInstalledAlt")} +
{t("fail2ban.notInstalledCaption")}
+
+ +

+ {t.rich("fail2ban.clickBody", { em })} +

+ +
+ {t("fail2ban.confirmAlt")} +
{t.rich("fail2ban.confirmCaption", { code })}
+
+ +

{t("fail2ban.confirmIntro")}

+ +
+ {t("fail2ban.progressAlt")} +
{t("fail2ban.progressCaption")}
+
+ +

+ {t.rich("fail2ban.afterInstall", { em })} +

+ +
+ {t("fail2ban.activeAlt")} +
{t.rich("fail2ban.activeCaption", { code })}
+
+ +

+ {t.rich("fail2ban.tuneBody", { strong, em })} +

+ +
+ {t("fail2ban.configAlt")} +
{t.rich("fail2ban.configCaption", { em })}
+
+ +

+ {t.rich("fail2ban.outro", { em, code, link: fail2banLink })} +

+ + + {t.rich("fail2ban.calloutBody", { em, code })} + + +

+ {t("lynis.heading")} {t("lynis.subHeading")} +

+ +

+ {t.rich("lynis.whatIs", { strong })} +

+ +

+ {t.rich("lynis.whyUseful", { strong, code })} +

+ +
+ {t("lynis.notInstalledAlt")} +
{t("lynis.notInstalledCaption")}
+
+ +

+ {t.rich("lynis.notBundled", { strong })} +

+ +
+ {t("lynis.confirmAlt")} +
{t("lynis.confirmCaption")}
+
+ +
+ {t("lynis.progressAlt")} +
{t("lynis.progressCaption")}
+
+ +

+ {t.rich("lynis.afterInstall", { em })} +

+ +
+ {t("lynis.installedAlt")} +
{t("lynis.installedCaption")}
+
+ +
+ {t("lynis.runningAlt")} +
{t("lynis.runningCaption")}
+
+ +

+ {t.rich("lynis.finishedBody", { em })} +

+ +
+ {t("lynis.resultsAlt")} +
{t.rich("lynis.resultsCaption", { strong })}
+
+ + + {t.rich("lynis.scoreIntro", { em, code })} +
    + {lynisScoreItems.map((_, idx) => ( +
  • {t.rich(`lynis.scoreItems.${idx}`, { em })}
  • + ))} +
+
+ +

+ {t.rich("lynis.reportBody", { strong, em })} +

+ +
+ {t("lynis.reportAlt")} +
+ {t.rich("lynis.reportCaption", { a: lynisSampleAnchor })} +
+
+ +

{t("lynis.runPeriodically")}

+ +

+ {t.rich("lynis.outro", { em, link: lynisLink })} +

+ +

{t("dataCollected.heading")}

+ +
+ + + + + + + + + + {dataRows.map((row, idx) => ( + + + + + + ))} + +
{t("dataCollected.headerCard")}{t("dataCollected.headerEndpoint")}{t("dataCollected.headerSource")}
{row.card}{row.endpoint}{t.rich(`dataCollected.rows.${idx}.source`, { code })}
+
+ + + +

{t("whereNext.heading")}

+
    + {whereNextItems.map((item, idx) => ( +
  • + + {item.label} + + {item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/monitor/dashboard/settings/page.tsx b/web/app/[locale]/docs/monitor/dashboard/settings/page.tsx new file mode 100644 index 00000000..5c9716a5 --- /dev/null +++ b/web/app/[locale]/docs/monitor/dashboard/settings/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const green = () => + const amber = () => + const healthLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const storageTabLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const networkTabLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const notifLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const aiLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const autoLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const customLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const updatesLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const uninstallLink = (chunks: React.ReactNode) => ( + {chunks} + ) + + return ( +
+ + + + {t("intro.body")} + + +

{t("networkUnits.heading")}

+ +
+ {t("networkUnits.imageAlt")} +
+ {t("networkUnits.imageCaption")} +
+
+ +

+ {t.rich("networkUnits.body", { strong })} +

+ +

{t("health.heading")}

+ +

+ {t.rich("health.intro", { strong })} +

+
    + {healthItems.map((_, idx) => ( +
  • {t.rich(`health.items.${idx}`, { strong })}
  • + ))} +
+ +
+ {t("health.imageAlt")} +
+ {t("health.imageCaption")} +
+
+ +

{t("health.editTitle")}

+

+ {t.rich("health.editBody", { strong })} +

+ +

{t("health.activeTitle")}

+

+ {t.rich("health.activeIntro", { strong, em })} +

+
    + {activeSuppressionItems.map((_, idx) => ( +
  • {t.rich(`health.activeItems.${idx}`, { strong, em, code })}
  • + ))} +
+ +

{t("health.activeReenableTitle")}

+

+ {t.rich("health.activeReenableBody", { strong, code })} +

+ + + {t("health.activeAutoRefreshBody")} + + +

+ {t.rich("health.activePermanentNote", { strong, em, code })} +

+ + + {t.rich("health.calloutBody", { link: healthLink })} + + +

{t("thresholds.heading")}

+ +

+ {t.rich("thresholds.intro", { em, strong })} +

+ +

{t("thresholds.whatForTitle")}

+

{t("thresholds.whatForIntro")}

+
    + {whatForItems.map((_, idx) => ( +
  • {t(`thresholds.whatForItems.${idx}`)}
  • + ))} +
+

+ {t.rich("thresholds.whatForOutro", { strong, code })} +

+ +

{t("thresholds.coloursTitle")}

+

{t("thresholds.coloursIntro")}

+ +
+ + + + + + + + + + {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 ( + + + + + + ) + })} + +
{t("thresholds.headerColour")}{t("thresholds.headerRange")}{t("thresholds.headerMeaning")}
+ + {row.colour} + {row.range}{row.meaning}
+
+ +

{t("thresholds.sectionsTitle")}

+

+ {t.rich("thresholds.sectionsIntro", { em })} +

+ +
+ + + + + + + + + + + {thresholdRows.map((row, idx) => ( + + + + + + + ))} + +
{t("thresholds.headerSection")}{t("thresholds.headerWarning")}{t("thresholds.headerCritical")}{t("thresholds.headerGates")}
{row.section}{row.warning}{row.critical}{t.rich(`thresholds.thresholdRows.${idx}.gates`, { code, em })}
+
+ + + {t.rich("thresholds.defaultsBody", { em, strong })} + + + + {t("thresholds.validationBody")} + + +

{t("lxcDetection.heading")}

+ +
+ {t("lxcDetection.imageAlt")} +
+ {t.rich("lxcDetection.imageCaption", { code })} +
+
+ +

+ {t.rich("lxcDetection.intro", { code })} +

+ +

{t("lxcDetection.whatRunsTitle")}

+

+ {t.rich("lxcDetection.whatRunsIntro", { code })} +

+
    + {whatRunsItems.map((_, idx) => ( +
  • {t.rich(`lxcDetection.whatRunsItems.${idx}`, { strong, code })}
  • + ))} +
+ + + {t.rich("lxcDetection.selfUpdateBody", { code })} + + + + {t.rich("lxcDetection.refreshBody", { code })} + + +

{t("lxcDetection.toggleTitle")}

+

+ {t.rich("lxcDetection.toggleBody", { code, strong })} +

+ + + {t.rich("lxcDetection.purgeBody", { code })} + + +

{t("storageExclusions.heading")}

+ +
+ {t("storageExclusions.imageAlt")} +
+ {t.rich("storageExclusions.imageCaption", { em })} +
+
+ +

{t("storageExclusions.intro")}

+
    + {storageItems.map((_, idx) => ( +
  • {t.rich(`storageExclusions.items.${idx}`, { strong })}
  • + ))} +
+

+ {t.rich("storageExclusions.outro", { em, code, link: storageTabLink })} +

+ +

{t("interfaceExclusions.heading")}

+ +
+ {t("interfaceExclusions.imageAlt")} +
+ {t.rich("interfaceExclusions.imageCaption", { em })} +
+
+ +

{t("interfaceExclusions.intro")}

+
    + {interfaceItems.map((_, idx) => ( +
  • {t.rich(`interfaceExclusions.items.${idx}`, { code })}
  • + ))} +
+

+ {t.rich("interfaceExclusions.outro", { code, em, link: networkTabLink })} +

+ +

{t("notifications.heading")}

+ +

+ {t.rich("notifications.body1", { em })} +

+ +

{t("notifications.body2")}

+ +
    + {notificationItems.map((_, idx) => ( +
  • {t.rich(`notifications.items.${idx}`, { notifLink, aiLink })}
  • + ))} +
+ +

{t("optimizations.heading")}

+ +

+ {t.rich("optimizations.intro", { code, autoLink, customLink })} +

+ +
+ {t("optimizations.imageAlt")} +
+ {t.rich("optimizations.imageCaption", { em })} +
+
+ +

{t("optimizations.dotsTitle")}

+
    + {dotsItems.map((_, idx) => ( +
  • {t.rich(`optimizations.dotsItems.${idx}`, { strong, em, green, amber })}
  • + ))} +
+ +

{t("optimizations.clickTitle")}

+

+ {t.rich("optimizations.clickBody", { code })} +

+ +
+ {t("optimizations.detailAlt")} +
+ {t.rich("optimizations.detailCaption", { em, code })} +
+
+ + + {t("optimizations.whyBody")} + + +

{t("optimizations.updatesTitle")}

+

+ {t.rich("optimizations.updatesBody", { strong, em })} +

+ +
+ {t("optimizations.updatesAlt")} +
+ {t.rich("optimizations.updatesCaption", { link: updatesLink })} +
+
+ +

{t("optimizations.revertTitle")}

+

+ {t.rich("optimizations.revertBody", { code, link: uninstallLink })} +

+ +

{t("dataCollected.heading")}

+ +
+ + + + + + + + + + {dataRows.map((row, idx) => ( + + + + + + ))} + +
{t("dataCollected.headerCard")}{t("dataCollected.headerEndpoint")}{t("dataCollected.headerSource")}
{row.card}{row.endpoint}{t.rich(`dataCollected.rows.${idx}.source`, { code, notifLink, aiLink })}
+
+ +

{t("whereNext.heading")}

+
    + {whereNextItems.map((item, idx) => ( +
  • + + {item.label} + + {item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { customLink }) : item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/monitor/dashboard/storage/page.tsx b/web/app/[locale]/docs/monitor/dashboard/storage/page.tsx new file mode 100644 index 00000000..1483c07b --- /dev/null +++ b/web/app/[locale]/docs/monitor/dashboard/storage/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const green = () => + const amber = () => + const red = () => + const thresholdsLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const zfsHmLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const physicalWarnLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const hmLink = (chunks: React.ReactNode) => ( + {chunks} + ) + + return ( +
+ + + + {t.rich("intro.body", { code })} + + + + {t.rich("thresholds.intro", { strong, green, amber, red })} +
    + {thresholdsItems.map((_, idx) => ( +
  • {t.rich(`thresholds.items.${idx}`, { strong })}
  • + ))} +
+ {t.rich("thresholds.outro", { link: thresholdsLink })} +
+ +

{t("topRow.heading")}

+

{t("topRow.intro")}

+ +
+ {t("topRow.imageAlt")} +
+ {t("topRow.imageCaption")} +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
{t("topRow.headerCard")}{t("topRow.headerWhat")}
{t("topRow.totalLabel")}{t("topRow.totalWhat")}
{t("topRow.localLabel")}{t.rich("topRow.localWhat", { em })}
{t("topRow.remoteLabel")}{t("topRow.remoteWhat")}
{t("topRow.disksLabel")} + {t("topRow.disksIntro")} +
    + {disksItems.map((_, idx) => ( +
  • {t.rich(`topRow.disksItems.${idx}`, { strong, em })}
  • + ))} +
+
+
+ +

{t("pveStorage.heading")}

+

+ {t.rich("pveStorage.intro", { code })} +

+
    + {pveItems.map((_, idx) => ( +
  • {t.rich(`pveStorage.items.${idx}`, { strong })}
  • + ))} +
+ + + {t.rich("pveStorage.calloutBody", { em, code })} + + +

{t("zfs.heading")}

+

{t("zfs.intro")}

+
    + {zfsItems.map((_, idx) => ( +
  • {t.rich(`zfs.items.${idx}`, { strong })}
  • + ))} +
+

+ {t.rich("zfs.outro", { em, link: zfsHmLink })} +

+ +

{t("physical.heading")}

+

{t("physical.intro")}

+
    + {physicalItems.map((_, idx) => ( +
  • {t.rich(`physical.items.${idx}`, { strong, em, code })}
  • + ))} +
+

{t("physical.clickHint")}

+ + + {t.rich("physical.warningBody", { strong, link: physicalWarnLink })} + + +

{t("external.heading")}

+

+ {t.rich("external.body", { strong })} +

+ +

{t("drillIn.heading")}

+

+ {t.rich("drillIn.intro", { strong, em })} +

+ +

{t("drillIn.overviewTitle")}

+ +
+ {t("drillIn.overviewImageAlt")} +
+ {t("drillIn.overviewImageCaption")} +
+
+ +

{t("drillIn.overviewIntro")}

+
    + {overviewItems.map((_, idx) => ( +
  • {t.rich(`drillIn.overviewItems.${idx}`, { strong, em })}
  • + ))} +
+ +

{t("drillIn.smartTitle")}

+ +
+ {t("drillIn.smartImageAlt")} +
+ {t("drillIn.smartImageCaption")} +
+
+ +

{t("drillIn.smartIntro")}

+
    + {smartItems.map((_, idx) => ( +
  • {t.rich(`drillIn.smartItems.${idx}`, { strong, em, code })}
  • + ))} +
+ +

{t("drillIn.pdfTitle")}

+

+ {t.rich("drillIn.pdfIntro", { strong })} +

+ +
+ {t("drillIn.pdfPreviewAlt")} +
+ {t("drillIn.pdfPreviewCaption")} +
+
+ + + +

{t("drillIn.pdfSectionsIntro")}

+
    + {pdfSections.map((_, idx) => ( +
  1. {t.rich(`drillIn.pdfSections.${idx}`, { strong, em })}
  2. + ))} +
+

+ {t.rich("drillIn.pdfOutro", { code })} +

+ +

{t("drillIn.historyTitle")}

+ +
+ {t("drillIn.historyImageAlt")} +
+ {t.rich("drillIn.historyImageCaption", { code })} +
+
+ +

+ {t.rich("drillIn.historyIntro", { code })} +

+
    + {historyItems.map((_, idx) => ( +
  • {t.rich(`drillIn.historyItems.${idx}`, { strong, em, code })}
  • + ))} +
+ +

{t("drillIn.scheduleTitle")}

+ +
+ {t("drillIn.scheduleImageAlt")} +
+ {t.rich("drillIn.scheduleImageCaption", { code })} +
+
+ +

{t("drillIn.scheduleIntro")}

+
    + {scheduleItems.map((_, idx) => ( +
  • {t.rich(`drillIn.scheduleItems.${idx}`, { strong, em })}
  • + ))} +
+

{t("drillIn.scheduleOutro")}

+ +

{t("drillIn.tempTitle")}

+ +

{t("drillIn.tempIntro")}

+ +
+ {t("drillIn.tempImageAlt")} +
+ {t("drillIn.tempImageCaption")} +
+
+ +

{t("drillIn.tempShowsTitle")}

+
    + {tempShowsItems.map((_, idx) => ( +
  • + {t.rich(`drillIn.tempShowsItems.${idx}`, { strong, em })} + {idx === 2 && ( +
      + {tempDiskTypes.map((_, didx) => ( +
    • {t.rich(`drillIn.tempDiskTypes.${didx}`, { strong })}
    • + ))} +
    + )} +
  • + ))} +
+

+ {t.rich("drillIn.tempConfigurable", { em })} +

+ +

{t("drillIn.tempWhyTitle")}

+
    + {tempWhyItems.map((_, idx) => ( +
  • {t.rich(`drillIn.tempWhyItems.${idx}`, { strong, em })}
  • + ))} +
+ +

{t("drillIn.obsTitle")}

+ +

+ {t.rich("drillIn.obsIntro", { strong, em })} +

+ +
+ {t("drillIn.obsImageAlt")} +
+ {t.rich("drillIn.obsImageCaption", { strong })} +
+
+ +

{t("drillIn.obsWhatTitle")}

+

+ {t.rich("drillIn.obsWhatIntro", { strong })} +

+
    + {obsWhatItems.map((_, idx) => ( +
  • {t.rich(`drillIn.obsWhatItems.${idx}`, { strong, code })}
  • + ))} +
+ +

{t("drillIn.obsWhyTitle")}

+
    + {obsWhyItems.map((_, idx) => ( +
  • {t.rich(`drillIn.obsWhyItems.${idx}`, { strong, em })}
  • + ))} +
+ +

{t("drillIn.obsDedupTitle")}

+

+ {t.rich("drillIn.obsDedupBody1", { strong, code })} +

+

+ {t("drillIn.obsDedupBody2")} +

+ +

{t("drillIn.obsDismissTitle")}

+

+ {t.rich("drillIn.obsDismissBody1", { strong })} +

+

+ {t.rich("drillIn.obsDismissBody2", { link: hmLink })} +

+ +

{t("dataCollected.heading")}

+ +
+ + + + + + + + + + {dataRows.map((row, idx) => ( + + + + + + ))} + +
{t("dataCollected.headerSection")}{t("dataCollected.headerEndpoint")}{t("dataCollected.headerSource")}
{row.section}{row.endpoint}{t.rich(`dataCollected.rows.${idx}.source`, { code })}
+
+ +

{t("dataCollected.outro")}

+ + " \\ + http://: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" + /> + +

{t("whereNext.heading")}

+
    + {whereNextItems.map((item, idx) => ( +
  • + + {item.label} + + {item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/monitor/dashboard/system-logs/page.tsx b/web/app/[locale]/docs/monitor/dashboard/system-logs/page.tsx new file mode 100644 index 00000000..8fff6c70 --- /dev/null +++ b/web/app/[locale]/docs/monitor/dashboard/system-logs/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const link = (chunks: React.ReactNode) => ( + + {chunks} + + ) + + return ( +
+ + + + {t.rich("readOnly.body", { code })} + + +

{t("topRow.heading")}

+
    + {topRowItems.map((_, idx) => ( +
  • {t.rich(`topRow.items.${idx}`, { code, strong })}
  • + ))} +
+ +

{t("subtabs.heading")}

+ +

{t("subtabs.logsTitle")}

+

{t.rich("subtabs.logsIntro", { code })}

+
    + {logsFilters.map((_, idx) => ( +
  • {t.rich(`subtabs.logsFilters.${idx}`, { code, strong })}
  • + ))} +
+

+ {t.rich("subtabs.logsRowsAfter", { code, strong })} +

+ +

{t("subtabs.logDetailsModalTitle")}

+

+ {t.rich("subtabs.logDetailsBody", { code, strong })} +

+ +
+ {t("subtabs.logDetailsImageAlt")} +
+ {t("subtabs.logDetailsImageCaption")} +
+
+ +

{t("subtabs.fieldsIntro")}

+
    + {fields.map((_, idx) => ( +
  • {t.rich(`subtabs.fields.${idx}`, { code, strong })}
  • + ))} +
+ + + {t.rich("subtabs.maxLevelStoreBody", { code })} + + +

{t("subtabs.backupsTitle")}

+

{t.rich("subtabs.backupsBody", { code, em })}

+ +

{t("subtabs.notificationsTitle")}

+

{t("subtabs.notificationsBody1")}

+

{t.rich("subtabs.notificationsBody2", { link })}

+ +

{t("dataCollected.heading")}

+
+ + + + + + + + + + {dataRows.map((row, idx) => ( + + + + + ))} + +
{t("dataCollected.headerSubtab")}{t("dataCollected.headerEndpoint")}{t("dataCollected.headerSource")}
{row.subtab} + + {t.rich(`dataCollected.rows.${idx}.source`, { code })} +
+
+ +

{t("dataCollected.apiIntro")}

+ " \\ + "http://:8008/api/logs?severity=error&since=1h&search=zfs" + +${t("dataCollected.codeComment2")} +curl -H "Authorization: Bearer " \\ + -o pmx-journal.txt \\ + "http://:8008/api/logs/download?since=6h" + +${t("dataCollected.codeComment3")} +curl -H "Authorization: Bearer " \\ + "http://:8008/api/task-log/"`} + className="my-4" + /> + +

{t("whereNext.heading")}

+
    + {whereNextItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/monitor/dashboard/system-overview/page.tsx b/web/app/[locale]/docs/monitor/dashboard/system-overview/page.tsx new file mode 100644 index 00000000..55d3fea1 --- /dev/null +++ b/web/app/[locale]/docs/monitor/dashboard/system-overview/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const green = () => + const amber = () => + const red = () => + const thresholdsLink = (chunks: React.ReactNode) => ( + + {chunks} + + ) + const storageLink = (chunks: React.ReactNode) => ( + + {chunks} + + ) + const networkLink = (chunks: React.ReactNode) => ( + + {chunks} + + ) + + return ( +
+ + + + {t("readOnly.body")} + + +
+ {t("captureAlt")} +
+ {t("captureCaption")} +
+
+ +

{t("topRow.heading")}

+

+ {t.rich("topRow.intro", { code })} +

+ +
+ + + + + + + + + + {topRows.map((row, idx) => ( + + + + + + ))} + +
{t("topRow.headerCard")}{t("topRow.headerWhat")}{t("topRow.headerSource")}
+ {row.card} + + {t.rich(`topRow.rows.${idx}.what`, { code, em })} + {row.source}
+
+ + + {t.rich("topRow.thresholdsIntro", { strong, green, amber, red })} +
    + {thresholdsItems.map((_, idx) => ( +
  • {t.rich(`topRow.thresholdsItems.${idx}`, { strong })}
  • + ))} +
+ {t.rich("topRow.thresholdsOutro", { link: thresholdsLink })} +
+ + + {t("topRow.sparklineBody")} + + +

{t("middle.heading")}

+

+ {t.rich("middle.body1", { code, em })} +

+

+ {t("middle.body2")} +

+ +

{t("bottom.heading")}

+ +

{t("bottom.storageTitle")}

+

{t("bottom.storageIntro")}

+
    + {storageItems.map((_, idx) => ( +
  • {t.rich(`bottom.storageItems.${idx}`, { strong })}
  • + ))} +
+

+ {t.rich("bottom.storageDrillIn", { link: storageLink })} +

+ +

{t("bottom.networkTitle")}

+

+ {t.rich("bottom.networkBody1", { code })} +

+

+ {t.rich("bottom.networkBody2", { link: networkLink })} +

+ +

{t("refresh.heading")}

+

+ {t.rich("refresh.intro", { code, em })} +

+
    + {refreshItems.map((_, idx) => ( +
  • {t.rich(`refresh.items.${idx}`, { strong })}
  • + ))} +
+ +

{t("dataCollected.heading")}

+ +
+ + + + + + + + + + {dataRows.map((row, idx) => ( + + + + + + ))} + +
{t("dataCollected.headerCard")}{t("dataCollected.headerEndpoint")}{t("dataCollected.headerSource")}
{row.card}{row.endpoint} + {t.rich(`dataCollected.rows.${idx}.source`, { code })} +
+
+ + :8008/api/health ${t("dataCollected.codeComment2")} + +${t("dataCollected.codeComment3")} +curl -H "Authorization: Bearer " \\ + http://:8008/api/system | jq '.cpu,.memory,.uptime'`} + className="my-4" + /> + +

{t("whereNext.heading")}

+
    + {whereNextItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/monitor/dashboard/terminal/page.tsx b/web/app/[locale]/docs/monitor/dashboard/terminal/page.tsx new file mode 100644 index 00000000..e2856e2c --- /dev/null +++ b/web/app/[locale]/docs/monitor/dashboard/terminal/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const green = (chunks: React.ReactNode) => {chunks} + const red = (chunks: React.ReactNode) => {chunks} + const vmsLink = (chunks: React.ReactNode) => ( + + {chunks} + + ) + const vmsLinkAmber = (chunks: React.ReactNode) => ( + + {chunks} + + ) + const authLink = (chunks: React.ReactNode) => ( + + {chunks} + + ) + const authLinkWarn = (chunks: React.ReactNode) => ( + + {chunks} + + ) + const gatewayLink = (chunks: React.ReactNode) => ( + + {chunks} + + ) + + return ( +
+ + + + {t.rich("intro.body", { code })} + + +
+ {t("singleAlt")} +
+ {t.rich("singleCaption", { em })} +
+
+ +

{t("target.heading")}

+

+ {t.rich("target.body1", { strong })} +

+

+ {t.rich("target.body2", { strong, em, code, link: vmsLink })} +

+ +

{t("fourTerminals.heading")}

+

{t("fourTerminals.intro")}

+
    + {fourTerminalsItems.map((_, idx) => ( +
  • {t.rich(`fourTerminals.items.${idx}`, { strong, em, code })}
  • + ))} +
+

+ {t.rich("fourTerminals.outro", { strong, em, code })} +

+ +
+ {t("gridAlt")} +
+ {t.rich("gridCaption", { code })} +
+
+ +

{t("keyboard.heading")}

+

+ {t.rich("keyboard.intro", { code })} +

+ +
+ + + + + + + + + + {kbRows.map((row, idx) => ( + + + + + + ))} + +
{t("keyboard.headerButton")}{t("keyboard.headerSends")}{t("keyboard.headerUse")}
{row.button}{row.sends} + {row.useRich ? ( + <> + {t("keyboard.ctrlIntro")} +
    + {ctrlItems.map((_, cidx) => ( +
  • {t.rich(`keyboard.ctrlItems.${cidx}`, { code })}
  • + ))} +
+ + ) : ( + t.rich(`keyboard.rows.${idx}.use`, { code }) + )} +
+
+ + + {t.rich("keyboard.modalBody", { code, link: vmsLinkAmber })} + + +
+ {t("lxcAlt")} +
+ {t.rich("lxcCaption", { em })} +
+
+ +

{t("search.heading")}

+

+ {t.rich("search.intro", { code, strong, em })} +

+ +
+ {t("search.modalAlt")} +
+ {t.rich("search.modalCaption", { code, em })} +
+
+ +

+ {t("search.aboutLabel")}{" "} + + cheat.sh + + {" "} + {t.rich("search.aboutBody", { code })} +

+ +
+ + + + + + + + + + + + + + + + + + + + +
{t("search.headerSource")}{t("search.headerWhen")}{t("search.headerWhat")}
+ + cheat.sh + + {" "} + {t("search.onlineLabel")} + {t("search.onlineWhen")}{t.rich("search.onlineWhat", { green })}
{t("search.fallbackLabel")}{t("search.fallbackWhen")}{t.rich("search.fallbackWhat", { red })}
+
+ +

+ {t.rich("search.sendingNote", { strong })} +

+ +

{t("auth.heading")}

+
    + {authItems.map((_, idx) => ( +
  • {t.rich(`auth.items.${idx}`, { code, link: authLink })}
  • + ))} +
+ +

{t("clipboard.heading")}

+
    + {clipboardItems.map((_, idx) => ( +
  • {t.rich(`clipboard.items.${idx}`, { strong, code })}
  • + ))} +
+ +

{t("disconnect.heading")}

+

{t("disconnect.intro")}

+
+ + + + + + + + + {disconnectRows.map((row, idx) => ( + + + + + ))} + +
{t("disconnect.headerCause")}{t("disconnect.headerFix")}
{row.cause}{t.rich(`disconnect.rows.${idx}.fix`, { code })}
+
+ + + {t.rich("warning.body", { code, authLink: authLinkWarn, gatewayLink })} + + +

{t("whereNext.heading")}

+
    + {whereNextItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/monitor/dashboard/vms-lxcs/page.tsx b/web/app/[locale]/docs/monitor/dashboard/vms-lxcs/page.tsx new file mode 100644 index 00000000..f093cfe0 --- /dev/null +++ b/web/app/[locale]/docs/monitor/dashboard/vms-lxcs/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const green = () => + const amber = () => + const red = () => + const greenText = (chunks: React.ReactNode) => {chunks} + const amberText = (chunks: React.ReactNode) => {chunks} + const redText = (chunks: React.ReactNode) => {chunks} + const orangeText = (chunks: React.ReactNode) => {chunks} + const link = (chunks: React.ReactNode) => ( + {chunks} + ) + + 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 ( +
+ + + + {t.rich("intro.body", { code })} + + +

{t("topRow.heading")}

+

{t("topRow.intro")}

+ +
+ {t("topRow.imageAlt")} +
+ {t("topRow.imageCaption")} +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
{t("topRow.headerCard")}{t("topRow.headerWhat")}
{t("topRow.totalLabel")}{t.rich("topRow.totalWhat", { em })}
{t("topRow.cpuLabel")}{t.rich("topRow.cpuWhat", { em })}
{t("topRow.memoryLabel")} + {t("topRow.memoryIntro")} +
    + {memoryItems.map((_, idx) => ( +
  • {t.rich(`topRow.memoryItems.${idx}`, { strong, em, code })}
  • + ))} +
+
{t("topRow.diskLabel")}{t.rich("topRow.diskWhat", { em })}
+
+ +

{t("inventory.heading")}

+

+ {t.rich("inventory.intro", { code })} +

+ +
+ {t("inventory.imageAlt")} +
+ {t("inventory.imageCaption")} +
+
+ +

{t("inventory.rowsIntro")}

+
    + {inventoryRows.map((_, idx) => ( +
  • {t.rich(`inventory.rows.${idx}`, { strong, em })}
  • + ))} +
+

{t("inventory.clickHint")}

+ + + {t("inventory.mobileBody")} + + +

{t("drillIn.heading")}

+

+ {t.rich("drillIn.intro", { strong, em })} +

+ +

{t("drillIn.statusTitle")}

+ +
+ {t("drillIn.statusImageAlt")} +
+ {t("drillIn.statusImageCaption")} +
+
+ +

{t("drillIn.statusIntro")}

+ +

{t("drillIn.liveTitle")}

+
    + {liveItems.map((_, idx) => ( +
  • {t.rich(`drillIn.liveItems.${idx}`, { strong, em })}
  • + ))} +
+ +

{t("drillIn.ioTitle")}

+
    + {ioItems.map((_, idx) => ( +
  • {t.rich(`drillIn.ioItems.${idx}`, { strong })}
  • + ))} +
+ +

{t("drillIn.resourcesTitle")}

+

+ {t.rich("drillIn.resourcesIntro", { code })} +

+
    + {resourcesItems.map((_, idx) => ( +
  • {t.rich(`drillIn.resourcesItems.${idx}`, { strong, code })}
  • + ))} +
+ +

{t("drillIn.ipsTitle")}

+

{t("drillIn.ipsBody")}

+ +

{t("drillIn.mountsTitle")}

+ +
+ {t("drillIn.mountsImageAlt")} +
+ {t("drillIn.mountsImageCaption")} +
+
+ +

+ {t.rich("drillIn.mountsIntro", { strong, code })} +

+ +

{t("drillIn.mountTypesTitle")}

+
    + {mountTypesItems.map((_, idx) => ( +
  • {t.rich(`drillIn.mountTypesItems.${idx}`, { strong, em, code })}
  • + ))} +
+ +

{t("drillIn.mountStateTitle")}

+
    + {mountStateItems.map((_, idx) => ( +
  • + {t.rich(`drillIn.mountStateItems.${idx}`, { strong, em, code, green, amber, red })} +
  • + ))} +
+ + + {t("drillIn.mountsCalloutBody")} + + +

{t("drillIn.backupsTitle")}

+ +
+ {t("drillIn.backupsImageAlt")} +
+ {t("drillIn.backupsImageCaption")} +
+
+ +

{t("drillIn.backupsIntro")}

+
    + {backupsItems.map((_, idx) => ( +
  • {t.rich(`drillIn.backupsItems.${idx}`, { strong })}
  • + ))} +
+

+ {t.rich("drillIn.backupsOutro", { strong })} +

+ +

{t("drillIn.updatesTitle")}

+ +
+ {t("drillIn.updatesImageAlt")} +
+ {t("drillIn.updatesImageCaption")} +
+
+ +

+ {t.rich("drillIn.updatesIntro", { strong, code })} +

+ +

{t("drillIn.updatesPanelTitle")}

+
    + {updatesPanelItems.map((_, idx) => ( +
  • {t.rich(`drillIn.updatesPanelItems.${idx}`, { strong })}
  • + ))} +
+ +

{t("drillIn.updatesScopeTitle")}

+

+ {t.rich("drillIn.updatesScopeBody", { strong, em, code })} +

+ +

{t("drillIn.updatesToggleTitle")}

+ + {t.rich("drillIn.updatesToggleCalloutBody", { strong, code })} + + +

{t("drillIn.updatesApplyTitle")}

+

+ {t.rich("drillIn.updatesApplyBody", { code })} +

+ +

{t("drillIn.firewallTitle")}

+ +

{t("drillIn.firewallIntro")}

+ +
    + {firewallItems.map((_, idx) => ( +
  • + {t.rich(`drillIn.firewallItems.${idx}`, { + strong, + em, + code, + green: greenText, + orange: orangeText, + red: redText, + })} +
  • + ))} +
+ +

+ {t.rich("drillIn.firewallRefresh", { em, code })} +

+ + + {t("drillIn.firewallCalloutBody")} + + +

{t("drillIn.actionBarTitle")}

+

{t("drillIn.actionBarIntro")}

+
    +
  • {t.rich("drillIn.consoleItem", { strong, code, link })}
  • +
+

+ {t.rich("drillIn.lifecycleIntro", { code })} +

+
+ + + + + + + + + + {lifecycleRows.map((row, idx) => ( + + + + + + ))} + +
{t("drillIn.headerButton")}{t("drillIn.headerEnabled")}{t("drillIn.headerAction")}
+ {row.button} + {row.enabled}{row.action}
+
+ + + {t.rich("drillIn.forceStopBody", { strong })} + + +

{t("dataCollected.heading")}

+ +
+ + + + + + + + + + {dataRows.map((row, idx) => ( + + + + + + ))} + +
{t("dataCollected.headerSection")}{t("dataCollected.headerEndpoint")}{t("dataCollected.headerSource")}
{row.section}{row.endpoint}{t.rich(`dataCollected.rows.${idx}.source`, { code })}
+
+ + + +

{t("whereNext.heading")}

+
    + {whereNextItems.map((item, idx) => ( +
  • + + {item.label} + + {t.rich(`whereNext.items.${idx}.tailRich`, { code })} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/monitor/health-monitor/page.tsx b/web/app/[locale]/docs/monitor/health-monitor/page.tsx new file mode 100644 index 00000000..ecb58279 --- /dev/null +++ b/web/app/[locale]/docs/monitor/health-monitor/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const notifLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const aiLink = (chunks: React.ReactNode) => ( + {chunks} + ) + + return ( +
+ + + + {t.rich("intro.body", { code, strong })} + + +

{t("howItWorks.heading")}

+ +

+ {t.rich("howItWorks.intro", { strong })} +

+ +

{t("howItWorks.scannerTitle")}

+ + + +

{t("howItWorks.notifTitle")}

+ + + +

{t("categories.heading")}

+ +
+ {t("categories.imageAlt")} +
+ {t("categories.imageCaption")} +
+
+ +

+ {t.rich("categories.intro", { strong })} +

+ +
+ + + + + + + + + + {categoryRows.map((row, idx) => ( + + + + + + ))} + +
{t("categories.headerCategory")}{t("categories.headerChecks")}{t("categories.headerEvents")}
{row.category}{t.rich(`categories.rows.${idx}.checks`, { code })}{t.rich(`categories.rows.${idx}.events`, { code })}
+
+ +

{t("severity.heading")}

+
+ + + + + + + + + + + {severityRows.map((row, idx) => ( + + + + + + + ))} + +
{t("severity.headerStatus")}{t("severity.headerColour")}{t("severity.headerMeaning")}{t("severity.headerNotification")}
{row.status}{row.colour}{t.rich(`severity.rows.${idx}.meaning`, { em })}{row.notification}
+
+ +

+ {t.rich("severity.infoNote", { strong })} +

+ + + {t.rich("severity.unknownBody", { code, strong })} + + +

{t("dashboardView.heading")}

+

+ {t.rich("dashboardView.intro", { strong })} +

+
    + {dashboardItems.map((_, idx) => ( +
  • {t.rich(`dashboardView.items.${idx}`, { strong, em, code })}
  • + ))} +
+ + {t("dashboardView.pillBody")} + + +

{t("dismiss.heading")}

+

+ {t.rich("dismiss.intro", { em })} +

+
    +
  1. {t.rich("dismiss.step1", { strong, code })}
  2. +
  3. + {t.rich("dismiss.step2", { strong, code })} +
    {`24 hours       (default)
    +72 hours
    +168 hours      (one week)
    +720 hours      (one month)
    +8760 hours     (one year)
    +-1             (permanent — never re-fires)
    +       (any positive integer of hours)`}
    +
  4. +
+ +
+ {t("dismiss.dropdownImageAlt")} +
+ {t.rich("dismiss.dropdownImageCaption", { em })} +
+
+ +
+ {t("dismiss.imageAlt")} +
+ {t("dismiss.imageCaption")} +
+
+ +

+ {t.rich("dismiss.outro", { code })} +

+ +

{t("dismiss.activeSuppressionsTitle")}

+

+ {t.rich("dismiss.activeSuppressionsBody", { strong, em })} +

+ +

{t("dismiss.autoTitle")}

+

+ {t.rich("dismiss.autoBody", { strong })} +

+ + + {t.rich("dismiss.tempBody", { strong })} + + +

{t("dismiss.nonDismissableTitle")}

+

{t("dismiss.nonDismissableBody")}

+
+ + + + + + + + + {dismissRows.map((row, idx) => ( + + + + + ))} + +
{t("dismiss.headerFinding")}{t("dismiss.headerWhy")}
{row.finding}{row.why}
+
+

{t("dismiss.principle")}

+ +

{t("autoresolve.heading")}

+

{t("autoresolve.intro")}

+
+ + + + + + + + + {autoresolveRows.map((row, idx) => ( + + + + + ))} + +
{t("autoresolve.headerTrigger")}{t("autoresolve.headerAction")}
{t.rich(`autoresolve.rows.${idx}.trigger`, { code })}{row.action}
+
+ + {t.rich("autoresolve.permanentBody", { code, em })} + + +

{t("observations.heading")}

+

+ {t.rich("observations.intro", { code, strong })} +

+
+ + + + + + + + + + {observationsRows.map((row, idx) => ( + + + + + + ))} + +
{t("observations.headerProperty")}{t.rich("observations.headerErrors", { code })}{t.rich("observations.headerObs", { code })}
{row.property}{t.rich(`observations.rows.${idx}.errors`, { em, code, strong })}{t.rich(`observations.rows.${idx}.obs`, { em, code, strong })}
+
+

{t("observations.outro")}

+ + + {t.rich("observations.renameBody", { code })} + + +

{t("notification.heading")}

+

{t("notification.intro")}

+
    + {notificationItems.map((_, idx) => ( +
  1. {t.rich(`notification.items.${idx}`, { strong, code })}
  2. + ))} +
+

+ {t.rich("notification.outro", { notifLink, aiLink })} +

+ +

{t("rest.heading")}

+

{t("rest.intro")}

+
+ + + + + + + + + + {restRows.map((row, idx) => ( + + + + + + ))} + +
{t("rest.headerEndpoint")}{t("rest.headerMethod")}{t("rest.headerUse")}
{row.endpoint}{row.method}{t.rich(`rest.rows.${idx}.use`, { code })}
+
+ + " \\ + http://:8008/api/health/full | jq '.health.overall' + +${t("rest.codeComment2")} +curl -X POST http://:8008/api/health/acknowledge \\ + -H "Authorization: Bearer " \\ + -H "Content-Type: application/json" \\ + -d '{"error_key":"smart_sdh"}' + +${t("rest.codeComment3")} +curl -X POST http://:8008/api/health/settings \\ + -H "Authorization: Bearer " \\ + -H "Content-Type: application/json" \\ + -d '{"suppress_disks":"168"}'`} + className="my-4" + /> + +

{t("whereNext.heading")}

+
    + {whereNextItems.map((item, idx) => ( +
  • + + {item.label} + + {item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/monitor/integrations/page.tsx b/web/app/[locale]/docs/monitor/integrations/page.tsx new file mode 100644 index 00000000..bbed0903 --- /dev/null +++ b/web/app/[locale]/docs/monitor/integrations/page.tsx @@ -0,0 +1,1164 @@ +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 { + const { locale } = await params + const t = await getTranslations({ locale, namespace: "docs.monitor.integrations.meta" }) + return { + title: t("title"), + description: t("description"), + keywords: [ + "proxmox homepage integration", + "proxmox home assistant", + "proxmox grafana", + "proxmox prometheus", + "proxmox uptime kuma", + "proxmox dashboard", + "proxmenux integrations", + "proxmox custom api widget", + "proxmox rest sensor", + ], + alternates: { canonical: "https://proxmenux.com/docs/monitor/integrations" }, + openGraph: { + title: t("ogTitle"), + description: t("ogDescription"), + type: "article", + url: "https://proxmenux.com/docs/monitor/integrations", + }, + twitter: { + card: "summary", + title: t("twitterTitle"), + description: t("twitterDescription"), + }, + } +} + +type Row2 = { query: string; confirms: string } +type Row3 = { panel: string; promql: string } +type WhereNextItem = { label: string; href: string; tail: string } + +export default async function MonitorIntegrationsPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations({ locale, namespace: "docs.monitor.integrations" }) + + const messages = (await getMessages({ locale })) as unknown as { + docs: { monitor: { integrations: { + auth: { httpsItems: string[] } + homeAssistant: { + altViewSteps: string[] + twoEditorsItems: string[] + logoBrokenSteps: string[] + } + grafana: { verifyRows: Row2[]; panelRows: Row3[] } + uptimeKuma: { kumaSteps: string[] } + whereNext: { items: WhereNextItem[] } + } } } + } + const i = messages.docs.monitor.integrations + const httpsItems = i.auth.httpsItems + const altViewSteps = i.homeAssistant.altViewSteps + const twoEditorsItems = i.homeAssistant.twoEditorsItems + const logoBrokenSteps = i.homeAssistant.logoBrokenSteps + const verifyRows = i.grafana.verifyRows + const panelRows = i.grafana.panelRows + const kumaSteps = i.uptimeKuma.kumaSteps + const whereNextItems = i.whereNext.items + + const code = (chunks: React.ReactNode) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const apiLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const accessLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const promAnchor = (chunks: React.ReactNode) => ( + {chunks} + ) + const notifEventsLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const pveLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const ext = (href: string) => (chunks: React.ReactNode) => + ( + + {chunks} + + + ) + + return ( +
+ + + + {t.rich("intro.body", { link: apiLink })} + + +

{t("auth.heading")}

+ +

{t("auth.intro")}

+ +

{t("auth.optAtitle")}

+ +

+ {t.rich("auth.optAbody1", { strong, em })} +

+ +

{t("auth.optAbody2")}

+ + " http://:8008/api/system | jq`} + className="my-4" + /> + +

{t("auth.optBtitle")}

+ +

{t("auth.optBbody")}

+ + :8008/api/auth/login \\ + -H "Content-Type: application/json" \\ + -d '{"username":"admin","password":"","totp_token":"123456"}' | jq -r '.token' + +# 2. Use the returned token exactly like an API token +curl -H "Authorization: Bearer " http://:8008/api/system | jq`} + className="my-4" + /> + +

+ {t.rich("auth.outro", { link: accessLink })} +

+ + + {t.rich("auth.httpsIntro", { code, strong })} +
    + {httpsItems.map((_, idx) => ( +
  • {t.rich(`auth.httpsItems.${idx}`, { strong, code })}
  • + ))} +
+
+ +

+ + {t("homepage.heading")} + +

+ +

+ {t.rich("homepage.intro", { code })} +

+ + + {t.rich("homepage.iconCalloutBody", { + code, + a1: ext("https://dashboardicons.com"), + a2: ext("https://dashboardicons.com/icons/external/proxmenux"), + })} + + +
+ {t("homepage.imageAlt")} +
{t.rich("homepage.imageCaption", { code })}
+
+ +

{t("homepage.basicTitle")}

+ +

+ {t.rich("homepage.basicIntro", { code })} +

+ + + +

{t("homepage.authedTitle")}

+ +

+ {t.rich("homepage.authedIntro", { strong, code })} +

+ + + +

{t("homepage.authedOutro")}

+ +

{t("homepage.multiTitle")}

+ +

{t("homepage.multiIntro")}

+ + + + + {t.rich("homepage.multiCalloutBody", { code })} + + +

+ + {t("homeAssistant.heading")} + +

+ +

+ {t.rich("homeAssistant.intro", { code, link: apiLink })} +

+ +
+ {t("homeAssistant.imageAlt")} +
{t("homeAssistant.imageCaption")}
+
+ +

{t("homeAssistant.step1Title")}

+ +

+ {t.rich("homeAssistant.step1Body", { code })} +

+ + "`} + className="my-4" + /> + +

{t("homeAssistant.step2Title")}

+ +

+ {t.rich("homeAssistant.step2Body", { code })} +

+ + :8008/api/system + headers: + Authorization: !secret proxmenux_token_header + scan_interval: 30 + sensor: + - name: "ProxMenux CPU" + unique_id: proxmenux_cpu + value_template: "{{ value_json.cpu_usage }}" + unit_of_measurement: "%" + state_class: measurement + icon: mdi:cpu-64-bit + - name: "ProxMenux RAM" + unique_id: proxmenux_ram + value_template: "{{ value_json.memory_usage }}" + unit_of_measurement: "%" + state_class: measurement + icon: mdi:memory + - name: "ProxMenux Memory Used" + unique_id: proxmenux_memory_used + value_template: "{{ value_json.memory_used }}" + unit_of_measurement: "GB" + state_class: measurement + icon: mdi:memory + - name: "ProxMenux Memory Total" + unique_id: proxmenux_memory_total + value_template: "{{ value_json.memory_total }}" + unit_of_measurement: "GB" + icon: mdi:memory + - name: "ProxMenux CPU Temperature" + unique_id: proxmenux_cpu_temperature + value_template: "{{ value_json.temperature }}" + unit_of_measurement: "°C" + device_class: temperature + state_class: measurement + - name: "ProxMenux Uptime" + unique_id: proxmenux_uptime + value_template: "{{ value_json.uptime }}" + icon: mdi:clock-outline + - name: "ProxMenux Load 1m" + unique_id: proxmenux_load_1m + value_template: "{{ value_json.load_average[0] | round(2) }}" + state_class: measurement + icon: mdi:gauge + - name: "ProxMenux Available Updates" + unique_id: proxmenux_available_updates + value_template: "{{ value_json.available_updates }}" + state_class: measurement + icon: mdi:package-up + - name: "ProxMenux Host" + unique_id: proxmenux_host + value_template: "{{ value_json.hostname }}" + json_attributes: + - kernel_version + - proxmox_version + - cpu_cores + - cpu_threads + + # ─── Block 2: Health Monitor (overall + per-category + active errors) ─── + - resource: http://:8008/api/health/full + headers: + Authorization: !secret proxmenux_token_header + scan_interval: 60 + sensor: + - name: "ProxMenux Health" + unique_id: proxmenux_health_overall + value_template: "{{ value_json.health.overall }}" + json_attributes_path: "$.health" + json_attributes: + - summary + - details + icon: mdi:heart-pulse + - name: "ProxMenux Active Errors" + unique_id: proxmenux_active_errors + value_template: "{{ value_json.active_errors | length }}" + state_class: measurement + icon: mdi:alert-circle + json_attributes: + - active_errors + - name: "ProxMenux Dismissed Errors" + unique_id: proxmenux_dismissed_errors + value_template: "{{ value_json.dismissed | length }}" + state_class: measurement + icon: mdi:alert-circle-outline + + # ─── Block 3: VMs and containers ─── + - resource: http://:8008/api/vms + headers: + Authorization: !secret proxmenux_token_header + scan_interval: 60 + sensor: + - name: "ProxMenux VMs Total" + unique_id: proxmenux_vms_total + value_template: "{{ value_json | length }}" + state_class: measurement + icon: mdi:server + json_attributes: + - vms + - name: "ProxMenux VMs Running" + unique_id: proxmenux_vms_running + value_template: > + {{ value_json | selectattr('status', 'eq', 'running') | list | length }} + state_class: measurement + icon: mdi:play-circle + - name: "ProxMenux VMs Stopped" + unique_id: proxmenux_vms_stopped + value_template: > + {{ value_json | selectattr('status', 'eq', 'stopped') | list | length }} + state_class: measurement + icon: mdi:stop-circle + + # ─── Block 4: Storage summary ─── + - resource: http://:8008/api/storage/summary + headers: + Authorization: !secret proxmenux_token_header + scan_interval: 300 + sensor: + - name: "ProxMenux Storage Total" + unique_id: proxmenux_storage_total + value_template: "{{ value_json.total }}" + unit_of_measurement: "TB" + icon: mdi:harddisk + - name: "ProxMenux Storage Used" + unique_id: proxmenux_storage_used + value_template: "{{ value_json.used }}" + unit_of_measurement: "GB" + state_class: measurement + icon: mdi:harddisk + - name: "ProxMenux Storage Available" + unique_id: proxmenux_storage_available + value_template: "{{ value_json.available }}" + unit_of_measurement: "GB" + state_class: measurement + - name: "ProxMenux Disk Count" + unique_id: proxmenux_disk_count + value_template: "{{ value_json.disk_count }}" + + # ─── Block 5: Network summary ─── + - resource: http://:8008/api/network/summary + headers: + Authorization: !secret proxmenux_token_header + scan_interval: 30 + sensor: + - name: "ProxMenux Net Rx Bytes" + unique_id: proxmenux_net_rx_bytes + value_template: "{{ value_json.traffic.bytes_recv }}" + unit_of_measurement: "B" + device_class: data_size + state_class: total_increasing + - name: "ProxMenux Net Tx Bytes" + unique_id: proxmenux_net_tx_bytes + value_template: "{{ value_json.traffic.bytes_sent }}" + unit_of_measurement: "B" + device_class: data_size + state_class: total_increasing + - name: "ProxMenux Physical NICs Up" + unique_id: proxmenux_physical_nics_up + value_template: > + {{ value_json.physical_active_count }} / {{ value_json.physical_total_count }} + icon: mdi:ethernet + - name: "ProxMenux Bridges Up" + unique_id: proxmenux_bridges_up + value_template: > + {{ value_json.bridge_active_count }} / {{ value_json.bridge_total_count }} + icon: mdi:bridge + + # ─── Block 6: ProxMenux update availability ─── + - resource: http://:8008/api/proxmenux/update-status + headers: + Authorization: !secret proxmenux_token_header + scan_interval: 3600 + sensor: + - name: "ProxMenux Monitor Update" + unique_id: proxmenux_monitor_update + value_template: > + {{ 'update available' if (value_json.stable or value_json.beta) else 'up to date' }} + json_attributes: + - stable + - stable_version + - beta + - beta_version + icon: mdi:update`} + className="my-4" + /> + +

{t("homeAssistant.step3Title")}

+ +

{t("homeAssistant.step3Body")}

+ + + {{ (states('sensor.proxmenux_memory_total') | float + - states('sensor.proxmenux_memory_used') | float) | round(1) }} + - name: "ProxMenux Storage Usage Percent" + unique_id: proxmenux_storage_usage_percent + unit_of_measurement: "%" + state_class: measurement + state: > + {% set used = states('sensor.proxmenux_storage_used') | float %} + {% set free = states('sensor.proxmenux_storage_available') | float %} + {% set total = used + free %} + {{ (used / total * 100) | round(1) if total > 0 else 0 }}`} + className="my-4" + /> + +

{t("homeAssistant.step4Title")}

+ +

+ {t.rich("homeAssistant.step4Body", { em })} +

+ + + {t.rich("homeAssistant.replaceBody", { em, code })} + + +

{t("homeAssistant.step5Title")}

+ +

+ {t.rich("homeAssistant.step5Body", { strong, em })} +

+ + + + + {t.rich("homeAssistant.viewTipBody", { em, code })} +
{`- title: ProxMenux Monitor
+  icon: mdi:server
+  cards:
+    # ... paste the cards from the vertical-stack above, without the
+    # outer "type: vertical-stack" wrapper, indented one extra level
+`}
+ {t("homeAssistant.viewTipOutro")} +
+ +

{t("homeAssistant.altViewTitle")}

+ +

{t("homeAssistant.altViewIntro")}

+ +
    + {altViewSteps.map((_, idx) => ( +
  1. {t.rich(`homeAssistant.altViewSteps.${idx}`, { em })}
  2. + ))} +
+ + + {t("homeAssistant.twoEditorsIntro")} +
    + {twoEditorsItems.map((_, idx) => ( +
  • {t.rich(`homeAssistant.twoEditorsItems.${idx}`, { strong, em, code })}
  • + ))} +
+ {t.rich("homeAssistant.twoEditorsOutro", { em, code })} +
+ + + +
+ {t("homeAssistant.viewImageAlt")} +
{t.rich("homeAssistant.viewImageCaption", { em })}
+
+ + + {t.rich("homeAssistant.twoColTipBody", { em, code })} +
{`  - type: horizontal-stack
+    cards:
+      - type: entities
+        title: System
+        entities: [...]
+      - type: glance
+        title: "VMs & Containers"
+        entities: [...]`}
+ {t("homeAssistant.twoColTipOutro")} +
+ +

{t("homeAssistant.step6Title")}

+ +

+ {t.rich("homeAssistant.step6Body", { code })} +

+ + + data: + title: "Proxmox: warning" + message: > + {{ state_attr('sensor.proxmenux_health', 'summary') }} + +- alias: "ProxMenux — health CRITICAL" + trigger: + - platform: state + entity_id: sensor.proxmenux_health + to: "CRITICAL" + action: + - service: notify.mobile_app_ + data: + title: "🚨 Proxmox CRITICAL" + message: > + {{ state_attr('sensor.proxmenux_health', 'summary') }} + - service: persistent_notification.create + data: + title: "Proxmox CRITICAL" + message: > + {{ state_attr('sensor.proxmenux_health', 'summary') }} + notification_id: proxmenux_critical + +- alias: "ProxMenux — VM unexpectedly stopped" + trigger: + - platform: numeric_state + entity_id: sensor.proxmenux_vms_stopped + above: 0 + for: "00:02:00" + action: + - service: notify.mobile_app_ + data: + title: "Proxmox: VM stopped" + message: > + {{ states('sensor.proxmenux_vms_stopped') }} VM(s) currently stopped`} + className="my-4" + /> + +

{t("homeAssistant.logoTitle")}

+ +

+ {t.rich("homeAssistant.logoBody", { + a1: ext("https://dashboardicons.com"), + a2: ext("https://dashboardicons.com/icons/external/proxmenux"), + })} +

+ + + {t("homeAssistant.logoBrokenIntro")} +
    + {logoBrokenSteps.map((_, idx) => ( +
  1. + {t.rich(`homeAssistant.logoBrokenSteps.${idx}`, { + code, + a: ext("https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/proxmenux.svg"), + })} +
  2. + ))} +
+
+ + + {t.rich("homeAssistant.scanTipBody", { code })} + + +

+ + Prometheus + {" "}+{" "} + + Grafana + +

+ +

+ {t.rich("grafana.intro", { code })} +

+ +
+ {t("grafana.imageAlt")} +
{t.rich("grafana.imageCaption", { link: promAnchor })}
+
+ +

{t("grafana.step1Title")}

+ +

+ {t.rich("grafana.step1Body", { code })} +

+ + + +

+ {t.rich("grafana.step1After", { code, em })} +

+ + + {t.rich("grafana.tokenTipBody", { code })} + + +

{t("grafana.step2Title")}

+ +

+ {t.rich("grafana.step2Body", { code, em })} +

+ +
+ + + + + + + + + {verifyRows.map((row, idx) => ( + + + + + ))} + +
{t("grafana.headerQuery")}{t("grafana.headerConfirms")}
{row.query}{t.rich(`grafana.verifyRows.${idx}.confirms`, { code })}
+
+ + + {t.rich("grafana.calloutBody", { em, code })} + + +

{t("grafana.step3Title")}

+ +

+ {t.rich("grafana.step3Body", { em, code })} +

+ +

{t("grafana.step4Title")}

+ +

{t("grafana.step4Body")}

+ +
+ + + + + + + + + {panelRows.map((row, idx) => ( + + + + + ))} + +
{t("grafana.headerPanel")}{t("grafana.headerPromql")}
{row.panel}{row.promql}
+
+ +

+ {t.rich("grafana.outro", { em, code })} +

+ +

+ + Uptime Kuma + {" "}and other status checkers +

+ +

+ {t.rich("uptimeKuma.intro", { code })} +

+ +

{t("uptimeKuma.kumaTitle")}

+ +
    + {kumaSteps.map((_, idx) => ( +
  1. {t.rich(`uptimeKuma.kumaSteps.${idx}`, { em, code })}
  2. + ))} +
+ + + +

{t("uptimeKuma.healthchecksTitle")}

+ +

+ {t.rich("uptimeKuma.healthchecksBody", { code })} +

+ + + {t.rich("uptimeKuma.richBody", { code })} + + +

{t("workflows.heading")}

+ +

+ {t.rich("workflows.intro", { em, code })} +

+ + + +

+ {t.rich("workflows.n8nBody", { em, code })} +

+ +

+ {t.rich("workflows.severityBody", { code, link: notifEventsLink })} +

+ +

{t("pveWebhook.heading")}

+ +

+ {t.rich("pveWebhook.intro1", { em })} +

+ +

+ {t.rich("pveWebhook.intro2", { code, link: pveLink })} +

+ +

{t("whereNext.heading")}

+
    + {whereNextItems.map((item) => ( +
  • + + {item.label} + + {item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/monitor/notifications/page.tsx b/web/app/[locale]/docs/monitor/notifications/page.tsx new file mode 100644 index 00000000..27d636ee --- /dev/null +++ b/web/app/[locale]/docs/monitor/notifications/page.tsx @@ -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 { + 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) => {chunks} + const strong = (chunks: React.ReactNode) => {chunks} + const em = (chunks: React.ReactNode) => {chunks} + const hmLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const pveLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const aiLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const aiPageLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const catalogueLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const quietLink = (chunks: React.ReactNode) => ( + {chunks} + ) + const ext = (href: string) => (chunks: React.ReactNode) => + ( + + {chunks} + + + ) + + return ( +
+ + + + {t.rich("intro.body", { link: hmLink })} + + +

{t("howItWorks.heading")}

+ +

{t("howItWorks.intro")}

+ + + +

{t("enabling.heading")}

+ +

+ {t.rich("enabling.intro", { em })} +

+ +
+ {t("enabling.disabledAlt")} +
{t("enabling.disabledCaption")}
+
+ +

{t("enabling.stepsIntro")}

+
    + {enablingSteps.map((_, idx) => ( +
  1. {t.rich(`enabling.steps.${idx}`, { em, code, pvelink: pveLink })}
  2. + ))} +
+ +
+ {t("enabling.activeAlt")} +
{t.rich("enabling.activeCaption", { em })}
+
+ +

{t("sources.heading")}

+ +

+ {t.rich("sources.intro", { code })} +

+ +
+ + + + + + + + + + {sourceRows.map((row, idx) => ( + + + + + + ))} + +
{t("sources.headerCollector")}{t("sources.headerWatches")}{t("sources.headerEvents")}
{row.collector}{t.rich(`sources.rows.${idx}.watches`, { code, pvelink: pveLink })}{t.rich(`sources.rows.${idx}.events`, { code })}
+
+ +

+ {t.rich("sources.after1", { code })} +

+ +

+ {t.rich("sources.after2", { code, ailink: aiLink })} +

+ +

{t("channels.heading")}

+ +

+ {t.rich("channels.intro", { em })} +

+ + + {t.rich("channels.credsBody", { code })} + + +

{t("telegram.heading")}

+ +

+ {t.rich("telegram.intro", { strong, em })} +

+ +
+ {t("telegram.guideAlt")} +
{t.rich("telegram.guideCaption", { em })}
+
+ +

{t("telegram.step1Title")}

+ +
    + {tgStep1.map((_, idx) => ( +
  1. {t.rich(`telegram.step1Items.${idx}`, { em, code, a: ext("https://t.me/BotFather") })}
  2. + ))} +
+ +

{t("telegram.step2Title")}

+ +

+ {t.rich("telegram.step2Intro", { em })} +

+ +

+ {t("telegram.privateLabel")} +

+
    + {tgPriv.map((_, idx) => ( +
  1. + {t.rich(`telegram.privateItems.${idx}`, { + code, + a1: ext("https://t.me/userinfobot"), + a2: ext("https://t.me/myidbot"), + a: ext("https://t.me/userinfobot"), + })} +
  2. + ))} +
+ +
+ {t("telegram.privateAlt")} +
{t("telegram.privateCaption")}
+
+ +

+ {t("telegram.groupLabel")} +

+
    + {tgGroup.map((_, idx) => ( +
  1. {t.rich(`telegram.groupItems.${idx}`, { code, em })}
  2. + ))} +
+ +
+ {t("telegram.groupAlt")} +
{t.rich("telegram.groupCaption", { code, em })}
+
+ +

{t("telegram.step3Title")}

+ +

+ {t.rich("telegram.step3Body", { em })} +

+ +

{t("discord.heading")}

+ +

+ {t.rich("discord.intro", { em })} +

+ +
    + {discordItems.map((_, idx) => ( +
  1. {t.rich(`discord.items.${idx}`, { em, code })}
  2. + ))} +
+ +
+ {t("discord.imageAlt")} +
{t.rich("discord.imageCaption", { em })}
+
+ +

{t("gotify.heading")}

+ +

+ {t.rich("gotify.intro", { em })} +

+ +
    + {gotifyItems.map((_, idx) => ( +
  1. {t.rich(`gotify.items.${idx}`, { em, code, a: ext("https://gotify.net/docs/install") })}
  2. + ))} +
+ +
+ {t("gotify.imageAlt")} +
{t("gotify.imageCaption")}
+
+ +

{t("email.heading")}

+ +

{t("email.intro")}

+ +
+ {t("email.imageAlt")} +
{t("email.imageCaption")}
+
+ +

+ {t.rich("email.appNote", { strong })} +

+ +

{t("email.gmailTitle")}

+ +

+ {t.rich("email.gmailIntro", { strong, em })} +

+ +
    + {gmailItems.map((_, idx) => ( +
  1. + {t.rich(`email.gmailItems.${idx}`, { + em, + code, + a: + idx === 0 + ? ext("https://myaccount.google.com/security") + : ext("https://myaccount.google.com/apppasswords"), + })} +
  2. + ))} +
+ +

{t("email.outlookTitle")}

+ +

+ {t.rich("email.outlookIntro", { strong })} +

+ +
    + {outlookItems.map((_, idx) => ( +
  1. {t.rich(`email.outlookItems.${idx}`, { em, code, a: ext("https://account.microsoft.com/security") })}
  2. + ))} +
+ + + {t("email.relayBody")} + + +

{t("apprise.heading")}

+ +

{t("apprise.intro")}

+ +

{t("apprise.listIntro")}

+ +
    + {appriseListItems.map((_, idx) => ( +
  • + {t.rich(`apprise.listItems.${idx}`, { + a: idx === 0 ? ext("https://github.com/caronc/apprise/wiki") : ext("https://github.com/caronc/apprise/wiki/URLBasics"), + })} +
  • + ))} +
+ +

{t("apprise.stepsTitle")}

+ +
    + {appriseSteps.map((_, idx) => ( +
  1. {t.rich(`apprise.steps.${idx}`, { em, code, a: ext("https://github.com/caronc/apprise/wiki") })}
  2. + ))} +
+ + + {t("apprise.deliveredBody")} + + + + {t.rich("apprise.fanoutBody", { a: ext("https://github.com/caronc/apprise-api") })} + + +

{t("rich.heading")}

+ +

+ {t.rich("rich.intro", { em })} +

+ +
+ {t("rich.imageAlt")} +
{t.rich("rich.imageCaption", { em })}
+
+ +

{t("rich.richTitle")}

+ +

+ {t.rich("rich.richIntro", { em })} +

+ +
+
+
+ {t("rich.plainHeader")} +
+
+{`[INFO] vm_start
+VM 101 (homeassistant) started
+on node pve-01
+host: home-lab`}
+          
+
+
+
+ {t("rich.richHeader")} +
+
+{`🟢 VM started
+VM 101 (homeassistant) is now
+running on node pve-01
+🏠 home-lab`}
+          
+
+
+ +

{t("rich.richOutro")}

+ +

{t("rich.togglesTitle")}

+ +

{t("rich.togglesIntro")}

+ +
    + {togglesItems.map((_, idx) => ( +
  1. {t.rich(`rich.togglesItems.${idx}`, { strong, em, code })}
  2. + ))} +
+ +

+ {t.rich("rich.togglesOutro", { em, code })} +

+ +

{t("quiet.heading")}

+ +

+ {t.rich("quiet.intro", { strong })} +

+ +
+ {t("quiet.imageAlt")} +
{t("quiet.imageCaption")}
+
+ +

{t("quiet.purposeTitle")}

+
    + {quietPurpose.map((_, idx) => ( +
  • {t.rich(`quiet.purposeItems.${idx}`, { strong })}
  • + ))} +
+ +

{t("quiet.howTitle")}

+
    + {quietHow.map((_, idx) => ( +
  1. {t.rich(`quiet.howItems.${idx}`, { strong, code })}
  2. + ))} +
+ + + {t.rich("quiet.criticalBody", { link: catalogueLink })} + + +

{t("digest.heading")}

+ +

+ {t.rich("digest.intro1", { strong })} +

+ +

+ {t.rich("digest.intro2", { link: quietLink })} +

+ +

{t("digest.purposeTitle")}

+
    + {digestPurpose.map((_, idx) => ( +
  • {t.rich(`digest.purposeItems.${idx}`, { strong })}
  • + ))} +
+ +

{t("digest.howTitle")}

+
    + {digestHow.map((_, idx) => ( +
  1. + {t.rich(`digest.howItems.${idx}`, { strong, em, code })} + {idx === 3 && ( +
      + {digestNeverSub.map((_, sIdx) => ( +
    • {t.rich(`digest.neverDelayedSub.${sIdx}`, { strong })}
    • + ))} +
    + )} +
  2. + ))} +
+ + + {t.rich("digest.comboBody", { em })} + + +

{t("displayName.heading")}

+ +

+ {t.rich("displayName.intro", { em, code })} +

+ +
+ {t("displayName.imageAlt")} +
{t("displayName.imageCaption")}
+
+ +

+ {t.rich("displayName.outro", { em, code })} +

+ +

{t("dispatch.heading")}

+ +

{t("dispatch.intro")}

+ +
+ + + + + + + + + + {dispatchRows.map((row, idx) => ( + + + + + + ))} + +
{t("dispatch.headerStage")}{t("dispatch.headerWhat")}{t("dispatch.headerTunable")}
{row.stage}{t.rich(`dispatch.rows.${idx}.what`, { code })}{row.tunable}
+
+ + + {t("dispatch.calloutBody")} + + +

{t("aiRewrite.heading")}

+ +

{t("aiRewrite.body1")}

+ +

+ {t.rich("aiRewrite.body2", { code, link: aiPageLink })} +

+ + + {t("aiRewrite.privacyBody")} + + +

{t("pveWebhook.heading")}

+ +

+ {t.rich("pveWebhook.intro1", { em, code })} +

+ +

+ {t.rich("pveWebhook.intro2", { em })} +

+ +
+ {t("pveWebhook.imageAlt")} +
{t("pveWebhook.imageCaption")}
+
+ +

{t("pveWebhook.registeredIntro")}

+ +
    + {pveRegistered.map((_, idx) => ( +
  • + {t.rich(`pveWebhook.registeredItems.${idx}`, { strong, em, code })} + {idx === 1 && ( + {`{ "title": "{{ escape title }}", + "message": "{{ escape message }}", + "severity": "{{ severity }}", + "timestamp": "{{ timestamp }}", + "fields": {{ json fields }} }`} + )} +
  • + ))} +
+ +

{t("pveWebhook.securityTitle")}

+ +

+ {t.rich("pveWebhook.securityIntro", { code })} +

+ +
    + {pveSecurity.map((_, idx) => ( +
  • {t.rich(`pveWebhook.securityItems.${idx}`, { strong, code })}
  • + ))} +
+ + + {t.rich("pveWebhook.practiceBody", { code })} + + +

{t("pveWebhook.actionsIntro")}

+ +
    + {pveActions.map((_, idx) => ( +
  • {t.rich(`pveWebhook.actionsItems.${idx}`, { strong, code })}
  • + ))} +
+ + + {t.rich("pveWebhook.clusterBody", { code, em })} + + +

{t("catalogue.heading")}

+ +

{t("catalogue.intro")}

+ +
+ + + + + + + + + {catalogueRows.map((row, idx) => ( + + + + + ))} + +
{t("catalogue.headerGroup")}{t("catalogue.headerEvents")}
{row.group}{t.rich(`catalogue.rows.${idx}.events`, { code })}
+
+ +

+ {t.rich("catalogue.burstNote", { code })} +

+ +

{t("history.heading")}

+ +

+ {t.rich("history.body1", { em, code })} +

+ +

+ {t.rich("history.body2", { em })} +

+ +

+ {t.rich("history.body3", { code })} +

+ +

{t("api.heading")}

+ +
+ + + + + + + + + + {apiRows.map((row, idx) => ( + + + + + + ))} + +
{t("api.headerEndpoint")}{t("api.headerMethod")}{t("api.headerUse")}
{row.endpoint}{row.method}{t.rich(`api.rows.${idx}.use`, { code })}
+
+ + :8008/api/notifications/test \\ + -H "Authorization: Bearer " \\ + -H "Content-Type: application/json" \\ + -d '{"channel":"discord"}' + +# Emit a custom event from a script +curl -X POST http://:8008/api/notifications/send \\ + -H "Authorization: Bearer " \\ + -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 " \\ + 'http://:8008/api/notifications/history?channel=telegram&limit=50' | jq + +# Test an AI provider connection (verifies the API key and model) +curl -X POST http://:8008/api/notifications/test-ai \\ + -H "Authorization: Bearer " \\ + -H "Content-Type: application/json" \\ + -d '{"provider":"openai","api_key":"sk-...","model":"gpt-4o-mini"}'`} + className="my-4" + /> + +

{t("whereNext.heading")}

+
    + {whereNextItems.map((item, idx) => ( +
  • + + {item.label} + + {item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail} +
  • + ))} +
+
+ ) +} diff --git a/web/app/[locale]/docs/monitor/page.tsx b/web/app/[locale]/docs/monitor/page.tsx new file mode 100644 index 00000000..bee8e0eb --- /dev/null +++ b/web/app/[locale]/docs/monitor/page.tsx @@ -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//docs/
/.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: '' })` once and uses + * `t()` for plain text and `t.rich()` for paragraphs containing + * , , or 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 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> + ) +} diff --git a/web/app/[locale]/docs/network/backup-restore/page.tsx b/web/app/[locale]/docs/network/backup-restore/page.tsx new file mode 100644 index 00000000..735836f2 --- /dev/null +++ b/web/app/[locale]/docs/network/backup-restore/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/network/bridge-analysis/page.tsx b/web/app/[locale]/docs/network/bridge-analysis/page.tsx new file mode 100644 index 00000000..8ce5b965 --- /dev/null +++ b/web/app/[locale]/docs/network/bridge-analysis/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/network/config-analysis/page.tsx b/web/app/[locale]/docs/network/config-analysis/page.tsx new file mode 100644 index 00000000..08ff03b0 --- /dev/null +++ b/web/app/[locale]/docs/network/config-analysis/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/network/diagnostics/page.tsx b/web/app/[locale]/docs/network/diagnostics/page.tsx new file mode 100644 index 00000000..95d18a2d --- /dev/null +++ b/web/app/[locale]/docs/network/diagnostics/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/network/monitoring/page.tsx b/web/app/[locale]/docs/network/monitoring/page.tsx new file mode 100644 index 00000000..56766fdd --- /dev/null +++ b/web/app/[locale]/docs/network/monitoring/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/network/page.tsx b/web/app/[locale]/docs/network/page.tsx new file mode 100644 index 00000000..0c7a2138 --- /dev/null +++ b/web/app/[locale]/docs/network/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/network/persistent-names/page.tsx b/web/app/[locale]/docs/network/persistent-names/page.tsx new file mode 100644 index 00000000..b136cd5f --- /dev/null +++ b/web/app/[locale]/docs/network/persistent-names/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/page.tsx b/web/app/[locale]/docs/page.tsx new file mode 100644 index 00000000..9cfb1079 --- /dev/null +++ b/web/app/[locale]/docs/page.tsx @@ -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 }) +} diff --git a/web/app/[locale]/docs/post-install/automated/page.tsx b/web/app/[locale]/docs/post-install/automated/page.tsx new file mode 100644 index 00000000..a2ca3256 --- /dev/null +++ b/web/app/[locale]/docs/post-install/automated/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/post-install/basic-settings/page.tsx b/web/app/[locale]/docs/post-install/basic-settings/page.tsx new file mode 100644 index 00000000..0cab3228 --- /dev/null +++ b/web/app/[locale]/docs/post-install/basic-settings/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/post-install/customizable/page.tsx b/web/app/[locale]/docs/post-install/customizable/page.tsx new file mode 100644 index 00000000..631225d2 --- /dev/null +++ b/web/app/[locale]/docs/post-install/customizable/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/post-install/customization/page.tsx b/web/app/[locale]/docs/post-install/customization/page.tsx new file mode 100644 index 00000000..35e2e3bc --- /dev/null +++ b/web/app/[locale]/docs/post-install/customization/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/post-install/monitoring/page.tsx b/web/app/[locale]/docs/post-install/monitoring/page.tsx new file mode 100644 index 00000000..15cc24c7 --- /dev/null +++ b/web/app/[locale]/docs/post-install/monitoring/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/post-install/network/page.tsx b/web/app/[locale]/docs/post-install/network/page.tsx new file mode 100644 index 00000000..609b31c8 --- /dev/null +++ b/web/app/[locale]/docs/post-install/network/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/post-install/optional/page.tsx b/web/app/[locale]/docs/post-install/optional/page.tsx new file mode 100644 index 00000000..673c759e --- /dev/null +++ b/web/app/[locale]/docs/post-install/optional/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/post-install/page.tsx b/web/app/[locale]/docs/post-install/page.tsx new file mode 100644 index 00000000..eb1b45d3 --- /dev/null +++ b/web/app/[locale]/docs/post-install/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/post-install/performance/page.tsx b/web/app/[locale]/docs/post-install/performance/page.tsx new file mode 100644 index 00000000..734f8197 --- /dev/null +++ b/web/app/[locale]/docs/post-install/performance/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/post-install/security/page.tsx b/web/app/[locale]/docs/post-install/security/page.tsx new file mode 100644 index 00000000..95dbf88f --- /dev/null +++ b/web/app/[locale]/docs/post-install/security/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/post-install/storage/page.tsx b/web/app/[locale]/docs/post-install/storage/page.tsx new file mode 100644 index 00000000..7d21c57e --- /dev/null +++ b/web/app/[locale]/docs/post-install/storage/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/post-install/system/page.tsx b/web/app/[locale]/docs/post-install/system/page.tsx new file mode 100644 index 00000000..074b01b6 --- /dev/null +++ b/web/app/[locale]/docs/post-install/system/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/post-install/uninstall/page.tsx b/web/app/[locale]/docs/post-install/uninstall/page.tsx new file mode 100644 index 00000000..c082d766 --- /dev/null +++ b/web/app/[locale]/docs/post-install/uninstall/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/post-install/updates/page.tsx b/web/app/[locale]/docs/post-install/updates/page.tsx new file mode 100644 index 00000000..35bbf7b3 --- /dev/null +++ b/web/app/[locale]/docs/post-install/updates/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/post-install/virtualization/page.tsx b/web/app/[locale]/docs/post-install/virtualization/page.tsx new file mode 100644 index 00000000..0e7ea213 --- /dev/null +++ b/web/app/[locale]/docs/post-install/virtualization/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/security/fail2ban/page.tsx b/web/app/[locale]/docs/security/fail2ban/page.tsx new file mode 100644 index 00000000..73c483b2 --- /dev/null +++ b/web/app/[locale]/docs/security/fail2ban/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/security/lynis/page.tsx b/web/app/[locale]/docs/security/lynis/page.tsx new file mode 100644 index 00000000..73be4ab5 --- /dev/null +++ b/web/app/[locale]/docs/security/lynis/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/security/page.tsx b/web/app/[locale]/docs/security/page.tsx new file mode 100644 index 00000000..f9dc8da3 --- /dev/null +++ b/web/app/[locale]/docs/security/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/security/ssl-letsencrypt/page.tsx b/web/app/[locale]/docs/security/ssl-letsencrypt/page.tsx new file mode 100644 index 00000000..678cbc33 --- /dev/null +++ b/web/app/[locale]/docs/security/ssl-letsencrypt/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/settings/beta-program/page.tsx b/web/app/[locale]/docs/settings/beta-program/page.tsx new file mode 100644 index 00000000..93aef76f --- /dev/null +++ b/web/app/[locale]/docs/settings/beta-program/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/settings/change-language/page.tsx b/web/app/[locale]/docs/settings/change-language/page.tsx new file mode 100644 index 00000000..bac73773 --- /dev/null +++ b/web/app/[locale]/docs/settings/change-language/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/settings/page.tsx b/web/app/[locale]/docs/settings/page.tsx new file mode 100644 index 00000000..b8c714e0 --- /dev/null +++ b/web/app/[locale]/docs/settings/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/settings/proxmenux-monitor/page.tsx b/web/app/[locale]/docs/settings/proxmenux-monitor/page.tsx new file mode 100644 index 00000000..70824193 --- /dev/null +++ b/web/app/[locale]/docs/settings/proxmenux-monitor/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/settings/show-version-information/page.tsx b/web/app/[locale]/docs/settings/show-version-information/page.tsx new file mode 100644 index 00000000..82ba7aa9 --- /dev/null +++ b/web/app/[locale]/docs/settings/show-version-information/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/settings/uninstall-proxmenux/page.tsx b/web/app/[locale]/docs/settings/uninstall-proxmenux/page.tsx new file mode 100644 index 00000000..5d1b401f --- /dev/null +++ b/web/app/[locale]/docs/settings/uninstall-proxmenux/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/storage-share/host-iscsi/page.tsx b/web/app/[locale]/docs/storage-share/host-iscsi/page.tsx new file mode 100644 index 00000000..83b08d63 --- /dev/null +++ b/web/app/[locale]/docs/storage-share/host-iscsi/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/storage-share/host-local-disk/page.tsx b/web/app/[locale]/docs/storage-share/host-local-disk/page.tsx new file mode 100644 index 00000000..0ab5d05a --- /dev/null +++ b/web/app/[locale]/docs/storage-share/host-local-disk/page.tsx @@ -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"> </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> + ) +} diff --git a/web/app/[locale]/docs/storage-share/host-local-shared/page.tsx b/web/app/[locale]/docs/storage-share/host-local-shared/page.tsx new file mode 100644 index 00000000..dfe66272 --- /dev/null +++ b/web/app/[locale]/docs/storage-share/host-local-shared/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/storage-share/host-nfs/page.tsx b/web/app/[locale]/docs/storage-share/host-nfs/page.tsx new file mode 100644 index 00000000..c7315bb3 --- /dev/null +++ b/web/app/[locale]/docs/storage-share/host-nfs/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/storage-share/host-samba/page.tsx b/web/app/[locale]/docs/storage-share/host-samba/page.tsx new file mode 100644 index 00000000..c2ef1dfd --- /dev/null +++ b/web/app/[locale]/docs/storage-share/host-samba/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/storage-share/lxc-mount-points/page.tsx b/web/app/[locale]/docs/storage-share/lxc-mount-points/page.tsx new file mode 100644 index 00000000..903386e6 --- /dev/null +++ b/web/app/[locale]/docs/storage-share/lxc-mount-points/page.tsx @@ -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> + ) +} diff --git a/web/app/[locale]/docs/storage-share/lxc-nfs-client/page.tsx b/web/app/[locale]/docs/storage-share/lxc-nfs-client/page.tsx new file mode 100644 index 00000000..d7a1aa64 --- /dev/null +++ b/web/app/[locale]/docs/storage-share/lxc-nfs-client/page.tsx @@ -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 { 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.lxcNfsClient.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-nfs-client", + }, + } +} + +type FlagRow = { flag: string; effect?: string; effectRich?: string } +type StringItem = string +type RelatedItem = { href: string; label: string; tail?: string; tailRich?: string } + +export default async function LxcNfsClientPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations({ locale, namespace: "docs.storageShare.lxcNfsClient" }) + + const messages = (await getMessages({ locale })) as unknown as { + docs: { storageShare: { lxcNfsClient: { + fstabFlags: { rows: FlagRow[] } + troubleshoot: { aptItems: StringItem[]; squashItems: StringItem[] } + related: { items: RelatedItem[] } + } } } + } + const flagRows = messages.docs.storageShare.lxcNfsClient.fstabFlags.rows + const aptItems = messages.docs.storageShare.lxcNfsClient.troubleshoot.aptItems + const squashItems = messages.docs.storageShare.lxcNfsClient.troubleshoot.squashItems + const relatedItems = messages.docs.storageShare.lxcNfsClient.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 hostNfsLink = (chunks: React.ReactNode) => ( + <Link href="/docs/storage-share/host-nfs" className="text-blue-700 hover:underline">{chunks}</Link> + ) + const mountLink = (chunks: React.ReactNode) => ( + <Link href="/docs/storage-share/lxc-mount-points" className="text-blue-700 hover:underline">{chunks}</Link> + ) + const importLink = (chunks: React.ReactNode) => ( + <Link href="/docs/disk-manager/import-disk-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={8} + scriptPath="share/nfs_client.sh" + /> + + <Callout variant="warning" title={t("privReq.title")}> + {t.rich("privReq.body", { code, strong, hostNfsLink, mountLink })} + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("what.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("what.body", { em, strong, code })} + </p> + + <DataFlowDiagram + nodes={[ + { label: t("what.diagramServerLabel"), detail: t("what.diagramServerDetail"), variant: "source" }, + { label: t("what.diagramHostLabel"), detail: t("what.diagramHostDetail"), variant: "bridge" }, + { label: t("what.diagramCtLabel"), detail: t("what.diagramCtDetail"), variant: "target" }, + ]} + arrowLabel={t("what.diagramArrow")} + command={`# Inside the CT — what the script writes: +pct exec <ctid> -- mount -t nfs -o rw,hard,rsize=…,wsize=… \\ + <server>:/export/data /mnt/data + +# Persistent (added to /etc/fstab inside the CT): +<server>:/export/data /mnt/data nfs <opts>,_netdev,x-systemd.automount,noauto 0 0`} + /> + + <Callout variant="info" title={t("what.twoWaysTitle")}> + <ul className="mt-2 list-disc list-inside space-y-1"> + <li>{t.rich("what.twoWaysBind", { strong, mountLink })}</li> + <li>{t.rich("what.twoWaysDirect", { strong })}</li> + </ul> + </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-nfs-client-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> + + <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, server, export, options │ +│ (nothing touched yet) │ +└──────────────────┬──────────────────────────┘ + ▼ + Privileged-CT gate (share-common.func) + ├─ pct list — pick CT + ├─ Auto-start if stopped + └─ Reads /etc/pve/lxc/<ctid>.conf + └─ unprivileged: 1 → abort with help message + │ + ▼ + Install NFS client packages (in CT) + └─ pct exec apt-get install -y nfs-common + (skipped if nfs-common is already installed) + Verifies: showmount + mount.nfs both present + │ + ▼ + Server selection + ├─ Auto-discover (nmap from HOST on /24, + │ port 2049, then showmount -e per result) + └─ Manual: type IP or hostname + │ + ▼ + Reachability validation chain (from inside CT) + ├─ pct exec ping -c 1 -W 3 <server> ── fail → abort + ├─ pct exec nc -z -w 3 <server> 2049 ── fail → abort + └─ pct exec showmount -e <server> ── fail → abort + │ + ▼ + Export selection + ├─ Server returns exports → checklist with ACL + └─ No exports / blocked → manual input + │ + ▼ + Validate the chosen export still exists + (re-runs showmount -e | grep <export>) + │ + ▼ + Mount-point picker (3 options) + ├─ 1. Create new folder in /mnt + │ (default: nfs_<server>_<export-basename>) + ├─ 2. Select existing folder in /mnt + │ (warns if folder is not empty — + │ mounting hides existing files) + └─ 3. Enter custom path + │ + ▼ + Mount-options preset (3 options) + ├─ 1. Read/write + │ rw,hard,rsize=1048576,wsize=1048576, + │ timeo=600,retrans=2 + ├─ 2. Read-only + │ ro,hard,rsize=1048576,wsize=1048576, + │ timeo=600,retrans=2 + └─ 3. Custom — type your own option string + │ + ▼ + Permanent mount? (yes/no) + └─ yes → write entry to /etc/fstab + │ + ┌──────── Cancel OR Confirm ────┐ + ▼ ▼ +Exit, nothing ┌─────────────────┴─────────────────┐ +was changed │ PHASE 2 — Mount and persist │ + └─────────────────┬─────────────────┘ + ▼ + Create mount point if missing + (pct exec mkdir -p <path>) + ▼ + If something is already mounted there, + offer to unmount first + ▼ + pct exec mount -t nfs \\ + -o <chosen options> \\ + <server>:<export> <mount-point> + ▼ + Smoke test: write a 0-byte file + (.test_write) and delete it + └─ no write access → "read-only" + ▼ + If "permanent" was chosen: + └─ Append to /etc/fstab inside CT: + <srv>:<exp> <mp> nfs \\ + <opts>,_netdev, + x-systemd.automount,noauto 0 0 + (any prior entry for this MP is removed first) + ▼ + Print summary (server / export / mp / + options / permanent yes-no)`} + </pre> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("fstabFlags.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("fstabFlags.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("fstabFlags.headerFlag")}</th> + <th className="px-4 py-2 font-semibold">{t("fstabFlags.headerEffect")}</th> + </tr> + </thead> + <tbody className="divide-y divide-gray-200 text-gray-800"> + {flagRows.map((row, idx) => ( + <tr key={row.flag}> + <td className="px-4 py-2 font-mono">{row.flag}</td> + <td className="px-4 py-2"> + {row.effectRich ? t.rich(`fstabFlags.rows.${idx}.effectRich`, { code }) : row.effect} + </td> + </tr> + ))} + </tbody> + </table> + </div> + + <Callout variant="info" title={t("fstabFlags.netEffectTitle")}> + {t.rich("fstabFlags.netEffectBody", { 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.rich("manual.body", { strong, code })} + </p> + <CopyableCode code={`# 1. install the NFS client (one-time) +apt-get update +apt-get install -y nfs-common + +# 2. test reachability +ping -c 1 -W 3 10.0.0.50 +nc -z -w 3 10.0.0.50 2049 +showmount -e 10.0.0.50 + +# 3. mount it (one-shot) +mkdir -p /mnt/data +mount -t nfs -o "rw,hard,rsize=1048576,wsize=1048576,timeo=600,retrans=2" \\ + 10.0.0.50:/export/data /mnt/data + +# 4. make it permanent (safe boot defaults) +cat >> /etc/fstab <<EOF +10.0.0.50:/export/data /mnt/data nfs rw,hard,rsize=1048576,wsize=1048576,timeo=600,retrans=2,_netdev,x-systemd.automount,noauto 0 0 +EOF +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 })}</p> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("unmount.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("unmount.body", { strong, em, code })} + </p> + + <Callout variant="warning" title={t("unmount.warnTitle")}> + {t.rich("unmount.warnBody", { code, em })} + </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.privTitle")}> + {t.rich("troubleshoot.privBody", { code, importLink })} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.aptTitle")}> + {t.rich("troubleshoot.aptIntro", { code })} + <ul className="mt-2 list-disc list-inside space-y-1"> + {aptItems.map((_, idx) => ( + <li key={idx}>{t.rich(`troubleshoot.aptItems.${idx}`, { code })}</li> + ))} + </ul> + {t("troubleshoot.aptOutro")} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.portTitle")}> + {t.rich("troubleshoot.portBody", { code })} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.bootTitle")}> + {t.rich("troubleshoot.bootBody", { code })} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.squashTitle")}> + {t.rich("troubleshoot.squashIntro", { code })} + <ul className="mt-2 list-disc list-inside space-y-1"> + {squashItems.map((_, idx) => ( + <li key={idx}>{t.rich(`troubleshoot.squashItems.${idx}`, { code })}</li> + ))} + </ul> + {t("troubleshoot.squashOutro")} + </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`, { em }) : item.tail} + </li> + ))} + </ul> + </div> + ) +} diff --git a/web/app/[locale]/docs/storage-share/lxc-nfs-server/page.tsx b/web/app/[locale]/docs/storage-share/lxc-nfs-server/page.tsx new file mode 100644 index 00000000..375e3d2a --- /dev/null +++ b/web/app/[locale]/docs/storage-share/lxc-nfs-server/page.tsx @@ -0,0 +1,350 @@ +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.lxcNfsServer.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-nfs-server", + }, + } +} + +type NetworkRow = { mode: string; value: string; when?: string; whenRich?: string } +type OptionRow = { option: string; effect?: string; effectRich?: string } +type StringItem = string +type RelatedItem = { href: string; label: string; tail?: string; tailRich?: string } + +export default async function LxcNfsServerPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations({ locale, namespace: "docs.storageShare.lxcNfsServer" }) + + const messages = (await getMessages({ locale })) as unknown as { + docs: { storageShare: { lxcNfsServer: { + network: { rows: NetworkRow[] } + options: { rows: OptionRow[] } + troubleshoot: { aptItems: StringItem[]; ownItems: StringItem[] } + related: { items: RelatedItem[] } + } } } + } + const networkRows = messages.docs.storageShare.lxcNfsServer.network.rows + const optionRows = messages.docs.storageShare.lxcNfsServer.options.rows + const aptItems = messages.docs.storageShare.lxcNfsServer.troubleshoot.aptItems + const ownItems = messages.docs.storageShare.lxcNfsServer.troubleshoot.ownItems + const relatedItems = messages.docs.storageShare.lxcNfsServer.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={12} + scriptPath="share/nfs_lxc_server.sh" + /> + + <Callout variant="warning" title={t("privReq.title")}> + {t.rich("privReq.body", { code, strong })} + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("what.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("what.body", { em, strong })} + </p> + + <DataFlowDiagram + nodes={[ + { label: t("what.diagramServerLabel"), detail: t("what.diagramServerDetail"), variant: "source" }, + { label: t("what.diagramClientLabel"), detail: t("what.diagramClientDetail"), variant: "target" }, + ]} + arrowLabel={t("what.diagramArrow")} + bidirectional + command={`# /etc/exports inside the CT: +/mnt/data <network>(rw,sync,no_subtree_check,no_root_squash)`} + /> + + <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.rich("shared.body", { code, strong })} + </p> + + <Callout variant="info" title={t("shared.gidTitle")}> + {t.rich("shared.gidBody", { strong, code })} + </Callout> + + <Callout variant="warning" title={t("shared.remapTitle")}> + {t.rich("shared.remapBody", { code, strong })} + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("defaults.heading")}</h2> + <Callout variant="danger" title={t("defaults.warnTitle")}> + {t.rich("defaults.warnBody", { 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/lxc-nfs-server-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> + + <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, folder, network, opts │ +│ (nothing touched yet) │ +└──────────────────┬──────────────────────────┘ + ▼ + Privileged-CT gate (share-common.func) + ├─ pct list — pick CT + ├─ Auto-start if stopped + └─ Aborts if "unprivileged: 1" in CT config + │ + ▼ + Folder selection (2 modes) + ├─ Auto: choose from existing folders + │ inside /mnt of the CT + └─ Manual: enter any absolute path + (must already exist inside the CT) + │ + ▼ + Network ACL (3 modes) + ├─ 1. Local network (192.168.0.0/16) + ├─ 2. Custom subnet (e.g. 192.168.10.0/24) + └─ 3. Single host IP + │ + ▼ + Export options (3 modes) + ├─ 1. Read-write — rw,sync,no_subtree_check, + │ no_root_squash (DEFAULT) + ├─ 2. Read-only — ro,sync,no_subtree_check, + │ no_root_squash + └─ 3. Custom — type your own option string + │ + ┌──────── Cancel OR Confirm ────┐ + ▼ ▼ +Exit, nothing ┌─────────────────┴─────────────────┐ +was changed │ PHASE 2 — Install + configure │ + └─────────────────┬─────────────────┘ + ▼ + Install NFS server (in CT) + └─ pct exec apt-get install -y \\ + nfs-kernel-server + nfs-common rpcbind + + systemctl enable --now both + (skipped if already installed) + ▼ + setup_universal_sharedfiles_group + └─ groupadd -g 101000 sharedfiles + (or groupmod if exists at wrong GID) + For each regular user (UID >= 1000): + ├─ usermod -a -G sharedfiles <user> + └─ useradd -u <uid+100000> \\ + -g sharedfiles \\ + remap_<uid> + Same for common UIDs (33, 1000-1002) + ▼ + Apply ownership + SGID on the folder + └─ chown root:sharedfiles <folder> + chmod 2775 <folder> + (sticky group: new files inherit + the sharedfiles group) + ▼ + Update /etc/exports + └─ If existing entry for the folder: + ask "update?", remove + replace. + Else: + append the new line. + ▼ + systemctl restart rpcbind \\ + nfs-kernel-server + exportfs -ra + ▼ + Print connection details: + • Server IP (CT hostname -I) + • Export path + • Mount options chosen + • Network ACL + • Mount examples (auto / v4 / v3)`} + </pre> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("network.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("network.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("network.headerMode")}</th> + <th className="px-4 py-2 font-semibold">{t("network.headerValue")}</th> + <th className="px-4 py-2 font-semibold">{t("network.headerWhen")}</th> + </tr> + </thead> + <tbody className="divide-y divide-gray-200 text-gray-800"> + {networkRows.map((row, idx) => ( + <tr key={row.mode}> + <td className="px-4 py-2 font-semibold">{row.mode}</td> + <td className="px-4 py-2 font-mono">{row.value}</td> + <td className="px-4 py-2"> + {row.whenRich ? t.rich(`network.rows.${idx}.whenRich`, { code }) : row.when} + </td> + </tr> + ))} + </tbody> + </table> + </div> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("options.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("options.headerOption")}</th> + <th className="px-4 py-2 font-semibold">{t("options.headerEffect")}</th> + </tr> + </thead> + <tbody className="divide-y divide-gray-200 text-gray-800"> + {optionRows.map((row, idx) => ( + <tr key={row.option}> + <td className="px-4 py-2 font-mono">{row.option}</td> + <td className="px-4 py-2"> + {row.effectRich ? t.rich(`options.rows.${idx}.effectRich`, { code, strong }) : row.effect} + </td> + </tr> + ))} + </tbody> + </table> + </div> + + <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", { strong, code })} + </p> + <CopyableCode code={`# 1. install the NFS server (one-time) +apt-get update +apt-get install -y nfs-kernel-server nfs-common rpcbind +systemctl enable --now rpcbind nfs-kernel-server + +# 2. create the sharedfiles group convention +groupadd -g 101000 sharedfiles +# add each regular user to it +for u in $(awk -F: '$3 >= 1000 && $3 < 65534 {print $1}' /etc/passwd); do + usermod -a -G sharedfiles "$u" +done + +# 3. set ownership + SGID on the folder +mkdir -p /mnt/data +chown root:sharedfiles /mnt/data +chmod 2775 /mnt/data # SGID: new files inherit group + +# 4. add the export line +echo "/mnt/data 192.168.0.0/16(rw,sync,no_subtree_check,no_root_squash)" \\ + >> /etc/exports + +# 5. apply +systemctl restart rpcbind nfs-kernel-server +exportfs -ra + +# verify +exportfs -v +showmount -e localhost`} /> + + <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("delete.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("delete.body", { code })}</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.body", { code })}</p> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("uninstall.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("uninstall.body", { code, strong })} + </p> + + <Callout variant="warning" title={t("uninstall.warnTitle")}> + {t.rich("uninstall.warnBody", { em, 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.privTitle")}> + {t.rich("troubleshoot.privBody", { code })} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.aptTitle")}> + {t("troubleshoot.aptIntro")} + <ul className="mt-2 list-disc list-inside space-y-1"> + {aptItems.map((_, idx) => ( + <li key={idx}>{t.rich(`troubleshoot.aptItems.${idx}`, { code })}</li> + ))} + </ul> + {t("troubleshoot.aptOutro")} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.aclTitle")}> + {t.rich("troubleshoot.aclBody", { code })} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.ownTitle")}> + {t("troubleshoot.ownIntro")} + <ul className="mt-2 list-disc list-inside space-y-1"> + {ownItems.map((_, idx) => ( + <li key={idx}>{t.rich(`troubleshoot.ownItems.${idx}`, { code })}</li> + ))} + </ul> + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.noShowTitle")}> + {t.rich("troubleshoot.noShowBody", { 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`, { em }) : item.tail} + </li> + ))} + </ul> + </div> + ) +} diff --git a/web/app/[locale]/docs/storage-share/lxc-samba-client/page.tsx b/web/app/[locale]/docs/storage-share/lxc-samba-client/page.tsx new file mode 100644 index 00000000..c2d4c7db --- /dev/null +++ b/web/app/[locale]/docs/storage-share/lxc-samba-client/page.tsx @@ -0,0 +1,379 @@ +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.lxcSambaClient.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-samba-client", + }, + } +} + +type OptionRow = { option: string; effect: string } +type StringItem = string +type RelatedItem = { href: string; label: string; tail?: string; tailRich?: string } + +export default async function LxcSambaClientPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations({ locale, namespace: "docs.storageShare.lxcSambaClient" }) + + const messages = (await getMessages({ locale })) as unknown as { + docs: { storageShare: { lxcSambaClient: { + options: { rows: OptionRow[] } + troubleshoot: { aptItems: StringItem[] } + related: { items: RelatedItem[] } + } } } + } + const optionRows = messages.docs.storageShare.lxcSambaClient.options.rows + const aptItems = messages.docs.storageShare.lxcSambaClient.troubleshoot.aptItems + const relatedItems = messages.docs.storageShare.lxcSambaClient.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 hostSambaLink = (chunks: React.ReactNode) => ( + <Link href="/docs/storage-share/host-samba" className="text-blue-700 hover:underline">{chunks}</Link> + ) + 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={10} + scriptPath="share/samba_client.sh" + /> + + <Callout variant="warning" title={t("privReq.title")}> + {t.rich("privReq.body", { code, strong, hostSambaLink, mountLink })} + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("what.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t("what.body")}</p> + + <DataFlowDiagram + nodes={[ + { label: t("what.diagramServerLabel"), detail: t("what.diagramServerDetail"), variant: "source" }, + { label: t("what.diagramHostLabel"), detail: t("what.diagramHostDetail"), variant: "bridge" }, + { label: t("what.diagramCtLabel"), detail: t("what.diagramCtDetail"), variant: "target" }, + ]} + arrowLabel={t("what.diagramArrow")} + command={`# Credentials stored in the CT (root:0600): +# /etc/samba/credentials/<server>_<share>.cred + +# What the script writes inside the CT: +pct exec <ctid> -- mount -t cifs //<server>/<share> /mnt/share \\ + -o "rw,file_mode=0664,dir_mode=0775,iocharset=utf8, + credentials=/etc/samba/credentials/<srv>_<sh>.cred"`} + /> + + <Callout variant="info" title={t("what.twoWaysTitle")}> + <ul className="mt-2 list-disc list-inside space-y-1"> + <li>{t.rich("what.twoWaysBind", { strong, mountLink })}</li> + <li>{t.rich("what.twoWaysDirect", { strong })}</li> + </ul> + </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-samba-client-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> + + <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, server, auth, share │ +│ (nothing touched yet) │ +└──────────────────┬──────────────────────────┘ + ▼ + Privileged-CT gate (share-common.func) + ├─ pct list — pick CT + ├─ Auto-start if stopped + └─ Reads /etc/pve/lxc/<ctid>.conf + └─ unprivileged: 1 → abort with help message + │ + ▼ + Install Samba client packages (in CT) + └─ pct exec apt-get install -y \\ + cifs-utils smbclient + (skipped if already installed) + Verifies: smbclient + mount.cifs both present + Creates /etc/samba/credentials (mode 0700) + │ + ▼ + Server selection (3 modes) + ├─ Auto-discover (nmap from HOST on /24, + │ ports 139/445, then nmblookup -A + │ for NetBIOS names → "NETBIOS (ip)") + ├─ Manual: type IP or hostname + └─ Recent: parses /etc/fstab for previously + used CIFS servers (one-click selection) + │ + ▼ + Authentication (2 modes) + ├─ User + password + │ ├─ Username (whiptail inputbox) + │ ├─ Password (passwordbox, hidden) + │ ├─ Confirm password + │ └─ ACTIVE VALIDATION against the server: + │ creates a temp credentials file, + │ runs smbclient -L with -A, + │ distinguishes "guest fallback" from + │ real auth success, retries on failure + └─ Guest: validate guest access first + (smbclient -L -N must succeed) + │ + ▼ + Share selection + ├─ Server returns shares → menu + │ (filters out IPC$, ADMIN$, print$; + │ for guest: only shares the user + │ confirmed accessible during validation) + └─ No shares / blocked → manual input + │ + ▼ + Validate the chosen share still exists + │ + ▼ + Mount-point picker (3 options) + ├─ 1. Create new folder in /mnt + │ (default: same name as the share) + ├─ 2. Select existing folder in /mnt + └─ 3. Enter custom path + │ + ▼ + Mount-options preset (3 options) + ├─ 1. Read/write + │ rw,file_mode=0664,dir_mode=0775, + │ iocharset=utf8 + ├─ 2. Read-only + │ ro,file_mode=0444,dir_mode=0555, + │ iocharset=utf8 + └─ 3. Custom — type your own option string + │ + ▼ + Permanent mount? (yes/no) + └─ yes → write entry to /etc/fstab + │ + ┌──────── Cancel OR Confirm ────┐ + ▼ ▼ +Exit, nothing ┌─────────────────┴─────────────────┐ +was changed │ PHASE 2 — Mount and persist │ + └─────────────────┬─────────────────┘ + ▼ + Create mount point if missing + (pct exec mkdir -p <path>) + ▼ + If something is already mounted there, + offer to unmount first + ▼ + For user auth: write credentials file + /etc/samba/credentials/<srv>_<sh>.cred + (root:0600 inside the CT) + ▼ + pct exec mount -t cifs \\ + //<server>/<share> <mp> \\ + -o <opts>,credentials=<file> + (or -o <opts>,guest for guest) + ▼ + Smoke test: write a 0-byte file + and delete it (.test_write) + └─ no write access → "read-only" + ▼ + If "permanent" was chosen: + └─ Append to /etc/fstab inside CT: + //<srv>/<sh> <mp> cifs \\ + <opts>,credentials=…, + _netdev, + x-systemd.automount,noauto 0 0 + (any prior entry for this mp is removed first) + ▼ + Print summary (server / share / mp / + auth mode / permanent yes-no)`} + </pre> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("creds.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("creds.body", { strong, 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"> +{`Path: /etc/samba/credentials/<server>_<share>.cred +Owner: root +Mode: 0600 + +Content: + username=<your-username> + password=<your-password> + +Reference in /etc/fstab: + //<server>/<share> /mnt/<path> cifs rw,..., + credentials=/etc/samba/credentials/<server>_<share>.cred, + _netdev,x-systemd.automount,noauto 0 0`} + </pre> + + <Callout variant="info" title={t("creds.whyTitle")}> + {t.rich("creds.whyBody", { code })} + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("options.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("options.headerOption")}</th> + <th className="px-4 py-2 font-semibold">{t("options.headerEffect")}</th> + </tr> + </thead> + <tbody className="divide-y divide-gray-200 text-gray-800"> + {optionRows.map((row) => ( + <tr key={row.option}> + <td className="px-4 py-2 font-mono">{row.option}</td> + <td className="px-4 py-2">{row.effect}</td> + </tr> + ))} + </tbody> + </table> + </div> + + <Callout variant="info" title={t("options.netEffectTitle")}> + {t.rich("options.netEffectBody", { 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.rich("manual.body", { strong, code })} + </p> + <CopyableCode code={`# 1. install the Samba client (one-time) +apt-get update +apt-get install -y cifs-utils smbclient + +# 2. test reachability +ping -c 1 -W 3 10.0.0.50 +nc -z -w 3 10.0.0.50 445 +smbclient -L 10.0.0.50 -U user + +# 3. write the credentials file (root-only) +mkdir -p /etc/samba/credentials +chmod 700 /etc/samba/credentials +cat > /etc/samba/credentials/10.0.0.50_share.cred <<EOF +username=user +password=s3cret +EOF +chmod 600 /etc/samba/credentials/10.0.0.50_share.cred + +# 4. mount it (one-shot) +mkdir -p /mnt/share +mount -t cifs //10.0.0.50/share /mnt/share \\ + -o "rw,file_mode=0664,dir_mode=0775,iocharset=utf8,credentials=/etc/samba/credentials/10.0.0.50_share.cred" + +# 5. make it permanent (safe boot defaults) +cat >> /etc/fstab <<EOF +//10.0.0.50/share /mnt/share cifs rw,file_mode=0664,dir_mode=0775,iocharset=utf8,credentials=/etc/samba/credentials/10.0.0.50_share.cred,_netdev,x-systemd.automount,noauto 0 0 +EOF +systemctl daemon-reload`} /> + + <p className="mb-3 mt-6 text-gray-800 leading-relaxed">{t("manual.guestIntro")}</p> + <CopyableCode code={`mount -t cifs //10.0.0.50/public /mnt/public \\ + -o "rw,file_mode=0664,dir_mode=0775,iocharset=utf8,guest" + +# fstab equivalent +echo "//10.0.0.50/public /mnt/public cifs rw,file_mode=0664,dir_mode=0775,iocharset=utf8,guest,_netdev,x-systemd.automount,noauto 0 0" \\ + >> /etc/fstab`} /> + + <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("unmount.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("unmount.body", { strong, code })} + </p> + + <Callout variant="warning" title={t("unmount.warnTitle")}> + {t.rich("unmount.warnBody", { code, em })} + </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.privTitle")}> + {t.rich("troubleshoot.privBody", { code })} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.aptTitle")}> + {t.rich("troubleshoot.aptIntro", { code })} + <ul className="mt-2 list-disc list-inside space-y-1"> + {aptItems.map((_, idx) => ( + <li key={idx}>{t.rich(`troubleshoot.aptItems.${idx}`, { code })}</li> + ))} + </ul> + {t("troubleshoot.aptOutro")} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.guestFallbackTitle")}> + {t("troubleshoot.guestFallbackBody")} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.denyTitle")}> + {t.rich("troubleshoot.denyBody", { code })} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.utf8Title")}> + {t.rich("troubleshoot.utf8Body", { code })} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.bootTitle")}> + {t.rich("troubleshoot.bootBody", { 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`, { em }) : item.tail} + </li> + ))} + </ul> + </div> + ) +} diff --git a/web/app/[locale]/docs/storage-share/lxc-samba-server/page.tsx b/web/app/[locale]/docs/storage-share/lxc-samba-server/page.tsx new file mode 100644 index 00000000..cdf510e1 --- /dev/null +++ b/web/app/[locale]/docs/storage-share/lxc-samba-server/page.tsx @@ -0,0 +1,395 @@ +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.lxcSambaServer.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-samba-server", + }, + } +} + +type StringItem = string +type RelatedItem = { href: string; label: string; tail?: string; tailRich?: string } + +export default async function LxcSambaServerPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations({ locale, namespace: "docs.storageShare.lxcSambaServer" }) + + const messages = (await getMessages({ locale })) as unknown as { + docs: { storageShare: { lxcSambaServer: { + troubleshoot: { aptItems: StringItem[] } + related: { items: RelatedItem[] } + } } } + } + const aptItems = messages.docs.storageShare.lxcSambaServer.troubleshoot.aptItems + const relatedItems = messages.docs.storageShare.lxcSambaServer.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 nfsLink = (chunks: React.ReactNode) => ( + <Link href="/docs/storage-share/lxc-nfs-server" className="text-blue-700 hover:underline">{chunks}</Link> + ) + const clientLink = (chunks: React.ReactNode) => ( + <Link href="/docs/storage-share/lxc-samba-client" className="text-blue-600 hover:underline">{chunks}</Link> + ) + + return ( + <div> + <DocHeader + title={t("header.title")} + description={t("header.description")} + section={t("header.section")} + estimatedMinutes={12} + scriptPath="share/samba_lxc_server.sh" + /> + + <Callout variant="warning" title={t("privReq.title")}> + {t.rich("privReq.body", { code, strong })} + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("what.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("what.body", { code })} + </p> + + <DataFlowDiagram + nodes={[ + { label: t("what.diagramServerLabel"), detail: t("what.diagramServerDetail"), variant: "source" }, + { label: t("what.diagramClientLabel"), detail: t("what.diagramClientDetail"), variant: "target" }, + ]} + arrowLabel={t("what.diagramArrow")} + bidirectional + command={`# /etc/samba/smb.conf — block written by ProxMenux: +[<share-name>] + path = /mnt/data + valid users = <username> + force group = sharedfiles + read only = no + create mask = 0664 + directory mask = 2775`} + /> + + <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.body", { code, strong })} + </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.bindType")}</div> + <div className="text-xs text-gray-600 mt-1">{t.rich("perms.bindTypeSubRich", { code })}</div> + </td> + <td className="px-4 py-3">{t.rich("perms.bindActionRich", { code })}</td> + </tr> + <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">{t("perms.localTypeSub")}</div> + </td> + <td className="px-4 py-3">{t.rich("perms.localActionRich", { code })}</td> + </tr> + </tbody> + </table> + </div> + + <Callout variant="warning" title={t("perms.gidTitle")}> + {t.rich("perms.gidBody", { strong, code, nfsLink })} + </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-samba-server-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> + + <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, folder, user, options │ +│ (nothing touched yet) │ +└──────────────────┬──────────────────────────┘ + ▼ + Privileged-CT gate (share-common.func) + ├─ pct list — pick CT + ├─ Auto-start if stopped + └─ Aborts if "unprivileged: 1" in CT config + │ + ▼ + Folder selection (2 modes) + ├─ Auto: choose from /mnt/* in the CT + └─ Manual: enter any absolute path + (offers to mkdir -p if missing) + │ + ▼ + Samba install check + ├─ Already installed? + │ └─ Detect existing user via pdbedit -L + └─ First time? + ├─ apt-get install samba samba-common-bin acl + ├─ Ask username (default: "proxmenux") + ├─ Ask password (twice — must match) + ├─ adduser <username> (no password) + └─ smbpasswd -a <username> + │ + ▼ + Permission setup (2 paths) + ├─ Bind-mount detected + │ groupadd -g 999 sharedfiles + │ usermod -aG sharedfiles <user> + │ chown root:sharedfiles + chmod 2775 + │ setfacl fallback if write fails + └─ Local folder + chown -R <user>:<user> + chmod -R 755 + setfacl fallback if needed + │ + ▼ + Share permissions (3 modes) + ├─ rw — read-write block (default) + ├─ ro — read-only block + └─ custom — your own directives + │ + ┌──────── Cancel OR Confirm ────┐ + ▼ ▼ +Exit, nothing ┌─────────────────┴─────────────────┐ +was changed │ PHASE 2 — Write smb.conf + apply │ + └─────────────────┬─────────────────┘ + ▼ + If [share-name] already in smb.conf: + └─ ask "update?", remove + replace + (sed deletes from [name] to next blank) + Else: + └─ append the new block + ▼ + systemctl restart smbd.service + ▼ + Print connection details: + • Server IP (hostname -I) + • Share name + path + • Username + • Sample mount commands`} + </pre> + + <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.rich("modes.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("modes.headerMode")}</th> + <th className="px-4 py-2 font-semibold">{t.rich("modes.headerBlock", { code })}</th> + </tr> + </thead> + <tbody className="divide-y divide-gray-200 text-gray-800 align-top"> + <tr> + <td className="px-4 py-3 font-semibold whitespace-nowrap">{t("modes.rwMode")}</td> + <td className="px-4 py-3"> + <pre className="text-xs font-mono">{`read only = no +writable = yes +browseable = yes +guest ok = no +create mask = 0664 +directory mask = 2775 +force create mode = 0664 +force directory mode = 2775`}</pre> + </td> + </tr> + <tr> + <td className="px-4 py-3 font-semibold whitespace-nowrap">{t("modes.roMode")}</td> + <td className="px-4 py-3"> + <pre className="text-xs font-mono">{`read only = yes +writable = no +browseable = yes +guest ok = no`}</pre> + </td> + </tr> + <tr> + <td className="px-4 py-3 font-semibold">{t("modes.customMode")}</td> + <td className="px-4 py-3"> + {t.rich("modes.customBodyRich", { code })} + </td> + </tr> + </tbody> + </table> + </div> + + <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", { strong, code })} + </p> + <CopyableCode code={`# 1. install Samba (one-time) +apt-get update +apt-get install -y samba samba-common-bin acl + +# 2. create a Samba user (system + smbpasswd) +adduser --disabled-password --gecos "" proxmenux +echo -e "P4ssw0rd\\nP4ssw0rd" | smbpasswd -a proxmenux + +# 3. for a bind-mounted folder: shared group + SGID +mkdir -p /mnt/data +groupadd -g 999 sharedfiles 2>/dev/null || true +usermod -aG sharedfiles proxmenux +chown root:sharedfiles /mnt/data +chmod 2775 /mnt/data +# fallback if user can't write: +# setfacl -R -m u:proxmenux:rwx /mnt/data + +# 4. write the share block +cat >> /etc/samba/smb.conf <<'EOF' + +[data] + path = /mnt/data + valid users = proxmenux + force group = sharedfiles + read only = no + writable = yes + browseable = yes + guest ok = no + create mask = 0664 + directory mask = 2775 + force create mode = 0664 + force directory mode = 2775 + veto files = /lost+found/ +EOF + +# 5. apply +systemctl restart smbd +testparm -s | grep -A6 '^\\[data\\]'`} /> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("connect.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("connect.headerOs")}</th> + <th className="px-4 py-2 font-semibold">{t("connect.headerHow")}</th> + </tr> + </thead> + <tbody className="divide-y divide-gray-200 text-gray-800 align-top"> + <tr> + <td className="px-4 py-3 font-semibold">{t("connect.windowsOs")}</td> + <td className="px-4 py-3">{t.rich("connect.windowsHowRich", { code, em })}</td> + </tr> + <tr> + <td className="px-4 py-3 font-semibold">{t("connect.macosOs")}</td> + <td className="px-4 py-3">{t.rich("connect.macosHowRich", { code, em })}</td> + </tr> + <tr> + <td className="px-4 py-3 font-semibold">{t("connect.linuxOs")}</td> + <td className="px-4 py-3">{t.rich("connect.linuxHowRich", { code, clientLink })}</td> + </tr> + </tbody> + </table> + </div> + + <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("delete.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("delete.body", { code })}</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.body", { code })}</p> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("uninstall.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("uninstall.body", { code, strong })} + </p> + + <Callout variant="warning" title={t("uninstall.warnTitle")}> + {t.rich("uninstall.warnBody", { em, 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.privTitle")}> + {t.rich("troubleshoot.privBody", { code })} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.aptTitle")}> + {t("troubleshoot.aptIntro")} + <ul className="mt-2 list-disc list-inside space-y-1"> + {aptItems.map((_, idx) => ( + <li key={idx}>{t.rich(`troubleshoot.aptItems.${idx}`, { code })}</li> + ))} + </ul> + {t("troubleshoot.aptOutro")} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.noShareTitle")}> + {t.rich("troubleshoot.noShareBody", { code })} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.authTitle")}> + {t.rich("troubleshoot.authBody", { em, code })} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.groupTitle")}> + {t.rich("troubleshoot.groupBody", { code })} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.bothTitle")}> + {t.rich("troubleshoot.bothBody", { code })} + <pre className="mt-2 p-2 rounded bg-white/50 text-xs overflow-x-auto"><code>groupmod -g 101000 sharedfiles +chgrp -R sharedfiles /mnt/<your-share></code></pre> + </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`, { em }) : item.tail} + </li> + ))} + </ul> + </div> + ) +} diff --git a/web/app/[locale]/docs/storage-share/page.tsx b/web/app/[locale]/docs/storage-share/page.tsx new file mode 100644 index 00000000..f82443bd --- /dev/null +++ b/web/app/[locale]/docs/storage-share/page.tsx @@ -0,0 +1,269 @@ +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, + Server, + Network, + FolderOpen, + Database, + Share2, + Download, + Upload, + Link2, +} 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.storageShare.meta" }) + return { + title: t("title"), + description: t("description"), + keywords: [ + "proxmox nfs", + "proxmox samba", + "proxmox cifs", + "proxmox iscsi", + "proxmox lxc mount points", + "proxmox bind mount", + "proxmox shared storage", + "proxmox storage share", + "proxmox nfs server lxc", + "proxmox samba server lxc", + ], + alternates: { canonical: "https://proxmenux.com/docs/storage-share" }, + openGraph: { + title: t("ogTitle"), + description: t("ogDescription"), + type: "article", + url: "https://proxmenux.com/docs/storage-share", + }, + twitter: { + card: "summary", + title: t("twitterTitle"), + description: t("twitterDescription"), + }, + } +} + +type OptionData = { href: string; icon: string; title: string; description: string } +type StringItem = string + +const ICONS: Record<string, React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>> = { + Network, + Share2, + Database, + HardDrive, + FolderOpen, + Download, + Upload, + Link2, +} + +function OptionCard({ option }: { option: OptionData }) { + const Icon = ICONS[option.icon] || Network + 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 StorageShareOverviewPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations({ locale, namespace: "docs.storageShare" }) + + const messages = (await getMessages({ locale })) as unknown as { + docs: { storageShare: { + groups: { hostItems: StringItem[]; lxcMountItems: StringItem[]; lxcNetItems: StringItem[] } + host: { options: OptionData[] } + lxcNet: { options: OptionData[] } + } } + } + const hostItems = messages.docs.storageShare.groups.hostItems + const lxcMountItems = messages.docs.storageShare.groups.lxcMountItems + const lxcNetItems = messages.docs.storageShare.groups.lxcNetItems + const hostOptions = messages.docs.storageShare.host.options + const lxcNetOptions = messages.docs.storageShare.lxcNet.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> + 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={5} + scriptPath="menus/share_menu.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("opening.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("opening.body", { strong })} + </p> + + <Image + src="/share/storage-share-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, em })} + </p> + + <div className="grid gap-4 md:grid-cols-1 lg:grid-cols-3 mb-8 not-prose"> + <a + href="#host" + 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.hostTitle")}</h3> + </div> + <p className="text-sm text-gray-800 mb-3">{t.rich("groups.hostBody", { code })}</p> + <ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400"> + {hostItems.map((_, idx) => ( + <li key={idx}>{t(`groups.hostItems.${idx}`)}</li> + ))} + </ul> + </a> + + <a + href="#lxc-mount" + 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"> + <Link2 className="h-5 w-5" aria-hidden /> + </span> + <h3 className="text-lg font-semibold text-gray-900 m-0">{t("groups.lxcMountTitle")}</h3> + </div> + <p className="text-sm text-gray-800 mb-3">{t.rich("groups.lxcMountBody", { code })}</p> + <ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400"> + {lxcMountItems.map((_, idx) => ( + <li key={idx}>{t.rich(`groups.lxcMountItems.${idx}`, { code })}</li> + ))} + </ul> + </a> + + <a + href="#lxc-net" + 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"> + <Network className="h-5 w-5" aria-hidden /> + </span> + <h3 className="text-lg font-semibold text-gray-900 m-0">{t("groups.lxcNetTitle")}</h3> + </div> + <p className="text-sm text-gray-800 mb-3">{t.rich("groups.lxcNetBody", { strong })}</p> + <ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400"> + {lxcNetItems.map((_, idx) => ( + <li key={idx}>{t.rich(`groups.lxcNetItems.${idx}`, { strong })}</li> + ))} + </ul> + </a> + </div> + + <h2 id="host" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24">{t("host.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("host.intro", { code, strong })} + </p> + <div className="grid gap-3 md:grid-cols-2 mb-8 not-prose"> + {hostOptions.map((o) => ( + <OptionCard key={o.href} option={o} /> + ))} + </div> + + <h2 id="lxc-mount" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24">{t("lxcMount.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("lxcMount.intro", { code })} + </p> + <div className="grid gap-3 md:grid-cols-2 mb-8 not-prose"> + <OptionCard + option={{ + title: t("lxcMount.card.title"), + description: t("lxcMount.card.description"), + icon: "Link2", + href: "/docs/storage-share/lxc-mount-points", + }} + /> + </div> + + <h2 id="lxc-net" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24">{t("lxcNet.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("lxcNet.intro", { em, mountLink })} + </p> + <div className="grid gap-3 md:grid-cols-2 mb-8 not-prose"> + {lxcNetOptions.map((o) => ( + <OptionCard key={o.href} option={o} /> + ))} + </div> + + <Callout variant="warning" title={t("privReq.title")}> + {t.rich("privReq.body", { strong, code, mountLink })} + </Callout> + + <Callout variant="info" title={t("unprivExplain.title")}> + {t.rich("unprivExplain.body", { strong, em, 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> + <ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1"> + <li> + <a + href="https://github.com/MacRimi/ProxMenux/blob/main/scripts/global/share-common.func" + target="_blank" + rel="noopener noreferrer" + className="text-blue-600 hover:underline font-mono" + > + global/share-common.func + </a> + {t("scripts.itemTail")} + </li> + </ul> + </div> + ) +} diff --git a/web/app/[locale]/docs/utils/UUp-Dump-ISO-Creator/page.tsx b/web/app/[locale]/docs/utils/UUp-Dump-ISO-Creator/page.tsx new file mode 100644 index 00000000..408276b0 --- /dev/null +++ b/web/app/[locale]/docs/utils/UUp-Dump-ISO-Creator/page.tsx @@ -0,0 +1,249 @@ +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.utils.uupDumpIsoCreator.meta" }) + return { + title: t("title"), + description: t("description"), + openGraph: { + title: t("ogTitle"), + description: t("ogDescription"), + type: "article", + url: "https://macrimi.github.io/ProxMenux/docs/utils/UUp-Dump-ISO-Creator", + images: [ + { + url: "/utils/uup-dump-iso-creator.png", + width: 1200, + height: 630, + alt: t("ogImageAlt"), + }, + ], + }, + } +} + +type DepRow = { pkg: string; roleRich: string } +type FlowStep = string +type Flag = string +type StepItem = { + title: string + img: string + caption: string + body?: string + bodyRich?: string +} + +export default async function UUPDumpISOCreatorPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations({ locale, namespace: "docs.utils.uupDumpIsoCreator" }) + + const messages = (await getMessages({ locale })) as unknown as { + docs: { + utils: { + uupDumpIsoCreator: { + what: { items: string[] } + dependencies: { rows: DepRow[] } + flow: { steps: FlowStep[] } + aria2: { flags: Flag[] } + step1: { items: StepItem[] } + step2: { items: StepItem[] } + } + } + } + } + const block = messages.docs.utils.uupDumpIsoCreator + const whatItems = block.what.items + const depRows = block.dependencies.rows + const flowSteps = block.flow.steps + const aria2Flags = block.aria2.flags + const step1Items = block.step1.items + const step2Items = block.step2.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 extlinkUupdump = (chunks: React.ReactNode) => ( + <a + href="https://uupdump.net/" + 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 step1Badge = t("step1.stepBadge") + const step2Badge = t("step2.stepBadge") + + return ( + <div> + <DocHeader + title={t("header.title")} + description={t("header.description")} + section={t("header.section")} + estimatedMinutes={15} + scriptPath="vm/uupdump_creator.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="/utils/uup-dump-iso-creator.png" + alt={t("hero.imageAlt")} + 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("hero.caption")}</span> + </div> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("what.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t("what.intro")}</p> + <ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1"> + {whatItems.map((_, idx) => ( + <li key={idx}>{t(`what.items.${idx}`)}</li> + ))} + </ul> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("what.learnMore", { extlink: extlinkUupdump })} + </p> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("automates.heading")}</h2> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("dependencies.heading")}</h3> + <p className="mb-3 text-gray-800 leading-relaxed">{t.rich("dependencies.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("dependencies.headerPackage")}</th> + <th className="px-4 py-2 font-semibold">{t("dependencies.headerRole")}</th> + </tr> + </thead> + <tbody className="divide-y divide-gray-200 text-gray-800"> + {depRows.map((row, idx) => ( + <tr key={row.pkg}> + <td className="px-4 py-2"><code>{row.pkg}</code></td> + <td className="px-4 py-2">{t.rich(`dependencies.rows.${idx}.roleRich`, { code })}</td> + </tr> + ))} + </tbody> + </table> + </div> + <p className="mb-3 text-sm text-gray-700 leading-relaxed">{t("dependencies.manualIntro")}</p> + <pre className="bg-gray-100 p-3 rounded-md overflow-x-auto text-sm font-mono mb-4"> + <code>{t("dependencies.manualCode")}</code> + </pre> + + <h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("flow.heading")}</h3> + <ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1"> + {flowSteps.map((_, idx) => ( + <li key={idx}>{t.rich(`flow.steps.${idx}`, { code })}</li> + ))} + </ol> + + <h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("aria2.heading")}</h3> + <pre className="bg-gray-100 p-3 rounded-md overflow-x-auto text-sm font-mono mb-3"> + <code>{t("aria2.code")}</code> + </pre> + <ul className="list-disc pl-6 mb-4 text-sm text-gray-700 leading-relaxed space-y-1"> + {aria2Flags.map((_, idx) => ( + <li key={idx}>{t.rich(`aria2.flags.${idx}`, { code })}</li> + ))} + </ul> + <p className="text-sm text-gray-600 mb-4">{t("aria2.runtime")}</p> + + <h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("step1.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("step1.intro", { code, extlink: extlinkUupdump })} + </p> + + {step1Items.map((item, idx) => ( + <section key={`s1-${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"> + {step1Badge} {idx + 1} + </span> + <h3 className="text-lg font-semibold text-gray-900 m-0">{item.title}</h3> + </div> + <p className="mb-4 text-gray-800 leading-relaxed"> + {item.bodyRich ? t.rich(`step1.items.${idx}.bodyRich`, { code, strong, em }) : item.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={item.img} + alt={item.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">{item.caption}</span> + </div> + </section> + ))} + + <h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("step2.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t("step2.intro")}</p> + + {step2Items.map((item, idx) => ( + <section key={`s2-${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"> + {step2Badge} {idx + 1} + </span> + <h3 className="text-lg font-semibold text-gray-900 m-0">{item.title}</h3> + </div> + <p className="mb-4 text-gray-800 leading-relaxed"> + {item.bodyRich ? t.rich(`step2.items.${idx}.bodyRich`, { code, strong, em }) : item.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={item.img} + alt={item.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">{item.caption}</span> + </div> + </section> + ))} + + <Callout variant="info" title={t("tempFiles.title")}> + {t.rich("tempFiles.body", { code })} + </Callout> + </div> + ) +} diff --git a/web/app/[locale]/docs/utils/export-vm/page.tsx b/web/app/[locale]/docs/utils/export-vm/page.tsx new file mode 100644 index 00000000..0918d69c --- /dev/null +++ b/web/app/[locale]/docs/utils/export-vm/page.tsx @@ -0,0 +1,223 @@ +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.utils.exportVm.meta" }) + return { + title: t("title"), + description: t("description"), + openGraph: { + title: t("ogTitle"), + description: t("ogDescription"), + type: "article", + url: "https://macrimi.github.io/ProxMenux/docs/utils/export-vm", + }, + } +} + +type StringItem = string +type FormatRow = { format: string; output: string; pros: string; cons: string } +type ImportItem = { href?: string; preRich?: string; linkLabel?: string; tailRich?: string } +type RelatedItem = { href: string; label: string; tail?: string; tailRich?: string } + +export default async function ExportVmPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations({ locale, namespace: "docs.utils.exportVm" }) + + const messages = (await getMessages({ locale })) as unknown as { + docs: { utils: { exportVm: { + stopped: { items: StringItem[] } + format: { rows: FormatRow[] } + exported: { items: StringItem[]; notItems: StringItem[] } + import: { items: ImportItem[] } + related: { items: RelatedItem[] } + } } } + } + const block = messages.docs.utils.exportVm + const stoppedItems = block.stopped.items + const formatRows = block.format.rows + const exportedItems = block.exported.items + const notExportedItems = block.exported.notItems + const importItems = block.import.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={6} + scriptPath="utilities/export_vm_ova_ovf.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("picker.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("picker.body", { code })} + </p> + + <Image + src="/utils/export-vm-picker.png" + alt={t("picker.imgAlt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + /> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("stopped.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t("stopped.intro")}</p> + <ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-1"> + {stoppedItems.map((_, idx) => ( + <li key={idx}>{t.rich(`stopped.items.${idx}`, { code })}</li> + ))} + </ol> + <Callout variant="warning" title={t("stopped.warnTitle")}> + {t.rich("stopped.warnBody", { code })} + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("format.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("format.headerFormat")}</th> + <th className="text-left px-3 py-2 border-b border-gray-200">{t("format.headerOutput")}</th> + <th className="text-left px-3 py-2 border-b border-gray-200">{t("format.headerPros")}</th> + <th className="text-left px-3 py-2 border-b border-gray-200">{t("format.headerCons")}</th> + </tr> + </thead> + <tbody className="text-gray-800"> + {formatRows.map((row, idx) => ( + <tr key={row.format} className={idx < formatRows.length - 1 ? "border-b border-gray-100" : ""}> + <td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.format}</strong></td> + <td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.output}</td> + <td className="px-3 py-2 align-top">{row.pros}</td> + <td className="px-3 py-2 align-top">{row.cons}</td> + </tr> + ))} + </tbody> + </table> + </div> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("destination.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("destination.body", { code })} + </p> + <Callout variant="info" title={t("destination.calloutTitle")}> + {t("destination.calloutBody")} + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("package.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("package.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("package.code") as string}</pre> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("exported.heading")}</h2> + <ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1"> + {exportedItems.map((_, idx) => ( + <li key={idx}>{t.rich(`exported.items.${idx}`, { code, strong })}</li> + ))} + </ul> + <Callout variant="warning" title={t("exported.notTitle")}> + <ul className="list-disc pl-6 mb-0 space-y-1"> + {notExportedItems.map((_, idx) => ( + <li key={idx}>{t.rich(`exported.notItems.${idx}`, { code, strong })}</li> + ))} + </ul> + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("conversion.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t("conversion.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("conversion.code") as string}</pre> + <p className="mt-4 mb-6 text-gray-800 leading-relaxed"> + {t.rich("conversion.outro", { code })} + </p> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manifest.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("manifest.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("manifest.code") as string}</pre> + <p className="mt-4 mb-6 text-gray-800 leading-relaxed"> + {t.rich("manifest.outro", { code })} + </p> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("import.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t("import.intro")}</p> + <ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1"> + {importItems.map((item, idx) => ( + <li key={idx}> + {item.href && item.linkLabel ? ( + <> + {t.rich(`import.items.${idx}.preRich`, { strong })} + <Link href={item.href} className="text-blue-600 hover:underline"> + {item.linkLabel} + </Link> + {t.rich(`import.items.${idx}.tailRich`, { strong })} + </> + ) : ( + t.rich(`import.items.${idx}.tailRich`, { strong }) + )} + </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.noSpaceTitle")}> + {t("troubleshoot.noSpaceBody")} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.unsupportedHwTitle")}> + {t.rich("troubleshoot.unsupportedHwBody", { code })} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.nicTitle")}> + {t.rich("troubleshoot.nicBody", { code })} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.runningTitle")}> + {t.rich("troubleshoot.runningBody", { code })} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.slowTitle")}> + {t.rich("troubleshoot.slowBody", { 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> + ) +} diff --git a/web/app/[locale]/docs/utils/import-vm/page.tsx b/web/app/[locale]/docs/utils/import-vm/page.tsx new file mode 100644 index 00000000..8c438db3 --- /dev/null +++ b/web/app/[locale]/docs/utils/import-vm/page.tsx @@ -0,0 +1,245 @@ +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.utils.importVm.meta" }) + return { + title: t("title"), + description: t("description"), + openGraph: { + title: t("ogTitle"), + description: t("ogDescription"), + type: "article", + url: "https://macrimi.github.io/ProxMenux/docs/utils/import-vm", + }, + } +} + +type StringItem = string +type FlowNode = { label: string; detail: string; variant: "source" | "bridge" | "target" } +type OvfRow = { field: string; source: string; default: string } +type PostRow = { setting: string; default: string; recommended?: string; recommendedRich?: string } +type RelatedItem = { href: string; label: string; tail?: string; tailRich?: string } + +export default async function ImportVmPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations({ locale, namespace: "docs.utils.importVm" }) + + const messages = (await getMessages({ locale })) as unknown as { + docs: { utils: { importVm: { + picker: { items: StringItem[] } + flow: { nodes: FlowNode[] } + ovf: { rows: OvfRow[] } + dialog: { items: StringItem[] } + diskLoop: { items: StringItem[] } + postImport: { rows: PostRow[] } + notImported: { items: StringItem[] } + related: { items: RelatedItem[] } + } } } + } + const block = messages.docs.utils.importVm + const pickerItems = block.picker.items + const flowNodes = block.flow.nodes + const ovfRows = block.ovf.rows + const dialogItems = block.dialog.items + const diskLoopItems = block.diskLoop.items + const postRows = block.postImport.rows + const notImportedItems = block.notImported.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={6} + scriptPath="utilities/import_vm_ova_ovf.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("picker.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t("picker.intro")}</p> + <ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1"> + {pickerItems.map((_, idx) => ( + <li key={idx}>{t.rich(`picker.items.${idx}`, { code })}</li> + ))} + </ul> + <p className="mb-6 text-gray-800 leading-relaxed">{t.rich("picker.outro", { code })}</p> + + <Image + src="/utils/import-vm-picker.png" + alt={t("picker.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("flow.heading")}</h2> + + <DataFlowDiagram + nodes={flowNodes.map((n) => ({ label: n.label, detail: n.detail, variant: n.variant }))} + /> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("ovf.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("ovf.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("ovf.headerField")}</th> + <th className="text-left px-3 py-2 border-b border-gray-200">{t("ovf.headerSource")}</th> + <th className="text-left px-3 py-2 border-b border-gray-200">{t("ovf.headerDefault")}</th> + </tr> + </thead> + <tbody className="text-gray-800"> + {ovfRows.map((row, idx) => ( + <tr key={idx} className={idx < ovfRows.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 whitespace-nowrap font-mono text-xs">{row.source}</td> + <td className="px-3 py-2 align-top">{row.default}</td> + </tr> + ))} + </tbody> + </table> + </div> + + <Callout variant="warning" title={t("memWarn.title")}> + {t.rich("memWarn.body", { code, strong, em })} + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dialog.heading")}</h2> + <ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-2"> + {dialogItems.map((_, idx) => ( + <li key={idx}>{t.rich(`dialog.items.${idx}`, { code, strong })}</li> + ))} + </ol> + + <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("create.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("create.code") as string}</pre> + <p className="mt-4 mb-6 text-gray-800 leading-relaxed">{t.rich("create.outro", { code })}</p> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("diskLoop.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t("diskLoop.intro")}</p> + <ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-1"> + {diskLoopItems.map((_, idx) => ( + <li key={idx}>{t.rich(`diskLoop.items.${idx}`, { code })}</li> + ))} + </ol> + <p className="mb-6 text-gray-800 leading-relaxed">{t.rich("diskLoop.outro", { code })}</p> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("postImport.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t("postImport.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("postImport.headerSetting")}</th> + <th className="text-left px-3 py-2 border-b border-gray-200">{t("postImport.headerDefault")}</th> + <th className="text-left px-3 py-2 border-b border-gray-200">{t("postImport.headerRecommended")}</th> + </tr> + </thead> + <tbody className="text-gray-800"> + {postRows.map((row, idx) => ( + <tr key={idx} className={idx < postRows.length - 1 ? "border-b border-gray-100" : ""}> + <td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.setting}</strong></td> + <td className="px-3 py-2 align-top">{row.default}</td> + <td className="px-3 py-2 align-top"> + {row.recommendedRich + ? t.rich(`postImport.rows.${idx}.recommendedRich`, { code }) + : row.recommended} + </td> + </tr> + ))} + </tbody> + </table> + </div> + + <Callout variant="warning" title={t("fwWarn.title")}> + {t.rich("fwWarn.body", { code, em })} + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("notImported.heading")}</h2> + <ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1"> + {notImportedItems.map((_, idx) => ( + <li key={idx}>{t.rich(`notImported.items.${idx}`, { strong })}</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.orphanTitle")}> + {t("troubleshoot.orphanIntro")} + <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.orphanCode") as string}</pre> + {t("troubleshoot.orphanOutro")} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.memTitle")}> + {t("troubleshoot.memIntro")} + <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.memCode") as string}</pre> + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.bootTitle")}> + {t("troubleshoot.bootIntro")} + <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.bootCode") as string}</pre> + {t("troubleshoot.bootOutro")} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.bsodTitle")}> + {t.rich("troubleshoot.bsodBody", { code, em })} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.zeroTitle")}> + {t.rich("troubleshoot.zeroBody", { code })} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.awkTitle")}> + {t.rich("troubleshoot.awkIntro", { 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.awkCode") as string}</pre> + {t("troubleshoot.awkOutro")} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.netTitle")}> + {t.rich("troubleshoot.netBody", { 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"> + {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> + ) +} diff --git a/web/app/[locale]/docs/utils/page.tsx b/web/app/[locale]/docs/utils/page.tsx new file mode 100644 index 00000000..785294f8 --- /dev/null +++ b/web/app/[locale]/docs/utils/page.tsx @@ -0,0 +1,253 @@ +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, + Disc, + Package, + RefreshCw, + ArrowUpCircle, + Upload, + Download, + Wrench, + Boxes, + ArrowLeftRight, +} 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.utils.meta" }) + return { + title: t("title"), + description: t("description"), + keywords: [ + "proxmox utilities", + "proxmox update", + "pve 8 to 9 upgrade", + "pve9 upgrade", + "proxmox ova export", + "proxmox ovf import", + "uup dump proxmox", + "windows iso proxmox", + "vmware to proxmox", + "virtualbox to proxmox", + ], + alternates: { canonical: "https://proxmenux.com/docs/utils" }, + openGraph: { + title: t("ogTitle"), + description: t("ogDescription"), + type: "article", + url: "https://proxmenux.com/docs/utils", + }, + twitter: { + card: "summary", + title: t("twitterTitle"), + description: t("twitterDescription"), + }, + } +} + +type StringItem = string +type OptionItem = { title: string; description: string; href: 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 UtilitiesOverviewPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations({ locale, namespace: "docs.utils" }) + + const messages = (await getMessages({ locale })) as unknown as { + docs: { utils: { + groups: { + iso: { bullets: StringItem[] } + maintenance: { bullets: StringItem[] } + portability: { bullets: StringItem[] } + } + isoSection: { options: OptionItem[] } + maintenanceSection: { options: OptionItem[] } + portabilitySection: { options: OptionItem[] } + } } + } + const block = messages.docs.utils + const isoBullets = block.groups.iso.bullets + const maintBullets = block.groups.maintenance.bullets + const portBullets = block.groups.portability.bullets + const isoOptions = block.isoSection.options + const maintOptions = block.maintenanceSection.options + const portOptions = block.portabilitySection.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> + + const isoIcons: React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>[] = [Disc] + const maintIcons: React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>[] = [Package, RefreshCw, ArrowUpCircle] + const portIcons: React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>[] = [Upload, Download] + + return ( + <div> + <DocHeader + title={t("header.title")} + description={t("header.description")} + section={t("header.section")} + estimatedMinutes={5} + scriptPath="menus/utilities_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="/utils/utilities-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("groups.intro")}</p> + + <div className="grid gap-4 md:grid-cols-1 lg:grid-cols-3 mb-8 not-prose"> + <a + href="#iso" + 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"> + <Disc className="h-5 w-5" aria-hidden /> + </span> + <h3 className="text-lg font-semibold text-gray-900 m-0">{t("groups.iso.title")}</h3> + </div> + <p className="text-sm text-gray-800 mb-3">{t("groups.iso.body")}</p> + <ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400"> + {isoBullets.map((_, idx) => ( + <li key={idx}>{t(`groups.iso.bullets.${idx}`)}</li> + ))} + </ul> + </a> + + <a + href="#maintenance" + 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"> + <Wrench className="h-5 w-5" aria-hidden /> + </span> + <h3 className="text-lg font-semibold text-gray-900 m-0">{t("groups.maintenance.title")}</h3> + </div> + <p className="text-sm text-gray-800 mb-3">{t("groups.maintenance.body")}</p> + <ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400"> + {maintBullets.map((_, idx) => ( + <li key={idx}>{t(`groups.maintenance.bullets.${idx}`)}</li> + ))} + </ul> + </a> + + <a + href="#portability" + 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"> + <ArrowLeftRight className="h-5 w-5" aria-hidden /> + </span> + <h3 className="text-lg font-semibold text-gray-900 m-0">{t("groups.portability.title")}</h3> + </div> + <p className="text-sm text-gray-800 mb-3">{t("groups.portability.body")}</p> + <ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400"> + {portBullets.map((_, idx) => ( + <li key={idx}>{t(`groups.portability.bullets.${idx}`)}</li> + ))} + </ul> + </a> + </div> + + <Callout variant="warning" title={t("upgradeWarn.title")}> + {t.rich("upgradeWarn.body", { strong })} + </Callout> + + <h2 id="iso" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24"> + {t("isoSection.heading")} + </h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("isoSection.body", { code })}</p> + <div className="grid gap-3 md:grid-cols-2 mb-8 not-prose"> + {isoOptions.map((o, idx) => ( + <OptionCard key={o.href} {...o} Icon={isoIcons[idx]} /> + ))} + </div> + + <h2 id="maintenance" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24"> + {t("maintenanceSection.heading")} + </h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("maintenanceSection.body", { strong })}</p> + <div className="grid gap-3 md:grid-cols-2 mb-8 not-prose"> + {maintOptions.map((o, idx) => ( + <OptionCard key={o.href} {...o} Icon={maintIcons[idx]} /> + ))} + </div> + + <h2 id="portability" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24"> + {t("portabilitySection.heading")} + </h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t("portabilitySection.body")}</p> + <div className="grid gap-3 md:grid-cols-2 mb-8 not-prose"> + {portOptions.map((o, idx) => ( + <OptionCard key={o.href} {...o} Icon={portIcons[idx]} /> + ))} + </div> + + <Callout variant="info" title={t("diskSpaceCallout.title")}> + {t.rich("diskSpaceCallout.body", { code, strong })} + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900"> + <Boxes className="inline h-5 w-5 mr-1 -mt-1 text-gray-700" aria-hidden /> + {t("fitsTogether.heading")} + </h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("fitsTogether.body", { code, em })}</p> + </div> + ) +} diff --git a/web/app/[locale]/docs/utils/system-update/page.tsx b/web/app/[locale]/docs/utils/system-update/page.tsx new file mode 100644 index 00000000..4507e5fd --- /dev/null +++ b/web/app/[locale]/docs/utils/system-update/page.tsx @@ -0,0 +1,221 @@ +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.utils.systemUpdate.meta" }) + return { + title: t("title"), + description: t("description"), + openGraph: { + title: t("ogTitle"), + description: t("ogDescription"), + type: "article", + url: "https://macrimi.github.io/ProxMenux/docs/utils/system-update", + }, + } +} + +type StringItem = string +type TroubleItem = { title: string; body: string } +type RelatedItem = { href: string; label: string; tail?: string } + +export default async function SystemUpdatePage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations({ locale, namespace: "docs.utils.systemUpdate" }) + + const messages = (await getMessages({ locale })) as unknown as { + docs: { utils: { systemUpdate: { + onTop: { items: StringItem[] } + worker: { items: StringItem[] } + post: { items: StringItem[] } + noSub: { items: StringItem[] } + doesnt: { items: StringItem[] } + troubleshooting: { items: TroubleItem[] } + related: { items: RelatedItem[] } + } } } + } + const block = messages.docs.utils.systemUpdate + const onTopItems = block.onTop.items + const workerItems = block.worker.items + const postItems = block.post.items + const noSubItems = block.noSub.items + const doesntItems = block.doesnt.items + const troubleItems = block.troubleshooting.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 kbd = (chunks: React.ReactNode) => <kbd>{chunks}</kbd> + const linkUpgrade = (chunks: React.ReactNode) => ( + <Link href="/docs/utils/upgrade-pve8-pve9" className="text-blue-700 hover:underline">{chunks}</Link> + ) + const linkUpgrade2 = (chunks: React.ReactNode) => ( + <Link href="/docs/utils/upgrade-pve8-pve9" 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="utilities/proxmox_update.sh" + /> + + <Callout variant="info" title={t("calloutWhat.title")}> + {t.rich("calloutWhat.body", { strong, link: linkUpgrade })} + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("official.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t("official.intro")}</p> + <CopyableCode code={t.raw("official.code") as string} language="bash" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("official.outro")}</p> + + <h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("onTop.heading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("onTop.intro", { strong, code })} + </p> + <ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1"> + {onTopItems.map((_, idx) => ( + <li key={idx}>{t.rich(`onTop.items.${idx}`, { strong, code })}</li> + ))} + </ul> + <Callout variant="info" title={t("calloutOneSentence.title")}> + {t("calloutOneSentence.body")} + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("confirm.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t("confirm.intro")}</p> + + <Image + src="/utils/system-update-confirm.png" + alt={t("confirm.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("routes.heading")}</h2> + + <DataFlowDiagram + nodes={[ + { + label: t("routes.nodes.source.label"), + detail: t("routes.nodes.source.detail"), + variant: "source", + }, + { + label: t("routes.nodes.bridge.label"), + detail: t("routes.nodes.bridge.detail"), + variant: "bridge", + }, + { + label: t("routes.nodes.target.label"), + detail: t("routes.nodes.target.detail"), + variant: "target", + }, + ]} + /> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("worker.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("worker.intro", { code })} + </p> + <ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-2"> + {workerItems.map((_, idx) => ( + <li key={idx}>{t.rich(`worker.items.${idx}`, { strong, code })}</li> + ))} + </ol> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("post.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t("post.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("post.code") as string}</pre> + <p className="mt-4 mb-4 text-gray-800 leading-relaxed">{t("post.afterCode")}</p> + <ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1"> + {postItems.map((_, idx) => ( + <li key={idx}>{t.rich(`post.items.${idx}`, { code })}</li> + ))} + </ul> + <p className="mb-6 text-gray-800 leading-relaxed"> + {t.rich("post.outro", { em })} + </p> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("end.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t("end.intro")}</p> + + <Image + src="/utils/system-update-result.png" + alt={t("end.imageAlt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + /> + + <Callout variant="warning" title={t("calloutDeclineReboot.title")}> + {t.rich("calloutDeclineReboot.body", { code })} + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("noSub.heading")}</h2> + <p className="mb-6 text-gray-800 leading-relaxed"> + {t.rich("noSub.intro", { code })} + </p> + <ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-1"> + {noSubItems.map((_, idx) => ( + <li key={idx}>{t.rich(`noSub.items.${idx}`, { code })}</li> + ))} + </ol> + <p className="mb-6 text-gray-800 leading-relaxed">{t("noSub.outro")}</p> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("cluster.heading")}</h2> + <Callout variant="warning" title={t("cluster.calloutTitle")}> + {t.rich("cluster.calloutBody", { strong })} + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("doesnt.heading")}</h2> + <ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1"> + {doesntItems.map((_, idx) => ( + <li key={idx}>{t.rich(`doesnt.items.${idx}`, { strong, link: linkUpgrade2 })}</li> + ))} + </ul> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshooting.heading")}</h2> + + {troubleItems.map((_, idx) => ( + <Callout key={idx} variant="troubleshoot" title={t(`troubleshooting.items.${idx}.title`)}> + {t.rich(`troubleshooting.items.${idx}.body`, { code, kbd })} + </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"> + {relatedItems.map((item, idx) => ( + <li key={idx}> + <Link href={item.href} className="text-blue-600 hover:underline">{item.label}</Link> + {item.tail} + </li> + ))} + </ul> + </div> + ) +} diff --git a/web/app/[locale]/docs/utils/system-utils/page.tsx b/web/app/[locale]/docs/utils/system-utils/page.tsx new file mode 100644 index 00000000..0d5429ec --- /dev/null +++ b/web/app/[locale]/docs/utils/system-utils/page.tsx @@ -0,0 +1,187 @@ +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.utils.systemUtils.meta" }) + return { + title: t("title"), + description: t("description"), + openGraph: { + title: t("ogTitle"), + description: t("ogDescription"), + type: "article", + url: "https://macrimi.github.io/ProxMenux/docs/utils/system-utils", + }, + } +} + +type ActionRow = { option: string; behaviourRich: string } +type PackageRow = { package: string; verify: string; description: string } +type RelatedItem = { href: string; label: string; tailRich?: string } + +export default async function SystemUtilsPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations({ locale, namespace: "docs.utils.systemUtils" }) + + const messages = (await getMessages({ locale })) as unknown as { + docs: { utils: { systemUtils: { + actions: { rows: ActionRow[] } + packages: { rows: PackageRow[] } + howItWorks: { items: string[]; verifyOutcomes: string[] } + related: { items: RelatedItem[] } + } } } + } + const block = messages.docs.utils.systemUtils + const actionRows = block.actions.rows + const packageRows = block.packages.rows + const howItems = block.howItWorks.items + const verifyOutcomes = block.howItWorks.verifyOutcomes + 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 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="utilities/system_utils.sh" + /> + + <Callout variant="info" title={t("info.title")}> + {t.rich("info.body", { 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.intro", { strong })} + </p> + + <Image + src="/utils/system-utils-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("actions.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("actions.headerOption")}</th> + <th className="text-left px-3 py-2 border-b border-gray-200">{t("actions.headerBehaviour")}</th> + </tr> + </thead> + <tbody className="text-gray-800"> + {actionRows.map((row, idx) => ( + <tr key={idx} className={idx < actionRows.length - 1 ? "border-b border-gray-100" : ""}> + <td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.option}</strong></td> + <td className="px-3 py-2 align-top">{t.rich(`actions.rows.${idx}.behaviourRich`, { code })}</td> + </tr> + ))} + </tbody> + </table> + </div> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("packages.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("packages.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("packages.headerPackage")}</th> + <th className="text-left px-3 py-2 border-b border-gray-200">{t("packages.headerVerify")}</th> + <th className="text-left px-3 py-2 border-b border-gray-200">{t("packages.headerDescription")}</th> + </tr> + </thead> + <tbody className="text-gray-800 [&>tr>td]:px-3 [&>tr>td]:py-2 [&>tr>td]:align-top [&>tr>td:nth-child(-n+2)]:whitespace-nowrap [&>tr>td:nth-child(-n+2)]:font-mono [&>tr>td:nth-child(-n+2)]:text-xs"> + {packageRows.map((row, idx) => ( + <tr key={idx} className={idx < packageRows.length - 1 ? "border-b border-gray-100" : ""}> + <td>{row.package}</td> + <td>{row.verify}</td> + <td>{row.description}</td> + </tr> + ))} + </tbody> + </table> + </div> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howItWorks.heading")}</h2> + <ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-1"> + {howItems.map((_, idx) => ( + <li key={idx}>{t.rich(`howItWorks.items.${idx}`, { code })}</li> + ))} + <li> + {t.rich("howItWorks.verifyIntro", { code })} + <ul className="list-disc pl-6 mt-1 space-y-1"> + {verifyOutcomes.map((_, idx) => ( + <li key={idx}>{t.rich(`howItWorks.verifyOutcomes.${idx}`, { strong, em })}</li> + ))} + </ul> + </li> + <li>{t.rich("howItWorks.summary", { em })}</li> + </ol> + + <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.rich("verify.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("verify.code") as string}</pre> + <p className="mt-4 mb-6 text-gray-800 leading-relaxed"> + {t.rich("verify.outro", { 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.reposTitle")}> + {t.rich("troubleshoot.reposBody", { code })} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.warningsTitle")}> + {t.rich("troubleshoot.warningsBody", { code })} + </Callout> + + <Callout variant="troubleshoot" title={t("troubleshoot.hangsTitle")}> + {t.rich("troubleshoot.hangsBody", { code, kbd })} + </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"> + {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 }) : null} + </li> + ))} + </ul> + </div> + ) +} diff --git a/web/app/[locale]/docs/utils/upgrade-pve8-pve9/page.tsx b/web/app/[locale]/docs/utils/upgrade-pve8-pve9/page.tsx new file mode 100644 index 00000000..c61e3837 --- /dev/null +++ b/web/app/[locale]/docs/utils/upgrade-pve8-pve9/page.tsx @@ -0,0 +1,475 @@ +import type { Metadata } from "next" +import { getTranslations, getMessages, setRequestLocale } from "next-intl/server" +import { Link } from "@/i18n/navigation" +import Image from "next/image" +import { Zap, MessageSquare, BookOpen, ExternalLink, Youtube } from "lucide-react" +import { DocHeader } from "@/components/ui/doc-header" +import { Callout } from "@/components/ui/callout" +import { YouTubeEmbed } from "@/components/ui/youtube-embed" + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }> +}): Promise<Metadata> { + const { locale } = await params + const t = await getTranslations({ locale, namespace: "docs.utils.upgradePve8Pve9.meta" }) + return { + title: t("title"), + description: t("description"), + openGraph: { + title: t("ogTitle"), + description: t("ogDescription"), + type: "article", + url: "https://macrimi.github.io/ProxMenux/docs/utils/upgrade-pve8-pve9", + }, + } +} + +type StringItem = string +type TableRow2 = { settingRich: string; effectRich: string } +type PreflightRow = { checkRich: string; whyRich: string } +type DpkgRow = { fileRich: string; answerRich: string; whyRich: string; fileFont: boolean } +type TroubleItem = { title: string; bodyRich: string } +type RefItem = { href: string; title: string; desc: string } +type RelatedItem = { href: string; label: string; tail: string } + +export default async function UpgradePve8Pve9Page({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations({ locale, namespace: "docs.utils.upgradePve8Pve9" }) + + const messages = (await getMessages({ locale })) as unknown as { + docs: { utils: { upgradePve8Pve9: { + dangerCallout: { items: StringItem[] } + threeWays: { + auto: { items: StringItem[] } + interactive: { items: StringItem[] } + manual: { items: StringItem[] } + } + auto: { + behaviourTable: { rows: TableRow2[] } + preflightTable: { rows: PreflightRow[] } + postItems: StringItem[] + } + interactive: { whenItems: StringItem[] } + manual: { dpkgTable: { rows: DpkgRow[] } } + troubleshooting: { items: TroubleItem[] } + references: { items: RefItem[] } + related: { items: RelatedItem[] } + } } } + } + const block = messages.docs.utils.upgradePve8Pve9 + const dangerItems = block.dangerCallout.items + const autoCardItems = block.threeWays.auto.items + const interactiveCardItems = block.threeWays.interactive.items + const manualCardItems = block.threeWays.manual.items + const behaviourRows = block.auto.behaviourTable.rows + const preflightRows = block.auto.preflightTable.rows + const postItems = block.auto.postItems + const whenItems = block.interactive.whenItems + const dpkgRows = block.manual.dpkgTable.rows + const troubleItems = block.troubleshooting.items + const refItems = block.references.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 kbd = (chunks: React.ReactNode) => <kbd>{chunks}</kbd> + + const autolink = (chunks: React.ReactNode) => ( + <Link href="#auto" className="text-blue-600 hover:underline"> + {chunks} + </Link> + ) + const dpkglink = (chunks: React.ReactNode) => ( + <Link href="#dpkg-prompts" className="text-blue-700 hover:underline"> + {chunks} + </Link> + ) + const netlink = (chunks: React.ReactNode) => ( + <Link href="/docs/network/persistent-names" className="text-blue-600 hover:underline"> + {chunks} + </Link> + ) + const wikilink = (chunks: React.ReactNode) => ( + <a + href="https://pve.proxmox.com/wiki/Upgrade_from_8_to_9" + 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 cephlink = (chunks: React.ReactNode) => ( + <a + href="https://pve.proxmox.com/wiki/Ceph_Squid" + 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={20} + scriptPath="utilities/upgrade_pve8_to_pve9.sh" + /> + + <Callout variant="danger" title={t("dangerCallout.title")}> + {t.rich("dangerCallout.intro", { strong })} + <ul className="list-disc pl-6 mt-2 mb-0 space-y-1"> + {dangerItems.map((_, idx) => ( + <li key={idx}>{t.rich(`dangerCallout.items.${idx}`, { strong })}</li> + ))} + </ul> + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("modeMenu.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("modeMenu.intro", { em })} + </p> + + <Image + src="/utils/upgrade-pve8-pve9-menu.png" + alt={t("modeMenu.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.rich("threeWays.intro", { code })} + </p> + + <div className="grid gap-4 md:grid-cols-1 lg:grid-cols-3 mb-8 not-prose"> + <a + href="#auto" + 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"> + <Zap className="h-5 w-5" aria-hidden /> + </span> + <h3 className="text-lg font-semibold text-gray-900 m-0">{t("threeWays.auto.title")}</h3> + </div> + <p className="text-sm text-gray-800 mb-3"> + {t.rich("threeWays.auto.summary", { code, strong })} + </p> + <ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400"> + {autoCardItems.map((_, idx) => ( + <li key={idx}>{t.rich(`threeWays.auto.items.${idx}`, { code, strong })}</li> + ))} + </ul> + </a> + + <a + href="#interactive" + 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"> + <MessageSquare className="h-5 w-5" aria-hidden /> + </span> + <h3 className="text-lg font-semibold text-gray-900 m-0">{t("threeWays.interactive.title")}</h3> + </div> + <p className="text-sm text-gray-800 mb-3"> + {t("threeWays.interactive.summary")} + </p> + <ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400"> + {interactiveCardItems.map((_, idx) => ( + <li key={idx}>{t.rich(`threeWays.interactive.items.${idx}`, { code })}</li> + ))} + </ul> + </a> + + <a + href="#manual" + 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"> + <BookOpen className="h-5 w-5" aria-hidden /> + </span> + <h3 className="text-lg font-semibold text-gray-900 m-0">{t("threeWays.manual.title")}</h3> + </div> + <p className="text-sm text-gray-800 mb-3"> + {t("threeWays.manual.summary")} + </p> + <ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400"> + {manualCardItems.map((_, idx) => ( + <li key={idx}>{t(`threeWays.manual.items.${idx}`)}</li> + ))} + </ul> + </a> + </div> + + <Callout variant="info" title={t("precheckCallout.title")}> + {t.rich("precheckCallout.body", { code, strong })} + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("webTerminal.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("webTerminal.body", { code })} + </p> + <Callout variant="warning" title={t("webTerminal.warningTitle")}> + {t.rich("webTerminal.warningBody", { code })} + </Callout> + + <h2 id="auto" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24"> + {t("auto.heading")} + </h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t("auto.intro")} + </p> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("auto.behaviourHeading")}</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("auto.behaviourTable.settingHeader")}</th> + <th className="text-left px-3 py-2 border-b border-gray-200">{t("auto.behaviourTable.effectHeader")}</th> + </tr> + </thead> + <tbody className="text-gray-800"> + {behaviourRows.map((_, idx) => ( + <tr key={idx} className={idx < behaviourRows.length - 1 ? "border-b border-gray-100" : ""}> + <td className="px-3 py-2 align-top whitespace-nowrap"> + {t.rich(`auto.behaviourTable.rows.${idx}.settingRich`, { code })} + </td> + <td className="px-3 py-2 align-top"> + {t.rich(`auto.behaviourTable.rows.${idx}.effectRich`, { strong })} + </td> + </tr> + ))} + </tbody> + </table> + </div> + + <h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("auto.flowHeading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("auto.flowIntro", { code })} + </p> + + <pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-900 leading-relaxed border border-gray-200 whitespace-pre">{t.raw("auto.flowDiagram") as string}</pre> + + <h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("auto.preflightHeading")}</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("auto.preflightTable.checkHeader")}</th> + <th className="text-left px-3 py-2 border-b border-gray-200">{t("auto.preflightTable.whyHeader")}</th> + </tr> + </thead> + <tbody className="text-gray-800"> + {preflightRows.map((_, idx) => ( + <tr key={idx} className={idx < preflightRows.length - 1 ? "border-b border-gray-100" : ""}> + <td className="px-3 py-2 align-top whitespace-nowrap"> + {t.rich(`auto.preflightTable.rows.${idx}.checkRich`, { strong })} + </td> + <td className="px-3 py-2 align-top"> + {t.rich(`auto.preflightTable.rows.${idx}.whyRich`, { code })} + </td> + </tr> + ))} + </tbody> + </table> + </div> + + <h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("auto.distUpgradeHeading")}</h3> + <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("auto.distUpgradeCode") as string}</pre> + <p className="mt-4 mb-6 text-gray-800 leading-relaxed"> + {t.rich("auto.distUpgradeOutro", { code })} + </p> + + <h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("auto.postHeading")}</h3> + <ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-1"> + {postItems.map((_, idx) => ( + <li key={idx}>{t.rich(`auto.postItems.${idx}`, { code, strong })}</li> + ))} + </ol> + + <h2 id="interactive" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24"> + {t("interactive.heading")} + </h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("interactive.intro", { code, strong, autolink })} + </p> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("interactive.distUpgradeHeading")}</h3> + <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("interactive.distUpgradeCode") as string}</pre> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("interactive.whenHeading")}</h3> + <ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1"> + {whenItems.map((_, idx) => ( + <li key={idx}>{t.rich(`interactive.whenItems.${idx}`, { code })}</li> + ))} + </ul> + + <Callout variant="info" title={t("interactive.promptCalloutTitle")}> + {t.rich("interactive.promptCalloutBody", { strong, dpkglink })} + </Callout> + + <h2 id="manual" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24"> + {t("manual.heading")} + </h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("manual.intro", { strong, em, wikilink })} + </p> + + <Callout variant="warning" title={t("manual.rootCalloutTitle")}> + {t.rich("manual.rootCalloutBody", { code })} + </Callout> + + <h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("manual.phase1Heading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t("manual.phase1Intro")} + </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.phase1Code") as string}</pre> + + <h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("manual.phase2Heading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("manual.phase2Intro", { 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("manual.phase2Code") as string}</pre> + + <h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("manual.phase3Heading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t("manual.phase3Intro")} + </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.phase3Code") as string}</pre> + + <h4 id="dpkg-prompts" className="text-lg font-semibold mt-6 mb-3 text-gray-900 scroll-mt-24"> + {t("manual.dpkgHeading")} + </h4> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("manual.dpkgIntro", { 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("manual.dpkgTable.fileHeader")}</th> + <th className="text-left px-3 py-2 border-b border-gray-200">{t("manual.dpkgTable.answerHeader")}</th> + <th className="text-left px-3 py-2 border-b border-gray-200">{t("manual.dpkgTable.whyHeader")}</th> + </tr> + </thead> + <tbody className="text-gray-800"> + {dpkgRows.map((row, idx) => ( + <tr key={idx} className={idx < dpkgRows.length - 1 ? "border-b border-gray-100" : ""}> + <td className={`px-3 py-2 align-top whitespace-nowrap${row.fileFont ? " font-mono text-xs" : ""}`}> + {t.rich(`manual.dpkgTable.rows.${idx}.fileRich`, { code })} + </td> + <td className="px-3 py-2 align-top whitespace-nowrap"> + {t.rich(`manual.dpkgTable.rows.${idx}.answerRich`, { strong, kbd })} + </td> + <td className="px-3 py-2 align-top"> + {t(`manual.dpkgTable.rows.${idx}.whyRich`)} + </td> + </tr> + ))} + </tbody> + </table> + </div> + + <Callout variant="info" title={t("manual.inspectCalloutTitle")}> + {t.rich("manual.inspectCalloutBody", { strong })} + </Callout> + + <h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("manual.phase4Heading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("manual.phase4Intro", { 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.phase4Code") as string}</pre> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("clusterCeph.heading")}</h2> + + <Callout variant="warning" title={t("clusterCeph.clusterCalloutTitle")}> + {t.rich("clusterCeph.clusterCalloutBody", { code, strong })} + </Callout> + + <Callout variant="warning" title={t("clusterCeph.cephCalloutTitle")}> + {t.rich("clusterCeph.cephCalloutBody", { code, cephlink })} + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshooting.heading")}</h2> + + {troubleItems.map((item, idx) => ( + <Callout key={idx} variant="troubleshoot" title={item.title}> + {t.rich(`troubleshooting.items.${idx}.bodyRich`, { code, netlink })} + </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"> + <ExternalLink className="inline h-5 w-5 mr-1 -mt-1 text-gray-700" aria-hidden /> + {t("references.heading")} + </h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t("references.intro")} + </p> + <div className="grid gap-3 md:grid-cols-2 mb-6 not-prose"> + {refItems.map((item, idx) => ( + <a + key={idx} + href={item.href} + 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 flex items-center gap-1.5"> + {t(`references.items.${idx}.title`)} + <ExternalLink className="h-3.5 w-3.5 text-gray-400" aria-hidden /> + </div> + <div className="text-xs text-gray-600">{t(`references.items.${idx}.desc`)}</div> + </a> + ))} + </div> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900"> + <Youtube className="inline h-5 w-5 mr-1 -mt-1 text-red-600" aria-hidden /> + {t("video.heading")} + </h2> + <p className="mb-4 text-gray-800 leading-relaxed"> + {t("video.intro")} + </p> + + <YouTubeEmbed + videoId="AmpgWHePp18" + title={t("video.title")} + caption={t("video.caption")} + /> + + <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={idx}> + <Link href={item.href} className="text-blue-600 hover:underline"> + {item.label} + </Link> + {item.tail} + </li> + ))} + </ul> + </div> + ) +} diff --git a/web/app/[locale]/guides/backup-cloud/page.tsx b/web/app/[locale]/guides/backup-cloud/page.tsx new file mode 100644 index 00000000..7d343918 --- /dev/null +++ b/web/app/[locale]/guides/backup-cloud/page.tsx @@ -0,0 +1,263 @@ +import type { Metadata } from "next" +import { getTranslations, getMessages, setRequestLocale } from "next-intl/server" +import { ExternalLink } from "lucide-react" +import { DocHeader } from "@/components/ui/doc-header" +import { Callout } from "@/components/ui/callout" +import Image from "next/image" +import CopyableCode from "@/components/CopyableCode" +import Footer from "@/components/footer" + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }> +}): Promise<Metadata> { + const { locale } = await params + const t = await getTranslations({ locale, namespace: "guides.backupCloud.meta" }) + return { + title: t("title"), + description: t("description"), + alternates: { canonical: "https://proxmenux.com/docs/guides/backup-cloud" }, + openGraph: { + title: t("ogTitle"), + description: t("ogDescription"), + type: "article", + url: "https://proxmenux.com/docs/guides/backup-cloud", + }, + } +} + +export default async function BackupCloudGuide({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations({ locale, namespace: "guides.backupCloud" }) + + const messages = (await getMessages({ locale })) as unknown as { + guides: { backupCloud: { + intro: { steps: string[] } + createDir: { configItems: string[] } + mount: { mountItems: string[] } + retention: { starterItems: string[] } + troubleshoot: { items: string[] } + } } + } + const introSteps = messages.guides.backupCloud.intro.steps + const configItems = messages.guides.backupCloud.createDir.configItems + const mountItems = messages.guides.backupCloud.mount.mountItems + const starterItems = messages.guides.backupCloud.retention.starterItems + const troubleItems = messages.guides.backupCloud.troubleshoot.items + + const code = (chunks: React.ReactNode) => <code>{chunks}</code> + const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong> + + const pbsLink = (chunks: React.ReactNode) => ( + <a + href="https://www.proxmox.com/proxmox-backup-server" + 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 rcloneLink = (chunks: React.ReactNode) => ( + <a + href="https://rclone.org" + 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 remoteSetupLink = (chunks: React.ReactNode) => ( + <a + href="https://rclone.org/remote_setup/" + 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 providerDocsLink = (chunks: React.ReactNode) => ( + <a + href="https://rclone.org/docs/" + 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 className="min-h-screen bg-white text-gray-900 pt-16 flex flex-col"> + <div className="container mx-auto px-4 pt-6 pb-16 flex-grow" style={{ maxWidth: "980px" }}> + <DocHeader + title={t("header.title")} + description={t("header.description")} + section={t("header.section")} + estimatedMinutes={15} + /> + + <Callout variant="info" title={t("intro.pbsCalloutTitle")}> + {t.rich("intro.pbsCalloutBody", { strong, code, pbsLink })} + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("intro.stepsTitle")}</h2> + <ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-1"> + {introSteps.map((_, idx) => ( + <li key={idx}>{t(`intro.steps.${idx}`)}</li> + ))} + </ol> + + <Callout variant="warning" title={t("intro.vzdumpCalloutTitle")}> + {t.rich("intro.vzdumpCalloutBody", { strong, code })} + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("createDir.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("createDir.body", { strong, code })}</p> + <CopyableCode code={t.raw("createDir.mkdirCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("createDir.afterMkdir", { strong, code })}</p> + <Image + src="/guides/backup_cloud/imagen1.png" + alt={t("createDir.image1Alt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("createDir.configIntro")}</p> + <ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1"> + {configItems.map((_, idx) => ( + <li key={idx}>{t.rich(`createDir.configItems.${idx}`, { strong, code })}</li> + ))} + </ul> + <Image + src="/guides/backup_cloud/imagen2.png" + alt={t("createDir.image2Alt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("createDir.afterConfig", { strong })}</p> + <Image + src="/guides/backup_cloud/imagen3.png" + alt={t("createDir.image3Alt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("createDir.afterAdd")}</p> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("installRclone.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("installRclone.body", { rcloneLink })}</p> + <CopyableCode code={t.raw("installRclone.installCode") as string} className="my-4" /> + + <Callout variant="info" title={t("installRclone.newerCalloutTitle")}> + <p className="mb-2">{t("installRclone.newerCalloutBody")}</p> + <CopyableCode code={t.raw("installRclone.newerCode") as string} className="my-2" /> + </Callout> + + <h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("installRclone.tunnelHeading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("installRclone.tunnelBody", { remoteSetupLink })}</p> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("installRclone.tunnelFrom", { strong, code })}</p> + <CopyableCode code={t.raw("installRclone.tunnelCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("installRclone.tunnelAfter")}</p> + + <h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("installRclone.runHeading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed">{t("installRclone.runBody")}</p> + <CopyableCode code={t.raw("installRclone.runCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("installRclone.runAfter", { providerDocsLink })}</p> + <CopyableCode code={t.raw("installRclone.authPrompt") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("installRclone.authAfter", { strong })}</p> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("installRclone.nameRemote", { code })}</p> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("mount.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("mount.body", { code })}</p> + <p className="mb-4 text-gray-800 leading-relaxed">{t("mount.mountIntro")}</p> + <CopyableCode code={t.raw("mount.mountCode") as string} className="my-4" /> + <ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1"> + {mountItems.map((_, idx) => ( + <li key={idx}>{t.rich(`mount.mountItems.${idx}`, { code })}</li> + ))} + </ul> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("mount.mountFootnote", { strong })}</p> + + <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.body", { code })}</p> + <p className="mb-4 text-gray-800 leading-relaxed">{t("systemd.createIntro")}</p> + <CopyableCode code={t.raw("systemd.createCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("systemd.pasteIntro")}</p> + <CopyableCode code={t.raw("systemd.unitCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("systemd.adjust", { code })}</p> + <CopyableCode code={t.raw("systemd.enableCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("systemd.verifyIntro")}</p> + <CopyableCode code={t.raw("systemd.verifyCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("systemd.verifyAfter", { code })}</p> + + <Callout variant="info" title={t("systemd.vfsCalloutTitle")}> + {t.rich("systemd.vfsCalloutBody", { code })} + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("configureBackup.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("configureBackup.body", { strong })}</p> + <Image + src="/guides/backup_cloud/imagen5.png" + alt={t("configureBackup.image5Alt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("configureBackup.after")}</p> + <Image + src="/guides/backup_cloud/imagen6.png" + alt={t("configureBackup.image6Alt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("retention.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("retention.body", { strong })}</p> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("retention.uiPath", { strong })}</p> + <Image + src="/guides/backup_cloud/imagen7.png" + alt={t("retention.image7Alt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("retention.starterIntro")}</p> + <ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1"> + {starterItems.map((_, idx) => ( + <li key={idx}>{t(`retention.starterItems.${idx}`)}</li> + ))} + </ul> + <p className="mb-4 text-gray-800 leading-relaxed">{t("retention.adjust")}</p> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2> + <ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-2"> + {troubleItems.map((_, idx) => ( + <li key={idx}>{t.rich(`troubleshoot.items.${idx}`, { code, strong })}</li> + ))} + </ul> + </div> + <Footer /> + </div> + ) +} diff --git a/web/app/[locale]/guides/kodi-lxc/page.tsx b/web/app/[locale]/guides/kodi-lxc/page.tsx new file mode 100644 index 00000000..f9358f73 --- /dev/null +++ b/web/app/[locale]/guides/kodi-lxc/page.tsx @@ -0,0 +1,179 @@ +import type { Metadata } from "next" +import { getTranslations, getMessages, setRequestLocale } from "next-intl/server" +import { ExternalLink } from "lucide-react" +import { DocHeader } from "@/components/ui/doc-header" +import { Callout } from "@/components/ui/callout" +import Image from "next/image" +import CopyableCode from "@/components/CopyableCode" +import Footer from "@/components/footer" + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }> +}): Promise<Metadata> { + const { locale } = await params + const t = await getTranslations({ locale, namespace: "guides.kodiLxc.meta" }) + return { + title: t("title"), + description: t("description"), + alternates: { canonical: "https://proxmenux.com/docs/guides/kodi-lxc" }, + openGraph: { + title: t("ogTitle"), + description: t("ogDescription"), + type: "article", + url: "https://proxmenux.com/docs/guides/kodi-lxc", + }, + } +} + +export default async function KodiLxcGuide({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations({ locale, namespace: "guides.kodiLxc" }) + + const messages = (await getMessages({ locale })) as unknown as { + guides: { kodiLxc: { + intro: { steps: string[] } + troubleshoot: { items: string[] } + } } + } + const introSteps = messages.guides.kodiLxc.intro.steps + const troubleItems = messages.guides.kodiLxc.troubleshoot.items + + const code = (chunks: React.ReactNode) => <code>{chunks}</code> + const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong> + const gpuLink = (chunks: React.ReactNode) => ( + <a + href="/docs/hardware/igpu-acceleration-lxc" + className="text-blue-700 hover:underline" + > + {chunks} + </a> + ) + const authorLink = (chunks: React.ReactNode) => ( + <a + href="https://github.com/mrrudy" + 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 konpatLink = (chunks: React.ReactNode) => ( + <a + href="https://blog.konpat.me/dev/2019/03/11/setting-up-lxc-for-intel-gpu-proxmox.html" + 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 className="min-h-screen bg-white text-gray-900 pt-16 flex flex-col"> + <div className="container mx-auto px-4 pt-6 pb-16 flex-grow" style={{ maxWidth: "980px" }}> + <DocHeader + title={t("header.title")} + description={t("header.description")} + section={t("header.section")} + estimatedMinutes={10} + /> + + <Callout variant="info" title={t("intro.calloutTitle")}> + {t.rich("intro.calloutBody", { strong, code, gpuLink })} + </Callout> + + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("intro.credit", { authorLink })} + </p> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("intro.stepsTitle")}</h2> + <ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-1"> + {introSteps.map((_, idx) => ( + <li key={idx}>{t(`intro.steps.${idx}`)}</li> + ))} + </ol> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("createCt.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("createCt.body", { strong, code })}</p> + <CopyableCode code={t.raw("createCt.code") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("createCt.after", { code })}</p> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("addInput.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("addInput.body", { code })}</p> + <CopyableCode code={t.raw("addInput.listCode") as string} className="my-4" /> + <Image + src="/guides/kodi/kodi1.png" + alt={t("addInput.imageAlt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("addInput.afterList", { code, strong })}</p> + <CopyableCode code={t.raw("addInput.editCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("addInput.addLines", { code, strong })}</p> + <CopyableCode code={t.raw("addInput.configCode") as string} className="my-4" /> + <Image + src="/guides/kodi/kodi2.png" + alt={t("addInput.imageConfigAlt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("addInput.save", { code, strong })}</p> + <CopyableCode code={t.raw("addInput.restartCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("addInput.plug")}</p> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("updateKodi.heading")}</h2> + <Callout variant="warning" title={t("updateKodi.calloutTitle")}> + {t.rich("updateKodi.calloutBody", { strong, code })} + </Callout> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("updateKodi.body", { code })}</p> + <CopyableCode code={t.raw("updateKodi.code") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("updateKodi.after")}</p> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("screenshots.heading")}</h2> + <Image + src="/guides/kodi/kodi3.png" + alt={t("screenshots.image1Alt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + <Image + src="/guides/kodi/kodi4.jpeg" + alt={t("screenshots.image2Alt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2> + <ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-2"> + {troubleItems.map((_, idx) => ( + <li key={idx}>{t.rich(`troubleshoot.items.${idx}`, { code, strong })}</li> + ))} + </ul> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("further.heading")}</h2> + <ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1"> + <li>{t.rich("further.konpatRich", { konpatLink })}</li> + </ul> + </div> + <Footer /> + </div> + ) +} diff --git a/web/app/guides/linux-resources/page.tsx b/web/app/[locale]/guides/linux-resources/page.tsx similarity index 79% rename from web/app/guides/linux-resources/page.tsx rename to web/app/[locale]/guides/linux-resources/page.tsx index 5cf4628a..bf874143 100644 --- a/web/app/guides/linux-resources/page.tsx +++ b/web/app/[locale]/guides/linux-resources/page.tsx @@ -1,7 +1,49 @@ +import type { Metadata } from "next" import Link from "next/link" import { BookOpen, ExternalLink, Shield, Activity, Database, FileCode, ArrowLeft } from "lucide-react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import Footer2 from "@/components/footer2" +import Footer from "@/components/footer" + +export const metadata: Metadata = { + title: + "Linux & Proxmox Learning Resources — Cheatsheets, Security, ZFS, Monitoring | ProxMenux", + description: + "Curated catalogue of external Linux and Proxmox VE learning resources: command-line cheatsheets (TLDR, Explainshell, Cheat.sh), security hardening references, monitoring tools and ZFS documentation. Complements the ProxMenux command catalog.", + keywords: [ + "linux cheatsheet", + "proxmox learning resources", + "linux security hardening", + "zfs documentation", + "tldr pages", + "explainshell", + "linux command reference", + "linux monitoring tools", + "proxmox community resources", + ], + alternates: { canonical: "https://proxmenux.com/guides/linux-resources" }, + openGraph: { + title: "Linux & Proxmox Learning Resources", + description: + "Curated external Linux and Proxmox VE learning resources — cheatsheets, security, monitoring, ZFS — that complement the ProxMenux command catalog.", + type: "article", + url: "https://proxmenux.com/guides/linux-resources", + siteName: "ProxMenux", + images: [ + { + url: "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/main.png", + width: 1363, + height: 735, + alt: "Linux & Proxmox Learning Resources — ProxMenux", + }, + ], + }, + twitter: { + card: "summary", + title: "Linux & Proxmox Learning Resources | ProxMenux", + description: + "Curated external resources — cheatsheets, security, monitoring, ZFS — that complement the ProxMenux command catalog.", + }, +} export default function LinuxResourcesPage() { const resourceCategories = [ @@ -133,7 +175,7 @@ export default function LinuxResourcesPage() { ))} </div> </div> - <Footer2 /> + <Footer /> </div> ) } diff --git a/web/app/[locale]/guides/lxc-samba/page.tsx b/web/app/[locale]/guides/lxc-samba/page.tsx new file mode 100644 index 00000000..fb62eeb7 --- /dev/null +++ b/web/app/[locale]/guides/lxc-samba/page.tsx @@ -0,0 +1,217 @@ +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" +import Image from "next/image" +import CopyableCode from "@/components/CopyableCode" +import Footer from "@/components/footer" + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }> +}): Promise<Metadata> { + const { locale } = await params + const t = await getTranslations({ locale, namespace: "guides.lxcSamba.meta" }) + return { + title: t("title"), + description: t("description"), + alternates: { canonical: "https://proxmenux.com/docs/guides/lxc-samba" }, + openGraph: { + title: t("ogTitle"), + description: t("ogDescription"), + type: "article", + url: "https://proxmenux.com/docs/guides/lxc-samba", + }, + } +} + +export default async function LxcSambaGuide({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations({ locale, namespace: "guides.lxcSamba" }) + + const messages = (await getMessages({ locale })) as unknown as { + guides: { lxcSamba: { + recommended: { items: string[] } + intro: { steps: string[]; useCases: string[] } + troubleshoot: { items: string[] } + } } + } + const recommendedItems = messages.guides.lxcSamba.recommended.items + const introSteps = messages.guides.lxcSamba.intro.steps + const useCases = messages.guides.lxcSamba.intro.useCases + const troubleItems = messages.guides.lxcSamba.troubleshoot.items + + const code = (chunks: React.ReactNode) => <code>{chunks}</code> + const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong> + + return ( + <div className="min-h-screen bg-white text-gray-900 pt-16 flex flex-col"> + <div className="container mx-auto px-4 pt-6 pb-16 flex-grow" style={{ maxWidth: "980px" }}> + <DocHeader + title={t("header.title")} + description={t("header.description")} + section={t("header.section")} + estimatedMinutes={20} + /> + + <Callout variant="warning" title={t("recommended.calloutTitle")}> + <p className="mb-2">{t("recommended.calloutIntro")}</p> + <ul className="list-disc pl-6 mb-3 space-y-1"> + {recommendedItems.map((_, idx) => ( + <li key={idx}>{t.rich(`recommended.items.${idx}`, { strong, code })}</li> + ))} + </ul> + <p>{t("recommended.calloutOutro")}</p> + </Callout> + + <p className="mb-4 text-gray-800 leading-relaxed">{t("intro.body")}</p> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("intro.stepsTitle")}</h2> + <ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-1"> + {introSteps.map((_, idx) => ( + <li key={idx}>{t(`intro.steps.${idx}`)}</li> + ))} + </ol> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("intro.useCasesTitle")}</h2> + <ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1"> + {useCases.map((_, idx) => ( + <li key={idx}>{t(`intro.useCases.${idx}`)}</li> + ))} + </ul> + + <Callout variant="warning" title={t("intro.privilegedCalloutTitle")}> + {t.rich("intro.privilegedCalloutBody", { strong, code })} + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("attach.heading")}</h2> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("attach.identifyHeading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("attach.identifyBody", { strong })}</p> + <p className="mb-2 text-gray-800 leading-relaxed">{t("attach.beforeLabel")}</p> + <Image + src="/guides/lxc_samba/lxc_3.png" + alt={t("attach.imageBeforeAlt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + <p className="mb-2 text-gray-800 leading-relaxed">{t("attach.afterLabel")}</p> + <Image + src="/guides/lxc_samba/lxc_4.png" + alt={t("attach.imageAfterAlt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("attach.lsblkBody", { code })}</p> + + <Callout variant="warning" title={t("attach.stableCalloutTitle")}> + <p className="mb-2">{t.rich("attach.stableCalloutBody", { code })}</p> + <CopyableCode code={t.raw("attach.stableCalloutCode") as string} className="my-3" /> + <p>{t.rich("attach.stableCalloutAfter", { code })}</p> + </Callout> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("attach.formatHeading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("attach.formatBody", { strong })}</p> + <CopyableCode code={t.raw("attach.formatCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("attach.formatAfter", { code })}</p> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("attach.mkdirHeading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed">{t("attach.mkdirBody")}</p> + <CopyableCode code={t.raw("attach.mkdirCode") as string} className="my-4" /> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("attach.wireHeading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("attach.wireBody", { strong, code })}</p> + <CopyableCode code={t.raw("attach.wireEditCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("attach.wireAddLine")}</p> + <CopyableCode code={t.raw("attach.wireConfigCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("attach.wireShortForm", { code })}</p> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("attach.wireBackupNote", { code })}</p> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("attach.restartHeading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed">{t("attach.restartBody")}</p> + <CopyableCode code={t.raw("attach.restartCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("attach.permsBody")}</p> + <CopyableCode code={t.raw("attach.permsCode") as string} className="my-4" /> + <Callout variant="info" title={t("attach.permsNoteTitle")}> + {t.rich("attach.permsNote", { code })} + </Callout> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("samba.heading")}</h2> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("samba.installHeading")}</h3> + <CopyableCode code={t.raw("samba.installCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("samba.confirmBody")}</p> + <CopyableCode code={t.raw("samba.confirmCode") as string} className="my-4" /> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("samba.userHeading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("samba.userBody", { code })}</p> + <CopyableCode code={t.raw("samba.userCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("samba.passwordBody")}</p> + <CopyableCode code={t.raw("samba.passwordCode") as string} className="my-4" /> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("samba.aclHeading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("samba.aclBody", { code })}</p> + <CopyableCode code={t.raw("samba.aclCode") as string} className="my-4" /> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("configure.heading")}</h2> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("configure.editHeading")}</h3> + <CopyableCode code={t.raw("configure.editCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("configure.appendBody")}</p> + <CopyableCode code={t.raw("configure.shareCode") as string} className="my-4" /> + <Callout variant="info" title={t("configure.validUsersNoteTitle")}> + {t.rich("configure.validUsersNote", { code })} + </Callout> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("configure.reloadHeading")}</h3> + <CopyableCode code={t.raw("configure.reloadCode") as string} className="my-4" /> + + <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.rich("verify.body", { code })}</p> + <Image + src="/guides/lxc_samba/lxc_1.png" + alt={t("verify.image1Alt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + <Image + src="/guides/lxc_samba/lxc_2.png" + alt={t("verify.image2Alt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("verify.usageBody")}</p> + <Image + src="/guides/lxc_samba/lxc_5.png" + alt={t("verify.image3Alt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2> + <ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-2"> + {troubleItems.map((_, idx) => ( + <li key={idx}>{t.rich(`troubleshoot.items.${idx}`, { code, strong })}</li> + ))} + </ul> + </div> + <Footer /> + </div> + ) +} diff --git a/web/app/[locale]/guides/nvidia-manual/page.tsx b/web/app/[locale]/guides/nvidia-manual/page.tsx new file mode 100644 index 00000000..54c30c8c --- /dev/null +++ b/web/app/[locale]/guides/nvidia-manual/page.tsx @@ -0,0 +1,328 @@ +import type { Metadata } from "next" +import { getTranslations, getMessages, setRequestLocale } from "next-intl/server" +import { ExternalLink } from "lucide-react" +import { DocHeader } from "@/components/ui/doc-header" +import { Callout } from "@/components/ui/callout" +import Image from "next/image" +import CopyableCode from "@/components/CopyableCode" +import Footer from "@/components/footer" + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }> +}): Promise<Metadata> { + const { locale } = await params + const t = await getTranslations({ locale, namespace: "guides.nvidiaManual.meta" }) + return { + title: t("title"), + description: t("description"), + alternates: { canonical: "https://proxmenux.com/docs/guides/nvidia-manual" }, + openGraph: { + title: t("ogTitle"), + description: t("ogDescription"), + type: "article", + url: "https://proxmenux.com/docs/guides/nvidia-manual", + }, + } +} + +export default async function NvidiaManualGuide({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations({ locale, namespace: "guides.nvidiaManual" }) + + const messages = (await getMessages({ locale })) as unknown as { + guides: { nvidiaManual: { + intro: { steps: string[] } + lxcSetup: { tableRows: { device: string; major: string }[] } + troubleshoot: { items: string[] } + } } + } + const introSteps = messages.guides.nvidiaManual.intro.steps + const tableRows = messages.guides.nvidiaManual.lxcSetup.tableRows + const troubleItems = messages.guides.nvidiaManual.troubleshoot.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 patchLink = (chunks: React.ReactNode) => ( + <a + href="https://github.com/keylase/nvidia-patch" + 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 className="min-h-screen bg-white text-gray-900 pt-16 flex flex-col"> + <div className="container mx-auto px-4 pt-6 pb-16 flex-grow" style={{ maxWidth: "980px" }}> + <DocHeader + title={t("header.title")} + description={t("header.description")} + section={t("header.section")} + estimatedMinutes={30} + /> + + <Callout variant="info" title={t("intro.calloutTitle")}> + {t.rich("intro.calloutBody", { strong, em })} + </Callout> + + <p className="mb-4 text-gray-800 leading-relaxed"> + {t.rich("intro.targetNote", { strong })} + </p> + + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("intro.stepsTitle")}</h2> + <ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-1"> + {introSteps.map((_, idx) => ( + <li key={idx}>{t(`intro.steps.${idx}`)}</li> + ))} + </ol> + + {/* Section 1 - Prepare host */} + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("prepareHost.heading")}</h2> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("prepareHost.blacklistHeading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("prepareHost.blacklistBody", { code })}</p> + <CopyableCode code={t.raw("prepareHost.blacklistCheckCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("prepareHost.blacklistAdd", { code })}</p> + <CopyableCode code={t.raw("prepareHost.blacklistAddCode") as string} className="my-4" /> + <Image + src="/guides/nvidia/nvidia-2.png" + alt={t("prepareHost.blacklistImageAlt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("prepareHost.reposHeading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed">{t("prepareHost.reposBody")}</p> + <p className="mb-4 text-gray-800 leading-relaxed">{t("prepareHost.reposOtherwise")}</p> + <CopyableCode code={t.raw("prepareHost.reposEditCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("prepareHost.reposPveBody")}</p> + <CopyableCode code={t.raw("prepareHost.reposPveCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("prepareHost.reposDebianBody", { code })}</p> + <CopyableCode code={t.raw("prepareHost.reposDebianCode") as string} className="my-4" /> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("prepareHost.updateHeading")}</h3> + <CopyableCode code={t.raw("prepareHost.updateCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("prepareHost.buildToolsBody")}</p> + <CopyableCode code={t.raw("prepareHost.buildToolsCode") as string} className="my-4" /> + + {/* Section 2 - Install driver */} + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("installDriver.heading")}</h2> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("installDriver.pickHeading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed">{t("installDriver.pickBody")}</p> + <CopyableCode code={t.raw("installDriver.pickUrlCode") as string} className="my-4" /> + <Callout variant="info" title={t("installDriver.nvencCalloutTitle")}> + {t.rich("installDriver.nvencCallout", { patchLink })} + </Callout> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("installDriver.pickReplace", { code })}</p> + <CopyableCode code={t.raw("installDriver.pickListCode") as string} className="my-4" /> + <Image + src="/guides/nvidia/nvidia-1.png" + alt={t("installDriver.pickImageAlt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("installDriver.pickVersionNote", { code })}</p> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("installDriver.downloadHeading")}</h3> + <CopyableCode code={t.raw("installDriver.downloadCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("installDriver.firstPassBody", { code })}</p> + <CopyableCode code={t.raw("installDriver.firstPassCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("installDriver.secondPassBody")}</p> + <CopyableCode code={t.raw("installDriver.secondPassCode") as string} className="my-4" /> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("installDriver.modulesHeading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed">{t("installDriver.modulesBody")}</p> + <CopyableCode code={t.raw("installDriver.modulesEditCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("installDriver.modulesAddBody")}</p> + <CopyableCode code={t.raw("installDriver.modulesAddCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("installDriver.modulesSaveBody", { code })}</p> + <CopyableCode code={t.raw("installDriver.modulesSaveCode") as string} className="my-4" /> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("installDriver.udevHeading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("installDriver.udevBody", { code })}</p> + <CopyableCode code={t.raw("installDriver.udevEditCode") as string} className="my-4" /> + <CopyableCode code={t.raw("installDriver.udevRulesCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("installDriver.udevSaveBody", { code })}</p> + + {/* Section 3 - Persistence */} + <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.body")}</p> + <CopyableCode code={t.raw("persistence.installCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("persistence.verifyBody")}</p> + <CopyableCode code={t.raw("persistence.verifySmiCode") as string} className="my-4" /> + <Image + src="/guides/nvidia/nvidia-3.png" + alt={t("persistence.smiImageAlt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + <CopyableCode code={t.raw("persistence.verifyServiceCode") as string} className="my-4" /> + <Image + src="/guides/nvidia/nvidia-4.png" + alt={t("persistence.serviceImageAlt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + + {/* Section 4 - NVENC */} + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("nvenc.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t("nvenc.body")}</p> + <CopyableCode code={t.raw("nvenc.code") as string} className="my-4" /> + <Image + src="/guides/nvidia/nvidia-5.png" + alt={t("nvenc.imageAlt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("nvenc.after", { code })}</p> + + {/* Section 5 - LXC setup */} + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("lxcSetup.heading")}</h2> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("lxcSetup.identifyHeading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed">{t("lxcSetup.identifyBody")}</p> + <CopyableCode code={t.raw("lxcSetup.identifyCode") as string} className="my-4" /> + <Image + src="/guides/nvidia/nvidia-6.png" + alt={t("lxcSetup.identifyImageAlt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("lxcSetup.identifyNote")}</p> + <div className="overflow-x-auto my-4"> + <table className="min-w-full border-collapse border border-gray-300"> + <thead className="bg-gray-100"> + <tr> + <th className="border border-gray-300 px-4 py-2 text-left text-gray-900">{t("lxcSetup.tableHeaders.device")}</th> + <th className="border border-gray-300 px-4 py-2 text-left text-gray-900">{t("lxcSetup.tableHeaders.major")}</th> + </tr> + </thead> + <tbody> + {tableRows.map((_, idx) => ( + <tr key={idx}> + <td className="border border-gray-300 px-4 py-2 text-gray-800"> + {t.rich(`lxcSetup.tableRows.${idx}.device`, { code })} + </td> + <td className="border border-gray-300 px-4 py-2 text-gray-800"> + {t.rich(`lxcSetup.tableRows.${idx}.major`, { code })} + </td> + </tr> + ))} + </tbody> + </table> + </div> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("lxcSetup.editHeading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("lxcSetup.editBody", { code })}</p> + <CopyableCode code={t.raw("lxcSetup.editCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("lxcSetup.editConfigBody", { code, strong })}</p> + <CopyableCode code={t.raw("lxcSetup.editConfigCode") as string} className="my-4" /> + <Image + src="/guides/nvidia/nvidia-7.png" + alt={t("lxcSetup.editConfigImageAlt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("lxcSetup.editSave", { code })}</p> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("lxcSetup.installCtHeading")}</h3> + <Callout variant="warning" title={t("lxcSetup.installCtCalloutTitle")}> + {t.rich("lxcSetup.installCtCalloutBody", { strong })} + </Callout> + <p className="mb-4 text-gray-800 leading-relaxed">{t("lxcSetup.installCtBody")}</p> + <CopyableCode code={t.raw("lxcSetup.installCtCode") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("lxcSetup.installCtAfter")}</p> + <Image + src="/guides/nvidia/nvidia-8.png" + alt={t("lxcSetup.installCtImageAlt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("lxcSetup.verifyCtHeading")}</h3> + <CopyableCode code={t.raw("lxcSetup.verifyCtSmiCode") as string} className="my-4" /> + <Image + src="/guides/nvidia/nvidia-9.png" + alt={t("lxcSetup.verifyCtSmiImageAlt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + <CopyableCode code={t.raw("lxcSetup.verifyCtLsCode") as string} className="my-4" /> + <Image + src="/guides/nvidia/nvidia-10.png" + alt={t("lxcSetup.verifyCtLsImageAlt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("lxcSetup.verifyCtAfter")}</p> + + <h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("lxcSetup.workloadHeading")}</h3> + <p className="mb-4 text-gray-800 leading-relaxed">{t("lxcSetup.workloadBody")}</p> + <Image + src="/guides/nvidia/nvidia-11.png" + alt={t("lxcSetup.workloadImage1Alt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + <Image + src="/guides/nvidia/nvidia-12.png" + alt={t("lxcSetup.workloadImage2Alt")} + width={900} + height={500} + className="rounded shadow-lg my-6" + unoptimized + /> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("lxcSetup.repeatNote", { strong })}</p> + + {/* Section 6 - Docker */} + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("docker.heading")}</h2> + <p className="mb-4 text-gray-800 leading-relaxed">{t.rich("docker.body", { code })}</p> + <CopyableCode code={t.raw("docker.code") as string} className="my-4" /> + <p className="mb-4 text-gray-800 leading-relaxed">{t("docker.after")}</p> + + {/* Troubleshooting */} + <h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2> + <ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-2"> + {troubleItems.map((_, idx) => ( + <li key={idx}>{t.rich(`troubleshoot.items.${idx}`, { code, strong })}</li> + ))} + </ul> + </div> + <Footer /> + </div> + ) +} diff --git a/web/app/[locale]/guides/page.tsx b/web/app/[locale]/guides/page.tsx new file mode 100644 index 00000000..6ef67430 --- /dev/null +++ b/web/app/[locale]/guides/page.tsx @@ -0,0 +1,309 @@ +import type { Metadata } from "next" +import { Link } from "@/i18n/navigation" +import { getTranslations, getMessages, setRequestLocale } from "next-intl/server" +import { + Play, + MessageCircle, + Users, + Book, + Database, + Code, + BookOpen, + Library, + Star, + Sparkles, + ExternalLink, +} from "lucide-react" +import Footer from "@/components/footer" + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }> +}): Promise<Metadata> { + const { locale } = await params + const t = await getTranslations({ locale, namespace: "guides.meta" }) + return { + title: t("title"), + description: t("description"), + keywords: [ + "proxmox guides", + "proxmox tutorials", + "proxmox kodi lxc", + "proxmox nvidia driver", + "proxmox samba lxc", + "proxmox cloud backup", + "proxmox vzdump rclone", + "proxmox coral tpu", + "proxmox gpu lxc", + "proxmox ve 9 guides", + ], + alternates: { canonical: "https://proxmenux.com/guides" }, + openGraph: { + title: t("ogTitle"), + description: t("ogDescription"), + type: "website", + url: "https://proxmenux.com/guides", + siteName: "ProxMenux", + images: [ + { + url: "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/main.png", + width: 1363, + height: 735, + alt: t("ogImageAlt"), + }, + ], + }, + twitter: { + card: "summary_large_image", + title: t("twitterTitle"), + description: t("twitterDescription"), + images: [ + "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/main.png", + ], + }, + } +} + +interface Guide { + title: string + description: string + slug: string +} + +interface ExternalCardProps { + href: string + title: string + description: string + Icon: React.ComponentType<{ className?: string }> + color: string // tailwind bg + hover classes + external?: boolean +} + +function CardLink({ href, title, description, Icon, color, external = true }: ExternalCardProps) { + const Inner = ( + <div className={`block p-6 rounded-lg shadow-md transition-colors h-full ${color}`}> + <div className="flex items-center gap-3 mb-2"> + <Icon className="h-6 w-6 text-white flex-shrink-0" /> + <h3 className="text-xl font-semibold text-white m-0">{title}</h3> + </div> + <p className="text-gray-200 text-sm m-0">{description}</p> + </div> + ) + + if (external) { + return ( + <a href={href} target="_blank" rel="noopener noreferrer" className="block h-full"> + {Inner} + </a> + ) + } + return ( + <Link href={href} className="block h-full"> + {Inner} + </Link> + ) +} + +export default async function GuidesPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations({ locale, namespace: "guides" }) + + const messages = (await getMessages({ locale })) as unknown as { + guides: { inDepth: { items: Guide[] } } + } + const guides = messages.guides.inDepth.items + + const link = (chunks: React.ReactNode) => ( + <a + href="https://github.com/MacRimi/ProxMenux/issues" + target="_blank" + rel="noopener noreferrer" + className="text-blue-400 hover:underline inline-flex items-center gap-1" + > + {chunks} + <ExternalLink className="w-3 h-3" /> + </a> + ) + + return ( + <div className="min-h-screen bg-gradient-to-b from-gray-900 to-gray-800 text-white pt-16 flex flex-col"> + <div className="flex-grow container mx-auto px-4 pt-6 pb-16"> + <div className="mb-10"> + <h1 className="text-4xl font-bold mb-3">{t("header.title")}</h1> + <p className="text-xl text-gray-200 max-w-3xl">{t("header.tagline")}</p> + </div> + + {/* ─────────────────────────── In-depth guides ─────────────────────────── */} + <section className="mb-16"> + <div className="flex items-center gap-2 mb-6"> + <BookOpen className="h-7 w-7 text-blue-400" /> + <h2 className="text-3xl font-bold m-0">{t("inDepth.heading")}</h2> + </div> + <p className="text-gray-300 mb-6 max-w-3xl">{t("inDepth.intro")}</p> + <div className="grid md:grid-cols-2 gap-6"> + {guides.map((guide) => ( + <Link + key={guide.slug} + href={`/guides/${guide.slug}`} + className="block p-6 bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow" + > + <h3 className="text-xl font-semibold mb-2 text-gray-900 m-0">{guide.title}</h3> + <p className="text-sm text-gray-600 mt-2 mb-0">{guide.description}</p> + </Link> + ))} + </div> + </section> + + {/* ─────────────────────────── ProxMenux references ─────────────────────────── */} + <section className="mb-16"> + <div className="flex items-center gap-2 mb-6"> + <Library className="h-7 w-7 text-emerald-400" /> + <h2 className="text-3xl font-bold m-0">{t("references.heading")}</h2> + </div> + <p className="text-gray-300 mb-6 max-w-3xl">{t("references.intro")}</p> + <div className="grid md:grid-cols-2 gap-6"> + <CardLink + href="/docs/glossary" + title={t("references.cards.glossary.title")} + description={t("references.cards.glossary.description")} + Icon={Sparkles} + color="bg-emerald-600 hover:bg-emerald-700" + external={false} + /> + <CardLink + href="/docs/help-info" + title={t("references.cards.helpInfo.title")} + description={t("references.cards.helpInfo.description")} + Icon={Code} + color="bg-teal-600 hover:bg-teal-700" + external={false} + /> + <CardLink + href="/guides/linux-resources" + title={t("references.cards.linuxResources.title")} + description={t("references.cards.linuxResources.description")} + Icon={BookOpen} + color="bg-cyan-600 hover:bg-cyan-700" + external={false} + /> + <CardLink + href="/docs/external-repositories" + title={t("references.cards.externalRepos.title")} + description={t("references.cards.externalRepos.description")} + Icon={Code} + color="bg-slate-600 hover:bg-slate-700" + external={false} + /> + </div> + </section> + + {/* ─────────────────────────── Official Proxmox resources ─────────────────────────── */} + <section className="mb-16"> + <div className="flex items-center gap-2 mb-6"> + <Book className="h-7 w-7 text-amber-400" /> + <h2 className="text-3xl font-bold m-0">{t("official.heading")}</h2> + </div> + <p className="text-gray-300 mb-6 max-w-3xl">{t("official.intro")}</p> + <div className="grid md:grid-cols-2 gap-6"> + <CardLink + href="https://pve.proxmox.com/pve-docs/index.html" + title={t("official.cards.pveDocs.title")} + description={t("official.cards.pveDocs.description")} + Icon={Book} + color="bg-green-600 hover:bg-green-700" + /> + <CardLink + href="https://pbs.proxmox.com/docs/index.html" + title={t("official.cards.pbsDocs.title")} + description={t("official.cards.pbsDocs.description")} + Icon={Database} + color="bg-yellow-600 hover:bg-yellow-700" + /> + <CardLink + href="https://www.proxmox.com/en/services/training-courses/videos" + title={t("official.cards.videoTraining.title")} + description={t("official.cards.videoTraining.description")} + Icon={Play} + color="bg-red-600 hover:bg-red-700" + /> + <CardLink + href="https://forum.proxmox.com/" + title={t("official.cards.forum.title")} + description={t("official.cards.forum.description")} + Icon={MessageCircle} + color="bg-purple-600 hover:bg-purple-700" + /> + </div> + </section> + + {/* ─────────────────────────── Community projects & resources ─────────────────────────── */} + <section className="mb-16"> + <div className="flex items-center gap-2 mb-6"> + <Star className="h-7 w-7 text-pink-400" /> + <h2 className="text-3xl font-bold m-0">{t("community.heading")}</h2> + </div> + <p className="text-gray-300 mb-6 max-w-3xl">{t("community.intro")}</p> + <div className="grid md:grid-cols-2 gap-6"> + <CardLink + href="https://community-scripts.github.io/ProxmoxVE/" + title={t("community.cards.helperScripts.title")} + description={t("community.cards.helperScripts.description")} + Icon={Code} + color="bg-indigo-600 hover:bg-indigo-700" + /> + <CardLink + href="https://github.com/Corsinvest/awesome-proxmox-ve" + title={t("community.cards.awesome.title")} + description={t("community.cards.awesome.description")} + Icon={Star} + color="bg-pink-600 hover:bg-pink-700" + /> + </div> + + <p className="text-sm text-gray-400 mt-6 italic"> + {t.rich("community.suggestRich", { link })} + </p> + </section> + + {/* ─────────────────────────── Discussion ─────────────────────────── */} + <section className="mb-8"> + <div className="flex items-center gap-2 mb-6"> + <Users className="h-7 w-7 text-orange-400" /> + <h2 className="text-3xl font-bold m-0">{t("discussion.heading")}</h2> + </div> + <p className="text-gray-300 mb-6 max-w-3xl">{t("discussion.intro")}</p> + <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> + <CardLink + href="https://github.com/MacRimi/ProxMenux/discussions" + title={t("discussion.cards.proxmenuxDiscussions.title")} + description={t("discussion.cards.proxmenuxDiscussions.description")} + Icon={MessageCircle} + color="bg-blue-600 hover:bg-blue-700" + /> + <CardLink + href="https://forum.proxmox.com/" + title={t("discussion.cards.proxmoxForum.title")} + description={t("discussion.cards.proxmoxForum.description")} + Icon={MessageCircle} + color="bg-purple-600 hover:bg-purple-700" + /> + <CardLink + href="https://www.reddit.com/r/Proxmox/" + title={t("discussion.cards.reddit.title")} + description={t("discussion.cards.reddit.description")} + Icon={Users} + color="bg-orange-600 hover:bg-orange-700" + /> + </div> + </section> + </div> + <Footer /> + </div> + ) +} diff --git a/web/app/[locale]/layout.tsx b/web/app/[locale]/layout.tsx new file mode 100644 index 00000000..5fd80566 --- /dev/null +++ b/web/app/[locale]/layout.tsx @@ -0,0 +1,186 @@ +import { Suspense } from "react" +import Navbar from "@/components/navbar" +import MouseMoveEffect from "@/components/mouse-move-effect" +import { PagefindHighlighter } from "@/components/pagefind-highlighter" +import { LocaleHtmlSync } from "@/components/locale-html-sync" +import type React from "react" +import { notFound } from "next/navigation" +import { NextIntlClientProvider, hasLocale } from "next-intl" +import { setRequestLocale } from "next-intl/server" +import { routing } from "@/i18n/routing" + +/** + * Tell Next.js which locales to pre-render under [locale]. Required + * for `output: "export"` — without this, the static build can't enumerate + * the dynamic segment and falls back to ISR (which static export doesn't + * support). + */ +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })) +} + +/** + * Force every nested page to render statically. Without this Next.js + * treats pages that don't explicitly call `setRequestLocale()` as + * dynamic (because next-intl's `getRequestConfig` reads + * `requestLocale` which internally falls back to `headers()`), + * breaking the `output: "export"` build with + * `StaticGenBailoutError: dynamic = "error" couldn't be rendered + * statically because it used 'headers'`. Marking force-static here + * spares us from adding `setRequestLocale(locale)` to all 100+ docs + * pages individually — the locale comes from the [locale] segment of + * the URL, which is part of `generateStaticParams`, so each combination + * is pre-rendered without ever needing request headers. + */ +export const dynamic = "force-static" + +export const metadata = { + title: "ProxMenux — Interactive Menu and Web Dashboard for Proxmox VE", + generator: "Next.js", + applicationName: "ProxMenux", + referrer: "origin-when-cross-origin", + keywords: [ + "Proxmox VE", + "Proxmox", + "PVE", + "ProxMenux", + "MacRimi", + "proxmox menu", + "proxmox tui", + "proxmox dashboard", + "proxmox web dashboard", + "proxmox monitor", + "proxmox open source", + "proxmox community", + "proxmox helper script", + "proxmox automation", + "menu-driven", + "self-hosted", + "virtualization", + "VM management", + "LXC management", + "container management", + ], + authors: [{ name: "MacRimi", url: "https://github.com/MacRimi" }], + creator: "MacRimi", + publisher: "MacRimi", + description: + "ProxMenux is an open-source, menu-driven tool for Proxmox VE management with a self-hosted web dashboard. Run post-install tweaks, create VMs and LXC containers, manage GPU passthrough, disks, network and storage from an interactive terminal menu — and watch host health, metrics and notifications from a browser.", + formatDetection: { + email: false, + address: false, + telephone: false, + }, + metadataBase: new URL("https://proxmenux.com"), + alternates: { + canonical: "https://proxmenux.com", + types: { + "application/rss+xml": "https://proxmenux.com/rss.xml", + }, + }, + openGraph: { + title: "ProxMenux — Interactive Menu and Web Dashboard for Proxmox VE", + description: + "Open-source CLI/TUI plus a self-hosted web dashboard for Proxmox VE management. Run scripts and wizards from a terminal menu, or watch host health and notifications from a browser.", + url: "https://proxmenux.com", + siteName: "ProxMenux", + images: [ + { + url: "https://proxmenux.com/main.png", + width: 1363, + height: 735, + alt: "ProxMenux — Interactive Menu and Web Dashboard for Proxmox VE", + }, + ], + locale: "en_US", + type: "website", + }, + twitter: { + card: "summary_large_image", + title: "ProxMenux — Interactive Menu and Web Dashboard for Proxmox VE", + description: + "Open-source CLI/TUI + self-hosted web dashboard for Proxmox VE management.", + images: ["https://proxmenux.com/main.png"], + creator: "@MacRimi", + }, + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + "max-video-preview": -1, + "max-image-preview": "large", + "max-snippet": -1, + }, + }, + icons: { + icon: [ + { url: "/favicon.ico", sizes: "any" }, + { url: "/icon.svg", type: "image/svg+xml" }, + ], + apple: [{ url: "/apple-touch-icon.png", sizes: "180x180" }], + }, +} + +export default async function LocaleLayout({ + children, + params, +}: { + children: React.ReactNode + params: Promise<{ locale: string }> +}) { + const { locale } = await params + + if (!hasLocale(routing.locales, locale)) { + notFound() + } + + setRequestLocale(locale) + + return ( + <NextIntlClientProvider> + {/* LocaleHtmlSync writes `document.documentElement.lang = locale` + after hydration so the active language is reflected in <html lang> + for accessibility tools and SEO crawlers that execute JS. The + static root layout always serves lang="en" as the fallback for + users with JavaScript disabled. */} + <LocaleHtmlSync locale={locale} /> + <script + type="application/ld+json" + dangerouslySetInnerHTML={{ + __html: JSON.stringify({ + "@context": "https://schema.org", + "@type": "SoftwareApplication", + name: "ProxMenux", + description: + "Open-source, menu-driven tool for Proxmox VE management with a self-hosted web dashboard. Includes post-install tweaks, VM and LXC creation, GPU and Coral TPU passthrough, disk, storage and network workflows, plus a Health Monitor with notifications and a REST API.", + applicationCategory: "DeveloperApplication", + operatingSystem: "Linux", + offers: { + "@type": "Offer", + price: "0", + priceCurrency: "USD", + }, + author: { + "@type": "Person", + name: "MacRimi", + url: "https://github.com/MacRimi", + }, + license: "https://github.com/MacRimi/ProxMenux/blob/main/LICENSE", + codeRepository: "https://github.com/MacRimi/ProxMenux", + url: "https://proxmenux.com", + image: "https://proxmenux.com/main.png", + }), + }} + /> + <Navbar /> + <MouseMoveEffect /> + <div className="pt-16 md:pt-16">{children}</div> + <script src="/pagefind/pagefind-highlight.js" type="module" defer /> + <Suspense fallback={null}> + <PagefindHighlighter /> + </Suspense> + </NextIntlClientProvider> + ) +} diff --git a/web/app/metadata.ts b/web/app/[locale]/metadata.ts similarity index 100% rename from web/app/metadata.ts rename to web/app/[locale]/metadata.ts diff --git a/web/app/[locale]/page.tsx b/web/app/[locale]/page.tsx new file mode 100644 index 00000000..f5a54dd8 --- /dev/null +++ b/web/app/[locale]/page.tsx @@ -0,0 +1,16 @@ +import Hero from "@/components/hero" +import Resources from "@/components/resources" +import SupportProject from "@/components/support-project" +import Footer from "@/components/footer" + + +export default function Home() { + return ( + <div className="min-h-screen bg-gradient-to-b from-gray-900 to-gray-800 text-white pt-16"> + <Hero /> + <Resources /> + <SupportProject /> + <Footer /> + </div> + ) +} diff --git a/web/app/[locale]/rss.xml/route.ts b/web/app/[locale]/rss.xml/route.ts new file mode 100644 index 00000000..5bc6851a --- /dev/null +++ b/web/app/[locale]/rss.xml/route.ts @@ -0,0 +1,249 @@ +import { NextResponse } from "next/server" +import fs from "fs" +import path from "path" +import { routing } from "@/i18n/routing" + +export const dynamic = "force-static" + +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })) +} + +interface ChangelogEntry { + version: string + date: string + content: string + url: string + title: string + image?: string +} + +// Per-locale RSS feed. Mirrors /app/rss.xml/route.ts (which stays the +// canonical English feed at the root for backwards compatibility with +// existing subscribers) but reads the localized CHANGELOG at +// <repo>/lang/<locale>/CHANGELOG.md. Falls back to the English source +// when the localized file doesn't exist yet so partial translations +// still produce a valid feed. + +const DEFAULT_CHANNEL_IMAGE = + "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/main.png" + +type LocaleStrings = { + lang: string + channelTitle: string + channelDescription: string + itemTitlePrefix: string // "ProxMenux" — used as `${prefix} ${version}` for versioned releases + itemUpdatePrefix: string // "ProxMenux Update" — used as `${prefix} ${date}` for dated releases + category: string +} + +const STRINGS: Record<string, LocaleStrings> = { + en: { + lang: "en-US", + channelTitle: "ProxMenux Changelog", + channelDescription: + "Release notes and changes in ProxMenux — an open-source interactive menu and web dashboard for Proxmox VE management.", + itemTitlePrefix: "ProxMenux", + itemUpdatePrefix: "ProxMenux Update", + category: "Changelog", + }, + es: { + lang: "es-ES", + channelTitle: "Changelog de ProxMenux", + channelDescription: + "Notas de release y cambios en ProxMenux — un menú interactivo y panel web open-source para gestionar Proxmox VE.", + itemTitlePrefix: "ProxMenux", + itemUpdatePrefix: "Actualización ProxMenux", + category: "Changelog", + }, +} + +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") +} + +function extractFirstImage(rawContent: string): string | null { + const match = rawContent.match(/!\[[^\]]*\]\(([^)]+)\)/) + if (!match) return null + const url = match[1] + if (url.startsWith("http://") || url.startsWith("https://")) { + return url.replace( + "https://macrimi.github.io/ProxMenux", + "https://proxmenux.com", + ) + } + if (url.startsWith("/")) return `https://proxmenux.com${url}` + return `https://proxmenux.com/${url}` +} + +function escapeXml(text: string): string { + return text + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +function formatContentForRSS(content: string): string { + return content + .replace(/https:\/\/macrimi\.github\.io\/ProxMenux/g, "https://proxmenux.com") + .replace(/`([^`]+)`/g, "<code>$1</code>") + .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => { + let absoluteUrl = url + if (url.startsWith("/")) { + absoluteUrl = `https://proxmenux.com${url}` + } else if (!url.startsWith("http://") && !url.startsWith("https://")) { + absoluteUrl = `https://proxmenux.com/${url}` + } + return `<div style="margin: 1.5em 0; text-align: center;"> + <img src="${absoluteUrl}" alt="${alt}" style="max-width: 100%; height: auto; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);" /> + </div>` + }) + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>') + .replace(/^### (.+)$/gm, "<h3>$1</h3>") + .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>") + .replace(/```[\s\S]*?```/g, (match) => { + const code = match.replace(/```/g, "").trim() + return `<pre><code>${code}</code></pre>` + }) + .replace(/^- (.+)$/gm, "<li>$1</li>") + .replace(/(<li>.*?<\/li>\s*)+/g, (match) => `<ul>${match}</ul>`) + .replace(/^---$/gm, '<hr style="border: none; border-top: 2px solid #eee; margin: 2em 0;" />') + .replace(/\n/g, "<br/>") + .replace(/\s+/g, " ") + .trim() +} + +async function parseChangelog(locale: string, strings: LocaleStrings): Promise<ChangelogEntry[]> { + try { + const changelogPath = resolveChangelogPath(locale) + if (!fs.existsSync(changelogPath)) return [] + + const fileContents = fs.readFileSync(changelogPath, "utf8") + const entries: ChangelogEntry[] = [] + + const lines = fileContents.split("\n") + let currentEntry: Partial<ChangelogEntry> | null = null + let contentLines: string[] = [] + + for (const line of lines) { + const versionMatch = line.match(/^##\s+\[([^\]]+)\]\s*-\s*(\d{4}-\d{2}-\d{2})/) + const dateMatch = line.match(/^##\s+(\d{4}-\d{2}-\d{2})$/) + + if (versionMatch || dateMatch) { + if (currentEntry && contentLines.length > 0) { + const rawContent = contentLines.join("\n").trim() + const firstImage = extractFirstImage(rawContent) + if (firstImage) currentEntry.image = firstImage + currentEntry.content = formatContentForRSS(rawContent) + if (currentEntry.version && currentEntry.date && currentEntry.title) { + entries.push(currentEntry as ChangelogEntry) + } + } + + if (versionMatch) { + const version = versionMatch[1] + const date = versionMatch[2] + currentEntry = { + version, + date, + url: `https://proxmenux.com/${locale}/changelog#${version}`, + title: `${strings.itemTitlePrefix} ${version}`, + } + } else if (dateMatch) { + const date = dateMatch[1] + currentEntry = { + version: date, + date, + url: `https://proxmenux.com/${locale}/changelog#${date}`, + title: `${strings.itemUpdatePrefix} ${date}`, + } + } + + contentLines = [] + } else if (currentEntry && line.trim()) { + if (contentLines.length > 0 || line.trim() !== "") { + contentLines.push(line) + } + } + } + + if (currentEntry && contentLines.length > 0) { + const rawContent = contentLines.join("\n").trim() + const firstImage = extractFirstImage(rawContent) + if (firstImage) currentEntry.image = firstImage + currentEntry.content = formatContentForRSS(rawContent) + if (currentEntry.version && currentEntry.date && currentEntry.title) { + entries.push(currentEntry as ChangelogEntry) + } + } + + return entries.slice(0, 20) + } catch (error) { + console.error("Error parsing changelog:", error) + return [] + } +} + +export async function GET( + _req: Request, + { params }: { params: Promise<{ locale: string }> }, +) { + const { locale } = await params + const strings = STRINGS[locale] ?? STRINGS.en + const entries = await parseChangelog(locale, strings) + const siteUrl = "https://proxmenux.com" + const channelImage = entries.find((e) => e.image)?.image ?? DEFAULT_CHANNEL_IMAGE + const feedUrl = `${siteUrl}/${locale}/rss.xml` + const changelogUrl = `${siteUrl}/${locale}/changelog` + + const rssXml = `<?xml version="1.0" encoding="UTF-8"?> +<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/"> + <channel> + <title>${escapeXml(strings.channelTitle)} + ${escapeXml(strings.channelDescription)} + ${changelogUrl} + + ${strings.lang} + ${new Date().toUTCString()} + ProxMenux RSS Generator + 60 + + ${escapeXml(channelImage)} + ${escapeXml(strings.channelTitle)} + ${changelogUrl} + + + ${entries + .map( + (entry) => ` + + ${escapeXml(entry.title)} + ${escapeXml(entry.content.replace(/<[^>]*>/g, "").substring(0, 200))}... + + ${entry.url} + ${entry.url} + ${new Date(entry.date).toUTCString()} + ${escapeXml(strings.category)}${entry.image ? ` + + + ` : ""} + `, + ) + .join("")} + +` + + return new NextResponse(rssXml, { + headers: { + "Content-Type": "application/rss+xml; charset=utf-8", + "Cache-Control": "public, max-age=3600, s-maxage=3600", + }, + }) +} diff --git a/web/app/changelog/page.tsx b/web/app/changelog/page.tsx deleted file mode 100644 index cbb41e75..00000000 --- a/web/app/changelog/page.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import fs from "fs" -import path from "path" -import { remark } from "remark" -import html from "remark-html" -import * as gfm from "remark-gfm" -import dynamic from "next/dynamic" -import parse from "html-react-parser" -import Footer from "@/components/footer" -import RSSLink from "@/components/rss-link" - - -const CopyableCode = dynamic(() => import("@/components/CopyableCode"), { ssr: false }) - -async function getChangelogContent() { - try { - const changelogPath = path.join(process.cwd(), "..", "CHANGELOG.md") - - if (!fs.existsSync(changelogPath)) { - console.error("❌ CHANGELOG.md file not found.") - return "

Error: CHANGELOG.md file not found

" - } - - 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 "

Error: Could not load changelog content.

" - } -} - -// Clean backticks in inline code fragments -function cleanInlineCode(content: string) { - return content.replace(/(.*?)<\/code>/g, (_, codeContent) => { - return `${codeContent.replace(/^`|`$/g, "")}` - }) -} - -// 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 - } - } - }, - }) -} - -export default async function ChangelogPage() { - const changelogContent = await getChangelogContent() - const cleanedInlineCode = cleanInlineCode(changelogContent) // First clean inline code - const parsedContent = wrapCodeBlocksWithCopyable(cleanedInlineCode) // Then apply JSX to code blocks - - return ( -
-
- {" "} - {/* Exact adjustment like GitHub */} -

Changelog

- {/* RSS Link Component */} - -
{parsedContent}
{/* Text adjusted to 16px */} -
-
-
- ) -} diff --git a/web/app/docs/about/code-of-conduct/page.tsx b/web/app/docs/about/code-of-conduct/page.tsx deleted file mode 100644 index cbe96b15..00000000 --- a/web/app/docs/about/code-of-conduct/page.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import fs from "fs" -import path from "path" -import { remark } from "remark" -import html from "remark-html" -import * as gfm from "remark-gfm" -import dynamic from "next/dynamic" -import React from "react" -import parse from "html-react-parser" - -const CopyableCode = dynamic(() => import("@/components/CopyableCode"), { ssr: false }) - -async function getCodeOfConductContent() { - 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 "

Error: CODE_OF_CONDUCT.md file not found.

"; - } - - 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 "

Error: Unable to load the Code of Conduct content.

"; - } -} - -function cleanInlineCode(content: string) { - return content.replace(/(.*?)<\/code>/g, (_, codeContent) => { - return `${codeContent.replace(/^`|`$/g, "")}` - }) -} - -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 - } - } - } - }) -} - -export default async function CodeOfConductPage() { - const codeOfConductContent = await getCodeOfConductContent() - const cleanedInlineCode = cleanInlineCode(codeOfConductContent) - const parsedContent = wrapCodeBlocksWithCopyable(cleanedInlineCode) - - return ( -
-
-
{parsedContent}
-
-
- ) -} \ No newline at end of file diff --git a/web/app/docs/about/contributors/page.tsx b/web/app/docs/about/contributors/page.tsx deleted file mode 100644 index 733f82de..00000000 --- a/web/app/docs/about/contributors/page.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { Users, FlaskRound, Youtube } from "lucide-react" - -export const metadata = { - title: "ProxMenux Contributors – Meet the Team Behind ProxMenux", - description: "Meet the contributors who make ProxMenux possible. Learn more about the developers, testers, and designers who have contributed to the project.", - openGraph: { - title: "ProxMenux Contributors – Meet the Team Behind ProxMenux", - description: "Meet the contributors who make ProxMenux possible. Learn more about the developers, testers, and designers who have contributed to the project.", - type: "article", - url: "https://macrimi.github.io/ProxMenux/docs/about/contributors", - images: [ - { - url: "https://macrimi.github.io/ProxMenux/contributors-image.png", - width: 1200, - height: 630, - alt: "ProxMenux Contributors", - }, - ], - }, - twitter: { - card: "summary_large_image", - title: "ProxMenux Contributors – Meet the Team Behind ProxMenux", - description: "Meet the contributors who make ProxMenux possible. Learn more about the developers, testers, and designers who have contributed to the project.", - images: ["https://macrimi.github.io/ProxMenux/contributors-image.png"], - }, -}; - - -const contributors = [ - { - name: "MALOW", - role: "Testing", - avatar: "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/avatars/malow.png", - }, - { - name: "Segarra", - role: "Testing", - avatar: "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/avatars/segarra.png", - }, - { - name: "Aprilia", - role: "Testing", - avatar: "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/avatars/aprilia.png", - }, - { - name: "Jonatan Castro", - role: "Testing and reviewer", - avatar: "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/avatars/jonatancastro.png", - youtubeUrl: "https://www.youtube.com/@JonatanCastro", - }, - { - name: "Kamunhas", - role: "Testing", - avatar: "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/avatars/Kamunhas.png", - }, -] - -export default function Contributors() { - return ( -
- {/* 🔹 Icon + Title */} -
- -

Contributors

-
- - {/* 🔹 Description */} -

- The ProxMenux project grows and thrives thanks to the contribution of its collaborators. -

-

This is the well-deserved recognition of their work:

- - {/* 🔹 Contributors List */} -
- {contributors.map((contributor) => ( -
-
- {contributor.name} -
- -
-
-

{contributor.name}

-

{contributor.role}

- {contributor.youtubeUrl && ( - - - YouTube - - )} -
- ))} -
- - {/* 🔹 Call to Action */} -

- Would you like to contribute? You can collaborate as a tester, developer,{" "} - designer, or by sharing ideas and suggestions. Any contribution is welcome! -

-
- ) -} diff --git a/web/app/docs/about/faq/page.tsx b/web/app/docs/about/faq/page.tsx deleted file mode 100644 index 48cf871b..00000000 --- a/web/app/docs/about/faq/page.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import type { Metadata } from "next"; -import { HelpCircle } from "lucide-react"; -import Link from "next/link"; - -export const metadata: Metadata = { - title: "ProxMenux FAQ – Frequently Asked Questions", - description: "Frequently Asked Questions about ProxMenux, including installation, updates, compatibility, and security.", - openGraph: { - title: "ProxMenux FAQ – Frequently Asked Questions", - description: "Frequently Asked Questions about ProxMenux, including installation, updates, compatibility, and security.", - type: "article", - url: "https://macrimi.github.io/ProxMenux/docs/faq", - images: [ - { - url: "https://macrimi.github.io/ProxMenux/faq-image.png", - width: 1200, - height: 630, - alt: "ProxMenux FAQ", - }, - ], - }, - twitter: { - card: "summary_large_image", - title: "ProxMenux FAQ – Frequently Asked Questions", - description: "Frequently Asked Questions about ProxMenux, including installation, updates, compatibility, and security.", - images: ["https://macrimi.github.io/ProxMenux/faq-image.png"], - }, -}; - -function StepNumber({ number }: { number: number }) { - return ( -
- {number} -
- ); -} - -export default function FaqPage() { - return ( -
-
- -

Frequently Asked Questions (FAQ)

-
- - {/* 1️⃣ What is ProxMenux? */} -

- - What is ProxMenux, and what is it used for? -

-

- ProxMenux is an interactive menu-driven tool designed to make Proxmox VE more accessible - to all users, regardless of their technical experience. It simplifies command execution, allowing users to perform - actions on their system without requiring advanced Linux knowledge. -

-

- For less experienced users, ProxMenux provides an intuitive way to run commands through a structured - menu interface, reducing the need for manual terminal input. -

-

- Proxmox VE is widely used for: -

-
    -
  • Enterprise-grade virtualization
  • -
  • HomeLab and personal cloud solutions
  • -
  • Multimedia servers, automation, and more
  • -
- - {/* 2️⃣ Installation */} -

- - How do I install ProxMenux? -

-

- Follow the instructions in the{" "} - - Installation Guide - . You can install ProxMenux by running: -

-
-        
-          bash -c "$(wget -qLO - https://raw.githubusercontent.com/MacRimi/ProxMenux/main/install_proxmenux.sh)"
-        
-      
-

Once installed, simply start it with:

-
-        menu
-      
- - {/* 3️⃣ Compatibility */} -

- - Is ProxMenux compatible with all Proxmox versions? -

-

- No, ProxMenux is only compatible with Proxmox VE 8 and later versions. -

- - {/* 4️⃣ Customization */} -

- - Can I customize ProxMenux? -

-

- The core scripts cannot be modified directly as they are hosted on GitHub. However, users can - personalize the console logo using the FastFetch tool available in the - Post-Install options. -

- - {/* 5️⃣ Updates */} -

- - How do I update ProxMenux? -

-

- When a new version is available, ProxMenux will automatically detect it upon launch and prompt - users to update. If accepted, the update process will replace utility files and configurations. -

- - {/* 6️⃣ Reporting Issues */} -

- - Where can I report issues? -

-

- If you encounter bugs or errors, report them in the{" "} - - Issues section - . -

-

- If you find a security issue, please do not publish it. - Instead, review the{" "} - - Code of Conduct & Best Practices - {" "} - for guidance on how to proceed. -

- - {/* 7️⃣ Contributing */} -

- - Can I contribute to ProxMenux? -

-

- Absolutely! -

-

- ProxMenux is an open-source and collaborative project where you can contribute by developing - new features, opening discussions, or sharing ideas and improvements. -

-

- Join the{" "} - - Discussions section - {" "} - to share ideas and propose enhancements. -

-

- Make sure to review the{" "} - - Code of Conduct & Best Practices - . -

-

- All ideas are welcome! -

- - {/* 8️⃣ Modifying System Files */} -

- - Does ProxMenux modify critical system files? -

-

- No, ProxMenux does not modify critical Proxmox system files. - It only installs dependencies such as whiptail, curl, - jq, and Python3, sets up a virtual environment for translations, - and downloads its scripts into /usr/local/share/proxmenux/. - The executable menu is placed in /usr/local/bin/. - ProxMenux does not interfere with Proxmox’s core operations. -

- - {/* 9️⃣ Production Use */} -

- - Is it safe to use ProxMenux in production? -

-

- Yes, ProxMenux is safe for production. - Since it does not modify core Proxmox files, it can be used in production environments. - However, it is always recommended to test it in a controlled environment first. -

- - {/* 🔟 Uninstallation */} -

- - How do I uninstall ProxMenux? -

-

- You can uninstall ProxMenux from the Settings menu using the Uninstall ProxMenux option. - Detailed steps can be found in the{" "} - - Uninstall Guide - . -

- -
- ); -} \ No newline at end of file diff --git a/web/app/docs/create-vm/page.tsx b/web/app/docs/create-vm/page.tsx deleted file mode 100644 index ebf98d73..00000000 --- a/web/app/docs/create-vm/page.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import type React from "react" -import type { Metadata } from "next" -import Link from "next/link" -import Image from "next/image" -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" -import { ArrowRight, Server, ComputerIcon as Windows, LaptopIcon as Linux, HardDrive, Monitor } from "lucide-react" - -export const metadata: Metadata = { - title: "ProxMenux Documentation: Virtual Machines", - description: - "Comprehensive guide for creating and configuring virtual machines on Proxmox VE using ProxMenux, with dedicated sections for NAS, Windows, and Linux systems.", - openGraph: { - title: "ProxMenux Documentation: Virtual Machines", - description: - "Comprehensive guide for creating and configuring virtual machines on Proxmox VE using ProxMenux, with dedicated sections for NAS, Windows, and Linux systems.", - type: "article", - url: "https://macrimi.github.io/ProxMenux/docs/virtual-machines", - images: [ - { - url: "https://macrimi.github.io/ProxMenux/vm/vm-creation-menu.png", - width: 1200, - height: 630, - alt: "ProxMenux Virtual Machines Menu", - }, - ], - }, - twitter: { - card: "summary_large_image", - title: "ProxMenux Documentation: Virtual Machines", - description: - "Comprehensive guide for creating and configuring virtual machines on Proxmox VE using ProxMenux, with dedicated sections for NAS, Windows, and Linux systems.", - images: ["https://macrimi.github.io/ProxMenux/vm/vm-creation-menu.png"], - }, -} - -interface ImageWithCaptionProps { - src: string - alt: string - caption: string - -} - -function ImageWithCaption({ src, alt, caption }: ImageWithCaptionProps) { - return ( -
-
- {alt} -
- {caption} -
- ) -} - -export default function VirtualMachinesPage() { - return ( -
-
-
- -

Virtual Machines Menu

-
- -
-

- ProxMenux provides an automated system for creating and configuring virtual machines on Proxmox VE through - an interactive menu interface. Select one of the categories below to explore the available VM creation - options. -

- -

- Each category contains specialized scripts and configurations designed to simplify the process of creating - virtual machines for different operating systems and use cases. This eliminates the need to remember complex - command syntax or manually configure VMs when deploying new systems. -

-
-
- - - -
- } - href="/docs/create-vm/system-nas" - /> - - } - href="/docs/create-vm/system-windows" - /> - - } - href="/docs/create-vm/system-linux" - /> - - } - href="/docs/create-vm/system-linux#other-linux-systems" - /> - - - } - href="https://osx-proxmox.com" - externalLink - /> -
-
- ) -} - -interface VMCardProps { - title: string - description: string - icon: React.ReactNode - href: string - externalLink?: boolean -} - -function VMCard({ title, description, icon, href, externalLink = false }: VMCardProps) { - return ( - - -
- {icon} - {title} -
-
- - {description} - - - {externalLink ? ( - - View details - - ) : ( - - View details - - )} - -
- ) -} diff --git a/web/app/docs/create-vm/synology/page.tsx b/web/app/docs/create-vm/synology/page.tsx deleted file mode 100644 index e1376895..00000000 --- a/web/app/docs/create-vm/synology/page.tsx +++ /dev/null @@ -1,836 +0,0 @@ -"use client" - -import Image from "next/image" -import { - Wrench, - Target, - CheckCircle, - Github, - Server, - HardDrive, - Download, - Settings, - Cpu, - Zap, - Sliders, - } from "lucide-react" -import { useState } from "react" - -export default function Page() { - const [activeLoader, setActiveLoader] = useState("arc") - - return ( -
-

Synology VM Creator Script

- -
-

- - Introduction -

-

- ProxMenux provides an automated script that creates and configures a virtual machine (VM) to install Synology - DSM (DiskStation Manager) on Proxmox VE. This script simplifies the process by downloading and adding one of - the available loaders to the VM boot, giving you the option between four different choices: -

- - -

The script simplifies the VM creation process by offering the following options:

-
    -
  • Selection of default or advanced configuration
  • -
  • Configuration of CPU, RAM, BIOS, and machine type
  • -
  • Choice between virtual disk or physical disk passthrough
  • -
- -
-

- - Default and Advanced Configuration -

-

The script offers two configuration modes:

- -

- - Default Configuration -

-

- If you select default configuration, the script will automatically apply the following values: -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterDefault Value
Machine Typeq35
BIOS TypeOVMF (UEFI)
CPU TypeHost
Core Count2
RAM Size4096 MB
Bridgevmbr0
MAC AddressAutomatically generated
Start VM on CompletionNo
-
-

- If you want to customize the configuration, select the Advanced Settings option in the menu. -

- -

- - Advanced Configuration -

-

- If you select advanced configuration, the script will allow you to customize each parameter: -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterOptions
Machine Typeq35 or i440fx
BIOS TypeOVMF (UEFI) or SeaBIOS (Legacy)
CPU TypeHost or KVM64
Core CountNumber of CPU cores
RAM SizeAmount of memory allocated to the VM
BridgeNetwork bridge for connection
MAC AddressCustom MAC address
VLANVLAN tag (if used)
MTUMaximum Transmission Unit size
-
-
- -
-

- - Disk Selection -

-

- Once the machine is configured, the script allows you to choose between two types of disks: -

- -

Virtual Disk

-
    -
  • The script lists the storage options available in Proxmox
  • -
  • The user selects the disk and size in GB
  • -
  • The virtual disk is automatically assigned to the VM. If more disks are configured, they will be added as SATA (e.g., sata0, sata1, etc.), up to a maximum of 6 virtual disks.
  • -
- -

Physical Disk Passthrough

-
    -
  • The script detects all available physical disks
  • -
  • The user selects the physical disk or disks they want to use.
  • -
  • The physical disk is directly assigned to the VM via passthrough. If more disks are configured, they will be added as SATA (e.g., sata0, sata1, etc.), up to a maximum of 6 physical disks.
  • -
-
- -
-

- - Loader Installation -

-

- The script automatically downloads and extracts the loader from the developer's repository. If the download fails, the script will display an error message. -

-

- AuxXxilium Arc, RedPill RR, and TinyCore RedPill M-shell. - Downloads and extracts automatically. -

-

- For Custom Loader, the script searches for files in /var/lib/vz/template/iso. - If multiple files are found, you will be prompted to select the desired file. -

-

-

You can upload custom loaders from the local storage options:

- - -
- - -
-

- - VM Creation -

-

Once the loader is downloaded, the script creates the VM using the following commands:

-
    -
  • - qm create – Creates the virtual machine with the configured parameters -
  • -
  • - qm importdisk – Imports the boot loader disk to the VM. For greater compatibility the loader is imported as an IDE disk -
  • -
  • - qm set – Assigns configuration values such as CPU, RAM, and storage -
  • -
  • - qm set -boot – Configures the boot order -
  • -
-
-
- -
-

- - Step-by-Step Boot Loader Configuration Guide -

-

- While all loaders share similarities, each one has its own structure and configuration methods. - This section provides a basic guide covering the 6 steps involved in setting up a Synology DSM loader. - The exact steps may vary depending on the loader and any changes introduced by the developer. - Therefore, understanding these common basic steps is crucial to correctly building and configuring - the loader of your choice for proper Synology DSM functionality. -

- - - {/* Selector de loader global */} -
-

Select your loader type:

-
- - - -
-
-
- -
-

- - Start the VM and Access the Main Menu -

-

- Once the VM is created, start it. The first time you boot the VM, you'll access the loader's main menu to select - and configure the DSM model you want to build. Once the loader is created, this step will be skipped unless you - manually force a reconfiguration from the boot monitor. All loaders also have the option to configure the loader - via a web interface. -

- -
- {activeLoader === "arc" && ( -
- -

- Web interface, To access the web interface, simply open a web browser and enter - the IP address shown in the VM's console output. For example, in our case: http://192.169.0.32. -

- - - -

- Terminal interface, Access it directly from the VM's console output. -

- - -
- )} - - {activeLoader === "rr" && ( -
- -

- Web interface, To access the web interface, simply open a web browser and enter - the IP address shown in the VM's console output, followed by port 7681. - For example, in our case: http://192.169.0.33:7681. -

- - - -

- Terminal interface, Access it directly from the VM's console output by typing menu.sh on the screen -

- - -
- )} - - {activeLoader === "tinycore" && ( -
- -

- Web interface, To access the web interface, simply open a web browser and enter - the IP address shown in the VM's console output, followed by port 7681. - For example, in our case: http://192.169.0.35:7681. -

- - - -

- Terminal interface, Access it directly from the VM's console output. Keep an eye on - the screen, as at some point it may prompt you to press a key to continue or ask if you want to change the language. -

- - -
- )} -
-
- -
-

- - Select Model -

-

- After loading the menu, select the Synology DSM model you want to install. Depending on the loader, you may - sometimes need to expand the options to see more models. -

- -
- {activeLoader === "arc" && ( -
- -
- )} - - {activeLoader === "rr" && ( -
- -
- )} - - {activeLoader === "tinycore" && ( -
- -
- )} -
-

In our example, we'll choose the SA6400 model.

-
- -
-

- - Select DSM Version -

-

- After selecting the model, you need to choose the DSM version you want to install. -

- -
- {activeLoader === "arc" && ( -
- - -
- )} - - {activeLoader === "rr" && ( -
- - - -
- )} - - {activeLoader === "tinycore" && ( -
- - -
- )} -
-
- -
-

- - Select Addons -

-

This step allows you to add additional features or custom configurations to the loader.

- -
- {activeLoader === "arc" && ( -
-

- Arc gives you the option to configure automatically or manually adjust the settings. - If automatic configuration is selected, the loader will start applying the necessary settings and will - automatically reboot once the process is complete. -

- -

- If we choose not to use automatic mode, we enter the menu to configure different options necessary for - the loader: -

- - - - -
- )} - - {activeLoader === "rr" && ( -
- - - -
- )} - - {activeLoader === "tinycore" && ( -
- - - - -
- )} -
-
- -
-

- - Build the Loader -

-

- Once you have selected the model, DSM version, and addons, proceed to build the loader. This process might - take a few minutes depending on the loader and the selected configuration. To start, select the "Build the - Loader" option. -

- -
- {activeLoader === "arc" && ( -
- -
- )} - - {activeLoader === "rr" && ( -
- -
- )} - - {activeLoader === "tinycore" && ( -
- -
- )} -
-
- -
-

- - Boot the Loader -

-

- Once the loader has been built, it will prompt you to boot. The VM will restart with the configuration you've - created and start the DSM installation. -

- -
- {activeLoader === "arc" && ( -
- -
- )} - - {activeLoader === "rr" && ( -
- -
- )} - - {activeLoader === "tinycore" && ( -
- -
- )} -
-
- - {/* STARTING DSM INSTALLATION */} -
-

- - Starting the DSM Installation -

-

Once the loader is booted, you can find your Synology device using:

-
- https://finds.synology.com -
-

Follow the on-screen steps to complete the DSM installation.

-
- -

- Please be patient – The process may take a few minutes to complete. The progress percentage will - update automatically as the setup progresses. A countdown will start once the installation - is nearing completion. -

- -
-
- -
-

- - Tips -

-
    -
  • - Keep in mind that available options may change depending on the loader version and developer updates. If you - encounter issues during the loader creation process, consult the loader documentation: -
  • - - - -
  • - Some older DSM models may have issues recognizing disks or the network card. It is recommended to use more - recent models. -
  • - -
    -

    Update:

    -

    - Some loaders offer the option to update the loader directly from the menu. -

    -
    - -
    -

    Important:

    -

    - ProxMenux does not provide support for the different loaders. -

    -
    - -
-
-
- ) -} - - - -function ImageWithCaption({ src, alt, caption }: { src: string; alt: string; caption: string }) { - return ( -
-
- {alt} -
- {caption} -
- ) -} - -function StepNumber({ number }: { number: number }) { - return ( - - ) -} - diff --git a/web/app/docs/create-vm/system-linux/page.tsx b/web/app/docs/create-vm/system-linux/page.tsx deleted file mode 100644 index c677e2bd..00000000 --- a/web/app/docs/create-vm/system-linux/page.tsx +++ /dev/null @@ -1,594 +0,0 @@ -"use client" - -import Image from "next/image" -import CopyableCode from "@/components/CopyableCode" -import { Monitor, Settings, Zap, Sliders, HardDrive, ExternalLink, FileCode, Server, Terminal, Cloud } from "lucide-react" - - -interface ImageWithCaptionProps { - src: string - alt: string - caption: string -} - -function ImageWithCaption({ src, alt, caption }: ImageWithCaptionProps) { - return ( -
-
- {alt} -
- {caption} -
- ) -} - -export default function LinuxVMContent() { - return ( -
-
-
- -

Linux VM Creator Script

-
- -
-

- ProxMenux provides automated scripts that create and configure Linux virtual machines on Proxmox VE. These - scripts simplify the process by handling the necessary configurations and optimizations for various Linux - distributions, including Ubuntu, Debian, Fedora, and many others. -

-
-
- - - -
-

Script Overview

-

- The Linux VM creation script automates the process of setting up virtual machines optimized for running Linux - operating systems. The script handles all aspects of VM configuration, including hardware allocation, disk - setup, and boot options. -

- -

The script simplifies the VM creation process by offering the following options:

-
    -
  • Selection of default or advanced configuration
  • -
  • Configuration of CPU, RAM, BIOS, and machine type
  • -
  • Choice between virtual disk or physical disk passthrough
  • -
  • Selection of disk interface type (SCSI, SATA, VirtIO, or IDE)
  • -
  • Automatic configuration of EFI for UEFI boot
  • -
  • Multiple installation methods: official ISOs, Cloud-Init, or local ISO
  • -
- -
-

- - Default and Advanced Configuration -

-

The script offers two configuration modes:

- -

- - Default Configuration -

-

- If you select default configuration, the script will automatically apply the following values: -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterDefault Value
Machine Typeq35
BIOS TypeOVMF (UEFI)
CPU TypeHost
Core Count2
RAM Size4096 MB
Bridgevmbr0
MAC AddressAutomatically generated
Start VM on CompletionNo
-
-

- If you want to customize the configuration, select the Advanced Settings option in the menu. -

- -

- - Advanced Configuration -

-

- If you select advanced configuration, the script will allow you to customize each parameter: -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterOptions
Machine Typeq35 or i440fx
BIOS TypeOVMF (UEFI) or SeaBIOS (Legacy)
CPU TypeHost or KVM64
Core CountNumber of CPU cores
RAM SizeAmount of memory allocated to the VM
BridgeNetwork bridge for connection
MAC AddressCustom MAC address
VLANVLAN tag (if used)
MTUMaximum Transmission Unit size
-
-
- -
-

- - Disk Interface Selection -

-

- The script allows you to choose the disk interface type for both virtual and physical disks: -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Interface TypeDescriptionBest For
SCSI - Modern interface with good performance and features - - Recommended for most Linux distributions (includes discard/trim support) -
SATAStandard interface with high compatibility - Good general-purpose choice (includes discard/trim support) -
VirtIO - Paravirtualized interface with highest performance - - Best performance for Linux (includes discard/trim support) -
IDELegacy interface with maximum compatibilityLegacy systems only (no discard/trim support)
-
-
- -
-

- - Disk Selection -

-

- Once the machine is configured, the script allows you to choose between two types of disks: -

- -

Virtual Disk

-
    -
  • The script lists the storage options available in Proxmox
  • -
  • The user selects the disk and size in GB
  • -
  • - The virtual disk is automatically assigned to the VM using the selected interface type (SCSI, SATA, - VirtIO, or IDE) -
  • -
  • - Multiple disks can be added and will be assigned sequential device numbers (e.g., scsi0, scsi1, etc.) -
  • -
- -

Physical Disk Passthrough

-
    -
  • The script detects all available physical disks
  • -
  • The user selects the physical disk or disks they want to use
  • -
  • - The physical disk is directly assigned to the VM via passthrough using the selected interface type (SCSI, - SATA, VirtIO, or IDE) -
  • -
  • - Multiple disks can be added and will be assigned sequential device numbers (e.g., scsi0, scsi1, etc.) -
  • -
-
- -
-

- - Additional Features -

- -

EFI Disk Configuration

-

- When UEFI BIOS (OVMF) is selected, the script automatically configures an EFI system disk: -

-
    -
  • You'll be prompted to select the storage location for the EFI disk
  • -
  • A 4MB EFI disk is created and attached to the VM
  • -
  • - The disk is formatted appropriately based on the selected storage backend (e.g., raw format for - directory-based storage) -
  • -
- -

ISO Mounting

-

The script also handles ISO mounting automatically:

-
    -
  • - The installation ISO is mounted to the first available IDE slot (typically ide2) -
  • -
  • For VirtIO disk interfaces, the VirtIO drivers ISO can be mounted if needed
  • -
- -

QEMU Guest Agent

-

The script automatically configures QEMU Guest Agent support:

-
    -
  • Enables the QEMU Guest Agent in the VM configuration
  • -
  • Sets up the necessary communication channel
  • -
  • Provides instructions for installing the guest agent inside the VM after installation
  • -
-
- -
-

Linux Installation Options

-

ProxMenux offers three methods for installing Linux on your virtual machine:

- -
-
-
- -

Official ISO Installation

-
- -

- This option allows you to install Linux using official distribution ISOs. ProxMenux provides a curated - list of popular Linux distributions that can be automatically downloaded and used for installation. -

- -

Available Distributions:

-
    -
  • Ubuntu (Desktop & Server)
  • -
  • Debian (Full & Netinst)
  • -
  • Fedora Workstation
  • -
  • Rocky Linux
  • -
  • Linux Mint
  • -
  • openSUSE Leap
  • -
  • Alpine Linux
  • -
  • Kali Linux
  • -
  • Manjaro
  • -
- -
- -
-
- -
-
- -

Cloud-Init Installation

-
- -

- This option uses Cloud-Init to automate the installation process. It's faster than traditional - installation and provides a pre-configured system ready to use. -

- -

Available Cloud-Init Images:

-
    -
  • Arch Linux
  • -
  • Debian 12
  • -
  • Ubuntu 22.04 LTS
  • -
  • Ubuntu 24.04 LTS
  • -
  • Ubuntu 24.10
  • -
- -
-
- -
External Scripts
-
-

- Cloud-Init installations use external helper scripts from the community. For more information, visit: -

- - community-scripts.github.io/ProxmoxVE - - -
-
- -
-
- -

Local ISO Installation

-
- -

- This option allows you to use your own Linux ISO file that's already uploaded to your Proxmox server's - local storage. Ideal if you have custom or specific Linux installation media. -

- -
- -
-
-
-
- -
-

Installation Process

-

- After configuring the VM settings and selecting your installation method, the script will: -

-
    -
  1. Create the VM with the specified configuration
  2. -
  3. Configure EFI disk if UEFI BIOS is selected
  4. -
  5. Create and attach virtual disks or pass through physical disks
  6. -
  7. Download and mount the Linux ISO (if using official distribution) or mount your local ISO
  8. -
  9. Set the boot order (disk first, then ISO)
  10. -
  11. Configure the QEMU Guest Agent
  12. -
  13. Start the VM if requested
  14. -
-
- -
-

- - Linux-Specific Tips -

- -
-
-

Installing QEMU Guest Agent

-

- For better integration with Proxmox, it's recommended to install the QEMU Guest Agent inside your Linux - VM. This enables features like proper shutdown, file system freeze for snapshots, and more accurate - memory reporting. -

- -
-

Installation commands by distribution:

-
-
- -

Debian / Ubuntu:

- -
- -
-

Fedora / CentOS / Rocky Linux:

- -
- -
-

Arch Linux:

- -
- -
-

openSUSE:

- -
-
-
-
- -
-

VirtIO Drivers in Linux

-

- Most modern Linux distributions include VirtIO drivers by default, which means you can use VirtIO disk - and network interfaces without additional configuration. This provides the best performance for your - Linux VM. -

- -
-

Note:

-

- If you're using an older Linux distribution (pre-2.6.25 kernel) and VirtIO disk interfaces, you might - need to load the VirtIO modules during installation. In such cases, you may need to provide a driver - disk or use SATA/SCSI interfaces instead. -

-
-
- -
-

Optimizing Linux VMs

-
    -
  • - Enable disk trim/discard: To enable TRIM support for better SSD performance, add the{" "} - discard mount option in{" "} - /etc/fstab for your partitions. -
  • -
  • - CPU type selection: For best performance, use the "host" CPU type which passes - through all CPU features from your host to the VM. -
  • -
  • - Memory ballooning: Enable memory ballooning to allow dynamic memory allocation. The - balloon driver is included in most Linux distributions. -
  • -
  • - Use VirtIO network interfaces: VirtIO network interfaces provide better performance - than emulated network cards. -
  • -
-
- -
-
- -
-

- Other Linux Systems -

-

- ProxMenux provides access to external community scripts that allow the creation of specialized Linux virtual machines for specific use cases: -

- - -
-
-
- -

Home Assistant OS VM (HAOS)

-
-

- Create a virtual machine that runs Home Assistant OS using a helper script from the community. Ideal for smart home automation. -

-
- -
-
- -

Docker VM (Debian + SSH + Docker)

-
-

- Deploy a lightweight Debian-based virtual machine with Docker and SSH pre-installed using an external script. -

-
-
- -
-
- -
External Scripts
-
-

- These installations are handled by community-maintained scripts. For more information or to contribute, visit: -

- - community-scripts.github.io/ProxmoxVE - - -
-
-
-
- ) -} diff --git a/web/app/docs/create-vm/system-nas/page.tsx b/web/app/docs/create-vm/system-nas/page.tsx deleted file mode 100644 index 716cde5f..00000000 --- a/web/app/docs/create-vm/system-nas/page.tsx +++ /dev/null @@ -1,312 +0,0 @@ -import type React from "react" -import type { Metadata } from "next" -import Link from "next/link" -import Image from "next/image" -import { HardDrive, Info, Database, Server, MonitorIcon, Star, Cpu, Github } from "lucide-react" -import { Badge } from "@/components/ui/badge" - -export const metadata: Metadata = { - title: "ProxMenux Documentation: System NAS Virtual Machines", - description: - "Guide for creating and configuring NAS virtual machines on Proxmox VE using ProxMenux, including Synology DSM, TrueNAS, and other storage systems.", - openGraph: { - title: "ProxMenux Documentation: System NAS Virtual Machines", - description: - "Guide for creating and configuring NAS virtual machines on Proxmox VE using ProxMenux, including Synology DSM, TrueNAS, and other storage systems.", - type: "article", - url: "https://macrimi.github.io/ProxMenux/docs/virtual-machines/system-nas", - images: [ - { - url: "https://macrimi.github.io/ProxMenux/vm/system-nas-menu.png", - width: 1200, - height: 630, - alt: "ProxMenux System NAS Menu", - }, - ], - }, - twitter: { - card: "summary_large_image", - title: "ProxMenux Documentation: System NAS Virtual Machines", - description: - "Guide for creating and configuring NAS virtual machines on Proxmox VE using ProxMenux, including Synology DSM, TrueNAS, and other storage systems.", - images: ["https://macrimi.github.io/ProxMenux/vm/system-nas-menu.png"], - }, -} - -interface ImageWithCaptionProps { - src: string - alt: string - caption: string -} - -function ImageWithCaption({ src, alt, caption }: ImageWithCaptionProps) { - return ( -
-
- {alt} -
- {caption} -
- ) -} - -interface NASSystemProps { - name: string - description: string - icon: React.ReactNode - features: string[] - technicalDetails: string[] - href: string - isExternal?: boolean - externalUrl?: string -} - -function NASSystemItem({ - name, - description, - icon, - features, - technicalDetails, - href, - isExternal, - externalUrl, -}: NASSystemProps) { - return ( -
-
-
- {isExternal && externalUrl ? ( - - {icon} -

{name}

-
- ) : ( - - {icon} -

{name}

- - )} - - {isExternal && ( -
- - - External Script - - -
- )} -
-
- -
-
-

{description}

- -
-
-

- - Key Features: -

-
    - {features.map((feature, index) => ( -
  • {feature}
  • - ))} -
-
- -
-

- - Technical Details: -

-
    - {technicalDetails.map((detail, index) => ( -
  • {detail}
  • - ))} -
-
-
-
-
-
- ) -} - -export default function SystemNASPage() { - return ( -
-
-
- -

System NAS Virtual Machines

-
- -
-

- ProxMenux provides automated scripts that create and configure virtual machines for various NAS systems on - Proxmox VE. These scripts simplify the process by handling the necessary configurations and optimizations - for each NAS platform. -

-
-
- - - -
-
- -

Available NAS Systems

-
-

- Select one of the NAS systems below to view detailed documentation on installation and configuration. -

-
- -
- } - features={[ - "User-friendly web interface", - "Extensive app ecosystem", - "File sharing and synchronization", - "Media streaming capabilities", - ]} - technicalDetails={[ - "Base OS: Linux (Custom)", - "File Systems: Btrfs, ext4", - "Virtualization: Yes (Docker)", - "Hardware Requirements: Moderate", - ]} - href="/docs/create-vm/synology" - /> - - } - features={[ - "Linux-based (Debian)", - "Docker container support", - "Kubernetes integration", - "Scale-out clustering capabilities", - ]} - technicalDetails={[ - "Base OS: Debian Linux", - "File System: ZFS", - "Virtualization: Yes (KVM)", - "Hardware Requirements: High", - ]} - href="/docs/create-vm/system-nas/system-nas-others" - /> - - } - features={["FreeBSD-based", "ZFS file system", "Snapshots and replication", "Encryption"]} - technicalDetails={[ - "Base OS: FreeBSD", - "File System: ZFS", - "Virtualization: Yes (Jails)", - "Hardware Requirements: Moderate to High", - ]} - href="/docs/create-vm/system-nas/system-nas-others" - /> - - } - features={[ - "Modular plugin architecture", - "Multiple filesystem support", - "Docker support via plugins", - "Low resource requirements", - ]} - technicalDetails={[ - "Base OS: Debian Linux", - "File Systems: ext4, XFS, Btrfs", - "Virtualization: Yes (via plugins)", - "Hardware Requirements: Low", - ]} - href="/docs/create-vm/system-nas/system-nas-others" - /> - - } - features={[ - "BTRFS file system", - "Web-based UI", - "Docker-based app framework (Rock-ons)", - "Snapshots and replication", - ]} - technicalDetails={[ - "Base OS: openSUSE based", - "File System: Btrfs", - "Virtualization: Yes (Docker)", - "Hardware Requirements: Moderate", - ]} - href="/docs/create-vm/system-nas/system-nas-others" - /> - - } - features={[ - "Low resource footprint", - "Docker support", - "Media streaming optimization", - "Home automation integration", - ]} - technicalDetails={[ - "Base OS: ROGGER proxmox-zimaos", - "File Systems: ext4, XFS", - "Virtualization: Yes (Docker)", - "Hardware Requirements: Low", - ]} - href="/docs/virtual-machines/system-nas/zimaos" - isExternal={true} - externalUrl="https://github.com/R0GGER/proxmox-zimaos" - /> -
- -
-

About NAS Virtual Machines

-
-

- Network Attached Storage (NAS) systems provide file-level data storage services to other devices on the - network. Running NAS software in a virtual machine on Proxmox VE allows you to leverage the reliability and - management features of Proxmox while providing flexible storage solutions. -

- -

- ProxMenux simplifies the creation of NAS virtual machines by automating the configuration process, including - network settings, storage allocation, and system optimization for each specific NAS platform. -

-
-
-
- ) -} diff --git a/web/app/docs/create-vm/system-nas/system-nas-others/page.tsx b/web/app/docs/create-vm/system-nas/system-nas-others/page.tsx deleted file mode 100644 index 88a859c6..00000000 --- a/web/app/docs/create-vm/system-nas/system-nas-others/page.tsx +++ /dev/null @@ -1,623 +0,0 @@ -import type { Metadata } from "next" -import Link from "next/link" -import Image from "next/image" -import { ArrowLeft, HardDrive, Settings, Zap, Sliders, Server, Database, ExternalLink } from "lucide-react" - -export const metadata: Metadata = { - title: "ProxMenux Documentation: Other NAS Systems VM Creation", - description: - "Guide for creating and configuring virtual machines for TrueNAS SCALE, TrueNAS CORE, OpenMediaVault, and Rockstor on Proxmox VE using ProxMenux.", - openGraph: { - title: "ProxMenux Documentation: Other NAS Systems VM Creation", - description: - "Guide for creating and configuring virtual machines for TrueNAS SCALE, TrueNAS CORE, OpenMediaVault, and Rockstor on Proxmox VE using ProxMenux.", - type: "article", - url: "https://macrimi.github.io/ProxMenux/docs/virtual-machines/system-nas/others", - images: [ - { - url: "https://macrimi.github.io/ProxMenux/vm/other-nas-systems.png", - width: 1200, - height: 630, - alt: "ProxMenux Other NAS Systems", - }, - ], - }, - twitter: { - card: "summary_large_image", - title: "ProxMenux Documentation: Other NAS Systems VM Creation", - description: - "Guide for creating and configuring virtual machines for TrueNAS SCALE, TrueNAS CORE, OpenMediaVault, and Rockstor on Proxmox VE using ProxMenux.", - images: ["https://macrimi.github.io/ProxMenux/vm/other-nas-systems.png"], - }, -} - -interface ImageWithCaptionProps { - src: string - alt: string - caption: string -} - -function ImageWithCaption({ src, alt, caption }: ImageWithCaptionProps) { - return ( -
-
- {alt} -
- {caption} -
- ) -} - -export default function OtherNASSystemsPage() { - return ( -
-
- -
- -

NAS Systems VM Creation

-
- -
-

- ProxMenux provides automated scripts that create and configure virtual machines for various NAS systems on - Proxmox VE. This documentation covers the VM creation process for TrueNAS SCALE, TrueNAS CORE, - OpenMediaVault, and Rockstor. -

-
-
- - -
-

Script Overview

-

- The VM creation script for NAS systems automates the process of setting up virtual machines optimized for - running various Network Attached Storage solutions. The script handles all aspects of VM configuration, - including hardware allocation, disk setup, and boot options. -

- -

The script simplifies the VM creation process by offering the following options:

-
    -
  • Selection of default or advanced configuration
  • -
  • Configuration of CPU, RAM, BIOS, and machine type
  • -
  • Choice between virtual disk or physical disk passthrough
  • -
  • Selection of disk interface type (SCSI, SATA, VirtIO, or IDE)
  • -
  • Automatic configuration of EFI and TPM when required
  • -
  • Automatic mounting of installation ISO images
  • -
- -
-

- - Default and Advanced Configuration -

-

The script offers two configuration modes:

- -

- - Default Configuration -

-

- If you select default configuration, the script will automatically apply the following values: -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterDefault Value
Machine Typeq35
BIOS TypeOVMF (UEFI)
CPU TypeHost
Core Count2
RAM Size8192 MB
Bridgevmbr0
MAC AddressAutomatically generated
Start VM on CompletionNo
-
-

- If you want to customize the configuration, select the Advanced Settings option in the menu. -

- -

- - Advanced Configuration -

-

- If you select advanced configuration, the script will allow you to customize each parameter: -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterOptions
Machine Typeq35 or i440fx
BIOS TypeOVMF (UEFI) or SeaBIOS (Legacy)
CPU TypeHost or KVM64
Core CountNumber of CPU cores
RAM SizeAmount of memory allocated to the VM
BridgeNetwork bridge for connection
MAC AddressCustom MAC address
VLANVLAN tag (if used)
MTUMaximum Transmission Unit size
-
-
- -
-

- - Disk Interface Selection -

-

- Unlike the Synology-specific script, this script allows you to choose the disk interface type for both - virtual and physical disks: -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Interface TypeDescriptionBest For
SCSI - Modern interface with good performance and features - - Recommended for Linux and Windows (includes discard/trim support) -
SATAStandard interface with high compatibility - Good general-purpose choice (includes discard/trim support) -
VirtIO - Paravirtualized interface with highest performance - - Advanced users seeking maximum performance (includes discard/trim support) -
IDELegacy interface with maximum compatibilityLegacy systems only (no discard/trim support)
-
-
- -
-

- - Disk Selection -

-

- Once the machine is configured, the script allows you to choose between two types of disks: -

- -

Virtual Disk

-
    -
  • The script lists the storage options available in Proxmox
  • -
  • The user selects the disk and size in GB
  • -
  • - The virtual disk is automatically assigned to the VM using the selected interface type (SCSI, SATA, - VirtIO, or IDE) -
  • -
  • - Multiple disks can be added and will be assigned sequential device numbers (e.g., scsi0, scsi1, etc.) -
  • -
- -

Physical Disk Passthrough

-
    -
  • The script detects all available physical disks
  • -
  • The user selects the physical disk or disks they want to use
  • -
  • - The physical disk is directly assigned to the VM via passthrough using the selected interface type (SCSI, - SATA, VirtIO, or IDE) -
  • -
  • - Multiple disks can be added and will be assigned sequential device numbers (e.g., scsi0, scsi1, etc.) -
  • -
-
- -
-

- - Additional Features -

- -

EFI Disk Configuration

-

When UEFI BIOS (OVMF) is selected, the script automatically configures an EFI disk:

-
    -
  • The script prompts for storage location for the EFI disk
  • -
  • A 4MB EFI disk is created and configured
  • -
  • - The EFI disk is properly formatted based on the storage type (raw format for directory-based storage) -
  • -
- -

ISO Mounting

-

The script handles ISO mounting for installation:

-
    -
  • The installation ISO is automatically mounted to the ide2 device
  • -
  • For Windows VMs, VirtIO driver ISO can be automatically downloaded and mounted to ide3
  • -
- -

QEMU Guest Agent

-

The script automatically configures QEMU Guest Agent support:

-
    -
  • Enables the QEMU Guest Agent in the VM configuration
  • -
  • Sets up the necessary communication channel
  • -
  • Provides instructions for installing the guest agent inside the VM after installation
  • -
-
- -
-

NAS-Specific Installation Notes

- -
-
-
- -

TrueNAS SCALE

-
-
    -
  • - Recommended interface: SATA or SCSI -
  • -
  • - Minimum RAM: 8GB (16GB+ recommended) -
  • -
  • - Minimum CPU cores: 2 (4+ recommended) -
  • -
  • UEFI boot is recommended
  • -
  • VirtIO network adapter provides best performance
  • -
-
- -
-
- -

TrueNAS CORE

-
-
    -
  • - Recommended interface: SATA -
  • -
  • - Minimum RAM: 8GB (16GB+ recommended) -
  • -
  • - Minimum CPU cores: 2 (4+ recommended) -
  • -
  • UEFI boot is recommended
  • -
  • VirtIO network adapter provides best performance
  • -
-
- -
-
- -

OpenMediaVault

-
-
    -
  • - Recommended interface: SATA or VirtIO -
  • -
  • - Minimum RAM: 2GB (4GB+ recommended) -
  • -
  • - Minimum CPU cores: 1 (2+ recommended) -
  • -
  • Both UEFI and Legacy BIOS are supported
  • -
  • VirtIO network adapter provides best performance
  • -
-
- -
-
- -

Rockstor

-
-
    -
  • - Recommended interface: SATA or VirtIO -
  • -
  • - Minimum RAM: 2GB (4GB+ recommended) -
  • -
  • - Minimum CPU cores: 2 -
  • -
  • UEFI boot is recommended
  • -
  • VirtIO network adapter provides best performance
  • -
-
-
-
- -
-

Installation Process

-

After configuring the VM settings and disk options, the script will:

-
    -
  1. Create the VM with the specified configuration
  2. -
  3. Configure EFI disk if UEFI BIOS is selected
  4. -
  5. Create and attach virtual disks or pass through physical disks
  6. -
  7. Mount the installation ISO
  8. -
  9. Set the boot order (disk first, then ISO)
  10. -
  11. Configure the QEMU Guest Agent
  12. -
  13. Generate a detailed HTML description for the VM
  14. -
  15. Start the VM if requested
  16. -
-

- Once the VM is created, you can proceed with the installation of your chosen NAS system by following the - on-screen instructions in the VM console. -

-
- -
-

NAS Systems Interfaces

-

- Below are screenshots of the shell and web interfaces for each NAS system after successful installation: -

- - {/* TrueNAS SCALE */} -
-

- - TrueNAS SCALE - - Official Website - -

-
-
-

Shell Interface

-
- TrueNAS SCALE Shell Interface -
-
-
-

Web Interface

-
- TrueNAS SCALE Web Interface -
-
-
-
- - {/* TrueNAS CORE */} -
-

- - TrueNAS CORE - - Official Website - -

-
-
-

Shell Interface

-
- TrueNAS CORE Shell Interface -
-
-
-

Web Interface

-
- TrueNAS CORE Web Interface -
-
-
-
- - {/* OpenMediaVault */} -
-

- - OpenMediaVault - - Official Website - -

-
-
-

Shell Interface

-
- OpenMediaVault Shell Interface -
-
-
-

Web Interface

-
- OpenMediaVault Web Interface -
-
-
-
- - {/* Rockstor */} -
-

- - Rockstor - - Official Website - -

-
-
-

Shell Interface

-
- Rockstor Shell Interface -
-
-
-

Web Interface

-
- Rockstor Web Interface -
-
-
-
-
-
-
- ) -} diff --git a/web/app/docs/create-vm/system-windows/page.tsx b/web/app/docs/create-vm/system-windows/page.tsx deleted file mode 100644 index 2bf745c5..00000000 --- a/web/app/docs/create-vm/system-windows/page.tsx +++ /dev/null @@ -1,570 +0,0 @@ -import type { Metadata } from "next" -import Link from "next/link" -import Image from "next/image" -import { ArrowLeft, Monitor, Settings, Zap, Sliders, HardDrive, ExternalLink, Server, Target } from "lucide-react" - -export const metadata: Metadata = { - title: "ProxMenux Documentation: Windows Virtual Machines", - description: - "Guide for creating and configuring Windows virtual machines on Proxmox VE using ProxMenux, including UUP Dump ISO and local ISO options.", - openGraph: { - title: "ProxMenux Documentation: Windows Virtual Machines", - description: - "Guide for creating and configuring Windows virtual machines on Proxmox VE using ProxMenux, including UUP Dump ISO and local ISO options.", - type: "article", - url: "https://macrimi.github.io/ProxMenux/docs/virtual-machines/windows", - images: [ - { - url: "https://macrimi.github.io/ProxMenux/vm/menu_windows.png", - width: 1200, - height: 630, - alt: "ProxMenux Windows VM Menu", - }, - ], - }, - twitter: { - card: "summary_large_image", - title: "ProxMenux Documentation: Windows Virtual Machines", - description: - "Guide for creating and configuring Windows virtual machines on Proxmox VE using ProxMenux, including UUP Dump ISO and local ISO options.", - images: ["https://macrimi.github.io/ProxMenux/vm/menu_windows.png"], - }, -} - -interface ImageWithCaptionProps { - src: string - alt: string - caption: string -} - -function ImageWithCaption({ src, alt, caption }: ImageWithCaptionProps) { - return ( -
-
- {alt} -
- {caption} -
- ) -} - -export default function WindowsVMPage() { - return ( -
-
- -
- -

Windows VM Creator Script

-
- -
-

- ProxMenux provides automated scripts that create and configure Windows virtual machines on Proxmox VE. These - scripts simplify the process by handling the necessary configurations and optimizations for Windows - installations, including VirtIO drivers setup and TPM configuration. -

-
-
- - - -
-

Script Overview

-

- The Windows VM creation script automates the process of setting up virtual machines optimized for running - Windows operating systems. The script handles all aspects of VM configuration, including hardware allocation, - disk setup, and boot options. -

- -

The script simplifies the VM creation process by offering the following options:

-
    -
  • Selection of default or advanced configuration
  • -
  • Configuration of CPU, RAM, BIOS, and machine type
  • -
  • Choice between virtual disk or physical disk passthrough
  • -
  • Selection of disk interface type (SCSI, SATA, VirtIO, or IDE)
  • -
  • Automatic configuration of EFI and TPM for secure boot
  • -
  • Automatic VirtIO drivers setup for optimal performance
  • -
- -
-

- - Default and Advanced Configuration -

-

The script offers two configuration modes:

- -

- - Default Configuration -

-

- If you select default configuration, the script will automatically apply the following values: -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterDefault Value
Machine Typeq35
BIOS TypeOVMF (UEFI)
CPU TypeHost
Core Count4
RAM Size8192 MB
Bridgevmbr0
MAC AddressAutomatically generated
TPMEnabled (v2.0)
Start VM on CompletionNo
-
-

- If you want to customize the configuration, select the Advanced Settings option in the menu. -

- -

- - Advanced Configuration -

-

- If you select advanced configuration, the script will allow you to customize each parameter: -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterOptions
Machine Typeq35 or i440fx
BIOS TypeOVMF (UEFI) or SeaBIOS (Legacy)
CPU TypeHost or KVM64
Core CountNumber of CPU cores
RAM SizeAmount of memory allocated to the VM
BridgeNetwork bridge for connection
MAC AddressCustom MAC address
VLANVLAN tag (if used)
MTUMaximum Transmission Unit size
TPMEnable or disable TPM
-
-
- -
-

- - Disk Interface Selection -

-

- The script allows you to choose the disk interface type for both virtual and physical disks: -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Interface TypeDescriptionBest For
SCSI - Modern interface with good performance and features - - Recommended for Windows 10/11 (includes discard/trim support) -
SATAStandard interface with high compatibility - Good general-purpose choice (includes discard/trim support) -
VirtIO - Paravirtualized interface with highest performance - - Advanced users seeking maximum performance (includes discard/trim support) -
IDELegacy interface with maximum compatibility - Legacy Windows systems only (no discard/trim support) -
-
-
- -
-

- - Disk Selection -

-

- Once the machine is configured, the script allows you to choose between two types of disks: -

- -

Virtual Disk

-
    -
  • The script lists the storage options available in Proxmox
  • -
  • The user selects the disk and size in GB
  • -
  • - The virtual disk is automatically assigned to the VM using the selected interface type (SCSI, SATA, - VirtIO, or IDE) -
  • -
  • - Multiple disks can be added and will be assigned sequential device numbers (e.g., scsi0, scsi1, etc.) -
  • -
- -

Physical Disk Passthrough

-
    -
  • The script detects all available physical disks
  • -
  • The user selects the physical disk or disks they want to use
  • -
  • - The physical disk is directly assigned to the VM via passthrough using the selected interface type (SCSI, - SATA, VirtIO, or IDE) -
  • -
  • - Multiple disks can be added and will be assigned sequential device numbers (e.g., scsi0, scsi1, etc.) -
  • -
-
- -
-

- - Additional Features -

- -

EFI Disk Configuration

-

- When UEFI BIOS (OVMF) is selected, the script automatically configures an EFI system disk to ensure compatibility with modern bootloaders: -

-
    -
  • You’ll be prompted to select the storage location for the EFI disk
  • -
  • A 4MB EFI disk is created and attached to the VM
  • -
  • The disk is formatted appropriately based on the selected storage backend (e.g., raw format for directory-based storage)
  • -
-

- For Windows systems, a TPM 2.0 device is also added automatically to meet installation requirements for modern versions like Windows 11 and Windows Server 2022. -

- -

ISO Mounting

-

- The script also handles ISO mounting automatically for both installation media and optional drivers: -

-
    -
  • The main installation ISO is mounted to the first available IDE slot (typically ide2)
  • -
  • If the system is Windows, the VirtIO drivers ISO is downloaded and mounted to the next IDE slot (typically ide3)
  • -
- - -

QEMU Guest Agent

-

The script automatically configures QEMU Guest Agent support:

-
    -
  • Enables the QEMU Guest Agent in the VM configuration
  • -
  • Sets up the necessary communication channel
  • -
  • Provides instructions for installing the guest agent inside the VM after installation
  • -
-
- -
-

Windows Installation Options

-

ProxMenux offers two methods for installing Windows on your virtual machine:

- -
-
-
-
- UUP Dump Logo -
-

Script UUP Dump ISO Creator

-
- -

- The UUP Dump ISO Creator script is a utility included in ProxMenux that allows you to - download and create Windows installation media directly from Microsoft's Windows Update servers. - This option provides access to the latest Windows builds, including Insider Preview versions. -

- -

Features:

-
    -
  • Access to the latest Windows builds
  • -
  • Ability to download Insider Preview versions
  • -
  • Clean, official Microsoft installation files
  • -
  • Automatic ISO creation and mounting
  • -
  • Support for various Windows editions (Home, Pro, Enterprise)
  • -
- -

- - Learn more about UUP Dump ISO Creator - -

-
- -
-
- -

Install with Local ISO

-
- -

- This option allows you to use your own Windows ISO file that's already uploaded to your Proxmox server's - local storage. Ideal if you have custom or specific Windows installation media. -

- -
- -
-
-
-
- -
-

Installation Process

-

- After configuring the VM settings and selecting your installation method, the script will: -

-
    -
  1. Create the VM with the specified configuration
  2. -
  3. Configure EFI disk and TPM for secure boot support
  4. -
  5. Create and attach virtual disks or pass through physical disks
  6. -
  7. Download and mount the Windows ISO (UUP Dump option) or mount your local ISO
  8. -
  9. Download and mount the VirtIO drivers ISO
  10. -
  11. Set the boot order (disk first, then ISO)
  12. -
  13. Configure the QEMU Guest Agent
  14. -
  15. Start the VM if requested
  16. -
-
- -
-

VirtIO Drivers Setup

-

- For optimal performance, Windows VMs require VirtIO drivers. The script automatically handles this by: -

-
    -
  • Downloading the latest VirtIO drivers ISO or using an existing one
  • -
  • Mounting the VirtIO drivers ISO to the VM
  • -
  • Providing instructions for loading the drivers during Windows installation
  • -
-

- If you select a SCSI or VirtIO disk interface for the virtual machine, - Windows installation will not detect the disk by default. In this case, you must click "Load Driver" during the disk selection - step and browse to the mounted VirtIO ISO to install the necessary storage drivers. -
- These interfaces offer significantly better performance compared to traditional SATA disks, - and are therefore recommended for optimal disk I/O. -

-
- -
-

- - Tips -

-
    - -
  • - If you select VirtIO as the network interface (recommended for performance), - you must also install the VirtIO network drivers from the same ISO. This ensures that the Windows installer can access - the network to complete updates or activate the system. -
  • - -
    -

    Important:

    -

    - Without the VirtIO network driver, the virtual machine will not have internet access during installation, - which may prevent Windows from completing activation or downloading necessary updates. -

    -
    -
-
- - -
- {/* Step 1 */} -
-

- Step 1: Access the "Where do you want to install Windows?" screen -

-

- During Windows installation, if no disks are shown on the “Where do you want to install Windows?” screen, it means the required storage drivers for your selected disk interface (such as SCSI or VirtIO) are not available. You'll need to load them manually. -

- -
- - {/* Step 2 */} -
-

Step 2: Click "Load driver"

-

- Click the “Load driver” button to browse the mounted VirtIO ISO. This will allow you to load the necessary storage drivers so Windows can detect the virtual disk. -

- -
- - {/* Step 3 */} -
-

Step 3: Browse to the correct driver location

-

- On the mounted VirtIO ISO, navigate to the appropriate driver folder that matches your selected disk interface and Windows version. - For example, the viostor folder contains storage drivers, and you'll find subfolders organized by version (e.g., Windows 10, 11, Server). -

- -
- - {/* Step 4 */} -
-

Step 4: Select the appropriate driver

-

- After selecting the folder, Windows will list the available drivers. Choose the appropriate one — usually “Red Hat VirtIO SCSI controller” — and click “Next” to proceed with the installation. -

- -
- - {/* Step 5 */} -
-

Step 5: Install network drivers (recommended)

-

- Pro Tip: If you selected VirtIO as the network interface, Windows will not recognize it by default. To enable internet access during installation, load the VirtIO network driver from the ISO by browsing to the NetKVM folder and selecting the correct subfolder for your Windows version. -

- -
- - - {/* Post-installation block */} -
-

Post-Installation Driver Setup

-

- After the Windows installation is complete, it's recommended to install the remaining VirtIO drivers for full hardware support and optimal performance. - To do this, open the mounted VirtIO ISO in File Explorer and run the installer named{" "} - virtio-win-guest-tools.exe. This will install drivers for network, display, input, ballooning, and other virtualized components. -

-
-
- - -
-
- ) -} diff --git a/web/app/docs/external-repositories/page.tsx b/web/app/docs/external-repositories/page.tsx deleted file mode 100644 index c2b84086..00000000 --- a/web/app/docs/external-repositories/page.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import type { Metadata } from "next" -import { Link2 } from "lucide-react" -import Link from "next/link" - -export const metadata: Metadata = { - title: "ProxMenux - External Repositories", - description: - "Learn about the external repositories used in ProxMenux, how they are selected, and how to report issues or suggest new integrations.", - openGraph: { - title: "ProxMenux - External Repositories", - description: - "Learn about the external repositories used in ProxMenux, how they are selected, and how to report issues or suggest new integrations.", - type: "article", - url: "https://macrimi.github.io/ProxMenux/docs/external-repositories", - images: [ - { - url: "https://macrimi.github.io/ProxMenux/external-repos-image.png", - width: 1200, - height: 630, - alt: "ProxMenux External Repositories", - }, - ], - }, - twitter: { - card: "summary_large_image", - title: "ProxMenux - External Repositories", - description: - "Learn about the external repositories used in ProxMenux, how they are selected, and how to report issues or suggest new integrations.", - images: ["https://macrimi.github.io/ProxMenux/external-repos-image.png"], - }, -} - -function SectionHeader({ number, title }: { number: number; title: string }) { - return ( -

-
- {number} -
- {title} -

- ) -} - -export default function ExternalRepositoriesPage() { - return ( -
-
- -

External Repositories

-
- - {/* Introduction */} -

- ProxMenux integrates with selected external repositories to provide alternative scripts for various - functionalities. These scripts come from trusted sources and serve as additional options in - some menu sections. -

-

- When an external script is available as an alternative, ProxMenux will clearly indicate that it originates from - an external repository and specify which one. -

- - {/* 1️⃣ Example of External Repository */} - -

Essential repositories for Proxmox VE users include:

-

- - Proxmox VE Helper-Scripts - {" "} - - A highly recommended repository that provides additional tools and utilities for managing Proxmox VE more - efficiently. -

-

- - Proxmox ZimaOS - {" "} - - Script para instalar una VM del sistema NAS ZimaOS en menos de 5 minutos. -

- - {/* 2️⃣ Attribution & Recognition */} - -
    -
  • Credit is always given to the original authors.
  • -
  • A link to the source repository is provided.
  • -
  • Users are encouraged to support the developers of these external projects.
  • -
- - {/* 3️⃣ Reporting Issues with External Scripts */} - -

- If you encounter an issue with an external script,{" "} - please report it directly to the original repository instead of opening an issue in the - ProxMenux repository. -

-

- ProxMenux does not modify external scripts; it simply provides a link to the original source. - Therefore, any problems related to functionality should be reported to the respective developers. -

- - {/* 4️⃣ Suggesting New External Repositories */} - -

- If you know of a script or repository that could enhance ProxMenux, feel free to suggest it by opening a - discussion or issue in our GitHub repository. -

-

- 🔗{" "} - - Open a Discussion - {" "} - |{" "} - - Report an Issue - -

-
- ) -} diff --git a/web/app/docs/hardware/coral-tpu-lxc/page.tsx b/web/app/docs/hardware/coral-tpu-lxc/page.tsx deleted file mode 100644 index 3057f102..00000000 --- a/web/app/docs/hardware/coral-tpu-lxc/page.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { Steps } from "@/components/ui/steps" -import CopyableCode from "@/components/CopyableCode" -import Image from "next/image" - -export const metadata = { - title: "Enable Coral TPU in LXC | ProxMenux Documentation", - description: "Step-by-step guide to enable Google Coral TPU support in an LXC container using ProxMenux.", -} - -export default function CoralTPULXC() { - return ( -
-

Enable Coral TPU in an LXC

- -

- This guide explains how to configure Google Coral TPU support for LXC containers in Proxmox VE using ProxMenux. - Coral TPU provides dedicated AI acceleration, improving inference performance for machine learning applications. It is particularly useful for video surveillance applications with real-time video analysis, such as Frigate or Agent DVR or Blue Iris using CodeProject.AI. -

- -

Overview

-

The script automates the complete configuration of Coral TPU support in LXC containers, including USB and M.2 variants. It applies Proxmox-specific container settings, manages device passthrough permissions, and installs required drivers both on the host and inside the container.

-

The USB variant uses a persistent mapping based on /dev/coral via udev rules, avoiding reliance on dynamic USB paths like /dev/bus/usb/*. This ensures consistent device assignment across reboots and hardware reordering.

-

The M.2 version is detected automatically and configured only if present.

- -

Implementation Steps

- - -

The script lists available LXC containers and prompts for selection.

-
- -

The script applies necessary changes to enable Coral TPU:

-
    -
  • Switches the container to privileged mode if required.
  • -
  • Enables nesting to allow GPU and TPU usage.
  • -
  • Sets device permissions for TPU and iGPU.
  • -
  • Configures proper device mounts.
  • -
- - -
- -

The script installs the necessary components inside the container:

-
    -
  • GPU drivers:
  • -
      -
    • va-driver-all
    • -
    • ocl-icd-libopencl1
    • -
    • intel-opencl-icd
    • -
    • vainfo
    • -
    • intel-gpu-tools
    • -
    -
  • Coral TPU dependencies:
  • -
      -
    • python3
    • -
    • python3-pip
    • -
    • python3-venv
    • -
    • gnupg
    • -
    • curl
    • -
    -
  • Coral TPU drivers:
  • -
      -
    • libedgetpu1-std (standard performance)
    • -
    • libedgetpu1-max (maximum performance, optional)
    • -
    -
-
- -

If a Coral M.2 device is detected, the script prompts the user to select:

-
    -
  • Standard mode - balanced performance.
  • -
  • Maximum performance mode - higher speed, increased power usage.
  • -
-
-
- -

Expected Results

-
    -
  • The selected container is correctly configured for TPU and iGPU usage.
  • -
  • Required drivers and dependencies are installed inside the container.
  • -
  • The container will restart as needed during the process.
  • -
  • After completion, applications inside the container can utilize Coral TPU acceleration.
  • -
- -

Important Considerations

-
    -
  • The script supports both USB and M.2 Coral TPU devices.
  • -
  • The Proxmox host must have the required Coral TPU and Intel GPU drivers installed.
  • -
  • Additional application-specific configurations may be required inside the container.
  • -
  • Coral USB passthrough uses a persistent device alias /dev/coral created by a udev rule. This improves stability and avoids issues with changing USB port identifiers.
  • -
  • Coral M.2 devices are detected dynamically using lspci and configured only if present.
  • -
-
- ) -} diff --git a/web/app/docs/hardware/igpu-acceleration-lxc/page.tsx b/web/app/docs/hardware/igpu-acceleration-lxc/page.tsx deleted file mode 100644 index d16fb43b..00000000 --- a/web/app/docs/hardware/igpu-acceleration-lxc/page.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Steps } from "@/components/ui/steps" -import CopyableCode from "@/components/CopyableCode" -import Image from "next/image" - -export const metadata = { - title: "Enable iGPU Acceleration in LXC | ProxMenux Documentation", - description: "Step-by-step guide to enable Intel iGPU acceleration in an LXC container using ProxMenux.", -} - -export default function IGPUAccelerationLXC() { - return ( -
-

Enable Intel iGPU Acceleration in an LXC

- -

- This guide explains how to configure Intel Integrated GPU (iGPU) acceleration for LXC containers in Proxmox VE - using ProxMenux. Enabling iGPU support allows containers to use the host’s GPU for hardware acceleration - in applications such as video transcoding and rendering. -

- -

Overview of the Process

-

When you run this script in ProxMenux, it performs the following steps:

-
    -
  1. Prompts you to select an existing LXC container.
  2. -
  3. Checks if the container is privileged and adjusts its settings accordingly.
  4. -
  5. Modifies the container’s configuration to allow GPU access.
  6. -
  7. Installs the required Intel GPU drivers inside the container.
  8. -
- -

Step-by-Step Guide

- - -

You will be presented with a list of your LXC containers to choose from.

- Select LXC Container -
- -

The script applies the following changes to your container:

-
    -
  • Switches to privileged mode if required.
  • -
  • Enables the nesting feature.
  • -
  • Grants permissions for GPU access.
  • -
  • Configures necessary device mounts.
  • -
-
- -

Inside the container, the following GPU-related packages will be installed:

-
    -
  • va-driver-all - Video acceleration drivers
  • -
  • ocl-icd-libopencl1 - OpenCL runtime
  • -
  • intel-opencl-icd - Intel OpenCL implementation
  • -
  • vainfo - Tool to verify VAAPI support
  • -
  • intel-gpu-tools - Intel GPU debugging tools
  • -
-
-
- -

Expected Outcome

-
    -
  • Your LXC container will be configured for Intel iGPU acceleration.
  • -
  • The required GPU drivers and tools will be installed inside the container.
  • -
  • The container will briefly stop and restart as part of the setup.
  • -
  • After completion, applications inside the container will be able to leverage the GPU for acceleration.
  • -
- -

Important Notes

-
    -
  • This script is designed specifically for Intel iGPUs.
  • -
  • Some applications inside the container may need additional setup to use the GPU.
  • -
- -
- ) -} diff --git a/web/app/docs/hardware/install-coral-tpu-host/page.tsx b/web/app/docs/hardware/install-coral-tpu-host/page.tsx deleted file mode 100644 index a6990ddb..00000000 --- a/web/app/docs/hardware/install-coral-tpu-host/page.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { Steps } from "@/components/ui/steps" -import CopyableCode from "@/components/CopyableCode" - -export const metadata = { - title: "Install Coral TPU on the Host | ProxMenux Documentation", - description: "Step-by-step guide to install Google Coral TPU drivers on a Proxmox VE host using ProxMenux.", -} - -export default function InstallCoralTPUHost() { - return ( -
-

Install Coral TPU on the Host

- -

- Before using Coral TPU inside an LXC container, the drivers must first be installed on the Proxmox VE host. This script automates that process, ensuring the necessary setup is completed. -

- This guide explains how to install and configure Google Coral TPU drivers on a Proxmox VE host using ProxMenux. This setup enables hardware acceleration for AI-based applications that leverage Coral TPU. -

- -

Overview

-

The script automates the following steps:

-
    -
  1. Prompts for confirmation before proceeding with installation.
  2. -
  3. Verifies and configures necessary repositories on the host.
  4. -
  5. Installs required build dependencies and kernel headers for driver compilation.
  6. -
  7. Clones the Coral TPU driver repository and builds the drivers.
  8. -
  9. Installs the compiled Coral TPU drivers.
  10. -
  11. Prompts for a system restart to apply changes.
  12. -
- -

Implementation Steps

- - -

The script prompts the user for confirmation before proceeding, as a system restart is required after installation.

-
- - -

The script verifies and configures required repositories:

-
    -
  • Adds the pve-no-subscription repository if not present.
  • -
  • Adds non-free-firmware repositories for required packages.
  • -
  • Runs apt-get update to fetch the latest package lists.
  • -
-
- - -

The script installs and compiles the required Coral TPU drivers:

-
    -
  • Installs the following packages:
  • -
      -
    • git
    • -
    • devscripts
    • -
    • dh-dkms
    • -
    • dkms
    • -
    • pve-headers-$(uname -r) (Proxmox kernel headers)
    • -
    -
  • Clones the Coral TPU driver source from:
  • -
      -
    • https://github.com/google/gasket-driver
    • -
    -
  • Builds the driver using debuild and installs it using dpkg -i.
  • -
- - -
- - -

The script prompts the user to restart the server to apply the changes.

-
-
- -

Expected Results

-
    -
  • The Coral TPU drivers are installed successfully on the Proxmox VE host.
  • -
  • Required repositories and dependencies are configured properly.
  • -
  • A system restart is performed to complete the installation.
  • -
-
- ) -} diff --git a/web/app/docs/help-info/backup-commands/page.tsx b/web/app/docs/help-info/backup-commands/page.tsx deleted file mode 100644 index bae161e7..00000000 --- a/web/app/docs/help-info/backup-commands/page.tsx +++ /dev/null @@ -1,261 +0,0 @@ -"use client" - -import React, { useState } from "react" -import Link from "next/link" -import { Archive, ArrowLeft, Copy, Check, Terminal } from "lucide-react" -import { Button } from "@/components/ui/button" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" - -export default function BackupRestorePage() { - const [viewMode, setViewMode] = useState<"table" | "accordion">("table") - - // Group commands by category for better organization - const commandGroups = [ - { - title: "VM Backup", - commands: [ - { command: "vzdump ", description: "Create a backup of a specific VM/CT" }, - { command: "vzdump --storage ", description: "Backup VM to specific storage" }, - { command: "vzdump --mode snapshot", description: "Create snapshot backup (for VMs)" }, - { command: "vzdump --mode suspend", description: "Suspend VM during backup" }, - { command: "vzdump --mode stop", description: "Stop VM during backup" }, - { command: "vzdump --all", description: "Backup all VMs and containers" }, - { command: "vzdump --exclude ,", description: "Backup all except specified VMs" }, - ], - }, - { - title: "Backup Options", - commands: [ - { command: "vzdump --compress zstd", description: "Use zstd compression for backup" }, - { command: "vzdump --pigz ", description: "Use pigz with multiple threads" }, - { command: "vzdump --notes ", description: "Add notes to backup" }, - { command: "vzdump --mailto ", description: "Send notification email" }, - { command: "vzdump --maxfiles ", description: "Keep only n backups per VM" }, - { command: "vzdump --stdexcludes 0", description: "Don't exclude temporary files" }, - { command: "vzdump --quiet 1", description: "Suppress output messages" }, - ], - }, - { - title: "Restore Backups", - commands: [ - { command: "qmrestore ", description: "Restore VM from backup" }, - { command: "qmrestore --storage ", description: "Restore to specific storage" }, - { command: "qmrestore --unique", description: "Create a VM with unique ID" }, - { command: "pct restore ", description: "Restore container from backup" }, - { - command: "pct restore --storage ", - description: "Restore container to specific storage", - }, - { command: "pct restore --rootfs ", description: "Restore to specific rootfs" }, - { command: "pct restore --unprivileged 1", description: "Restore as unprivileged CT" }, - ], - }, - { - title: "Backup Management", - commands: [ - { command: "ls -la /var/lib/vz/dump/", description: "List backups in default location" }, - { command: 'find /var/lib/vz/dump/ -name "*.vma*"', description: "Find VM backups" }, - { command: 'find /var/lib/vz/dump/ -name "*.tar*"', description: "Find container backups" }, - { command: "pvesm list ", description: "List backups in specific storage" }, - { command: "rm /var/lib/vz/dump/", description: "Delete a backup file" }, - { command: "cat /etc/vzdump.conf", description: "Show backup configuration" }, - ], - }, - { - title: "Scheduled Backups", - commands: [ - { command: "cat /etc/cron.d/vzdump", description: "Show backup schedule" }, - { command: "nano /etc/vzdump.conf", description: "Edit backup configuration" }, - { command: "systemctl list-timers", description: "List all scheduled tasks" }, - { command: "systemctl status cron", description: "Check cron service status" }, - { command: "grep vzdump /var/log/syslog", description: "Check backup logs in syslog" }, - { command: "tail -f /var/log/vzdump.log", description: "Monitor backup log in real-time" }, - ], - }, - { - title: "Advanced Operations", - commands: [ - { command: "qmrestore --force", description: "Force restore, overwriting existing VM" }, - { command: "vzdump --dumpdir ", description: "Specify custom backup directory" }, - { command: "vzdump --script