mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-07-26 18:38:30 +00:00
complete i18n migration to /[locale]/ with EN+ES content
Full rewrite of the docs site under app/[locale]/ with next-intl in localePrefix:"always" mode. Every page now exists at both /en/<path> and /es/<path>; the root / shows a meta-refresh + JS redirect to /<defaultLocale>/ so GitHub Pages serves something on the apex URL. Highlights: - 107 doc pages migrated to file-per-page JSON namespaces under messages/en/ and messages/es/. Spanish content is fully translated (no copy-of-English placeholders). - New documentation for the Active Suppressions section in the Settings tab and the per-event Dismiss dropdown in the Health Monitor modal. - New screenshots: dismiss-duration-dropdown.png and an updated health-suppression-settings.png. - Pagefind integrated for client-side search; index is built on every CI deploy (not committed). - RSS feeds: per-locale at /<locale>/rss.xml plus root /rss.xml for backward compat. - Removed the dead app/[locale]/guides/[slug]/ route — every guide now has its own static page and no markdown source remains. - Fixed orphan link /guides/nvidia -> /guides/nvidia-manual in docs/hardware/nvidia-host. - Removed obsolete components (footer2, calendar, drawer). Verified locally with `npm ci && npm run build`: 2804 files in out/, 231 pages indexed by pagefind, root redirect intact, both locale roots and the new Active Suppressions docs render OK.
This commit is contained in:
1
web/.npmrc
Normal file
1
web/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
legacy-peer-deps=true
|
||||
329
web/CONTRIBUTING-TRANSLATIONS.md
Normal file
329
web/CONTRIBUTING-TRANSLATIONS.md
Normal file
@@ -0,0 +1,329 @@
|
||||
# Contributing translations
|
||||
|
||||
The ProxMenux documentation site is built with Next.js (App Router) and
|
||||
serves every page under two URLs:
|
||||
|
||||
- `/en/<path>` — English, the source of truth
|
||||
- `/es/<path>` — Spanish, in progress
|
||||
|
||||
We use [`next-intl`](https://next-intl.dev) for the i18n plumbing. Anyone
|
||||
can translate the docs without writing TypeScript: most of the work is
|
||||
filling in a JSON file. This guide explains the workflow end to end.
|
||||
|
||||
> **Default policy: small, focused PRs.** One page per pull request. Big
|
||||
> bundles of "I translated 30 pages at once" are hard to review and
|
||||
> merge cleanly when several contributors are working in parallel.
|
||||
|
||||
---
|
||||
|
||||
## What's already wired
|
||||
|
||||
Out of the box you get:
|
||||
|
||||
- Routing under `app/[locale]/...` — every page already renders at both
|
||||
`/en/...` and `/es/...`.
|
||||
- Locale-aware navigation via `@/i18n/navigation` (`<Link>`, `useRouter`,
|
||||
`usePathname`). Use these instead of `next/link` for internal hrefs so
|
||||
the active `[locale]` prefix is preserved.
|
||||
- A language switcher in the navbar (`<LanguageSwitcher />`).
|
||||
- Automatic message discovery: any JSON file under
|
||||
`messages/<locale>/...` is loaded and merged into a single namespace
|
||||
tree. **You never need to register a new file anywhere**, the build
|
||||
picks it up automatically.
|
||||
- Fallback to English when a translation is missing — pages render in
|
||||
English instead of breaking with a `MISSING_MESSAGE` placeholder.
|
||||
|
||||
---
|
||||
|
||||
## File layout
|
||||
|
||||
```
|
||||
web/
|
||||
├── i18n/
|
||||
│ ├── routing.ts # supported locales + default
|
||||
│ ├── request.ts # per-request config (uses loadMessages)
|
||||
│ ├── loadMessages.ts # walks messages/<locale>/ and builds the tree
|
||||
│ └── navigation.ts # locale-aware Link, useRouter, etc.
|
||||
├── messages/
|
||||
│ ├── en/
|
||||
│ │ ├── common.json # shared strings (nav, footer, language switcher)
|
||||
│ │ └── docs/
|
||||
│ │ └── monitor/
|
||||
│ │ └── index.json # page-specific strings for /docs/monitor
|
||||
│ └── es/
|
||||
│ ├── common.json
|
||||
│ └── docs/
|
||||
│ └── monitor/
|
||||
│ └── index.json
|
||||
└── app/[locale]/
|
||||
└── docs/
|
||||
└── monitor/
|
||||
└── page.tsx # the page itself
|
||||
```
|
||||
|
||||
### Naming convention
|
||||
|
||||
- `common.json` and `index.json` at any folder level → keys are merged
|
||||
at the current namespace level (no extra nesting). Use these for
|
||||
"this whole folder" or "this whole section" defaults.
|
||||
- `<name>.json` at any folder level → keys go under the `<name>`
|
||||
namespace.
|
||||
- Sub-directories nest as additional namespaces, with **kebab-case
|
||||
converted to camelCase** in the JS API. So
|
||||
`messages/en/docs/monitor/access-auth.json` is consumed as
|
||||
`getTranslations({ namespace: 'docs.monitor.accessAuth' })`.
|
||||
|
||||
---
|
||||
|
||||
## Workflow: translate one page
|
||||
|
||||
### 1. Pick a page
|
||||
|
||||
Browse `app/[locale]/docs/` and find a page that:
|
||||
|
||||
- Has no entry yet under `messages/es/<same-path>/` (Spanish), **and**
|
||||
- Is not already mid-translation by someone else (check open PRs).
|
||||
|
||||
If you're translating to a new locale, start with the smallest pages so
|
||||
you can submit early PRs and get feedback before tackling the big ones.
|
||||
|
||||
### 2. Check whether the page is already i18n-ready
|
||||
|
||||
There are two cases:
|
||||
|
||||
**Case A — the page already uses `getTranslations()`** (look for an
|
||||
`import` from `next-intl/server` and `t()` calls in JSX). Your job is
|
||||
straightforward: only create the `messages/<locale>/<path>.json` file
|
||||
with the translated strings. **You don't touch the `.tsx` file at all**.
|
||||
|
||||
**Case B — the page still has hard-coded English strings in JSX.** You
|
||||
need both:
|
||||
|
||||
1. Refactor the page to read its strings from a JSON file (this part
|
||||
touches the `.tsx`).
|
||||
2. Provide the English JSON (the source of truth) **and** the
|
||||
translated JSON.
|
||||
|
||||
The pilot page `app/[locale]/docs/monitor/page.tsx` is the reference
|
||||
implementation for case B — copy its patterns.
|
||||
|
||||
### 3. Add the JSON
|
||||
|
||||
Create `messages/<locale>/<same-path-as-page>.json` (or `index.json` if
|
||||
the page is the section index). Mirror the English file's structure
|
||||
exactly — every key must exist in both, only the values change.
|
||||
|
||||
If a key contains inline HTML-style tags (`<code>`, `<strong>`,
|
||||
`<em>`, `<link>`, `<linkApi>`, etc.), keep them in the same positions
|
||||
in your translation. They're not real HTML — they're placeholders that
|
||||
the page renders via `t.rich()` and substitutes for real React nodes.
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"intro": "Eight first-class sections, backed by their own API endpoints."
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"intro": "Ocho secciones principales, respaldadas por sus propios endpoints de API."
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"footer": "See the <link>Architecture</link> page for details."
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"footer": "Mira la página de <link>Architecture</link> para más detalles."
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Test locally
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open `http://localhost:3000/<locale>/<page-path>` and check that:
|
||||
|
||||
- All your translated strings render correctly.
|
||||
- No `MISSING_MESSAGE` text appears (means a key is in the page's
|
||||
`.tsx` but missing from your JSON).
|
||||
- The page still passes `npm run build` cleanly.
|
||||
|
||||
### 5. Open the PR
|
||||
|
||||
One page per PR is the convention. Title format:
|
||||
|
||||
```
|
||||
docs(i18n/<locale>): translate <route>
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
- `docs(i18n/es): translate /docs/monitor`
|
||||
- `docs(i18n/fr): translate /docs/monitor/notifications`
|
||||
|
||||
In the description, mention which page you translated and whether you
|
||||
also had to refactor the `.tsx` (case B) or only added JSON (case A).
|
||||
|
||||
---
|
||||
|
||||
## Workflow: convert a page from hard-coded English to i18n (case B)
|
||||
|
||||
This is the more involved path. Use the pilot
|
||||
`app/[locale]/docs/monitor/page.tsx` as the reference.
|
||||
|
||||
### High-level changes to the `.tsx`
|
||||
|
||||
1. Make the page an **async Server Component** that receives
|
||||
`params: Promise<{ locale: string }>`.
|
||||
2. Add `generateStaticParams()` that returns one entry per locale (see
|
||||
`routing.locales` in `i18n/routing.ts`).
|
||||
3. If the page exports `metadata`, replace it with `generateMetadata()`
|
||||
that reads from `getTranslations({ namespace: '<page>.meta' })`.
|
||||
4. Call `setRequestLocale(locale)` near the top of the component body.
|
||||
5. Call `await getTranslations({ locale, namespace: 'docs.<section>.<page>' })`
|
||||
and rename it to `t`.
|
||||
6. Replace every English string in JSX with `t('key')` (plain text) or
|
||||
`t.rich('key', { code, strong, em, link, ... })` for strings with
|
||||
inline tags.
|
||||
7. For lists / tables of structured items (e.g. table rows, nav items),
|
||||
pull the array from `getMessages()` and iterate.
|
||||
|
||||
### JSON file structure
|
||||
|
||||
Use the pilot's `messages/en/docs/monitor/index.json` as the template.
|
||||
The high-level shape is:
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"title": "...",
|
||||
"description": "...",
|
||||
"ogTitle": "...",
|
||||
"ogDescription": "...",
|
||||
"twitterTitle": "...",
|
||||
"twitterDescription": "..."
|
||||
},
|
||||
"header": {
|
||||
"title": "...",
|
||||
"description": "...",
|
||||
"section": "..."
|
||||
},
|
||||
"<section1>": { ... },
|
||||
"<section2>": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
Group keys by the section they appear in. Keep nesting shallow (2-3
|
||||
levels max) so the JSON stays readable for translators.
|
||||
|
||||
### Rich-text placeholder tags
|
||||
|
||||
When a paragraph contains inline elements like `<code>` or `<strong>`,
|
||||
encode them in the JSON exactly as you want them to appear — but
|
||||
remember they're **placeholders**, not real HTML. The `.tsx` registers
|
||||
React renderers for each tag name in the call:
|
||||
|
||||
```tsx
|
||||
t.rich("intro", {
|
||||
code: (chunks) => <code>{chunks}</code>,
|
||||
strong: (chunks) => <strong>{chunks}</strong>,
|
||||
link: (chunks) => (
|
||||
<Link href="/docs/monitor" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
),
|
||||
})
|
||||
```
|
||||
|
||||
Translators just have to keep the tags in roughly the same positions.
|
||||
Don't introduce new tag names in the JSON unless the `.tsx` also
|
||||
registers a renderer for them.
|
||||
|
||||
---
|
||||
|
||||
## What's intentionally NOT translatable
|
||||
|
||||
- **Code blocks** (inside `CopyableCode` etc.) — only the comment lines
|
||||
(`# Comment here`) should be moved to JSON if they're explanatory.
|
||||
Don't translate code keywords, command names or paths.
|
||||
- **URLs and paths** (`/docs/monitor`, `https://github.com/...`) —
|
||||
these stay identical across locales.
|
||||
- **External link labels** like "GitHub", "ProxMenux" — proper nouns
|
||||
and product names stay in their original form.
|
||||
- **Variable names, environment variables, file names** (`auth.json`,
|
||||
`MONITOR_VERSION`, `/var/log/journal/`) — never translated.
|
||||
|
||||
---
|
||||
|
||||
## Adding a new locale
|
||||
|
||||
If you want to add a language that isn't in the project yet:
|
||||
|
||||
1. Add the locale code to `routing.ts`:
|
||||
```ts
|
||||
export const routing = defineRouting({
|
||||
locales: ["en", "es", "fr"], // add your code here
|
||||
defaultLocale: "en",
|
||||
localePrefix: "always",
|
||||
})
|
||||
```
|
||||
2. Create the `messages/<locale>/` folder.
|
||||
3. Copy `messages/en/common.json` over and translate it. **This is
|
||||
mandatory** — without it the navbar and footer fall back to English
|
||||
on every page.
|
||||
4. Start translating individual pages one PR at a time.
|
||||
5. Mention in your first PR that you're seeding the locale so reviewers
|
||||
know to expect a follow-up batch.
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
### My translated page still shows English text. Why?
|
||||
|
||||
Three common causes:
|
||||
|
||||
1. The page file is **case A** (uses `getTranslations()`) and your JSON
|
||||
path doesn't match the namespace it expects. Check the page's
|
||||
`getTranslations({ namespace: '...' })` call and mirror it in your
|
||||
folder structure.
|
||||
2. The page is **case B** (still hard-coded). It needs the `.tsx`
|
||||
refactored first — that part is a developer task, not a translator
|
||||
task.
|
||||
3. The dev server is serving cached output. Stop it (Ctrl+C), remove
|
||||
`web/.next/`, and run `npm run dev` again.
|
||||
|
||||
### What about translations of the Monitor (the AppImage), not just the docs?
|
||||
|
||||
This guide only covers the **public documentation site** in `web/`.
|
||||
The Monitor's dashboard UI in `AppImage/` is a separate project and
|
||||
not currently i18n-enabled. Translating the Monitor would require a
|
||||
parallel effort.
|
||||
|
||||
### Where can I see what's missing?
|
||||
|
||||
Compare the directory trees:
|
||||
|
||||
```bash
|
||||
diff -rq web/messages/en web/messages/es
|
||||
```
|
||||
|
||||
Anything listed as "Only in en" still needs a Spanish version. (Swap
|
||||
`es` for your locale.)
|
||||
|
||||
### My PR conflicts with another translator's PR.
|
||||
|
||||
Because we keep one page per PR, the only realistic conflict zone is
|
||||
`messages/<locale>/common.json` (shared strings) or
|
||||
`app/[locale]/docs/<section>/page.tsx` (refactored at the same time).
|
||||
Rebase on `develop` and re-resolve; ping the other contributor in the
|
||||
PR thread if the merge is non-obvious.
|
||||
201
web/app/[locale]/changelog/page.tsx
Normal file
201
web/app/[locale]/changelog/page.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import type { Metadata } from "next"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { remark } from "remark"
|
||||
import html from "remark-html"
|
||||
import * as gfm from "remark-gfm"
|
||||
import parse from "html-react-parser"
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server"
|
||||
import Footer from "@/components/footer"
|
||||
import RSSLink from "@/components/rss-link"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
// Resolve which CHANGELOG.md to read for the given locale. The canonical
|
||||
// English file lives at the repo root (so GitHub displays it as-is and
|
||||
// existing RSS / external consumers don't break). Localized versions
|
||||
// sit under <repo>/lang/<locale>/CHANGELOG.md. Falls back to English if
|
||||
// the localized file doesn't exist yet — so a partially-translated
|
||||
// changelog still renders (in EN) instead of 404'ing.
|
||||
function resolveChangelogPath(locale: string): string {
|
||||
const repoRoot = path.join(process.cwd(), "..")
|
||||
if (locale && locale !== "en") {
|
||||
const localized = path.join(repoRoot, "lang", locale, "CHANGELOG.md")
|
||||
if (fs.existsSync(localized)) return localized
|
||||
}
|
||||
return path.join(repoRoot, "CHANGELOG.md")
|
||||
}
|
||||
|
||||
// Surface the latest changelog entry in the metadata so social previews and
|
||||
// SERP snippets reflect what was actually shipped. The CHANGELOG.md mixes two
|
||||
// formats — `## [x.y.z] - YYYY-MM-DD` (older releases) and `## YYYY-MM-DD`
|
||||
// (newer dated updates). The most recent entry is always the first `##` line
|
||||
// from the top of the file, regardless of which format it uses. We also try
|
||||
// to extract a version-looking suffix from the first body paragraph so dated
|
||||
// updates like `## 2026-04-20` followed by `### New version ProxMenux v1.2.1`
|
||||
// can still surface the version number.
|
||||
function readLatestChangelogVersion(locale: string): { version: string; date: string } | null {
|
||||
try {
|
||||
const changelogPath = resolveChangelogPath(locale)
|
||||
if (!fs.existsSync(changelogPath)) return null
|
||||
const text = fs.readFileSync(changelogPath, "utf8")
|
||||
|
||||
const firstHeading = text.match(/^##\s+(.+?)\s*$/m)
|
||||
if (!firstHeading) return null
|
||||
const headingText = firstHeading[1].trim()
|
||||
|
||||
// Format A: ## [1.1.1] - 2025-03-21
|
||||
const bracketMatch = headingText.match(/^\[([^\]]+)\]\s*-\s*(\d{4}-\d{2}-\d{2})$/)
|
||||
if (bracketMatch) return { version: bracketMatch[1], date: bracketMatch[2] }
|
||||
|
||||
// Format B: ## 2026-04-20 (use the date and try to find a v-tag in the body)
|
||||
const dateMatch = headingText.match(/^(\d{4}-\d{2}-\d{2})$/)
|
||||
if (dateMatch) {
|
||||
const date = dateMatch[1]
|
||||
const headingIdx = text.indexOf(firstHeading[0])
|
||||
const nextHeadingIdx = text.indexOf("\n## ", headingIdx + 1)
|
||||
const body =
|
||||
nextHeadingIdx === -1
|
||||
? text.slice(headingIdx)
|
||||
: text.slice(headingIdx, nextHeadingIdx)
|
||||
const vMatch = body.match(/\bProxMenux\s+v?(\d+(?:\.\d+){1,2})\b/i)
|
||||
return { version: vMatch ? `v${vMatch[1]}` : date, date }
|
||||
}
|
||||
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "changelog.meta" })
|
||||
const latest = readLatestChangelogVersion(locale)
|
||||
const versionTag = latest?.version ?? ""
|
||||
const dateTag = latest?.date ?? ""
|
||||
|
||||
const titleSuffix = versionTag ? ` — ${t("latest")}: ${versionTag}` : ""
|
||||
const descriptionSuffix = versionTag
|
||||
? `${t("mostRecent")}: ${versionTag}${dateTag && dateTag !== versionTag ? ` (${dateTag})` : ""}.`
|
||||
: ""
|
||||
|
||||
return {
|
||||
title: `${t("title")}${titleSuffix} | ${t("titleSuffix")}`,
|
||||
description: `${t("description")} ${descriptionSuffix} ${t("descriptionTail")}`.trim(),
|
||||
keywords: [
|
||||
"proxmenux changelog",
|
||||
"proxmenux release notes",
|
||||
"proxmenux updates",
|
||||
"proxmenux versions",
|
||||
"proxmox script changelog",
|
||||
"proxmenux history",
|
||||
"proxmenux roadmap",
|
||||
],
|
||||
alternates: {
|
||||
canonical: `https://proxmenux.com/${locale}/changelog`,
|
||||
types: {
|
||||
"application/rss+xml":
|
||||
locale === "en"
|
||||
? "https://proxmenux.com/rss.xml"
|
||||
: `https://proxmenux.com/${locale}/rss.xml`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: `${t("title")}${titleSuffix}`,
|
||||
description: `${t("ogDescription")} ${descriptionSuffix} ${t("ogTail")}`.trim(),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/changelog",
|
||||
siteName: "ProxMenux",
|
||||
images: [
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/main.png",
|
||||
width: 1363,
|
||||
height: 735,
|
||||
alt: "ProxMenux — Interactive Menu and Web Dashboard for Proxmox VE",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: `${t("title")}${titleSuffix}`,
|
||||
description: `${t("ogDescription")} ${descriptionSuffix}`.trim(),
|
||||
images: [
|
||||
"https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/main.png",
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function getChangelogContent(locale: string) {
|
||||
try {
|
||||
const changelogPath = resolveChangelogPath(locale)
|
||||
|
||||
if (!fs.existsSync(changelogPath)) {
|
||||
console.error("❌ CHANGELOG.md file not found.")
|
||||
return "<p class='text-red-600'>Error: CHANGELOG.md file not found</p>"
|
||||
}
|
||||
|
||||
const fileContents = fs.readFileSync(changelogPath, "utf8")
|
||||
|
||||
// Add remark-gfm to support images, tables and other advanced Markdown elements
|
||||
const result = await remark()
|
||||
.use(gfm.default || gfm) // Safe handling of remark-gfm
|
||||
.use(html)
|
||||
.process(fileContents)
|
||||
|
||||
return result.toString()
|
||||
} catch (error) {
|
||||
console.error("❌ Error reading CHANGELOG.md file", error)
|
||||
return "<p class='text-red-600'>Error: Could not load changelog content.</p>"
|
||||
}
|
||||
}
|
||||
|
||||
// Clean backticks in inline code fragments
|
||||
function cleanInlineCode(content: string) {
|
||||
return content.replace(/<code>(.*?)<\/code>/g, (_, codeContent) => {
|
||||
return `<code class="bg-gray-200 text-gray-900 px-1 rounded">${codeContent.replace(/^`|`$/g, "")}</code>`
|
||||
})
|
||||
}
|
||||
|
||||
// Wrap code blocks with CopyableCode component
|
||||
function wrapCodeBlocksWithCopyable(content: string) {
|
||||
return parse(content, {
|
||||
replace: (domNode: any) => {
|
||||
if (domNode.name === "pre" && domNode.children.length > 0) {
|
||||
const codeElement = domNode.children.find((child: any) => child.name === "code")
|
||||
if (codeElement) {
|
||||
const codeContent = codeElement.children[0]?.data?.trim() || ""
|
||||
return <CopyableCode code={codeContent} />
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default async function ChangelogPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "changelog" })
|
||||
const changelogContent = await getChangelogContent(locale)
|
||||
const cleanedInlineCode = cleanInlineCode(changelogContent)
|
||||
const parsedContent = wrapCodeBlocksWithCopyable(cleanedInlineCode)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-gray-900">
|
||||
<div className="container mx-auto px-4 py-16" style={{ maxWidth: "980px" }}>
|
||||
<h1 className="text-4xl font-bold mb-8">{t("pageTitle")}</h1>
|
||||
<RSSLink />
|
||||
<div className="prose max-w-none text-[16px]">{parsedContent}</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
85
web/app/[locale]/docs/about/code-of-conduct/page.tsx
Normal file
85
web/app/[locale]/docs/about/code-of-conduct/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { remark } from "remark"
|
||||
import html from "remark-html"
|
||||
import * as gfm from "remark-gfm"
|
||||
import React from "react"
|
||||
import parse from "html-react-parser"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.about.codeOfConduct.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/about/code-of-conduct",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function getCodeOfConductContent(notFoundMsg: string, loadFailedMsg: string) {
|
||||
try {
|
||||
const codeOfConductPath = path.join(process.cwd(), "..", "CODE_OF_CONDUCT.md")
|
||||
|
||||
if (!fs.existsSync(codeOfConductPath)) {
|
||||
console.error("CODE_OF_CONDUCT.md file not found.")
|
||||
return `<p class='text-red-600'>${notFoundMsg}</p>`
|
||||
}
|
||||
|
||||
const fileContents = fs.readFileSync(codeOfConductPath, "utf8")
|
||||
|
||||
const result = await remark()
|
||||
.use(gfm.default || gfm)
|
||||
.use(html)
|
||||
.process(fileContents)
|
||||
|
||||
return result.toString()
|
||||
} catch (error) {
|
||||
console.error("Error reading the CODE_OF_CONDUCT.md file", error)
|
||||
return `<p class='text-red-600'>${loadFailedMsg}</p>`
|
||||
}
|
||||
}
|
||||
|
||||
function cleanInlineCode(content: string) {
|
||||
return content.replace(/<code>(.*?)<\/code>/g, (_, codeContent) => {
|
||||
return `<code class="bg-gray-200 text-gray-900 px-1 rounded">${codeContent.replace(/^`|`$/g, "")}</code>`
|
||||
})
|
||||
}
|
||||
|
||||
function wrapCodeBlocksWithCopyable(content: string) {
|
||||
return parse(content, {
|
||||
replace: (domNode: any) => {
|
||||
if (domNode.name === "pre" && domNode.children.length > 0) {
|
||||
const codeElement = domNode.children.find((child: any) => child.name === "code")
|
||||
if (codeElement) {
|
||||
const codeContent = codeElement.children[0]?.data?.trim() || ""
|
||||
return <CopyableCode code={codeContent} />
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default async function CodeOfConductPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.about.codeOfConduct" })
|
||||
const codeOfConductContent = await getCodeOfConductContent(t("errors.notFound"), t("errors.loadFailed"))
|
||||
const cleanedInlineCode = cleanInlineCode(codeOfConductContent)
|
||||
const parsedContent = wrapCodeBlocksWithCopyable(cleanedInlineCode)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-gray-900">
|
||||
<div className="container mx-auto px-4 py-16" style={{ maxWidth: "980px" }}>
|
||||
<div className="prose max-w-none text-[16px]">{parsedContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
436
web/app/[locale]/docs/about/contributing/page.tsx
Normal file
436
web/app/[locale]/docs/about/contributing/page.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.about.contributing.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmenux contributing",
|
||||
"proxmenux pull request",
|
||||
"proxmenux branch model",
|
||||
"proxmenux develop branch",
|
||||
"proxmenux script template",
|
||||
"proxmenux script header",
|
||||
"proxmox bash contribution",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/about/contributing" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/about/contributing",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: t("title"),
|
||||
description: "How to contribute scripts, dialogs and improvements to the ProxMenux project.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type BranchingRow = { branch: string; purposeRich: string }
|
||||
type PhaseRow = { phaseRich: string; purposeRich: string; screenRich: string }
|
||||
type DialogRow = { toolRich: string; whenRich: string; effectRich: string }
|
||||
type MsgRow = { function: string; whenRich: string; spinner: string }
|
||||
type WhereNextItem =
|
||||
| { kind: "external"; url: string; label: string; tail: string }
|
||||
| { kind: "internal"; href: string; label: string; tail: string }
|
||||
|
||||
export default async function ContributingPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.about.contributing" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { about: { contributing: {
|
||||
branching: { rows: BranchingRow[] }
|
||||
scriptHeader: { bullets: string[] }
|
||||
twoPhase: { rows: PhaseRow[]; phase1Rules: string[] }
|
||||
dialogVsWhiptail: { rows: DialogRow[] }
|
||||
messageFunctions: { rows: MsgRow[] }
|
||||
dialogConventions: { bullets: string[] }
|
||||
translation: { bullets: string[] }
|
||||
variableStyle: { bullets: string[] }
|
||||
dosAndDonts: { doBullets: string[]; dontBullets: string[] }
|
||||
submitting: { steps: string[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } }
|
||||
}
|
||||
const block = messages.docs.about.contributing
|
||||
const branchingRows = block.branching.rows
|
||||
const scriptHeaderBullets = block.scriptHeader.bullets
|
||||
const twoPhaseRows = block.twoPhase.rows
|
||||
const phase1Rules = block.twoPhase.phase1Rules
|
||||
const dialogRows = block.dialogVsWhiptail.rows
|
||||
const msgRows = block.messageFunctions.rows
|
||||
const dialogBullets = block.dialogConventions.bullets
|
||||
const translationBullets = block.translation.bullets
|
||||
const variableStyleBullets = block.variableStyle.bullets
|
||||
const doBullets = block.dosAndDonts.doBullets
|
||||
const dontBullets = block.dosAndDonts.dontBullets
|
||||
const submittingSteps = block.submitting.steps
|
||||
const whereNextItems = block.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const contributorsLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/about/contributors" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const cocLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/about/code-of-conduct" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const licenseLink = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
const securityLink = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux/blob/main/SECURITY.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={15}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("twoPagesCallout.title")}>
|
||||
{t.rich("twoPagesCallout.body", { contributorsLink, em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("branching.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("branching.intro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("branching.headerBranch")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("branching.headerPurpose")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{branchingRows.map((row, idx) => (
|
||||
<tr key={row.branch} className={idx < branchingRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong><code>{row.branch}</code></strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`branching.rows.${idx}.purposeRich`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("branching.calloutTitle")}>
|
||||
{t.rich("branching.calloutBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("workflow.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("workflow.intro")}</p>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
|
||||
<li>
|
||||
{t.rich("workflow.step1Lead", { code, strong })}
|
||||
<CopyableCode code={t.raw("workflow.step1Code") as string} className="my-3" />
|
||||
{t.rich("workflow.step1Trail", { code })}
|
||||
</li>
|
||||
<li>
|
||||
{t.rich("workflow.step2Lead", { strong })}
|
||||
<CopyableCode code={t.raw("workflow.step2Code") as string} className="my-3" />
|
||||
</li>
|
||||
<li>{t.rich("workflow.step3", { code, strong })}</li>
|
||||
<li>{t.rich("workflow.step4", { code, strong })}</li>
|
||||
<li>{t.rich("workflow.step5", { code, strong })}</li>
|
||||
</ol>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("scriptHeader.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("scriptHeader.intro", { strong })}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
|
||||
{scriptHeaderBullets.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`scriptHeader.bullets.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="info" title={t("scriptHeader.licenseCalloutTitle")}>
|
||||
{t.rich("scriptHeader.licenseCalloutBody", { strong, code, licenseLink })}
|
||||
</Callout>
|
||||
|
||||
<CopyableCode code={t.raw("scriptHeader.templateCode") as string} className="my-4" />
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("scriptHeader.optionalNote", { code })}</p>
|
||||
|
||||
<Callout variant="tip" title={t("scriptHeader.whyCalloutTitle")}>
|
||||
{t("scriptHeader.whyCalloutBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("structure.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("structure.intro")}</p>
|
||||
|
||||
<CopyableCode code={t.raw("structure.treeCode") as string} className="my-4" />
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("structure.outro", { code })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("twoPhase.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("twoPhase.intro", { strong })}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("twoPhase.headerPhase")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("twoPhase.headerPurpose")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("twoPhase.headerScreen")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{twoPhaseRows.map((_, idx) => (
|
||||
<tr key={idx} className={idx < twoPhaseRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">{t.rich(`twoPhase.rows.${idx}.phaseRich`, { strong })}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`twoPhase.rows.${idx}.purposeRich`, { code })}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`twoPhase.rows.${idx}.screenRich`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("twoPhase.principle", { strong })}</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("twoPhase.phase1Heading")}</h3>
|
||||
|
||||
<CopyableCode code={t.raw("twoPhase.phase1Code") as string} className="my-4" />
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("twoPhase.phase1RulesIntro", { strong })}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{phase1Rules.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`twoPhase.phase1Rules.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("twoPhase.phase2Heading")}</h3>
|
||||
|
||||
<CopyableCode code={t.raw("twoPhase.phase2Code") as string} className="my-4" />
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("twoPhase.phase2Rules", { strong, code })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t.rich("dialogVsWhiptail.headingRich", { code })}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("dialogVsWhiptail.intro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dialogVsWhiptail.headerTool")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dialogVsWhiptail.headerWhen")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dialogVsWhiptail.headerEffect")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dialogRows.map((_, idx) => (
|
||||
<tr key={idx} className={idx < dialogRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">{t.rich(`dialogVsWhiptail.rows.${idx}.toolRich`, { strong, code })}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`dialogVsWhiptail.rows.${idx}.whenRich`, { strong, code })}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`dialogVsWhiptail.rows.${idx}.effectRich`, { code, em })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("dialogVsWhiptail.calloutTitle")}>
|
||||
{t.rich("dialogVsWhiptail.calloutBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("dialogVsWhiptail.rebootHeading")}</h3>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t.rich("dialogVsWhiptail.rebootIntro", { code })}</p>
|
||||
|
||||
<CopyableCode code={t.raw("dialogVsWhiptail.rebootCode") as string} className="my-4" />
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("messageFunctions.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("messageFunctions.intro", { code })}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("messageFunctions.headerFunction")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("messageFunctions.headerWhen")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("messageFunctions.headerSpinner")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{msgRows.map((row, idx) => (
|
||||
<tr key={idx} className={idx < msgRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.function}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`messageFunctions.rows.${idx}.whenRich`, { em })}</td>
|
||||
<td className="px-3 py-2 align-top">{row.spinner}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dialogConventions.heading")}</h2>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{dialogBullets.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`dialogConventions.bullets.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("dialogConventions.exampleIntro")}</p>
|
||||
|
||||
<CopyableCode code={t.raw("dialogConventions.exampleCode") as string} className="my-4" />
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("translation.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("translation.intro", { code })}</p>
|
||||
|
||||
<CopyableCode code={t.raw("translation.code") as string} className="my-4" />
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{translationBullets.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`translation.bullets.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("variableStyle.heading")}</h2>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{variableStyleBullets.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`variableStyle.bullets.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("variableStyle.standardNamesIntro")}</p>
|
||||
|
||||
<CopyableCode code={t.raw("variableStyle.standardNamesCode") as string} className="my-4" />
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("variableStyle.redirectHeading")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("variableStyle.redirectIntro", { code })}</p>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t.rich("variableStyle.withoutRedirectIntro", { strong })}</p>
|
||||
|
||||
<CopyableCode code={t.raw("variableStyle.withoutRedirectCode") as string} className="my-4" />
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t.rich("variableStyle.withRedirectIntro", { strong })}</p>
|
||||
|
||||
<CopyableCode code={t.raw("variableStyle.withRedirectCode") as string} className="my-4" />
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("variableStyle.twoPatternsIntro")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
|
||||
<li>
|
||||
{t.rich("variableStyle.discardLead", { strong })}
|
||||
<CopyableCode code={t.raw("variableStyle.discardCode") as string} className="my-3" />
|
||||
</li>
|
||||
<li>
|
||||
{t.rich("variableStyle.logLead", { strong })}
|
||||
<CopyableCode code={t.raw("variableStyle.logCode") as string} className="my-3" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("variableStyle.referenceOutro", { code })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dosAndDonts.heading")}</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900 text-green-700">{t("dosAndDonts.doHeading")}</h3>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{doBullets.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`dosAndDonts.doBullets.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900 text-red-700">{t("dosAndDonts.dontHeading")}</h3>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{dontBullets.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`dosAndDonts.dontBullets.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("submitting.heading")}</h2>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
|
||||
{submittingSteps.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`submitting.steps.${idx}`, { code, em, strong, cocLink })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("submitting.securityOutro", { securityLink })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item, idx) => (
|
||||
<li key={idx}>
|
||||
{item.kind === "external" ? (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{item.label}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
) : (
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
)}
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
181
web/app/[locale]/docs/about/contributors/page.tsx
Normal file
181
web/app/[locale]/docs/about/contributors/page.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import type { Metadata } from "next"
|
||||
import Image from "next/image"
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server"
|
||||
import { Youtube, FlaskRound, ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.about.contributors.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/about/contributors",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface Contributor {
|
||||
name: string
|
||||
roleKey: "testing" | "testingReviewer"
|
||||
avatar: string
|
||||
youtubeUrl?: string
|
||||
}
|
||||
|
||||
const contributors: Contributor[] = [
|
||||
{
|
||||
name: "MALOW",
|
||||
roleKey: "testing",
|
||||
avatar: "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/avatars/malow.png",
|
||||
},
|
||||
{
|
||||
name: "Segarra",
|
||||
roleKey: "testing",
|
||||
avatar: "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/avatars/segarra.png",
|
||||
},
|
||||
{
|
||||
name: "Aprilia",
|
||||
roleKey: "testing",
|
||||
avatar: "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/avatars/aprilia.png",
|
||||
},
|
||||
{
|
||||
name: "Jonatan Castro",
|
||||
roleKey: "testingReviewer",
|
||||
avatar: "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/avatars/jonatancastro.png",
|
||||
youtubeUrl: "https://www.youtube.com/@JonatanCastro",
|
||||
},
|
||||
{
|
||||
name: "Kamunhas",
|
||||
roleKey: "testing",
|
||||
avatar: "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/avatars/Kamunhas.png",
|
||||
},
|
||||
]
|
||||
|
||||
export default async function ContributorsPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.about.contributors" })
|
||||
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const extlink = (href: string) => (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-700 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
const extlinkBlue = (href: string) => (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
const extlinkEmerald = (href: string) => (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-emerald-700 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={1}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("beyond.title")}>
|
||||
{t.rich("beyond.body", {
|
||||
extlink: extlink("https://github.com/MacRimi/ProxMenux/graphs/contributors"),
|
||||
})}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("testers.heading")}</h2>
|
||||
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t("testers.intro")}</p>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-6 mt-6 mb-8 not-prose">
|
||||
{contributors.map((c) => (
|
||||
<div key={c.name} className="text-center">
|
||||
<div className="relative inline-block">
|
||||
<Image
|
||||
src={c.avatar}
|
||||
alt={c.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="w-20 h-20 rounded-full border-2 border-gray-300 object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
<div className="absolute -bottom-1 -right-1 bg-orange-500 rounded-full p-1">
|
||||
<FlaskRound className="h-4 w-4 text-white" aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-900 mt-2 mb-0">{c.name}</h3>
|
||||
<p className="text-xs text-gray-600 mt-0.5">{t(`testers.roles.${c.roleKey}`)}</p>
|
||||
{c.youtubeUrl && (
|
||||
<a
|
||||
href={c.youtubeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 mt-1 text-xs text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Youtube className="h-3.5 w-3.5" aria-hidden /> {t("testers.youtube")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("contribute.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("contribute.intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
<li>
|
||||
{t.rich("contribute.tester", {
|
||||
strong,
|
||||
beta: extlinkBlue("https://github.com/MacRimi/ProxMenux/blob/develop/install_proxmenux_beta.sh"),
|
||||
})}
|
||||
</li>
|
||||
<li>
|
||||
{t.rich("contribute.developer", {
|
||||
strong,
|
||||
gh: extlinkBlue("https://github.com/MacRimi/ProxMenux/pulls"),
|
||||
})}
|
||||
</li>
|
||||
<li>{t.rich("contribute.designer", { strong })}</li>
|
||||
<li>
|
||||
{t.rich("contribute.ideas", {
|
||||
strong,
|
||||
disc: extlinkBlue("https://github.com/MacRimi/ProxMenux/discussions"),
|
||||
})}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Callout variant="tip" title={t("coc.title")}>
|
||||
{t.rich("coc.body", {
|
||||
coclink: extlinkEmerald("https://github.com/MacRimi/ProxMenux/blob/main/CODE_OF_CONDUCT.md"),
|
||||
})}
|
||||
</Callout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
255
web/app/[locale]/docs/about/faq/page.tsx
Normal file
255
web/app/[locale]/docs/about/faq/page.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.about.faq.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/about/faq",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface QAProps {
|
||||
q: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function QA({ q, children }: QAProps) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3 text-gray-900">{q}</h2>
|
||||
<div className="text-gray-800 leading-relaxed space-y-3">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function FaqPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.about.faq" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: {
|
||||
about: {
|
||||
faq: {
|
||||
q1: { items: string[] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const q1Items = messages.docs.about.faq.q1.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
|
||||
const installlink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/installation" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const upgradelink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/utils/upgrade-pve8-pve9" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const betalink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/settings/beta-program" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const uninstalllink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/settings/uninstall-proxmenux" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const contriblink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/about/contributors" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const issueslink = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
const coclink = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux/blob/main/CODE_OF_CONDUCT.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
const discusslink = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux/discussions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
const scriptlink = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux/blob/main/install_proxmenux.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
const issuelink = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux/issues/162"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={5}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("quickLinks.title")}>
|
||||
<ul className="list-disc pl-6 mb-0 space-y-1">
|
||||
<li>
|
||||
<Link href="/docs/installation" className="text-blue-700 hover:underline">
|
||||
{t("quickLinks.installationLabel")}
|
||||
</Link>
|
||||
{t("quickLinks.installationSuffix")}
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/docs/introduction" className="text-blue-700 hover:underline">
|
||||
{t("quickLinks.introductionLabel")}
|
||||
</Link>
|
||||
{t("quickLinks.introductionSuffix")}
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/docs/settings/uninstall-proxmenux" className="text-blue-700 hover:underline">
|
||||
{t("quickLinks.uninstallLabel")}
|
||||
</Link>
|
||||
{t("quickLinks.uninstallSuffix")}
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-700 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("quickLinks.issuesLabel")}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>{" "}
|
||||
·{" "}
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux/discussions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-700 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("quickLinks.discussionsLabel")}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</Callout>
|
||||
|
||||
<QA q={t("q1.question")}>
|
||||
<p>{t.rich("q1.p1Rich", { strong })}</p>
|
||||
<p>{t("q1.p2")}</p>
|
||||
<p className="mb-0">{t("q1.p3")}</p>
|
||||
<ul className="list-disc pl-6 mb-0 space-y-1">
|
||||
{q1Items.map((_, idx) => (
|
||||
<li key={idx}>{t(`q1.items.${idx}`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</QA>
|
||||
|
||||
<QA q={t("q2.question")}>
|
||||
<p>{t.rich("q2.p1Rich", { installlink })}</p>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 border border-gray-200">
|
||||
{t("q2.stableInstall")}
|
||||
</pre>
|
||||
<p>{t("q2.p2")}</p>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 border border-gray-200">
|
||||
{t("q2.menuCmd")}
|
||||
</pre>
|
||||
</QA>
|
||||
|
||||
<QA q={t("q3.question")}>
|
||||
<p>{t.rich("q3.bodyRich", { strong, upgradelink })}</p>
|
||||
</QA>
|
||||
|
||||
<QA q={t("q5.question")}>
|
||||
<p>{t.rich("q5.p1Rich", { code })}</p>
|
||||
<p className="mb-0">{t.rich("q5.p2Rich", { betalink })}</p>
|
||||
</QA>
|
||||
|
||||
<QA q={t("q6.question")}>
|
||||
<p>{t.rich("q6.p1Rich", { issueslink, code })}</p>
|
||||
<p className="mb-0">{t.rich("q6.p2Rich", { strong, coclink })}</p>
|
||||
</QA>
|
||||
|
||||
<QA q={t("q7.question")}>
|
||||
<p>{t.rich("q7.p1Rich", { strong })}</p>
|
||||
<ul className="list-disc pl-6 mb-0 space-y-1">
|
||||
<li>{t.rich("q7.item1Rich", { discusslink })}</li>
|
||||
<li>{t.rich("q7.item2Rich", { coclink })}</li>
|
||||
<li>{t.rich("q7.item3Rich", { contriblink })}</li>
|
||||
</ul>
|
||||
</QA>
|
||||
|
||||
<QA q={t("q10.question")}>
|
||||
<p>{t.rich("q10.p1Rich", { strong, code })}</p>
|
||||
<p className="mb-0">{t.rich("q10.p2Rich", { uninstalllink })}</p>
|
||||
</QA>
|
||||
|
||||
<QA q={t("q11.question")}>
|
||||
<p>{t.rich("q11.p1Rich", { em, code })}</p>
|
||||
<p className="mb-0">{t.rich("q11.p2Rich", { scriptlink, issuelink })}</p>
|
||||
</QA>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
161
web/app/[locale]/docs/about/page.tsx
Normal file
161
web/app/[locale]/docs/about/page.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { ArrowRight, Users, HelpCircle, ScrollText, Heart, Star, ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.about.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/about",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type SectionOption = {
|
||||
icon: string
|
||||
href: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
type InvolvedCard = {
|
||||
href: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const ICONS: Record<string, React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>> = {
|
||||
HelpCircle,
|
||||
Users,
|
||||
ScrollText,
|
||||
}
|
||||
|
||||
function OptionCard({ option }: { option: SectionOption }) {
|
||||
const Icon = ICONS[option.icon] || HelpCircle
|
||||
return (
|
||||
<Link
|
||||
href={option.href}
|
||||
className="group flex items-start gap-3 rounded-md border border-gray-200 bg-white p-3 transition-colors hover:border-blue-400 hover:bg-blue-50"
|
||||
>
|
||||
<span className="inline-flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-gray-100 text-gray-600 group-hover:bg-blue-100 group-hover:text-blue-700">
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1 text-sm font-medium text-gray-900 group-hover:text-blue-700">
|
||||
{option.title}
|
||||
<ArrowRight className="h-3.5 w-3.5 text-gray-400 group-hover:text-blue-600 transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-gray-600 leading-snug">{option.description}</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function AboutOverviewPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.about" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: {
|
||||
about: {
|
||||
section: { options: SectionOption[] }
|
||||
involved: { cards: InvolvedCard[] }
|
||||
}
|
||||
}
|
||||
}
|
||||
const options = messages.docs.about.section.options
|
||||
const involvedCards = messages.docs.about.involved.cards
|
||||
|
||||
const starlink = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={2}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("callout.title")}>
|
||||
{t("callout.body")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("section.heading")}</h2>
|
||||
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
|
||||
{options.map((o) => (
|
||||
<OptionCard key={o.href} option={o} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("involved.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t("involved.intro")}
|
||||
</p>
|
||||
<div className="grid gap-3 md:grid-cols-3 mb-6 not-prose">
|
||||
{involvedCards.map((card) => (
|
||||
<a
|
||||
key={card.href}
|
||||
href={card.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-start justify-between gap-3 rounded-md border border-gray-200 bg-white p-4 transition-colors hover:border-blue-400 hover:bg-blue-50"
|
||||
>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 mb-1">{card.title}</div>
|
||||
<div className="text-xs text-gray-600">{card.description}</div>
|
||||
</div>
|
||||
<ExternalLink className="w-3.5 h-3.5 text-gray-400 mt-0.5 flex-shrink-0" aria-hidden />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
|
||||
<Star className="inline h-5 w-5 mr-1 -mt-1 text-amber-500" aria-hidden />
|
||||
{t("support.heading")}
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("support.introRich", { starlink })}
|
||||
</p>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
<a
|
||||
href="https://ko-fi.com/G2G313ECAN"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-pink-600 hover:underline"
|
||||
>
|
||||
<Heart className="h-4 w-4" aria-hidden /> {t("support.kofiLabel")}
|
||||
</a>
|
||||
{t("support.kofiOutro")}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
237
web/app/[locale]/docs/create-vm/page.tsx
Normal file
237
web/app/[locale]/docs/create-vm/page.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { ExternalLink, HardDrive, MonitorCog, Laptop } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.createVm.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox create vm",
|
||||
"proxmox synology vm",
|
||||
"proxmox windows vm",
|
||||
"proxmox truenas vm",
|
||||
"proxmox unraid vm",
|
||||
"proxmox openmediavault",
|
||||
"proxmox vm wizard",
|
||||
"proxmox nas vm",
|
||||
"proxmox dsm",
|
||||
"proxmenux create vm",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/create-vm" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/create-vm",
|
||||
images: [
|
||||
{
|
||||
url: "/vm/vm-creation-menu.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: t("ogImageAlt"),
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Route = {
|
||||
key: string
|
||||
title: string
|
||||
icon: string
|
||||
href: string
|
||||
accent: string
|
||||
iconBg: string
|
||||
description: string
|
||||
bullets: string[]
|
||||
}
|
||||
type StringItem = string
|
||||
type ScriptRowData = { path: string; role: string }
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
const ICONS: Record<string, React.ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }>> = {
|
||||
HardDrive,
|
||||
MonitorCog,
|
||||
Laptop,
|
||||
}
|
||||
|
||||
function ScriptRow({ path, role }: { path: string; role: string }) {
|
||||
return (
|
||||
<tr>
|
||||
<td className="px-4 py-2">
|
||||
<a
|
||||
className="inline-flex items-center gap-1.5 text-blue-600 hover:underline"
|
||||
href={`https://github.com/MacRimi/ProxMenux/blob/main/scripts/${path}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="font-mono">{path}</span>
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0 opacity-70" />
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-700">{role}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function CreateVMOverviewPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.createVm" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { createVm: {
|
||||
families: { routes: Route[] }
|
||||
afterPick: { items: StringItem[] }
|
||||
scripts: { rows: ScriptRowData[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} }
|
||||
}
|
||||
const routes = messages.docs.createVm.families.routes
|
||||
const afterPickItems = messages.docs.createVm.afterPick.items
|
||||
const scriptRows = messages.docs.createVm.scripts.rows
|
||||
const relatedItems = messages.docs.createVm.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const osxLink = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://osx-proxmox.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-700 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={2}
|
||||
scriptPath="menus/create_vm_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t("intro.body")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("opening.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("opening.body", { strong })}
|
||||
</p>
|
||||
|
||||
<Image
|
||||
src="/vm/vm-creation-menu.png"
|
||||
alt={t("opening.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("families.heading")}</h2>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t("families.intro")}</p>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-3 mb-8 not-prose">
|
||||
{routes.map((route) => {
|
||||
const Icon = ICONS[route.icon] || HardDrive
|
||||
return (
|
||||
<Link
|
||||
key={route.key}
|
||||
href={route.href}
|
||||
className={`rounded-lg border-2 p-5 ${route.accent} flex flex-col transition-shadow hover:shadow-md`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className={`inline-flex h-9 w-9 items-center justify-center rounded-full ${route.iconBg}`}>
|
||||
<Icon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900 m-0">{route.title}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-800 mb-3">{route.description}</p>
|
||||
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
|
||||
{route.bullets.map((b, i) => (
|
||||
<li key={i}>{b}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("community.title")}>
|
||||
{t.rich("community.intro", { em, strong })}
|
||||
<ul className="mt-3 list-disc list-inside space-y-1">
|
||||
<li>{t.rich("community.macosRich", { strong, osxLink })}</li>
|
||||
<li>{t.rich("community.othersRich", { strong })}</li>
|
||||
</ul>
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("afterPick.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("afterPick.intro")}</p>
|
||||
<ol className="list-decimal list-inside mb-4 text-gray-800 leading-relaxed space-y-2">
|
||||
{afterPickItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`afterPick.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<Callout variant="tip" title={t("afterPick.tipTitle")}>
|
||||
{t.rich("afterPick.tipBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("scripts.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("scripts.intro")}</p>
|
||||
<div className="overflow-x-auto rounded-md border border-gray-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold">{t("scripts.headerScript")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("scripts.headerRole")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{scriptRows.map((row) => (
|
||||
<ScriptRow key={row.path} path={row.path} role={row.role} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
363
web/app/[locale]/docs/create-vm/synology/page.tsx
Normal file
363
web/app/[locale]/docs/create-vm/synology/page.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
import {
|
||||
Wrench,
|
||||
Target,
|
||||
CheckCircle,
|
||||
Github,
|
||||
Server,
|
||||
HardDrive,
|
||||
Download,
|
||||
Settings,
|
||||
Cpu,
|
||||
Zap,
|
||||
Sliders,
|
||||
ExternalLink,
|
||||
} from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { useTranslations, useMessages } from "next-intl"
|
||||
|
||||
type LoaderKey = "arc" | "rr" | "tinycore"
|
||||
|
||||
type StepMedia = { htmlBefore?: string; src: string; alt: string; caption: string }
|
||||
type Step = {
|
||||
id: string
|
||||
title: string
|
||||
intro: string
|
||||
outro?: string
|
||||
loaders: Record<LoaderKey, StepMedia[]>
|
||||
}
|
||||
type LoaderLink = { name: string; url: string }
|
||||
type ConfigRow = { param: string; value?: string; options?: string }
|
||||
type DocLink = { label: string; url: string }
|
||||
|
||||
function ImageWithCaption({ src, alt, caption }: { src: string; alt: string; caption: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full max-w-[768px] mx-auto my-4">
|
||||
<div className="w-full rounded-md overflow-hidden">
|
||||
<Image
|
||||
src={src || "/placeholder.svg"}
|
||||
alt={alt}
|
||||
width={768}
|
||||
height={0}
|
||||
style={{ height: "auto" }}
|
||||
className="object-contain w-full"
|
||||
sizes="(max-width: 768px) 100vw, 768px"
|
||||
/>
|
||||
</div>
|
||||
<span className="mt-2 text-sm text-gray-600">{caption}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepNumber({ number }: { number: number }) {
|
||||
return (
|
||||
<div
|
||||
className="inline-flex items-center justify-center w-8 h-8 mr-3 text-white bg-blue-500 rounded-full"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="text-sm font-bold">{number}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [activeLoader, setActiveLoader] = useState<LoaderKey>("arc")
|
||||
const t = useTranslations("docs.createVm.synology")
|
||||
|
||||
const messages = useMessages() as unknown as {
|
||||
docs: { createVm: { synology: {
|
||||
intro: { loaders: LoaderLink[]; simplifies: string[] }
|
||||
config: { defaultRows: ConfigRow[]; advancedRows: ConfigRow[] }
|
||||
diskSelection: { virtualItems: string[]; physicalItems: string[] }
|
||||
vmCreation: { items: string[] }
|
||||
steps: Step[]
|
||||
tips: { docLinks: DocLink[] }
|
||||
} } }
|
||||
}
|
||||
const introLoaders = messages.docs.createVm.synology.intro.loaders
|
||||
const simplifies = messages.docs.createVm.synology.intro.simplifies
|
||||
const defaultRows = messages.docs.createVm.synology.config.defaultRows
|
||||
const advancedRows = messages.docs.createVm.synology.config.advancedRows
|
||||
const virtualItems = messages.docs.createVm.synology.diskSelection.virtualItems
|
||||
const physicalItems = messages.docs.createVm.synology.diskSelection.physicalItems
|
||||
const vmCreationItems = messages.docs.createVm.synology.vmCreation.items
|
||||
const steps = messages.docs.createVm.synology.steps
|
||||
const docLinks = messages.docs.createVm.synology.tips.docLinks
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">{t("title")}</h1>
|
||||
|
||||
<section className="mb-10">
|
||||
<h2 className="text-2xl font-semibold mb-4 flex items-center">
|
||||
<Server className="h-6 w-6 mr-2 text-blue-500" />
|
||||
{t("intro.heading")}
|
||||
</h2>
|
||||
<p className="mb-4">{t("intro.intro")}</p>
|
||||
<ul className="list-disc pl-5 mb-4">
|
||||
{introLoaders.map((l) => (
|
||||
<li key={l.name}>
|
||||
<a
|
||||
href={l.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{l.name}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>{" "}
|
||||
</li>
|
||||
))}
|
||||
<li>{t("intro.customLoader")}</li>
|
||||
</ul>
|
||||
|
||||
<p className="mb-4">{t("intro.simplifiesIntro")}</p>
|
||||
<ul className="list-disc pl-5 mb-4">
|
||||
{simplifies.map((item, i) => (
|
||||
<li key={i}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-8">
|
||||
<h3 className="text-xl font-semibold mb-3 flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2 text-blue-500" />
|
||||
{t("config.heading")}
|
||||
</h3>
|
||||
<p className="mb-3">{t("config.intro")}</p>
|
||||
|
||||
<h4 className="text-lg font-medium mt-12 mb-2 flex items-center">
|
||||
<Zap className="h-5 w-5 mr-2 text-green-500" />
|
||||
{t("config.defaultHeading")}
|
||||
</h4>
|
||||
<p className="mb-3">{t("config.defaultIntro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-4">
|
||||
<table className="min-w-full bg-white border border-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="py-2 px-4 border-b border-gray-200 bg-gray-50 text-left">{t("config.headerParam")}</th>
|
||||
<th className="py-2 px-4 border-b border-gray-200 bg-gray-50 text-left">{t("config.headerValue")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{defaultRows.map((row) => (
|
||||
<tr key={row.param}>
|
||||
<td className="py-2 px-4 border-b border-gray-200">{row.param}</td>
|
||||
<td className="py-2 px-4 border-b border-gray-200">{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mb-4">{t("config.defaultOutro")}</p>
|
||||
|
||||
<h4 className="text-lg font-medium mt-12 mb-2 flex items-center">
|
||||
<Sliders className="h-5 w-5 mr-2 text-orange-500" />
|
||||
{t("config.advancedHeading")}
|
||||
</h4>
|
||||
<p className="mb-3">{t("config.advancedIntro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-4">
|
||||
<table className="min-w-full bg-white border border-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="py-2 px-4 border-b border-gray-200 bg-gray-50 text-left">{t("config.headerParam")}</th>
|
||||
<th className="py-2 px-4 border-b border-gray-200 bg-gray-50 text-left">{t("config.headerOptions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{advancedRows.map((row) => (
|
||||
<tr key={row.param}>
|
||||
<td className="py-2 px-4 border-b border-gray-200">{row.param}</td>
|
||||
<td className="py-2 px-4 border-b border-gray-200">{row.options}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<h3 className="text-xl font-semibold mb-3 flex items-center">
|
||||
<HardDrive className="h-5 w-5 mr-2 text-blue-500" />
|
||||
{t("diskSelection.heading")}
|
||||
</h3>
|
||||
<p className="mb-3">{t("diskSelection.intro")}</p>
|
||||
|
||||
<h4 className="text-lg font-medium mt-4 mb-2">{t("diskSelection.virtualHeading")}</h4>
|
||||
<ul className="list-disc pl-5 mb-4">
|
||||
{virtualItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`diskSelection.virtualItems.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h4 className="text-lg font-medium mt-4 mb-2">{t("diskSelection.physicalHeading")}</h4>
|
||||
<ul className="list-disc pl-5 mb-4">
|
||||
{physicalItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`diskSelection.physicalItems.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<h3 className="text-xl font-semibold mb-3 flex items-center">
|
||||
<Download className="h-5 w-5 mr-2 text-blue-500" />
|
||||
{t("loaderInstall.heading")}
|
||||
</h3>
|
||||
<p className="mb-3">{t("loaderInstall.intro1")}</p>
|
||||
<p className="mb-4">
|
||||
{t.rich("loaderInstall.intro2Rich", { strong })}
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
{t.rich("loaderInstall.customRich", { strong, code })}
|
||||
</p>
|
||||
<p className="mt-12 mb-4"></p>
|
||||
<p>{t("loaderInstall.uploadIntro")}</p>
|
||||
|
||||
<ImageWithCaption
|
||||
src="https://macrimi.github.io/ProxMenux/vm/synology/add_loader.png"
|
||||
alt={t("loaderInstall.imageAlt")}
|
||||
caption={t("loaderInstall.imageCaption")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-16">
|
||||
<h3 className="text-xl font-semibold mb-3 flex items-center">
|
||||
<Cpu className="h-5 w-5 mr-2 text-blue-500" />
|
||||
{t("vmCreation.heading")}
|
||||
</h3>
|
||||
<p className="mb-3">{t("vmCreation.intro")}</p>
|
||||
<ul className="list-disc pl-5 mb-4">
|
||||
{vmCreationItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`vmCreation.items.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-10">
|
||||
<h2 className="text-2xl font-semibold mt-24 mb-4 flex items-center">
|
||||
<Wrench className="h-6 w-6 mr-2 text-blue-500" />
|
||||
{t("stepGuide.heading")}
|
||||
</h2>
|
||||
<p className="mb-4">{t("stepGuide.intro")}</p>
|
||||
|
||||
<div className="bg-blue-50 p-4 rounded-lg mb-6">
|
||||
<h3 className="text-lg font-semibold mb-2">{t("stepGuide.selectorHeading")}</h3>
|
||||
<div className="flex space-x-4">
|
||||
{(["arc", "rr", "tinycore"] as LoaderKey[]).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveLoader(key)}
|
||||
className={`px-4 py-2 rounded-md font-medium ${
|
||||
activeLoader === key
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-white border border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{t(`stepGuide.loaderButtons.${key}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{steps.map((step, stepIdx) => {
|
||||
const media = step.loaders[activeLoader] || []
|
||||
return (
|
||||
<section key={step.id} className="mb-12 border-b pb-8">
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center" id={step.id}>
|
||||
<StepNumber number={stepIdx + 1} />
|
||||
{step.title}
|
||||
</h2>
|
||||
<p className="mb-4">{step.intro}</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="flex flex-col space-y-8">
|
||||
{media.map((m, idx) => (
|
||||
<div key={idx}>
|
||||
{m.htmlBefore && (
|
||||
<p className="mt-16 mb-2">
|
||||
{t.rich(`steps.${stepIdx}.loaders.${activeLoader}.${idx}.htmlBefore`, { strong, code })}
|
||||
</p>
|
||||
)}
|
||||
<ImageWithCaption src={m.src} alt={m.alt} caption={m.caption} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{step.outro && <p className="mt-4">{step.outro}</p>}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center">
|
||||
<CheckCircle className="h-6 w-6 mr-3 text-green-500" />
|
||||
{t("dsmInstall.heading")}
|
||||
</h2>
|
||||
<p className="mb-4">{t("dsmInstall.intro")}</p>
|
||||
<div className="bg-gray-100 p-4 rounded-md overflow-x-auto text-sm mb-4">
|
||||
<code>https://finds.synology.com</code>
|
||||
</div>
|
||||
<p className="mb-6">{t("dsmInstall.afterCode")}</p>
|
||||
<div className="flex flex-col space-y-8">
|
||||
<ImageWithCaption
|
||||
src="https://macrimi.github.io/ProxMenux/vm/synology/install_DSM.png"
|
||||
alt={t("dsmInstall.setupAlt")}
|
||||
caption={t("dsmInstall.setupCaption")}
|
||||
/>
|
||||
<p className="mt-8 mb-8">{t("dsmInstall.patience")}</p>
|
||||
<ImageWithCaption
|
||||
src="https://macrimi.github.io/ProxMenux/vm/synology/finish_install_DSM.png"
|
||||
alt={t("dsmInstall.finishAlt")}
|
||||
caption={t("dsmInstall.finishCaption")}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mt-20 mb-4 flex items-center">
|
||||
<Target className="h-6 w-6 mr-2 text-blue-500" />
|
||||
{t("tips.heading")}
|
||||
</h2>
|
||||
<ul className="list-disc pl-5 space-y-4">
|
||||
<li>{t("tips.introItem")}</li>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mt-2">
|
||||
{docLinks.map((dl) => (
|
||||
<a
|
||||
key={dl.url}
|
||||
href={dl.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center px-3 py-2 text-sm font-medium rounded-md bg-gray-100 text-gray-800 hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<Github className="h-5 w-5 mr-2" />
|
||||
{dl.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<li>{t("tips.olderModels")}</li>
|
||||
|
||||
<div className="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 mb-4">
|
||||
<p className="font-semibold">{t("tips.updateLabel")}</p>
|
||||
<p>{t("tips.updateBody")}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4 mb-4">
|
||||
<p className="font-semibold">{t("tips.importantLabel")}</p>
|
||||
<p>{t("tips.importantBody")}</p>
|
||||
</div>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
333
web/app/[locale]/docs/create-vm/system-linux/page.tsx
Normal file
333
web/app/[locale]/docs/create-vm/system-linux/page.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { Server, HardDrive } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.createVm.systemLinux.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/create-vm/system-linux",
|
||||
images: [
|
||||
{
|
||||
url: "/vm/menu_linux.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: t("ogImageAlt"),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type ConfigRow = { param: string; value?: string; valueRich?: string; options?: string; optionsRich?: string }
|
||||
type Distro = { name: string; variants: string[] }
|
||||
type StringItem = string
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function SystemLinuxPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.createVm.systemLinux" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { createVm: { systemLinux: {
|
||||
config: { defaultRows: ConfigRow[]; advancedRows: ConfigRow[] }
|
||||
storagePlan: { virtualDiskItems: StringItem[]; importDiskItems: StringItem[]; pciItems: StringItem[] }
|
||||
installOptions: { distros: Distro[] }
|
||||
endToEnd: { items: StringItem[] }
|
||||
postInstall: { trimItems: StringItem[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const defaultRows = messages.docs.createVm.systemLinux.config.defaultRows
|
||||
const advancedRows = messages.docs.createVm.systemLinux.config.advancedRows
|
||||
const virtualDiskItems = messages.docs.createVm.systemLinux.storagePlan.virtualDiskItems
|
||||
const importDiskItems = messages.docs.createVm.systemLinux.storagePlan.importDiskItems
|
||||
const pciItems = messages.docs.createVm.systemLinux.storagePlan.pciItems
|
||||
const distros = messages.docs.createVm.systemLinux.installOptions.distros
|
||||
const endToEndItems = messages.docs.createVm.systemLinux.endToEnd.items
|
||||
const trimItems = messages.docs.createVm.systemLinux.postInstall.trimItems
|
||||
const relatedItems = messages.docs.createVm.systemLinux.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const gpuLink = (chunks: React.ReactNode) => (
|
||||
<a href="/docs/hardware/gpu-vm-passthrough" className="text-blue-600 hover:underline">{chunks}</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={15}
|
||||
scriptPath="vm/select_linux_iso.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t("intro.body")}
|
||||
</Callout>
|
||||
|
||||
<div className="flex flex-col items-center my-6">
|
||||
<div className="w-full max-w-[768px] overflow-hidden rounded-md border border-gray-200">
|
||||
<Image
|
||||
src="/vm/menu_linux.png"
|
||||
alt={t("image.alt")}
|
||||
width={768}
|
||||
height={0}
|
||||
style={{ height: "auto" }}
|
||||
className="w-full object-contain"
|
||||
sizes="(max-width: 768px) 100vw, 768px"
|
||||
/>
|
||||
</div>
|
||||
<span className="mt-2 text-sm text-gray-600">{t("image.caption")}</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("config.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("config.intro")}</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("config.defaultHeading")}</h3>
|
||||
<div className="overflow-x-auto mb-4 rounded-md border border-gray-200">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold">{t("config.headerParam")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("config.headerValue")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-800">
|
||||
{defaultRows.map((row, idx) => (
|
||||
<tr key={row.param}>
|
||||
<td className="px-4 py-2">{row.param}</td>
|
||||
<td className="px-4 py-2">
|
||||
{row.valueRich ? t.rich(`config.defaultRows.${idx}.valueRich`, { code }) : row.value}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("config.advancedHeading")}</h3>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("config.advancedIntro")}</p>
|
||||
<div className="overflow-x-auto mb-4 rounded-md border border-gray-200">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold">{t("config.headerParam")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("config.headerOptions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-800">
|
||||
{advancedRows.map((row, idx) => (
|
||||
<tr key={row.param}>
|
||||
<td className="px-4 py-2">{row.param}</td>
|
||||
<td className="px-4 py-2">
|
||||
{row.optionsRich ? t.rich(`config.advancedRows.${idx}.optionsRich`, { code }) : row.options}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("storagePlan.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("storagePlan.body", { strong })}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 my-6">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.virtualDiskTitle")}</h3>
|
||||
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
|
||||
{virtualDiskItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`storagePlan.virtualDiskItems.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.importDiskTitle")}</h3>
|
||||
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
|
||||
{importDiskItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`storagePlan.importDiskItems.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4 md:col-span-2">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.pciTitle")}</h3>
|
||||
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
|
||||
{pciItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`storagePlan.pciItems.${idx}`, { em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("storagePlan.resetTitle")}>
|
||||
{t.rich("storagePlan.resetBody", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("gpu.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("gpu.body", { gpuLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("autoFeatures.heading")}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 my-4">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("autoFeatures.efiTitle")}</h3>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{t("autoFeatures.efiBody")}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("autoFeatures.isoTitle")}</h3>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{t.rich("autoFeatures.isoBody", { code })}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("autoFeatures.guestTitle")}</h3>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{t("autoFeatures.guestBody")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("installOptions.heading")}</h2>
|
||||
|
||||
<section className="mt-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Server className="h-8 w-8 text-blue-500" />
|
||||
<h3 className="text-xl font-semibold text-gray-900 m-0">{t("installOptions.officialHeading")}</h3>
|
||||
</div>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("installOptions.officialBody", { code })}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 my-4">
|
||||
{distros.map((d) => (
|
||||
<div key={d.name} className="rounded-md border border-gray-200 bg-white p-3">
|
||||
<div className="text-sm font-semibold text-gray-900">{d.name}</div>
|
||||
<ul className="mt-1 text-xs text-gray-600 space-y-0.5">
|
||||
{d.variants.map((v) => (
|
||||
<li key={v}>{v}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-[768px] overflow-hidden rounded-md border border-gray-200">
|
||||
<Image src="/vm/distro_linux.png" alt={t("installOptions.officialImageAlt")} width={768} height={0} style={{ height: "auto" }} className="w-full object-contain" sizes="(max-width: 768px) 100vw, 768px" />
|
||||
</div>
|
||||
<span className="mt-2 text-sm text-gray-600">{t("installOptions.officialImageCaption")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-10">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<HardDrive className="h-8 w-8 text-blue-500" />
|
||||
<h3 className="text-xl font-semibold text-gray-900 m-0">{t("installOptions.localHeading")}</h3>
|
||||
</div>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("installOptions.localBody", { code })}
|
||||
</p>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-[768px] overflow-hidden rounded-md border border-gray-200">
|
||||
<Image src="/vm/local-store.png" alt={t("installOptions.localImageAlt")} width={768} height={0} style={{ height: "auto" }} className="w-full object-contain" sizes="(max-width: 768px) 100vw, 768px" />
|
||||
</div>
|
||||
<span className="mt-2 text-sm text-gray-600">{t("installOptions.localImageCaption")}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("endToEnd.heading")}</h2>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{endToEndItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`endToEnd.items.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("postInstall.heading")}</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("postInstall.guestAgentHeading")}</h3>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("postInstall.guestAgentBody")}</p>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900 mb-1">{t("postInstall.debian")}</p>
|
||||
<CopyableCode code={`sudo apt update && sudo apt install qemu-guest-agent -y
|
||||
sudo systemctl enable --now qemu-guest-agent`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900 mb-1">{t("postInstall.fedora")}</p>
|
||||
<CopyableCode code={`sudo dnf install qemu-guest-agent -y
|
||||
sudo systemctl enable --now qemu-guest-agent`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900 mb-1">{t("postInstall.arch")}</p>
|
||||
<CopyableCode code={`sudo pacman -S qemu-guest-agent
|
||||
sudo systemctl enable --now qemu-guest-agent`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900 mb-1">{t("postInstall.opensuse")}</p>
|
||||
<CopyableCode code={`sudo zypper install qemu-guest-agent
|
||||
sudo systemctl enable --now qemu-guest-agent`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-3 text-gray-900">{t("postInstall.virtioHeading")}</h3>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("postInstall.virtioBody", { code })}
|
||||
</p>
|
||||
<Callout variant="warning" title={t("postInstall.virtioWarnTitle")}>
|
||||
{t("postInstall.virtioWarnBody")}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-3 text-gray-900">{t("postInstall.trimHeading")}</h3>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("postInstall.trimBody", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{trimItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`postInstall.trimItems.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-3 text-gray-900">{t("postInstall.balloonHeading")}</h3>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("postInstall.balloonBody", { code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
193
web/app/[locale]/docs/create-vm/system-nas/page.tsx
Normal file
193
web/app/[locale]/docs/create-vm/system-nas/page.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { ArrowRight, ExternalLink, HardDrive, Database, Server, MonitorIcon, Github } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.createVm.systemNas.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/create-vm/system-nas",
|
||||
images: [
|
||||
{
|
||||
url: "/vm/system-nas-menu.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: t("ogImageAlt"),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type NASCard = {
|
||||
name: string
|
||||
tagline: string
|
||||
icon: string
|
||||
base: string
|
||||
fileSystem: string
|
||||
href: string
|
||||
flow: "loader" | "auto-iso" | "dedicated"
|
||||
external?: boolean
|
||||
}
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
const ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
HardDrive,
|
||||
Database,
|
||||
Server,
|
||||
MonitorIcon,
|
||||
}
|
||||
|
||||
const FLOW_CLS: Record<string, string> = {
|
||||
loader: "bg-purple-100 text-purple-800 border-purple-200",
|
||||
"auto-iso": "bg-blue-100 text-blue-800 border-blue-200",
|
||||
dedicated: "bg-indigo-100 text-indigo-800 border-indigo-200",
|
||||
}
|
||||
|
||||
export default async function SystemNASPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.createVm.systemNas" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { createVm: { systemNas: {
|
||||
supported: { cards: NASCard[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const cards = messages.docs.createVm.systemNas.supported.cards
|
||||
const relatedItems = messages.docs.createVm.systemNas.related.items
|
||||
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const umbrelLink = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://community-scripts.github.io/ProxmoxVE/scripts?id=umbrel-os-vm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1 ml-1"
|
||||
>
|
||||
<Github className="h-3.5 w-3.5" /> {chunks}
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={3}
|
||||
scriptPath="vm/select_nas_iso.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t("intro.body")}
|
||||
</Callout>
|
||||
|
||||
<div className="flex flex-col items-center my-6">
|
||||
<div className="w-full max-w-[768px] overflow-hidden rounded-md border border-gray-200">
|
||||
<Image
|
||||
src="/vm/system-nas-menu.png"
|
||||
alt={t("image.alt")}
|
||||
width={768}
|
||||
height={0}
|
||||
style={{ height: "auto" }}
|
||||
className="w-full object-contain"
|
||||
sizes="(max-width: 768px) 100vw, 768px"
|
||||
/>
|
||||
</div>
|
||||
<span className="mt-2 text-sm text-gray-600">{t("image.caption")}</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("supported.heading")}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
{cards.map((card) => {
|
||||
const Icon = ICONS[card.icon] || HardDrive
|
||||
const flowLabel = t(`flowBadges.${card.flow}`)
|
||||
const cls = "group block rounded-lg border border-gray-200 bg-white p-5 transition-all hover:border-blue-300 hover:shadow-md"
|
||||
const inner = (
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-blue-50 text-blue-600">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base font-semibold text-gray-900">{card.name}</h3>
|
||||
<p className="mt-1 text-sm text-gray-600 leading-relaxed">{card.tagline}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${FLOW_CLS[card.flow]}`}>
|
||||
{flowLabel}
|
||||
</span>
|
||||
</div>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-gray-600">
|
||||
<div>
|
||||
<dt className="font-semibold text-gray-700">{t("labels.base")}</dt>
|
||||
<dd>{card.base}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-semibold text-gray-700">{t("labels.fileSystem")}</dt>
|
||||
<dd>{card.fileSystem}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<span className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-blue-600 group-hover:text-blue-700">
|
||||
{t("labels.viewDetails")}
|
||||
{card.external ? (
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return card.external ? (
|
||||
<a key={card.name} href={card.href} target="_blank" rel="noopener noreferrer" className={cls}>
|
||||
{inner}
|
||||
</a>
|
||||
) : (
|
||||
<Link key={card.name} href={card.href} className={cls}>
|
||||
{inner}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("umbrel.title")}>
|
||||
{t.rich("umbrel.bodyRich", { strong, umbrelLink })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("zfsMem.title")}>
|
||||
{t.rich("zfsMem.bodyRich", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
427
web/app/[locale]/docs/create-vm/system-nas/synology/page.tsx
Normal file
427
web/app/[locale]/docs/create-vm/system-nas/synology/page.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { useState } from "react"
|
||||
import { Github, ExternalLink } from "lucide-react"
|
||||
import { useTranslations, useMessages } from "next-intl"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
type LoaderKey = "arc" | "rr" | "tinycore"
|
||||
|
||||
type LoaderLink = { name: string; url: string }
|
||||
type DefaultRow = { param: string; valueRich: string }
|
||||
type AdvancedRow = { param: string; optionsRich: string }
|
||||
type ItemAltCaption = { alt: string; caption: string }
|
||||
type DocLink = { label: string; url: string }
|
||||
type RelatedItem = { href: string; label: string; tail: string }
|
||||
|
||||
export default function SynologyPage() {
|
||||
const [activeLoader, setActiveLoader] = useState<LoaderKey>("arc")
|
||||
const t = useTranslations("docs.createVm.systemNas.synology")
|
||||
const messages = useMessages() as unknown as {
|
||||
docs: {
|
||||
createVm: {
|
||||
systemNas: {
|
||||
synology: {
|
||||
supportedLoaders: { loaders: LoaderLink[] }
|
||||
config: { defaultRowsRich: DefaultRow[]; advancedRowsRich: AdvancedRow[] }
|
||||
storagePlan: { virtualItemsRich: string[]; importItemsRich: string[]; pciItemsRich: string[] }
|
||||
vmCreation: { itemsRich: string[] }
|
||||
step3: { arc: ItemAltCaption[]; rr: ItemAltCaption[]; tinycore: ItemAltCaption[] }
|
||||
step4: { rr: ItemAltCaption[]; tinycore: ItemAltCaption[] }
|
||||
tips: { docLinks: DocLink[] }
|
||||
related: { itemsRich: RelatedItem[] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const s = messages.docs.createVm.systemNas.synology
|
||||
const loaders = s.supportedLoaders.loaders
|
||||
const defaultRows = s.config.defaultRowsRich
|
||||
const advancedRows = s.config.advancedRowsRich
|
||||
const virtualItems = s.storagePlan.virtualItemsRich
|
||||
const importItems = s.storagePlan.importItemsRich
|
||||
const pciItems = s.storagePlan.pciItemsRich
|
||||
const vmCreationItems = s.vmCreation.itemsRich
|
||||
const step3Arc = s.step3.arc
|
||||
const step3Rr = s.step3.rr
|
||||
const step3Tc = s.step3.tinycore
|
||||
const step4Rr = s.step4.rr
|
||||
const step4Tc = s.step4.tinycore
|
||||
const docLinks = s.tips.docLinks
|
||||
const relatedItems = s.related.itemsRich
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const gpuLink = (chunks: React.ReactNode) => (
|
||||
<a href="/docs/hardware/gpu-vm-passthrough" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={30}
|
||||
scriptPath="vm/synology.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("whatThisDoes.title")}>
|
||||
{t.rich("whatThisDoes.bodyRich", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("supportedLoaders.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("supportedLoaders.intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{loaders.map((l) => (
|
||||
<li key={l.url}>
|
||||
<a
|
||||
href={l.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{l.name} <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
<li>{t.rich("supportedLoaders.customRich", { strong, code })}</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("config.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("config.intro")}</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("config.defaultHeading")}</h3>
|
||||
<div className="overflow-x-auto mb-4 rounded-md border border-gray-200">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold">{t("config.headerParam")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("config.headerValue")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-800">
|
||||
{defaultRows.map((row, idx) => (
|
||||
<tr key={row.param}>
|
||||
<td className="px-4 py-2">{row.param}</td>
|
||||
<td className="px-4 py-2">{t.rich(`config.defaultRowsRich.${idx}.valueRich`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("config.advancedHeading")}</h3>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("config.advancedIntro")}</p>
|
||||
<div className="overflow-x-auto mb-4 rounded-md border border-gray-200">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold">{t("config.headerParam")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("config.headerOptions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-800">
|
||||
{advancedRows.map((row, idx) => (
|
||||
<tr key={row.param}>
|
||||
<td className="px-4 py-2">{row.param}</td>
|
||||
<td className="px-4 py-2">{t.rich(`config.advancedRowsRich.${idx}.optionsRich`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("storagePlan.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("storagePlan.introRich", { strong })}</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 my-6">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.virtualHeading")}</h3>
|
||||
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
|
||||
{virtualItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`storagePlan.virtualItemsRich.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.importHeading")}</h3>
|
||||
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
|
||||
{importItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`storagePlan.importItemsRich.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4 md:col-span-2">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.pciHeading")}</h3>
|
||||
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
|
||||
{pciItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`storagePlan.pciItemsRich.${idx}`, { code, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("storagePlan.resetCalloutTitle")}>
|
||||
{t.rich("storagePlan.resetCalloutBodyRich", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("gpu.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("gpu.bodyRich", { link: gpuLink })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("loaderInstall.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("loaderInstall.intro1Rich", { strong })}</p>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("loaderInstall.intro2Rich", { strong, code })}</p>
|
||||
<p className="mb-2 text-gray-800 leading-relaxed">{t("loaderInstall.uploadIntro")}</p>
|
||||
<ImageCaption src="/vm/synology/add_loader.png" alt={t("loaderInstall.imageAlt")} caption={t("loaderInstall.imageCaption")} />
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("vmCreation.heading")}</h2>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t.rich("vmCreation.introRich", { code })}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{vmCreationItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`vmCreation.itemsRich.${idx}`, { code, strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("stepByStep.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("stepByStep.intro")}</p>
|
||||
|
||||
<Callout variant="warning" title={t("stepByStep.warnCalloutTitle")}>
|
||||
{t("stepByStep.warnCalloutBody")}
|
||||
</Callout>
|
||||
|
||||
<div className="flex flex-wrap gap-2 my-6">
|
||||
{(["arc", "rr", "tinycore"] as LoaderKey[]).map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setActiveLoader(k)}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
activeLoader === k
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-white border border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{t(`loaderLabels.${k}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<StepSection n={1} title={t("step1.title")} stepLabel={t("stepBadge")}>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("step1.intro")}</p>
|
||||
|
||||
{activeLoader === "arc" && (
|
||||
<div className="flex flex-col space-y-8">
|
||||
<p className="text-gray-800 leading-relaxed">{t.rich("step1.arc.webRich", { strong, code })}</p>
|
||||
<ImageCaption src="/vm/synology/arc/arc_1_0_1.png" alt={t("step1.arc.webAlt")} caption={t("step1.arc.webCaption")} />
|
||||
<p className="text-gray-800 leading-relaxed">{t.rich("step1.arc.termRich", { strong })}</p>
|
||||
<ImageCaption src="/vm/synology/arc/arc_1_1_1.png" alt={t("step1.arc.termAlt")} caption={t("step1.arc.termCaption")} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeLoader === "rr" && (
|
||||
<div className="flex flex-col space-y-8">
|
||||
<p className="text-gray-800 leading-relaxed">{t.rich("step1.rr.webRich", { strong, code })}</p>
|
||||
<ImageCaption src="/vm/synology/rr/rr_2_0_2.png" alt={t("step1.rr.webAlt")} caption={t("step1.rr.webCaption")} />
|
||||
<p className="text-gray-800 leading-relaxed">{t.rich("step1.rr.termRich", { strong, code })}</p>
|
||||
<ImageCaption src="/vm/synology/rr/rr_2_1_1.png" alt={t("step1.rr.termAlt")} caption={t("step1.rr.termCaption")} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeLoader === "tinycore" && (
|
||||
<div className="flex flex-col space-y-8">
|
||||
<p className="text-gray-800 leading-relaxed">{t.rich("step1.tinycore.webRich", { strong, code })}</p>
|
||||
<ImageCaption src="/vm/synology/tinycore/tinycore_3_0_1.png" alt={t("step1.tinycore.webAlt")} caption={t("step1.tinycore.webCaption")} />
|
||||
<p className="text-gray-800 leading-relaxed">{t.rich("step1.tinycore.termRich", { strong })}</p>
|
||||
<ImageCaption src="/vm/synology/tinycore/tinycore_3_1_1.png" alt={t("step1.tinycore.termAlt")} caption={t("step1.tinycore.termCaption")} />
|
||||
</div>
|
||||
)}
|
||||
</StepSection>
|
||||
|
||||
<StepSection n={2} title={t("step2.title")} stepLabel={t("stepBadge")}>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("step2.introRich", { strong })}</p>
|
||||
{activeLoader === "arc" && (
|
||||
<ImageCaption src="/vm/synology/arc/arc_1_2_1.png" alt={t("step2.arc.alt")} caption={t("step2.arc.caption")} />
|
||||
)}
|
||||
{activeLoader === "rr" && (
|
||||
<ImageCaption src="/vm/synology/rr/rr_2_2_1.png" alt={t("step2.rr.alt")} caption={t("step2.rr.caption")} />
|
||||
)}
|
||||
{activeLoader === "tinycore" && (
|
||||
<ImageCaption src="/vm/synology/tinycore/tinycore_3_2_1.png" alt={t("step2.tinycore.alt")} caption={t("step2.tinycore.caption")} />
|
||||
)}
|
||||
</StepSection>
|
||||
|
||||
<StepSection n={3} title={t("step3.title")} stepLabel={t("stepBadge")}>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("step3.intro")}</p>
|
||||
{activeLoader === "arc" && (
|
||||
<div className="flex flex-col space-y-10">
|
||||
<ImageCaption src="/vm/synology/arc/arc_1_3_1.png" alt={step3Arc[0].alt} caption={step3Arc[0].caption} />
|
||||
<ImageCaption src="/vm/synology/arc/arc_1_3_2.png" alt={step3Arc[1].alt} caption={step3Arc[1].caption} />
|
||||
</div>
|
||||
)}
|
||||
{activeLoader === "rr" && (
|
||||
<div className="flex flex-col space-y-10">
|
||||
<ImageCaption src="/vm/synology/rr/rr_2_3_1.png" alt={step3Rr[0].alt} caption={step3Rr[0].caption} />
|
||||
<ImageCaption src="/vm/synology/rr/rr_2_3_2.png" alt={step3Rr[1].alt} caption={step3Rr[1].caption} />
|
||||
<ImageCaption src="/vm/synology/rr/rr_2_3_3.png" alt={step3Rr[2].alt} caption={step3Rr[2].caption} />
|
||||
</div>
|
||||
)}
|
||||
{activeLoader === "tinycore" && (
|
||||
<div className="flex flex-col space-y-10">
|
||||
<ImageCaption src="/vm/synology/tinycore/tinycore_3_3_1.png" alt={step3Tc[0].alt} caption={step3Tc[0].caption} />
|
||||
<ImageCaption src="/vm/synology/tinycore/tinycore_3_3_2.png" alt={step3Tc[1].alt} caption={step3Tc[1].caption} />
|
||||
</div>
|
||||
)}
|
||||
</StepSection>
|
||||
|
||||
<StepSection n={4} title={t("step4.title")} stepLabel={t("stepBadge")}>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("step4.intro")}</p>
|
||||
{activeLoader === "arc" && (
|
||||
<div className="flex flex-col space-y-10">
|
||||
<p className="text-gray-800 leading-relaxed">{t.rich("step4.arc.autoRich", { strong })}</p>
|
||||
<ImageCaption src="/vm/synology/arc/arc_1_4_1.png" alt={t("step4.arc.autoAlt")} caption={t("step4.arc.autoCaption")} />
|
||||
<p className="text-gray-800 leading-relaxed">{t("step4.arc.manualRich")}</p>
|
||||
<ImageCaption src="/vm/synology/arc/arc_1_4_2.png" alt={t("step4.arc.manualAlt")} caption={t("step4.arc.manualCaption")} />
|
||||
<ImageCaption src="/vm/synology/arc/arc_1_4_3.png" alt={t("step4.arc.snMacAlt")} caption={t("step4.arc.snMacCaption")} />
|
||||
<ImageCaption src="/vm/synology/arc/arc_1_4_4.png" alt={t("step4.arc.portmapAlt")} caption={t("step4.arc.portmapCaption")} />
|
||||
<ImageCaption src="/vm/synology/arc/arc_1_4_5.png" alt={t("step4.arc.addonsAlt")} caption={t("step4.arc.addonsCaption")} />
|
||||
</div>
|
||||
)}
|
||||
{activeLoader === "rr" && (
|
||||
<div className="flex flex-col space-y-10">
|
||||
<ImageCaption src="/vm/synology/rr/rr_2_4_1.png" alt={step4Rr[0].alt} caption={step4Rr[0].caption} />
|
||||
<ImageCaption src="/vm/synology/rr/rr_2_4_2.png" alt={step4Rr[1].alt} caption={step4Rr[1].caption} />
|
||||
<ImageCaption src="/vm/synology/rr/rr_2_4_3.png" alt={step4Rr[2].alt} caption={step4Rr[2].caption} />
|
||||
</div>
|
||||
)}
|
||||
{activeLoader === "tinycore" && (
|
||||
<div className="flex flex-col space-y-10">
|
||||
<ImageCaption src="/vm/synology/tinycore/tinycore_3_4_1.png" alt={step4Tc[0].alt} caption={step4Tc[0].caption} />
|
||||
<ImageCaption src="/vm/synology/tinycore/tinycore_3_4_2.png" alt={step4Tc[1].alt} caption={step4Tc[1].caption} />
|
||||
<ImageCaption src="/vm/synology/tinycore/tinycore_3_4_3.png" alt={step4Tc[2].alt} caption={step4Tc[2].caption} />
|
||||
<ImageCaption src="/vm/synology/tinycore/tinycore_3_4_4.png" alt={step4Tc[3].alt} caption={step4Tc[3].caption} />
|
||||
</div>
|
||||
)}
|
||||
</StepSection>
|
||||
|
||||
<StepSection n={5} title={t("step5.title")} stepLabel={t("stepBadge")}>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("step5.introRich", { strong })}</p>
|
||||
{activeLoader === "arc" && (
|
||||
<ImageCaption src="/vm/synology/arc/arc_1_5_1.png" alt={t("step5.arc.alt")} caption={t("step5.arc.caption")} />
|
||||
)}
|
||||
{activeLoader === "rr" && (
|
||||
<ImageCaption src="/vm/synology/rr/rr_2_5_1.png" alt={t("step5.rr.alt")} caption={t("step5.rr.caption")} />
|
||||
)}
|
||||
{activeLoader === "tinycore" && (
|
||||
<ImageCaption src="/vm/synology/tinycore/tinycore_3_5_1.png" alt={t("step5.tinycore.alt")} caption={t("step5.tinycore.caption")} />
|
||||
)}
|
||||
</StepSection>
|
||||
|
||||
<StepSection n={6} title={t("step6.title")} stepLabel={t("stepBadge")}>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("step6.intro")}</p>
|
||||
{activeLoader === "arc" && (
|
||||
<ImageCaption src="/vm/synology/arc/arc_1_6_1.png" alt={t("step6.arc.alt")} caption={t("step6.arc.caption")} />
|
||||
)}
|
||||
{activeLoader === "rr" && (
|
||||
<ImageCaption src="/vm/synology/rr/rr_2_6_1.png" alt={t("step6.rr.alt")} caption={t("step6.rr.caption")} />
|
||||
)}
|
||||
{activeLoader === "tinycore" && (
|
||||
<ImageCaption src="/vm/synology/tinycore/tinycore_3_6_1.png" alt={t("step6.tinycore.alt")} caption={t("step6.tinycore.caption")} />
|
||||
)}
|
||||
</StepSection>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("dsmInstall.heading")}</h2>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("dsmInstall.intro")}</p>
|
||||
<pre className="bg-gray-100 p-3 rounded-md overflow-x-auto text-sm font-mono mb-4">
|
||||
<code>https://finds.synology.com</code>
|
||||
</pre>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t("dsmInstall.afterCode")}</p>
|
||||
<div className="flex flex-col space-y-10">
|
||||
<ImageCaption src="/vm/synology/install_DSM.png" alt={t("dsmInstall.setupAlt")} caption={t("dsmInstall.setupCaption")} />
|
||||
<p className="text-gray-800 leading-relaxed">{t("dsmInstall.patience")}</p>
|
||||
<ImageCaption src="/vm/synology/finish_install_DSM.png" alt={t("dsmInstall.finishAlt")} caption={t("dsmInstall.finishCaption")} />
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("tips.heading")}</h2>
|
||||
|
||||
<Callout variant="tip" title={t("tips.recentTitle")}>
|
||||
{t("tips.recentBody")}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="info" title={t("tips.updateTitle")}>
|
||||
{t("tips.updateBody")}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="warning" title={t("tips.warnTitle")}>
|
||||
{t("tips.warnBody")}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-3 text-gray-900">{t("tips.docsHeading")}</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{docLinks.map((dl) => (
|
||||
<a
|
||||
key={dl.url}
|
||||
href={dl.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center px-3 py-2 text-sm font-medium rounded-md bg-gray-100 text-gray-800 hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<Github className="h-4 w-4 mr-2" />
|
||||
{dl.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ImageCaption({ src, alt, caption }: { src: string; alt: string; caption: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full max-w-[768px] mx-auto my-4">
|
||||
<div className="w-full overflow-hidden rounded-md border border-gray-200">
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={768}
|
||||
height={0}
|
||||
style={{ height: "auto" }}
|
||||
className="w-full object-contain"
|
||||
sizes="(max-width: 768px) 100vw, 768px"
|
||||
/>
|
||||
</div>
|
||||
<span className="mt-2 text-sm text-gray-600">{caption}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepSection({ n, title, stepLabel, children }: { n: number; title: string; stepLabel: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="mt-10 border-b border-gray-200 pb-8">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-0.5 text-xs font-semibold text-blue-800">
|
||||
{stepLabel} {n}
|
||||
</span>
|
||||
<h3 className="text-xl font-semibold text-gray-900 m-0">{title}</h3>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { ExternalLink, Database, Server, HardDrive, MonitorIcon } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.createVm.systemNas.systemNasOthers.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/create-vm/system-nas/system-nas-others",
|
||||
images: [
|
||||
{
|
||||
url: "/vm/system-nas-menu.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: t("ogImageAlt"),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type DefaultRow = { param: string; valueRich: string }
|
||||
type AdvancedRow = { param: string; optionsRich: string }
|
||||
type RelatedItem = { href: string; label: string; tail: string }
|
||||
type SystemEntry = {
|
||||
id: string
|
||||
title: string
|
||||
icon: string
|
||||
officialName: string
|
||||
officialUrl: string
|
||||
description: string
|
||||
specs: string[]
|
||||
shellImg?: string
|
||||
webImg?: string
|
||||
shellAlt?: string
|
||||
webAlt?: string
|
||||
}
|
||||
|
||||
const ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Database,
|
||||
Server,
|
||||
HardDrive,
|
||||
MonitorIcon,
|
||||
}
|
||||
|
||||
export default async function OtherNASSystemsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.createVm.systemNas.systemNasOthers" })
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: {
|
||||
createVm: {
|
||||
systemNas: {
|
||||
systemNasOthers: {
|
||||
config: { defaultRowsRich: DefaultRow[]; advancedRowsRich: AdvancedRow[] }
|
||||
storagePlan: { virtualItemsRich: string[]; importItemsRich: string[]; pciItemsRich: string[] }
|
||||
endToEnd: { itemsRich: string[] }
|
||||
systems: Record<string, SystemEntry>
|
||||
related: { itemsRich: RelatedItem[] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const o = messages.docs.createVm.systemNas.systemNasOthers
|
||||
const defaultRows = o.config.defaultRowsRich
|
||||
const advancedRows = o.config.advancedRowsRich
|
||||
const virtualItems = o.storagePlan.virtualItemsRich
|
||||
const importItems = o.storagePlan.importItemsRich
|
||||
const pciItems = o.storagePlan.pciItemsRich
|
||||
const endToEndItems = o.endToEnd.itemsRich
|
||||
const systemOrder = ["truenasScale", "truenasCore", "openmediavault", "xigmanas", "rockstor", "zimaos"]
|
||||
const relatedItems = o.related.itemsRich
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const note = (chunks: React.ReactNode) => <span className="text-xs text-gray-500">{chunks}</span>
|
||||
const gpuLink = (chunks: React.ReactNode) => (
|
||||
<a href="/docs/hardware/gpu-vm-passthrough" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={15}
|
||||
scriptPath="vm/select_nas_iso.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.bodyRich", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("config.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("config.intro")}</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("config.defaultHeading")}</h3>
|
||||
<div className="overflow-x-auto mb-4 rounded-md border border-gray-200">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold">{t("config.headerParam")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("config.headerValue")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-800">
|
||||
{defaultRows.map((row, idx) => (
|
||||
<tr key={row.param}>
|
||||
<td className="px-4 py-2">{row.param}</td>
|
||||
<td className="px-4 py-2">{t.rich(`config.defaultRowsRich.${idx}.valueRich`, { code, note })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("config.advancedHeading")}</h3>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("config.advancedIntro")}</p>
|
||||
<div className="overflow-x-auto mb-4 rounded-md border border-gray-200">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold">{t("config.headerParam")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("config.headerOptions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-800">
|
||||
{advancedRows.map((row, idx) => (
|
||||
<tr key={row.param}>
|
||||
<td className="px-4 py-2">{row.param}</td>
|
||||
<td className="px-4 py-2">{t.rich(`config.advancedRowsRich.${idx}.optionsRich`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("config.zfsCalloutTitle")}>
|
||||
{t("config.zfsCalloutBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("storagePlan.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("storagePlan.intro")}</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 my-6">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.virtualHeading")}</h3>
|
||||
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
|
||||
{virtualItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`storagePlan.virtualItemsRich.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.importHeading")}</h3>
|
||||
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
|
||||
{importItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`storagePlan.importItemsRich.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4 md:col-span-2">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.pciHeading")}</h3>
|
||||
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
|
||||
{pciItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`storagePlan.pciItemsRich.${idx}`, { code, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("storagePlan.resetCalloutTitle")}>
|
||||
{t.rich("storagePlan.resetCalloutBodyRich", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("gpu.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("gpu.bodyRich", { link: gpuLink })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("autoFeatures.heading")}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 my-4">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("autoFeatures.efiTitle")}</h3>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{t("autoFeatures.efiBody")}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("autoFeatures.isoTitle")}</h3>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{t.rich("autoFeatures.isoBodyRich", { code })}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("autoFeatures.guestTitle")}</h3>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{t("autoFeatures.guestBody")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("endToEnd.heading")}</h2>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{endToEndItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`endToEnd.itemsRich.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-6 text-gray-900">{t("perSystem.heading")}</h2>
|
||||
|
||||
{systemOrder.map((key) => {
|
||||
const sys = o.systems[key]
|
||||
const Icon = ICONS[sys.icon] ?? Database
|
||||
return (
|
||||
<NASSection
|
||||
key={sys.id}
|
||||
id={sys.id}
|
||||
title={sys.title}
|
||||
icon={<Icon className="h-6 w-6 text-blue-500" />}
|
||||
officialName={sys.officialName}
|
||||
officialUrl={sys.officialUrl}
|
||||
description={sys.description}
|
||||
specs={sys.specs}
|
||||
shellImg={sys.shellImg}
|
||||
webImg={sys.webImg}
|
||||
shellAlt={sys.shellAlt}
|
||||
webAlt={sys.webAlt}
|
||||
shellLabel={t("perSystem.shellLabel")}
|
||||
webLabel={t("perSystem.webLabel")}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface NASSectionProps {
|
||||
id: string
|
||||
title: string
|
||||
icon: React.ReactNode
|
||||
officialName: string
|
||||
officialUrl: string
|
||||
description: string
|
||||
specs: string[]
|
||||
shellImg?: string
|
||||
webImg?: string
|
||||
shellAlt?: string
|
||||
webAlt?: string
|
||||
shellLabel: string
|
||||
webLabel: string
|
||||
}
|
||||
|
||||
function NASSection({
|
||||
id,
|
||||
title,
|
||||
icon,
|
||||
officialName,
|
||||
officialUrl,
|
||||
description,
|
||||
specs,
|
||||
shellImg,
|
||||
webImg,
|
||||
shellAlt,
|
||||
webAlt,
|
||||
shellLabel,
|
||||
webLabel,
|
||||
}: NASSectionProps) {
|
||||
return (
|
||||
<section id={id} className="mt-10 scroll-mt-24 border-b border-gray-200 pb-8">
|
||||
<h3 className="text-xl font-semibold mb-3 flex items-center flex-wrap gap-2 text-gray-900">
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
<a
|
||||
href={officialUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-2 text-sm font-normal text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{officialName} <ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{description}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{specs.map((s, i) => (
|
||||
<li key={i}>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
{(shellImg || webImg) && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
|
||||
{shellImg && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">{shellLabel}</h4>
|
||||
<div className="overflow-hidden rounded-md border border-gray-200">
|
||||
<Image src={shellImg} alt={shellAlt ?? `${title} shell interface`} width={600} height={400} className="w-full object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{webImg && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">{webLabel}</h4>
|
||||
<div className="overflow-hidden rounded-md border border-gray-200">
|
||||
<Image src={webImg} alt={webAlt ?? `${title} web interface`} width={600} height={400} className="w-full object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
314
web/app/[locale]/docs/create-vm/system-windows/page.tsx
Normal file
314
web/app/[locale]/docs/create-vm/system-windows/page.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { ExternalLink, Server } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.createVm.systemWindows.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/create-vm/system-windows",
|
||||
images: [
|
||||
{
|
||||
url: "/vm/menu_windows.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: t("ogImageAlt"),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type ConfigRow = { param: string; value?: string; valueRich?: string; options?: string; optionsRich?: string }
|
||||
type StringItem = string
|
||||
type VirtioStep = { title: string; body?: string; bodyRich?: string; img: string; caption: string }
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function SystemWindowsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.createVm.systemWindows" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { createVm: { systemWindows: {
|
||||
config: { defaultRows: ConfigRow[]; advancedRows: ConfigRow[] }
|
||||
storagePlan: { virtualDiskItems: StringItem[]; importDiskItems: StringItem[]; pciItems: StringItem[] }
|
||||
installOptions: { uupItems: StringItem[] }
|
||||
endToEnd: { items: StringItem[] }
|
||||
virtio: { steps: VirtioStep[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const defaultRows = messages.docs.createVm.systemWindows.config.defaultRows
|
||||
const advancedRows = messages.docs.createVm.systemWindows.config.advancedRows
|
||||
const virtualDiskItems = messages.docs.createVm.systemWindows.storagePlan.virtualDiskItems
|
||||
const importDiskItems = messages.docs.createVm.systemWindows.storagePlan.importDiskItems
|
||||
const pciItems = messages.docs.createVm.systemWindows.storagePlan.pciItems
|
||||
const uupItems = messages.docs.createVm.systemWindows.installOptions.uupItems
|
||||
const endToEndItems = messages.docs.createVm.systemWindows.endToEnd.items
|
||||
const virtioSteps = messages.docs.createVm.systemWindows.virtio.steps
|
||||
const relatedItems = messages.docs.createVm.systemWindows.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const gpuLink = (chunks: React.ReactNode) => (
|
||||
<a href="/docs/hardware/gpu-vm-passthrough" className="text-blue-600 hover:underline">{chunks}</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={20}
|
||||
scriptPath="vm/select_windows_iso.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<div className="flex flex-col items-center my-6">
|
||||
<div className="w-full max-w-[768px] overflow-hidden rounded-md border border-gray-200">
|
||||
<Image
|
||||
src="/vm/menu_windows.png"
|
||||
alt={t("image.alt")}
|
||||
width={768}
|
||||
height={0}
|
||||
style={{ height: "auto" }}
|
||||
className="w-full object-contain"
|
||||
sizes="(max-width: 768px) 100vw, 768px"
|
||||
/>
|
||||
</div>
|
||||
<span className="mt-2 text-sm text-gray-600">{t("image.caption")}</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("config.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("config.intro")}</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("config.defaultHeading")}</h3>
|
||||
<div className="overflow-x-auto mb-4 rounded-md border border-gray-200">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold">{t("config.headerParam")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("config.headerValue")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-800">
|
||||
{defaultRows.map((row, idx) => (
|
||||
<tr key={row.param}>
|
||||
<td className="px-4 py-2">{row.param}</td>
|
||||
<td className="px-4 py-2">
|
||||
{row.valueRich ? t.rich(`config.defaultRows.${idx}.valueRich`, { code }) : row.value}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("config.advancedHeading")}</h3>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("config.advancedIntro")}</p>
|
||||
<div className="overflow-x-auto mb-4 rounded-md border border-gray-200">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold">{t("config.headerParam")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("config.headerOptions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-800">
|
||||
{advancedRows.map((row, idx) => (
|
||||
<tr key={row.param}>
|
||||
<td className="px-4 py-2">{row.param}</td>
|
||||
<td className="px-4 py-2">
|
||||
{row.optionsRich ? t.rich(`config.advancedRows.${idx}.optionsRich`, { code }) : row.options}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="warning" title={t("config.tpmWarnTitle")}>
|
||||
{t("config.tpmWarnBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("storagePlan.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("storagePlan.body", { strong })}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 my-6">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.virtualDiskTitle")}</h3>
|
||||
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
|
||||
{virtualDiskItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`storagePlan.virtualDiskItems.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.importDiskTitle")}</h3>
|
||||
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
|
||||
{importDiskItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`storagePlan.importDiskItems.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4 md:col-span-2">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("storagePlan.pciTitle")}</h3>
|
||||
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1">
|
||||
{pciItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`storagePlan.pciItems.${idx}`, { em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("storagePlan.resetTitle")}>
|
||||
{t.rich("storagePlan.resetBody", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("gpu.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("gpu.body", { gpuLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("autoFeatures.heading")}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 my-4">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("autoFeatures.efiTitle")}</h3>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{t("autoFeatures.efiBody")}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("autoFeatures.tpmTitle")}</h3>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{t("autoFeatures.tpmBody")}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("autoFeatures.isoTitle")}</h3>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{t.rich("autoFeatures.isoBody", { code, em })}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("autoFeatures.guestTitle")}</h3>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{t.rich("autoFeatures.guestBody", { code })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("installOptions.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("installOptions.intro")}</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 my-6">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 flex items-center justify-center rounded-md bg-blue-50">
|
||||
<Image src="https://uupdump.net/static/uupdump/1.1.0/img/logo.svg" alt={t("installOptions.uupLogoAlt")} width={40} height={40} className="object-contain" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 m-0">{t("installOptions.uupTitle")}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 leading-relaxed mb-4">
|
||||
{t.rich("installOptions.uupBody", { strong })}
|
||||
</p>
|
||||
<ul className="list-disc pl-5 text-sm text-gray-700 leading-relaxed space-y-1 mb-4">
|
||||
{uupItems.map((_, idx) => (
|
||||
<li key={idx}>{t(`installOptions.uupItems.${idx}`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link href="/docs/utils/UUp-Dump-ISO-Creator" className="inline-flex items-center gap-1 text-sm text-blue-600 hover:underline">
|
||||
{t("installOptions.uupLearnMore")}
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 flex items-center justify-center rounded-md bg-blue-50 text-blue-600">
|
||||
<Server className="h-7 w-7" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 m-0">{t("installOptions.localTitle")}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 leading-relaxed mb-4">
|
||||
{t.rich("installOptions.localBody", { code })}
|
||||
</p>
|
||||
<div className="mt-4 overflow-hidden rounded-md border border-gray-200">
|
||||
<Image src="/vm/local-store-windows.png" alt={t("installOptions.localImageAlt")} width={600} height={400} className="w-full object-contain" />
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-600">{t("installOptions.localImageCaption")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("endToEnd.heading")}</h2>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{endToEndItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`endToEnd.items.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("virtio.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("virtio.body", { code })}
|
||||
</p>
|
||||
|
||||
<Callout variant="warning" title={t("virtio.warnTitle")}>
|
||||
{t.rich("virtio.warnBody", { code })}
|
||||
</Callout>
|
||||
|
||||
{virtioSteps.map((step, idx) => (
|
||||
<section key={idx} className="mt-8 border-b border-gray-200 pb-8">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-0.5 text-xs font-semibold text-blue-800">
|
||||
{t("virtio.stepLabel")} {idx + 1}
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900 m-0">{step.title}</h3>
|
||||
</div>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{step.bodyRich ? t.rich(`virtio.steps.${idx}.bodyRich`, { strong, code }) : step.body}
|
||||
</p>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-[768px] overflow-hidden rounded-md border border-gray-200">
|
||||
<Image src={step.img} alt={step.caption} width={768} height={0} style={{ height: "auto" }} className="w-full object-contain" sizes="(max-width: 768px) 100vw, 768px" />
|
||||
</div>
|
||||
<span className="mt-2 text-sm text-gray-600">{step.caption}</span>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<Callout variant="tip" title={t("virtio.tipTitle")}>
|
||||
{t.rich("virtio.tipBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.diskManager.addControllerNvmeVm.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/disk-manager/add-controller-nvme-vm",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type StepData = {
|
||||
title: string
|
||||
body?: string
|
||||
bodyRich?: string
|
||||
items?: string[]
|
||||
outro?: string
|
||||
img?: string
|
||||
alt?: string
|
||||
caption?: string
|
||||
}
|
||||
type StringItem = string
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function AddControllerNVMeVMPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.diskManager.addControllerNvmeVm" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { diskManager: { addControllerNvmeVm: {
|
||||
prereqs: { items: StringItem[] }
|
||||
steps: { list: StepData[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const prereqItems = messages.docs.diskManager.addControllerNvmeVm.prereqs.items
|
||||
const stepList = messages.docs.diskManager.addControllerNvmeVm.steps.list
|
||||
const relatedItems = messages.docs.diskManager.addControllerNvmeVm.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={15}
|
||||
scriptPath="storage/add_controller_nvme_vm.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { strong, code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howRuns.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("howRuns.body")}</p>
|
||||
|
||||
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
|
||||
{`┌─────────────────────────────────────────────┐
|
||||
│ PHASE 1 — Detect, validate, plan │
|
||||
│ (nothing touched yet) │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
▼
|
||||
qm list — user picks target VM
|
||||
│
|
||||
▼
|
||||
IOMMU status on the running kernel
|
||||
├─ /sys/kernel/iommu_groups/* exists?
|
||||
│
|
||||
├─ Yes → IOMMU active, continue
|
||||
│
|
||||
└─ No → cmdline check + offer to enable
|
||||
CPU vendor detect (cat /proc/cpuinfo)
|
||||
├─ Intel → write intel_iommu=on
|
||||
└─ AMD → write amd_iommu=on
|
||||
Into:
|
||||
├─ /etc/kernel/cmdline (systemd-boot)
|
||||
└─ /etc/default/grub (GRUB)
|
||||
+ update-initramfs -u -k all
|
||||
+ offer reboot now
|
||||
├─ reboot accepted → reboot
|
||||
└─ reboot declined → abort
|
||||
(re-run after reboot)
|
||||
│
|
||||
▼
|
||||
Enumerate storage-class PCI devices
|
||||
lspci -Dnn filtered by class:
|
||||
├─ SATA / SAS / SCSI / NVMe controllers
|
||||
├─ Resolve IOMMU group via /sys path
|
||||
└─ For HBAs: list disks currently behind
|
||||
│
|
||||
▼
|
||||
Conflict / eligibility filter
|
||||
├─ Already in this VM's hostpci? → hide
|
||||
├─ Already in another VM's hostpci?
|
||||
│ → block (shown with owner VM id)
|
||||
├─ Carries the Proxmox root disk
|
||||
│ or any disk referenced by an LXC
|
||||
│ → block
|
||||
└─ Shared IOMMU group
|
||||
with non-storage members?
|
||||
→ show ⚠ warning inline
|
||||
│
|
||||
▼
|
||||
User selects device(s) via checklist
|
||||
│
|
||||
▼
|
||||
Summary:
|
||||
(VM + each PCI device + IOMMU group
|
||||
membership + reboot status)
|
||||
│
|
||||
┌──────── Cancel OR Confirm ────┐
|
||||
▼ ▼
|
||||
Exit, nothing ┌─────────────────┴─────────────────┐
|
||||
was changed │ PHASE 2 — Apply │
|
||||
└─────────────────┬─────────────────┘
|
||||
▼
|
||||
Host side (once per session):
|
||||
├─ Add vfio-pci to /etc/modules
|
||||
├─ Append the device vendor:device
|
||||
│ IDs to /etc/modprobe.d/vfio.conf
|
||||
└─ update-initramfs -u -k all
|
||||
(so the device is bound to vfio-pci
|
||||
at next boot, not the native driver)
|
||||
│
|
||||
▼
|
||||
For each selected device:
|
||||
├─ Find next free hostpciN slot
|
||||
│ (scans qm config)
|
||||
└─ qm set <VMID> --hostpciN \\
|
||||
<BDF>,pcie=1
|
||||
(e.g. 0000:01:00.0,pcie=1)
|
||||
│
|
||||
▼
|
||||
Verify: qm config <VMID> shows
|
||||
the new hostpciN entries
|
||||
│
|
||||
▼
|
||||
If IOMMU was just enabled:
|
||||
└─ reminder to reboot before
|
||||
starting the VM
|
||||
│
|
||||
▼
|
||||
Guest on next boot sees the
|
||||
controller directly + every disk
|
||||
behind it (full SMART, native
|
||||
firmware features, no Proxmox layer)`}
|
||||
</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("iommu.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("iommu.body", { strong })}
|
||||
</p>
|
||||
<div className="my-6 rounded-md border border-gray-200 bg-gray-50 p-4 overflow-x-auto">
|
||||
<pre className="text-xs leading-relaxed text-gray-800 whitespace-pre font-mono">
|
||||
{` Host PCIe bus — grouped by IOMMU
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Group 12 │
|
||||
│ ──────── │
|
||||
│ 00:17.0 SATA HBA │
|
||||
│ └── sda sdb sdc sdd │
|
||||
│ │
|
||||
│ Pass-through takes: │
|
||||
│ the HBA + every disk on it │
|
||||
│ │
|
||||
│ ✓ clean — no extra members in group │
|
||||
└──────────────────┬──────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Group 13 │
|
||||
│ ──────── │
|
||||
│ 01:00.0 NVMe controller │
|
||||
│ │
|
||||
│ Pass-through takes: │
|
||||
│ the NVMe controller itself │
|
||||
│ │
|
||||
│ ✓ clean — NVMe alone in its group │
|
||||
└──────────────────┬──────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Group 14 │
|
||||
│ ──────── │
|
||||
│ 02:00.0 SATA HBA │
|
||||
│ └── sde sdf │
|
||||
│ 02:00.1 USB 3.0 controller │
|
||||
│ │
|
||||
│ Pass-through takes: │
|
||||
│ SATA HBA + USB 3.0 controller │
|
||||
│ (whole group leaves together) │
|
||||
│ │
|
||||
│ ⚠ shared group — the USB ports will │
|
||||
│ also leave the host. Review whether │
|
||||
│ that is acceptable before confirming.│
|
||||
└─────────────────────────────────────────┘`}
|
||||
</pre>
|
||||
</div>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("iommu.outro")}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("prereqs.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{prereqItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`prereqs.items.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="warning" title={t("prereqs.warnTitle")}>
|
||||
{t("prereqs.warnBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("steps.heading")}</h2>
|
||||
|
||||
{stepList.map((step, idx) => (
|
||||
<section key={idx} className="mt-8 border-b border-gray-200 pb-8">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-0.5 text-xs font-semibold text-blue-800">
|
||||
{t("steps.stepLabel")} {idx + 1}
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900 m-0">{step.title}</h3>
|
||||
</div>
|
||||
<div className="mb-4 text-gray-800 leading-relaxed">
|
||||
{step.bodyRich ? (
|
||||
<p className="mb-4">{t.rich(`steps.list.${idx}.bodyRich`, { code, strong })}</p>
|
||||
) : step.body && <p className="mb-4">{step.body}</p>}
|
||||
{step.items && (
|
||||
<ul className="list-disc pl-6 mt-2 space-y-1 mb-4">
|
||||
{step.items.map((_, i) => (
|
||||
<li key={i}>{t.rich(`steps.list.${idx}.items.${i}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{step.outro && <p className="mb-4">{step.outro}</p>}
|
||||
</div>
|
||||
{step.img && (
|
||||
<div className="flex flex-col items-center w-full max-w-[768px] mx-auto my-4">
|
||||
<div className="w-full overflow-hidden rounded-md border border-gray-200">
|
||||
<Image src={step.img} alt={step.alt || step.title} width={768} height={0} style={{ height: "auto" }} className="w-full object-contain" sizes="(max-width: 768px) 100vw, 768px" />
|
||||
</div>
|
||||
{step.caption && <span className="mt-2 text-sm text-gray-600">{step.caption}</span>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
|
||||
<CopyableCode code={`# 1. verify IOMMU is active
|
||||
dmesg | grep -iE "DMAR|IOMMU" | head
|
||||
ls /sys/kernel/iommu_groups/
|
||||
|
||||
# 2. list IOMMU groups and their members
|
||||
for d in /sys/kernel/iommu_groups/*/devices/*; do
|
||||
n=$(basename "$d"); g=$(dirname "$(dirname "$d")")
|
||||
printf 'group %3d %s %s\\n' "$(basename "$g")" "$n" \\
|
||||
"$(lspci -s "$n" | cut -d' ' -f2-)"
|
||||
done | sort -n
|
||||
|
||||
# 3. attach a storage controller at PCI 0000:00:17.0 to VM 101
|
||||
qm set 101 --hostpci0 0000:00:17.0,pcie=1
|
||||
|
||||
# 4. verify
|
||||
qm config 101 | grep ^hostpci`} />
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.noGroupsTitle")}>
|
||||
{t("troubleshoot.noGroupsBody")}
|
||||
</Callout>
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.busyTitle")}>
|
||||
{t.rich("troubleshoot.busyBody", { code })}
|
||||
</Callout>
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.noDisksTitle")}>
|
||||
{t("troubleshoot.noDisksBody")}
|
||||
</Callout>
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.sharedTitle")}>
|
||||
{t.rich("troubleshoot.sharedBody", { em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
275
web/app/[locale]/docs/disk-manager/format-disk/page.tsx
Normal file
275
web/app/[locale]/docs/disk-manager/format-disk/page.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.diskManager.formatDisk.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/disk-manager/format-disk",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type StringItem = string
|
||||
type ModeRow = { mode: string; part: string; data: string; useCase: string }
|
||||
type StepData = { title: string; body?: string; bodyRich?: string }
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function FormatDiskPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.diskManager.formatDisk" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { diskManager: { formatDisk: {
|
||||
visibility: { items: StringItem[]; safetyItems: StringItem[] }
|
||||
modes: { rows: ModeRow[]; fullFormatItems: StringItem[] }
|
||||
steps: { list: StepData[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const visItems = messages.docs.diskManager.formatDisk.visibility.items
|
||||
const safetyItems = messages.docs.diskManager.formatDisk.visibility.safetyItems
|
||||
const modeRows = messages.docs.diskManager.formatDisk.modes.rows
|
||||
const fullFormatItems = messages.docs.diskManager.formatDisk.modes.fullFormatItems
|
||||
const stepList = messages.docs.diskManager.formatDisk.steps.list
|
||||
const relatedItems = messages.docs.diskManager.formatDisk.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={5}
|
||||
scriptPath="storage/format-disk.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="danger" title={t("danger.title")}>
|
||||
{t("danger.body")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howRuns.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("howRuns.body")}</p>
|
||||
|
||||
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
|
||||
{`┌─────────────────────────────────────────────┐
|
||||
│ PHASE 1 — Filter, choose, confirm │
|
||||
│ (nothing touched yet) │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
▼
|
||||
Detect disks on host (lsblk)
|
||||
│
|
||||
▼
|
||||
Visibility filter
|
||||
├─ Hidden: root / swap / system-mounted
|
||||
├─ Hidden: active ZFS / LVM / RAID members
|
||||
├─ Hidden: referenced by any VM/LXC config
|
||||
└─ Shown: fully free disks (⚠ for stale sigs)
|
||||
│
|
||||
▼
|
||||
User picks a disk
|
||||
│
|
||||
▼
|
||||
Operation mode
|
||||
├─ 1. Wipe all (partitions + sigs)
|
||||
├─ 2. Remove FS labels (data preserved)
|
||||
├─ 3. Zero all data (partitions kept)
|
||||
└─ 4. Full format (new GPT + mkfs)
|
||||
│
|
||||
▼
|
||||
Mode = 4? → extra questions
|
||||
├─ Filesystem: ext4 / xfs / exfat / btrfs
|
||||
│ └─ if tool missing (mkfs.btrfs,
|
||||
│ mkfs.exfat) → abort with hint
|
||||
└─ Optional label
|
||||
│
|
||||
▼
|
||||
╔════════════════════════════════════╗
|
||||
║ Double confirmation gate ║
|
||||
║ (1) yes/no dialog with summary ║
|
||||
║ (2) type the full disk path exactly║
|
||||
║ (e.g. /dev/sdc) ║
|
||||
║ Any mismatch → abort ║
|
||||
╚══════════════════╤═════════════════╝
|
||||
│
|
||||
┌──────── Cancel OR Confirm ────┐
|
||||
▼ ▼
|
||||
Exit, nothing ┌─────────────────┴─────────────────┐
|
||||
was changed │ PHASE 2 — Execute │
|
||||
└─────────────────┬─────────────────┘
|
||||
▼
|
||||
Pre-execution re-validation
|
||||
(state may have changed since
|
||||
Phase 1 — user just confirmed)
|
||||
├─ Disk now hosts system mount?
|
||||
│ → hard block, abort
|
||||
├─ Disk now in root ZFS pool?
|
||||
│ → hard block, abort
|
||||
├─ Disk has active swap?
|
||||
│ → hard block, abort
|
||||
└─ Data partitions still mounted?
|
||||
→ auto-unmount; abort if fails
|
||||
│
|
||||
▼
|
||||
Run the selected mode:
|
||||
┌─────────────────────────────────┐
|
||||
│ 1. Wipe all │
|
||||
│ wipefs -af <disk> │
|
||||
│ sgdisk --zap-all <disk> │
|
||||
├─────────────────────────────────┤
|
||||
│ 2. Remove FS labels │
|
||||
│ wipefs -af <disk> │
|
||||
│ + wipefs -af each partition │
|
||||
│ (partition table PRESERVED) │
|
||||
├─────────────────────────────────┤
|
||||
│ 3. Zero all data │
|
||||
│ For each partition: │
|
||||
│ dd if=/dev/zero of=<part> │
|
||||
│ bs=4M │
|
||||
│ (partition table PRESERVED) │
|
||||
├─────────────────────────────────┤
|
||||
│ 4. Full format │
|
||||
│ wipefs -af <disk> │
|
||||
│ sgdisk --zap-all <disk> │
|
||||
│ sgdisk -n 1:0:0 -t 1:8300 │
|
||||
│ <disk> │
|
||||
│ mkfs.<fs> [-L <label>] │
|
||||
│ <disk>1 │
|
||||
└─────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Final summary (operation + disk +
|
||||
bytes touched if applicable)`}
|
||||
</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("visibility.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("visibility.intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{visItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`visibility.items.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="warning" title={t("visibility.safetyTitle")}>
|
||||
<ul className="mt-2 list-disc list-inside space-y-1">
|
||||
{safetyItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`visibility.safetyItems.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("modes.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("modes.intro")}</p>
|
||||
<div className="overflow-x-auto mb-4 rounded-md border border-gray-200">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold">{t("modes.headerMode")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("modes.headerPart")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("modes.headerData")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("modes.headerUseCase")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-800">
|
||||
{modeRows.map((row) => (
|
||||
<tr key={row.mode}>
|
||||
<td className="px-4 py-2 font-semibold">{row.mode}</td>
|
||||
<td className="px-4 py-2">{row.part}</td>
|
||||
<td className="px-4 py-2">{row.data}</td>
|
||||
<td className="px-4 py-2">{row.useCase}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("modes.fullFormatOutro", { strong })}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{fullFormatItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`modes.fullFormatItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("steps.heading")}</h2>
|
||||
|
||||
{stepList.map((step, idx) => (
|
||||
<section key={idx} className="mt-8 border-b border-gray-200 pb-8">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-0.5 text-xs font-semibold text-blue-800">
|
||||
{t("steps.stepLabel")} {idx + 1}
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900 m-0">{step.title}</h3>
|
||||
</div>
|
||||
<div className="mb-4 text-gray-800 leading-relaxed">
|
||||
{step.bodyRich ? (
|
||||
<p>{t.rich(`steps.list.${idx}.bodyRich`, { strong, code })}</p>
|
||||
) : step.body && <p>{step.body}</p>}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("manual.body")}</p>
|
||||
<CopyableCode code={`# --- mode 1: wipe all (partition table + signatures) ---
|
||||
wipefs -af /dev/sdX
|
||||
sgdisk --zap-all /dev/sdX
|
||||
|
||||
# --- mode 2: remove FS labels only (keep partitions + data) ---
|
||||
wipefs -af /dev/sdX # clears superblock signatures
|
||||
# (do NOT run sgdisk --zap-all; it would wipe the partition table)
|
||||
|
||||
# --- mode 3: zero all data (keep partition table) ---
|
||||
for p in /dev/sdX?*; do
|
||||
dd if=/dev/zero of="$p" bs=4M status=progress || true
|
||||
done
|
||||
|
||||
# --- mode 4: full format (new GPT + filesystem) ---
|
||||
wipefs -af /dev/sdX
|
||||
sgdisk --zap-all /dev/sdX
|
||||
sgdisk -n 1:0:0 -t 1:8300 /dev/sdX # one partition, Linux filesystem
|
||||
mkfs.ext4 -L mylabel /dev/sdX1 # pick the fs you want`} />
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.notListedTitle")}>
|
||||
{t.rich("troubleshoot.notListedBody", { code })}
|
||||
</Callout>
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.busyTitle")}>
|
||||
{t.rich("troubleshoot.busyBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
233
web/app/[locale]/docs/disk-manager/import-disk-image-vm/page.tsx
Normal file
233
web/app/[locale]/docs/disk-manager/import-disk-image-vm/page.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.diskManager.importDiskImageVm.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/disk-manager/import-disk-image-vm",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type StepData = { title: string; body?: string; bodyRich?: string; intro?: string; items?: string[] }
|
||||
type StringItem = string
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function ImportDiskImageVMPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.diskManager.importDiskImageVm" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { diskManager: { importDiskImageVm: {
|
||||
prereqs: { items: StringItem[] }
|
||||
steps: { list: StepData[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const prereqItems = messages.docs.diskManager.importDiskImageVm.prereqs.items
|
||||
const stepList = messages.docs.diskManager.importDiskImageVm.steps.list
|
||||
const relatedItems = messages.docs.diskManager.importDiskImageVm.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={5}
|
||||
scriptPath="storage/import-disk-image.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { em, strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howRuns.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("howRuns.body", { code })}
|
||||
</p>
|
||||
|
||||
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
|
||||
{`┌─────────────────────────────────────────────┐
|
||||
│ PHASE 1 — Collect every decision │
|
||||
│ (nothing touched yet) │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
▼
|
||||
qm list — user picks target VM
|
||||
(target VM should be powered off)
|
||||
│
|
||||
▼
|
||||
pvesm status -content images
|
||||
├─ 0 candidates → abort
|
||||
│ "no storage for disk images"
|
||||
├─ 1 candidate → auto-select, skip dialog
|
||||
└─ 2+ → user picks
|
||||
│
|
||||
▼
|
||||
Source directory
|
||||
├─ default: /var/lib/vz/template/iso
|
||||
└─ custom: user types absolute path
|
||||
└─ not a directory → abort
|
||||
│
|
||||
▼
|
||||
Scan the directory (maxdepth 1)
|
||||
for *.img *.qcow2 *.vmdk *.raw
|
||||
├─ 0 results → abort
|
||||
│ "no compatible disk images found"
|
||||
└─ N results → continue
|
||||
│
|
||||
▼
|
||||
User selects one or several images
|
||||
(checklist — multiple allowed)
|
||||
│
|
||||
▼
|
||||
For each image, user picks:
|
||||
├─ Bus: scsi (default) / virtio / sata / ide
|
||||
├─ SSD emulation (ssd=1)
|
||||
│ └─ offered only when bus ≠ virtio
|
||||
└─ Bootable? (adds to boot order in Phase 2)
|
||||
│
|
||||
▼
|
||||
Summary of everything Phase 2 will do
|
||||
│
|
||||
┌──────── Cancel OR Confirm ────┐
|
||||
▼ ▼
|
||||
Exit, nothing ┌─────────────────┴─────────────────┐
|
||||
was changed │ PHASE 2 — Import and attach │
|
||||
└─────────────────┬─────────────────┘
|
||||
▼
|
||||
For each selected image:
|
||||
├─ qm importdisk <VMID> \\
|
||||
│ <source-file> \\
|
||||
│ <target-storage>
|
||||
│ (format conversion is transparent:
|
||||
│ qcow2/vmdk/img → raw when the
|
||||
│ target cannot hold the source
|
||||
│ format natively — LVM, ZFS, …)
|
||||
│
|
||||
├─ Find next free {bus}N slot
|
||||
│ (scans qm config)
|
||||
│
|
||||
└─ qm set <VMID> -{bus}N \\
|
||||
<storage>:vm-<VMID>-disk-N[,ssd=1]
|
||||
│
|
||||
▼
|
||||
If any image was marked bootable:
|
||||
└─ qm set <VMID> --boot order={bus}N
|
||||
(first bootable wins; others can be
|
||||
reordered later in the Proxmox UI)
|
||||
│
|
||||
▼
|
||||
Verify: qm config <VMID> shows the
|
||||
new slot(s) and, if applicable, the
|
||||
new boot order
|
||||
│
|
||||
▼
|
||||
Source image file on the host is
|
||||
kept unchanged (copied, not moved)`}
|
||||
</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("prereqs.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{prereqItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`prereqs.items.${idx}`, { code, strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("steps.heading")}</h2>
|
||||
|
||||
{stepList.map((step, idx) => (
|
||||
<section key={idx} className="mt-8 border-b border-gray-200 pb-8">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-0.5 text-xs font-semibold text-blue-800">
|
||||
{t("steps.stepLabel")} {idx + 1}
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900 m-0">{step.title}</h3>
|
||||
</div>
|
||||
<div className="mb-4 text-gray-800 leading-relaxed">
|
||||
{step.bodyRich ? (
|
||||
<p>{t.rich(`steps.list.${idx}.bodyRich`, { code, strong })}</p>
|
||||
) : step.intro ? (
|
||||
<>
|
||||
<p>{step.intro}</p>
|
||||
{step.items && (
|
||||
<ul className="list-disc pl-6 mt-2 space-y-2">
|
||||
{step.items.map((_, i) => (
|
||||
<li key={i}>{t.rich(`steps.list.${idx}.items.${i}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
step.body && <p>{step.body}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("manual.body", { code })}
|
||||
</p>
|
||||
<CopyableCode code={`# 1. import the image file into the target storage (here: local-lvm)
|
||||
qm importdisk 101 /var/lib/vz/template/iso/server.qcow2 local-lvm
|
||||
|
||||
# 2. attach the imported disk as scsi1 with SSD emulation
|
||||
qm set 101 -scsi1 local-lvm:vm-101-disk-1,ssd=1
|
||||
|
||||
# 3. (optional) make it the primary boot device
|
||||
qm set 101 --boot order=scsi1`} />
|
||||
|
||||
<Callout variant="warning" title={t("manual.warnTitle")}>
|
||||
{t.rich("manual.warnBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.noImagesTitle")}>
|
||||
{t.rich("troubleshoot.noImagesBody", { code })}
|
||||
</Callout>
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.slowTitle")}>
|
||||
{t("troubleshoot.slowBody")}
|
||||
</Callout>
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.uefiTitle")}>
|
||||
{t("troubleshoot.uefiBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
275
web/app/[locale]/docs/disk-manager/import-disk-lxc/page.tsx
Normal file
275
web/app/[locale]/docs/disk-manager/import-disk-lxc/page.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.diskManager.importDiskLxc.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/disk-manager/import-disk-lxc",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type StepData = {
|
||||
title: string
|
||||
body?: string
|
||||
bodyRich?: string
|
||||
intro?: string
|
||||
items?: string[]
|
||||
img?: string
|
||||
caption?: string
|
||||
extraImg?: string
|
||||
extraAlt?: string
|
||||
extraCaption?: string
|
||||
}
|
||||
type StringItem = string
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function ImportDiskLXCPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.diskManager.importDiskLxc" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { diskManager: { importDiskLxc: {
|
||||
prereqs: { items: StringItem[] }
|
||||
steps: { list: StepData[] }
|
||||
important: { items: StringItem[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const prereqItems = messages.docs.diskManager.importDiskLxc.prereqs.items
|
||||
const stepList = messages.docs.diskManager.importDiskLxc.steps.list
|
||||
const importantItems = messages.docs.diskManager.importDiskLxc.important.items
|
||||
const relatedItems = messages.docs.diskManager.importDiskLxc.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const wipeLink = (chunks: React.ReactNode) => (
|
||||
<a href="/docs/disk-manager/format-disk" className="text-blue-600 hover:underline">{chunks}</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={8}
|
||||
scriptPath="storage/disk-passthrough_ct.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { strong, em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howRuns.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("howRuns.body")}</p>
|
||||
|
||||
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
|
||||
{`┌─────────────────────────────────────────────┐
|
||||
│ PHASE 1 — Pick CT, detect disk, plan │
|
||||
│ (nothing touched yet) │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
▼
|
||||
pct list — user picks target CT
|
||||
│
|
||||
▼
|
||||
Privileged check
|
||||
├─ unprivileged: 1 in config
|
||||
│ → offer to convert now
|
||||
│ (edits /etc/pve/lxc/<CTID>.conf,
|
||||
│ writes unprivileged: 0)
|
||||
│ ├─ accept → continue
|
||||
│ └─ cancel → abort
|
||||
└─ privileged → continue
|
||||
│
|
||||
▼
|
||||
Detect disks on host (lsblk)
|
||||
│
|
||||
▼
|
||||
Visibility filter
|
||||
├─ Hidden: root / swap / system-mounted
|
||||
├─ Hidden: active ZFS / LVM / RAID members
|
||||
├─ Hidden: already in any VM/CT config
|
||||
├─ Shown: free disks
|
||||
└─ Shown with ⚠ label: stale metadata
|
||||
│
|
||||
▼
|
||||
User selects ONE disk
|
||||
(only a single disk per run)
|
||||
│
|
||||
▼
|
||||
Filesystem probe on the first partition
|
||||
├─ ext4 / xfs / btrfs → reuse as-is
|
||||
│ (data is preserved)
|
||||
└─ empty / unsupported → offer to format
|
||||
├─ pick fs: ext4 / xfs / btrfs
|
||||
└─ mkfs.<fs> will run in Phase 2
|
||||
│
|
||||
▼
|
||||
User types mount point path
|
||||
(e.g. /mnt/data /mnt/disk_passthrough)
|
||||
│
|
||||
▼
|
||||
Summary: disk → mount point
|
||||
│
|
||||
┌──────── Cancel OR Confirm ────┐
|
||||
▼ ▼
|
||||
Exit, nothing ┌─────────────────┴─────────────────┐
|
||||
was changed │ PHASE 2 — Apply │
|
||||
└─────────────────┬─────────────────┘
|
||||
▼
|
||||
If conversion was accepted:
|
||||
└─ rewrite CT config line:
|
||||
unprivileged: 1 → 0
|
||||
│
|
||||
▼
|
||||
If formatting was chosen:
|
||||
└─ mkfs.<fs> /dev/disk/by-id/…-part1
|
||||
│
|
||||
▼
|
||||
Resolve best persistent partition
|
||||
path (/dev/disk/by-id/...-partN)
|
||||
│
|
||||
▼
|
||||
Find next free mpN index
|
||||
(scans pct config output)
|
||||
│
|
||||
▼
|
||||
pct set <CTID> -mpN \\
|
||||
<persistent-part-path>, \\
|
||||
mp=<mount-point>, \\
|
||||
backup=0,ro=0[,acl=1]
|
||||
│
|
||||
▼
|
||||
Verify: pct config <CTID> shows
|
||||
the new mpN entry
|
||||
│
|
||||
▼
|
||||
Container sees the directory at
|
||||
the chosen mount point path`}
|
||||
</pre>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("howRuns.summary")}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("prereqs.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{prereqItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`prereqs.items.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="warning" title={t("prereqs.warnTitle")}>
|
||||
{t.rich("prereqs.warnBody", { strong, code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("steps.heading")}</h2>
|
||||
|
||||
{stepList.map((step, idx) => (
|
||||
<section key={idx} className="mt-8 border-b border-gray-200 pb-8">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-0.5 text-xs font-semibold text-blue-800">
|
||||
{t("steps.stepLabel")} {idx + 1}
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900 m-0">{step.title}</h3>
|
||||
</div>
|
||||
<div className="mb-4 text-gray-800 leading-relaxed">
|
||||
{step.bodyRich ? (
|
||||
<p>{t.rich(`steps.list.${idx}.bodyRich`, { code, strong, em })}</p>
|
||||
) : step.intro ? (
|
||||
<>
|
||||
<p>{step.intro}</p>
|
||||
{step.items && (
|
||||
<ul className="list-disc pl-6 mt-2 space-y-1">
|
||||
{step.items.map((_, i) => (
|
||||
<li key={i}>{t(`steps.list.${idx}.items.${i}`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
step.body && <p>{step.body}</p>
|
||||
)}
|
||||
</div>
|
||||
{step.img && (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-[768px] overflow-hidden rounded-md border border-gray-200">
|
||||
<Image src={step.img} alt={step.caption || step.title} width={768} height={0} style={{ height: "auto" }} className="w-full object-contain" sizes="(max-width: 768px) 100vw, 768px" />
|
||||
</div>
|
||||
{step.caption && <span className="mt-2 text-sm text-gray-600">{step.caption}</span>}
|
||||
</div>
|
||||
)}
|
||||
{step.extraImg && (
|
||||
<div className="mt-4 flex flex-col items-center">
|
||||
<div className="w-full max-w-[768px] overflow-hidden rounded-md border border-gray-200">
|
||||
<Image src={step.extraImg} alt={step.extraAlt || step.title} width={768} height={0} style={{ height: "auto" }} className="w-full object-contain" sizes="(max-width: 768px) 100vw, 768px" />
|
||||
</div>
|
||||
{step.extraCaption && <span className="mt-2 text-sm text-gray-600">{step.extraCaption}</span>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t.rich("manual.body", { code })}</p>
|
||||
<CopyableCode code={`# find the partition's persistent path
|
||||
ls -l /dev/disk/by-id | grep part1 | grep sdb
|
||||
|
||||
# format (only if the disk is new or unreadable)
|
||||
mkfs.ext4 /dev/disk/by-id/ata-WDC_WD40EFAX-68JH4N0_WD-WX11D1234567-part1
|
||||
|
||||
# attach to CT 101 as mp0 at /mnt/data
|
||||
pct set 101 -mp0 /dev/disk/by-id/ata-WDC_WD40EFAX-68JH4N0_WD-WX11D1234567-part1,mp=/mnt/data,backup=0,ro=0
|
||||
|
||||
# verify
|
||||
pct config 101 | grep -E '^mp[0-9]+:'`} />
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("important.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{importantItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`important.items.${idx}`, { strong, code, wipeLink })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.unprivTitle")}>
|
||||
{t.rich("troubleshoot.unprivBody", { code })}
|
||||
</Callout>
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.permsTitle")}>
|
||||
{t.rich("troubleshoot.permsBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
256
web/app/[locale]/docs/disk-manager/import-disk-vm/page.tsx
Normal file
256
web/app/[locale]/docs/disk-manager/import-disk-vm/page.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.diskManager.importDiskVm.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/disk-manager/import-disk-vm",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type StepData = {
|
||||
title: string
|
||||
body?: string
|
||||
bodyRich?: string
|
||||
intro?: string
|
||||
items?: string[]
|
||||
img?: string
|
||||
caption?: string
|
||||
}
|
||||
type StringItem = string
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function ImportDiskVMPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.diskManager.importDiskVm" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { diskManager: { importDiskVm: {
|
||||
prereqs: { items: StringItem[] }
|
||||
steps: { list: StepData[] }
|
||||
troubleshoot: { noDisksItems: StringItem[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const prereqItems = messages.docs.diskManager.importDiskVm.prereqs.items
|
||||
const stepList = messages.docs.diskManager.importDiskVm.steps.list
|
||||
const noDisksItems = messages.docs.diskManager.importDiskVm.troubleshoot.noDisksItems
|
||||
const relatedItems = messages.docs.diskManager.importDiskVm.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const winLink = (chunks: React.ReactNode) => (
|
||||
<a href="/docs/create-vm/system-windows" className="text-blue-600 hover:underline">{chunks}</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={5}
|
||||
scriptPath="storage/disk-passthrough.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howRuns.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("howRuns.body")}</p>
|
||||
|
||||
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
|
||||
{`┌─────────────────────────────────────────────┐
|
||||
│ PHASE 1 — Pick VM, detect disks, select │
|
||||
│ (nothing touched yet) │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
▼
|
||||
qm list — user picks target VM
|
||||
│
|
||||
▼
|
||||
VM status check
|
||||
├─ running → abort (power off first)
|
||||
└─ stopped → continue
|
||||
│
|
||||
▼
|
||||
Detect disks on host (lsblk)
|
||||
│
|
||||
▼
|
||||
Visibility filter
|
||||
├─ Hidden: root / swap / system-mounted
|
||||
├─ Hidden: active ZFS / LVM / RAID members
|
||||
├─ Hidden: already in this VM's config
|
||||
├─ Shown: free disks
|
||||
└─ Shown with ⚠ label: stale ZFS/LVM/RAID
|
||||
signatures (not active)
|
||||
│
|
||||
▼
|
||||
User selects disk(s) via checklist
|
||||
+ picks bus interface:
|
||||
SATA / SCSI / VirtIO / IDE
|
||||
│
|
||||
▼
|
||||
Per-disk cross-check
|
||||
├─ Assigned to a RUNNING VM/CT? → skip disk
|
||||
├─ Assigned to stopped VM/CT? → ask
|
||||
│ "continue anyway?" yes/no
|
||||
└─ NVMe detected? → suggest
|
||||
using "Add Controller / NVMe"
|
||||
(user can still add as disk)
|
||||
│
|
||||
▼
|
||||
Summary of disks to process
|
||||
│
|
||||
┌──────── Cancel OR Confirm ────┐
|
||||
▼ ▼
|
||||
Exit, nothing ┌─────────────────┴─────────────────┐
|
||||
was changed │ PHASE 2 — Attach │
|
||||
└─────────────────┬─────────────────┘
|
||||
▼
|
||||
For each selected disk:
|
||||
├─ Resolve best persistent path
|
||||
│ preferred order:
|
||||
│ 1. /dev/disk/by-id/ata-*
|
||||
│ 2. /dev/disk/by-id/nvme-*
|
||||
│ 3. /dev/disk/by-id/scsi-*
|
||||
│ 4. /dev/disk/by-id/wwn-*
|
||||
│ fallback: raw /dev/sdX
|
||||
├─ Find next free {bus}N slot
|
||||
│ (scans qm config output)
|
||||
└─ qm set <VMID> -{bus}N <path>
|
||||
│
|
||||
▼
|
||||
Verify: qm config <VMID> shows
|
||||
the new slot(s)
|
||||
│
|
||||
▼
|
||||
Guest sees each disk as a native
|
||||
block device under its bus
|
||||
(e.g. /dev/sda, /dev/nvme0n1)`}
|
||||
</pre>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("howRuns.summary", { em })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("prereqs.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{prereqItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`prereqs.items.${idx}`, { code, strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("steps.heading")}</h2>
|
||||
|
||||
{stepList.map((step, idx) => (
|
||||
<section key={idx} className="mt-8 border-b border-gray-200 pb-8">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-0.5 text-xs font-semibold text-blue-800">
|
||||
{t("steps.stepLabel")} {idx + 1}
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900 m-0">{step.title}</h3>
|
||||
</div>
|
||||
<div className="mb-4 text-gray-800 leading-relaxed">
|
||||
{step.bodyRich ? (
|
||||
<p>{t.rich(`steps.list.${idx}.bodyRich`, { code, strong })}</p>
|
||||
) : (
|
||||
<>
|
||||
{step.body && <p>{step.body}</p>}
|
||||
{step.intro && (
|
||||
<>
|
||||
<p>{step.intro}</p>
|
||||
{step.items && (
|
||||
<ul className="list-disc pl-6 mt-2 space-y-1">
|
||||
{step.items.map((_, i) => (
|
||||
<li key={i}>{t.rich(`steps.list.${idx}.items.${i}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{step.img && (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-[768px] overflow-hidden rounded-md border border-gray-200">
|
||||
<Image src={step.img} alt={step.caption || step.title} width={768} height={0} style={{ height: "auto" }} className="w-full object-contain" sizes="(max-width: 768px) 100vw, 768px" />
|
||||
</div>
|
||||
{step.caption && <span className="mt-2 text-sm text-gray-600">{step.caption}</span>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("manual.body", { code })}
|
||||
</p>
|
||||
<CopyableCode code={`# find the persistent path
|
||||
ls -l /dev/disk/by-id | grep -v part | grep sdb
|
||||
|
||||
# attach to VM 101 as scsi1
|
||||
qm set 101 -scsi1 /dev/disk/by-id/ata-WDC_WD40EFAX-68JH4N0_WD-WX11D1234567
|
||||
|
||||
# verify
|
||||
qm config 101 | grep -E '^scsi[0-9]+:'`} />
|
||||
|
||||
<Callout variant="warning" title={t("manual.migrationTitle")}>
|
||||
{t.rich("manual.migrationBody", { strong, code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="warning" title={t("manual.shareTitle")}>
|
||||
{t.rich("manual.shareBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.noDisksTitle")}>
|
||||
{t("troubleshoot.noDisksIntro")}
|
||||
<ul className="mt-2 list-disc list-inside space-y-1">
|
||||
{noDisksItems.map((_, idx) => (
|
||||
<li key={idx}>{t(`troubleshoot.noDisksItems.${idx}`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
{t.rich("troubleshoot.noDisksOutro", { code })}
|
||||
</Callout>
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.noVisibleTitle")}>
|
||||
{t.rich("troubleshoot.noVisibleBody", { strong, winLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
232
web/app/[locale]/docs/disk-manager/page.tsx
Normal file
232
web/app/[locale]/docs/disk-manager/page.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { ArrowRight, HardDrive, FileDown, Cpu, Boxes, Eraser, Activity, Server, Wrench } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.diskManager.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox disk passthrough",
|
||||
"proxmox attach disk to vm",
|
||||
"proxmox import disk",
|
||||
"proxmox qm importdisk",
|
||||
"proxmox lxc bind mount disk",
|
||||
"proxmox smart test",
|
||||
"proxmox wipe disk",
|
||||
"proxmox hba passthrough",
|
||||
"proxmox nvme passthrough",
|
||||
"qm set scsi",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/disk-manager" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/disk-manager",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type DiskOption = { icon: string; href: string; title: string; description: string }
|
||||
type StringItem = string
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
const ICONS: Record<string, React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>> = {
|
||||
HardDrive,
|
||||
FileDown,
|
||||
Cpu,
|
||||
Boxes,
|
||||
Eraser,
|
||||
Activity,
|
||||
}
|
||||
|
||||
function DiskOptionCard({ option }: { option: DiskOption }) {
|
||||
const Icon = ICONS[option.icon] || HardDrive
|
||||
return (
|
||||
<Link
|
||||
href={option.href}
|
||||
className="group flex items-start gap-3 rounded-md border border-gray-200 bg-white p-3 transition-colors hover:border-blue-400 hover:bg-blue-50"
|
||||
>
|
||||
<span className="inline-flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-gray-100 text-gray-600 group-hover:bg-blue-100 group-hover:text-blue-700">
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1 text-sm font-medium text-gray-900 group-hover:text-blue-700">
|
||||
{option.title}
|
||||
<ArrowRight className="h-3.5 w-3.5 text-gray-400 group-hover:text-blue-600 transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-gray-600 leading-snug">{option.description}</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function DiskManagerOverviewPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.diskManager" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { diskManager: {
|
||||
groups: { vmItems: StringItem[]; lxcItems: StringItem[]; utilitiesItems: StringItem[] }
|
||||
vm: { options: DiskOption[] }
|
||||
lxc: { options: DiskOption[] }
|
||||
utilities: { options: DiskOption[] }
|
||||
safety: { items: StringItem[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} }
|
||||
}
|
||||
const vmItems = messages.docs.diskManager.groups.vmItems
|
||||
const lxcItems = messages.docs.diskManager.groups.lxcItems
|
||||
const utilitiesItems = messages.docs.diskManager.groups.utilitiesItems
|
||||
const vmOptions = messages.docs.diskManager.vm.options
|
||||
const lxcOptions = messages.docs.diskManager.lxc.options
|
||||
const utilityOptions = messages.docs.diskManager.utilities.options
|
||||
const safetyItems = messages.docs.diskManager.safety.items
|
||||
const relatedItems = messages.docs.diskManager.related.items
|
||||
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={3}
|
||||
scriptPath="menus/storage_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("opening.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("opening.body", { strong })}
|
||||
</p>
|
||||
|
||||
<Image
|
||||
src="/disk/disk-manager-menu.png"
|
||||
alt={t("opening.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("groups.heading")}</h2>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("groups.intro", { strong })}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-3 mb-8 not-prose">
|
||||
<a
|
||||
href="#vm"
|
||||
className="rounded-lg border-2 border-blue-300 bg-blue-50 p-5 flex flex-col transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-blue-100 text-blue-700">
|
||||
<Server className="h-5 w-5" aria-hidden />
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900 m-0">{t("groups.vmTitle")}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-800 mb-3">{t("groups.vmBody")}</p>
|
||||
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
|
||||
{vmItems.map((item, i) => <li key={i}>{item}</li>)}
|
||||
</ul>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#lxc"
|
||||
className="rounded-lg border-2 border-emerald-300 bg-emerald-50 p-5 flex flex-col transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
|
||||
<Boxes className="h-5 w-5" aria-hidden />
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900 m-0">{t("groups.lxcTitle")}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-800 mb-3">{t("groups.lxcBody")}</p>
|
||||
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
|
||||
{lxcItems.map((item, i) => <li key={i}>{item}</li>)}
|
||||
</ul>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#utilities"
|
||||
className="rounded-lg border-2 border-amber-300 bg-amber-50 p-5 flex flex-col transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-amber-100 text-amber-700">
|
||||
<Wrench className="h-5 w-5" aria-hidden />
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900 m-0">{t("groups.utilitiesTitle")}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-800 mb-3">{t("groups.utilitiesBody")}</p>
|
||||
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
|
||||
{utilitiesItems.map((item, i) => <li key={i}>{item}</li>)}
|
||||
</ul>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h2 id="vm" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24">{t("vm.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("vm.intro")}</p>
|
||||
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
|
||||
{vmOptions.map((o) => <DiskOptionCard key={o.href} option={o} />)}
|
||||
</div>
|
||||
|
||||
<h2 id="lxc" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24">{t("lxc.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("lxc.intro")}</p>
|
||||
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
|
||||
{lxcOptions.map((o) => <DiskOptionCard key={o.href} option={o} />)}
|
||||
</div>
|
||||
|
||||
<h2 id="utilities" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24">{t("utilities.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("utilities.intro")}</p>
|
||||
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
|
||||
{utilityOptions.map((o) => <DiskOptionCard key={o.href} option={o} />)}
|
||||
</div>
|
||||
|
||||
<Callout variant="warning" title={t("safety.title")}>
|
||||
{t("safety.intro")}
|
||||
<ul className="mt-3 list-disc list-inside space-y-1">
|
||||
{safetyItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`safety.items.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
263
web/app/[locale]/docs/disk-manager/smart-disk-test/page.tsx
Normal file
263
web/app/[locale]/docs/disk-manager/smart-disk-test/page.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.diskManager.smartDiskTest.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/disk-manager/smart-disk-test",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type ActionRow = { action: string; what?: string; whatRich?: string; dur: string }
|
||||
type StepData = { title: string; body?: string; bodyRich?: string; img?: string; alt?: string; caption?: string }
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function SmartDiskTestPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.diskManager.smartDiskTest" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { diskManager: { smartDiskTest: {
|
||||
actions: { rows: ActionRow[] }
|
||||
steps: { list: StepData[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const actionRows = messages.docs.diskManager.smartDiskTest.actions.rows
|
||||
const stepList = messages.docs.diskManager.smartDiskTest.steps.list
|
||||
const relatedItems = messages.docs.diskManager.smartDiskTest.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const br = () => <br />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={10}
|
||||
scriptPath="storage/smart-disk-test.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howRuns.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("howRuns.body")}</p>
|
||||
|
||||
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
|
||||
{` Detect dependencies (first run)
|
||||
├─ smartctl present? (smartmontools)
|
||||
└─ nvme present? (nvme-cli)
|
||||
Any missing → apt-get install silently
|
||||
│
|
||||
▼
|
||||
Enumerate disks on host (lsblk)
|
||||
(no safety filter — read-only tool,
|
||||
root / system disks are shown too)
|
||||
│
|
||||
▼
|
||||
User picks a disk
|
||||
│
|
||||
▼
|
||||
Detect disk class from path / TRAN
|
||||
├─ /dev/nvme* → NVMe
|
||||
└─ anything else → SATA / SAS / SCSI
|
||||
│
|
||||
▼
|
||||
Action menu (loop — stays open after
|
||||
each action so you can chain queries)
|
||||
│
|
||||
┌────────────────┬────────────────┬────────────────┬───────────────┐
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
Quick Full Short Long Check
|
||||
status report test test progress
|
||||
(instant) (instant) (~2 min) (hours) (instant)
|
||||
│ │ │ │ │
|
||||
│ │ │ │ │
|
||||
│ │ │ Long test only: │
|
||||
│ │ │ confirm "runs in background, │
|
||||
│ │ │ result saved to JSON" │
|
||||
│ │ │ │ │
|
||||
│ │ └───────┬────────┘ │
|
||||
│ │ │ │
|
||||
│ │ Queued on drive firmware: │
|
||||
│ │ ├─ SATA/SAS: smartctl -t short|long │
|
||||
│ │ └─ NVMe: nvme device-self-test │
|
||||
│ │ Returns to menu while running │
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
Read: Read: Read status:
|
||||
SATA/SAS → SATA/SAS → SATA/SAS →
|
||||
smartctl -H smartctl -x smartctl -c
|
||||
smartctl -A NVMe →
|
||||
nvme self-test-log
|
||||
NVMe → NVMe →
|
||||
nvme smart- nvme smart-log
|
||||
log + nvme id-ctrl
|
||||
│ │ │
|
||||
└──────┬──────┴──────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Output to terminal (color-coded when applicable)
|
||||
+
|
||||
JSON export to:
|
||||
/usr/local/share/proxmenux/smart/<disk>/
|
||||
<YYYY-MM-DD_HHMMSS>_<action>.json
|
||||
│
|
||||
▼
|
||||
Retention policy: oldest beyond the limit
|
||||
are trimmed automatically
|
||||
│
|
||||
▼
|
||||
ProxMenux Monitor reads these files to
|
||||
render health trends per disk over time`}
|
||||
</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("deps.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("deps.body", { code })}
|
||||
</p>
|
||||
<CopyableCode code={`apt-get install smartmontools nvme-cli`} />
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("actions.heading")}</h2>
|
||||
<div className="overflow-x-auto mb-4 rounded-md border border-gray-200">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold">{t("actions.headerAction")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("actions.headerWhat")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("actions.headerDur")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-800">
|
||||
{actionRows.map((row, idx) => (
|
||||
<tr key={row.action}>
|
||||
<td className="px-4 py-2 font-semibold">{row.action}</td>
|
||||
<td className="px-4 py-2">
|
||||
{row.whatRich ? t.rich(`actions.rows.${idx}.whatRich`, { code, br }) : row.what}
|
||||
</td>
|
||||
<td className="px-4 py-2">{row.dur}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("actions.tipTitle")}>
|
||||
{t.rich("actions.tipBody", { em, strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("json.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("json.intro", { code })}
|
||||
</p>
|
||||
<pre className="bg-gray-100 p-3 rounded-md overflow-x-auto text-sm font-mono mb-4">
|
||||
<code>{`/usr/local/share/proxmenux/smart/
|
||||
├── sda/
|
||||
│ ├── 2026-04-23_145312_status.json
|
||||
│ ├── 2026-04-23_180041_short.json
|
||||
│ └── 2026-04-24_020015_long.json
|
||||
└── nvme0n1/
|
||||
├── 2026-04-23_145318_status.json
|
||||
└── 2026-04-24_021407_long.json`}</code>
|
||||
</pre>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("json.outro")}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("steps.heading")}</h2>
|
||||
|
||||
{stepList.map((step, idx) => (
|
||||
<section key={idx} className="mt-8 border-b border-gray-200 pb-8">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-0.5 text-xs font-semibold text-blue-800">
|
||||
{t("steps.stepLabel")} {idx + 1}
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900 m-0">{step.title}</h3>
|
||||
</div>
|
||||
<div className="mb-4 text-gray-800 leading-relaxed">
|
||||
{step.bodyRich ? (
|
||||
<p className="mb-4">{t.rich(`steps.list.${idx}.bodyRich`, { strong })}</p>
|
||||
) : step.body && <p className="mb-4">{step.body}</p>}
|
||||
</div>
|
||||
{step.img && (
|
||||
<div className="flex flex-col items-center w-full max-w-[768px] mx-auto my-4">
|
||||
<div className="w-full overflow-hidden rounded-md border border-gray-200">
|
||||
<Image src={step.img} alt={step.alt || step.title} width={768} height={0} style={{ height: "auto" }} className="w-full object-contain" sizes="(max-width: 768px) 100vw, 768px" />
|
||||
</div>
|
||||
{step.caption && <span className="mt-2 text-sm text-gray-600">{step.caption}</span>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
|
||||
<CopyableCode code={`# --- SATA / SAS drives (smartmontools) ---
|
||||
# quick health
|
||||
smartctl -H /dev/sdX
|
||||
smartctl -A /dev/sdX # attribute table
|
||||
|
||||
# full report
|
||||
smartctl -x /dev/sdX
|
||||
|
||||
# self-tests
|
||||
smartctl -t short /dev/sdX
|
||||
smartctl -t long /dev/sdX
|
||||
smartctl -c /dev/sdX | head # current test progress
|
||||
|
||||
# --- NVMe drives (nvme-cli) ---
|
||||
nvme smart-log /dev/nvme0n1
|
||||
nvme id-ctrl /dev/nvme0n1
|
||||
nvme self-test-log /dev/nvme0n1`} />
|
||||
|
||||
<Callout variant="warning" title={t("manual.nvmeWarnTitle")}>
|
||||
{t.rich("manual.nvmeWarnBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.noSmartTitle")}>
|
||||
{t.rich("troubleshoot.noSmartBody", { code })}
|
||||
</Callout>
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.longTitle")}>
|
||||
{t.rich("troubleshoot.longBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
119
web/app/[locale]/docs/external-repositories/page.tsx
Normal file
119
web/app/[locale]/docs/external-repositories/page.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.externalRepositories.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/external-repositories",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type RepoItem = { name: string; url: string; description: string; usedIn: string }
|
||||
|
||||
export default async function ExternalRepositoriesPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.externalRepositories" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: {
|
||||
externalRepositories: {
|
||||
integrated: { items: RepoItem[] }
|
||||
attribution: { items: string[] }
|
||||
candidate: { items: string[] }
|
||||
}
|
||||
}
|
||||
}
|
||||
const block = messages.docs.externalRepositories
|
||||
const repos = block.integrated.items
|
||||
const attributionItems = block.attribution.items
|
||||
const candidateItems = block.candidate.items
|
||||
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={3}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("practice.title")}>
|
||||
{t("practice.body")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("integrated.heading")}</h2>
|
||||
<div className="space-y-4 mb-8">
|
||||
{repos.map((r) => (
|
||||
<a
|
||||
key={r.url}
|
||||
href={r.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block rounded-md border border-gray-200 bg-white p-4 transition-colors hover:border-blue-400 hover:bg-blue-50"
|
||||
>
|
||||
<div className="font-semibold text-gray-900 mb-1">{r.name}</div>
|
||||
<div className="text-sm text-gray-700 leading-snug mb-2">{r.description}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
<strong>{t("integrated.usedInLabel")}</strong> {r.usedIn}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("attribution.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{attributionItems.map((_, idx) => (
|
||||
<li key={idx}>{t(`attribution.items.${idx}`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="warning" title={t("report.title")}>
|
||||
{t.rich("report.body", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("suggest.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("suggest.intro")}</p>
|
||||
<div className="grid gap-3 md:grid-cols-2 mb-6 not-prose">
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux/discussions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block rounded-md border border-gray-200 bg-white p-4 transition-colors hover:border-blue-400 hover:bg-blue-50"
|
||||
>
|
||||
<div className="font-semibold text-gray-900 mb-1">{t("suggest.discussionTitle")}</div>
|
||||
<div className="text-xs text-gray-600">{t("suggest.discussionBody")}</div>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block rounded-md border border-gray-200 bg-white p-4 transition-colors hover:border-blue-400 hover:bg-blue-50"
|
||||
>
|
||||
<div className="font-semibold text-gray-900 mb-1">{t("suggest.issueTitle")}</div>
|
||||
<div className="text-xs text-gray-600">{t("suggest.issueBody")}</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("candidate.title")}>
|
||||
<ul className="list-disc pl-6 mb-0 space-y-1">
|
||||
{candidateItems.map((_, idx) => (
|
||||
<li key={idx}>{t(`candidate.items.${idx}`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Callout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
175
web/app/[locale]/docs/glossary/page.tsx
Normal file
175
web/app/[locale]/docs/glossary/page.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.glossary.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/glossary",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Category = "Proxmox" | "Virtualization" | "Storage" | "Network" | "Linux" | "ProxMenux"
|
||||
|
||||
type SeeAlso = { label: string; href: string }
|
||||
|
||||
type GlossaryEntry = {
|
||||
term: string
|
||||
aliases?: string[]
|
||||
category: Category
|
||||
definitionRich: string
|
||||
seeAlso?: SeeAlso[]
|
||||
}
|
||||
|
||||
const categoryColor: Record<Category, string> = {
|
||||
Proxmox: "bg-red-50 text-red-700 border-red-200",
|
||||
Virtualization: "bg-blue-50 text-blue-700 border-blue-200",
|
||||
Storage: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
Network: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||
Linux: "bg-purple-50 text-purple-700 border-purple-200",
|
||||
ProxMenux: "bg-gray-100 text-gray-700 border-gray-300",
|
||||
}
|
||||
|
||||
function CategoryBadge({ category }: { category: Category }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${categoryColor[category]}`}
|
||||
>
|
||||
{category}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function GlossaryPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.glossary" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { glossary: { entries: GlossaryEntry[] } }
|
||||
}
|
||||
const entries = messages.docs.glossary.entries
|
||||
|
||||
const sortedEntries = [...entries].sort((a, b) =>
|
||||
a.term.localeCompare(b.term, "en", { sensitivity: "base" })
|
||||
)
|
||||
|
||||
const lettersInUse = Array.from(
|
||||
new Set(sortedEntries.map((e) => e.term[0].toUpperCase()))
|
||||
).sort()
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const ext = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-emerald-700 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={5}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("callout.title")}>
|
||||
{t.rich("callout.bodyRich", { em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("jumpHeading")}</h2>
|
||||
<div className="flex flex-wrap gap-1.5 mb-8 not-prose">
|
||||
{lettersInUse.map((letter) => (
|
||||
<a
|
||||
key={letter}
|
||||
href={`#letter-${letter}`}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-gray-200 bg-white text-sm font-medium text-gray-700 hover:border-blue-400 hover:bg-blue-50 hover:text-blue-700 transition-colors"
|
||||
>
|
||||
{letter}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{sortedEntries.map((entry, i) => {
|
||||
const letter = entry.term[0].toUpperCase()
|
||||
const prevLetter = i > 0 ? sortedEntries[i - 1].term[0].toUpperCase() : null
|
||||
const isFirstOfLetter = letter !== prevLetter
|
||||
const entryIdx = entries.findIndex((e) => e.term === entry.term)
|
||||
|
||||
return (
|
||||
<div key={entry.term}>
|
||||
{isFirstOfLetter && (
|
||||
<h2
|
||||
id={`letter-${letter}`}
|
||||
className="text-3xl font-bold text-gray-900 mt-12 mb-4 scroll-mt-24 border-b border-gray-200 pb-2"
|
||||
>
|
||||
{letter}
|
||||
</h2>
|
||||
)}
|
||||
<article id={`term-${entry.term.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`} className="scroll-mt-24">
|
||||
<div className="flex flex-wrap items-baseline gap-2 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 m-0">{entry.term}</h3>
|
||||
<CategoryBadge category={entry.category} />
|
||||
{entry.aliases && entry.aliases.length > 0 && (
|
||||
<span className="text-xs text-gray-500 italic">
|
||||
{t("aliasesLabel")} {entry.aliases.join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-800 leading-relaxed mb-2">
|
||||
{t.rich(`entries.${entryIdx}.definitionRich`, { code, strong, em })}
|
||||
</p>
|
||||
{entry.seeAlso && entry.seeAlso.length > 0 && (
|
||||
<p className="text-xs text-gray-600 m-0">
|
||||
<span className="font-medium text-gray-700">{t("seeAlsoLabel")}</span>{" "}
|
||||
{entry.seeAlso.map((s, idx) => (
|
||||
<span key={s.href}>
|
||||
<Link href={s.href} className="text-blue-600 hover:underline">
|
||||
{s.label}
|
||||
</Link>
|
||||
{idx < entry.seeAlso!.length - 1 && " · "}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("missingCallout.title")} className="mt-12">
|
||||
{t.rich("missingCallout.leadRich", { ext, code })}
|
||||
</Callout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
377
web/app/[locale]/docs/hardware/coral-tpu-lxc/page.tsx
Normal file
377
web/app/[locale]/docs/hardware/coral-tpu-lxc/page.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { Prerequisites } from "@/components/ui/prerequisites"
|
||||
import Image from "next/image"
|
||||
import { Steps } from "@/components/ui/steps"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.hardware.coralTpuLxc.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
}
|
||||
}
|
||||
|
||||
type RelatedItem = { label: string; href: string; tail?: string }
|
||||
type DriverItem = string
|
||||
|
||||
export default async function AddCoralTPUtoLXC({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.hardware.coralTpuLxc" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { hardware: { coralTpuLxc: {
|
||||
walkthrough: { drivers: { items: DriverItem[] } }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const driverItems = messages.docs.hardware.coralTpuLxc.walkthrough.drivers.items
|
||||
const relatedItems = messages.docs.hardware.coralTpuLxc.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const frigateLink = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://frigate.video/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-700 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
const hostLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/install-coral-tpu-host" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const lxcGpuLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/igpu-acceleration-lxc" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const coralLink = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://coral.ai/docs/accelerator/get-started/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-700 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={8}
|
||||
scriptPath="gpu_tpu/install_coral_lxc.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code, strong, lxcGpuLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whenUse.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("whenUse.body", { frigateLink })}
|
||||
</p>
|
||||
|
||||
<Prerequisites
|
||||
title={t("prereqs.title")}
|
||||
items={[
|
||||
{
|
||||
label: <>{t.rich("prereqs.drivers", { strong, code, hostLink })}</>,
|
||||
check: t("prereqs.driversCheck"),
|
||||
},
|
||||
{
|
||||
label: <>{t.rich("prereqs.container", { strong, code })}</>,
|
||||
},
|
||||
{
|
||||
label: <>{t.rich("prereqs.downtime", { strong })}</>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Callout variant="warning" title={t("hostPrep.title")}>
|
||||
{t.rich("hostPrep.body", { code, strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("running.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("running.body", { strong })}
|
||||
</p>
|
||||
|
||||
<Image
|
||||
src="/gpu-tpu/coral-lxc-01-menu-entry.png"
|
||||
alt={t("running.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howRuns.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("howRuns.body")}</p>
|
||||
|
||||
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
|
||||
{`┌────────────────────────────────────────────────┐
|
||||
│ 1. User picks the LXC container │
|
||||
│ (pct list → dialog → CTID) │
|
||||
└────────────────┬───────────────────────────────┘
|
||||
▼
|
||||
Stop container if running
|
||||
│
|
||||
▼
|
||||
┌─────────┴──────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
Coral M.2/PCIe? Coral USB?
|
||||
lspci "Global (udev rule creates
|
||||
Unichip" /dev/coral symlink
|
||||
│ on the host)
|
||||
│ │
|
||||
Yes Yes
|
||||
│ │
|
||||
▼ ▼
|
||||
/dev/apex_0 Write udev rule
|
||||
exists? /etc/udev/rules.d/
|
||||
│ 99-coral-usb.rules
|
||||
├─ Yes → (ATTRS idVendor/idProduct
|
||||
│ dev<N>: → SYMLINK /dev/coral)
|
||||
│ /dev/apex_0 │
|
||||
│ gid=apex ▼
|
||||
│ Append to LXC config:
|
||||
└─ No → lxc.cgroup2.devices.allow:
|
||||
cgroup2 c 189:* rwm
|
||||
fallback lxc.mount.entry:
|
||||
(major 245 /dev/bus/usb dev/bus/usb
|
||||
from /proc/ none bind,optional,
|
||||
devices) create=dir
|
||||
│ (bind the WHOLE usb tree,
|
||||
▼ not /dev/coral — survives
|
||||
cgroup2 USB replug to other port)
|
||||
+ mount │
|
||||
│
|
||||
└──────────────┬──────┘
|
||||
▼
|
||||
Clean up duplicate entries in the config
|
||||
│
|
||||
▼
|
||||
┌──────────────┴──────────────┐
|
||||
│ Start container + wait │
|
||||
│ up to 15s for readiness │
|
||||
└──────────────┬──────────────┘
|
||||
▼
|
||||
pct exec inside container:
|
||||
├─ apt-get update
|
||||
├─ Install Coral deps (gnupg, curl, ca-certificates)
|
||||
├─ Add Google Coral APT repo
|
||||
│ /etc/apt/keyrings/coral-edgetpu.gpg
|
||||
│ /etc/apt/sources.list.d/coral-edgetpu.list
|
||||
└─ apt install libedgetpu1-std
|
||||
(or libedgetpu1-max for M.2 if user picks)
|
||||
│
|
||||
▼
|
||||
Show summary (what was enabled)
|
||||
Container stays running
|
||||
|
||||
Note: iGPU passthrough (Quick Sync / VA-API
|
||||
/ NVENC) is now handled exclusively by
|
||||
"Add GPU to LXC" — run it BEFORE this script
|
||||
if you also want hardware video decode.`}
|
||||
</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("walkthrough.heading")}</h2>
|
||||
|
||||
<Steps>
|
||||
<Steps.Step title={t("walkthrough.pick.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.pick.body", { code })}</p>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.gpuHint.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.gpuHint.body", { code, lxcGpuLink })}</p>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.usb.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.usb.body", { code, strong })}</p>
|
||||
<CopyableCode
|
||||
code={`# /etc/udev/rules.d/99-coral-usb.rules (on the host)
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="1a6e", ATTRS{idProduct}=="089a", \\
|
||||
SYMLINK+="coral", MODE="0666"
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="18d1", ATTRS{idProduct}=="9302", \\
|
||||
SYMLINK+="coral", MODE="0666"
|
||||
|
||||
# Appended to /etc/pve/lxc/<ctid>.conf
|
||||
lxc.cgroup2.devices.allow: c 189:* rwm
|
||||
lxc.mount.entry: /dev/bus/usb dev/bus/usb none bind,optional,create=dir`}
|
||||
className="my-4"
|
||||
/>
|
||||
<Callout variant="tip" title={t("walkthrough.usb.whyTitle")}>
|
||||
{t.rich("walkthrough.usb.whyBody", { code })}
|
||||
</Callout>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.pcie.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.pcie.body1", { code })}</p>
|
||||
<CopyableCode
|
||||
code={`# Appended to /etc/pve/lxc/<ctid>.conf — modern path
|
||||
dev0: /dev/apex_0,gid=<APEX_GID>`}
|
||||
className="my-4"
|
||||
/>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.pcie.body2", { code, hostLink })}</p>
|
||||
<CopyableCode
|
||||
code={`# Fallback when /dev/apex_0 isn't yet present on host
|
||||
lxc.cgroup2.devices.allow: c 245:0 rwm
|
||||
lxc.mount.entry: /dev/apex_0 dev/apex_0 none bind,optional,create=file`}
|
||||
className="my-4"
|
||||
/>
|
||||
<Callout variant="troubleshoot" title={t("walkthrough.pcie.rebootTitle")}>
|
||||
{t.rich("walkthrough.pcie.rebootBody", { code })}
|
||||
</Callout>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.drivers.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.drivers.body", { code })}</p>
|
||||
<ol className="list-decimal pl-6 space-y-1 text-gray-800 mb-3">
|
||||
{driverItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`walkthrough.drivers.items.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.summary.title")}>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.summary.body")}</p>
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("manual.body")}</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("manual.usbHeading")}</h3>
|
||||
<CopyableCode
|
||||
code={`# On the HOST — persistent udev alias
|
||||
cat > /etc/udev/rules.d/99-coral-usb.rules <<'EOF'
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="1a6e", ATTRS{idProduct}=="089a", SYMLINK+="coral", MODE="0666"
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="18d1", ATTRS{idProduct}=="9302", SYMLINK+="coral", MODE="0666"
|
||||
EOF
|
||||
udevadm control --reload-rules
|
||||
udevadm trigger --subsystem-match=usb
|
||||
|
||||
# Append to /etc/pve/lxc/<ctid>.conf
|
||||
# (container must be stopped to apply)
|
||||
lxc.cgroup2.devices.allow: c 189:* rwm
|
||||
lxc.mount.entry: /dev/bus/usb dev/bus/usb none bind,optional,create=dir`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("manual.pcieHeading")}</h3>
|
||||
<CopyableCode
|
||||
code={`# On the HOST — PVE dev API (works in privileged AND unprivileged CTs)
|
||||
# Append to /etc/pve/lxc/<ctid>.conf
|
||||
dev0: /dev/apex_0,gid=$(getent group apex | cut -d: -f3)
|
||||
|
||||
# ─── OPTIONAL — fallback path ────────────────────────────────────
|
||||
# Only use this block if /dev/apex_0 doesn't exist yet on the host
|
||||
# (apex module not loaded — reboot still pending). The PVE dev API
|
||||
# above is preferred when the device is present.
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# lxc.cgroup2.devices.allow: c 245:0 rwm
|
||||
# lxc.mount.entry: /dev/apex_0 dev/apex_0 none bind,optional,create=file`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("manual.runtimeHeading")}</h3>
|
||||
<CopyableCode
|
||||
code={`# Assumes Debian / Ubuntu
|
||||
apt-get update
|
||||
apt-get install -y gnupg curl ca-certificates
|
||||
|
||||
# Google Coral APT repo
|
||||
curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg \\
|
||||
| gpg --dearmor -o /usr/share/keyrings/coral-edgetpu.gpg
|
||||
|
||||
echo 'deb [signed-by=/usr/share/keyrings/coral-edgetpu.gpg] https://packages.cloud.google.com/apt coral-edgetpu-stable main' \\
|
||||
> /etc/apt/sources.list.d/coral-edgetpu.list
|
||||
|
||||
apt-get update
|
||||
apt-get install -y libedgetpu1-std
|
||||
# Or for M.2 + maximum performance (runs hotter):
|
||||
# apt-get install -y libedgetpu1-max`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("verification.heading")}</h2>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("verification.body")}</p>
|
||||
<CopyableCode
|
||||
code={`pct enter <ctid>
|
||||
|
||||
# USB Coral
|
||||
lsusb | grep -E '1a6e:089a|18d1:9302'
|
||||
ls /dev/bus/usb/
|
||||
|
||||
# M.2 Coral
|
||||
ls -l /dev/apex_0
|
||||
# Expect: crw-rw---- 1 root apex ... /dev/apex_0
|
||||
|
||||
# Runtime installed
|
||||
dpkg -l libedgetpu1-std
|
||||
|
||||
# Frigate-style test: run a quick Python inference
|
||||
python3 -c "from pycoral.utils.edgetpu import list_edge_tpus; print(list_edge_tpus())"
|
||||
# Expect a non-empty list with at least one device`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.apexTitle")}>
|
||||
{t.rich("troubleshoot.apexBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.replugTitle")}>
|
||||
{t.rich("troubleshoot.replugBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.alpineTitle")}>
|
||||
{t.rich("troubleshoot.alpineBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.frigateTitle")}>
|
||||
{t.rich("troubleshoot.frigateBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("troubleshoot.logsTitle")}>
|
||||
{t.rich("troubleshoot.logsBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
480
web/app/[locale]/docs/hardware/gpu-vm-passthrough/page.tsx
Normal file
480
web/app/[locale]/docs/hardware/gpu-vm-passthrough/page.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { Prerequisites } from "@/components/ui/prerequisites"
|
||||
import { Steps } from "@/components/ui/steps"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.hardware.gpuVmPassthrough.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
}
|
||||
}
|
||||
|
||||
type StringItem = string
|
||||
type RelatedItem = { label: string; href: string; tail?: string }
|
||||
|
||||
export default async function GpuVmPassthroughPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.hardware.gpuVmPassthrough" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { hardware: { gpuVmPassthrough: {
|
||||
walkthrough: {
|
||||
preflight: { items: StringItem[] }
|
||||
switchMode: { items: StringItem[] }
|
||||
hostApply: { items: StringItem[] }
|
||||
}
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const preflightItems = messages.docs.hardware.gpuVmPassthrough.walkthrough.preflight.items
|
||||
const switchModeItems = messages.docs.hardware.gpuVmPassthrough.walkthrough.switchMode.items
|
||||
const hostApplyItems = messages.docs.hardware.gpuVmPassthrough.walkthrough.hostApply.items
|
||||
const relatedItems = messages.docs.hardware.gpuVmPassthrough.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const pveLink = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://pve.proxmox.com/wiki/PCI(e)_Passthrough"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-700 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
const lxcLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/igpu-acceleration-lxc" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const nvidiaLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/nvidia-host" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const postLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/virtualization" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const vendorResetLink = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://github.com/gnif/vendor-reset"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-700 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
const sriovLink = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://github.com/strongtz/i915-sriov-dkms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-700 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={15}
|
||||
scriptPath="gpu_tpu/add_gpu_vm.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code, em, pveLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("who.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("who.body", { strong, em, lxcLink })}
|
||||
</p>
|
||||
|
||||
<Prerequisites
|
||||
title={t("prereqs.title")}
|
||||
items={[
|
||||
{ label: <>{t.rich("prereqs.gpu", { strong, code })}</>, check: t("prereqs.gpuCheck") },
|
||||
{ label: <>{t.rich("prereqs.iommu", { strong })}</> },
|
||||
{ label: <>{t.rich("prereqs.q35", { strong, code })}</>, check: t("prereqs.q35Check") },
|
||||
{ label: <>{t.rich("prereqs.moreGpus", { strong, em })}</> },
|
||||
{ label: <>{t.rich("prereqs.nvidiaInstalled", { nvidiaLink, code, strong })}</> },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Callout variant="warning" title={t("pickOne.title")}>
|
||||
{t.rich("pickOne.body", { code })}
|
||||
</Callout>
|
||||
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-4">
|
||||
<li>{t.rich("pickOne.vmItem", { strong })}</li>
|
||||
<li>{t.rich("pickOne.lxcItem", { strong, lxcLink })}</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("running.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("running.body", { strong })}
|
||||
</p>
|
||||
|
||||
<Image
|
||||
src="/gpu-tpu/gpu-vm-01-menu-entry.png"
|
||||
alt={t("running.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howRuns.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("howRuns.body")}</p>
|
||||
|
||||
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
|
||||
{`┌─────────────────────────────────────────────┐
|
||||
│ PHASE 1 — Gather info, validate, confirm │
|
||||
│ (nothing touched yet) │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
▼
|
||||
┌────────────┴────────────┐
|
||||
▼ ▼
|
||||
lspci detects IOMMU enabled?
|
||||
GPUs (Intel/AMD/ ├─ No → offer to add
|
||||
NVIDIA) │ intel_iommu=on /
|
||||
│ │ amd_iommu=on
|
||||
▼ └─ Yes → continue
|
||||
User selects GPU
|
||||
│
|
||||
▼
|
||||
Pre-flight checks
|
||||
├─ Not in SR-IOV
|
||||
├─ Not D3cold (AMD)
|
||||
├─ Has FLR or equivalent reset
|
||||
├─ Warn if single-GPU host
|
||||
└─ Resolve IOMMU group
|
||||
│
|
||||
▼
|
||||
Audio companion
|
||||
├─ Has .1 sibling? (dGPU: NVIDIA/AMD HDMI)
|
||||
│ → auto-include (never used by host)
|
||||
└─ No .1 sibling? (Intel iGPU, split audio)
|
||||
→ checklist of host audio controllers,
|
||||
default = none (user opts in)
|
||||
│
|
||||
▼
|
||||
User selects VM
|
||||
│
|
||||
▼
|
||||
VM is q35? ── No → abort
|
||||
│
|
||||
Yes
|
||||
▼
|
||||
GPU already assigned elsewhere?
|
||||
│
|
||||
├─ To LXC → offer to remove it from LXC
|
||||
├─ To other VM → offer to remove it there
|
||||
│ + clean up orphan audio
|
||||
│ (skips audio whose
|
||||
│ display sibling stays)
|
||||
└─ Free → continue
|
||||
│
|
||||
▼
|
||||
Show confirmation summary
|
||||
(GPU + IOMMU siblings + audio + target VM)
|
||||
│
|
||||
┌─────── Cancel OR Confirm ────┐
|
||||
▼ ▼
|
||||
exit, nothing ┌─────────────┴──────────────┐
|
||||
was changed │ PHASE 2 — Apply changes │
|
||||
└─────────────┬──────────────┘
|
||||
▼
|
||||
Host:
|
||||
├─ /etc/modules (vfio_*)
|
||||
├─ /etc/modprobe.d/vfio.conf (ids=...)
|
||||
├─ /etc/modprobe.d/blacklist.conf
|
||||
├─ kernel cmdline (IOMMU if missing)
|
||||
├─ NVIDIA: disable udev rule + hard blacklist
|
||||
├─ AMD: dump ROM → /usr/share/kvm/*.bin
|
||||
└─ update-initramfs -u -k all
|
||||
|
||||
VM config (qm set <vmid>):
|
||||
├─ hostpci0 = GPU (x-vga=1 unless Intel iGPU)
|
||||
├─ hostpci1..n = IOMMU group siblings
|
||||
├─ hostpci<last> = audio function(s)
|
||||
├─ vga = std
|
||||
└─ NVIDIA: cpu=host,hidden=1
|
||||
args=... hv_vendor_id=NV43FIX
|
||||
│
|
||||
▼
|
||||
┌───────────────┴───────────────┐
|
||||
│ PHASE 3 — Summary + reboot │
|
||||
└───────────────────────────────┘
|
||||
Show what changed. If host config
|
||||
touched → prompt reboot.`}
|
||||
</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("walkthrough.heading")}</h2>
|
||||
|
||||
<Steps>
|
||||
<Steps.Step title={t("walkthrough.detect.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.detect.body", { code })}</p>
|
||||
<Callout variant="tip" title={t("walkthrough.detect.tipTitle")}>
|
||||
{t.rich("walkthrough.detect.tipBody", { postLink })}
|
||||
</Callout>
|
||||
<Image
|
||||
src="/gpu-tpu/gpu-vm-02-gpu-detection.png"
|
||||
alt={t("walkthrough.detect.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.preflight.title")}>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.preflight.intro")}</p>
|
||||
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-3">
|
||||
{preflightItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`walkthrough.preflight.items.${idx}`, { strong, code, em })}</li>
|
||||
))}
|
||||
<li>
|
||||
{t.rich("walkthrough.preflight.audioIntro", { strong })}
|
||||
<ul className="list-disc pl-6 mt-1 space-y-1">
|
||||
<li>{t.rich("walkthrough.preflight.audioDgpu", { strong, code })}</li>
|
||||
<li>{t.rich("walkthrough.preflight.audioIgpu", { strong, code })}</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.pickVm.title")}>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.pickVm.body")}</p>
|
||||
<Image
|
||||
src="/gpu-tpu/gpu-vm-03-vm-select.png"
|
||||
alt={t("walkthrough.pickVm.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.switchMode.title")}>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.switchMode.intro")}</p>
|
||||
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-3">
|
||||
{switchModeItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`walkthrough.switchMode.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Image
|
||||
src="/gpu-tpu/gpu-vm-04-switch-mode.png"
|
||||
alt={t("walkthrough.switchMode.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<Callout variant="tip" title={t("walkthrough.switchMode.smartTitle")}>
|
||||
{t.rich("walkthrough.switchMode.smartBody", { strong, code })}
|
||||
</Callout>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.audioPick.title")}>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.audioPick.body")}</p>
|
||||
<Image
|
||||
src="/gpu-tpu/gpu-vm-07-audio-checklist.png"
|
||||
alt={t("walkthrough.audioPick.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
<Callout variant="warning" title={t("walkthrough.audioPick.warnTitle")}>
|
||||
{t("walkthrough.audioPick.warnBody")}
|
||||
</Callout>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.summary.title")}>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.summary.body")}</p>
|
||||
<Image
|
||||
src="/gpu-tpu/gpu-vm-05-summary.png"
|
||||
alt={t("walkthrough.summary.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.hostApply.title")}>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.hostApply.intro")}</p>
|
||||
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-3">
|
||||
{hostApplyItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`walkthrough.hostApply.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.vmApply.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.vmApply.body", { code })}</p>
|
||||
<CopyableCode
|
||||
code={`# Example — what ends up in the VM config after a GPU + audio passthrough
|
||||
# (you don't type this, ProxMenux does it for you)
|
||||
|
||||
hostpci0: 0000:01:00.0,pcie=1,x-vga=1[,romfile=vbios_card.bin] # GPU video
|
||||
hostpci1: 0000:01:00.1,pcie=1 # GPU audio
|
||||
vga: std
|
||||
|
||||
# NVIDIA only — hide the hypervisor from the guest driver (Code 43 fix)
|
||||
cpu: host,hidden=1,flags=+pcid
|
||||
args: -cpu 'host,+kvm_pv_unhalt,+kvm_pv_eoi,hv_vendor_id=NV43FIX,kvm=off'`}
|
||||
className="my-4"
|
||||
/>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.vmApply.after1", { code, strong })}</p>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.vmApply.after2", { code })}</p>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.reboot.title")}>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.reboot.body")}</p>
|
||||
<Image
|
||||
src="/gpu-tpu/gpu-vm-06-reboot.png"
|
||||
alt={t("walkthrough.reboot.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("vendors.heading")}</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("vendors.nvidiaHeading")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("vendors.nvidiaBody", { em, code })}
|
||||
</p>
|
||||
|
||||
<h4 className="text-base font-semibold mt-4 mb-2 text-gray-900">{t("vendors.nvidiaMultiHeading")}</h4>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("vendors.nvidiaMultiBody", { strong, code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("vendors.amdHeading")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("vendors.amdBody", { em, vendorResetLink })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("vendors.intelHeading")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("vendors.intelBody", { code, sriovLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("verification.heading")}</h2>
|
||||
|
||||
<CopyableCode
|
||||
code={`# After reboot, confirm the GPU is bound to vfio-pci (not to the vendor driver)
|
||||
lspci -nnk -d <vendor:device>
|
||||
# Expect: "Kernel driver in use: vfio-pci"
|
||||
|
||||
# Start the VM and watch for successful binding
|
||||
qm start <vmid>
|
||||
journalctl -u qemu-server@<vmid>.service -f
|
||||
|
||||
# Inside the guest, drivers install normally and the GPU works as if it were physical.
|
||||
# NVIDIA: verify with nvidia-smi inside the VM.
|
||||
# AMD: verify with Windows Device Manager / DXDiag.
|
||||
# Intel: verify display output / intel_gpu_top inside the VM.`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.code43Title")}>
|
||||
{t.rich("troubleshoot.code43Body", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.amdResetTitle")}>
|
||||
{t.rich("troubleshoot.amdResetBody", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.stuckBootTitle")}>
|
||||
{t.rich("troubleshoot.stuckBootBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.darkTitle")}>
|
||||
{t.rich("troubleshoot.darkBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<CopyableCode
|
||||
code={`# Rescue — remove passthrough
|
||||
# Remove hostpciN lines from the VM config
|
||||
sed -i '/^hostpci[0-9]*:/d' /etc/pve/qemu-server/<vmid>.conf
|
||||
|
||||
# Unblacklist the host driver
|
||||
rm -f /etc/modprobe.d/blacklist.conf /etc/modprobe.d/vfio.conf
|
||||
# (if NVIDIA was involved)
|
||||
mv /etc/udev/rules.d/70-nvidia.rules.proxmenux-disabled /etc/udev/rules.d/70-nvidia.rules 2>/dev/null
|
||||
|
||||
update-initramfs -u -k all
|
||||
reboot`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="tip" title={t("troubleshoot.logTitle")}>
|
||||
{t.rich("troubleshoot.logBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("revert.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("revert.intro")}</p>
|
||||
<CopyableCode
|
||||
code={`# Remove hostpci lines from the VM
|
||||
qm set <vmid> --delete hostpci0
|
||||
# (repeat for hostpci1, hostpci2, ... if multiple were added)
|
||||
|
||||
# ─── OPTIONAL — not required ──────────────────────────────────────
|
||||
# The steps above already free the VM from the GPU. The lines below
|
||||
# are only needed if you also want the host to use the GPU again
|
||||
# (e.g. for LXC sharing or host-side transcoding). Skip this block
|
||||
# if you simply want to stop passing the GPU to the VM.
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# Release the GPU back to the host driver:
|
||||
rm -f /etc/modprobe.d/vfio.conf
|
||||
rm -f /etc/modprobe.d/blacklist.conf # careful — this file may have other blacklists
|
||||
# NVIDIA only — re-enable the udev rule + unpin the hard blacklist
|
||||
mv /etc/udev/rules.d/70-nvidia.rules.proxmenux-disabled /etc/udev/rules.d/70-nvidia.rules 2>/dev/null
|
||||
rm -f /etc/modprobe.d/nvidia-blacklist.conf
|
||||
|
||||
update-initramfs -u -k all
|
||||
reboot`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
405
web/app/[locale]/docs/hardware/igpu-acceleration-lxc/page.tsx
Normal file
405
web/app/[locale]/docs/hardware/igpu-acceleration-lxc/page.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { Prerequisites } from "@/components/ui/prerequisites"
|
||||
import { Steps } from "@/components/ui/steps"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.hardware.igpuAccelerationLxc.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
}
|
||||
}
|
||||
|
||||
type CompareRow = { feature: string; lxc: string; vm: string }
|
||||
type PreflightItem = string
|
||||
type DistroRow = { distro: string; intel: string; nvidia: string }
|
||||
type RelatedItem = { label: string; href: string; tail?: string }
|
||||
|
||||
export default async function AddGpuToLxcPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.hardware.igpuAccelerationLxc" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { hardware: { igpuAccelerationLxc: {
|
||||
compare: { rows: CompareRow[] }
|
||||
walkthrough: {
|
||||
preflight: { items: PreflightItem[] }
|
||||
installDrivers: { rows: DistroRow[] }
|
||||
}
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const compareRows = messages.docs.hardware.igpuAccelerationLxc.compare.rows
|
||||
const preflightItems = messages.docs.hardware.igpuAccelerationLxc.walkthrough.preflight.items
|
||||
const distroRows = messages.docs.hardware.igpuAccelerationLxc.walkthrough.installDrivers.rows
|
||||
const relatedItems = messages.docs.hardware.igpuAccelerationLxc.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const vmLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/gpu-vm-passthrough" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const switchLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/switch-gpu-mode" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const nvidiaLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/nvidia-host" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={10}
|
||||
scriptPath="gpu_tpu/add_gpu_lxc.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("compare.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("compare.intro", { em, vmLink })}
|
||||
</p>
|
||||
<div className="my-4 overflow-x-auto">
|
||||
<table className="min-w-full border border-gray-200 text-sm">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("compare.headerFeature")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("compare.headerLxc")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">
|
||||
<Link href="/docs/hardware/gpu-vm-passthrough" className="text-blue-700 hover:underline">
|
||||
{t("compare.headerVm")}
|
||||
</Link>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{compareRows.map((row, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="border border-gray-200 px-3 py-2">{row.feature}</td>
|
||||
<td className="border border-gray-200 px-3 py-2">{row.lxc}</td>
|
||||
<td className="border border-gray-200 px-3 py-2">{row.vm}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Prerequisites
|
||||
title={t("prereqs.title")}
|
||||
items={[
|
||||
{
|
||||
label: <>{t.rich("prereqs.gpu", { strong, code })}</>,
|
||||
check: t("prereqs.gpuCheck"),
|
||||
},
|
||||
{
|
||||
label: <>{t.rich("prereqs.vfio", { strong, switchLink })}</>,
|
||||
},
|
||||
{
|
||||
label: <>{t.rich("prereqs.nvidia", { strong, nvidiaLink })}</>,
|
||||
check: t("prereqs.nvidiaCheck"),
|
||||
},
|
||||
{
|
||||
label: <>{t.rich("prereqs.container", { strong })}</>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Callout variant="warning" title={t("unpriv.title")}>
|
||||
{t.rich("unpriv.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("running.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("running.body", { strong })}
|
||||
</p>
|
||||
|
||||
<Image
|
||||
src="/gpu-tpu/gpu-lxc-01-menu-entry.png"
|
||||
alt={t("running.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howRuns.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("howRuns.body")}</p>
|
||||
|
||||
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
|
||||
{`┌─────────────────────────────────────────────┐
|
||||
│ PHASE 1 — Detect, select, validate │
|
||||
│ (nothing touched yet) │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
▼
|
||||
lspci detects Intel / AMD / NVIDIA GPU(s)
|
||||
(NVIDIA: also check nvidia module loaded +
|
||||
nvidia-smi, capture host driver version)
|
||||
│
|
||||
▼
|
||||
User picks LXC container from the list
|
||||
│
|
||||
▼
|
||||
User selects which GPU(s) to add
|
||||
(checklist; auto-selects if only one)
|
||||
│
|
||||
▼
|
||||
Pre-flight checks
|
||||
├─ Not in SR-IOV (VF / active PF) → block
|
||||
├─ Bound to vfio-pci? → offer Switch Mode, exit
|
||||
└─ Already configured in this CT? → filter out
|
||||
(skip duplicates, warn if partial)
|
||||
│
|
||||
┌─────── Cancel OR Confirm ────┐
|
||||
▼ ▼
|
||||
Exit, nothing ┌──────────────────┴──────────────────┐
|
||||
was changed │ PHASE 2 — Configure + install │
|
||||
└──────────────────┬──────────────────┘
|
||||
▼
|
||||
Stop container (if running)
|
||||
│
|
||||
▼
|
||||
Write LXC config (/etc/pve/lxc/<ctid>.conf):
|
||||
├─ Intel / AMD iGPU:
|
||||
│ dev<N>: /dev/dri/card* gid=video
|
||||
│ dev<N>: /dev/dri/renderD* gid=render
|
||||
├─ AMD with ROCm (if /dev/kfd):
|
||||
│ dev<N>: /dev/kfd gid=render
|
||||
└─ NVIDIA:
|
||||
dev<N>: /dev/nvidia0..N
|
||||
dev<N>: /dev/nvidiactl · nvidia-uvm*
|
||||
dev<N>: /dev/nvidia-modeset
|
||||
dev<N>: /dev/nvidia-caps/* (if exists)
|
||||
│
|
||||
▼
|
||||
Install GPU guard hookscript
|
||||
(same one used by VM passthrough, if
|
||||
available — prevents conflicts on start/stop)
|
||||
│
|
||||
▼
|
||||
Start container + wait for readiness
|
||||
(pct exec — true, up to ~30 s)
|
||||
│
|
||||
▼
|
||||
Install userspace drivers inside CT
|
||||
(distro auto-detected)
|
||||
├─ Intel → apk/pacman/apt
|
||||
│ (intel-media-driver,
|
||||
│ libva-utils, opencl-icd)
|
||||
├─ AMD → Mesa VA drivers
|
||||
│ (mesa-va-drivers, libva)
|
||||
└─ NVIDIA →
|
||||
├─ Alpine: apk add nvidia-utils
|
||||
├─ Arch: pacman -S nvidia-utils
|
||||
└─ Debian/Ubuntu/others:
|
||||
host .run is pre-extracted, packed,
|
||||
pct push'd into the container, run
|
||||
with --no-kernel-modules --no-dkms
|
||||
│
|
||||
▼
|
||||
Align GIDs in /etc/group inside CT
|
||||
(video=44, render=104 to match host)
|
||||
│
|
||||
▼
|
||||
Restore container state
|
||||
(stop if it was stopped before)
|
||||
│
|
||||
▼
|
||||
Show summary + nvidia-smi output
|
||||
(if NVIDIA) + log path`}
|
||||
</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("walkthrough.heading")}</h2>
|
||||
|
||||
<Steps>
|
||||
<Steps.Step title={t("walkthrough.detect.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.detect.body", { code })}</p>
|
||||
<Callout variant="tip" title={t("walkthrough.detect.tipTitle")}>
|
||||
{t.rich("walkthrough.detect.tipBody", { nvidiaLink })}
|
||||
</Callout>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.pickCt.title")}>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.pickCt.body")}</p>
|
||||
<Image
|
||||
src="/gpu-tpu/select-container.png"
|
||||
alt={t("walkthrough.pickCt.imageAlt")}
|
||||
width={800}
|
||||
height={400}
|
||||
className="rounded shadow-lg my-4"
|
||||
/>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.selectGpu.title")}>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.selectGpu.body")}</p>
|
||||
<Image
|
||||
src="/gpu-tpu/gpu-lxc-02-gpu-select.png"
|
||||
alt={t("walkthrough.selectGpu.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-4"
|
||||
/>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.preflight.title")}>
|
||||
<Image
|
||||
src="/gpu-tpu/gpu-lxc-03-switch-mode.png"
|
||||
alt={t("walkthrough.preflight.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-4"
|
||||
/>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.preflight.intro")}</p>
|
||||
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-3">
|
||||
{preflightItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`walkthrough.preflight.items.${idx}`, { strong, code, switchLink })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.applyConfig.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.applyConfig.body1", { code })}</p>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.applyConfig.body2")}</p>
|
||||
<CopyableCode
|
||||
code={`# in /etc/pve/lxc/<ctid>.conf
|
||||
dev0: /dev/dri/card0,gid=44
|
||||
dev1: /dev/dri/renderD128,gid=104
|
||||
dev2: /dev/nvidia0,gid=44
|
||||
dev3: /dev/nvidiactl,gid=44
|
||||
dev4: /dev/nvidia-uvm,gid=44
|
||||
dev5: /dev/nvidia-uvm-tools,gid=44
|
||||
dev6: /dev/nvidia-modeset,gid=44`}
|
||||
className="my-4"
|
||||
/>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.installDrivers.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.installDrivers.body", { code })}</p>
|
||||
<div className="my-4 overflow-x-auto">
|
||||
<table className="min-w-full border border-gray-200 text-sm">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("walkthrough.installDrivers.headerDistro")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("walkthrough.installDrivers.headerInt")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("walkthrough.installDrivers.headerNvidia")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{distroRows.map((row, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="border border-gray-200 px-3 py-2">{row.distro}</td>
|
||||
<td className="border border-gray-200 px-3 py-2"><code>{row.intel}</code></td>
|
||||
<td className="border border-gray-200 px-3 py-2"><code>{row.nvidia}</code></td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td className="border border-gray-200 px-3 py-2">{t("walkthrough.installDrivers.debianDistro")}</td>
|
||||
<td className="border border-gray-200 px-3 py-2"><code>{t("walkthrough.installDrivers.debianIntel")}</code></td>
|
||||
<td className="border border-gray-200 px-3 py-2">{t.rich("walkthrough.installDrivers.debianNvidia", { code })}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Callout variant="tip" title={t("walkthrough.installDrivers.whyTitle")}>
|
||||
{t.rich("walkthrough.installDrivers.whyBody", { code, strong })}
|
||||
</Callout>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.alignGids.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.alignGids.body1", { code })}</p>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.alignGids.body2")}</p>
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("vendors.heading")}</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("vendors.intelHeading")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("vendors.intelBody", { em, code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("vendors.amdHeading")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("vendors.amdBody", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("vendors.nvidiaHeading")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("vendors.nvidiaBody", { code, strong })}
|
||||
</p>
|
||||
|
||||
<Callout variant="warning" title={t("vendors.updateTitle")}>
|
||||
{t.rich("vendors.updateBody", { code, nvidiaLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("verification.heading")}</h2>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("verification.body")}</p>
|
||||
<CopyableCode
|
||||
code={`# Enter the container
|
||||
pct enter <ctid>
|
||||
|
||||
# Intel / AMD — check DRI nodes and VA-API
|
||||
ls -l /dev/dri/
|
||||
vainfo
|
||||
|
||||
# NVIDIA — check nvidia-smi matches the host version
|
||||
nvidia-smi
|
||||
nvidia-smi --query-gpu=driver_version --format=csv,noheader
|
||||
|
||||
# Check group alignment
|
||||
getent group video render`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.mismatchTitle")}>
|
||||
{t.rich("troubleshoot.mismatchBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.denyTitle")}>
|
||||
{t.rich("troubleshoot.denyBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.vainfoTitle")}>
|
||||
{t.rich("troubleshoot.vainfoBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("troubleshoot.logTitle")}>
|
||||
{t.rich("troubleshoot.logBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
511
web/app/[locale]/docs/hardware/install-coral-tpu-host/page.tsx
Normal file
511
web/app/[locale]/docs/hardware/install-coral-tpu-host/page.tsx
Normal file
@@ -0,0 +1,511 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { Prerequisites } from "@/components/ui/prerequisites"
|
||||
import { Steps } from "@/components/ui/steps"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.hardware.installCoralTpuHost.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
}
|
||||
}
|
||||
|
||||
type StringItem = string
|
||||
type RelatedItem = { label: string; href: string; tail?: string }
|
||||
|
||||
export default async function InstallCoralTPUHostPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.hardware.installCoralTpuHost" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { hardware: { installCoralTpuHost: {
|
||||
walkthrough: {
|
||||
detect: { items: StringItem[] }
|
||||
pcie: { items: StringItem[]; kernelPatches: StringItem[]; afterItems: StringItem[] }
|
||||
usb: { items: StringItem[] }
|
||||
}
|
||||
reinstallUninstall: { uninstallItems: StringItem[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const detectItems = messages.docs.hardware.installCoralTpuHost.walkthrough.detect.items
|
||||
const pcieItems = messages.docs.hardware.installCoralTpuHost.walkthrough.pcie.items
|
||||
const kernelPatches = messages.docs.hardware.installCoralTpuHost.walkthrough.pcie.kernelPatches
|
||||
const pcieAfterItems = messages.docs.hardware.installCoralTpuHost.walkthrough.pcie.afterItems
|
||||
const usbItems = messages.docs.hardware.installCoralTpuHost.walkthrough.usb.items
|
||||
const uninstallItems = messages.docs.hardware.installCoralTpuHost.reinstallUninstall.uninstallItems
|
||||
const relatedItems = messages.docs.hardware.installCoralTpuHost.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const lxcLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/coral-tpu-lxc" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const feranickLink = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://github.com/feranick/gasket-driver"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
const googleLink = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://github.com/google/gasket-driver"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={10}
|
||||
scriptPath="gpu_tpu/install_coral.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { strong, code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("which.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("which.body")}</p>
|
||||
|
||||
<div className="my-4 overflow-x-auto">
|
||||
<table className="min-w-full border border-gray-200 text-sm">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("which.headerForm")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("which.headerDetect")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("which.headerInstall")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("which.headerReboot")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
<tr>
|
||||
<td className="border border-gray-200 px-3 py-2">
|
||||
<strong>{t("which.pcieForm")}</strong>
|
||||
<br />
|
||||
<span className="text-xs text-gray-600">{t("which.pcieFormSub")}</span>
|
||||
</td>
|
||||
<td className="border border-gray-200 px-3 py-2">{t.rich("which.pcieDetect", { code })}</td>
|
||||
<td className="border border-gray-200 px-3 py-2">{t("which.pcieInstall")}</td>
|
||||
<td className="border border-gray-200 px-3 py-2"><strong>{t("which.pcieReboot")}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-200 px-3 py-2">
|
||||
<strong>{t("which.usbForm")}</strong>
|
||||
<br />
|
||||
<span className="text-xs text-gray-600">{t("which.usbFormSub")}</span>
|
||||
</td>
|
||||
<td className="border border-gray-200 px-3 py-2">{t.rich("which.usbDetect", { code })}</td>
|
||||
<td className="border border-gray-200 px-3 py-2">{t.rich("which.usbInstall", { code })}</td>
|
||||
<td className="border border-gray-200 px-3 py-2">{t("which.usbReboot")}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Prerequisites
|
||||
title={t("prereqs.title")}
|
||||
items={[
|
||||
{ label: <>{t.rich("prereqs.coral", { strong })}</>, check: t("prereqs.coralCheck") },
|
||||
{ label: <>{t.rich("prereqs.internet", { strong, code })}</> },
|
||||
{ label: <>{t.rich("prereqs.headers", { strong, code })}</> },
|
||||
{ label: <>{t.rich("prereqs.reboot", { strong })}</> },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Callout variant="tip" title={t("hostPrepTip.title")}>
|
||||
{t.rich("hostPrepTip.body", { em, lxcLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("running.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("running.body", { strong })}
|
||||
</p>
|
||||
|
||||
<Image
|
||||
src="/gpu-tpu/coral-host-01-menu-entry.png"
|
||||
alt={t("running.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howRuns.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("howRuns.body")}</p>
|
||||
|
||||
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
|
||||
{`┌────────────────────────────────────────────────┐
|
||||
│ 1. detect_coral_hardware() │
|
||||
│ → count PCIe (vendor 1ac1) + USB (IDs) │
|
||||
└────────────────┬───────────────────────────────┘
|
||||
▼
|
||||
┌───────────┴───────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
None At least one
|
||||
│ │
|
||||
▼ ▼
|
||||
Dialog pre_install_prompt()
|
||||
"No Coral" → shows what was detected
|
||||
exit 0 and what will be installed
|
||||
│
|
||||
▼
|
||||
┌────────────────┴────────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
PCIe detected? USB detected?
|
||||
│ │
|
||||
Yes Yes
|
||||
▼ ▼
|
||||
install_gasket_apex_dkms install_libedgetpu_runtime
|
||||
├─ cleanup_broken_gasket_dkms ├─ add Google GPG keyring
|
||||
├─ apt install deps │ /etc/apt/keyrings/...
|
||||
│ (git, dkms, build-essential, ├─ add APT repo (signed-by)
|
||||
│ proxmox-headers-$(uname-r)) │ /etc/apt/sources.list.d/
|
||||
├─ clone feranick/gasket-driver │ coral-edgetpu.list
|
||||
│ (google fallback + patches) ├─ apt install libedgetpu1-std
|
||||
├─ copy src/ → /usr/src/ └─ udev reload + trigger
|
||||
│ gasket-1.0/
|
||||
├─ generate dkms.conf
|
||||
├─ dkms add / build / install
|
||||
└─ modprobe gasket + apex
|
||||
+ ensure_apex_group_and_udev
|
||||
│ │
|
||||
└────────────────┬────────────────┘
|
||||
▼
|
||||
┌────────────────┴────────────────┐
|
||||
│ │
|
||||
PCIe ran? USB only
|
||||
│ │
|
||||
▼ ▼
|
||||
restart_prompt() "No reboot required"
|
||||
(reboot required to (runtime + udev rules
|
||||
load fresh kernel are already active)
|
||||
module cleanly)`}
|
||||
</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("walkthrough.heading")}</h2>
|
||||
|
||||
<Steps>
|
||||
<Steps.Step title={t("walkthrough.detect.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.detect.body", { code, strong })}</p>
|
||||
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-3">
|
||||
{detectItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`walkthrough.detect.items.${idx}`, { code, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.detect.outro")}</p>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.prompt.title")}>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.prompt.body")}</p>
|
||||
<Image
|
||||
src="/gpu-tpu/coral-host-02-pre-install.png"
|
||||
alt={t("walkthrough.prompt.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-4"
|
||||
/>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.pcie.title")}>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.pcie.body")}</p>
|
||||
<ol className="list-decimal pl-6 space-y-1 text-gray-800 mb-3">
|
||||
{pcieItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`walkthrough.pcie.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
<li>
|
||||
{t.rich("walkthrough.pcie.cloneIntro", { strong, feranickLink, googleLink })}
|
||||
<ul className="list-disc pl-6 mt-1">
|
||||
{kernelPatches.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`walkthrough.pcie.kernelPatches.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
{pcieAfterItems.map((_, idx) => (
|
||||
<li key={`after-${idx}`}>{t.rich(`walkthrough.pcie.afterItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
<Image
|
||||
src="/gpu-tpu/coral-host-03-dkms-build.png"
|
||||
alt={t("walkthrough.pcie.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-4"
|
||||
/>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.usb.title")}>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.usb.body")}</p>
|
||||
<ol className="list-decimal pl-6 space-y-1 text-gray-800 mb-3">
|
||||
{usbItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`walkthrough.usb.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
<Callout variant="tip" title={t("walkthrough.usb.stdTitle")}>
|
||||
{t.rich("walkthrough.usb.stdBody", { code })}
|
||||
</Callout>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.reboot.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.reboot.body", { code })}</p>
|
||||
<Image
|
||||
src="/gpu-tpu/coral-host-04-summary.png"
|
||||
alt={t("walkthrough.reboot.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-4"
|
||||
/>
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("reinstallUninstall.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("reinstallUninstall.intro", { code })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image
|
||||
src="/gpu-tpu/coral-host-05-action-menu.png"
|
||||
alt={t("reinstallUninstall.imageAlt")}
|
||||
width={1200}
|
||||
height={680}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("reinstallUninstall.imageCaption", { code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("reinstallUninstall.reinstallHeading")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("reinstallUninstall.reinstallBody", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("reinstallUninstall.uninstallHeading")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("reinstallUninstall.uninstallIntro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{uninstallItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`reinstallUninstall.uninstallItems.${idx}`, { code, strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="warning" title={t("reinstallUninstall.lxcWarnTitle")}>
|
||||
{t("reinstallUninstall.lxcWarnBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("updates.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("updates.intro")}</p>
|
||||
|
||||
<div className="my-4 overflow-x-auto">
|
||||
<table className="min-w-full border border-gray-200 text-sm">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("updates.headerVariant")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("updates.headerTracked")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("updates.headerUpstream")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
<tr>
|
||||
<td className="border border-gray-200 px-3 py-2">{t("updates.pcieVariant")}</td>
|
||||
<td className="border border-gray-200 px-3 py-2">{t.rich("updates.pcieTracked", { code })}</td>
|
||||
<td className="border border-gray-200 px-3 py-2">{t.rich("updates.pcieUpstream", { code })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-200 px-3 py-2">{t("updates.usbVariant")}</td>
|
||||
<td className="border border-gray-200 px-3 py-2">{t.rich("updates.usbTracked", { code })}</td>
|
||||
<td className="border border-gray-200 px-3 py-2">{t.rich("updates.usbUpstream", { code })}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("updates.outro", { code, strong })}
|
||||
</p>
|
||||
|
||||
<Callout variant="info" title={t("updates.antiTitle")}>
|
||||
{t("updates.antiBody")}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="info" title={t("updates.rebootTitle")}>
|
||||
{t("updates.rebootBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("manual.intro")}</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("manual.pcieHeading")}</h3>
|
||||
<CopyableCode
|
||||
code={`# Build deps
|
||||
apt-get update
|
||||
apt-get install -y git dkms build-essential "proxmox-headers-$(uname -r)"
|
||||
|
||||
# Clone the (maintained) fork
|
||||
cd /tmp
|
||||
rm -rf gasket-driver
|
||||
git clone --depth=1 https://github.com/feranick/gasket-driver.git
|
||||
|
||||
# Stage under /usr/src for DKMS
|
||||
cp -a /tmp/gasket-driver/src/. /usr/src/gasket-1.0/
|
||||
|
||||
# Tell DKMS what it is
|
||||
cat > /usr/src/gasket-1.0/dkms.conf <<'EOF'
|
||||
PACKAGE_NAME="gasket"
|
||||
PACKAGE_VERSION="1.0"
|
||||
BUILT_MODULE_NAME[0]="gasket"
|
||||
BUILT_MODULE_NAME[1]="apex"
|
||||
DEST_MODULE_LOCATION[0]="/updates/dkms"
|
||||
DEST_MODULE_LOCATION[1]="/updates/dkms"
|
||||
MAKE[0]="make KVERSION=\${kernelver}"
|
||||
CLEAN="make clean"
|
||||
AUTOINSTALL="yes"
|
||||
EOF
|
||||
|
||||
# Register + build + install
|
||||
dkms add /usr/src/gasket-1.0
|
||||
dkms build gasket/1.0 -k "$(uname -r)"
|
||||
dkms install gasket/1.0 -k "$(uname -r)"
|
||||
|
||||
# Load it
|
||||
modprobe gasket
|
||||
modprobe apex
|
||||
|
||||
# Group + udev so /dev/apex_* end up with sane perms
|
||||
groupadd --system apex 2>/dev/null || true
|
||||
cat > /etc/udev/rules.d/99-coral-apex.rules <<'EOF'
|
||||
KERNEL=="apex_*", GROUP="apex", MODE="0660"
|
||||
SUBSYSTEM=="apex", GROUP="apex", MODE="0660"
|
||||
EOF
|
||||
udevadm control --reload-rules
|
||||
udevadm trigger --subsystem-match=apex || true
|
||||
|
||||
# (Reboot recommended)`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("manual.usbHeading")}</h3>
|
||||
<CopyableCode
|
||||
code={`# GPG key + repo (modern signed-by layout)
|
||||
mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg \\
|
||||
| gpg --dearmor -o /etc/apt/keyrings/coral-edgetpu.gpg
|
||||
chmod 0644 /etc/apt/keyrings/coral-edgetpu.gpg
|
||||
|
||||
echo 'deb [signed-by=/etc/apt/keyrings/coral-edgetpu.gpg] https://packages.cloud.google.com/apt coral-edgetpu-stable main' \\
|
||||
> /etc/apt/sources.list.d/coral-edgetpu.list
|
||||
|
||||
# Install the runtime
|
||||
apt-get update
|
||||
apt-get install -y libedgetpu1-std
|
||||
|
||||
# Reload udev so shipped rules apply to anything already plugged in
|
||||
udevadm control --reload-rules
|
||||
udevadm trigger --subsystem-match=usb || true`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("verification.heading")}</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("verification.pcieHeading")}</h3>
|
||||
<CopyableCode
|
||||
code={`# Module loaded?
|
||||
lsmod | grep apex
|
||||
# Expect: apex, gasket (gasket as dependency)
|
||||
|
||||
# Device node present with apex group?
|
||||
ls -l /dev/apex_*
|
||||
# Expect: crw-rw---- 1 root apex ... /dev/apex_0
|
||||
|
||||
# Kernel sees the device cleanly?
|
||||
dmesg | grep -i apex | tail -10
|
||||
# Expect: "Apex chip ID ..." / "apex 0000:xx:00.0: Apex performance changed to ..."`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("verification.usbHeading")}</h3>
|
||||
<CopyableCode
|
||||
code={`# Is it seen by USB?
|
||||
lsusb | grep -E '1a6e:089a|18d1:9302'
|
||||
|
||||
# Runtime installed?
|
||||
dpkg -l libedgetpu1-std | grep ii
|
||||
|
||||
# Quick smoke test — run the Coral classification example from any Python env
|
||||
# pip install pycoral tflite_runtime pillow
|
||||
# (Ran from the container that will use the TPU, not the host.)`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.dkmsFailTitle")}>
|
||||
{t.rich("troubleshoot.dkmsFailBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.apexMissTitle")}>
|
||||
{t.rich("troubleshoot.apexMissBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.lxcMissTitle")}>
|
||||
{t.rich("troubleshoot.lxcMissBody", { lxcLink })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.usbUnreachTitle")}>
|
||||
{t.rich("troubleshoot.usbUnreachBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("troubleshoot.logTitle")}>
|
||||
{t.rich("troubleshoot.logBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
463
web/app/[locale]/docs/hardware/nvidia-host/page.tsx
Normal file
463
web/app/[locale]/docs/hardware/nvidia-host/page.tsx
Normal file
@@ -0,0 +1,463 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { Prerequisites } from "@/components/ui/prerequisites"
|
||||
import { Steps } from "@/components/ui/steps"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.hardware.nvidiaHost.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
}
|
||||
}
|
||||
|
||||
type MatrixRow = { kernel: string; pve: string; minCode: string; minTail: string }
|
||||
type StringItem = string
|
||||
type RelatedItem = { label: string; href: string; tail?: string }
|
||||
|
||||
export default async function NvidiaHostPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.hardware.nvidiaHost" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { hardware: { nvidiaHost: {
|
||||
walkthrough: {
|
||||
version: { rows: MatrixRow[] }
|
||||
prepare: { items: StringItem[] }
|
||||
}
|
||||
reinstallUninstall: { uninstallItems: StringItem[] }
|
||||
updates: { kindsItems: StringItem[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const matrixRows = messages.docs.hardware.nvidiaHost.walkthrough.version.rows
|
||||
const prepareItems = messages.docs.hardware.nvidiaHost.walkthrough.prepare.items
|
||||
const uninstallItems = messages.docs.hardware.nvidiaHost.reinstallUninstall.uninstallItems
|
||||
const kindsItems = messages.docs.hardware.nvidiaHost.updates.kindsItems
|
||||
const relatedItems = messages.docs.hardware.nvidiaHost.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const persistLink = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://github.com/NVIDIA/nvidia-persistenced"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
const patchLink = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://github.com/keylase/nvidia-patch"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
const patchTableLink = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://github.com/keylase/nvidia-patch#patch-list"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-700 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
const guideLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/guides/nvidia-manual" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={20}
|
||||
scriptPath="gpu_tpu/nvidia_installer.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("who.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("who.body", { strong, em })}
|
||||
</p>
|
||||
|
||||
<Prerequisites
|
||||
title={t("prereqs.title")}
|
||||
items={[
|
||||
{ label: <>{t.rich("prereqs.gpu", { strong })}</>, check: t("prereqs.gpuCheck") },
|
||||
{ label: <>{t.rich("prereqs.notVm", { strong })}</> },
|
||||
{ label: <>{t.rich("prereqs.internet", { code })}</> },
|
||||
{ label: <>{t.rich("prereqs.space", { strong, code })}</> },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Callout variant="warning" title={t("vmWarn.title")}>
|
||||
{t.rich("vmWarn.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("running.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("running.body", { strong })}
|
||||
</p>
|
||||
|
||||
<Image
|
||||
src="/gpu-tpu/nvidia-host-01-menu-entry.png"
|
||||
alt={t("running.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howRuns.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("howRuns.body")}</p>
|
||||
|
||||
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
|
||||
{`┌─────────────────────────────────────────────┐
|
||||
│ PHASE 1 — Detect, validate, pick version │
|
||||
│ (nothing touched yet) │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
▼
|
||||
┌────────────┴────────────┐
|
||||
▼ ▼
|
||||
lspci detects GPU bound to
|
||||
NVIDIA GPU(s) vfio-pci? ──→ Abort
|
||||
│ │ (remove VM
|
||||
│ No passthrough first)
|
||||
▼
|
||||
nvidia-smi: driver already installed?
|
||||
│
|
||||
├─ No → continue (fresh install)
|
||||
└─ Yes → ask: Reinstall/Update OR Remove
|
||||
│
|
||||
├─ Remove → complete uninstall
|
||||
│ + reboot prompt
|
||||
└─ Reinstall → continue
|
||||
│
|
||||
▼
|
||||
Show install overview
|
||||
(GPU list + current driver +
|
||||
LXC containers with NVIDIA passthrough)
|
||||
│
|
||||
▼
|
||||
Kernel-compat filter:
|
||||
├─ Kernel 6.17+ → driver 580.82.07+
|
||||
├─ Kernel 6.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`}
|
||||
</pre>
|
||||
|
||||
<Steps>
|
||||
<Steps.Step title={t("walkthrough.detect.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.detect.body1", { em })}</p>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.detect.body2")}</p>
|
||||
<Image
|
||||
src="/gpu-tpu/nvidia-host-02-overview.png"
|
||||
alt={t("walkthrough.detect.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.version.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.version.body1", { strong, em })}</p>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.version.body2")}</p>
|
||||
|
||||
<div className="my-4 overflow-x-auto">
|
||||
<table className="min-w-full border border-gray-200 text-sm">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("walkthrough.version.headerKernel")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("walkthrough.version.headerPve")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("walkthrough.version.headerMin")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{matrixRows.map((row, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="border border-gray-200 px-3 py-2">{row.kernel}</td>
|
||||
<td className="border border-gray-200 px-3 py-2">{row.pve}</td>
|
||||
<td className="border border-gray-200 px-3 py-2">
|
||||
<code>{row.minCode}</code>{row.minTail}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("walkthrough.version.whyTitle")}>
|
||||
{t("walkthrough.version.whyBody")}
|
||||
</Callout>
|
||||
|
||||
<Image
|
||||
src="/gpu-tpu/nvidia-host-03-version-menu.png"
|
||||
alt={t("walkthrough.version.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.uninstall.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.uninstall.body", { code })}</p>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.prepare.title")}>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.prepare.body")}</p>
|
||||
<ul className="list-disc pl-6 space-y-1 text-gray-800">
|
||||
{prepareItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`walkthrough.prepare.items.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.download.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.download.body", { code })}</p>
|
||||
<CopyableCode
|
||||
code={`# What ProxMenux runs under the hood (you don't have to type this):
|
||||
sh NVIDIA-Linux-x86_64-<version>.run \\
|
||||
--no-questions \\
|
||||
--ui=none \\
|
||||
--disable-nouveau \\
|
||||
--no-nouveau-check \\
|
||||
--dkms`}
|
||||
className="my-4"
|
||||
/>
|
||||
<Image
|
||||
src="/gpu-tpu/nvidia-host-04-install-progress.png"
|
||||
alt={t("walkthrough.download.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.persist.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.persist.body", { code, persistLink })}</p>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.nvenc.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.nvenc.body", { strong, patchLink })}</p>
|
||||
<Callout variant="warning" title={t("walkthrough.nvenc.supportTitle")}>
|
||||
{t.rich("walkthrough.nvenc.supportBody", { patchTableLink })}
|
||||
</Callout>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.propagate.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.propagate.body1", { code, strong })}</p>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.propagate.body2", { code })}</p>
|
||||
<Image
|
||||
src="/gpu-tpu/nvidia-host-05-lxc-update.png"
|
||||
alt={t("walkthrough.propagate.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.reboot.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.reboot.body", { code, strong })}</p>
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("reinstallUninstall.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("reinstallUninstall.intro", { code })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image
|
||||
src="/gpu-tpu/nvidia-host-06-action-menu.png"
|
||||
alt={t("reinstallUninstall.imageAlt")}
|
||||
width={1200}
|
||||
height={680}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("reinstallUninstall.imageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("reinstallUninstall.reinstallHeading")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("reinstallUninstall.reinstallBody")}</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("reinstallUninstall.uninstallHeading")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("reinstallUninstall.uninstallIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{uninstallItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`reinstallUninstall.uninstallItems.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="warning" title={t("reinstallUninstall.lxcWarnTitle")}>
|
||||
{t("reinstallUninstall.lxcWarnBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("updates.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("updates.body", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("updates.kindsHeading")}</h3>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{kindsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`updates.kindsItems.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="info" title={t("updates.antiTitle")}>
|
||||
{t("updates.antiBody")}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="info" title={t("updates.applyTitle")}>
|
||||
{t.rich("updates.applyBody", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("verify.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("verify.intro")}</p>
|
||||
<CopyableCode
|
||||
code={`nvidia-smi`}
|
||||
className="my-4"
|
||||
/>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("verify.after")}</p>
|
||||
<CopyableCode
|
||||
code={`systemctl status nvidia-persistenced`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Image
|
||||
src="/gpu-tpu/nvidia-host-06-nvidia-smi-ok.png"
|
||||
alt={t("verify.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.smiFailTitle")}>
|
||||
{t.rich("troubleshoot.smiFailBody", { strong, code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.lxcMissTitle")}>
|
||||
{t.rich("troubleshoot.lxcMissBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("troubleshoot.logTitle")}>
|
||||
{t.rich("troubleshoot.logBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manualSteps.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("manualSteps.body", { code, guideLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
450
web/app/[locale]/docs/hardware/switch-gpu-mode/page.tsx
Normal file
450
web/app/[locale]/docs/hardware/switch-gpu-mode/page.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { Prerequisites } from "@/components/ui/prerequisites"
|
||||
import { Steps } from "@/components/ui/steps"
|
||||
import { SwitchModeGraphic } from "@/components/ui/switch-mode-graphic"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.hardware.switchGpuMode.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
}
|
||||
}
|
||||
|
||||
type WhenRow = { situation?: string; situationRich?: string; use?: string; useRich?: string }
|
||||
type DirectionItem = string
|
||||
type RelatedItem = { label: string; href: string; tail?: string }
|
||||
|
||||
export default async function SwitchGpuModePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.hardware.switchGpuMode" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { hardware: { switchGpuMode: {
|
||||
when: { rows: WhenRow[] }
|
||||
walkthrough: { direction: { items: DirectionItem[] } }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const whenRows = messages.docs.hardware.switchGpuMode.when.rows
|
||||
const directionItems = messages.docs.hardware.switchGpuMode.walkthrough.direction.items
|
||||
const relatedItems = messages.docs.hardware.switchGpuMode.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const vmLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/gpu-vm-passthrough" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const lxcLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/igpu-acceleration-lxc" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={10}
|
||||
scriptPath="gpu_tpu/switch_gpu_mode.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code, em, strong })}
|
||||
</Callout>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 my-6 not-prose">
|
||||
<SwitchModeGraphic
|
||||
mode="lxc"
|
||||
title={t("graphics.lxcTitle")}
|
||||
description={t("graphics.lxcDesc")}
|
||||
/>
|
||||
<SwitchModeGraphic
|
||||
mode="vm"
|
||||
title={t("graphics.vmTitle")}
|
||||
description={t("graphics.vmDesc")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("when.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("when.intro", { strong })}
|
||||
</p>
|
||||
<div className="my-4 overflow-x-auto">
|
||||
<table className="min-w-full border border-gray-200 text-sm">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("when.headerSituation")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("when.headerUse")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{whenRows.map((row, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="border border-gray-200 px-3 py-2">
|
||||
{row.situationRich
|
||||
? t.rich(`when.rows.${idx}.situationRich`, { code })
|
||||
: row.situation}
|
||||
</td>
|
||||
<td className="border border-gray-200 px-3 py-2">
|
||||
{row.useRich
|
||||
? t.rich(`when.rows.${idx}.useRich`, { vmLink, lxcLink, strong })
|
||||
: row.use}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Prerequisites
|
||||
title={t("prereqs.title")}
|
||||
items={[
|
||||
{ label: <>{t.rich("prereqs.assigned", { strong })}</> },
|
||||
{
|
||||
label: <>{t.rich("prereqs.iommu", { strong, em })}</>,
|
||||
check: t("prereqs.iommuCheck"),
|
||||
},
|
||||
{ label: <>{t.rich("prereqs.reboot", { strong })}</> },
|
||||
{ label: <>{t.rich("prereqs.knowList", { strong })}</> },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Callout variant="warning" title={t("blocklist.title")}>
|
||||
{t.rich("blocklist.body", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("running.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("running.body", { strong })}
|
||||
</p>
|
||||
|
||||
<Image
|
||||
src="/gpu-tpu/gpu-switch-01-menu-entry.png"
|
||||
alt={t("running.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howRuns.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("howRuns.body")}</p>
|
||||
|
||||
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
|
||||
{`┌─────────────────────────────────────────────┐
|
||||
│ PHASE 1 — Detect, select, plan │
|
||||
│ (nothing touched yet) │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
▼
|
||||
lspci detects every GPU + current driver
|
||||
(vfio-pci, nvidia, amdgpu, i915, …)
|
||||
│
|
||||
▼
|
||||
User selects GPU(s) to switch
|
||||
(checklist; auto-selects if only one)
|
||||
│
|
||||
▼
|
||||
Uniform current mode check
|
||||
├─ All in VM mode → target = LXC
|
||||
├─ All in LXC mode → target = VM
|
||||
└─ Mixed → reject, reselect
|
||||
│
|
||||
▼
|
||||
Validations
|
||||
├─ SR-IOV VF / active PF? → block
|
||||
├─ Target = VM and blocked ID? → block
|
||||
└─ IOMMU parameter present? → warn if missing
|
||||
│
|
||||
▼
|
||||
Find affected workloads
|
||||
├─ LXC configs referencing the GPU
|
||||
└─ VM configs with hostpci for the GPU
|
||||
(precise BDF regex, no substring false-positives)
|
||||
│
|
||||
▼
|
||||
Conflict policy per affected workload
|
||||
┌──────────────────────────────────────┐
|
||||
│ Keep config, disable onboot │
|
||||
│ └─ safest; workload stays defined │
|
||||
│ but won't auto-start broken │
|
||||
│ Remove GPU lines from config │
|
||||
│ └─ clean; workload works without │
|
||||
│ the GPU after the switch │
|
||||
└──────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
If target = LXC (leaving VM mode):
|
||||
└─ Orphan audio cascade
|
||||
(offer to remove companion audio
|
||||
hostpci + clean vfio.conf if the
|
||||
audio ID isn't used by any other VM)
|
||||
│
|
||||
▼
|
||||
Confirmation summary
|
||||
(target mode + affected workloads +
|
||||
host changes about to happen)
|
||||
│
|
||||
┌─────── Cancel OR Confirm ────┐
|
||||
▼ ▼
|
||||
Exit, nothing ┌──────────────────┴──────────────────┐
|
||||
was changed │ PHASE 2 — Apply │
|
||||
└──────────────────┬──────────────────┘
|
||||
▼
|
||||
Target = VM (bind to vfio-pci):
|
||||
├─ /etc/modprobe.d/vfio.conf
|
||||
│ add vendor:device + disable_vga=1
|
||||
├─ /etc/modprobe.d/blacklist.conf
|
||||
│ add type-specific blacklists
|
||||
├─ /etc/modules
|
||||
│ add vfio-pci, vfio
|
||||
├─ NVIDIA: sanitize host stack
|
||||
│ (disable udev rule, hard-blacklist)
|
||||
└─ AMD: softdep vfio-pci
|
||||
|
||||
Target = LXC (back to native driver):
|
||||
├─ /etc/modprobe.d/vfio.conf
|
||||
│ drop vendor:device IDs for this GPU
|
||||
│ (delete line if now empty)
|
||||
├─ /etc/modprobe.d/blacklist.conf
|
||||
│ drop type blacklists if no GPU of
|
||||
│ that type remains in vfio.conf
|
||||
├─ /etc/modules
|
||||
│ drop vfio-pci if no GPU in vfio.conf
|
||||
└─ NVIDIA: restore host stack
|
||||
(re-enable udev, drop hard-blacklist)
|
||||
│
|
||||
▼
|
||||
Apply workload conflict policy
|
||||
(pct set onboot=0 OR sed hostpci/dev
|
||||
lines out of VM/LXC configs)
|
||||
│
|
||||
▼
|
||||
update-initramfs -u -k all
|
||||
(only if host config actually changed)
|
||||
│
|
||||
▼
|
||||
Reboot prompt — required for the new
|
||||
binding to take effect`}
|
||||
</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("walkthrough.heading")}</h2>
|
||||
|
||||
<Steps>
|
||||
<Steps.Step title={t("walkthrough.detect.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.detect.body", { code })}</p>
|
||||
<Image
|
||||
src="/gpu-tpu/gpu-switch-02-gpu-select.png"
|
||||
alt={t("walkthrough.detect.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.pickGpu.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.pickGpu.body", { em })}</p>
|
||||
<Callout variant="tip" title={t("walkthrough.pickGpu.tipTitle")}>
|
||||
{t("walkthrough.pickGpu.tipBody")}
|
||||
</Callout>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.direction.title")}>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.direction.intro")}</p>
|
||||
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-3">
|
||||
{directionItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`walkthrough.direction.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.direction.outro")}</p>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.conflict.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.conflict.body", { code })}</p>
|
||||
<div className="my-4 overflow-x-auto">
|
||||
<table className="min-w-full border border-gray-200 text-sm">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("walkthrough.conflict.headerPolicy")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("walkthrough.conflict.headerEffect")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("walkthrough.conflict.headerWhen")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
<tr>
|
||||
<td className="border border-gray-200 px-3 py-2"><strong>{t("walkthrough.conflict.keepPolicy")}</strong></td>
|
||||
<td className="border border-gray-200 px-3 py-2">{t.rich("walkthrough.conflict.keepEffect", { code })}</td>
|
||||
<td className="border border-gray-200 px-3 py-2">{t("walkthrough.conflict.keepWhen")}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-200 px-3 py-2"><strong>{t("walkthrough.conflict.removePolicy")}</strong></td>
|
||||
<td className="border border-gray-200 px-3 py-2">{t.rich("walkthrough.conflict.removeEffect", { code })}</td>
|
||||
<td className="border border-gray-200 px-3 py-2">{t("walkthrough.conflict.removeWhen")}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Image
|
||||
src="/gpu-tpu/gpu-switch-03-conflict-policy.png"
|
||||
alt={t("walkthrough.conflict.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.audio.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.audio.body1", { code })}</p>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.audio.body2", { code })}</p>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.apply.title")}>
|
||||
<p className="mb-3 text-gray-800">{t.rich("walkthrough.apply.body", { code })}</p>
|
||||
</Steps.Step>
|
||||
|
||||
<Steps.Step title={t("walkthrough.reboot.title")}>
|
||||
<p className="mb-3 text-gray-800">{t("walkthrough.reboot.body")}</p>
|
||||
<Image
|
||||
src="/gpu-tpu/gpu-switch-04-summary.png"
|
||||
alt={t("walkthrough.reboot.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("manual.intro", { strong, code })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`# Drop the vendor:device from vfio.conf — keep other GPUs intact
|
||||
sed -i 's/10de:2204,//; s/,10de:2204//; s/=10de:2204 /=/' /etc/modprobe.d/vfio.conf
|
||||
|
||||
# Remove the NVIDIA hard-blacklist and nouveau blacklist
|
||||
sed -i '/^blacklist nouveau$/d; /^blacklist nvidia$/d; /^blacklist nvidia_drm$/d; /^blacklist nvidia_modeset$/d; /^blacklist nvidia_uvm$/d; /^blacklist nvidiafb$/d' /etc/modprobe.d/blacklist.conf
|
||||
rm -f /etc/modprobe.d/nvidia-blacklist.conf
|
||||
|
||||
# Re-enable NVIDIA udev rule + modules-load config (if disabled by VM-mode switch)
|
||||
[ -f /etc/udev/rules.d/70-nvidia.rules.proxmenux-disabled-vfio ] && \\
|
||||
mv /etc/udev/rules.d/70-nvidia.rules.proxmenux-disabled-vfio \\
|
||||
/etc/udev/rules.d/70-nvidia.rules
|
||||
[ -f /etc/modules-load.d/nvidia-vfio.conf.proxmenux-disabled-vfio ] && \\
|
||||
mv /etc/modules-load.d/nvidia-vfio.conf.proxmenux-disabled-vfio \\
|
||||
/etc/modules-load.d/nvidia-vfio.conf
|
||||
|
||||
# Clean up the VM config — precise BDF regex, no substring collisions
|
||||
# (replace 0000:01:00 with your GPU's slot)
|
||||
sed -E -i '/^hostpci[0-9]+:[[:space:]]*(0000:)?01:00\\.[0-7]([,[:space:]]|$)/d' \\
|
||||
/etc/pve/qemu-server/<vmid>.conf
|
||||
|
||||
# Rebuild initramfs and reboot
|
||||
update-initramfs -u -k all
|
||||
reboot`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("manual.lxcToVm", { strong })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`# Add the vendor:device to vfio.conf (create the line if missing)
|
||||
grep -q '^options vfio-pci ids=' /etc/modprobe.d/vfio.conf && \\
|
||||
sed -i '/^options vfio-pci ids=/ s/$/,10de:2204/' /etc/modprobe.d/vfio.conf || \\
|
||||
echo 'options vfio-pci ids=10de:2204 disable_vga=1' >> /etc/modprobe.d/vfio.conf
|
||||
|
||||
# Blacklist the native driver so vfio-pci can claim the card
|
||||
cat >> /etc/modprobe.d/blacklist.conf <<'EOF'
|
||||
blacklist nouveau
|
||||
blacklist nvidia
|
||||
blacklist nvidia_drm
|
||||
blacklist nvidia_modeset
|
||||
blacklist nvidia_uvm
|
||||
blacklist nvidiafb
|
||||
options nouveau modeset=0
|
||||
EOF
|
||||
|
||||
# Make sure vfio-pci loads at boot
|
||||
grep -q '^vfio-pci$' /etc/modules || echo 'vfio-pci' >> /etc/modules
|
||||
|
||||
# Rebuild initramfs and reboot
|
||||
update-initramfs -u -k all
|
||||
reboot`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="warning" title={t("manual.oneVmTitle")}>
|
||||
{t.rich("manual.oneVmBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("verification.heading")}</h2>
|
||||
<CopyableCode
|
||||
code={`# Confirm the GPU is bound to the driver you expect
|
||||
lspci -nnk -d <vendor:device>
|
||||
# Expected (LXC mode): "Kernel driver in use: nvidia" (or amdgpu, i915)
|
||||
# Expected (VM mode): "Kernel driver in use: vfio-pci"
|
||||
|
||||
# LXC mode — is the host tool happy?
|
||||
nvidia-smi # if NVIDIA
|
||||
intel_gpu_top # if Intel iGPU
|
||||
|
||||
# VM mode — ready to be claimed by a VM start
|
||||
lsmod | grep vfio`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.stillVfioTitle")}>
|
||||
{t.rich("troubleshoot.stillVfioBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.vmFailTitle")}>
|
||||
{t.rich("troubleshoot.vmFailBody", { code, em })}
|
||||
</Callout>
|
||||
<CopyableCode
|
||||
code={`# Delete every hostpci line for the GPU slot
|
||||
sed -E -i '/^hostpci[0-9]+:[[:space:]]*(0000:)?<slot>\\.[0-7]([,[:space:]]|$)/d' \\
|
||||
/etc/pve/qemu-server/<vmid>.conf`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.smiFailTitle")}>
|
||||
{t.rich("troubleshoot.smiFailBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("troubleshoot.logTitle")}>
|
||||
{t.rich("troubleshoot.logBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
100
web/app/[locale]/docs/help-info/backup-commands/page.tsx
Normal file
100
web/app/[locale]/docs/help-info/backup-commands/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { CommandTable, type CommandGroup } from "@/components/ui/command-table"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.helpInfo.backupCommands.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"vzdump",
|
||||
"qmrestore",
|
||||
"pct restore",
|
||||
"proxmox backup commands",
|
||||
"proxmox vm backup",
|
||||
"proxmox lxc backup",
|
||||
"proxmox scheduled backup",
|
||||
"vzdump exclude",
|
||||
"proxmox restore command",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/help-info/backup-commands" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/help-info/backup-commands",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function BackupRestorePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.helpInfo.backupCommands" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { helpInfo: { backupCommands: {
|
||||
commandGroups: CommandGroup[]
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const commandGroups = messages.docs.helpInfo.backupCommands.commandGroups
|
||||
const relatedItems = messages.docs.helpInfo.backupCommands.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={5}
|
||||
scriptPath="help_info_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { strong })}
|
||||
</Callout>
|
||||
|
||||
<CommandTable groups={commandGroups} />
|
||||
|
||||
<Callout variant="tip" title={t("testRestores.title")}>
|
||||
{t.rich("testRestores.bodyRich", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
100
web/app/[locale]/docs/help-info/gpu-commands/page.tsx
Normal file
100
web/app/[locale]/docs/help-info/gpu-commands/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { CommandTable, type CommandGroup } from "@/components/ui/command-table"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.helpInfo.gpuCommands.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox gpu passthrough",
|
||||
"qm hostpci",
|
||||
"vfio proxmox",
|
||||
"iommu proxmox",
|
||||
"lspci proxmox",
|
||||
"proxmox nvidia passthrough",
|
||||
"proxmox amd passthrough",
|
||||
"proxmox intel iommu",
|
||||
"intel_iommu proxmox",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/help-info/gpu-commands" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/help-info/gpu-commands",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function GPUPassthroughPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.helpInfo.gpuCommands" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { helpInfo: { gpuCommands: {
|
||||
commandGroups: CommandGroup[]
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const commandGroups = messages.docs.helpInfo.gpuCommands.commandGroups
|
||||
const relatedItems = messages.docs.helpInfo.gpuCommands.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={5}
|
||||
scriptPath="help_info_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { em, code })}
|
||||
</Callout>
|
||||
|
||||
<CommandTable groups={commandGroups} />
|
||||
|
||||
<Callout variant="tip" title={t("afterChangesTip.title")}>
|
||||
{t.rich("afterChangesTip.bodyRich", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
100
web/app/[locale]/docs/help-info/network-commands/page.tsx
Normal file
100
web/app/[locale]/docs/help-info/network-commands/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { CommandTable, type CommandGroup } from "@/components/ui/command-table"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.helpInfo.networkCommands.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox network commands",
|
||||
"ip command proxmox",
|
||||
"pve-firewall",
|
||||
"proxmox iptables",
|
||||
"proxmox bridge",
|
||||
"ss command",
|
||||
"ping proxmox",
|
||||
"proxmox networking cli",
|
||||
"ethtool proxmox",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/help-info/network-commands" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/help-info/network-commands",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function NetworkCommandsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.helpInfo.networkCommands" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { helpInfo: { networkCommands: {
|
||||
commandGroups: CommandGroup[]
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const commandGroups = messages.docs.helpInfo.networkCommands.commandGroups
|
||||
const relatedItems = messages.docs.helpInfo.networkCommands.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={4}
|
||||
scriptPath="help_info_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<CommandTable groups={commandGroups} />
|
||||
|
||||
<Callout variant="tip" title={t("iptablesTip.title")}>
|
||||
{t.rich("iptablesTip.bodyRich", { strong, code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
145
web/app/[locale]/docs/help-info/page.tsx
Normal file
145
web/app/[locale]/docs/help-info/page.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import {
|
||||
ArrowRight,
|
||||
Terminal,
|
||||
HardDrive,
|
||||
Network,
|
||||
Package,
|
||||
Cpu,
|
||||
Database,
|
||||
Archive,
|
||||
Wrench,
|
||||
} from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.helpInfo.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox commands",
|
||||
"proxmox cli",
|
||||
"proxmox cheatsheet",
|
||||
"qm command",
|
||||
"pct command",
|
||||
"pveversion",
|
||||
"vzdump",
|
||||
"zpool",
|
||||
"proxmox reference",
|
||||
"proxmox commands list",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/help-info" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/help-info",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Option = { icon: string; href: string; title: string; description: string }
|
||||
|
||||
const ICONS: Record<string, React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>> = {
|
||||
Terminal,
|
||||
HardDrive,
|
||||
Network,
|
||||
Package,
|
||||
Cpu,
|
||||
Database,
|
||||
Archive,
|
||||
Wrench,
|
||||
}
|
||||
|
||||
function OptionCard({ option }: { option: Option }) {
|
||||
const Icon = ICONS[option.icon] || Terminal
|
||||
return (
|
||||
<Link
|
||||
href={option.href}
|
||||
className="group flex items-start gap-3 rounded-md border border-gray-200 bg-white p-3 transition-colors hover:border-blue-400 hover:bg-blue-50"
|
||||
>
|
||||
<span className="inline-flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-gray-100 text-gray-600 group-hover:bg-blue-100 group-hover:text-blue-700">
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1 text-sm font-medium text-gray-900 group-hover:text-blue-700">
|
||||
{option.title}
|
||||
<ArrowRight className="h-3.5 w-3.5 text-gray-400 group-hover:text-blue-600 transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-gray-600 leading-snug">{option.description}</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function HelpAndInfoPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.helpInfo" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { helpInfo: { categories: { options: Option[] } } }
|
||||
}
|
||||
const options = messages.docs.helpInfo.categories.options
|
||||
|
||||
const kbd = (chunks: React.ReactNode) => <kbd>{chunks}</kbd>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={2}
|
||||
scriptPath="help_info_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t("intro.body")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("opening.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("opening.body", { kbd })}
|
||||
</p>
|
||||
|
||||
<Image
|
||||
src="/help/help-info-menu.png"
|
||||
alt={t("opening.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("categories.heading")}</h2>
|
||||
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
|
||||
{options.map((o) => (
|
||||
<OptionCard key={o.href} option={o} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("tip.title")}>
|
||||
{t("tip.body")}
|
||||
</Callout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
web/app/[locale]/docs/help-info/storage-commands/page.tsx
Normal file
108
web/app/[locale]/docs/help-info/storage-commands/page.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { CommandTable, type CommandGroup } from "@/components/ui/command-table"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.helpInfo.storageCommands.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox storage commands",
|
||||
"lsblk proxmox",
|
||||
"pvesm",
|
||||
"qm importdisk",
|
||||
"qemu-img convert",
|
||||
"proxmox lvm commands",
|
||||
"proxmox disk commands",
|
||||
"lvdisplay",
|
||||
"pvs proxmox",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/help-info/storage-commands" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/help-info/storage-commands",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function StorageCommandsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.helpInfo.storageCommands" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { helpInfo: { storageCommands: {
|
||||
commandGroups: CommandGroup[]
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const commandGroups = messages.docs.helpInfo.storageCommands.commandGroups
|
||||
const relatedItems = messages.docs.helpInfo.storageCommands.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={4}
|
||||
scriptPath="help_info_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<CommandTable groups={commandGroups} />
|
||||
|
||||
<Callout variant="tip" title={t("lvmTip.title")}>
|
||||
{t.rich("lvmTip.bodyRich", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="info" title={t("smartInfo.title")}>
|
||||
{t.rich("smartInfo.bodyRich", { strong, code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="warning" title={t("selfTestWarn.title")}>
|
||||
{t.rich("selfTestWarn.bodyRich", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
94
web/app/[locale]/docs/help-info/system-commands/page.tsx
Normal file
94
web/app/[locale]/docs/help-info/system-commands/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { CommandTable, type CommandGroup } from "@/components/ui/command-table"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.helpInfo.systemCommands.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox commands",
|
||||
"pveversion",
|
||||
"proxmox system commands",
|
||||
"journalctl proxmox",
|
||||
"systemctl proxmox",
|
||||
"proxmox linux commands",
|
||||
"pveperf",
|
||||
"proxmox cli",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/help-info/system-commands" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/help-info/system-commands",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function SystemCommandsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.helpInfo.systemCommands" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { helpInfo: { systemCommands: {
|
||||
commandGroups: CommandGroup[]
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const commandGroups = messages.docs.helpInfo.systemCommands.commandGroups
|
||||
const relatedItems = messages.docs.helpInfo.systemCommands.related.items
|
||||
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={3}
|
||||
scriptPath="help_info_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { em })}
|
||||
</Callout>
|
||||
|
||||
<CommandTable groups={commandGroups} />
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
web/app/[locale]/docs/help-info/tools-commands/page.tsx
Normal file
104
web/app/[locale]/docs/help-info/tools-commands/page.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { CommandTable, type CommandGroup } from "@/components/ui/command-table"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.helpInfo.toolsCommands.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"htop proxmox",
|
||||
"iftop proxmox",
|
||||
"rsync proxmox",
|
||||
"tmux",
|
||||
"journalctl",
|
||||
"linux cli tools",
|
||||
"proxmox cli tools",
|
||||
"iotop",
|
||||
"lsof",
|
||||
"nmap proxmox",
|
||||
"mtr",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/help-info/tools-commands" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/help-info/tools-commands",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function SystemCLIToolsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.helpInfo.toolsCommands" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { helpInfo: { toolsCommands: {
|
||||
commandGroups: CommandGroup[]
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const commandGroups = messages.docs.helpInfo.toolsCommands.commandGroups
|
||||
const relatedItems = messages.docs.helpInfo.toolsCommands.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const utilsLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/utils/system-utils" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={5}
|
||||
scriptPath="help_info_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { utilsLink })}
|
||||
</Callout>
|
||||
|
||||
<CommandTable groups={commandGroups} />
|
||||
|
||||
<Callout variant="tip" title={t("tmuxTip.title")}>
|
||||
{t.rich("tmuxTip.bodyRich", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
102
web/app/[locale]/docs/help-info/update-commands/page.tsx
Normal file
102
web/app/[locale]/docs/help-info/update-commands/page.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { CommandTable, type CommandGroup } from "@/components/ui/command-table"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.helpInfo.updateCommands.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox apt update",
|
||||
"proxmox apt upgrade",
|
||||
"pveupgrade",
|
||||
"proxmox dist-upgrade",
|
||||
"dpkg proxmox",
|
||||
"proxmox repository",
|
||||
"proxmox package commands",
|
||||
"apt pveversion",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/help-info/update-commands" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/help-info/update-commands",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function UpdateCommandsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.helpInfo.updateCommands" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { helpInfo: { updateCommands: {
|
||||
commandGroups: CommandGroup[]
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const commandGroups = messages.docs.helpInfo.updateCommands.commandGroups
|
||||
const relatedItems = messages.docs.helpInfo.updateCommands.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const upgradeLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/utils/upgrade-pve8-pve9" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={3}
|
||||
scriptPath="help_info_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<CommandTable groups={commandGroups} />
|
||||
|
||||
<Callout variant="warning" title={t("backupWarn.title")}>
|
||||
{t.rich("backupWarn.bodyRich", { code, strong, upgradeLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
web/app/[locale]/docs/help-info/vm-ct-commands/page.tsx
Normal file
108
web/app/[locale]/docs/help-info/vm-ct-commands/page.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { CommandTable, type CommandGroup } from "@/components/ui/command-table"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.helpInfo.vmCtCommands.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"qm command proxmox",
|
||||
"pct command proxmox",
|
||||
"proxmox vm commands",
|
||||
"proxmox lxc commands",
|
||||
"qm start",
|
||||
"qm stop",
|
||||
"pct create",
|
||||
"pct enter",
|
||||
"proxmox container commands",
|
||||
"qm clone",
|
||||
"qm migrate",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/help-info/vm-ct-commands" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/help-info/vm-ct-commands",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function VMCTCommandsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.helpInfo.vmCtCommands" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { helpInfo: { vmCtCommands: {
|
||||
commandGroups: CommandGroup[]
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const commandGroups = messages.docs.helpInfo.vmCtCommands.commandGroups
|
||||
const relatedItems = messages.docs.helpInfo.vmCtCommands.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const backupLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/help-info/backup-commands" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={3}
|
||||
scriptPath="help_info_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<CommandTable groups={commandGroups} />
|
||||
|
||||
<Callout variant="warning" title={t("destroyWarn.title")}>
|
||||
{t.rich("destroyWarn.bodyRich", { code, backupLink })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("qmConfigTip.title")}>
|
||||
{t.rich("qmConfigTip.bodyRich", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
99
web/app/[locale]/docs/help-info/zfs-commands/page.tsx
Normal file
99
web/app/[locale]/docs/help-info/zfs-commands/page.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { CommandTable, type CommandGroup } from "@/components/ui/command-table"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.helpInfo.zfsCommands.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"zfs proxmox",
|
||||
"zpool proxmox",
|
||||
"zfs snapshot",
|
||||
"zfs send receive",
|
||||
"zpool scrub",
|
||||
"zfs replication proxmox",
|
||||
"zfs commands",
|
||||
"zpool status",
|
||||
"proxmox zfs cli",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/help-info/zfs-commands" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/help-info/zfs-commands",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function ZFSManagementPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.helpInfo.zfsCommands" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { helpInfo: { zfsCommands: {
|
||||
commandGroups: CommandGroup[]
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const commandGroups = messages.docs.helpInfo.zfsCommands.commandGroups
|
||||
const relatedItems = messages.docs.helpInfo.zfsCommands.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={5}
|
||||
scriptPath="help_info_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<CommandTable groups={commandGroups} />
|
||||
|
||||
<Callout variant="tip" title={t("bestPractices.title")}>
|
||||
{t.rich("bestPractices.bodyRich", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
205
web/app/[locale]/docs/installation/page.tsx
Normal file
205
web/app/[locale]/docs/installation/page.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { Metadata } from "next"
|
||||
import Image from "next/image"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { Terminal, FileCode, ShieldCheck, ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.installation.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/installation",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type DuringRow = { package: string; purpose: string }
|
||||
|
||||
export default async function InstallationPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.installation" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: {
|
||||
installation: {
|
||||
during: { rows: DuringRow[] }
|
||||
}
|
||||
}
|
||||
}
|
||||
const duringRows = messages.docs.installation.during.rows
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
|
||||
const internalLink = (href: string, className = "text-blue-600 hover:underline") =>
|
||||
(chunks: React.ReactNode) => (
|
||||
<Link href={href} className={className}>
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
|
||||
const extlink = (href: string, className = "text-blue-600 hover:underline inline-flex items-center gap-1") =>
|
||||
(chunks: React.ReactNode) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className={className}>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={3}
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
|
||||
<Terminal className="inline h-5 w-5 mr-1 -mt-1 text-blue-600" aria-hidden />
|
||||
{t("stable.heading")}
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("stable.intro")}</p>
|
||||
<CopyableCode code={t.raw("stable.code") as string} />
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("during.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("during.intro")}</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("during.tablePackage")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("during.tablePurpose")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{duringRows.map((row, idx) => (
|
||||
<tr key={row.package} className={idx < duringRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.package}</td>
|
||||
<td className="px-3 py-2 align-top">{row.purpose}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("during.outro", { code, strong })}</p>
|
||||
|
||||
<Image
|
||||
src="https://macrimi.github.io/ProxMenux/install/install.png"
|
||||
alt={t("during.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("first.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("first.intro")}</p>
|
||||
<CopyableCode code={t.raw("first.code") as string} />
|
||||
|
||||
<p className="mt-4 mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("first.outro", { postlink: internalLink("/docs/post-install") })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("beta.heading")}</h2>
|
||||
<Callout variant="info" title={t("beta.calloutTitle")}>
|
||||
{t.rich("beta.calloutBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("beta.intro")}</p>
|
||||
<CopyableCode code={t.raw("beta.code") as string} />
|
||||
|
||||
<p className="mt-4 mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("beta.outro", { code, betalink: internalLink("/docs/settings/beta-program") })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("updating.heading")}</h2>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t.rich("updating.body", { code })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("uninstall.heading")}</h2>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("uninstall.body", {
|
||||
strong,
|
||||
code,
|
||||
uninstalllink: internalLink("/docs/settings/uninstall-proxmenux"),
|
||||
})}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.virustotalTitle")}>
|
||||
{t.rich("troubleshoot.virustotalBody", {
|
||||
code,
|
||||
em,
|
||||
issuelink: extlink("https://github.com/MacRimi/ProxMenux/issues/162"),
|
||||
})}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.aptTitle")}>
|
||||
{t.rich("troubleshoot.aptBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.menuTitle")}>
|
||||
{t.rich("troubleshoot.menuBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.stuckTitle")}>
|
||||
{t.rich("troubleshoot.stuckBody", { code, strong })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.otherTitle")}>
|
||||
{t.rich("troubleshoot.otherBody", {
|
||||
code,
|
||||
issueslink: extlink("https://github.com/MacRimi/ProxMenux/issues"),
|
||||
})}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("next.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
<li>{t.rich("next.postInstall", { postlink: internalLink("/docs/post-install") })}</li>
|
||||
<li>{t.rich("next.introduction", { introlink: internalLink("/docs/introduction") })}</li>
|
||||
<li>{t.rich("next.monitor", { monitorlink: internalLink("/docs/settings/proxmenux-monitor") })}</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("requirements.heading")}</h2>
|
||||
|
||||
<Callout variant="info" title={t("requirements.reqTitle")}>
|
||||
{t.rich("requirements.reqBody", { strong })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="info" title={t("requirements.inspectTitle")}>
|
||||
<ul className="list-disc pl-6 mt-1 mb-0 space-y-1">
|
||||
<li>
|
||||
<FileCode className="inline h-4 w-4 mr-1 -mt-0.5 text-blue-600" aria-hidden />{" "}
|
||||
{t.rich("requirements.inspectReview", {
|
||||
sourcelink: extlink(
|
||||
"https://github.com/MacRimi/ProxMenux/blob/main/install_proxmenux.sh",
|
||||
"text-blue-700 hover:underline inline-flex items-center gap-1",
|
||||
),
|
||||
})}
|
||||
</li>
|
||||
<li>
|
||||
<ShieldCheck className="inline h-4 w-4 mr-1 -mt-0.5 text-emerald-600" aria-hidden />{" "}
|
||||
{t.rich("requirements.inspectCoc", {
|
||||
coclink: extlink(
|
||||
"https://github.com/MacRimi/ProxMenux?tab=coc-ov-file#-2-security--code-responsibility",
|
||||
"text-blue-700 hover:underline inline-flex items-center gap-1",
|
||||
),
|
||||
})}
|
||||
</li>
|
||||
</ul>
|
||||
</Callout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
339
web/app/[locale]/docs/introduction/page.tsx
Normal file
339
web/app/[locale]/docs/introduction/page.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import {
|
||||
ArrowRight,
|
||||
Server,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Network,
|
||||
Shield,
|
||||
Activity,
|
||||
Wrench,
|
||||
Boxes,
|
||||
BookOpen,
|
||||
Bell,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Code2,
|
||||
Plug,
|
||||
ExternalLink,
|
||||
} from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.introduction.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmenux",
|
||||
"proxmox menu script",
|
||||
"proxmox management tool",
|
||||
"proxmox tui",
|
||||
"proxmox cli",
|
||||
"proxmox dashboard",
|
||||
"proxmox open source",
|
||||
"proxmox automation",
|
||||
"proxmox helper script",
|
||||
"proxmox post install",
|
||||
"proxmox community tool",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/introduction" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/introduction",
|
||||
siteName: "ProxMenux",
|
||||
images: [
|
||||
{
|
||||
url: "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/main.png",
|
||||
width: 1363,
|
||||
height: 735,
|
||||
alt: "ProxMenux — Menu-Driven Tool for Proxmox VE",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
images: [
|
||||
"https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/main.png",
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>> = {
|
||||
Server,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Network,
|
||||
Shield,
|
||||
Activity,
|
||||
Wrench,
|
||||
Boxes,
|
||||
BookOpen,
|
||||
Bell,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Code2,
|
||||
Plug,
|
||||
}
|
||||
|
||||
type FeatureItem = {
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
href: string
|
||||
}
|
||||
|
||||
type InstallRow = { pathRich: string; bundles: string; when: string }
|
||||
|
||||
type NextItem = {
|
||||
lead: string
|
||||
linkHref: string
|
||||
linkLabel: string
|
||||
tail?: string
|
||||
tailRich?: string
|
||||
}
|
||||
|
||||
function FeatureCard({
|
||||
title,
|
||||
description,
|
||||
Icon,
|
||||
href,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
Icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>
|
||||
href: string
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="group flex items-start gap-3 rounded-md border border-gray-200 bg-white p-3 transition-colors hover:border-blue-400 hover:bg-blue-50"
|
||||
>
|
||||
<span className="inline-flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-gray-100 text-gray-600 group-hover:bg-blue-100 group-hover:text-blue-700">
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1 text-sm font-medium text-gray-900 group-hover:text-blue-700">
|
||||
{title}
|
||||
<ArrowRight className="h-3.5 w-3.5 text-gray-400 group-hover:text-blue-600 transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-gray-600 leading-snug">{description}</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function IntroductionPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.introduction" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: {
|
||||
introduction: {
|
||||
twoProducts: { layers: string[] }
|
||||
scriptsSection: { items: FeatureItem[] }
|
||||
monitorSection: { items: FeatureItem[] }
|
||||
installPaths: { rows: InstallRow[] }
|
||||
next: { items: NextItem[] }
|
||||
}
|
||||
}
|
||||
}
|
||||
const block = messages.docs.introduction
|
||||
const layers = block.twoProducts.layers
|
||||
const scriptItems = block.scriptsSection.items
|
||||
const monitorItems = block.monitorSection.items
|
||||
const installRows = block.installPaths.rows
|
||||
const nextItems = block.next.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const github = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
const monitorOverviewLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const installationLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/installation" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={5}
|
||||
/>
|
||||
|
||||
<div className="my-6 not-prose flex flex-col items-center gap-4 rounded-lg border border-gray-200 bg-gradient-to-br from-blue-50 to-white p-6 sm:flex-row sm:items-start">
|
||||
<Image
|
||||
src="https://macrimi.github.io/ProxMenux/logo.png"
|
||||
alt="ProxMenux Logo"
|
||||
width={96}
|
||||
height={96}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<div className="text-center sm:text-left">
|
||||
<p className="text-base text-gray-800 leading-relaxed mb-3">
|
||||
{t.rich("hero.tagline", { strong })}
|
||||
</p>
|
||||
<p className="text-sm text-gray-700 m-0">
|
||||
{t.rich("hero.audience", { em, github })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("twoProducts.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("twoProducts.intro")}</p>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 mb-6 not-prose">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Terminal className="h-5 w-5 text-blue-600" aria-hidden />
|
||||
<h3 className="text-base font-semibold text-gray-900 m-0">{t("twoProducts.scripts.title")}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 leading-relaxed m-0">
|
||||
{t.rich("twoProducts.scripts.body", { code })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Activity className="h-5 w-5 text-blue-600" aria-hidden />
|
||||
<h3 className="text-base font-semibold text-gray-900 m-0">{t("twoProducts.monitor.title")}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 leading-relaxed m-0">
|
||||
{t("twoProducts.monitor.body")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("twoProducts.calloutTitle")}>
|
||||
{t("twoProducts.calloutIntro")}
|
||||
<ul className="list-disc pl-6 mt-2 mb-0 space-y-1">
|
||||
{layers.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`twoProducts.layers.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("scriptsSection.heading")}</h2>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("scriptsSection.intro", { code })}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
|
||||
{scriptItems.map((f) => {
|
||||
const Icon = iconMap[f.icon] ?? Server
|
||||
return <FeatureCard key={f.href} title={f.title} description={f.description} Icon={Icon} href={f.href} />
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("monitorSection.heading")}</h2>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("monitorSection.intro", { link: monitorOverviewLink })}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
|
||||
{monitorItems.map((f) => {
|
||||
const Icon = iconMap[f.icon] ?? Activity
|
||||
return <FeatureCard key={f.href} title={f.title} description={f.description} Icon={Icon} href={f.href} />
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("installPaths.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("installPaths.intro")}</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("installPaths.headerPath")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("installPaths.headerBundles")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{installRows.map((row, idx) => (
|
||||
<tr key={idx} className={idx < installRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">
|
||||
{t.rich(`installPaths.rows.${idx}.pathRich`, { strong })}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">{row.bundles}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("installPaths.outro", { link: installationLink })}
|
||||
</p>
|
||||
|
||||
<Callout variant="warning" title={t("warnSource.title")}>
|
||||
{t("warnSource.body")}{" "}
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux/tree/main/scripts"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-amber-700 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("warnSource.sourceLabel")}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
{" "}·{" "}
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux?tab=coc-ov-file#-2-security--code-responsibility"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-amber-700 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("warnSource.cocLabel")}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("next.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{nextItems.map((item, idx) => (
|
||||
<li key={idx}>
|
||||
{t.rich(`next.items.${idx}.lead`, { strong })}
|
||||
<Link href={item.linkHref} className="text-blue-600 hover:underline">
|
||||
{item.linkLabel}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`next.items.${idx}.tailRich`, { code }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
43
web/app/[locale]/docs/layout.tsx
Normal file
43
web/app/[locale]/docs/layout.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type React from "react"
|
||||
import DocSidebar from "@/components/DocSidebar"
|
||||
import Footer from "@/components/footer"
|
||||
import { DocNavigation } from "@/components/ui/doc-navigation"
|
||||
import { DocBreadcrumb } from "@/components/DocBreadcrumb"
|
||||
import { DocTableOfContents } from "@/components/DocTableOfContents"
|
||||
|
||||
/**
|
||||
* Docs layout — three-column shell matching the Hermes / Docusaurus
|
||||
* pattern the user asked for:
|
||||
*
|
||||
* ┌─────────┬───────────────────────┬─────────┐
|
||||
* │ Sidebar │ Breadcrumb + Article │ ToC │
|
||||
* │ (fixed) │ (scrollable) │ (sticky)│
|
||||
* └─────────┴───────────────────────┴─────────┘
|
||||
*
|
||||
* - Sidebar: 18 rem, fixed left on lg+, slide-down drawer on mobile.
|
||||
* - Main: max width capped at ~980 px for comfortable line length.
|
||||
* - ToC: 14 rem, sticky right rail, only shown on xl+ where there is
|
||||
* enough horizontal room to display it without crowding the article.
|
||||
*/
|
||||
export default function DocsLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-white text-gray-900">
|
||||
<DocSidebar />
|
||||
<div className="flex flex-col flex-1 pt-16 lg:pt-0 lg:pl-72">
|
||||
<div className="flex flex-1">
|
||||
<main className="flex-1 min-w-0 p-4 lg:p-6 pt-6 lg:pt-6">
|
||||
<div className="max-w-3xl mx-auto" style={{ maxWidth: "980px" }}>
|
||||
<DocBreadcrumb />
|
||||
{children}
|
||||
<DocNavigation />
|
||||
</div>
|
||||
</main>
|
||||
<aside className="hidden xl:block w-56 shrink-0 py-6 pr-6">
|
||||
<DocTableOfContents />
|
||||
</aside>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
710
web/app/[locale]/docs/monitor/access-auth/page.tsx
Normal file
710
web/app/[locale]/docs/monitor/access-auth/page.tsx
Normal file
@@ -0,0 +1,710 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.accessAuth.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox 2fa",
|
||||
"proxmox totp",
|
||||
"proxmox dashboard authentication",
|
||||
"proxmox user profile",
|
||||
"proxmox dashboard avatar",
|
||||
"proxmox api tokens",
|
||||
"proxmox reverse proxy",
|
||||
"proxmox nginx",
|
||||
"proxmox caddy",
|
||||
"proxmox traefik",
|
||||
"proxmox fail2ban dashboard",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/monitor/access-auth" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/monitor/access-auth",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Row2 = { button: string; what: string; api: string }
|
||||
type FieldRow = { field: string; required: string; notes: string }
|
||||
type EndpointRow = { endpoint: string; what: string }
|
||||
type CryptoRow = { asset: string; algorithm: string; where: string }
|
||||
type AppRow = { name: string; href: string; platforms: string; notes: string }
|
||||
type WhereNextItem = { label: string; href: string; tail?: string; tailRich?: string }
|
||||
|
||||
export default async function MonitorAccessAuthPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.accessAuth" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { accessAuth: {
|
||||
firstLaunch: {
|
||||
rows: Row2[]
|
||||
fieldRows: FieldRow[]
|
||||
endpointRows: EndpointRow[]
|
||||
}
|
||||
password: {
|
||||
items: string[]
|
||||
publicItems: string[]
|
||||
cryptoRows: CryptoRow[]
|
||||
}
|
||||
twofa: {
|
||||
apps: AppRow[]
|
||||
setupSteps: string[]
|
||||
setupStep4Sub: string[]
|
||||
lostItems: string[]
|
||||
rejectedItems: string[]
|
||||
}
|
||||
apiTokens: { generateSteps: string[]; cheatItems: string[] }
|
||||
https: { items: string[] }
|
||||
fail2ban: { items: string[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } }
|
||||
}
|
||||
const aa = messages.docs.monitor.accessAuth
|
||||
const firstLaunchRows = aa.firstLaunch.rows
|
||||
const fieldRows = aa.firstLaunch.fieldRows
|
||||
const endpointRows = aa.firstLaunch.endpointRows
|
||||
const passwordItems = aa.password.items
|
||||
const publicItems = aa.password.publicItems
|
||||
const cryptoRows = aa.password.cryptoRows
|
||||
const apps = aa.twofa.apps
|
||||
const setupSteps = aa.twofa.setupSteps
|
||||
const setupStep4Sub = aa.twofa.setupStep4Sub
|
||||
const lostItems = aa.twofa.lostItems
|
||||
const rejectedItems = aa.twofa.rejectedItems
|
||||
const generateSteps = aa.apiTokens.generateSteps
|
||||
const cheatItems = aa.apiTokens.cheatItems
|
||||
const httpsItems = aa.https.items
|
||||
const fail2banItems = aa.fail2ban.items
|
||||
const whereNextItems = aa.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const apiLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const intLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const gatewayLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const fail2banLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/security/fail2ban" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const tailscaleAnchor = (chunks: React.ReactNode) => (
|
||||
<a href="https://tailscale.com" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
|
||||
{chunks}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
)
|
||||
const tsKeysAnchor = (chunks: React.ReactNode) => (
|
||||
<a href="https://login.tailscale.com/admin/settings/keys" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
|
||||
{chunks}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={15}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { em, strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("reaching.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("reaching.intro", { code })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`# 1) Direct on the LAN
|
||||
http://<proxmox-ip>:8008
|
||||
|
||||
# 2) Behind a reverse proxy with a dedicated host name (recommended off-LAN)
|
||||
https://monitor.example.com
|
||||
|
||||
# 3) Through Secure Gateway (Tailscale) — same LAN URL, from anywhere
|
||||
http://<proxmox-lan-ip>:8008 # works from any device on your tailnet`}
|
||||
className="my-4"
|
||||
/>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("reaching.outro", { code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("firstLaunch.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("firstLaunch.intro", { code, em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img src="/monitor/auth-setup.png" alt={t("firstLaunch.imageAlt")} className="rounded-lg border border-gray-200 shadow-sm w-full" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("firstLaunch.imageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerButton")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerWhat")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerApi")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{firstLaunchRows.map((row, idx) => (
|
||||
<tr key={row.button} className={idx < firstLaunchRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.button}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`firstLaunch.rows.${idx}.what`, { em, code })}</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.api}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("firstLaunch.twofaCalloutTitle")}>
|
||||
{t.rich("firstLaunch.twofaCalloutBody", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("firstLaunch.createTitle")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("firstLaunch.createIntro", { em })}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerField")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerRequired")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerNotes")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{fieldRows.map((row, idx) => (
|
||||
<tr key={row.field} className={idx < fieldRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.field}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{row.required}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`firstLaunch.fieldRows.${idx}.notes`, { code, strong })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<figure className="my-6">
|
||||
<img src="/monitor/security/create-user-form.png" alt={t("firstLaunch.createImageAlt")} className="rounded-lg border border-gray-200 shadow-sm w-full" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("firstLaunch.createImageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<Callout variant="info" title={t("firstLaunch.saveCalloutTitle")}>
|
||||
{t.rich("firstLaunch.saveCalloutBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("firstLaunch.avatarTitle")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("firstLaunch.avatarBody1", { strong })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("firstLaunch.avatarBody2")}</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img src="/monitor/security/profile-page.png" alt={t("firstLaunch.profileImageAlt")} className="rounded-lg border border-gray-200 shadow-sm w-full" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("firstLaunch.profileImageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerEpWhat")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{endpointRows.map((row, idx) => (
|
||||
<tr key={row.endpoint} className={idx < endpointRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`firstLaunch.endpointRows.${idx}.what`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="warning" title={t("firstLaunch.reversibleTitle")}>
|
||||
{t.rich("firstLaunch.reversibleBody", { em, strong, code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("password.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("password.intro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{passwordItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`password.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<figure className="my-6">
|
||||
<img src="/monitor/login-screen.png" alt={t("password.loginImageAlt")} className="rounded-lg border border-gray-200 shadow-sm w-full" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("password.loginImageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("password.loginFlowTitle")}</h3>
|
||||
<CopyableCode
|
||||
code={`# Without 2FA
|
||||
curl -X POST http://<host>:8008/api/auth/login \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"username":"<user>","password":"<password>"}'
|
||||
|
||||
# Response
|
||||
{
|
||||
"success": true,
|
||||
"token": "eyJhbGciOiJIUzI1NiIs..."
|
||||
}`}
|
||||
className="my-4"
|
||||
/>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("password.twofaIntro", { code })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`curl -X POST http://<host>:8008/api/auth/login \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"username":"<user>","password":"<password>","totp_token":"123456"}'`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("password.publicTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("password.publicIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{publicItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`password.publicItems.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 id="security-model" className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("password.cryptoTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("password.cryptoIntro", { code })}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto mb-4">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("password.headerAsset")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("password.headerAlgo")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("password.headerWhere")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{cryptoRows.map((row, idx) => (
|
||||
<tr key={row.asset} className={idx < cryptoRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top"><strong>{row.asset}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`password.cryptoRows.${idx}.algorithm`, { code })}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`password.cryptoRows.${idx}.where`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("password.authJsonTitle")}>
|
||||
{t.rich("password.authJsonBody", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="warning" title={t("password.rotateTitle")}>
|
||||
{t.rich("password.rotateBody", { code, strong })}
|
||||
</Callout>
|
||||
|
||||
<h3 id="recovering-password" className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("password.recoverTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("password.recoverIntro", { code })}
|
||||
</p>
|
||||
|
||||
<CopyableCode
|
||||
code={`# 1. Run the ProxMenux menu as root
|
||||
menu
|
||||
|
||||
# 2. Settings → Reset ProxMenux Monitor Password
|
||||
# The menu will:
|
||||
# - Back up auth.json to auth.json.bak-<UTC timestamp>
|
||||
# - Stop the proxmenux-monitor service
|
||||
# - Clear username / password_hash / TOTP secret / backup codes
|
||||
# - Keep jwt_secret and api_tokens intact
|
||||
# - Restart the service
|
||||
|
||||
# 3. Open the dashboard at http://<host>:8008
|
||||
# The setup wizard appears — create a new admin account.`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("password.survivesTitle")}>
|
||||
{t.rich("password.survivesBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="warning" title={t("password.physicalTitle")}>
|
||||
{t.rich("password.physicalBody", { strong, code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("twofa.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("twofa.intro", { strong })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("twofa.pickTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("twofa.pickIntro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("twofa.headerApp")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("twofa.headerPlatforms")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("twofa.headerAppNotes")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{apps.map((row, idx) => (
|
||||
<tr key={row.name} className={idx < apps.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">
|
||||
<a
|
||||
href={row.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{row.name}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">{row.platforms}</td>
|
||||
<td className="px-3 py-2 align-top">{row.notes}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("twofa.backupTitle")}>
|
||||
{t("twofa.backupBody")}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("twofa.setupTitle")}</h3>
|
||||
|
||||
<figure className="my-6">
|
||||
<img src="/monitor/2fa-setup.png" alt={t("twofa.setupImageAlt")} className="rounded-lg border border-gray-200 shadow-sm w-full" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("twofa.setupImageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-3">
|
||||
{setupSteps.map((_, idx) => (
|
||||
<li key={idx}>
|
||||
{t.rich(`twofa.setupSteps.${idx}`, { strong, em, code })}
|
||||
{idx === 3 && (
|
||||
<ul className="list-disc pl-6 mt-2 space-y-1">
|
||||
{setupStep4Sub.map((_, sIdx) => (
|
||||
<li key={sIdx}>{t.rich(`twofa.setupStep4Sub.${sIdx}`, { em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<Callout variant="warning" title={t("twofa.testTitle")}>
|
||||
{t.rich("twofa.testBody", { em, code })}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("twofa.lostTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("twofa.lostIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{lostItems.map((_, idx) => (
|
||||
<li key={idx}>
|
||||
{t.rich(`twofa.lostItems.${idx}`, { strong, code })}
|
||||
{idx === 2 && (
|
||||
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{`systemctl restart proxmenux-monitor.service`}</pre>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("twofa.lostShellOutro")}</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("twofa.disableTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("twofa.disableBody", { strong, code })}
|
||||
</p>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("twofa.rejectedTitle")}>
|
||||
{t("twofa.rejectedIntro")}
|
||||
<ul className="list-disc pl-5 mt-2 space-y-1">
|
||||
{rejectedItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`twofa.rejectedItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
{t("twofa.rejectedOutro")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("apiTokens.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("apiTokens.intro", { strong, code })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img src="/monitor/api-tokens.png" alt={t("apiTokens.imageAlt")} className="rounded-lg border border-gray-200 shadow-sm w-full" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("apiTokens.imageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("apiTokens.generateTitle")}</h3>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("apiTokens.generateIntro")}</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{generateSteps.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`apiTokens.generateSteps.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("apiTokens.generateCli")}</p>
|
||||
<CopyableCode
|
||||
code={`curl -X POST http://<host>:8008/api/auth/generate-api-token \\
|
||||
-H "Authorization: Bearer <session-token>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"password": "<your-password>",
|
||||
"totp_token": "123456",
|
||||
"token_name": "Home Assistant"
|
||||
}'
|
||||
|
||||
# Response — the "token" field is the only place the token appears.
|
||||
{
|
||||
"success": true,
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"token_name": "Home Assistant",
|
||||
"expires_in": "365 days"
|
||||
}`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("apiTokens.useTitle")}</h3>
|
||||
<CopyableCode
|
||||
code={`curl -H "Authorization: Bearer <api-token>" \\
|
||||
http://<host>:8008/api/system`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("apiTokens.revokeTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("apiTokens.revokeBody", { strong, code })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`# Same operation via API
|
||||
curl -X DELETE http://<host>:8008/api/auth/api-tokens/<token-id> \\
|
||||
-H "Authorization: Bearer <session-token>"`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="tip" title={t("apiTokens.cheatTitle")}>
|
||||
<ul className="list-disc pl-5 mt-2 space-y-1">
|
||||
{cheatItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`apiTokens.cheatItems.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Callout>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("apiTokens.outro", { apiLink, intLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("https.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("https.intro")}</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{httpsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`https.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
<Callout variant="warning" title={t("https.calloutTitle")}>
|
||||
{t("https.calloutBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("gateway.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("gateway.intro", { strong, a: tailscaleAnchor })}
|
||||
</p>
|
||||
<Callout variant="tip" title={t("gateway.calloutTitle")}>
|
||||
{t.rich("gateway.calloutBody", { code })}
|
||||
</Callout>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("gateway.deployBody", { a: tsKeysAnchor })}
|
||||
</p>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("gateway.outro", { link: gatewayLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("proxy.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("proxy.intro", { strong, code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("proxy.nginxTitle")}</h3>
|
||||
<CopyableCode
|
||||
code={`# /etc/nginx/sites-available/proxmenux-monitor.conf
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name monitor.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/monitor.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/monitor.example.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8008;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# WebSocket upgrade (terminal tab)
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Real client IP — required for the auth log + Fail2Ban hook
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
# Long-running terminal sessions
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
}`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("proxy.caddyTitle")}</h3>
|
||||
<CopyableCode
|
||||
code={`# Caddyfile
|
||||
monitor.example.com {
|
||||
reverse_proxy 127.0.0.1:8008 {
|
||||
# Caddy auto-handles WebSocket upgrades and forwards X-Forwarded-* by default.
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote}
|
||||
}
|
||||
}`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("proxy.traefikTitle")}</h3>
|
||||
<CopyableCode
|
||||
code={`# docker-compose snippet, or equivalent IngressRoute on Kubernetes
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.proxmenux.rule=Host(\`monitor.example.com\`)"
|
||||
- "traefik.http.routers.proxmenux.tls=true"
|
||||
- "traefik.http.routers.proxmenux.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.proxmenux.loadbalancer.server.port=8008"
|
||||
# WebSocket and forwarded headers are on by default in Traefik.`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="tip" title={t("proxy.subPathTitle")}>
|
||||
{t.rich("proxy.subPathBody", { code, strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("audit.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("audit.intro", { code })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`# Failed login from 192.0.2.10 (real IP recovered from X-Forwarded-For)
|
||||
2026-04-24 14:32:11 WARNING proxmenux.auth: authentication failure; rhost=192.0.2.10 user=admin
|
||||
|
||||
# Successful login
|
||||
2026-04-24 14:32:18 INFO proxmenux.auth: authentication success; rhost=192.0.2.10 user=admin`}
|
||||
className="my-4"
|
||||
/>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("audit.outro", { code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("fail2ban.heading")}</h2>
|
||||
<Callout variant="info" title={t("fail2ban.calloutTitle")}>
|
||||
{t.rich("fail2ban.calloutBody", { strong, link: fail2banLink })}
|
||||
</Callout>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("fail2ban.intro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{fail2banItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`fail2ban.items.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("fail2ban.outro", { link: fail2banLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.noScreenTitle")}>
|
||||
{t.rich("troubleshoot.noScreenBody", { code })}
|
||||
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{`rm /root/.config/proxmenux-monitor/auth.json
|
||||
systemctl restart proxmenux-monitor.service`}</pre>
|
||||
{t.rich("troubleshoot.noScreenOutro", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.tokenTitle")}>
|
||||
{t.rich("troubleshoot.tokenBody", { code })}
|
||||
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{`curl -H "Authorization: Bearer <token>" \\
|
||||
http://<host>:8008/api/system | jq .`}</pre>
|
||||
{t.rich("troubleshoot.tokenOutro", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.no2faTitle")}>
|
||||
{t.rich("troubleshoot.no2faBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.wsTitle")}>
|
||||
{t.rich("troubleshoot.wsBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item, idx) => (
|
||||
<li key={item.href + idx}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
726
web/app/[locale]/docs/monitor/ai-assistant/page.tsx
Normal file
726
web/app/[locale]/docs/monitor/ai-assistant/page.tsx
Normal file
@@ -0,0 +1,726 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.aiAssistant.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox ai",
|
||||
"proxmox openai integration",
|
||||
"proxmox claude",
|
||||
"proxmox gemini",
|
||||
"proxmox ollama",
|
||||
"proxmox local ai",
|
||||
"proxmox groq",
|
||||
"proxmox openrouter",
|
||||
"proxmox notification rewrite",
|
||||
"proxmox llm",
|
||||
"proxmenux ai assistant",
|
||||
"proxmox ai prompt",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/monitor/ai-assistant" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/monitor/ai-assistant",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SYSTEM_PROMPT = `You are a notification FORMATTER for ProxMenux Monitor (Proxmox VE).
|
||||
Your job: translate alerts into {language} and enrich them with context when provided.
|
||||
|
||||
═══ ABSOLUTE CONSTRAINTS (NO EXCEPTIONS) ═══
|
||||
- NO HALLUCINATIONS: Do not invent causes, solutions, or facts not present in the provided data
|
||||
- NO SPECULATION: If something is unclear, state what IS known, not what MIGHT be
|
||||
- NO CONVERSATIONAL TEXT: Never write "Here is...", "I've translated...", "Let me explain..."
|
||||
- ONLY use information from: the message, journal context, and known error database (if provided)
|
||||
|
||||
═══ WHAT TO TRANSLATE ═══
|
||||
Translate: labels, descriptions, status words, units (GB→Go in French, etc.)
|
||||
DO NOT translate: hostnames, IPs, paths, VM/CT IDs, device names (/dev/sdX), technical identifiers
|
||||
|
||||
═══ CORE RULES ═══
|
||||
1. Plain text only — NO markdown, no **bold**, no \`code\`, no bullet lists (use "• " for packages only)
|
||||
2. Preserve severity: "failed" stays "failed", "warning" stays "warning" — never soften errors
|
||||
3. Preserve structure: keep same fields and line order, only translate content
|
||||
4. Detail level "{detail_level}" - controls AMOUNT OF EVENT INFO (not tips/suggestions):
|
||||
- brief: 1-2 lines max. Only: what happened + where
|
||||
- standard: 3-6 lines. Include: what, where, cause, affected devices
|
||||
- detailed: Full report with ALL info: what, where, cause, affected, logs, SMART data, history
|
||||
5. DEDUPLICATION: merge duplicate facts from multiple sources into one clear statement
|
||||
6. EMPTY LISTS: write translated "none" after label, never leave blank
|
||||
7. Keep "hostname:" prefix in title — translate only the descriptive part
|
||||
8. DO NOT add recommendations or suggestions UNLESS AI Suggestions mode is enabled below
|
||||
9. ENRICHED CONTEXT: You may receive additional context data including:
|
||||
- "System uptime: X days (stable system)" → helps distinguish startup issues from runtime failures
|
||||
- "Event frequency: N occurrences, first seen X ago" → indicates recurring vs one-time issues
|
||||
- "SMART Health: PASSED/FAILED" with disk attributes → critical for disk errors
|
||||
- "KNOWN PROXMOX ERROR DETECTED" with cause/solution → YOU MUST USE this exact information
|
||||
|
||||
How to use enriched context:
|
||||
- If uptime is <10min and error is service-related → mention "occurred shortly after boot"
|
||||
- If frequency shows recurring pattern → mention "recurring issue (N times in X hours)"
|
||||
- If SMART shows FAILED → treat as CRITICAL: "Disk failing - immediate attention required"
|
||||
- If KNOWN ERROR is provided → YOU MUST incorporate its Cause and Solution (translate, don't copy verbatim)
|
||||
|
||||
10. JOURNAL CONTEXT EXTRACTION: When journal logs are provided:
|
||||
- Extract specific IDs (VM/CT numbers, disk devices, service names)
|
||||
- Include relevant timestamps if they help explain the timeline
|
||||
- Identify root cause when logs clearly show it (e.g., "exit-code 255" -> "process crashed")
|
||||
- Translate technical terms: "Emask 0x10" -> "ATA bus error", "DRDY ERR" -> "drive not ready"
|
||||
- If logs show the same error repeating, state frequency: "occurred 15 times in 10 minutes"
|
||||
- IGNORE journal entries unrelated to the main event
|
||||
11. OUTPUT ONLY the final result — no "Original:", no before/after comparisons
|
||||
12. Unknown input: preserve as closely as possible, translate what you can
|
||||
13. REDUNDANCY: Never repeat the same information twice. If title says "CT 103 failed", body should not start with "Container 103 failed"
|
||||
{suggestions_addon}
|
||||
═══ PROXMOX MAPPINGS (use directly, never explain) ═══
|
||||
pve-container@XXXX → "CT XXXX" | qemu-server@XXXX → "VM XXXX" | vzdump → "backup"
|
||||
pveproxy/pvedaemon/pvestatd → "Proxmox service" | corosync → "cluster service"
|
||||
"ata8.00: exception Emask..." → "ATA error on port 8"
|
||||
"blk_update_request: I/O error, dev sdX" → "I/O error on /dev/sdX"
|
||||
{emoji_instructions}
|
||||
═══ MESSAGE FORMATS ═══
|
||||
|
||||
BACKUP: List each VM/CT with status/size/duration/storage. End with summary.
|
||||
- Partial failure (some OK, some failed) = "Backup partially failed", not "failed"
|
||||
- NEVER collapse multi-VM backup into one line — show each VM separately
|
||||
- ALWAYS include storage path and summary line
|
||||
|
||||
UPDATES: Counts on own lines. Packages use "• " under header. No redundant summary.
|
||||
|
||||
DISK/SMART: Device + specific error. Deduplicate repeated info.
|
||||
|
||||
HEALTH: Category + severity + what changed. Duration if resolved.
|
||||
|
||||
VM/CT LIFECYCLE: Confirm event with key facts (1-2 lines).
|
||||
|
||||
═══ OUTPUT FORMAT (CRITICAL - MUST FOLLOW EXACTLY) ═══
|
||||
|
||||
Your response MUST have EXACTLY this structure:
|
||||
[TITLE]
|
||||
your translated title text
|
||||
[BODY]
|
||||
your translated body text
|
||||
|
||||
ABSOLUTE RULES (violations break the parser):
|
||||
1. [TITLE] and [BODY] are INVISIBLE PARSING MARKERS — they separate title from body
|
||||
2. Your actual title/body content must NEVER contain the words "[TITLE]" or "[BODY]"
|
||||
3. Your actual title/body content must NEVER contain "Title:" or "Body:" prefixes
|
||||
4. Line 1: write exactly [TITLE]
|
||||
5. Line 2: write your title text (emoji + hostname: description)
|
||||
6. Line 3: write exactly [BODY]
|
||||
7. Line 4+: write your body text
|
||||
|
||||
- Output ONLY the formatted result — no explanations, no "Original:", no commentary`
|
||||
|
||||
const SUGGESTIONS_ADDON = `═══ AI SUGGESTIONS MODE (ENABLED) ═══
|
||||
You MAY add ONE brief, actionable tip at the END of the body using this exact format:
|
||||
|
||||
💡 Tip: [your concise suggestion here]
|
||||
|
||||
Rules for the tip:
|
||||
- ONLY include if the log context or Known Error database clearly points to a specific fix
|
||||
- Keep under 100 characters
|
||||
- Be specific: "Run 'pvecm status' to check quorum" NOT "Check cluster status"
|
||||
- If Known Error provides a solution, YOU MUST USE IT (don't invent your own)
|
||||
- Never guess — skip the tip if the cause/solution is unclear`
|
||||
|
||||
const EXAMPLE_CUSTOM_PROMPT = `You are a notification formatter for ProxMenux Monitor.
|
||||
|
||||
Your task is to translate and format server notifications.
|
||||
|
||||
RULES:
|
||||
1. Translate to the user's preferred language
|
||||
2. Use plain text only (no markdown, no bold, no italic)
|
||||
3. Be concise and factual
|
||||
4. Do not add recommendations or suggestions
|
||||
5. Present only the facts from the input
|
||||
6. Keep hostname prefix in titles (e.g., "pve01: ")
|
||||
|
||||
OUTPUT FORMAT:
|
||||
[TITLE]
|
||||
your translated title here
|
||||
[BODY]
|
||||
your translated message here
|
||||
|
||||
Detail levels:
|
||||
- brief: 2-3 lines, essential only
|
||||
- standard: short paragraph with key details
|
||||
- detailed: full technical breakdown`
|
||||
|
||||
type ContextRow = { block: string; when: string; what: string }
|
||||
type CapRow = { level: string; cap: string; consumption: string }
|
||||
type DetailRow = { level: string; label: string; cap: string; produce: string }
|
||||
type PrivacyRow = { provider: string; destination: string }
|
||||
type WhereNextItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function AIAssistantPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.aiAssistant" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { aiAssistant: {
|
||||
howItWorks: { steps: string[]; notes: string[] }
|
||||
context: { rows: ContextRow[] }
|
||||
tokens: { items: string[]; capRows: CapRow[] }
|
||||
providers: {
|
||||
groq: { items: string[] }
|
||||
openai: { items: string[] }
|
||||
anthropic: { items: string[] }
|
||||
gemini: { items: string[] }
|
||||
openrouter: { items: string[] }
|
||||
ollama: { items: string[] }
|
||||
}
|
||||
models: { consequences: string[] }
|
||||
defaultPrompt: { passages: string[] }
|
||||
customPrompt: { changes: string[] }
|
||||
suggestions: { rules: string[] }
|
||||
detailLevel: { rows: DetailRow[]; defaults: string[] }
|
||||
language: { rules: string[] }
|
||||
privacy: { rows: PrivacyRow[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } }
|
||||
}
|
||||
const ai = messages.docs.monitor.aiAssistant
|
||||
const howSteps = ai.howItWorks.steps
|
||||
const howNotes = ai.howItWorks.notes
|
||||
const contextRows = ai.context.rows
|
||||
const tokensItems = ai.tokens.items
|
||||
const tokensCapRows = ai.tokens.capRows
|
||||
const groqItems = ai.providers.groq.items
|
||||
const openaiItems = ai.providers.openai.items
|
||||
const anthropicItems = ai.providers.anthropic.items
|
||||
const geminiItems = ai.providers.gemini.items
|
||||
const openrouterItems = ai.providers.openrouter.items
|
||||
const ollamaItems = ai.providers.ollama.items
|
||||
const modelsConsequences = ai.models.consequences
|
||||
const defaultPassages = ai.defaultPrompt.passages
|
||||
const customChanges = ai.customPrompt.changes
|
||||
const suggestionsRules = ai.suggestions.rules
|
||||
const detailLevelRows = ai.detailLevel.rows
|
||||
const detailLevelDefaults = ai.detailLevel.defaults
|
||||
const languageRules = ai.language.rules
|
||||
const privacyRows = ai.privacy.rows
|
||||
const whereNextItems = ai.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const detailLink = (chunks: React.ReactNode) => (
|
||||
<Link href="#detail-level-per-channel" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const notifLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/notifications" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
const providerLink = (href: string) => (chunks: React.ReactNode) =>
|
||||
(
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={25}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howItWorks.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("howItWorks.intro")}</p>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
|
||||
{howSteps.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`howItWorks.steps.${idx}`, { strong, code, em })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("howItWorks.notesIntro")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{howNotes.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`howItWorks.notes.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("enabling.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("enabling.intro", { em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/ai-enhancement-collapsed.png" alt={t("enabling.collapsedAlt")} width={2000} height={244} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("enabling.collapsedCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/ai-enhancement-panel.png" alt={t("enabling.panelAlt")} width={2000} height={1854} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("enabling.panelCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("enabling.outro", { em })}
|
||||
</p>
|
||||
|
||||
<h2 id="what-context-the-ai-receives" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
|
||||
{t("context.heading")}
|
||||
</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("context.intro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("context.headerBlock")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("context.headerWhen")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("context.headerWhat")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{contextRows.map((row, idx) => (
|
||||
<tr key={row.block} className={idx < contextRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.block}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`context.rows.${idx}.when`, { code })}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`context.rows.${idx}.what`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("context.afterBlocks")}</p>
|
||||
|
||||
<CopyableCode
|
||||
code={`Severity: WARNING
|
||||
Title: pve01: Disk I/O error
|
||||
Message:
|
||||
I/O error on /dev/sda — 1 sector pending reallocation.
|
||||
|
||||
Journal log context:
|
||||
Event frequency: 5 occurrences, first seen 2h ago, recurring
|
||||
|
||||
SMART Health: PASSED
|
||||
SMART attribute Reallocated_Sector_Ct: 1 (raw 1)
|
||||
SMART attribute Current_Pending_Sector: 1 (raw 1)
|
||||
|
||||
Journal logs:
|
||||
ata8.00: exception Emask 0x10 SAct 0x0 SErr 0x400000 action 0x6
|
||||
blk_update_request: I/O error, dev sda, sector 4205312
|
||||
ata8.00: error: { ICRC ABRT }`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("context.calloutTitle")}>
|
||||
{t("context.calloutBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("tokens.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("tokens.intro1", { em })}</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("tokens.intro2")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{tokensItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`tokens.items.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("tokens.capsIntro", { code })}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("tokens.headerLevel")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("tokens.headerCap")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("tokens.headerConsumption")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{tokensCapRows.map((row, idx) => (
|
||||
<tr key={row.level} className={idx < tokensCapRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><code>{row.level}</code></td>
|
||||
<td className="px-3 py-2 align-top">{row.cap}</td>
|
||||
<td className="px-3 py-2 align-top">{row.consumption}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("tokens.customNote")}</p>
|
||||
|
||||
<Callout variant="tip" title={t("tokens.sizingTitle")}>
|
||||
{t.rich("tokens.sizingBody", { code, link: detailLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("providers.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("providers.intro")}</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/ai-providers-information.png" alt={t("providers.imageAlt")} width={1602} height={2138} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto mx-auto max-w-2xl" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("providers.imageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 id="provider-groq" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("providers.groq.heading")}</h3>
|
||||
<p className="mb-2 text-gray-800 leading-relaxed"><em>{t("providers.groq.tagline")}</em></p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{groqItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`providers.groq.items.${idx}`, { code, strong, a: providerLink("https://console.groq.com/keys") })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 id="provider-openai" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("providers.openai.heading")}</h3>
|
||||
<p className="mb-2 text-gray-800 leading-relaxed"><em>{t("providers.openai.tagline")}</em></p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{openaiItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`providers.openai.items.${idx}`, { code, strong, a: providerLink("https://platform.openai.com/api-keys") })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="tip" title={t("providers.openai.baseUrlTitle")}>
|
||||
{t.rich("providers.openai.baseUrlBody", { em, strong, code })}
|
||||
</Callout>
|
||||
|
||||
<h3 id="provider-anthropic" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("providers.anthropic.heading")}</h3>
|
||||
<p className="mb-2 text-gray-800 leading-relaxed"><em>{t("providers.anthropic.tagline")}</em></p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{anthropicItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`providers.anthropic.items.${idx}`, { code, strong, a: providerLink("https://console.anthropic.com/settings/keys") })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 id="provider-gemini" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("providers.gemini.heading")}</h3>
|
||||
<p className="mb-2 text-gray-800 leading-relaxed"><em>{t("providers.gemini.tagline")}</em></p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{geminiItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`providers.gemini.items.${idx}`, { code, strong, a: providerLink("https://aistudio.google.com/app/apikey") })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 id="provider-openrouter" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("providers.openrouter.heading")}</h3>
|
||||
<p className="mb-2 text-gray-800 leading-relaxed"><em>{t("providers.openrouter.tagline")}</em></p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{openrouterItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`providers.openrouter.items.${idx}`, { code, strong, a: providerLink("https://openrouter.ai/keys") })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 id="provider-ollama" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("providers.ollama.heading")}</h3>
|
||||
<p className="mb-2 text-gray-800 leading-relaxed"><em>{t("providers.ollama.tagline")}</em></p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{ollamaItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`providers.ollama.items.${idx}`, { code, strong, em, a: providerLink("https://ollama.com/download") })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("models.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("models.intro", { code })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("models.consequencesIntro")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{modelsConsequences.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`models.consequences.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="info" title={t("models.ollamaTitle")}>
|
||||
{t("models.ollamaBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("defaultPrompt.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("defaultPrompt.intro", { em, code })}
|
||||
</p>
|
||||
|
||||
<details className="mb-4 rounded-md border border-gray-200 bg-gray-50">
|
||||
<summary className="cursor-pointer px-4 py-3 font-medium text-gray-900 hover:bg-gray-100 rounded-md">
|
||||
{t("defaultPrompt.showFullSummary")}
|
||||
</summary>
|
||||
<div className="px-4 pb-4">
|
||||
<pre className="text-xs font-mono text-gray-800 whitespace-pre-wrap leading-relaxed bg-white border border-gray-200 rounded p-3 overflow-x-auto">
|
||||
{DEFAULT_SYSTEM_PROMPT}
|
||||
</pre>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("defaultPrompt.passagesIntro", { em })}
|
||||
</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{defaultPassages.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`defaultPrompt.passages.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("defaultPrompt.suggestionsPlaceholder", { code })}
|
||||
</p>
|
||||
|
||||
<details className="mb-4 rounded-md border border-gray-200 bg-gray-50">
|
||||
<summary className="cursor-pointer px-4 py-3 font-medium text-gray-900 hover:bg-gray-100 rounded-md">
|
||||
{t("defaultPrompt.showAddonSummary")}
|
||||
</summary>
|
||||
<div className="px-4 pb-4">
|
||||
<pre className="text-xs font-mono text-gray-800 whitespace-pre-wrap leading-relaxed bg-white border border-gray-200 rounded p-3 overflow-x-auto">
|
||||
{SUGGESTIONS_ADDON}
|
||||
</pre>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<h2 id="custom-prompt-mode" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("customPrompt.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("customPrompt.intro", { em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/ai-custom-prompt.png" alt={t("customPrompt.imageAlt")} width={2000} height={987} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("customPrompt.imageCaption", { em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("customPrompt.changesTitle")}</h3>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
|
||||
{customChanges.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`customPrompt.changes.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("customPrompt.starterTitle")}</h3>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("customPrompt.starterIntro", { em })}
|
||||
</p>
|
||||
|
||||
<details className="mb-4 rounded-md border border-gray-200 bg-gray-50">
|
||||
<summary className="cursor-pointer px-4 py-3 font-medium text-gray-900 hover:bg-gray-100 rounded-md">
|
||||
{t("customPrompt.showStarterSummary")}
|
||||
</summary>
|
||||
<div className="px-4 pb-4">
|
||||
<pre className="text-xs font-mono text-gray-800 whitespace-pre-wrap leading-relaxed bg-white border border-gray-200 rounded p-3 overflow-x-auto">
|
||||
{EXAMPLE_CUSTOM_PROMPT}
|
||||
</pre>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("customPrompt.shareTitle")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("customPrompt.shareIntro", { em, code })}
|
||||
</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux/discussions/categories/share-custom-prompts-for-ai-notifications"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("customPrompt.shareLinkLabel")}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("customPrompt.shareOutro")}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("suggestions.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("suggestions.intro", { strong, em })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("suggestions.formatIntro")}</p>
|
||||
|
||||
<CopyableCode
|
||||
code={`💡 Tip: Run 'pvecm status' to check quorum`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("suggestions.rulesIntro")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{suggestionsRules.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`suggestions.rules.${idx}`, { em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="warning" title={t("suggestions.betaTitle")}>
|
||||
{t("suggestions.betaBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 id="detail-level-per-channel" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
|
||||
{t("detailLevel.heading")}
|
||||
</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("detailLevel.intro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("detailLevel.headerLevel")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("detailLevel.headerLabel")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("detailLevel.headerCap")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("detailLevel.headerProduce")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{detailLevelRows.map((row, idx) => (
|
||||
<tr key={row.level} className={idx < detailLevelRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><code>{row.level}</code></td>
|
||||
<td className="px-3 py-2 align-top">{row.label}</td>
|
||||
<td className="px-3 py-2 align-top">{row.cap}</td>
|
||||
<td className="px-3 py-2 align-top">{row.produce}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("detailLevel.defaultsIntro")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{detailLevelDefaults.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`detailLevel.defaults.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="info" title={t("detailLevel.emailTitle")}>
|
||||
{t.rich("detailLevel.emailBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 id="language" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("language.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("language.intro", { code, em })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("language.list", { code })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("language.rulesIntro")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{languageRules.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`language.rules.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("language.customNote", { strong })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("templates.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("templates.body1", { code })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("templates.body2", { link: notifLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("privacy.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("privacy.intro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("privacy.headerProvider")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("privacy.headerDestination")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{privacyRows.map((row, idx) => (
|
||||
<tr key={row.provider} className={idx < privacyRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.provider}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`privacy.rows.${idx}.destination`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="warning" title={t("privacy.calloutTitle")}>
|
||||
{t("privacy.calloutBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux/discussions/categories/share-custom-prompts-for-ai-notifications"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("whereNext.communityLabel")}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
{t("whereNext.communityTail")}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
299
web/app/[locale]/docs/monitor/api/page.tsx
Normal file
299
web/app/[locale]/docs/monitor/api/page.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.apiReference.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox api",
|
||||
"proxmox rest api",
|
||||
"proxmox monitor api",
|
||||
"proxmox integration",
|
||||
"proxmox home assistant",
|
||||
"proxmox homepage",
|
||||
"proxmox grafana",
|
||||
"proxmox prometheus endpoint",
|
||||
"proxmox n8n",
|
||||
"proxmox bearer token",
|
||||
"proxmox curl example",
|
||||
"proxmenux api",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/monitor/api" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/monitor/api",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type EndpointRow = { endpoint: string; method: string; use: string }
|
||||
type WhereNextItem = { label: string; href: string; tail?: string; tailRich?: string }
|
||||
type MetricRow = { metric: string; desc: string }
|
||||
type MetricGroup = { group: string; metrics: MetricRow[] }
|
||||
|
||||
export default async function MonitorApiPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.apiReference" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { apiReference: {
|
||||
auth: { rows: EndpointRow[]; items: string[] }
|
||||
conventions: { items: string[] }
|
||||
system: { rows: EndpointRow[] }
|
||||
health: { rows: EndpointRow[] }
|
||||
storage: { rows: EndpointRow[] }
|
||||
network: { rows: EndpointRow[] }
|
||||
vms: { rows: EndpointRow[] }
|
||||
backups: { rows: EndpointRow[] }
|
||||
logs: { rows: EndpointRow[] }
|
||||
notifications: { rows: EndpointRow[] }
|
||||
security: { rows: EndpointRow[] }
|
||||
proxmenuxIntegration: { rows: EndpointRow[] }
|
||||
prometheus: { groups: MetricGroup[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } }
|
||||
}
|
||||
const api = messages.docs.monitor.apiReference
|
||||
const authRows = api.auth.rows
|
||||
const authItems = api.auth.items
|
||||
const conventionsItems = api.conventions.items
|
||||
const systemRows = api.system.rows
|
||||
const healthRows = api.health.rows
|
||||
const storageRows = api.storage.rows
|
||||
const networkRows = api.network.rows
|
||||
const vmsRows = api.vms.rows
|
||||
const backupsRows = api.backups.rows
|
||||
const logsRows = api.logs.rows
|
||||
const notifRows = api.notifications.rows
|
||||
const securityRows = api.security.rows
|
||||
const proxmenuxRows = api.proxmenuxIntegration.rows
|
||||
const metricGroups = api.prometheus.groups
|
||||
const whereNextItems = api.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const accessLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/access-auth" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const healthLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/health-monitor" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const notifLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/notifications" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const aiLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/ai-assistant" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const integrationsLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/integrations" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
const endpointTable = (rows: EndpointRow[], pathPrefix: string) => (
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("headerMethod")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("headerUse")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{rows.map((row, idx) => (
|
||||
<tr key={`${row.endpoint}-${row.method}-${idx}`} className={idx < rows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
|
||||
<td className="px-3 py-2 align-top">{row.method}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`${pathPrefix}.${idx}.use`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={22}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { strong, link: accessLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 id="authentication" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("auth.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("auth.intro")}</p>
|
||||
|
||||
<CopyableCode
|
||||
code={`curl -H "Authorization: Bearer <token>" http://<host>:8008/api/system | jq`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("auth.tokensIntro")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1 mb-4">
|
||||
{authItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`auth.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("auth.flowLink", { link: accessLink })}
|
||||
</p>
|
||||
|
||||
{endpointTable(authRows, "auth.rows")}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("conventions.heading")}</h2>
|
||||
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1 mb-6">
|
||||
{conventionsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`conventions.items.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("system.heading")}</h2>
|
||||
{endpointTable(systemRows, "system.rows")}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("health.heading")}</h2>
|
||||
{endpointTable(healthRows, "health.rows")}
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("health.outro", { link: healthLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("storage.heading")}</h2>
|
||||
{endpointTable(storageRows, "storage.rows")}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("network.heading")}</h2>
|
||||
{endpointTable(networkRows, "network.rows")}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("vms.heading")}</h2>
|
||||
{endpointTable(vmsRows, "vms.rows")}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("backups.heading")}</h2>
|
||||
{endpointTable(backupsRows, "backups.rows")}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("logs.heading")}</h2>
|
||||
{endpointTable(logsRows, "logs.rows")}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("notifications.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("notifications.intro", { notifLink, aiLink })}
|
||||
</p>
|
||||
{endpointTable(notifRows, "notifications.rows")}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("security.heading")}</h2>
|
||||
{endpointTable(securityRows, "security.rows")}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("proxmenuxIntegration.heading")}</h2>
|
||||
{endpointTable(proxmenuxRows, "proxmenuxIntegration.rows")}
|
||||
|
||||
<h2 id="prometheus" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("prometheus.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("prometheus.intro", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("prometheus.exportedTitle")}</h3>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("prometheus.headerGroup")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("prometheus.headerMetric")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("prometheus.headerDesc")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{metricGroups.flatMap((group, gIdx) =>
|
||||
group.metrics.map((m, mIdx) => (
|
||||
<tr key={`${group.group}-${m.metric}`} className="border-b border-gray-100">
|
||||
{mIdx === 0 && (
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap" rowSpan={group.metrics.length}>
|
||||
<strong>{group.group}</strong>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{m.metric}</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t.rich(`prometheus.groups.${gIdx}.metrics.${mIdx}.desc`, { code })}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("prometheus.scrapeTitle")}</h3>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("prometheus.scrapeIntro")}</p>
|
||||
|
||||
<CopyableCode
|
||||
code={`# /etc/prometheus/prometheus.yml
|
||||
scrape_configs:
|
||||
- job_name: 'proxmenux'
|
||||
metrics_path: /api/prometheus
|
||||
scheme: https # or http if TLS isn't enabled in ProxMenux
|
||||
scrape_interval: 30s
|
||||
authorization:
|
||||
type: Bearer
|
||||
credentials: '<your-api-token>'
|
||||
static_configs:
|
||||
- targets:
|
||||
- 'pve01.lan:8008'
|
||||
- 'pve02.lan:8008'
|
||||
- 'pve03.lan:8008'`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="tip" title={t("prometheus.perHostTitle")}>
|
||||
{t.rich("prometheus.perHostBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("puttingItTogether.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("puttingItTogether.body", { link: integrationsLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
396
web/app/[locale]/docs/monitor/architecture/page.tsx
Normal file
396
web/app/[locale]/docs/monitor/architecture/page.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { DataFlowDiagram } from "@/components/ui/data-flow-diagram"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.architecture.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmenux architecture",
|
||||
"proxmox monitor flask",
|
||||
"proxmox dashboard sqlite",
|
||||
"proxmox appimage dashboard",
|
||||
"proxmox websocket terminal",
|
||||
"proxmox monitor blueprints",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/monitor/architecture" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/monitor/architecture",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type ThreadRow = { thread: string; cadence: string; job: string }
|
||||
type BlueprintRow = { blueprint: string; prefix: string[]; owns: string }
|
||||
type DataRow = { source: string; usedFor: string }
|
||||
type PersistenceRow = { path: string; owner: string; contents: string }
|
||||
type WhereNextItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function MonitorArchitecturePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.architecture" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { architecture: {
|
||||
requestFlow: { rows: ThreadRow[] }
|
||||
systemd: { items: string[] }
|
||||
appimage: { consequences: string[] }
|
||||
flask: { rows: BlueprintRow[] }
|
||||
dataSources: { rows: DataRow[] }
|
||||
persistence: { rows: PersistenceRow[] }
|
||||
health: { items: string[] }
|
||||
notifications: { items: string[] }
|
||||
websocket: { items: string[] }
|
||||
proxy: { items: string[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } }
|
||||
}
|
||||
const arch = messages.docs.monitor.architecture
|
||||
const threadRows = arch.requestFlow.rows
|
||||
const systemdItems = arch.systemd.items
|
||||
const consequences = arch.appimage.consequences
|
||||
const blueprintRows = arch.flask.rows
|
||||
const dataRows = arch.dataSources.rows
|
||||
const persistenceRows = arch.persistence.rows
|
||||
const healthItems = arch.health.items
|
||||
const notificationItems = arch.notifications.items
|
||||
const websocketItems = arch.websocket.items
|
||||
const proxyItems = arch.proxy.items
|
||||
const whereNextItems = arch.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const link = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const notifLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/notifications" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const aiLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/ai-assistant" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const accessLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/access-auth" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const fail2banLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/security/fail2ban" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const fail2banWarnLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/security/fail2ban" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={10}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t("intro.body")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("requestFlow.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("requestFlow.intro")}</p>
|
||||
|
||||
<DataFlowDiagram
|
||||
nodes={[
|
||||
{ variant: "source", label: t("requestFlow.nodes.clientLabel"), detail: t("requestFlow.nodes.clientDetail") },
|
||||
{ variant: "bridge", label: t("requestFlow.nodes.flaskLabel"), detail: t("requestFlow.nodes.flaskDetail") },
|
||||
{ variant: "bridge", label: t("requestFlow.nodes.hostLabel"), detail: t("requestFlow.nodes.hostDetail") },
|
||||
{ variant: "target", label: t("requestFlow.nodes.stateLabel"), detail: t("requestFlow.nodes.stateDetail") },
|
||||
]}
|
||||
arrowLabel={t("requestFlow.diagramArrowLabel")}
|
||||
caption={t("requestFlow.diagramCaption")}
|
||||
/>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("requestFlow.threadsIntro", { strong })}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("requestFlow.headerThread")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("requestFlow.headerCadence")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("requestFlow.headerJob")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{threadRows.map((row, idx) => (
|
||||
<tr key={row.thread} className={idx < threadRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.thread}</td>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">{row.cadence}</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t.rich(`requestFlow.rows.${idx}.job`, { code })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("systemd.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("systemd.intro", { code })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`[Unit]
|
||||
Description=ProxMenux Monitor - Web Dashboard
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/proxmenux-monitor
|
||||
ExecStart=/opt/proxmenux-monitor/ProxMenux-Monitor.AppImage
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
Environment="PORT=8008"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target`}
|
||||
className="my-4"
|
||||
/>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{systemdItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`systemd.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Callout variant="tip" title={t("systemd.inspectTitle")}>
|
||||
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{`systemctl cat proxmenux-monitor.service # show the unit content
|
||||
systemctl status proxmenux-monitor.service # state + recent log
|
||||
journalctl -u proxmenux-monitor.service -f # follow live`}</pre>
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("appimage.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("appimage.intro", { code })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`AppDir/
|
||||
├── AppRun # entrypoint: sets PATH/LD_LIBRARY_PATH, exec flask_server
|
||||
├── usr/
|
||||
│ ├── bin/
|
||||
│ │ ├── flask_server.py # main process
|
||||
│ │ ├── flask_*_routes.py # Flask blueprints (auth, health, terminal, …)
|
||||
│ │ ├── auth_manager.py # JWT + TOTP + API tokens
|
||||
│ │ ├── health_monitor.py # 10-category checker
|
||||
│ │ ├── health_persistence.py # SQLite layer
|
||||
│ │ ├── notification_manager.py # orchestrator
|
||||
│ │ ├── notification_channels.py # Telegram, Discord, Email, …
|
||||
│ │ ├── notification_templates.py # message rendering + AI hook
|
||||
│ │ ├── notification_events.py # JournalWatcher, TaskWatcher, …
|
||||
│ │ ├── ai_providers/ # OpenAI · Anthropic · Gemini · Groq · Ollama · OpenRouter
|
||||
│ │ ├── proxmox_storage_monitor.py # storage pool inspection
|
||||
│ │ ├── hardware_monitor.py # CPU/PCIe/GPU enumeration
|
||||
│ │ ├── ipmitool, sensors, upsc # vendored hardware tools
|
||||
│ │ └── …
|
||||
│ ├── lib/python3/ # vendored Python deps (Flask, JWT, psutil, …)
|
||||
│ └── share/ # icons + .desktop file
|
||||
└── web/ # Next.js static export
|
||||
├── index.html
|
||||
├── _next/ # JS / CSS chunks
|
||||
└── manifest.json # PWA manifest`}
|
||||
className="my-4"
|
||||
/>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t("appimage.consequencesIntro")}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{consequences.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`appimage.consequences.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("flask.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("flask.intro", { code })}
|
||||
</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("flask.headerBlueprint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("flask.headerPrefix")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("flask.headerOwns")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{blueprintRows.map((row, idx) => (
|
||||
<tr key={row.blueprint} className={idx < blueprintRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.blueprint}</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs leading-6">
|
||||
{row.prefix.map((p, pidx) => (
|
||||
<span key={pidx}>
|
||||
{p}
|
||||
{pidx < row.prefix.length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t.rich(`flask.rows.${idx}.owns`, { code })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("flask.endpointsLink", { link })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataSources.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("dataSources.intro")}</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataSources.headerSource")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataSources.headerUsedFor")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dataRows.map((row, idx) => (
|
||||
<tr key={row.source} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.source}</td>
|
||||
<td className="px-3 py-2 align-top">{row.usedFor}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("dataSources.cacheTitle")}>
|
||||
{t.rich("dataSources.cacheBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("persistence.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("persistence.intro")}</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("persistence.headerPath")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("persistence.headerOwner")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("persistence.headerContents")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{persistenceRows.map((row, idx) => (
|
||||
<tr key={row.path} className={idx < persistenceRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.path}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`persistence.rows.${idx}.owner`, { code })}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`persistence.rows.${idx}.contents`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="warning" title={t("persistence.backupTitle")}>
|
||||
{t.rich("persistence.backupBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("health.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("health.intro", { code })}
|
||||
</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{healthItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`health.items.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("health.afterIntro", { code })}
|
||||
</p>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("health.cycleEnd", { em, code, link })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("notifications.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("notifications.intro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{notificationItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`notifications.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("notifications.linksFooter", { notifLink, aiLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("websocket.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("websocket.intro", { em, code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{websocketItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`websocket.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("websocket.outro", { code })}
|
||||
</p>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("websocket.proxyNote", { code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("proxy.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("proxy.intro")}</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{proxyItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`proxy.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
<Callout variant="info" title={t("proxy.calloutTitle")}>
|
||||
{t.rich("proxy.calloutBody", { strong, code, link: fail2banWarnLink })}
|
||||
</Callout>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("proxy.outro", { accessLink, fail2banLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
518
web/app/[locale]/docs/monitor/dashboard/hardware/page.tsx
Normal file
518
web/app/[locale]/docs/monitor/dashboard/hardware/page.tsx
Normal file
@@ -0,0 +1,518 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.hardware.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type GpuTool = { vendor: string; tool: string; projectLabel: string; projectHref?: string }
|
||||
type DataRow = { section: string; endpoint: string; source: string }
|
||||
type WhereNextItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function HardwareTabPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.hardware" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { dashboard: { hardware: {
|
||||
thresholds: { items: string[] }
|
||||
sections: { systemInfoItems: string[]; thermalItems: string[] }
|
||||
graphics: { tools: GpuTool[]; whereGoItems: string[] }
|
||||
coral: { pathsItems: string[] }
|
||||
power: { items: string[] }
|
||||
dataCollected: { rows: DataRow[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } } }
|
||||
}
|
||||
const hw = messages.docs.monitor.dashboard.hardware
|
||||
const thresholdsItems = hw.thresholds.items
|
||||
const systemInfoItems = hw.sections.systemInfoItems
|
||||
const thermalItems = hw.sections.thermalItems
|
||||
const gpuTools = hw.graphics.tools
|
||||
const whereGoItems = hw.graphics.whereGoItems
|
||||
const coralPathsItems = hw.coral.pathsItems
|
||||
const powerItems = hw.power.items
|
||||
const dataRows = hw.dataCollected.rows
|
||||
const whereNextItems = hw.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const green = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-green-500 align-middle mr-1" />
|
||||
const amber = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-amber-500 align-middle mr-1" />
|
||||
const red = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-red-500 align-middle mr-1" />
|
||||
const thresholdsLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard/settings#status-colours" className="text-blue-700 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const switchModeLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/switch-gpu-mode" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const nvidiaHostLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/nvidia-host" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const nvidiaAnchor = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://www.nvidia.com/Download/index.aspx"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-amber-900 underline hover:no-underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
)
|
||||
const link1 = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/nvidia-host" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const link2 = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/switch-gpu-mode" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const link3 = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/gpu-vm-passthrough" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const link4 = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/igpu-acceleration-lxc" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const installLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/install-coral-tpu-host" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const lxcLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/coral-tpu-lxc" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const coralAnchor = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://coral.ai/docs/m2/get-started/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
)
|
||||
const storageLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard/storage" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const smartLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/disk-manager/smart-disk-test" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const pciSwitchLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/switch-gpu-mode" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={14}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("thresholds.title")}>
|
||||
{t.rich("thresholds.intro", { strong, green, amber, red })}
|
||||
<ul className="list-disc pl-6 mt-2 space-y-0.5">
|
||||
{thresholdsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`thresholds.items.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
{t.rich("thresholds.outro", { link: thresholdsLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("sections.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("sections.intro", { em })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("sections.systemInfoTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("sections.systemInfoIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{systemInfoItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`sections.systemInfoItems.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("sections.memoryTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("sections.memoryBody", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("sections.thermalTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("sections.thermalIntro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{thermalItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`sections.thermalItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("graphics.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("graphics.intro", { em, strong, code, link: switchModeLink })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-graphics-cards-vfio.png"
|
||||
alt={t("graphics.vfioImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("graphics.vfioImageCaption", { code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-graphics-cards-lxc.png"
|
||||
alt={t("graphics.lxcImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("graphics.lxcImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("graphics.realtimeTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("graphics.realtimeBody", { code })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("graphics.toolsIntro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6 rounded-md border border-gray-200">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold">{t("graphics.headerVendor")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("graphics.headerTool")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("graphics.headerProject")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-800">
|
||||
{gpuTools.map((row) => (
|
||||
<tr key={row.vendor}>
|
||||
<td className="px-4 py-2 align-top whitespace-nowrap"><strong>{row.vendor}</strong></td>
|
||||
<td className="px-4 py-2 align-top"><code>{row.tool}</code></td>
|
||||
<td className="px-4 py-2 align-top">
|
||||
{row.projectHref ? (
|
||||
<a
|
||||
href={row.projectHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{row.projectLabel}
|
||||
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
) : (
|
||||
row.projectLabel
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-gpu-nvidia-modal.png"
|
||||
alt={t("graphics.nvidiaImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("graphics.nvidiaImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-gpu-intel-modal.png"
|
||||
alt={t("graphics.intelImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("graphics.intelImageCaption", { code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-gpu-amd-modal.png"
|
||||
alt={t("graphics.amdImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("graphics.amdImageCaption", { code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("graphics.installTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("graphics.installBody", { code, strong, link: nvidiaHostLink })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-gpu-nvidia-no-driver.png"
|
||||
alt={t("graphics.noDriverAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("graphics.noDriverCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-gpu-nvidia-install-prompt.png"
|
||||
alt={t("graphics.promptAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("graphics.promptCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-gpu-nvidia-install-success.png"
|
||||
alt={t("graphics.successAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("graphics.successCaption", { code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<Callout variant="warning" title={t("graphics.warningTitle")}>
|
||||
{t.rich("graphics.warningBody", { code, em, a: nvidiaAnchor })}
|
||||
</Callout>
|
||||
|
||||
<p className="mt-4 mb-2 text-gray-800 leading-relaxed">{t("graphics.whereGoIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereGoItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`graphics.whereGoItems.${idx}`, { em, link1, link2, link3, link4 })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
|
||||
{t("coral.heading")} <em>{t("coral.subHeading")}</em>
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("coral.intro", { code })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-coral-tpu-modal.png"
|
||||
alt={t("coral.imageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("coral.imageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("coral.pathsIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{coralPathsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`coral.pathsItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("coral.outro", { installLink, lxcLink, a: coralAnchor })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("storage.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("storage.intro", { code, em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-storage-summary.png"
|
||||
alt={t("storage.imageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("storage.imageCaption", { em })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("storage.nvmeBody", { strong })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-storage-modal-nvme.png"
|
||||
alt={t("storage.nvmeModalAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("storage.nvmeModalCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("storage.outro", { em, storageLink, smartLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("pci.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("pci.intro", { strong, em, code })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-pci-devices.png"
|
||||
alt={t("pci.imageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("pci.imageCaption", { code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<Callout variant="tip" title={t("pci.bdfTitle")}>
|
||||
{t.rich("pci.bdfBody", { code, link: pciSwitchLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("usb.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("usb.intro", { code, em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-usb-devices.png"
|
||||
alt={t("usb.imageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("usb.imageCaption", { code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
|
||||
{t("power.heading")} <em>{t("power.subHeading")}</em>
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("power.intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{powerItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`power.items.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-power-supplies.png"
|
||||
alt={t("power.supplyImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("power.supplyImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-cpu-power.png"
|
||||
alt={t("power.cpuImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("power.cpuImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
|
||||
{t("psu.heading")} <em>{t("psu.subHeading")}</em>
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("psu.body")}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
|
||||
{t("fans.heading")} <em>{t("fans.subHeading")}</em>
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("fans.body")}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
|
||||
{t("ups.heading")} <em>{t("ups.subHeading")}</em>
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("ups.body", { em })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSection")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dataRows.map((row, idx) => (
|
||||
<tr key={row.section} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top">{row.section}</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`dataCollected.rows.${idx}.source`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<CopyableCode
|
||||
code={`${t("dataCollected.codeComment1")}
|
||||
lspci -nnk | grep -A2 -E 'VGA|Audio|Network|3D'
|
||||
sensors
|
||||
|
||||
${t("dataCollected.codeComment2")}
|
||||
curl -H "Authorization: Bearer <token>" \\
|
||||
http://<host>:8008/api/hardware | jq '.gpus'`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
325
web/app/[locale]/docs/monitor/dashboard/network/page.tsx
Normal file
325
web/app/[locale]/docs/monitor/dashboard/network/page.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { Download } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.network.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type TopRow = { card: string; what: string }
|
||||
type DrillRow = { block: string; contents: string }
|
||||
type ThresholdRow = { status: string; range: string; impact: string }
|
||||
type DataRow = { section: string; endpoint: string; source: string }
|
||||
type WhereNextItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function NetworkTabPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.network" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { dashboard: { network: {
|
||||
topRow: { rows: TopRow[] }
|
||||
groups: { badges: string[] }
|
||||
drillIn: { rows: DrillRow[] }
|
||||
latency: {
|
||||
targets: string[]
|
||||
mode2Items: string[]
|
||||
thresholdRows: ThresholdRow[]
|
||||
sections: string[]
|
||||
}
|
||||
dataCollected: { rows: DataRow[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } } }
|
||||
}
|
||||
const net = messages.docs.monitor.dashboard.network
|
||||
const topRows = net.topRow.rows
|
||||
const badges = net.groups.badges
|
||||
const drillRows = net.drillIn.rows
|
||||
const targets = net.latency.targets
|
||||
const mode2Items = net.latency.mode2Items
|
||||
const thresholdRows = net.latency.thresholdRows
|
||||
const sections = net.latency.sections
|
||||
const dataRows = net.dataCollected.rows
|
||||
const whereNextItems = net.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={13}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("topRow.heading")}</h2>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerCard")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerWhat")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{topRows.map((row, idx) => (
|
||||
<tr key={row.card} className={idx < topRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.card}</strong></td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t.rich(`topRow.rows.${idx}.what`, { em, strong })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("groups.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("groups.intro", { strong })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{badges.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`groups.badges.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("groups.clickable", { strong })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("groups.physicalTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("groups.physicalBody", { code, strong, em })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("groups.bridgeTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("groups.bridgeBody", { code, strong })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("groups.vmTitle")}</h3>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("groups.vmBody", { code, em })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("drillIn.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.intro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("drillIn.headerBlock")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("drillIn.headerContents")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{drillRows.map((row, idx) => (
|
||||
<tr key={row.block} className={idx < drillRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.block}</strong></td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t.rich(`drillIn.rows.${idx}.contents`, { code })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("drillIn.inactiveTitle")}>
|
||||
{t.rich("drillIn.inactiveBody", { em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("latency.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("latency.intro", { em })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("latency.targetsTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("latency.targetsIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{targets.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`latency.targets.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("latency.mode1Title")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/network-latency-historical.png"
|
||||
alt={t("latency.mode1Alt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("latency.mode1Caption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("latency.mode1Body1", { em })}
|
||||
</p>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("latency.mode1Body2", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("latency.mode2Title")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/network-latency-realtime.png"
|
||||
alt={t("latency.mode2Alt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("latency.mode2Caption", { em })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("latency.mode2Intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{mode2Items.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`latency.mode2Items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("latency.thresholdsTitle")}</h3>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("latency.headerStatus")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("latency.headerRange")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("latency.headerImpact")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{thresholdRows.map((row, idx) => (
|
||||
<tr key={row.status} className={idx < thresholdRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.status}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{row.range}</td>
|
||||
<td className="px-3 py-2 align-top">{row.impact}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("latency.reportTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("latency.reportIntro", { strong })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/network-latency-report-preview.png"
|
||||
alt={t("latency.reportPreviewAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("latency.reportPreviewCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<div className="my-6">
|
||||
<a
|
||||
href="/monitor/sample-network-latency-report.pdf"
|
||||
download
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-md border border-blue-200 bg-blue-50 text-blue-700 font-medium hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
<Download className="h-4 w-4" aria-hidden="true" />
|
||||
{t("latency.downloadLabel")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("latency.sectionsIntro")}</p>
|
||||
<ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{sections.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`latency.sections.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<Callout variant="tip" title={t("latency.useCaseTitle")}>
|
||||
{t.rich("latency.useCaseBody", { em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("excluding.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("excluding.body1", { code })}
|
||||
</p>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("excluding.body2", { strong, em })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSection")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dataRows.map((row, idx) => (
|
||||
<tr key={row.section} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top">{row.section}</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t.rich(`dataCollected.rows.${idx}.source`, { code })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<CopyableCode
|
||||
code={`${t("dataCollected.codeComment1")}
|
||||
ip -br link
|
||||
ip -br addr
|
||||
|
||||
${t("dataCollected.codeComment2")}
|
||||
curl -H "Authorization: Bearer <token>" \\
|
||||
http://<host>:8008/api/network/latency/current | jq`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
120
web/app/[locale]/docs/monitor/dashboard/page.tsx
Normal file
120
web/app/[locale]/docs/monitor/dashboard/page.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
}
|
||||
}
|
||||
|
||||
type TabRow = { name: string; linksTo?: string; owns: string }
|
||||
type WhereNextItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function DashboardIndexPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { dashboard: {
|
||||
tabs: { rows: TabRow[] }
|
||||
headerAnatomy: { items: string[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } }
|
||||
}
|
||||
const tabRows = messages.docs.monitor.dashboard.tabs.rows
|
||||
const headerAnatomyItems = messages.docs.monitor.dashboard.headerAnatomy.items
|
||||
const whereNextItems = messages.docs.monitor.dashboard.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const link = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/health-monitor" className="text-blue-700 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={3}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("oneHeader.title")}>
|
||||
{t.rich("oneHeader.body", { link })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("tabs.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("tabs.intro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("tabs.headerTab")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("tabs.headerOwns")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{tabRows.map((row, idx) => (
|
||||
<tr
|
||||
key={row.name}
|
||||
className={idx < tabRows.length - 1 ? "border-b border-gray-100" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">
|
||||
{row.linksTo ? (
|
||||
<Link href={row.linksTo} className="text-blue-600 hover:underline font-semibold">
|
||||
{row.name}
|
||||
</Link>
|
||||
) : (
|
||||
<strong>{row.name}</strong>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t.rich(`tabs.rows.${idx}.owns`, { code })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("headerAnatomy.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{headerAnatomyItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`headerAnatomy.items.${idx}`, { code, strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
489
web/app/[locale]/docs/monitor/dashboard/security/page.tsx
Normal file
489
web/app/[locale]/docs/monitor/dashboard/security/page.tsx
Normal file
@@ -0,0 +1,489 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.security.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type DataRow = { card: string; endpoint: string; source: string }
|
||||
type WhereNextItem = { label: string; href: string; tail?: string; tailRich?: string }
|
||||
|
||||
export default async function SecurityTabPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.security" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { dashboard: { security: {
|
||||
auth: { items: string[] }
|
||||
ssl: { items: string[] }
|
||||
gateway: { step3Items: string[]; step4Items: string[] }
|
||||
firewall: { items: string[] }
|
||||
lynis: { scoreItems: string[] }
|
||||
dataCollected: { rows: DataRow[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } } }
|
||||
}
|
||||
const sec = messages.docs.monitor.dashboard.security
|
||||
const authItems = sec.auth.items
|
||||
const sslItems = sec.ssl.items
|
||||
const step3Items = sec.gateway.step3Items
|
||||
const step4Items = sec.gateway.step4Items
|
||||
const firewallItems = sec.firewall.items
|
||||
const lynisScoreItems = sec.lynis.scoreItems
|
||||
const dataRows = sec.dataCollected.rows
|
||||
const whereNextItems = sec.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const authLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/access-auth" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const sslPageLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/security/ssl-letsencrypt" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const integrationsLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/integrations" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const tailscaleHomeAnchor = (chunks: React.ReactNode) => (
|
||||
<a href="https://tailscale.com" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
|
||||
{chunks}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
)
|
||||
const tailscaleKeysAnchor = (chunks: React.ReactNode) => (
|
||||
<a href="https://login.tailscale.com/admin/settings/keys" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
|
||||
{chunks}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
)
|
||||
const tailscaleMachinesAnchor = (chunks: React.ReactNode) => (
|
||||
<a href="https://login.tailscale.com/admin/machines" target="_blank" rel="noopener noreferrer" className="text-amber-700 hover:underline inline-flex items-center gap-1">
|
||||
{chunks}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
)
|
||||
const fail2banLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/security/fail2ban" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const lynisLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/security/lynis" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const lynisSampleAnchor = (chunks: React.ReactNode) => (
|
||||
<a href="/monitor/security/lynis-sample-report.pdf" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
|
||||
{chunks}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={18}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("monitor.heading")}</h2>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t("monitor.intro")}</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("auth.heading")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/auth-card.png" alt={t("auth.imageAlt")} width={2000} height={956} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("auth.imageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("auth.intro", { link: authLink })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{authItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`auth.items.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("ssl.heading")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/ssl-https-card.png" alt={t("ssl.imageAlt")} width={2000} height={1124} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("ssl.imageCaption", { em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("ssl.intro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{sslItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`ssl.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/ssl-https-enabled.png" alt={t("ssl.enabledAlt")} width={2000} height={889} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("ssl.enabledCaption", { em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<Callout variant="info" title={t("ssl.acmeTitle")}>
|
||||
{t.rich("ssl.acmeBody", { em })}
|
||||
</Callout>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("ssl.walkthroughLink", { code, link: sslPageLink })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("apiTokens.heading")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/api-tokens-empty.png" alt={t("apiTokens.emptyAlt")} width={2000} height={855} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("apiTokens.emptyCaption", { em, code })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("apiTokens.intro")}</p>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("apiTokens.generateBody", { strong, em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/api-tokens-generate.png" alt={t("apiTokens.generateAlt")} width={2000} height={1124} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("apiTokens.generateCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("apiTokens.saveBody", { strong })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/api-tokens-generated.png" alt={t("apiTokens.generatedAlt")} width={2000} height={1468} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("apiTokens.generatedCaption", { code })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("apiTokens.outro", { em, link: integrationsLink })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("gateway.heading")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/secure-gateway-card.png" alt={t("gateway.cardAlt")} width={2000} height={434} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gateway.cardCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("gateway.intro", { code, a: tailscaleHomeAnchor })}
|
||||
</p>
|
||||
|
||||
<h4 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("gateway.wizardTitle")}</h4>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("gateway.wizardIntro", { em })}
|
||||
</p>
|
||||
|
||||
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step0Title")}</h5>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("gateway.step0Body", { em, a: tailscaleKeysAnchor })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/tailscale-auth-key-page.png" alt={t("gateway.step0Alt")} width={2000} height={1115} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("gateway.step0Caption", { em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step1Title")}</h5>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("gateway.step1Body", { em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/gateway-step-1-intro.png" alt={t("gateway.step1Alt")} width={1589} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gateway.step1Caption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step2Title")}</h5>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("gateway.step2Body", { code })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/gateway-step-3-auth.png" alt={t("gateway.step2Alt")} width={1985} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gateway.step2Caption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step3Title")}</h5>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("gateway.step3Intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-3 text-gray-800 leading-relaxed space-y-1">
|
||||
{step3Items.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`gateway.step3Items.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/gateway-step-2-scope.png" alt={t("gateway.step3Alt")} width={1934} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("gateway.step3Caption", { em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step4Title")}</h5>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("gateway.step4Intro", { strong })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-3 text-gray-800 leading-relaxed space-y-1">
|
||||
{step4Items.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`gateway.step4Items.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/gateway-step-4-advanced.png" alt={t("gateway.step4Alt")} width={1847} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gateway.step4Caption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step5Title")}</h5>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("gateway.step5Body", { strong })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/gateway-step-5-review.png" alt={t("gateway.step5Alt")} width={1860} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gateway.step5Caption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<Callout variant="warning" title={t("gateway.approvalTitle")}>
|
||||
{t.rich("gateway.approvalBody", { em, a: tailscaleMachinesAnchor })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("pve.heading")}</h2>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t("pve.intro")}</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("firewall.heading")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/firewall-card.png" alt={t("firewall.imageAlt")} width={2000} height={1256} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("firewall.imageCaption", { em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("firewall.intro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{firewallItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`firewall.items.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">
|
||||
{t("fail2ban.heading")} <em>{t("fail2ban.subHeading")}</em>
|
||||
</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("fail2ban.whatIs", { strong })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("fail2ban.notBundled", { strong })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/fail2ban-not-installed.png" alt={t("fail2ban.notInstalledAlt")} width={2000} height={968} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("fail2ban.notInstalledCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("fail2ban.clickBody", { em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/fail2ban-install-confirm.png" alt={t("fail2ban.confirmAlt")} width={1808} height={1678} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("fail2ban.confirmCaption", { code })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("fail2ban.confirmIntro")}</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/fail2ban-install-progress.png" alt={t("fail2ban.progressAlt")} width={2000} height={1512} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("fail2ban.progressCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("fail2ban.afterInstall", { em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/fail2ban-active.png" alt={t("fail2ban.activeAlt")} width={2000} height={1614} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("fail2ban.activeCaption", { code })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("fail2ban.tuneBody", { strong, em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/fail2ban-sshd-config.png" alt={t("fail2ban.configAlt")} width={2000} height={919} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("fail2ban.configCaption", { em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("fail2ban.outro", { em, code, link: fail2banLink })}
|
||||
</p>
|
||||
|
||||
<Callout variant="info" title={t("fail2ban.calloutTitle")}>
|
||||
{t.rich("fail2ban.calloutBody", { em, code })}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">
|
||||
{t("lynis.heading")} <em>{t("lynis.subHeading")}</em>
|
||||
</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("lynis.whatIs", { strong })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("lynis.whyUseful", { strong, code })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/lynis-not-installed.png" alt={t("lynis.notInstalledAlt")} width={2000} height={919} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("lynis.notInstalledCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("lynis.notBundled", { strong })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/lynis-install-confirm.png" alt={t("lynis.confirmAlt")} width={1985} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("lynis.confirmCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/lynis-install-progress.png" alt={t("lynis.progressAlt")} width={1856} height={972} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("lynis.progressCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("lynis.afterInstall", { em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/lynis-installed-empty.png" alt={t("lynis.installedAlt")} width={2000} height={1131} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("lynis.installedCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/lynis-audit-running.png" alt={t("lynis.runningAlt")} width={2000} height={1131} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("lynis.runningCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("lynis.finishedBody", { em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/lynis-audit-results.png" alt={t("lynis.resultsAlt")} width={2000} height={1183} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("lynis.resultsCaption", { strong })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<Callout variant="info" title={t("lynis.scoreTitle")}>
|
||||
{t.rich("lynis.scoreIntro", { em, code })}
|
||||
<ul className="list-disc pl-6 mt-2 mb-0 space-y-1">
|
||||
{lynisScoreItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`lynis.scoreItems.${idx}`, { em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Callout>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("lynis.reportBody", { strong, em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/lynis-report-pdf.png" alt={t("lynis.reportAlt")} width={1414} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("lynis.reportCaption", { a: lynisSampleAnchor })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("lynis.runPeriodically")}</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("lynis.outro", { em, link: lynisLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerCard")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dataRows.map((row, idx) => (
|
||||
<tr key={row.card} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top">{row.card}</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`dataCollected.rows.${idx}.source`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<CopyableCode
|
||||
code={`# Confirm the auth log on the host (used by Fail2Ban + audit)
|
||||
journalctl -t proxmenux-auth --since '7 days ago' | tail
|
||||
|
||||
# Cross-check the firewall rules the dashboard sees
|
||||
pve-firewall status
|
||||
cat /etc/pve/firewall/host.fw
|
||||
|
||||
# Verify Fail2Ban (only if installed)
|
||||
fail2ban-client status
|
||||
fail2ban-client status sshd
|
||||
|
||||
# Verify Lynis (only if installed)
|
||||
lynis show version
|
||||
ls -lh /var/log/lynis-report.dat`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
486
web/app/[locale]/docs/monitor/dashboard/settings/page.tsx
Normal file
486
web/app/[locale]/docs/monitor/dashboard/settings/page.tsx
Normal file
@@ -0,0 +1,486 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.settings.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type ColourRow = { colour: string; range: string; meaning: string }
|
||||
type ThresholdRow = { section: string; warning: string; critical: string; gates: string }
|
||||
type DataRow = { card: string; endpoint: string; source: string }
|
||||
type WhereNextItem = { label: string; href: string; tail?: string; tailRich?: string }
|
||||
|
||||
export default async function SettingsTabPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.settings" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { dashboard: { settings: {
|
||||
health: { items: string[]; activeItems: string[] }
|
||||
thresholds: {
|
||||
whatForItems: string[]
|
||||
colourRows: ColourRow[]
|
||||
thresholdRows: ThresholdRow[]
|
||||
}
|
||||
lxcDetection: { whatRunsItems: string[] }
|
||||
storageExclusions: { items: string[] }
|
||||
interfaceExclusions: { items: string[] }
|
||||
notifications: { items: string[] }
|
||||
optimizations: { dotsItems: string[] }
|
||||
dataCollected: { rows: DataRow[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } } }
|
||||
}
|
||||
const s = messages.docs.monitor.dashboard.settings
|
||||
const healthItems = s.health.items
|
||||
const activeSuppressionItems = s.health.activeItems
|
||||
const whatForItems = s.thresholds.whatForItems
|
||||
const colourRows = s.thresholds.colourRows
|
||||
const thresholdRows = s.thresholds.thresholdRows
|
||||
const whatRunsItems = s.lxcDetection.whatRunsItems
|
||||
const storageItems = s.storageExclusions.items
|
||||
const interfaceItems = s.interfaceExclusions.items
|
||||
const notificationItems = s.notifications.items
|
||||
const dotsItems = s.optimizations.dotsItems
|
||||
const dataRows = s.dataCollected.rows
|
||||
const whereNextItems = s.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const green = () => <span className="inline-block w-2 h-2 rounded-full bg-green-500 align-middle mr-1" />
|
||||
const amber = () => <span className="inline-block w-2 h-2 rounded-full bg-amber-500 align-middle mr-1" />
|
||||
const healthLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/health-monitor#dismissing-alerts-and-the-suppression-duration" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const storageTabLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard/storage" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const networkTabLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard/network" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const notifLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/notifications" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const aiLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/ai-assistant" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const autoLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/automated" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const customLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/customizable" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const updatesLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/updates" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const uninstallLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/uninstall" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={9}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t("intro.body")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("networkUnits.heading")}</h2>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image
|
||||
src="/monitor/settings/network-units.png"
|
||||
alt={t("networkUnits.imageAlt")}
|
||||
width={2000}
|
||||
height={374}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("networkUnits.imageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("networkUnits.body", { strong })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("health.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("health.intro", { strong })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{healthItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`health.items.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<figure className="my-6">
|
||||
<Image
|
||||
src="/monitor/health-suppression-settings.png"
|
||||
alt={t("health.imageAlt")}
|
||||
width={2010}
|
||||
height={1816}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("health.imageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("health.editTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("health.editBody", { strong })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("health.activeTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("health.activeIntro", { strong, em })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{activeSuppressionItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`health.activeItems.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("health.activeReenableTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("health.activeReenableBody", { strong, code })}
|
||||
</p>
|
||||
|
||||
<Callout variant="info" title={t("health.activeAutoRefreshTitle")}>
|
||||
{t("health.activeAutoRefreshBody")}
|
||||
</Callout>
|
||||
|
||||
<p className="mt-4 mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("health.activePermanentNote", { strong, em, code })}
|
||||
</p>
|
||||
|
||||
<Callout variant="info" title={t("health.calloutTitle")}>
|
||||
{t.rich("health.calloutBody", { link: healthLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("thresholds.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("thresholds.intro", { em, strong })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("thresholds.whatForTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("thresholds.whatForIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{whatForItems.map((_, idx) => (
|
||||
<li key={idx}>{t(`thresholds.whatForItems.${idx}`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("thresholds.whatForOutro", { strong, code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900" id="status-colours">{t("thresholds.coloursTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("thresholds.coloursIntro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("thresholds.headerColour")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("thresholds.headerRange")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("thresholds.headerMeaning")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{colourRows.map((row, idx) => {
|
||||
// Color tier is identified positionally so the dot stays
|
||||
// correct in any locale (Spanish: Verde / Ámbar / Rojo).
|
||||
const dotClass = ["bg-green-500", "bg-amber-500", "bg-red-500"][idx] ?? "bg-red-500"
|
||||
return (
|
||||
<tr key={row.colour} className={idx < colourRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">
|
||||
<span className={`inline-block w-3 h-3 rounded-full ${dotClass} align-middle mr-2`} />
|
||||
<strong>{row.colour}</strong>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">{row.range}</td>
|
||||
<td className="px-3 py-2 align-top">{row.meaning}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("thresholds.sectionsTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("thresholds.sectionsIntro", { em })}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("thresholds.headerSection")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200 whitespace-nowrap bg-amber-100/60">{t("thresholds.headerWarning")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200 whitespace-nowrap bg-red-100/60">{t("thresholds.headerCritical")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("thresholds.headerGates")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{thresholdRows.map((row, idx) => (
|
||||
<tr key={row.section} className={idx < thresholdRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top"><strong>{row.section}</strong></td>
|
||||
<td className={`px-3 py-2 align-top font-mono whitespace-nowrap ${row.warning === "—" ? "text-gray-400" : ""} bg-amber-100/40`}>{row.warning}</td>
|
||||
<td className="px-3 py-2 align-top font-mono whitespace-nowrap bg-red-100/40">{row.critical}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`thresholds.thresholdRows.${idx}.gates`, { code, em })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("thresholds.defaultsTitle")}>
|
||||
{t.rich("thresholds.defaultsBody", { em, strong })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("thresholds.validationTitle")}>
|
||||
{t("thresholds.validationBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("lxcDetection.heading")}</h2>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image
|
||||
src="/monitor/settings/lxc-update-detection.png"
|
||||
alt={t("lxcDetection.imageAlt")}
|
||||
width={2000}
|
||||
height={620}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("lxcDetection.imageCaption", { code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("lxcDetection.intro", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("lxcDetection.whatRunsTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("lxcDetection.whatRunsIntro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{whatRunsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`lxcDetection.whatRunsItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="info" title={t("lxcDetection.selfUpdateTitle")}>
|
||||
{t.rich("lxcDetection.selfUpdateBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("lxcDetection.refreshTitle")}>
|
||||
{t.rich("lxcDetection.refreshBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("lxcDetection.toggleTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("lxcDetection.toggleBody", { code, strong })}
|
||||
</p>
|
||||
|
||||
<Callout variant="warning" title={t("lxcDetection.purgeTitle")}>
|
||||
{t.rich("lxcDetection.purgeBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("storageExclusions.heading")}</h2>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image
|
||||
src="/monitor/settings/storage-exclusions.png"
|
||||
alt={t("storageExclusions.imageAlt")}
|
||||
width={2000}
|
||||
height={1120}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("storageExclusions.imageCaption", { em })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("storageExclusions.intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{storageItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`storageExclusions.items.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("storageExclusions.outro", { em, code, link: storageTabLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("interfaceExclusions.heading")}</h2>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image
|
||||
src="/monitor/settings/interface-exclusions.png"
|
||||
alt={t("interfaceExclusions.imageAlt")}
|
||||
width={2000}
|
||||
height={1142}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("interfaceExclusions.imageCaption", { em })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("interfaceExclusions.intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{interfaceItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`interfaceExclusions.items.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("interfaceExclusions.outro", { code, em, link: networkTabLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("notifications.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("notifications.body1", { em })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("notifications.body2")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{notificationItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`notifications.items.${idx}`, { notifLink, aiLink })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("optimizations.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("optimizations.intro", { code, autoLink, customLink })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image
|
||||
src="/monitor/settings/proxmenux-optimizations.png"
|
||||
alt={t("optimizations.imageAlt")}
|
||||
width={2000}
|
||||
height={1146}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("optimizations.imageCaption", { em })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("optimizations.dotsTitle")}</h3>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{dotsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`optimizations.dotsItems.${idx}`, { strong, em, green, amber })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("optimizations.clickTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("optimizations.clickBody", { code })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image
|
||||
src="/monitor/settings/optimization-detail.png"
|
||||
alt={t("optimizations.detailAlt")}
|
||||
width={2000}
|
||||
height={1040}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("optimizations.detailCaption", { em, code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<Callout variant="info" title={t("optimizations.whyTitle")}>
|
||||
{t("optimizations.whyBody")}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("optimizations.updatesTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("optimizations.updatesBody", { strong, em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image
|
||||
src="/monitor/settings/proxmenux-optimizations-update-banner.png"
|
||||
alt={t("optimizations.updatesAlt")}
|
||||
width={2000}
|
||||
height={1146}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("optimizations.updatesCaption", { link: updatesLink })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("optimizations.revertTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("optimizations.revertBody", { code, link: uninstallLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerCard")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dataRows.map((row, idx) => (
|
||||
<tr key={row.card} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top">{row.card}</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`dataCollected.rows.${idx}.source`, { code, notifLink, aiLink })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { customLink }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
474
web/app/[locale]/docs/monitor/dashboard/storage/page.tsx
Normal file
474
web/app/[locale]/docs/monitor/dashboard/storage/page.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { Download } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.storage.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type DataRow = { section: string; endpoint: string; source: string }
|
||||
type WhereNextItem = { label: string; href: string; tail?: string; tailRich?: string }
|
||||
|
||||
export default async function StorageTabPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.storage" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { dashboard: { storage: {
|
||||
thresholds: { items: string[] }
|
||||
topRow: { disksItems: string[] }
|
||||
pveStorage: { items: string[] }
|
||||
zfs: { items: string[] }
|
||||
physical: { items: string[] }
|
||||
drillIn: {
|
||||
overviewItems: string[]
|
||||
smartItems: string[]
|
||||
pdfSections: string[]
|
||||
historyItems: string[]
|
||||
scheduleItems: string[]
|
||||
tempShowsItems: string[]
|
||||
tempDiskTypes: string[]
|
||||
tempWhyItems: string[]
|
||||
obsWhatItems: string[]
|
||||
obsWhyItems: string[]
|
||||
}
|
||||
dataCollected: { rows: DataRow[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } } }
|
||||
}
|
||||
const s = messages.docs.monitor.dashboard.storage
|
||||
const thresholdsItems = s.thresholds.items
|
||||
const disksItems = s.topRow.disksItems
|
||||
const pveItems = s.pveStorage.items
|
||||
const zfsItems = s.zfs.items
|
||||
const physicalItems = s.physical.items
|
||||
const overviewItems = s.drillIn.overviewItems
|
||||
const smartItems = s.drillIn.smartItems
|
||||
const pdfSections = s.drillIn.pdfSections
|
||||
const historyItems = s.drillIn.historyItems
|
||||
const scheduleItems = s.drillIn.scheduleItems
|
||||
const tempShowsItems = s.drillIn.tempShowsItems
|
||||
const tempDiskTypes = s.drillIn.tempDiskTypes
|
||||
const tempWhyItems = s.drillIn.tempWhyItems
|
||||
const obsWhatItems = s.drillIn.obsWhatItems
|
||||
const obsWhyItems = s.drillIn.obsWhyItems
|
||||
const dataRows = s.dataCollected.rows
|
||||
const whereNextItems = s.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const green = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-green-500 align-middle mr-1" />
|
||||
const amber = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-amber-500 align-middle mr-1" />
|
||||
const red = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-red-500 align-middle mr-1" />
|
||||
const thresholdsLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard/settings#status-colours" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const zfsHmLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/health-monitor" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const physicalWarnLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/disk-manager/format-disk" className="text-amber-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const hmLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/health-monitor" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={14}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("thresholds.title")}>
|
||||
{t.rich("thresholds.intro", { strong, green, amber, red })}
|
||||
<ul className="list-disc pl-6 mt-2 space-y-0.5">
|
||||
{thresholdsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`thresholds.items.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
{t.rich("thresholds.outro", { link: thresholdsLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("topRow.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("topRow.intro")}</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/storage-top-row.png"
|
||||
alt={t("topRow.imageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("topRow.imageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerCard")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerWhat")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.totalLabel")}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t("topRow.totalWhat")}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.localLabel")}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich("topRow.localWhat", { em })}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.remoteLabel")}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t("topRow.remoteWhat")}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.disksLabel")}</strong></td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t("topRow.disksIntro")}
|
||||
<ul className="list-disc pl-5 mt-2 space-y-0.5">
|
||||
{disksItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`topRow.disksItems.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("pveStorage.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("pveStorage.intro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{pveItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`pveStorage.items.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="tip" title={t("pveStorage.calloutTitle")}>
|
||||
{t.rich("pveStorage.calloutBody", { em, code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("zfs.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("zfs.intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{zfsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`zfs.items.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("zfs.outro", { em, link: zfsHmLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("physical.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("physical.intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{physicalItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`physical.items.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t("physical.clickHint")}</p>
|
||||
|
||||
<Callout variant="warning" title={t("physical.warningTitle")}>
|
||||
{t.rich("physical.warningBody", { strong, link: physicalWarnLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("external.heading")}</h2>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("external.body", { strong })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("drillIn.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.intro", { strong, em })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.overviewTitle")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/disk-modal-overview.png"
|
||||
alt={t("drillIn.overviewImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("drillIn.overviewImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.overviewIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{overviewItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.overviewItems.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.smartTitle")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/disk-modal-smart.png"
|
||||
alt={t("drillIn.smartImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("drillIn.smartImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.smartIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{smartItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.smartItems.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.pdfTitle")}</h4>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.pdfIntro", { strong })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/smart-report-preview.png"
|
||||
alt={t("drillIn.pdfPreviewAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("drillIn.pdfPreviewCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<div className="my-6">
|
||||
<a
|
||||
href="/monitor/sample-smart-report.pdf"
|
||||
download
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-md border border-blue-200 bg-blue-50 text-blue-700 font-medium hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
<Download className="h-4 w-4" aria-hidden="true" />
|
||||
{t("drillIn.pdfDownloadLabel")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.pdfSectionsIntro")}</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{pdfSections.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.pdfSections.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.pdfOutro", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.historyTitle")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/disk-modal-history.png"
|
||||
alt={t("drillIn.historyImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("drillIn.historyImageCaption", { code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.historyIntro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{historyItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.historyItems.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.scheduleTitle")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/disk-modal-schedule.png"
|
||||
alt={t("drillIn.scheduleImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("drillIn.scheduleImageCaption", { code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.scheduleIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{scheduleItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.scheduleItems.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t("drillIn.scheduleOutro")}</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.tempTitle")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.tempIntro")}</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/disk-modal-temperature.png"
|
||||
alt={t("drillIn.tempImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("drillIn.tempImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.tempShowsTitle")}</h4>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{tempShowsItems.map((_, idx) => (
|
||||
<li key={idx}>
|
||||
{t.rich(`drillIn.tempShowsItems.${idx}`, { strong, em })}
|
||||
{idx === 2 && (
|
||||
<ul className="list-disc pl-6 mt-1">
|
||||
{tempDiskTypes.map((_, didx) => (
|
||||
<li key={didx}>{t.rich(`drillIn.tempDiskTypes.${didx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.tempConfigurable", { em })}
|
||||
</p>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.tempWhyTitle")}</h4>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{tempWhyItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.tempWhyItems.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.obsTitle")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.obsIntro", { strong, em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/disk-modal-observations.png"
|
||||
alt={t("drillIn.obsImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("drillIn.obsImageCaption", { strong })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.obsWhatTitle")}</h4>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.obsWhatIntro", { strong })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{obsWhatItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.obsWhatItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.obsWhyTitle")}</h4>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{obsWhyItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.obsWhyItems.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.obsDedupTitle")}</h4>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.obsDedupBody1", { strong, code })}
|
||||
</p>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t("drillIn.obsDedupBody2")}
|
||||
</p>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.obsDismissTitle")}</h4>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.obsDismissBody1", { strong })}
|
||||
</p>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.obsDismissBody2", { link: hmLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSection")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dataRows.map((row, idx) => (
|
||||
<tr key={row.section} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top">{row.section}</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`dataCollected.rows.${idx}.source`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("dataCollected.outro")}</p>
|
||||
|
||||
<CopyableCode
|
||||
code={`${t("dataCollected.codeComment1")}
|
||||
curl -H "Authorization: Bearer <api-token>" \\
|
||||
http://<host>:8008/api/storage | jq '.disks[] | {name,model,smart_status}'
|
||||
|
||||
${t("dataCollected.codeComment2")}
|
||||
lsblk -O
|
||||
zpool status
|
||||
journalctl -t smartd --since '1 day ago' | tail`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
178
web/app/[locale]/docs/monitor/dashboard/system-logs/page.tsx
Normal file
178
web/app/[locale]/docs/monitor/dashboard/system-logs/page.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.systemLogs.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type DataRow = { subtab: string; endpoint: string; source: string }
|
||||
type WhereNextItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function SystemLogsTabPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.systemLogs" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { dashboard: { systemLogs: {
|
||||
topRow: { items: string[] }
|
||||
subtabs: { logsFilters: string[]; fields: string[] }
|
||||
dataCollected: { rows: DataRow[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } } }
|
||||
}
|
||||
const sl = messages.docs.monitor.dashboard.systemLogs
|
||||
const topRowItems = sl.topRow.items
|
||||
const logsFilters = sl.subtabs.logsFilters
|
||||
const fields = sl.subtabs.fields
|
||||
const dataRows = sl.dataCollected.rows
|
||||
const whereNextItems = sl.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const link = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/notifications" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={7}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("readOnly.title")}>
|
||||
{t.rich("readOnly.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("topRow.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{topRowItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`topRow.items.${idx}`, { code, strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("subtabs.heading")}</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("subtabs.logsTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("subtabs.logsIntro", { code })}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{logsFilters.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`subtabs.logsFilters.${idx}`, { code, strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("subtabs.logsRowsAfter", { code, strong })}
|
||||
</p>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("subtabs.logDetailsModalTitle")}</h4>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("subtabs.logDetailsBody", { code, strong })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/log-details-modal.png"
|
||||
alt={t("subtabs.logDetailsImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("subtabs.logDetailsImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("subtabs.fieldsIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{fields.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`subtabs.fields.${idx}`, { code, strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="tip" title={t("subtabs.maxLevelStoreTitle")}>
|
||||
{t.rich("subtabs.maxLevelStoreBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("subtabs.backupsTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("subtabs.backupsBody", { code, em })}</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("subtabs.notificationsTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("subtabs.notificationsBody1")}</p>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t.rich("subtabs.notificationsBody2", { link })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSubtab")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dataRows.map((row, idx) => (
|
||||
<tr
|
||||
key={row.endpoint}
|
||||
className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 align-top">{row.subtab}</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs" dangerouslySetInnerHTML={{ __html: row.endpoint }} />
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t.rich(`dataCollected.rows.${idx}.source`, { code })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("dataCollected.apiIntro")}</p>
|
||||
<CopyableCode
|
||||
code={`${t("dataCollected.codeComment1")}
|
||||
curl -H "Authorization: Bearer <token>" \\
|
||||
"http://<host>:8008/api/logs?severity=error&since=1h&search=zfs"
|
||||
|
||||
${t("dataCollected.codeComment2")}
|
||||
curl -H "Authorization: Bearer <token>" \\
|
||||
-o pmx-journal.txt \\
|
||||
"http://<host>:8008/api/logs/download?since=6h"
|
||||
|
||||
${t("dataCollected.codeComment3")}
|
||||
curl -H "Authorization: Bearer <token>" \\
|
||||
"http://<host>:8008/api/task-log/<upid>"`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
225
web/app/[locale]/docs/monitor/dashboard/system-overview/page.tsx
Normal file
225
web/app/[locale]/docs/monitor/dashboard/system-overview/page.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.systemOverview.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type TopRow = { card: string; what: string; source: string }
|
||||
type DataRow = { card: string; endpoint: string; source: string }
|
||||
type WhereNextItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function SystemOverviewTabPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.systemOverview" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { dashboard: { systemOverview: {
|
||||
topRow: { rows: TopRow[]; thresholdsItems: string[] }
|
||||
bottom: { storageItems: string[] }
|
||||
refresh: { items: string[] }
|
||||
dataCollected: { rows: DataRow[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } } }
|
||||
}
|
||||
const so = messages.docs.monitor.dashboard.systemOverview
|
||||
const topRows = so.topRow.rows
|
||||
const thresholdsItems = so.topRow.thresholdsItems
|
||||
const storageItems = so.bottom.storageItems
|
||||
const refreshItems = so.refresh.items
|
||||
const dataRows = so.dataCollected.rows
|
||||
const whereNextItems = so.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const green = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-green-500 align-middle mr-1" />
|
||||
const amber = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-amber-500 align-middle mr-1" />
|
||||
const red = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-red-500 align-middle mr-1" />
|
||||
const thresholdsLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard/settings#status-colours" className="text-blue-700 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const storageLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const networkLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={6}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("readOnly.title")}>
|
||||
{t("readOnly.body")}
|
||||
</Callout>
|
||||
|
||||
<figure className="my-8">
|
||||
<img
|
||||
src="/monitor/dashboard-home.png"
|
||||
alt={t("captureAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("captureCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("topRow.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("topRow.intro", { code })}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerCard")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerWhat")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerSource")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{topRows.map((row, idx) => (
|
||||
<tr key={row.card} className={idx < topRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">
|
||||
<strong>{row.card}</strong>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t.rich(`topRow.rows.${idx}.what`, { code, em })}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.source}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("topRow.thresholdsTitle")}>
|
||||
{t.rich("topRow.thresholdsIntro", { strong, green, amber, red })}
|
||||
<ul className="list-disc pl-6 mt-2 space-y-0.5">
|
||||
{thresholdsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`topRow.thresholdsItems.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
{t.rich("topRow.thresholdsOutro", { link: thresholdsLink })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("topRow.sparklineTitle")}>
|
||||
{t("topRow.sparklineBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("middle.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("middle.body1", { code, em })}
|
||||
</p>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t("middle.body2")}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("bottom.heading")}</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("bottom.storageTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("bottom.storageIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{storageItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`bottom.storageItems.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("bottom.storageDrillIn", { link: storageLink })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("bottom.networkTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("bottom.networkBody1", { code })}
|
||||
</p>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("bottom.networkBody2", { link: networkLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("refresh.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("refresh.intro", { code, em })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{refreshItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`refresh.items.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerCard")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dataRows.map((row, idx) => (
|
||||
<tr key={row.card} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top">{row.card}</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t.rich(`dataCollected.rows.${idx}.source`, { code })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<CopyableCode
|
||||
code={`${t("dataCollected.codeComment1")}
|
||||
curl http://<host>:8008/api/health ${t("dataCollected.codeComment2")}
|
||||
|
||||
${t("dataCollected.codeComment3")}
|
||||
curl -H "Authorization: Bearer <token>" \\
|
||||
http://<host>:8008/api/system | jq '.cpu,.memory,.uptime'`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
319
web/app/[locale]/docs/monitor/dashboard/terminal/page.tsx
Normal file
319
web/app/[locale]/docs/monitor/dashboard/terminal/page.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.terminal.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type KeyboardRow = { button: string; sends: string; use?: string; useRich?: boolean }
|
||||
type DisconnectRow = { cause: string; fix: string }
|
||||
type WhereNextItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function TerminalTabPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.terminal" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { dashboard: { terminal: {
|
||||
keyboard: { rows: KeyboardRow[]; ctrlItems: string[] }
|
||||
auth: { items: string[] }
|
||||
clipboard: { items: string[] }
|
||||
disconnect: { rows: DisconnectRow[] }
|
||||
fourTerminals: { items: string[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } } }
|
||||
}
|
||||
const term = messages.docs.monitor.dashboard.terminal
|
||||
const kbRows = term.keyboard.rows
|
||||
const ctrlItems = term.keyboard.ctrlItems
|
||||
const authItems = term.auth.items
|
||||
const clipboardItems = term.clipboard.items
|
||||
const disconnectRows = term.disconnect.rows
|
||||
const fourTerminalsItems = term.fourTerminals.items
|
||||
const whereNextItems = term.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const green = (chunks: React.ReactNode) => <span className="text-green-600 font-semibold">{chunks}</span>
|
||||
const red = (chunks: React.ReactNode) => <span className="text-red-600 font-semibold">{chunks}</span>
|
||||
const vmsLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard/vms-lxcs" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const vmsLinkAmber = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard/vms-lxcs" className="text-blue-700 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const authLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/access-auth" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const authLinkWarn = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/access-auth" className="text-amber-700 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const gatewayLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/integrations" className="text-amber-700 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={7}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<figure className="my-6">
|
||||
<Image
|
||||
src="/monitor/terminal/single-terminal.png"
|
||||
alt={t("singleAlt")}
|
||||
width={1600}
|
||||
height={1000}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("singleCaption", { em })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("target.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("target.body1", { strong })}
|
||||
</p>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("target.body2", { strong, em, code, link: vmsLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("fourTerminals.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("fourTerminals.intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{fourTerminalsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`fourTerminals.items.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("fourTerminals.outro", { strong, em, code })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<Image
|
||||
src="/monitor/terminal/grid-4-terminals.png"
|
||||
alt={t("gridAlt")}
|
||||
width={1600}
|
||||
height={1000}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("gridCaption", { code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("keyboard.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("keyboard.intro", { code })}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("keyboard.headerButton")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("keyboard.headerSends")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("keyboard.headerUse")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{kbRows.map((row, idx) => (
|
||||
<tr key={row.button} className={idx < kbRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.button}</strong></td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.sends}</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{row.useRich ? (
|
||||
<>
|
||||
{t("keyboard.ctrlIntro")}
|
||||
<ul className="list-disc pl-5 mt-1 space-y-0.5">
|
||||
{ctrlItems.map((_, cidx) => (
|
||||
<li key={cidx}>{t.rich(`keyboard.ctrlItems.${cidx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
t.rich(`keyboard.rows.${idx}.use`, { code })
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("keyboard.modalTitle")}>
|
||||
{t.rich("keyboard.modalBody", { code, link: vmsLinkAmber })}
|
||||
</Callout>
|
||||
|
||||
<figure className="my-6">
|
||||
<Image
|
||||
src="/monitor/terminal/lxc-console-modal.png"
|
||||
alt={t("lxcAlt")}
|
||||
width={1600}
|
||||
height={1200}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("lxcCaption", { em })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("search.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("search.intro", { code, strong, em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<Image
|
||||
src="/monitor/terminal/search-commands.png"
|
||||
alt={t("search.modalAlt")}
|
||||
width={1600}
|
||||
height={1200}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("search.modalCaption", { code, em })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
<strong>{t("search.aboutLabel")}</strong>{" "}
|
||||
<a
|
||||
href="https://cheat.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
cheat.sh
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>{" "}
|
||||
{t.rich("search.aboutBody", { code })}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("search.headerSource")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("search.headerWhen")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("search.headerWhat")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">
|
||||
<a
|
||||
href="https://cheat.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1 font-semibold"
|
||||
>
|
||||
cheat.sh
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>{" "}
|
||||
{t("search.onlineLabel")}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">{t("search.onlineWhen")}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich("search.onlineWhat", { green })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("search.fallbackLabel")}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t("search.fallbackWhen")}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich("search.fallbackWhat", { red })}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-6 text-gray-800 leading-relaxed text-sm">
|
||||
{t.rich("search.sendingNote", { strong })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("auth.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{authItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`auth.items.${idx}`, { code, link: authLink })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("clipboard.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{clipboardItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`clipboard.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("disconnect.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("disconnect.intro")}</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("disconnect.headerCause")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("disconnect.headerFix")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{disconnectRows.map((row, idx) => (
|
||||
<tr key={row.cause} className={idx < disconnectRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top">{row.cause}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`disconnect.rows.${idx}.fix`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="warning" title={t("warning.title")}>
|
||||
{t.rich("warning.body", { code, authLink: authLinkWarn, gatewayLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
437
web/app/[locale]/docs/monitor/dashboard/vms-lxcs/page.tsx
Normal file
437
web/app/[locale]/docs/monitor/dashboard/vms-lxcs/page.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.vmsLxcs.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type LifecycleRow = { button: string; color: string; enabled: string; action: string }
|
||||
type DataRow = { section: string; endpoint: string; source: string }
|
||||
type WhereNextItem = { label: string; href: string; tailRich: string }
|
||||
|
||||
export default async function VmsLxcsTabPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.vmsLxcs" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { dashboard: { vmsLxcs: {
|
||||
topRow: { memoryItems: string[] }
|
||||
inventory: { rows: string[] }
|
||||
drillIn: {
|
||||
liveItems: string[]
|
||||
ioItems: string[]
|
||||
resourcesItems: string[]
|
||||
mountTypesItems: string[]
|
||||
mountStateItems: string[]
|
||||
backupsItems: string[]
|
||||
updatesPanelItems: string[]
|
||||
firewallItems: string[]
|
||||
lifecycleRows: LifecycleRow[]
|
||||
}
|
||||
dataCollected: { rows: DataRow[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } } }
|
||||
}
|
||||
const v = messages.docs.monitor.dashboard.vmsLxcs
|
||||
const memoryItems = v.topRow.memoryItems
|
||||
const inventoryRows = v.inventory.rows
|
||||
const liveItems = v.drillIn.liveItems
|
||||
const ioItems = v.drillIn.ioItems
|
||||
const resourcesItems = v.drillIn.resourcesItems
|
||||
const mountTypesItems = v.drillIn.mountTypesItems
|
||||
const mountStateItems = v.drillIn.mountStateItems
|
||||
const backupsItems = v.drillIn.backupsItems
|
||||
const updatesPanelItems = v.drillIn.updatesPanelItems
|
||||
const firewallItems = v.drillIn.firewallItems
|
||||
const lifecycleRows = v.drillIn.lifecycleRows
|
||||
const dataRows = v.dataCollected.rows
|
||||
const whereNextItems = v.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const green = () => <span className="inline-block w-2 h-2 rounded-full bg-green-500 align-middle mr-1" />
|
||||
const amber = () => <span className="inline-block w-2 h-2 rounded-full bg-amber-500 align-middle mr-1" />
|
||||
const red = () => <span className="inline-block w-2 h-2 rounded-full bg-red-500 align-middle mr-1" />
|
||||
const greenText = (chunks: React.ReactNode) => <span className="text-green-600 font-semibold">{chunks}</span>
|
||||
const amberText = (chunks: React.ReactNode) => <span className="text-amber-600 font-semibold">{chunks}</span>
|
||||
const redText = (chunks: React.ReactNode) => <span className="text-red-600 font-semibold">{chunks}</span>
|
||||
const orangeText = (chunks: React.ReactNode) => <span className="text-orange-600 font-semibold">{chunks}</span>
|
||||
const link = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard/terminal" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
const buttonColorClass = (color: string) => {
|
||||
switch (color) {
|
||||
case "green":
|
||||
return "text-green-600 font-semibold"
|
||||
case "blue":
|
||||
return "text-blue-600 font-semibold"
|
||||
case "red":
|
||||
return "text-red-600 font-semibold"
|
||||
default:
|
||||
return "font-semibold"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={12}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("topRow.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("topRow.intro")}</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/vms-top-row.png"
|
||||
alt={t("topRow.imageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("topRow.imageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerCard")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerWhat")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.totalLabel")}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich("topRow.totalWhat", { em })}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.cpuLabel")}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich("topRow.cpuWhat", { em })}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.memoryLabel")}</strong></td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t("topRow.memoryIntro")}
|
||||
<ul className="list-disc pl-5 mt-2 space-y-0.5">
|
||||
{memoryItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`topRow.memoryItems.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.diskLabel")}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich("topRow.diskWhat", { em })}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("inventory.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("inventory.intro", { code })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/vms-inventory-mobile.png"
|
||||
alt={t("inventory.imageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full max-w-md mx-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("inventory.imageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("inventory.rowsIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{inventoryRows.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`inventory.rows.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t("inventory.clickHint")}</p>
|
||||
|
||||
<Callout variant="tip" title={t("inventory.mobileTitle")}>
|
||||
{t("inventory.mobileBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("drillIn.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.intro", { strong, em })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.statusTitle")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/vms-modal-status.png"
|
||||
alt={t("drillIn.statusImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("drillIn.statusImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.statusIntro")}</p>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.liveTitle")}</h4>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{liveItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.liveItems.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.ioTitle")}</h4>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{ioItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.ioItems.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.resourcesTitle")}</h4>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.resourcesIntro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{resourcesItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.resourcesItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.ipsTitle")}</h4>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t("drillIn.ipsBody")}</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.mountsTitle")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/vms-modal-mounts.png"
|
||||
alt={t("drillIn.mountsImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("drillIn.mountsImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.mountsIntro", { strong, code })}
|
||||
</p>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.mountTypesTitle")}</h4>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{mountTypesItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.mountTypesItems.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.mountStateTitle")}</h4>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{mountStateItems.map((_, idx) => (
|
||||
<li key={idx}>
|
||||
{t.rich(`drillIn.mountStateItems.${idx}`, { strong, em, code, green, amber, red })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="info" title={t("drillIn.mountsCalloutTitle")}>
|
||||
{t("drillIn.mountsCalloutBody")}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.backupsTitle")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/vms-modal-backups.png"
|
||||
alt={t("drillIn.backupsImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("drillIn.backupsImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.backupsIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{backupsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.backupsItems.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.backupsOutro", { strong })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.updatesTitle")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/vms-modal-lxc-updates.png"
|
||||
alt={t("drillIn.updatesImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("drillIn.updatesImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.updatesIntro", { strong, code })}
|
||||
</p>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.updatesPanelTitle")}</h4>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{updatesPanelItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.updatesPanelItems.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.updatesScopeTitle")}</h4>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.updatesScopeBody", { strong, em, code })}
|
||||
</p>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.updatesToggleTitle")}</h4>
|
||||
<Callout variant="info" title={t("drillIn.updatesToggleCalloutTitle")}>
|
||||
{t.rich("drillIn.updatesToggleCalloutBody", { strong, code })}
|
||||
</Callout>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.updatesApplyTitle")}</h4>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.updatesApplyBody", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.firewallTitle")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.firewallIntro")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{firewallItems.map((_, idx) => (
|
||||
<li key={idx}>
|
||||
{t.rich(`drillIn.firewallItems.${idx}`, {
|
||||
strong,
|
||||
em,
|
||||
code,
|
||||
green: greenText,
|
||||
orange: orangeText,
|
||||
red: redText,
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.firewallRefresh", { em, code })}
|
||||
</p>
|
||||
|
||||
<Callout variant="info" title={t("drillIn.firewallCalloutTitle")}>
|
||||
{t("drillIn.firewallCalloutBody")}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.actionBarTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.actionBarIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
<li>{t.rich("drillIn.consoleItem", { strong, code, link })}</li>
|
||||
</ul>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.lifecycleIntro", { code })}
|
||||
</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("drillIn.headerButton")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("drillIn.headerEnabled")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("drillIn.headerAction")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{lifecycleRows.map((row, idx) => (
|
||||
<tr key={row.button} className={idx < lifecycleRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">
|
||||
<strong className={buttonColorClass(row.color)}>{row.button}</strong>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">{row.enabled}</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.action}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="warning" title={t("drillIn.forceStopTitle")}>
|
||||
{t.rich("drillIn.forceStopBody", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSection")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dataRows.map((row, idx) => (
|
||||
<tr key={row.section} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top">{row.section}</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`dataCollected.rows.${idx}.source`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<CopyableCode
|
||||
code={`${t("dataCollected.codeComment1")}
|
||||
pvesh get /cluster/resources --type vm --output-format=json | jq
|
||||
|
||||
${t("dataCollected.codeComment2")}
|
||||
qm config 100 ${t("dataCollected.codeComment3")}
|
||||
pct config 100 ${t("dataCollected.codeComment4")}`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{t.rich(`whereNext.items.${idx}.tailRich`, { code })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
432
web/app/[locale]/docs/monitor/health-monitor/page.tsx
Normal file
432
web/app/[locale]/docs/monitor/health-monitor/page.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { DataFlowDiagram } from "@/components/ui/data-flow-diagram"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.healthMonitor.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox health monitor",
|
||||
"proxmox health check",
|
||||
"proxmox smart monitoring",
|
||||
"proxmox zfs monitoring",
|
||||
"proxmox alerts",
|
||||
"proxmox proactive monitoring",
|
||||
"proxmox disk monitoring",
|
||||
"proxmox memory monitor",
|
||||
"proxmox cpu monitor",
|
||||
"proxmenux health monitor",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/monitor/health-monitor" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/monitor/health-monitor",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type CategoryRow = { category: string; checks: string; events: string }
|
||||
type SeverityRow = { status: string; colour: string; meaning: string; notification: string }
|
||||
type DismissRow = { finding: string; why: string }
|
||||
type AutoresolveRow = { trigger: string; action: string }
|
||||
type ObservationsRow = { property: string; errors: string; obs: string }
|
||||
type RestRow = { endpoint: string; method: string; use: string }
|
||||
type WhereNextItem = { label: string; href: string; tail?: string; tailRich?: string }
|
||||
|
||||
export default async function HealthMonitorPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.healthMonitor" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { healthMonitor: {
|
||||
categories: { rows: CategoryRow[] }
|
||||
severity: { rows: SeverityRow[] }
|
||||
dashboardView: { items: string[] }
|
||||
dismiss: { rows: DismissRow[] }
|
||||
autoresolve: { rows: AutoresolveRow[] }
|
||||
observations: { rows: ObservationsRow[] }
|
||||
notification: { items: string[] }
|
||||
rest: { rows: RestRow[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } }
|
||||
}
|
||||
const hm = messages.docs.monitor.healthMonitor
|
||||
const categoryRows = hm.categories.rows
|
||||
const severityRows = hm.severity.rows
|
||||
const dashboardItems = hm.dashboardView.items
|
||||
const dismissRows = hm.dismiss.rows
|
||||
const autoresolveRows = hm.autoresolve.rows
|
||||
const observationsRows = hm.observations.rows
|
||||
const notificationItems = hm.notification.items
|
||||
const restRows = hm.rest.rows
|
||||
const whereNextItems = hm.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const notifLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/notifications" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const aiLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/ai-assistant" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={18}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code, strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howItWorks.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("howItWorks.intro", { strong })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("howItWorks.scannerTitle")}</h3>
|
||||
|
||||
<DataFlowDiagram
|
||||
nodes={[
|
||||
{ variant: "source", label: t("howItWorks.scannerNodes.samplerLabel"), detail: t("howItWorks.scannerNodes.samplerDetail") },
|
||||
{ variant: "bridge", label: t("howItWorks.scannerNodes.cycleLabel"), detail: t("howItWorks.scannerNodes.cycleDetail") },
|
||||
{ variant: "bridge", label: t("howItWorks.scannerNodes.checksLabel"), detail: t("howItWorks.scannerNodes.checksDetail") },
|
||||
{ variant: "target", label: t("howItWorks.scannerNodes.sqliteLabel"), detail: t("howItWorks.scannerNodes.sqliteDetail") },
|
||||
]}
|
||||
arrowLabel={t("howItWorks.scannerArrowLabel")}
|
||||
caption={t("howItWorks.scannerCaption")}
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-3 text-gray-900">{t("howItWorks.notifTitle")}</h3>
|
||||
|
||||
<DataFlowDiagram
|
||||
nodes={[
|
||||
{ variant: "source", label: t("howItWorks.notifNodes.errorsLabel"), detail: t("howItWorks.notifNodes.errorsDetail") },
|
||||
{ variant: "bridge", label: t("howItWorks.notifNodes.dispatcherLabel"), detail: t("howItWorks.notifNodes.dispatcherDetail") },
|
||||
{ variant: "bridge", label: t("howItWorks.notifNodes.templatesLabel"), detail: t("howItWorks.notifNodes.templatesDetail") },
|
||||
{ variant: "target", label: t("howItWorks.notifNodes.channelsLabel"), detail: t("howItWorks.notifNodes.channelsDetail") },
|
||||
]}
|
||||
arrowLabel={t("howItWorks.notifArrowLabel")}
|
||||
caption={t("howItWorks.notifCaption")}
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("categories.heading")}</h2>
|
||||
|
||||
<figure className="my-6">
|
||||
<Image
|
||||
src="/monitor/health-monitor.png"
|
||||
alt={t("categories.imageAlt")}
|
||||
width={1608}
|
||||
height={1752}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto mx-auto max-w-2xl"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("categories.imageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("categories.intro", { strong })}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("categories.headerCategory")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("categories.headerChecks")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("categories.headerEvents")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{categoryRows.map((row, idx) => (
|
||||
<tr key={row.category} className={idx < categoryRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.category}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`categories.rows.${idx}.checks`, { code })}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`categories.rows.${idx}.events`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("severity.heading")}</h2>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("severity.headerStatus")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("severity.headerColour")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("severity.headerMeaning")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("severity.headerNotification")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{severityRows.map((row, idx) => (
|
||||
<tr key={row.status} className={idx < severityRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.status}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{row.colour}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`severity.rows.${idx}.meaning`, { em })}</td>
|
||||
<td className="px-3 py-2 align-top">{row.notification}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("severity.infoNote", { strong })}
|
||||
</p>
|
||||
|
||||
<Callout variant="info" title={t("severity.unknownTitle")}>
|
||||
{t.rich("severity.unknownBody", { code, strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dashboardView.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("dashboardView.intro", { strong })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{dashboardItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`dashboardView.items.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Callout variant="tip" title={t("dashboardView.pillTitle")}>
|
||||
{t("dashboardView.pillBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 id="dismissing-alerts-and-the-suppression-duration" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24">{t("dismiss.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("dismiss.intro", { em })}
|
||||
</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
|
||||
<li>{t.rich("dismiss.step1", { strong, code })}</li>
|
||||
<li>
|
||||
{t.rich("dismiss.step2", { strong, code })}
|
||||
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{`24 hours (default)
|
||||
72 hours
|
||||
168 hours (one week)
|
||||
720 hours (one month)
|
||||
8760 hours (one year)
|
||||
-1 (permanent — never re-fires)
|
||||
<custom> (any positive integer of hours)`}</pre>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<figure className="my-6">
|
||||
<Image
|
||||
src="/monitor/dismiss-duration-dropdown.png"
|
||||
alt={t("dismiss.dropdownImageAlt")}
|
||||
width={1540}
|
||||
height={1072}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("dismiss.dropdownImageCaption", { em })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure className="my-6">
|
||||
<Image
|
||||
src="/monitor/health-suppression-settings.png"
|
||||
alt={t("dismiss.imageAlt")}
|
||||
width={2010}
|
||||
height={1816}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("dismiss.imageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("dismiss.outro", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("dismiss.activeSuppressionsTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("dismiss.activeSuppressionsBody", { strong, em })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("dismiss.autoTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("dismiss.autoBody", { strong })}
|
||||
</p>
|
||||
|
||||
<Callout variant="warning" title={t("dismiss.tempTitle")}>
|
||||
{t.rich("dismiss.tempBody", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("dismiss.nonDismissableTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("dismiss.nonDismissableBody")}</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dismiss.headerFinding")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dismiss.headerWhy")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dismissRows.map((row, idx) => (
|
||||
<tr key={row.finding} className={idx < dismissRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.finding}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{row.why}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("dismiss.principle")}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("autoresolve.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("autoresolve.intro")}</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("autoresolve.headerTrigger")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("autoresolve.headerAction")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{autoresolveRows.map((row, idx) => (
|
||||
<tr key={idx} className={idx < autoresolveRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`autoresolve.rows.${idx}.trigger`, { code })}</td>
|
||||
<td className="px-3 py-2 align-top">{row.action}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Callout variant="tip" title={t("autoresolve.permanentTitle")}>
|
||||
{t.rich("autoresolve.permanentBody", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("observations.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("observations.intro", { code, strong })}
|
||||
</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("observations.headerProperty")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t.rich("observations.headerErrors", { code })}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t.rich("observations.headerObs", { code })}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{observationsRows.map((row, idx) => (
|
||||
<tr key={row.property} className={idx < observationsRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.property}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`observations.rows.${idx}.errors`, { em, code, strong })}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`observations.rows.${idx}.obs`, { em, code, strong })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("observations.outro")}</p>
|
||||
|
||||
<Callout variant="warning" title={t("observations.renameTitle")}>
|
||||
{t.rich("observations.renameBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("notification.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("notification.intro")}</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{notificationItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`notification.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("notification.outro", { notifLink, aiLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("rest.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("rest.intro")}</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("rest.headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("rest.headerMethod")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("rest.headerUse")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{restRows.map((row, idx) => (
|
||||
<tr key={row.endpoint} className={idx < restRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
|
||||
<td className="px-3 py-2 align-top">{row.method}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`rest.rows.${idx}.use`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<CopyableCode
|
||||
code={`${t("rest.codeComment1")}
|
||||
curl -H "Authorization: Bearer <api-token>" \\
|
||||
http://<host>:8008/api/health/full | jq '.health.overall'
|
||||
|
||||
${t("rest.codeComment2")}
|
||||
curl -X POST http://<host>:8008/api/health/acknowledge \\
|
||||
-H "Authorization: Bearer <api-token>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"error_key":"smart_sdh"}'
|
||||
|
||||
${t("rest.codeComment3")}
|
||||
curl -X POST http://<host>:8008/api/health/settings \\
|
||||
-H "Authorization: Bearer <api-token>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"suppress_disks":"168"}'`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1164
web/app/[locale]/docs/monitor/integrations/page.tsx
Normal file
1164
web/app/[locale]/docs/monitor/integrations/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
769
web/app/[locale]/docs/monitor/notifications/page.tsx
Normal file
769
web/app/[locale]/docs/monitor/notifications/page.tsx
Normal file
@@ -0,0 +1,769 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { DataFlowDiagram } from "@/components/ui/data-flow-diagram"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.notifications.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox notifications",
|
||||
"proxmox telegram",
|
||||
"proxmox discord",
|
||||
"proxmox email alerts",
|
||||
"proxmox gotify",
|
||||
"proxmox apprise",
|
||||
"proxmox ntfy",
|
||||
"proxmox matrix notifications",
|
||||
"proxmox alerts",
|
||||
"proxmox notification webhook",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/monitor/notifications" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/monitor/notifications",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type SourceRow = { collector: string; watches: string; events: string }
|
||||
type DispatchRow = { stage: string; what: string; tunable: string }
|
||||
type CatalogueRow = { group: string; events: string }
|
||||
type ApiRow = { endpoint: string; method: string; use: string }
|
||||
type WhereNextItem = { label: string; href: string; tail?: string; tailRich?: string }
|
||||
|
||||
export default async function NotificationsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.notifications" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { notifications: {
|
||||
enabling: { steps: string[] }
|
||||
sources: { rows: SourceRow[] }
|
||||
telegram: {
|
||||
step1Items: string[]
|
||||
privateItems: string[]
|
||||
groupItems: string[]
|
||||
}
|
||||
discord: { items: string[] }
|
||||
gotify: { items: string[] }
|
||||
email: { gmailItems: string[]; outlookItems: string[] }
|
||||
apprise: { listItems: string[]; steps: string[] }
|
||||
rich: { togglesItems: string[] }
|
||||
quiet: { purposeItems: string[]; howItems: string[] }
|
||||
digest: {
|
||||
purposeItems: string[]
|
||||
howItems: string[]
|
||||
neverDelayedSub: string[]
|
||||
}
|
||||
dispatch: { rows: DispatchRow[] }
|
||||
pveWebhook: {
|
||||
registeredItems: string[]
|
||||
securityItems: string[]
|
||||
actionsItems: string[]
|
||||
}
|
||||
catalogue: { rows: CatalogueRow[] }
|
||||
api: { rows: ApiRow[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } }
|
||||
}
|
||||
const n = messages.docs.monitor.notifications
|
||||
const enablingSteps = n.enabling.steps
|
||||
const sourceRows = n.sources.rows
|
||||
const tgStep1 = n.telegram.step1Items
|
||||
const tgPriv = n.telegram.privateItems
|
||||
const tgGroup = n.telegram.groupItems
|
||||
const discordItems = n.discord.items
|
||||
const gotifyItems = n.gotify.items
|
||||
const gmailItems = n.email.gmailItems
|
||||
const outlookItems = n.email.outlookItems
|
||||
const appriseListItems = n.apprise.listItems
|
||||
const appriseSteps = n.apprise.steps
|
||||
const togglesItems = n.rich.togglesItems
|
||||
const quietPurpose = n.quiet.purposeItems
|
||||
const quietHow = n.quiet.howItems
|
||||
const digestPurpose = n.digest.purposeItems
|
||||
const digestHow = n.digest.howItems
|
||||
const digestNeverSub = n.digest.neverDelayedSub
|
||||
const dispatchRows = n.dispatch.rows
|
||||
const pveRegistered = n.pveWebhook.registeredItems
|
||||
const pveSecurity = n.pveWebhook.securityItems
|
||||
const pveActions = n.pveWebhook.actionsItems
|
||||
const catalogueRows = n.catalogue.rows
|
||||
const apiRows = n.api.rows
|
||||
const whereNextItems = n.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const hmLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/health-monitor" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const pveLink = (chunks: React.ReactNode) => (
|
||||
<a href="#pve-webhook-integration" className="text-blue-600 hover:underline">{chunks}</a>
|
||||
)
|
||||
const aiLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/ai-assistant#what-context-the-ai-receives" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const aiPageLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/ai-assistant" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const catalogueLink = (chunks: React.ReactNode) => (
|
||||
<Link href="#event-catalogue" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const quietLink = (chunks: React.ReactNode) => (
|
||||
<Link href="#quiet-hours" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const ext = (href: string) => (chunks: React.ReactNode) =>
|
||||
(
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={18}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { link: hmLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howItWorks.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("howItWorks.intro")}</p>
|
||||
|
||||
<DataFlowDiagram
|
||||
nodes={[
|
||||
{ variant: "source", label: t("howItWorks.nodes.sourcesLabel"), detail: t("howItWorks.nodes.sourcesDetail") },
|
||||
{ variant: "bridge", label: t("howItWorks.nodes.dispatchLabel"), detail: t("howItWorks.nodes.dispatchDetail") },
|
||||
{ variant: "bridge", label: t("howItWorks.nodes.aiLabel"), detail: t("howItWorks.nodes.aiDetail") },
|
||||
{ variant: "target", label: t("howItWorks.nodes.channelsLabel"), detail: t("howItWorks.nodes.channelsDetail") },
|
||||
]}
|
||||
arrowLabel={t("howItWorks.arrowLabel")}
|
||||
caption={t("howItWorks.caption")}
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("enabling.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("enabling.intro", { em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/notifications-disabled.png" alt={t("enabling.disabledAlt")} width={2000} height={552} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("enabling.disabledCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("enabling.stepsIntro")}</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{enablingSteps.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`enabling.steps.${idx}`, { em, code, pvelink: pveLink })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/notifications-active.png" alt={t("enabling.activeAlt")} width={2000} height={1142} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("enabling.activeCaption", { em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("sources.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("sources.intro", { code })}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("sources.headerCollector")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("sources.headerWatches")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("sources.headerEvents")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{sourceRows.map((row, idx) => (
|
||||
<tr key={row.collector} className={idx < sourceRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.collector}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`sources.rows.${idx}.watches`, { code, pvelink: pveLink })}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`sources.rows.${idx}.events`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("sources.after1", { code })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("sources.after2", { code, ailink: aiLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("channels.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("channels.intro", { em })}
|
||||
</p>
|
||||
|
||||
<Callout variant="warning" title={t("channels.credsTitle")}>
|
||||
{t.rich("channels.credsBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h3 id="telegram" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("telegram.heading")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("telegram.intro", { strong, em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/telegram-setup-guide.png" alt={t("telegram.guideAlt")} width={1923} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("telegram.guideCaption", { em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("telegram.step1Title")}</h4>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{tgStep1.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`telegram.step1Items.${idx}`, { em, code, a: ext("https://t.me/BotFather") })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("telegram.step2Title")}</h4>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("telegram.step2Intro", { em })}
|
||||
</p>
|
||||
|
||||
<p className="mb-2 text-gray-800 leading-relaxed">
|
||||
<strong>{t("telegram.privateLabel")}</strong>
|
||||
</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{tgPriv.map((_, idx) => (
|
||||
<li key={idx}>
|
||||
{t.rich(`telegram.privateItems.${idx}`, {
|
||||
code,
|
||||
a1: ext("https://t.me/userinfobot"),
|
||||
a2: ext("https://t.me/myidbot"),
|
||||
a: ext("https://t.me/userinfobot"),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/telegram-private-chat.png" alt={t("telegram.privateAlt")} width={2000} height={768} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("telegram.privateCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-2 text-gray-800 leading-relaxed">
|
||||
<strong>{t("telegram.groupLabel")}</strong>
|
||||
</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{tgGroup.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`telegram.groupItems.${idx}`, { code, em })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/telegram-group-chat.png" alt={t("telegram.groupAlt")} width={2000} height={768} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("telegram.groupCaption", { code, em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("telegram.step3Title")}</h4>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("telegram.step3Body", { em })}
|
||||
</p>
|
||||
|
||||
<h3 id="discord" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("discord.heading")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("discord.intro", { em })}
|
||||
</p>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{discordItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`discord.items.${idx}`, { em, code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/discord-channel.png" alt={t("discord.imageAlt")} width={2000} height={435} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("discord.imageCaption", { em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 id="gotify" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("gotify.heading")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("gotify.intro", { em })}
|
||||
</p>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{gotifyItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`gotify.items.${idx}`, { em, code, a: ext("https://gotify.net/docs/install") })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/gotify-channel.png" alt={t("gotify.imageAlt")} width={2000} height={573} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gotify.imageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 id="email" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("email.heading")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("email.intro")}</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/email-channel.png" alt={t("email.imageAlt")} width={2000} height={1248} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("email.imageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("email.appNote", { strong })}
|
||||
</p>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("email.gmailTitle")}</h4>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("email.gmailIntro", { strong, em })}
|
||||
</p>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{gmailItems.map((_, idx) => (
|
||||
<li key={idx}>
|
||||
{t.rich(`email.gmailItems.${idx}`, {
|
||||
em,
|
||||
code,
|
||||
a:
|
||||
idx === 0
|
||||
? ext("https://myaccount.google.com/security")
|
||||
: ext("https://myaccount.google.com/apppasswords"),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("email.outlookTitle")}</h4>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("email.outlookIntro", { strong })}
|
||||
</p>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{outlookItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`email.outlookItems.${idx}`, { em, code, a: ext("https://account.microsoft.com/security") })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<Callout variant="tip" title={t("email.relayTitle")}>
|
||||
{t("email.relayBody")}
|
||||
</Callout>
|
||||
|
||||
<h3 id="apprise" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("apprise.heading")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("apprise.intro")}</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("apprise.listIntro")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{appriseListItems.map((_, idx) => (
|
||||
<li key={idx}>
|
||||
{t.rich(`apprise.listItems.${idx}`, {
|
||||
a: idx === 0 ? ext("https://github.com/caronc/apprise/wiki") : ext("https://github.com/caronc/apprise/wiki/URLBasics"),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("apprise.stepsTitle")}</h4>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{appriseSteps.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`apprise.steps.${idx}`, { em, code, a: ext("https://github.com/caronc/apprise/wiki") })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<Callout variant="info" title={t("apprise.deliveredTitle")}>
|
||||
{t("apprise.deliveredBody")}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("apprise.fanoutTitle")}>
|
||||
{t.rich("apprise.fanoutBody", { a: ext("https://github.com/caronc/apprise-api") })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("rich.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("rich.intro", { em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/notification-categories.png" alt={t("rich.imageAlt")} width={2000} height={1668} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("rich.imageCaption", { em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 id="rich-messages" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("rich.richTitle")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("rich.richIntro", { em })}
|
||||
</p>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4 mb-6 not-prose">
|
||||
<div className="rounded-lg border-2 border-gray-200 bg-gray-50 p-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-700 mb-2">
|
||||
{t("rich.plainHeader")}
|
||||
</div>
|
||||
<pre className="text-sm font-mono text-gray-800 whitespace-pre-wrap leading-relaxed m-0">
|
||||
{`[INFO] vm_start
|
||||
VM 101 (homeassistant) started
|
||||
on node pve-01
|
||||
host: home-lab`}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="rounded-lg border-2 border-blue-300 bg-blue-50 p-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-blue-800 mb-2">
|
||||
{t("rich.richHeader")}
|
||||
</div>
|
||||
<pre className="text-sm font-mono text-gray-800 whitespace-pre-wrap leading-relaxed m-0">
|
||||
{`🟢 VM started
|
||||
VM 101 (homeassistant) is now
|
||||
running on node pve-01
|
||||
🏠 home-lab`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("rich.richOutro")}</p>
|
||||
|
||||
<h3 id="event-toggles" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("rich.togglesTitle")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("rich.togglesIntro")}</p>
|
||||
|
||||
<ol className="list-decimal pl-6 text-gray-800 leading-relaxed space-y-2 mb-4">
|
||||
{togglesItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`rich.togglesItems.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("rich.togglesOutro", { em, code })}
|
||||
</p>
|
||||
|
||||
<h2 id="quiet-hours" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("quiet.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("quiet.intro", { strong })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/quiet-hours-and-digest-config.png" alt={t("quiet.imageAlt")} width={1600} height={1200} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("quiet.imageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("quiet.purposeTitle")}</h3>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{quietPurpose.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`quiet.purposeItems.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("quiet.howTitle")}</h3>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{quietHow.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`quiet.howItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<Callout variant="info" title={t("quiet.criticalTitle")}>
|
||||
{t.rich("quiet.criticalBody", { link: catalogueLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 id="daily-digest" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("digest.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("digest.intro1", { strong })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("digest.intro2", { link: quietLink })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("digest.purposeTitle")}</h3>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{digestPurpose.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`digest.purposeItems.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("digest.howTitle")}</h3>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{digestHow.map((_, idx) => (
|
||||
<li key={idx}>
|
||||
{t.rich(`digest.howItems.${idx}`, { strong, em, code })}
|
||||
{idx === 3 && (
|
||||
<ul className="list-disc pl-6 mt-1">
|
||||
{digestNeverSub.map((_, sIdx) => (
|
||||
<li key={sIdx}>{t.rich(`digest.neverDelayedSub.${sIdx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<Callout variant="tip" title={t("digest.comboTitle")}>
|
||||
{t.rich("digest.comboBody", { em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("displayName.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("displayName.intro", { em, code })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/display-name.png" alt={t("displayName.imageAlt")} width={2000} height={256} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("displayName.imageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("displayName.outro", { em, code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dispatch.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("dispatch.intro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dispatch.headerStage")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dispatch.headerWhat")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dispatch.headerTunable")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dispatchRows.map((row, idx) => (
|
||||
<tr key={row.stage} className={idx < dispatchRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.stage}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`dispatch.rows.${idx}.what`, { code })}</td>
|
||||
<td className="px-3 py-2 align-top">{row.tunable}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("dispatch.calloutTitle")}>
|
||||
{t("dispatch.calloutBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("aiRewrite.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("aiRewrite.body1")}</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("aiRewrite.body2", { code, link: aiPageLink })}
|
||||
</p>
|
||||
|
||||
<Callout variant="warning" title={t("aiRewrite.privacyTitle")}>
|
||||
{t("aiRewrite.privacyBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 id="pve-webhook-integration" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("pveWebhook.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("pveWebhook.intro1", { em, code })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("pveWebhook.intro2", { em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/pve-webhook-target.png" alt={t("pveWebhook.imageAlt")} width={1452} height={1360} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto mx-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("pveWebhook.imageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("pveWebhook.registeredIntro")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{pveRegistered.map((_, idx) => (
|
||||
<li key={idx}>
|
||||
{t.rich(`pveWebhook.registeredItems.${idx}`, { strong, em, code })}
|
||||
{idx === 1 && (
|
||||
<code className="block mt-2 bg-gray-50 border border-gray-200 rounded p-2 text-xs whitespace-pre-wrap">{`{ "title": "{{ escape title }}",
|
||||
"message": "{{ escape message }}",
|
||||
"severity": "{{ severity }}",
|
||||
"timestamp": "{{ timestamp }}",
|
||||
"fields": {{ json fields }} }`}</code>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("pveWebhook.securityTitle")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("pveWebhook.securityIntro", { code })}
|
||||
</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{pveSecurity.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`pveWebhook.securityItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="info" title={t("pveWebhook.practiceTitle")}>
|
||||
{t.rich("pveWebhook.practiceBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("pveWebhook.actionsIntro")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{pveActions.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`pveWebhook.actionsItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="info" title={t("pveWebhook.clusterTitle")}>
|
||||
{t.rich("pveWebhook.clusterBody", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<h2 id="event-catalogue" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("catalogue.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("catalogue.intro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("catalogue.headerGroup")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("catalogue.headerEvents")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{catalogueRows.map((row, idx) => (
|
||||
<tr key={row.group} className={idx < catalogueRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.group}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`catalogue.rows.${idx}.events`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("catalogue.burstNote", { code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("history.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("history.body1", { em, code })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("history.body2", { em })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("history.body3", { code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("api.heading")}</h2>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("api.headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("api.headerMethod")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("api.headerUse")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{apiRows.map((row, idx) => (
|
||||
<tr key={row.endpoint} className={idx < apiRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
|
||||
<td className="px-3 py-2 align-top">{row.method}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`api.rows.${idx}.use`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<CopyableCode
|
||||
code={`# Send a test notification to Discord
|
||||
curl -X POST http://<host>:8008/api/notifications/test \\
|
||||
-H "Authorization: Bearer <api-token>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"channel":"discord"}'
|
||||
|
||||
# Emit a custom event from a script
|
||||
curl -X POST http://<host>:8008/api/notifications/send \\
|
||||
-H "Authorization: Bearer <api-token>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"event_type":"custom","severity":"warning","data":{"message":"Cron job took >10 min"}}'
|
||||
|
||||
# Pull the last 50 history entries for one channel
|
||||
curl -H "Authorization: Bearer <api-token>" \\
|
||||
'http://<host>:8008/api/notifications/history?channel=telegram&limit=50' | jq
|
||||
|
||||
# Test an AI provider connection (verifies the API key and model)
|
||||
curl -X POST http://<host>:8008/api/notifications/test-ai \\
|
||||
-H "Authorization: Bearer <api-token>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"provider":"openai","api_key":"sk-...","model":"gpt-4o-mini"}'`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
322
web/app/[locale]/docs/monitor/page.tsx
Normal file
322
web/app/[locale]/docs/monitor/page.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
import { routing } from "@/i18n/routing"
|
||||
|
||||
/**
|
||||
* Pilot page for the i18n migration. Pattern used here is the same one
|
||||
* contributors should follow for every other docs page:
|
||||
*
|
||||
* - Translatable strings live under `messages/<locale>/docs/<section>/<page>.json`
|
||||
* (or `index.json` when the file represents the section's index page,
|
||||
* like this one).
|
||||
* - The page is an async Server Component that calls
|
||||
* `getTranslations({ namespace: '<namespace>' })` once and uses
|
||||
* `t()` for plain text and `t.rich()` for paragraphs containing
|
||||
* <code>, <strong>, <em> or <link> markers.
|
||||
* - Arrays of structured items (table rows, lists, etc.) are pulled
|
||||
* with `getMessages({ locale })` and iterated; that keeps the JSON readable
|
||||
* for translators.
|
||||
* - generateMetadata uses the same namespace so <title> and OG tags
|
||||
* translate with the locale.
|
||||
*/
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }))
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox monitor",
|
||||
"proxmox dashboard",
|
||||
"proxmox ve dashboard",
|
||||
"proxmox web dashboard",
|
||||
"proxmox notifications",
|
||||
"proxmox health monitor",
|
||||
"proxmox smart monitoring",
|
||||
"proxmox prometheus",
|
||||
"proxmox homepage integration",
|
||||
"proxmenux monitor",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/monitor" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/monitor",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type CoverageSection = { name: string; description: string }
|
||||
type NextStepItem = { label: string; description: string }
|
||||
|
||||
export default async function MonitorOverviewPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor" })
|
||||
|
||||
// Arrays of objects can't be expressed via t(), so pull them straight
|
||||
// from the message tree. This is the recommended pattern in the
|
||||
// next-intl docs for repeating structured items like table rows.
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: {
|
||||
monitor: {
|
||||
coverage: { sections: CoverageSection[] }
|
||||
nextSteps: { items: NextStepItem[] }
|
||||
howItRuns: { bullets: string[] }
|
||||
}
|
||||
}
|
||||
}
|
||||
const coverageSections = messages.docs.monitor.coverage.sections
|
||||
const nextStepsItems = messages.docs.monitor.nextSteps.items
|
||||
const howItRunsBullets = messages.docs.monitor.howItRuns.bullets
|
||||
|
||||
// Inline tag renderers shared across every t.rich() call on this page.
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
// <link>...</link> defaults to "/docs/monitor" — the section index.
|
||||
// Individual t.rich() calls override this to point elsewhere when
|
||||
// the source string demands it (api section, etc.).
|
||||
const link = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={6}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("atGlance.title")}>
|
||||
{t.rich("atGlance.body", { code })}
|
||||
</Callout>
|
||||
|
||||
{/* Hero screenshot */}
|
||||
<figure className="my-8">
|
||||
<img
|
||||
src="/monitor/dashboard-home.png"
|
||||
alt={t("hero.alt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("hero.caption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
{/* ─────────────────────────── What it covers ─────────────────────────── */}
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("coverage.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("coverage.intro")}</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("coverage.tableSection")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("coverage.tableWhat")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{coverageSections.map((row, idx) => (
|
||||
<tr
|
||||
key={row.name}
|
||||
className={idx < coverageSections.length - 1 ? "border-b border-gray-100" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">
|
||||
<strong>{row.name}</strong>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t.rich(`coverage.sections.${idx}.description`, { code })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("coverage.footer", { link })}
|
||||
</p>
|
||||
|
||||
{/* ─────────────────────────── How it runs ─────────────────────────── */}
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howItRuns.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("howItRuns.intro", { code })}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{howItRunsBullets.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`howItRuns.bullets.${idx}`, { code, strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("howItRuns.footer", { link })}
|
||||
</p>
|
||||
|
||||
<Callout variant="tip" title={t("noAgent.title")}>
|
||||
{t("noAgent.body")}
|
||||
</Callout>
|
||||
|
||||
{/* ─────────────────────────── Access ─────────────────────────── */}
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("access.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("access.intro")}</p>
|
||||
<CopyableCode
|
||||
code={`${t("access.codeComment1")}
|
||||
http://<your-proxmox-ip>:8008
|
||||
|
||||
${t("access.codeComment2")}
|
||||
https://<your-domain>/proxmenux-monitor/`}
|
||||
className="my-4"
|
||||
/>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("access.afterCode", { code })}
|
||||
</p>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("access.footer", { link })}
|
||||
</p>
|
||||
|
||||
{/* ─────────────────────────── Mobile / PWA ─────────────────────────── */}
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("mobile.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("mobile.intro", { code })}</p>
|
||||
<div className="grid md:grid-cols-2 gap-6 my-6 items-start">
|
||||
<figure>
|
||||
<img
|
||||
src="/monitor/mobile-home.png"
|
||||
alt={t("mobile.phoneAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("mobile.phoneCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2 text-gray-900">{t("mobile.addHeading")}</h3>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
<li>
|
||||
<strong>{t("mobile.iosLabel")}</strong> {t.rich("mobile.iosBody", { code, em })}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{t("mobile.androidLabel")}</strong>{" "}
|
||||
{t.rich("mobile.androidBody", { em })}
|
||||
</li>
|
||||
<li>{t("mobile.afterInstall")}</li>
|
||||
</ul>
|
||||
<Callout variant="warning" title={t("mobile.onlineOnlyTitle")} className="mt-4">
|
||||
{t.rich("mobile.onlineOnlyBody", { strong })}
|
||||
</Callout>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─────────────────────────── Health Monitor ─────────────────────────── */}
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("health.heading")}</h2>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/health-monitor.png"
|
||||
alt={t("health.alt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("health.caption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("health.body1", { code, strong })}
|
||||
</p>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("health.feedsIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
<li>{t.rich("health.feedsHealth", { strong })}</li>
|
||||
<li>{t.rich("health.feedsChannels", { strong })}</li>
|
||||
<li>{t.rich("health.feedsAI", { strong })}</li>
|
||||
</ul>
|
||||
<Callout variant="tip" title={t("health.suppressionTitle")}>
|
||||
{t.rich("health.suppressionBody", { em })}
|
||||
</Callout>
|
||||
|
||||
{/* ─────────────────────────── API & integrations ─────────────────────────── */}
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("api.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("api.intro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
<li>{t.rich("api.tokens", { code, strong })}</li>
|
||||
<li>{t.rich("api.bearer", { code })}</li>
|
||||
<li>
|
||||
{t.rich("api.catalog", {
|
||||
linkApi: (chunks) => (
|
||||
<Link href="/docs/monitor" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
),
|
||||
linkIntegrations: (chunks) => (
|
||||
<Link href="/docs/monitor" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
),
|
||||
})}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* ─────────────────────────── Service control ─────────────────────────── */}
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("serviceControl.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("serviceControl.intro", { em })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`${t("serviceControl.codeComment")}
|
||||
systemctl status proxmenux-monitor.service
|
||||
systemctl is-active proxmenux-monitor.service
|
||||
systemctl enable --now proxmenux-monitor.service
|
||||
systemctl disable --now proxmenux-monitor.service
|
||||
journalctl -u proxmenux-monitor.service -f`}
|
||||
className="my-4"
|
||||
/>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("serviceControl.footer", {
|
||||
link: (chunks) => (
|
||||
<Link href="/docs/settings/proxmenux-monitor" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
|
||||
{/* ─────────────────────────── Where to next ─────────────────────────── */}
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("nextSteps.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{nextStepsItems.map((item) => (
|
||||
<li key={item.label}>
|
||||
<Link href="/docs/monitor" className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>{" "}
|
||||
{item.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
167
web/app/[locale]/docs/network/backup-restore/page.tsx
Normal file
167
web/app/[locale]/docs/network/backup-restore/page.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.network.backupRestore.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/network/backup-restore",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Step = { title: string; body: string; tone: "blue" | "amber" | "emerald" }
|
||||
type RelatedItem = { label: string; href: string; tail?: string; tailRich?: string }
|
||||
|
||||
const TONE_CLASSES: Record<string, string> = {
|
||||
blue: "border-blue-400 bg-blue-50",
|
||||
amber: "border-amber-400 bg-amber-50",
|
||||
emerald: "border-emerald-400 bg-emerald-50",
|
||||
}
|
||||
|
||||
export default async function BackupRestorePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.network.backupRestore" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { network: { backupRestore: {
|
||||
restore: { steps: Step[] }
|
||||
restart: { warnItems: string[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const restoreSteps = messages.docs.network.backupRestore.restore.steps
|
||||
const warnItems = messages.docs.network.backupRestore.restart.warnItems
|
||||
const relatedItems = messages.docs.network.backupRestore.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const bridgeLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/network/bridge-analysis" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const configLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/network/config-analysis" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={4}
|
||||
scriptPath="menus/network_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("shared.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("shared.intro")}</p>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`/var/backups/proxmenux/
|
||||
├── interfaces_backup_2026-04-26_14-30-12 ← from a guided repair
|
||||
├── interfaces_backup_2026-04-26_15-08-44 ← from "Create Network Backup" (this page)
|
||||
├── interfaces_backup_2026-04-26_18-22-09 ← auto-taken before a restore
|
||||
└── …`}</pre>
|
||||
<p className="mt-4 mb-6 text-gray-800 leading-relaxed">{t("shared.outro")}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("show.heading")}</h2>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("show.body", { code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("create.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("create.body", { code })}
|
||||
</p>
|
||||
<Callout variant="tip" title={t("create.whenTitle")}>
|
||||
{t.rich("create.whenBody", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("restore.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("restore.intro", { code })}
|
||||
</p>
|
||||
<div className="space-y-4 mb-6">
|
||||
{restoreSteps.map((step, idx) => (
|
||||
<div key={idx} className={`border-l-4 ${TONE_CLASSES[step.tone]} p-4 rounded-r-md`}>
|
||||
<div className="font-semibold text-gray-900 mb-1">{step.title}</div>
|
||||
<p className="text-sm text-gray-800 m-0">{t.rich(`restore.steps.${idx}.body`, { code, strong })}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Callout variant="warning" title={t("restore.autoBackupTitle")}>
|
||||
{t.rich("restore.autoBackupBody", { em, code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("restart.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("restart.body", { code })}
|
||||
</p>
|
||||
<Callout variant="danger" title={t("restart.warnTitle")}>
|
||||
{t.rich("restart.warnBody", { code })}
|
||||
<ul className="list-disc pl-6 mt-2 mb-0 space-y-1">
|
||||
{warnItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`restart.warnItems.${idx}`, { em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manualRollback.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("manualRollback.intro")}</p>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`ls -lt /var/backups/proxmenux/interfaces_backup_* # newest first
|
||||
cp /var/backups/proxmenux/interfaces_backup_<TIMESTAMP> /etc/network/interfaces
|
||||
systemctl restart networking`}</pre>
|
||||
<p className="mt-4 mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("manualRollback.outro", { em, code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.noneTitle")}>
|
||||
{t.rich("troubleshoot.noneBody", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.unreachTitle")}>
|
||||
{t.rich("troubleshoot.unreachBody", { bridgeLink, configLink })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.emptyTitle")}>
|
||||
{t.rich("troubleshoot.emptyBody", { em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`related.items.${idx}.tailRich`, { code }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
224
web/app/[locale]/docs/network/bridge-analysis/page.tsx
Normal file
224
web/app/[locale]/docs/network/bridge-analysis/page.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { DataFlowDiagram } from "@/components/ui/data-flow-diagram"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.network.bridgeAnalysis.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/network/bridge-analysis",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Step = { title: string; body: string; tone: "blue" | "amber" | "emerald" }
|
||||
type RelatedItem = { label: string; href: string; tail?: string; tailRich?: string }
|
||||
|
||||
const TONE_CLASSES: Record<string, string> = {
|
||||
blue: "border-blue-400 bg-blue-50",
|
||||
amber: "border-amber-400 bg-amber-50",
|
||||
emerald: "border-emerald-400 bg-emerald-50",
|
||||
}
|
||||
|
||||
export default async function BridgeAnalysisPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.network.bridgeAnalysis" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { network: { bridgeAnalysis: {
|
||||
when: { items: string[] }
|
||||
step1: { items: string[] }
|
||||
step2: { steps: Step[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const whenItems = messages.docs.network.bridgeAnalysis.when.items
|
||||
const step1Items = messages.docs.network.bridgeAnalysis.step1.items
|
||||
const repairSteps = messages.docs.network.bridgeAnalysis.step2.steps
|
||||
const relatedItems = messages.docs.network.bridgeAnalysis.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const persistentLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/network/persistent-names" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const configLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/network/config-analysis" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={6}
|
||||
scriptPath="menus/network_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code, strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("when.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("when.intro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whenItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`when.items.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("when.outro", { link: persistentLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("bigPicture.heading")}</h2>
|
||||
|
||||
<DataFlowDiagram
|
||||
nodes={[
|
||||
{
|
||||
label: t("bigPicture.diagram1.nodes.sourceLabel"),
|
||||
detail: t("bigPicture.diagram1.nodes.sourceDetail"),
|
||||
variant: "source",
|
||||
},
|
||||
{
|
||||
label: t("bigPicture.diagram1.nodes.bridgeLabel"),
|
||||
detail: t("bigPicture.diagram1.nodes.bridgeDetail"),
|
||||
variant: "bridge",
|
||||
},
|
||||
{
|
||||
label: t("bigPicture.diagram1.nodes.targetLabel"),
|
||||
detail: t("bigPicture.diagram1.nodes.targetDetail"),
|
||||
variant: "target",
|
||||
},
|
||||
]}
|
||||
arrowLabel={t("bigPicture.diagram1.arrowLabel")}
|
||||
/>
|
||||
|
||||
<DataFlowDiagram
|
||||
nodes={[
|
||||
{
|
||||
label: t("bigPicture.diagram2.nodes.sourceLabel"),
|
||||
detail: t("bigPicture.diagram2.nodes.sourceDetail"),
|
||||
variant: "source",
|
||||
},
|
||||
{
|
||||
label: t("bigPicture.diagram2.nodes.targetLabel"),
|
||||
detail: t("bigPicture.diagram2.nodes.targetDetail"),
|
||||
variant: "target",
|
||||
},
|
||||
]}
|
||||
arrowLabel={t("bigPicture.diagram2.arrowLabel")}
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("step1.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("step1.intro", { strong })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{step1Items.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`step1.items.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`🔍 BRIDGE CONFIGURATION ANALYSIS
|
||||
==================================================
|
||||
|
||||
🌉 Bridge: vmbr0
|
||||
📍 Status: DOWN
|
||||
🌐 IP: No IP assigned
|
||||
🔌 Configured Ports: enp3s0
|
||||
❌ Port enp3s0: NOT FOUND
|
||||
|
||||
🔧 SUGGESTION FOR vmbr0:
|
||||
Replace invalid port(s) 'enp3s0' with: eno1
|
||||
Command: sed -i 's/bridge-ports.*/bridge-ports eno1/' /etc/network/interfaces
|
||||
|
||||
📊 ANALYSIS SUMMARY
|
||||
=========================
|
||||
Bridges analyzed: 1
|
||||
Issues found: 1
|
||||
Physical interfaces available: 2
|
||||
|
||||
⚠️ IMPORTANT: No changes have been made to your system
|
||||
Use the Guided Repair option to fix issues safely`}</pre>
|
||||
|
||||
<Callout variant="success" title={t("step1.readonlyTitle")}>
|
||||
{t.rich("step1.readonlyBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("step2.heading")}</h2>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t("step2.intro")}</p>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
{repairSteps.map((step, idx) => (
|
||||
<div key={idx} className={`border-l-4 ${TONE_CLASSES[step.tone]} p-4 rounded-r-md`}>
|
||||
<div className="font-semibold text-gray-900 mb-1">{step.title}</div>
|
||||
<p className="text-sm text-gray-800 m-0">{t.rich(`step2.steps.${idx}.body`, { code, em, strong })}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Callout variant="warning" title={t("step2.restartTitle")}>
|
||||
{t.rich("step2.restartBody", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("edits.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("edits.body", { code, link: configLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.unsupportedTitle")}>
|
||||
{t.rich("troubleshoot.unsupportedBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.noSuggestTitle")}>
|
||||
{t.rich("troubleshoot.noSuggestBody", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.stillDownTitle")}>
|
||||
{t.rich("troubleshoot.stillDownBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.lostSshTitle")}>
|
||||
{t("troubleshoot.lostSshIntro")}
|
||||
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{`cp /var/backups/proxmenux/interfaces_backup_<TIMESTAMP> /etc/network/interfaces
|
||||
systemctl restart networking`}</pre>
|
||||
{t("troubleshoot.lostSshOutro")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`related.items.${idx}.tailRich`, { code }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
201
web/app/[locale]/docs/network/config-analysis/page.tsx
Normal file
201
web/app/[locale]/docs/network/config-analysis/page.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.network.configAnalysis.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/network/config-analysis",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type DiffersRow = { aspect: string; bridge: string; config: string }
|
||||
type Step = { title: string; body: string; tone: "blue" | "amber" | "emerald" }
|
||||
type RelatedItem = { label: string; href: string; tail?: string; tailRich?: string }
|
||||
|
||||
const TONE_CLASSES: Record<string, string> = {
|
||||
blue: "border-blue-400 bg-blue-50",
|
||||
amber: "border-amber-400 bg-amber-50",
|
||||
emerald: "border-emerald-400 bg-emerald-50",
|
||||
}
|
||||
|
||||
export default async function ConfigAnalysisPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.network.configAnalysis" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { network: { configAnalysis: {
|
||||
differs: { rows: DiffersRow[] }
|
||||
step2: { steps: Step[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const differsRows = messages.docs.network.configAnalysis.differs.rows
|
||||
const cleanupSteps = messages.docs.network.configAnalysis.step2.steps
|
||||
const relatedItems = messages.docs.network.configAnalysis.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const bridgeLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/network/bridge-analysis" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const persistentLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/network/persistent-names" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const link = bridgeLink
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={5}
|
||||
scriptPath="menus/network_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("differs.heading")}</h2>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("differs.headerAspect")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("differs.headerBridge")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("differs.headerConfig")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{differsRows.map((row, idx) => (
|
||||
<tr key={idx} className={idx < differsRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">
|
||||
<strong>{t.rich(`differs.rows.${idx}.aspect`, { code })}</strong>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`differs.rows.${idx}.bridge`, { code })}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`differs.rows.${idx}.config`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("differs.outro", { strong, link })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("step1.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("step1.intro")}</p>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`🔍 NETWORK CONFIGURATION ANALYSIS
|
||||
==================================================
|
||||
|
||||
📋 CONFIGURED INTERFACES
|
||||
==============================
|
||||
🔌 Interface: enp3s0
|
||||
❌ Status: NOT FOUND
|
||||
⚠️ Issue: Configured but doesn't exist
|
||||
|
||||
🔌 Interface: eno1
|
||||
✅ Status: EXISTS (UP)
|
||||
🌐 IP: 192.168.1.10/24
|
||||
ℹ️ Type: Physical interface
|
||||
|
||||
🔌 Interface: vmbr0
|
||||
✅ Status: EXISTS (UP)
|
||||
🌐 IP: 192.168.1.10/24
|
||||
ℹ️ Type: Virtual interface (normal)
|
||||
|
||||
🔧 SUGGESTION FOR enp3s0:
|
||||
This interface is configured but doesn't exist physically
|
||||
Consider removing its configuration
|
||||
Command: sed -i '/iface enp3s0/,/^$/d' /etc/network/interfaces
|
||||
|
||||
📊 ANALYSIS SUMMARY
|
||||
=========================
|
||||
Interfaces configured: 3
|
||||
Issues found: 1
|
||||
|
||||
⚠️ IMPORTANT: No changes have been made to your system
|
||||
Use the Guided Cleanup option to fix issues safely`}</pre>
|
||||
|
||||
<Callout variant="success" title={t("step1.virtTitle")}>
|
||||
{t.rich("step1.virtBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("step2.heading")}</h2>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t("step2.intro")}</p>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
{cleanupSteps.map((step, idx) => (
|
||||
<div key={idx} className={`border-l-4 ${TONE_CLASSES[step.tone]} p-4 rounded-r-md`}>
|
||||
<div className="font-semibold text-gray-900 mb-1">{step.title}</div>
|
||||
<p className="text-sm text-gray-800 m-0">{t.rich(`step2.steps.${idx}.body`, { code, em, strong })}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Callout variant="warning" title={t("step2.noRestartTitle")}>
|
||||
{t.rich("step2.noRestartBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("caveats.heading")}</h2>
|
||||
|
||||
<Callout variant="warning" title={t("caveats.boundaryTitle")}>
|
||||
{t.rich("caveats.boundaryBody", { code, strong })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="info" title={t("caveats.tandemTitle")}>
|
||||
{t.rich("caveats.tandemBody", { code, link: bridgeLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.notFoundTitle")}>
|
||||
{t.rich("troubleshoot.notFoundBody", { code, link: persistentLink })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.tooMuchTitle")}>
|
||||
{t("troubleshoot.tooMuchBody")}
|
||||
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{`cp /var/backups/proxmenux/interfaces_backup_<TIMESTAMP> /etc/network/interfaces`}</pre>
|
||||
{t("troubleshoot.tooMuchOutro")}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.bridgeBreakTitle")}>
|
||||
{t.rich("troubleshoot.bridgeBreakBody", { code, link: bridgeLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
148
web/app/[locale]/docs/network/diagnostics/page.tsx
Normal file
148
web/app/[locale]/docs/network/diagnostics/page.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.network.diagnostics.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/network/diagnostics",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type ConnRow = { test: string; target: string; confirms: string }
|
||||
type RelatedItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function NetworkDiagnosticsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.network.diagnostics" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { network: { diagnostics: {
|
||||
connectivity: { rows: ConnRow[] }
|
||||
advanced: { items: string[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const connRows = messages.docs.network.diagnostics.connectivity.rows
|
||||
const advancedItems = messages.docs.network.diagnostics.advanced.items
|
||||
const relatedItems = messages.docs.network.diagnostics.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const monitoringLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/network/monitoring" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const backupLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/network/backup-restore" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={3}
|
||||
scriptPath="menus/network_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="success" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { strong, monitoringLink, backupLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("routing.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("routing.body", { code })}
|
||||
</p>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`Total routes: 4
|
||||
|
||||
➡ default via 192.168.1.1 dev vmbr0 onlink
|
||||
• 10.10.10.0/24 dev vmbr1 proto kernel scope link src 10.10.10.1
|
||||
• 169.254.0.0/16 dev vmbr0 scope link metric 1000
|
||||
• 192.168.1.0/24 dev vmbr0 proto kernel scope link src 192.168.1.10
|
||||
|
||||
🌍 Default Gateway: 192.168.1.1`}</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("connectivity.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("connectivity.intro", { code })}
|
||||
</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("connectivity.headerTest")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("connectivity.headerTarget")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("connectivity.headerConfirms")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{connRows.map((row, idx) => (
|
||||
<tr key={row.test} className={idx < connRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.test}</strong></td>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap font-mono">{row.target}</td>
|
||||
<td className="px-3 py-2 align-top">{row.confirms}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Callout variant="info" title={t("connectivity.readingTitle")}>
|
||||
{t.rich("connectivity.readingBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("advanced.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("advanced.intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
|
||||
{advancedItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`advanced.items.${idx}`, { strong, code, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Callout variant="warning" title={t("advanced.nmTitle")}>
|
||||
{t.rich("advanced.nmBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.gwTitle")}>
|
||||
{t.rich("troubleshoot.gwBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.dupTitle")}>
|
||||
{t.rich("troubleshoot.dupBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
238
web/app/[locale]/docs/network/monitoring/page.tsx
Normal file
238
web/app/[locale]/docs/network/monitoring/page.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.network.monitoring.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/network/monitoring",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type WhenRow = { question: string; use: string }
|
||||
type IptrafRow = { mode: string; useFor: string }
|
||||
type IperfRow = { mode: string; behaviour: string; cli: string }
|
||||
type RelatedItem = { label: string; href: string; tail?: string; tailRich?: string }
|
||||
|
||||
export default async function NetworkMonitoringPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.network.monitoring" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { network: { monitoring: {
|
||||
when: { rows: WhenRow[] }
|
||||
iptraf: { rows: IptrafRow[] }
|
||||
iperf3: { rows: IperfRow[]; workflow: string[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const whenRows = messages.docs.network.monitoring.when.rows
|
||||
const iptrafRows = messages.docs.network.monitoring.iptraf.rows
|
||||
const iperfRows = messages.docs.network.monitoring.iperf3.rows
|
||||
const workflow = messages.docs.network.monitoring.iperf3.workflow
|
||||
const relatedItems = messages.docs.network.monitoring.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const kbd = (chunks: React.ReactNode) => <kbd>{chunks}</kbd>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={4}
|
||||
scriptPath="menus/network_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("when.heading")}</h2>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("when.headerQuestion")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("when.headerUse")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{whenRows.map((row, idx) => (
|
||||
<tr key={idx} className={idx < whenRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`when.rows.${idx}.question`, { em })}</td>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.use}</strong></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("iftop.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("iftop.body", { code, em })}
|
||||
</p>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`12.5Kb 25.0Kb 37.5Kb 50.0Kb 62.5Kb
|
||||
└─────────────┴─────────────┴─────────────┴──────────────┴──────────
|
||||
proxmox.lan => 192.168.1.50 14.2Kb 9.8Kb 7.1Kb
|
||||
<= 2.3Kb 1.7Kb 1.4Kb
|
||||
proxmox.lan => 1.1.1.1 0b 145b 38b
|
||||
<= 128b 96b 24b
|
||||
─────────────────────────────────────────────────────────────────
|
||||
TX: 14.2Kb 9.9Kb 7.1Kb
|
||||
RX: 2.4Kb 1.8Kb 1.4Kb
|
||||
TOTAL: 16.6Kb 11.7Kb 8.5Kb`}</pre>
|
||||
<p className="mt-4 mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("iftop.exit", { strong, kbd })}
|
||||
</p>
|
||||
<Callout variant="tip" title={t("iftop.keysTitle")}>
|
||||
{t.rich("iftop.keysBody", { code, kbd })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("iptraf.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("iptraf.intro", { em })}
|
||||
</p>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("iptraf.menuIntro")}</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("iptraf.headerMode")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("iptraf.headerUseFor")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{iptrafRows.map((row, idx) => (
|
||||
<tr key={idx} className={idx < iptrafRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.mode}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{row.useFor}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("iptraf.exit", { strong, kbd })}
|
||||
</p>
|
||||
<Callout variant="tip" title={t("iptraf.logTitle")}>
|
||||
{t.rich("iptraf.logBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("iperf3.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("iperf3.intro1", { em })}
|
||||
</p>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("iperf3.intro2", { strong })}
|
||||
</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("iperf3.headerMode")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("iperf3.headerBehaviour")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("iperf3.headerCli")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{iperfRows.map((row, idx) => (
|
||||
<tr key={idx} className={idx < iperfRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.mode}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{row.behaviour}</td>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.cli}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("iperf3.workflowIntro")}</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{workflow.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`iperf3.workflow.${idx}`, { em, strong })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("iperf3.sample")}</p>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`Connecting to host 10.0.0.10, port 5201
|
||||
[ 5] local 10.0.0.20 port 53994 connected to 10.0.0.10 port 5201
|
||||
[ ID] Interval Transfer Bitrate Retr Cwnd
|
||||
[ 5] 0.00-1.00 sec 1.10 GBytes 9.45 Gbits/sec 0 1.55 MBytes
|
||||
[ 5] 1.00-2.00 sec 1.10 GBytes 9.45 Gbits/sec 0 1.55 MBytes
|
||||
[ 5] 2.00-3.00 sec 1.10 GBytes 9.45 Gbits/sec 0 1.55 MBytes
|
||||
...
|
||||
- - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
[ ID] Interval Transfer Bitrate Retr
|
||||
[ 5] 0.00-10.00 sec 11.0 GBytes 9.45 Gbits/sec 0 sender
|
||||
[ 5] 0.00-10.00 sec 11.0 GBytes 9.44 Gbits/sec receiver
|
||||
|
||||
iperf Done.`}</pre>
|
||||
|
||||
<Callout variant="tip" title={t("iperf3.flagsTitle")}>
|
||||
{t.rich("iperf3.flagsBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="warning" title={t("iperf3.firewallTitle")}>
|
||||
{t.rich("iperf3.firewallBody", { code, strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("install.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("install.body", { code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.hangTitle")}>
|
||||
{t.rich("troubleshoot.hangBody", { code, kbd })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.refusedTitle")}>
|
||||
{t.rich("troubleshoot.refusedBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.slowTitle")}>
|
||||
{t.rich("troubleshoot.slowBody", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.noTrafficTitle")}>
|
||||
{t.rich("troubleshoot.noTrafficBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
293
web/app/[locale]/docs/network/page.tsx
Normal file
293
web/app/[locale]/docs/network/page.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import {
|
||||
ArrowRight,
|
||||
Activity,
|
||||
LineChart,
|
||||
Wrench,
|
||||
ListChecks,
|
||||
Tag,
|
||||
Archive,
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
Eye,
|
||||
} from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.network.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox network management",
|
||||
"proxmox bridge configuration",
|
||||
"proxmox bond",
|
||||
"proxmox vlan",
|
||||
"proxmox network repair",
|
||||
"proxmox /etc/network/interfaces",
|
||||
"proxmox network diagnostics",
|
||||
"proxmox persistent interface names",
|
||||
"proxmox network backup",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/network" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/network",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type SectionOption = { title: string; description: string }
|
||||
|
||||
const READ_ONLY_CONFIG = [
|
||||
{ Icon: Activity, href: "/docs/network/diagnostics" },
|
||||
{ Icon: LineChart, href: "/docs/network/monitoring" },
|
||||
]
|
||||
const ANALYZE_CONFIG = [
|
||||
{ Icon: Wrench, href: "/docs/network/bridge-analysis" },
|
||||
{ Icon: ListChecks, href: "/docs/network/config-analysis" },
|
||||
]
|
||||
const APPLY_CONFIG = [
|
||||
{ Icon: Tag, href: "/docs/network/persistent-names" },
|
||||
{ Icon: Archive, href: "/docs/network/backup-restore" },
|
||||
]
|
||||
|
||||
function OptionCard({
|
||||
title,
|
||||
description,
|
||||
Icon,
|
||||
href,
|
||||
}: SectionOption & {
|
||||
Icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>
|
||||
href: string
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="group flex items-start gap-3 rounded-md border border-gray-200 bg-white p-3 transition-colors hover:border-blue-400 hover:bg-blue-50"
|
||||
>
|
||||
<span className="inline-flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-gray-100 text-gray-600 group-hover:bg-blue-100 group-hover:text-blue-700">
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1 text-sm font-medium text-gray-900 group-hover:text-blue-700">
|
||||
{title}
|
||||
<ArrowRight className="h-3.5 w-3.5 text-gray-400 group-hover:text-blue-600 transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-gray-600 leading-snug">{description}</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function NetworkOverviewPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.network" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { network: {
|
||||
tiers: {
|
||||
readOnly: { items: string[] }
|
||||
analyze: { items: string[] }
|
||||
apply: { items: string[] }
|
||||
}
|
||||
readOnlySection: { options: SectionOption[] }
|
||||
analyzeSection: { options: SectionOption[] }
|
||||
applySection: { options: SectionOption[] }
|
||||
} }
|
||||
}
|
||||
const readOnlyTierItems = messages.docs.network.tiers.readOnly.items
|
||||
const analyzeTierItems = messages.docs.network.tiers.analyze.items
|
||||
const applyTierItems = messages.docs.network.tiers.apply.items
|
||||
const readOnlyOptions = messages.docs.network.readOnlySection.options
|
||||
const analyzeOptions = messages.docs.network.analyzeSection.options
|
||||
const applyOptions = messages.docs.network.applySection.options
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={5}
|
||||
scriptPath="menus/network_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("openingMenu.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("openingMenu.intro", { strong })}
|
||||
</p>
|
||||
|
||||
<Image
|
||||
src="/network/network-menu.png"
|
||||
alt={t("openingMenu.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("safety.heading")}</h2>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t("safety.body")}</p>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-3 mb-8 not-prose">
|
||||
<a
|
||||
href="#read-only"
|
||||
className="rounded-lg border-2 border-emerald-300 bg-emerald-50 p-5 flex flex-col transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
|
||||
<Eye className="h-5 w-5" aria-hidden />
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900 m-0">{t("tiers.readOnly.title")}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-800 mb-3">{t("tiers.readOnly.body")}</p>
|
||||
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
|
||||
{readOnlyTierItems.map((item, idx) => (
|
||||
<li key={idx}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#analyze"
|
||||
className="rounded-lg border-2 border-amber-300 bg-amber-50 p-5 flex flex-col transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-amber-100 text-amber-700">
|
||||
<ShieldAlert className="h-5 w-5" aria-hidden />
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900 m-0">{t("tiers.analyze.title")}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-800 mb-3">{t("tiers.analyze.body")}</p>
|
||||
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
|
||||
{analyzeTierItems.map((item, idx) => (
|
||||
<li key={idx}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#apply"
|
||||
className="rounded-lg border-2 border-red-300 bg-red-50 p-5 flex flex-col transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-red-100 text-red-700">
|
||||
<ShieldCheck className="h-5 w-5" aria-hidden />
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900 m-0">{t("tiers.apply.title")}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-800 mb-3">{t("tiers.apply.body")}</p>
|
||||
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
|
||||
{applyTierItems.map((item, idx) => (
|
||||
<li key={idx}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Callout variant="warning" title={t("classicTitle")}>
|
||||
{t.rich("classicBody", { strong, code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("backups.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("backups.intro", { code })}
|
||||
</p>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`/var/backups/proxmenux/
|
||||
├── interfaces_backup_2026-04-26_14-30-12
|
||||
├── interfaces_backup_2026-04-26_15-08-44
|
||||
└── interfaces_backup_2026-04-26_18-22-09`}</pre>
|
||||
<p className="mt-4 mb-4 text-gray-800 leading-relaxed">{t("backups.rollbackIntro")}</p>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`cp /var/backups/proxmenux/interfaces_backup_<TIMESTAMP> /etc/network/interfaces
|
||||
systemctl restart networking`}</pre>
|
||||
|
||||
<h2 id="read-only" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24">
|
||||
{t("readOnlySection.heading")}
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("readOnlySection.body", { code })}
|
||||
</p>
|
||||
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
|
||||
{readOnlyOptions.map((o, idx) => (
|
||||
<OptionCard
|
||||
key={READ_ONLY_CONFIG[idx].href}
|
||||
title={o.title}
|
||||
description={o.description}
|
||||
Icon={READ_ONLY_CONFIG[idx].Icon}
|
||||
href={READ_ONLY_CONFIG[idx].href}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 id="analyze" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24">
|
||||
{t("analyzeSection.heading")}
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("analyzeSection.body", { code, strong })}
|
||||
</p>
|
||||
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
|
||||
{analyzeOptions.map((o, idx) => (
|
||||
<OptionCard
|
||||
key={ANALYZE_CONFIG[idx].href}
|
||||
title={o.title}
|
||||
description={o.description}
|
||||
Icon={ANALYZE_CONFIG[idx].Icon}
|
||||
href={ANALYZE_CONFIG[idx].href}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 id="apply" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24">
|
||||
{t("applySection.heading")}
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("applySection.body", { em })}
|
||||
</p>
|
||||
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
|
||||
{applyOptions.map((o, idx) => (
|
||||
<OptionCard
|
||||
key={APPLY_CONFIG[idx].href}
|
||||
title={o.title}
|
||||
description={o.description}
|
||||
Icon={APPLY_CONFIG[idx].Icon}
|
||||
href={APPLY_CONFIG[idx].href}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("consoleTitle")}</h2>
|
||||
<Callout variant="danger" title={t("consoleSubTitle")}>
|
||||
{t("consoleBody")}
|
||||
</Callout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
174
web/app/[locale]/docs/network/persistent-names/page.tsx
Normal file
174
web/app/[locale]/docs/network/persistent-names/page.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { DataFlowDiagram } from "@/components/ui/data-flow-diagram"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.network.persistentNames.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/network/persistent-names",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type ScopeRow = { type: string; behaviour: string; why: string }
|
||||
type RelatedItem = { label: string; href: string; tail?: string; tailRich?: string }
|
||||
|
||||
export default async function PersistentNamesPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.network.persistentNames" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { network: { persistentNames: {
|
||||
problem: { items: string[] }
|
||||
scope: { rows: ScopeRow[] }
|
||||
afterReboot: { items: string[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const problemItems = messages.docs.network.persistentNames.problem.items
|
||||
const scopeRows = messages.docs.network.persistentNames.scope.rows
|
||||
const afterRebootItems = messages.docs.network.persistentNames.afterReboot.items
|
||||
const relatedItems = messages.docs.network.persistentNames.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={4}
|
||||
scriptPath="menus/network_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("problem.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("problem.intro", { code, em })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{problemItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`problem.items.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("problem.outro", { code, em })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howWorks.heading")}</h2>
|
||||
|
||||
<DataFlowDiagram
|
||||
nodes={[
|
||||
{ label: t("howWorks.nodes.detectLabel"), detail: t("howWorks.nodes.detectDetail"), variant: "source" },
|
||||
{ label: t("howWorks.nodes.readLabel"), detail: t("howWorks.nodes.readDetail"), variant: "bridge" },
|
||||
{ label: t("howWorks.nodes.writeLabel"), detail: t("howWorks.nodes.writeDetail"), variant: "target" },
|
||||
]}
|
||||
arrowLabel={t("howWorks.arrowLabel")}
|
||||
/>
|
||||
|
||||
<p className="mt-6 mb-4 text-gray-800 leading-relaxed">{t("howWorks.minimalIntro")}</p>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`# /etc/systemd/network/10-eno1.link
|
||||
[Match]
|
||||
MACAddress=aa:bb:cc:dd:ee:ff
|
||||
|
||||
[Link]
|
||||
Name=eno1`}</pre>
|
||||
<p className="mt-4 mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("howWorks.minimalOutro", { em, code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("scope.heading")}</h2>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("scope.headerType")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("scope.headerBehaviour")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("scope.headerWhy")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{scopeRows.map((row, idx) => (
|
||||
<tr key={row.type} className={idx < scopeRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.type}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`scope.rows.${idx}.behaviour`, { code })}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`scope.rows.${idx}.why`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("safety.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("safety.intro", { code })}
|
||||
</p>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`/etc/systemd/network/backup-20260426-143012/
|
||||
├── 10-eno1.link (previous version)
|
||||
└── 10-enp3s0.link (previous version)`}</pre>
|
||||
<p className="mt-4 mb-6 text-gray-800 leading-relaxed">{t("safety.outro")}</p>
|
||||
|
||||
<Callout variant="warning" title={t("safety.rebootTitle")}>
|
||||
{t.rich("safety.rebootBody", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("afterReboot.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("afterReboot.intro")}</p>
|
||||
<ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-2">
|
||||
{afterRebootItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`afterReboot.items.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.emptyTitle")}>
|
||||
{t.rich("troubleshoot.emptyBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.noChangeTitle")}>
|
||||
{t.rich("troubleshoot.noChangeBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.undoTitle")}>
|
||||
{t.rich("troubleshoot.undoBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`related.items.${idx}.tailRich`, { em }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
web/app/[locale]/docs/page.tsx
Normal file
18
web/app/[locale]/docs/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { redirect } from "@/i18n/navigation"
|
||||
import { routing } from "@/i18n/routing"
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }))
|
||||
}
|
||||
|
||||
// Docs root has no content of its own — bounce to the canonical entry
|
||||
// page (Introduction). Using next-intl's redirect keeps the locale prefix
|
||||
// in the resulting URL.
|
||||
export default async function DocsRoot({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
redirect({ href: "/docs/introduction", locale })
|
||||
}
|
||||
196
web/app/[locale]/docs/post-install/automated/page.tsx
Normal file
196
web/app/[locale]/docs/post-install/automated/page.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.automated.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/post-install/automated",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type OptimizationRow = { tool: string; what: string; category: string; categorySlug: string }
|
||||
type RelatedItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function AutomatedPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.automated" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { postInstall: { automated: {
|
||||
optimizations: OptimizationRow[]
|
||||
upgrade: { items: string[] }
|
||||
notDoes: { items: string[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const optimizations = messages.docs.postInstall.automated.optimizations
|
||||
const upgradeItems = messages.docs.postInstall.automated.upgrade.items
|
||||
const notDoesItems = messages.docs.postInstall.automated.notDoes.items
|
||||
const relatedItems = messages.docs.postInstall.automated.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const customLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/customizable" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const upgradeLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/utils/system-update" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const secLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/security" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const virtLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/virtualization" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const optLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/optional" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const perfLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/performance" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const storLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/storage" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const overviewLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const uninstallLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/uninstall" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={10}
|
||||
scriptPath="post_install/auto_post_install.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { strong, link: customLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("applies.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("applies.intro", {
|
||||
em: (chunks) => <em>{chunks}</em>,
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto rounded-md border border-gray-200 mb-6">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold">{t("applies.headerNum")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("applies.headerTool")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("applies.headerWhat")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("applies.headerCategory")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-800">
|
||||
{optimizations.map((o, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-4 py-2 text-gray-500 font-mono">{i + 1}</td>
|
||||
<td className="px-4 py-2 font-semibold">{o.tool}</td>
|
||||
<td className="px-4 py-2 text-gray-700 leading-relaxed">{o.what}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Link
|
||||
href={`/docs/post-install/${o.categorySlug}`}
|
||||
className="text-blue-600 hover:underline whitespace-nowrap"
|
||||
>
|
||||
{o.category}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("hardwareTitle")}>
|
||||
{t.rich("hardwareBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("upgrade.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("upgrade.intro")}</p>
|
||||
<CopyableCode
|
||||
code={`apt update && apt full-upgrade -y`}
|
||||
language="bash"
|
||||
/>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("upgrade.after", { strong, link: upgradeLink })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{upgradeItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`upgrade.items.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Callout variant="info" title={t("upgrade.sameTitle")}>
|
||||
{t.rich("upgrade.sameBody", { code, link: upgradeLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("endResult.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("endResult.body")}</p>
|
||||
|
||||
<Image
|
||||
src="/post-install/automated-result.png"
|
||||
alt={t("endResult.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("notDoes.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{notDoesItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`notDoes.items.${idx}`, { secLink, virtLink, optLink, perfLink, storLink })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="warning" title={t("xshokTitle")}>
|
||||
{t.rich("xshokBody", { code, link: overviewLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("revert.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("revert.body", { code, link: uninstallLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
250
web/app/[locale]/docs/post-install/basic-settings/page.tsx
Normal file
250
web/app/[locale]/docs/post-install/basic-settings/page.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import Image from "next/image"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.basicSettings.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type UpgradeRow = { version: string; script: string; codename: string }
|
||||
type UtilityItem = { pkg: string; desc: string }
|
||||
type UtilityGroup = { group: string; items: UtilityItem[] }
|
||||
type RelatedItem = { label: string; href: string; tail: string }
|
||||
|
||||
const SCREENSHOTS = [
|
||||
{ pkg: "htop", alt: "htop interactive process viewer", src: "/basic/htop.png" },
|
||||
{ pkg: "btop", alt: "btop resource monitor", src: "/basic/btop.png" },
|
||||
{ pkg: "iftop", alt: "iftop bandwidth per connection", src: "/basic/iftop.png" },
|
||||
{ pkg: "iotop", alt: "iotop disk I/O per process", src: "/basic/iotop.png" },
|
||||
{ pkg: "iptraf-ng", alt: "iptraf-ng IP LAN monitor", src: "/basic/iptraf-ng.png" },
|
||||
{ pkg: "tmux", alt: "tmux terminal multiplexer", src: "/basic/tmux.png" },
|
||||
]
|
||||
|
||||
export default async function PostInstallBasicSettingsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.basicSettings" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { postInstall: { basicSettings: {
|
||||
upgrade: { rows: UpgradeRow[]; doesItems: string[] }
|
||||
utilities: { groups: UtilityGroup[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const upgradeRows = messages.docs.postInstall.basicSettings.upgrade.rows
|
||||
const doesItems = messages.docs.postInstall.basicSettings.upgrade.doesItems
|
||||
const utilityGroups = messages.docs.postInstall.basicSettings.utilities.groups
|
||||
const relatedItems = messages.docs.postInstall.basicSettings.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const updateLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/utils/system-update" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const uninstallLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/uninstall" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
section={t("header.section")}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t("intro.body")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("upgrade.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("upgrade.intro", { em })}
|
||||
</p>
|
||||
|
||||
<div className="my-4 overflow-x-auto">
|
||||
<table className="min-w-full border border-gray-200 text-sm">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("upgrade.headerVersion")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("upgrade.headerScript")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("upgrade.headerCodename")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{upgradeRows.map((row) => (
|
||||
<tr key={row.version}>
|
||||
<td className="border border-gray-200 px-3 py-2">{row.version}</td>
|
||||
<td className="border border-gray-200 px-3 py-2"><code>{row.script}</code></td>
|
||||
<td className="border border-gray-200 px-3 py-2">{row.codename}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("upgrade.officialTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("upgrade.officialBody")}</p>
|
||||
<CopyableCode
|
||||
code={`apt update && apt full-upgrade -y`}
|
||||
language="bash"
|
||||
/>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("upgrade.officialOutro", { em })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("upgrade.doesTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("upgrade.doesIntro", { link: updateLink })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-4">
|
||||
{doesItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`upgrade.doesItems.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Callout variant="info" title={t("upgrade.shortTitle")}>
|
||||
{t.rich("upgrade.shortBody", { code, link: updateLink })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="warning" title={t("upgrade.subTitle")}>
|
||||
{t("upgrade.subBody")}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("upgrade.safetyTitle")}>
|
||||
{t.rich("upgrade.safetyBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("time.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("time.intro", { code })}
|
||||
</p>
|
||||
|
||||
<Callout variant="warning" title={t("time.depTitle")}>
|
||||
{t.rich("time.depBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<CopyableCode
|
||||
code={`# Manual alternative — pick your IANA zone
|
||||
timedatectl list-timezones | grep -i europe # e.g. Europe/Madrid
|
||||
timedatectl set-timezone Europe/Madrid
|
||||
timedatectl set-ntp true
|
||||
timedatectl # verify`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="tip" title={t("time.revertTitle")}>
|
||||
{t.rich("time.revertBody", { link: uninstallLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("languages.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("languages.intro", { code, strong })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("languages.writtenTitle")}</h3>
|
||||
<CopyableCode
|
||||
code={`# /etc/apt/apt.conf.d/99-disable-translations
|
||||
Acquire::Languages "none";`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="tip" title={t("languages.revertTitle")}>
|
||||
{t.rich("languages.revertBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("utilities.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("utilities.intro", { strong, code })}
|
||||
</p>
|
||||
|
||||
<Image
|
||||
src="/basic/menu_utilities.png"
|
||||
alt={t("utilities.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<Callout variant="tip" title={t("utilities.reuseTitle")}>
|
||||
{t.rich("utilities.reuseBody", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("utilities.listTitle")}</h3>
|
||||
|
||||
{utilityGroups.map((group) => (
|
||||
<div key={group.group} className="mb-6">
|
||||
<h4 className="text-base font-semibold text-gray-900 mb-2">{group.group}</h4>
|
||||
<dl className="divide-y divide-gray-200 border border-gray-200 rounded-md overflow-hidden">
|
||||
{group.items.map((item) => (
|
||||
<div key={item.pkg} className="grid grid-cols-1 sm:grid-cols-[150px_1fr] gap-2 px-4 py-3 bg-white">
|
||||
<dt className="font-mono text-sm text-gray-900">{item.pkg}</dt>
|
||||
<dd className="text-sm text-gray-700 m-0 leading-relaxed">{item.desc}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-3 text-gray-900">{t("utilities.actionTitle")}</h3>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 my-4">
|
||||
{SCREENSHOTS.map((s) => (
|
||||
<figure key={s.pkg} className="m-0">
|
||||
<Image
|
||||
src={s.src}
|
||||
alt={s.alt}
|
||||
width={450}
|
||||
height={280}
|
||||
className="rounded shadow border border-gray-200 w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-xs text-gray-600 mt-1 text-center">
|
||||
<code>{s.pkg}</code>
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Callout variant="warning" title={t("utilities.noBulkTitle")}>
|
||||
{t.rich("utilities.noBulkBody", { strong })}
|
||||
</Callout>
|
||||
|
||||
<CopyableCode
|
||||
code={`# Remove a utility you no longer want
|
||||
apt purge htop
|
||||
apt autoremove --purge`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
136
web/app/[locale]/docs/post-install/customizable/page.tsx
Normal file
136
web/app/[locale]/docs/post-install/customizable/page.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.customizable.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/post-install/customizable",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Category = { name: string; description: string }
|
||||
type RelatedItem = { label: string; href: string; tail?: string; tailRich?: string }
|
||||
|
||||
const CATEGORY_SLUGS = [
|
||||
"basic-settings",
|
||||
"system",
|
||||
"virtualization",
|
||||
"network",
|
||||
"storage",
|
||||
"security",
|
||||
"customization",
|
||||
"monitoring",
|
||||
"performance",
|
||||
"optional",
|
||||
]
|
||||
|
||||
export default async function CustomizablePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.customizable" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { postInstall: { customizable: {
|
||||
categories: Category[]
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const categories = messages.docs.postInstall.customizable.categories
|
||||
const relatedItems = messages.docs.postInstall.customizable.related.items
|
||||
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const uninstallLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/uninstall" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const autoLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/automated" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const storageLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/storage" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const networkLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/network" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const customLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/customization" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={15}
|
||||
scriptPath="post_install/customizable_post_install.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { link: uninstallLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("compare.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("compare.body", { link: autoLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("categoriesSection.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("categoriesSection.body")}</p>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
|
||||
{categories.map((category, idx) => (
|
||||
<Link
|
||||
key={CATEGORY_SLUGS[idx]}
|
||||
href={`/docs/post-install/${CATEGORY_SLUGS[idx]}`}
|
||||
className="group flex items-start gap-2 rounded-md border border-gray-200 bg-white p-3 transition-colors hover:border-blue-400 hover:bg-blue-50"
|
||||
>
|
||||
<ArrowRight
|
||||
className="h-4 w-4 mt-0.5 text-gray-400 group-hover:text-blue-600 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-sm text-gray-900 group-hover:text-blue-700">{category.name}</div>
|
||||
<div className="text-xs text-gray-600 mt-0.5 leading-snug">{category.description}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("mixTip.title")}>
|
||||
{t.rich("mixTip.body", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`related.items.${idx}.tailRich`, { storageLink, networkLink, customLink }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
161
web/app/[locale]/docs/post-install/customization/page.tsx
Normal file
161
web/app/[locale]/docs/post-install/customization/page.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.customization.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type RelatedItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function PostInstallCustomizationPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.customization" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { postInstall: { customization: {
|
||||
banner: { versionItems: string[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const versionItems = messages.docs.postInstall.customization.banner.versionItems
|
||||
const relatedItems = messages.docs.postInstall.customization.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const uninstallLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/uninstall" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("bashrc.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("bashrc.intro", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("bashrc.writesTitle")}</h3>
|
||||
<CopyableCode
|
||||
code={`# BEGIN PMX_CORE_BASHRC
|
||||
# ProxMenux core customizations
|
||||
export HISTTIMEFORMAT="%d/%m/%y %T "
|
||||
export PS1="\\[\\e[31m\\][\\[\\e[m\\]\\[\\e[38;5;172m\\]\\u\\[\\e[m\\]@\\[\\e[38;5;153m\\]\\h\\[\\e[m\\] \\[\\e[38;5;214m\\]\\W\\[\\e[m\\]\\[\\e[31m\\]]\\[\\e[m\\]\\$ "
|
||||
alias l='ls -CF'
|
||||
alias la='ls -A'
|
||||
alias ll='ls -alF'
|
||||
alias ls='ls --color=auto'
|
||||
alias grep='grep --color=auto'
|
||||
alias fgrep='fgrep --color=auto'
|
||||
alias egrep='egrep --color=auto'
|
||||
source /etc/profile.d/bash_completion.sh
|
||||
# END PMX_CORE_BASHRC`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("bashrc.writesOutro", { code })}
|
||||
</p>
|
||||
|
||||
<Callout variant="tip" title={t("bashrc.rootTitle")}>
|
||||
{t("bashrc.rootBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("motd.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("motd.intro", { em, code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("motd.writesTitle")}</h3>
|
||||
<CopyableCode
|
||||
code={` This system is optimised by: ProxMenux
|
||||
|
||||
<original /etc/motd content follows here>`}
|
||||
className="my-4"
|
||||
/>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("motd.writesOutro", { code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("banner.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("banner.intro", { em })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("banner.versionTitle")}</h3>
|
||||
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-4">
|
||||
{versionItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`banner.versionItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("banner.versionOutro", { code })}
|
||||
</p>
|
||||
|
||||
<Callout variant="warning" title={t("banner.breakTitle")}>
|
||||
{t.rich("banner.breakBody", { code, link: uninstallLink })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="danger" title={t("banner.legalTitle")}>
|
||||
{t.rich("banner.legalBody", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("verify.heading")}</h2>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("verify.intro")}</p>
|
||||
<CopyableCode
|
||||
code={`# bashrc: the prompt becomes colored, ll / la aliases work
|
||||
exec bash # reload current shell
|
||||
ll
|
||||
|
||||
# MOTD: log out and SSH back in — the ProxMenux banner shows above the default message
|
||||
cat /etc/motd
|
||||
|
||||
# Subscription banner: log out of the web UI, then log back in — no popup`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="tip" title={t("verify.reversibleTitle")}>
|
||||
{t.rich("verify.reversibleBody", { code, link: uninstallLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
134
web/app/[locale]/docs/post-install/monitoring/page.tsx
Normal file
134
web/app/[locale]/docs/post-install/monitoring/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.monitoring.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type RelatedItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function PostInstallMonitoringPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.monitoring" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { postInstall: { monitoring: {
|
||||
ovh: { decisionsItems: string[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const decisionsItems = messages.docs.postInstall.monitoring.ovh.decisionsItems
|
||||
const relatedItems = messages.docs.postInstall.monitoring.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const ovhAnchor = (chunks: React.ReactNode) => (
|
||||
<a href="https://www.ovhcloud.com" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
const rtmAnchor = (chunks: React.ReactNode) => (
|
||||
<a href="https://www.ovhcloud.com/en/bare-metal/monitoring/" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("ovh.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("ovh.intro", { a: ovhAnchor })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("ovh.decisionsTitle")}</h3>
|
||||
<ol className="list-decimal pl-6 space-y-1 text-gray-800 mb-4">
|
||||
{decisionsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`ovh.decisionsItems.${idx}`, { code, em })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<Callout variant="danger" title={t("ovh.remoteTitle")}>
|
||||
{t.rich("ovh.remoteBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="warning" title={t("ovh.noOpTitle")}>
|
||||
{t.rich("ovh.noOpBody", { em })}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("ovh.runsTitle")}</h3>
|
||||
<CopyableCode
|
||||
code={`# Detect + conditionally install
|
||||
public_ip=$(curl -s ipinfo.io/ip)
|
||||
is_ovh=$(whois -h v4.whois.cymru.com " -t $public_ip" | tail -n 1 | cut -d'|' -f3 | grep -i "ovh")
|
||||
|
||||
if [ -n "$is_ovh" ]; then
|
||||
wget -qO - https://last-public-ovh-infra-yak.snap.mirrors.ovh.net/yak/archives/apply.sh \\
|
||||
| OVH_PUPPET_MANIFEST=distribyak/catalog/master/puppet/manifests/common/rtmv2.pp bash
|
||||
fi`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("ovh.verifyTitle")}</h3>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("ovh.verifyBody", { a: rtmAnchor })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`systemctl status ovh-rtm # or grep the unit name from your install log
|
||||
journalctl -u ovh-rtm --since "10 min ago"`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("ovh.troubleTitle")}</h3>
|
||||
|
||||
<Callout variant="tip" title={t("ovh.spuriousTitle")}>
|
||||
{t.rich("ovh.spuriousBody", { em, code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("ovh.revertTitle")}>
|
||||
{t.rich("ovh.revertBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
221
web/app/[locale]/docs/post-install/network/page.tsx
Normal file
221
web/app/[locale]/docs/post-install/network/page.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.network.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type AreaRow = { area: string; settings: string }
|
||||
type RelatedItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function PostInstallNetworkPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.network" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { postInstall: { network: {
|
||||
sysctl: { rows: AreaRow[] }
|
||||
names: { whyItems: string[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const sysctlRows = messages.docs.postInstall.network.sysctl.rows
|
||||
const whyItems = messages.docs.postInstall.network.names.whyItems
|
||||
const relatedItems = messages.docs.postInstall.network.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const uninstallLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/uninstall" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
section={t("header.section")}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { strong, code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("ipv4.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("ipv4.intro", { code })}
|
||||
</p>
|
||||
|
||||
<Callout variant="tip" title={t("ipv4.tipTitle")}>
|
||||
{t("ipv4.tipBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("sysctl.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("sysctl.intro", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("sysctl.tunedTitle")}</h3>
|
||||
<div className="my-4 overflow-x-auto">
|
||||
<table className="min-w-full border border-gray-200 text-sm">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("sysctl.headerArea")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("sysctl.headerSettings")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{sysctlRows.map((row, idx) => (
|
||||
<tr key={row.area}>
|
||||
<td className="border border-gray-200 px-3 py-2">{row.area}</td>
|
||||
<td className="border border-gray-200 px-3 py-2">{t.rich(`sysctl.rows.${idx}.settings`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("sysctl.sourceOutro", { code })}
|
||||
</p>
|
||||
|
||||
<Callout variant="warning" title={t("sysctl.rpFilterTitle")}>
|
||||
{t.rich("sysctl.rpFilterBody", { em, code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("ovs.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("ovs.intro", { code, em })}
|
||||
</p>
|
||||
|
||||
<Callout variant="tip" title={t("ovs.tipTitle")}>
|
||||
{t.rich("ovs.tipBody", { strong, code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="warning" title={t("ovs.revertTitle")}>
|
||||
{t("ovs.revertBody")}
|
||||
</Callout>
|
||||
|
||||
<CopyableCode
|
||||
code={`# After moving bridges off OVS:
|
||||
apt purge openvswitch-switch openvswitch-common
|
||||
apt autoremove --purge`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("bbr.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("bbr.intro")}</p>
|
||||
|
||||
<CopyableCode
|
||||
code={`# /etc/sysctl.d/99-kernel-bbr.conf
|
||||
net.core.default_qdisc = fq
|
||||
net.ipv4.tcp_congestion_control = bbr
|
||||
|
||||
# /etc/sysctl.d/99-tcp-fastopen.conf
|
||||
net.ipv4.tcp_fastopen = 3 # enable TFO for both client and server sockets`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("bbr.verifyTitle")}</h3>
|
||||
<CopyableCode
|
||||
code={`# BBR is active
|
||||
sysctl net.ipv4.tcp_congestion_control
|
||||
# Expected: net.ipv4.tcp_congestion_control = bbr
|
||||
|
||||
# Qdisc is fair queuing (required for BBR to work well)
|
||||
tc qdisc show | head
|
||||
|
||||
# TFO enabled (value 3 = client + server)
|
||||
sysctl net.ipv4.tcp_fastopen`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="tip" title={t("bbr.impactTitle")}>
|
||||
{t("bbr.impactBody")}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="warning" title={t("bbr.revertTitle")}>
|
||||
{t("bbr.revertBody")}
|
||||
</Callout>
|
||||
|
||||
<CopyableCode
|
||||
code={`rm /etc/sysctl.d/99-kernel-bbr.conf /etc/sysctl.d/99-tcp-fastopen.conf
|
||||
sysctl --system`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("names.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("names.intro", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("names.whyTitle")}</h3>
|
||||
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-4">
|
||||
{whyItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`names.whyItems.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("names.writtenTitle")}</h3>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("names.writtenIntro", { code })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`[Match]
|
||||
MACAddress=aa:bb:cc:dd:ee:ff
|
||||
|
||||
[Link]
|
||||
Name=enp3s0`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("names.writtenOutro", { code })}
|
||||
</p>
|
||||
|
||||
<Callout variant="tip" title={t("names.pveTitle")}>
|
||||
{t.rich("names.pveBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="warning" title={t("names.reviewTitle")}>
|
||||
{t.rich("names.reviewBody", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("names.revertTitle")}>
|
||||
{t.rich("names.revertBody", { code, link: uninstallLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
337
web/app/[locale]/docs/post-install/optional/page.tsx
Normal file
337
web/app/[locale]/docs/post-install/optional/page.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Plus } from "lucide-react"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.optional.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/post-install/optional",
|
||||
images: [
|
||||
{
|
||||
url: "https://macrimi.github.io/ProxMenux/optional-settings-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: t("ogImageAlt"),
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
images: ["https://macrimi.github.io/ProxMenux/optional-settings-image.png"],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Logo = { name: string; alt: string; src: string }
|
||||
|
||||
function StepNumber({ number }: { number: number }) {
|
||||
return (
|
||||
<div className="inline-flex items-center justify-center w-8 h-8 mr-3 text-white bg-blue-500 rounded-full">
|
||||
<span className="text-sm font-bold">{number}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function OptionalSettingsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.optional" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { postInstall: { optional: {
|
||||
ceph: { doesItems: string[] }
|
||||
amd: { doesItems: string[] }
|
||||
ha: { doesItems: string[] }
|
||||
testing: { doesItems: string[] }
|
||||
fastfetch: { doesItems: string[]; customItems: string[]; logos: Logo[] }
|
||||
figurine: { doesItems: string[] }
|
||||
} } }
|
||||
}
|
||||
const cephItems = messages.docs.postInstall.optional.ceph.doesItems
|
||||
const amdItems = messages.docs.postInstall.optional.amd.doesItems
|
||||
const haItems = messages.docs.postInstall.optional.ha.doesItems
|
||||
const testingItems = messages.docs.postInstall.optional.testing.doesItems
|
||||
const fastfetchItems = messages.docs.postInstall.optional.fastfetch.doesItems
|
||||
const fastfetchCustomItems = messages.docs.postInstall.optional.fastfetch.customItems
|
||||
const fastfetchLogos = messages.docs.postInstall.optional.fastfetch.logos
|
||||
const figurineItems = messages.docs.postInstall.optional.figurine.doesItems
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center mb-6">
|
||||
<Plus className="h-8 w-8 mr-2 text-blue-500" />
|
||||
<h1 className="text-3xl font-bold">{t("title")}</h1>
|
||||
</div>
|
||||
<p className="mb-4">
|
||||
{t.rich("intro", { strong })}
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold mt-8 mb-4">{t("available")}</h2>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-16 mb-4 flex items-center">
|
||||
<StepNumber number={1} />
|
||||
{t("ceph.title")}
|
||||
</h3>
|
||||
<p className="mb-4">{t("ceph.intro")}</p>
|
||||
<p className="mb-4">{t("ceph.doesIntro")}</p>
|
||||
<ul className="list-disc pl-5 mb-4">
|
||||
{cephItems.map((_, idx) => (
|
||||
<li key={idx}>{t(`ceph.doesItems.${idx}`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4">{t("ceph.howUse")}</p>
|
||||
<p className="text-lg mb-2">{t("ceph.automates")}</p>
|
||||
<CopyableCode
|
||||
code={`
|
||||
# Add Ceph repository
|
||||
echo "deb https://download.proxmox.com/debian/ceph-squid $(lsb_release -cs) no-subscription" > /etc/apt/sources.list.d/ceph-squid.list
|
||||
|
||||
# Update package lists
|
||||
apt-get update
|
||||
|
||||
# Install Ceph
|
||||
pveceph install
|
||||
|
||||
# Verify installation
|
||||
pveceph status
|
||||
`}
|
||||
/>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-16 mb-4 flex items-center">
|
||||
<StepNumber number={2} />
|
||||
{t("amd.title")}
|
||||
</h3>
|
||||
<p className="mb-4">{t("amd.intro")}</p>
|
||||
<p className="mb-4">{t("amd.doesIntro")}</p>
|
||||
<ul className="list-disc pl-5 mb-4">
|
||||
{amdItems.map((_, idx) => (
|
||||
<li key={idx}>{t(`amd.doesItems.${idx}`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4">{t("amd.howUse")}</p>
|
||||
<p className="text-lg mb-2">{t("amd.automates")}</p>
|
||||
<CopyableCode
|
||||
code={`
|
||||
# Set kernel parameter
|
||||
sed -i 's/GRUB_CMDLINE_LINUX_DEFAULT="/GRUB_CMDLINE_LINUX_DEFAULT="idle=nomwait /g' /etc/default/grub
|
||||
update-grub
|
||||
|
||||
# Configure KVM
|
||||
echo "options kvm ignore_msrs=Y" >> /etc/modprobe.d/kvm.conf
|
||||
echo "options kvm report_ignored_msrs=N" >> /etc/modprobe.d/kvm.conf
|
||||
|
||||
# Install latest Proxmox VE kernel
|
||||
apt-get install pve-kernel-$(uname -r | cut -d'-' -f1-2)
|
||||
`}
|
||||
/>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-16 mb-4 flex items-center">
|
||||
<StepNumber number={3} />
|
||||
{t("ha.title")}
|
||||
</h3>
|
||||
<p className="mb-4">{t("ha.intro")}</p>
|
||||
<p className="mb-4">{t("ha.doesIntro")}</p>
|
||||
<ul className="list-disc pl-5 mb-4">
|
||||
{haItems.map((_, idx) => (
|
||||
<li key={idx}>{t(`ha.doesItems.${idx}`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4">{t("ha.howUse")}</p>
|
||||
<p className="text-lg mb-2">{t("ha.automates")}</p>
|
||||
<CopyableCode
|
||||
code={`
|
||||
systemctl enable --now pve-ha-lrm pve-ha-crm corosync
|
||||
`}
|
||||
/>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-16 mb-4 flex items-center">
|
||||
<StepNumber number={4} />
|
||||
{t("testing.title")}
|
||||
</h3>
|
||||
<p className="mb-4">{t("testing.intro")}</p>
|
||||
<p className="mb-4">{t("testing.doesIntro")}</p>
|
||||
<ul className="list-disc pl-5 mb-4">
|
||||
{testingItems.map((_, idx) => (
|
||||
<li key={idx}>{t(`testing.doesItems.${idx}`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4">{t("testing.howUse")}</p>
|
||||
<p className="text-lg mb-2">{t("testing.manualIntro")}</p>
|
||||
<CopyableCode
|
||||
code={`
|
||||
# Add Proxmox testing repository
|
||||
echo "deb http://download.proxmox.com/debian/pve $(lsb_release -cs) pvetest" | sudo tee /etc/apt/sources.list.d/pve-testing-repo.list
|
||||
|
||||
# Update package lists
|
||||
sudo apt update
|
||||
`}
|
||||
/>
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
<strong>{t("testing.noteLabel")}</strong> {t("testing.noteBody")}
|
||||
</p>
|
||||
<p className="mt-4 text-yellow-600">
|
||||
<strong>{t("testing.warnLabel")}</strong> {t("testing.warnBody")}
|
||||
</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-16 mb-4 flex items-center">
|
||||
<StepNumber number={5} />
|
||||
{t("fastfetch.title")}
|
||||
</h3>
|
||||
|
||||
<p className="mb-4">{t("fastfetch.intro")}</p>
|
||||
|
||||
<p className="mb-4">
|
||||
<strong>{t("fastfetch.doesLabel")}</strong>
|
||||
</p>
|
||||
<ul className="list-disc pl-5 mb-4">
|
||||
{fastfetchItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`fastfetch.doesItems.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4 mb-4">
|
||||
<p className="font-semibold">{t("fastfetch.importantLabel")}</p>
|
||||
<p>
|
||||
{t.rich("fastfetch.importantBody", { strong, code })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 mb-4">
|
||||
<p className="font-semibold">{t("fastfetch.customLabel")}</p>
|
||||
<p>
|
||||
{t.rich("fastfetch.customBody1", { code })}
|
||||
</p>
|
||||
<p>
|
||||
{t.rich("fastfetch.customBody2", { code })}
|
||||
</p>
|
||||
<p>{t("fastfetch.customBody3")}</p>
|
||||
<ul className="list-disc pl-5 mt-2">
|
||||
{fastfetchCustomItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`fastfetch.customItems.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p className="mb-4">
|
||||
<strong>{t("fastfetch.examplesLabel")}</strong>
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{fastfetchLogos.map((logo) => (
|
||||
<div key={logo.name}>
|
||||
<p className="font-semibold text-center">{logo.name}</p>
|
||||
<img
|
||||
src={logo.src}
|
||||
alt={logo.alt}
|
||||
className="rounded shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-lg mb-2">{t("fastfetch.automates")}</p>
|
||||
<CopyableCode
|
||||
code={`
|
||||
# Download and install the latest version of Fastfetch
|
||||
FASTFETCH_URL=$(curl -s https://api.github.com/repos/fastfetch-cli/fastfetch/releases/latest | grep "browser_download_url.*fastfetch-linux-amd64.deb" | cut -d '"' -f 4)
|
||||
wget -q -O /tmp/fastfetch.deb "$FASTFETCH_URL"
|
||||
dpkg -i /tmp/fastfetch.deb
|
||||
apt-get install -f -y
|
||||
|
||||
# Configure Fastfetch (logo selection remains interactive)
|
||||
# The configuration is done through a series of jq commands
|
||||
|
||||
# Set Fastfetch to run at login
|
||||
echo "clear && fastfetch" >> ~/.bashrc
|
||||
`}
|
||||
/>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-16 mb-4 flex items-center">
|
||||
<StepNumber number={6} />
|
||||
{t("figurine.title")}
|
||||
</h3>
|
||||
|
||||
<p className="mb-4">{t("figurine.intro")}</p>
|
||||
|
||||
<p className="mb-4">
|
||||
<strong>{t("figurine.doesLabel")}</strong>
|
||||
</p>
|
||||
<ul className="list-disc pl-5 mb-4">
|
||||
{figurineItems.map((_, idx) => (
|
||||
<li key={idx}>{t(`figurine.doesItems.${idx}`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 mb-4">
|
||||
<p className="font-semibold">{t("figurine.practicalLabel")}</p>
|
||||
<p>{t("figurine.practicalBody")}</p>
|
||||
</div>
|
||||
|
||||
<p className="mb-4">
|
||||
<strong>{t("figurine.exampleLabel")}</strong>
|
||||
</p>
|
||||
|
||||
<div className="mb-6 flex justify-center">
|
||||
<img
|
||||
src="https://macrimi.github.io/ProxMenux/figurine/figurine.png"
|
||||
alt={t("figurine.imageAlt")}
|
||||
className="rounded-md shadow-lg border border-gray-200"
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-lg mb-2">{t("figurine.automates")}</p>
|
||||
<CopyableCode
|
||||
code={`
|
||||
# Check for previous installation and remove if found
|
||||
if command -v figurine &> /dev/null; then
|
||||
rm -f "/usr/local/bin/figurine"
|
||||
fi
|
||||
|
||||
# Download and install Figurine
|
||||
version="2.0.0"
|
||||
file="figurine_linux_amd64_v\${version}.tar.gz"
|
||||
url="https://github.com/arsham/figurine/releases/download/v\${version}/\${file}"
|
||||
wget -qO "/tmp/\${file}" "\${url}"
|
||||
tar -xf "/tmp/\${file}" -C "/tmp"
|
||||
mv "/tmp/deploy/figurine" "/usr/local/bin/figurine"
|
||||
chmod +x "/usr/local/bin/figurine"
|
||||
|
||||
# Create welcome message script
|
||||
cat << 'EOF' > "/etc/profile.d/figurine.sh"
|
||||
/usr/local/bin/figurine -f "3d.flf" $(hostname)
|
||||
EOF
|
||||
chmod +x "/etc/profile.d/figurine.sh"
|
||||
`}
|
||||
/>
|
||||
|
||||
<p className="mt-4">{t("figurine.outro")}</p>
|
||||
|
||||
<section className="mt-12 p-4 bg-blue-100 rounded-md">
|
||||
<h2 className="text-xl font-semibold mb-2">{t("autoApplication.title")}</h2>
|
||||
<p>{t("autoApplication.body")}</p>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
226
web/app/[locale]/docs/post-install/page.tsx
Normal file
226
web/app/[locale]/docs/post-install/page.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { Zap, SlidersHorizontal, Undo2, ExternalLink, RefreshCw } from "lucide-react"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox post install",
|
||||
"proxmox post-install script",
|
||||
"proxmox optimizations",
|
||||
"proxmox tweaks",
|
||||
"proxmox automated setup",
|
||||
"proxmox customization",
|
||||
"proxmox no subscription repository",
|
||||
"proxmox tuning",
|
||||
"proxmenux post install",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/post-install" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/post-install",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Route = { title: string; description: string; bullets: string[] }
|
||||
type RelatedItem = { label: string; href: string; tail: string }
|
||||
|
||||
const ROUTE_CONFIG = [
|
||||
{
|
||||
key: "automated",
|
||||
href: "/docs/post-install/automated",
|
||||
Icon: Zap,
|
||||
accent: "border-emerald-300 bg-emerald-50",
|
||||
iconBg: "bg-emerald-100 text-emerald-700",
|
||||
},
|
||||
{
|
||||
key: "customizable",
|
||||
href: "/docs/post-install/customizable",
|
||||
Icon: SlidersHorizontal,
|
||||
accent: "border-amber-300 bg-amber-50",
|
||||
iconBg: "bg-amber-100 text-amber-700",
|
||||
},
|
||||
{
|
||||
key: "updates",
|
||||
href: "/docs/post-install/updates",
|
||||
Icon: RefreshCw,
|
||||
accent: "border-violet-300 bg-violet-50",
|
||||
iconBg: "bg-violet-100 text-violet-700",
|
||||
},
|
||||
{
|
||||
key: "uninstall",
|
||||
href: "/docs/post-install/uninstall",
|
||||
Icon: Undo2,
|
||||
accent: "border-blue-300 bg-blue-50",
|
||||
iconBg: "bg-blue-100 text-blue-700",
|
||||
},
|
||||
]
|
||||
|
||||
export default async function PostInstallPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { postInstall: {
|
||||
routes: Route[]
|
||||
related: { items: RelatedItem[] }
|
||||
} }
|
||||
}
|
||||
const routes = messages.docs.postInstall.routes
|
||||
const relatedItems = messages.docs.postInstall.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const autoLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/automated" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const customLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/customizable" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const updatesLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/updates" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const uninstallLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/uninstall" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const xshokAnchor = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://github.com/extremeshok/xshok-proxmox"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-700 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
const communityAnchor = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://github.com/community-scripts/ProxmoxVE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
const externalRepoLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/external-repositories" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={3}
|
||||
scriptPath="menus/menu_post_install.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t("intro.body")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("openingMenu.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("openingMenu.body", { strong })}
|
||||
</p>
|
||||
|
||||
<Image
|
||||
src="/post-install/post-install-menu.png"
|
||||
alt={t("openingMenu.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("threeWays.heading")}</h2>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t("threeWays.body")}</p>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-8 not-prose">
|
||||
{ROUTE_CONFIG.map(({ key, href, Icon, accent, iconBg }, idx) => {
|
||||
const route = routes[idx]
|
||||
return (
|
||||
<Link
|
||||
key={key}
|
||||
href={href}
|
||||
className={`rounded-lg border-2 p-5 ${accent} flex flex-col transition-shadow hover:shadow-md`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className={`inline-flex h-9 w-9 items-center justify-center rounded-full ${iconBg}`}>
|
||||
<Icon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900 m-0">{route.title}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-800 mb-3">{route.description}</p>
|
||||
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
|
||||
{route.bullets.map((b, i) => (
|
||||
<li key={i}>{b}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("whichTitle")}>
|
||||
{t.rich("whichBody", { strong, autoLink, customLink, updatesLink, uninstallLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("mixing.heading")}</h2>
|
||||
|
||||
<Callout variant="warning" title={t("mixing.stackTitle")}>
|
||||
{t.rich("mixing.stackBody", { strong })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="info" title={t("mixing.xshokTitle")}>
|
||||
{t.rich("mixing.xshokBody", { a: xshokAnchor })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("community.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("community.body", { em, code, a: communityAnchor, link: externalRepoLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
148
web/app/[locale]/docs/post-install/performance/page.tsx
Normal file
148
web/app/[locale]/docs/post-install/performance/page.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.performance.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type RelatedItem = { label: string; href: string; tail?: string; tailRich?: string }
|
||||
|
||||
export default async function PostInstallPerformancePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.performance" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { postInstall: { performance: {
|
||||
pigz: { doesItems: string[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const doesItems = messages.docs.postInstall.performance.pigz.doesItems
|
||||
const relatedItems = messages.docs.postInstall.performance.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const pigzAnchor = (chunks: React.ReactNode) => (
|
||||
<a href="https://zlib.net/pigz/" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("pigz.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("pigz.intro", { code, strong, a: pigzAnchor })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("pigz.doesTitle")}</h3>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("pigz.doesIntro")}</p>
|
||||
<ol className="list-decimal pl-6 space-y-2 text-gray-800 mb-4">
|
||||
{doesItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`pigz.doesItems.${idx}`, { code, em })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<CopyableCode
|
||||
code={`# What ProxMenux runs under the hood
|
||||
sed -i "s/#pigz:.*/pigz: 1/" /etc/vzdump.conf
|
||||
apt-get -y install pigz
|
||||
|
||||
cat > /bin/pigzwrapper <<'EOF'
|
||||
#!/bin/sh
|
||||
PATH=/bin:$PATH
|
||||
GZIP="-1"
|
||||
exec /usr/bin/pigz "$@"
|
||||
EOF
|
||||
chmod +x /bin/pigzwrapper
|
||||
|
||||
# Only replaces gzip if not already replaced (idempotent)
|
||||
[ ! -f /bin/gzip.original ] && mv /bin/gzip /bin/gzip.original \\
|
||||
&& cp /bin/pigzwrapper /bin/gzip && chmod +x /bin/gzip`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="warning" title={t("pigz.replacesTitle")}>
|
||||
{t.rich("pigz.replacesBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="danger" title={t("pigz.revertTitle")}>
|
||||
{t.rich("pigz.revertBody", { strong })}
|
||||
</Callout>
|
||||
|
||||
<CopyableCode
|
||||
code={`# Manual rollback of pigz
|
||||
mv /bin/gzip.original /bin/gzip # restore original binary
|
||||
rm /bin/pigzwrapper
|
||||
sed -i 's/^pigz: 1/#pigz: 1/' /etc/vzdump.conf
|
||||
# Optional: remove the package
|
||||
apt purge pigz`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("pigz.verifyTitle")}</h3>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("pigz.verifyBody", { code })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`# Confirm gzip now points to pigz
|
||||
gzip --version
|
||||
# Expected first line: "pigz 2.x … by Mark Adler"
|
||||
|
||||
# Compare throughput (create a 1GB file of random data and compress it)
|
||||
dd if=/dev/urandom of=/tmp/test.bin bs=1M count=1024 status=none
|
||||
time gzip -k /tmp/test.bin # uses pigz — parallel
|
||||
rm /tmp/test.bin.gz
|
||||
|
||||
time /bin/gzip.original -k /tmp/test.bin # original single-threaded gzip
|
||||
rm /tmp/test.bin /tmp/test.bin.gz`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="tip" title={t("pigz.whenTitle")}>
|
||||
{t.rich("pigz.whenBody", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`related.items.${idx}.tailRich`, { code }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
web/app/[locale]/docs/post-install/security/page.tsx
Normal file
110
web/app/[locale]/docs/post-install/security/page.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.security.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type RelatedItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function PostInstallSecurityPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.security" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { postInstall: { security: {
|
||||
rpcbind: { whyItems: string[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const whyItems = messages.docs.postInstall.security.rpcbind.whyItems
|
||||
const relatedItems = messages.docs.postInstall.security.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const uninstallLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/uninstall" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
section={t("header.section")}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { strong, em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("rpcbind.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("rpcbind.intro", { code, strong })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("rpcbind.whyTitle")}</h3>
|
||||
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-4">
|
||||
{whyItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`rpcbind.whyItems.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="warning" title={t("rpcbind.nfsTitle")}>
|
||||
{t.rich("rpcbind.nfsBody", { strong, em, code })}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("rpcbind.runsTitle")}</h3>
|
||||
<CopyableCode
|
||||
code={`# Stop and disable the rpcbind service
|
||||
systemctl stop rpcbind
|
||||
systemctl disable rpcbind`}
|
||||
className="my-4"
|
||||
/>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("rpcbind.runsOutro")}</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("rpcbind.verifyTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("rpcbind.verifyBody", { code })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`systemctl is-active rpcbind # should report: inactive
|
||||
systemctl is-enabled rpcbind # should report: disabled
|
||||
ss -tulpn | grep ':111 ' # should return nothing`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="tip" title={t("rpcbind.reversibleTitle")}>
|
||||
{t.rich("rpcbind.reversibleBody", { em, link: uninstallLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
315
web/app/[locale]/docs/post-install/storage/page.tsx
Normal file
315
web/app/[locale]/docs/post-install/storage/page.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.storage.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type ArcRow = { ram: string; min: string; max: string }
|
||||
type SnapRow = { label: string; runs: string; kept: string }
|
||||
type RelatedItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function PostInstallStoragePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.storage" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { postInstall: { storage: {
|
||||
arc: { rows: ArcRow[] }
|
||||
autoSnap: { rows: SnapRow[] }
|
||||
autotrim: {
|
||||
practicalItems: string[]
|
||||
whenSkipItems: string[]
|
||||
}
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const arcRows = messages.docs.postInstall.storage.arc.rows
|
||||
const snapRows = messages.docs.postInstall.storage.autoSnap.rows
|
||||
const practicalItems = messages.docs.postInstall.storage.autotrim.practicalItems
|
||||
const whenSkipItems = messages.docs.postInstall.storage.autotrim.whenSkipItems
|
||||
const relatedItems = messages.docs.postInstall.storage.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const zfsAutoAnchor = (chunks: React.ReactNode) => (
|
||||
<a href="https://github.com/zfsonlinux/zfs-auto-snapshot" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
section={t("header.section")}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { strong })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="warning" title={t("notTrackedTitle")}>
|
||||
{t.rich("notTrackedBody", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("arc.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("arc.intro", { strong })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("arc.sizingTitle")}</h3>
|
||||
<div className="my-4 overflow-x-auto">
|
||||
<table className="min-w-full border border-gray-200 text-sm">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("arc.headerRam")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("arc.headerMin")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("arc.headerMax")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{arcRows.map((row) => (
|
||||
<tr key={row.ram}>
|
||||
<td className="border border-gray-200 px-3 py-2">{row.ram}</td>
|
||||
<td className="border border-gray-200 px-3 py-2">{row.min}</td>
|
||||
<td className="border border-gray-200 px-3 py-2">{row.max}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("arc.after", { code })}
|
||||
</p>
|
||||
|
||||
<Callout variant="warning" title={t("arc.rebootTitle")}>
|
||||
{t.rich("arc.rebootBody", { code, strong })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("arc.safeTitle")}>
|
||||
{t.rich("arc.safeBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("arc.verifyTitle")}</h3>
|
||||
<CopyableCode
|
||||
code={`# Check the config file is in place
|
||||
cat /etc/modprobe.d/99-zfsarc.conf
|
||||
|
||||
# After reboot, check actual ARC limits (in bytes)
|
||||
cat /sys/module/zfs/parameters/zfs_arc_min
|
||||
cat /sys/module/zfs/parameters/zfs_arc_max
|
||||
|
||||
# Manual rollback
|
||||
rm /etc/modprobe.d/99-zfsarc.conf
|
||||
update-initramfs -u -k all
|
||||
# (reboot for ZFS to load with defaults again)`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("autoSnap.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("autoSnap.intro", { a: zfsAutoAnchor })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("autoSnap.cadenceTitle")}</h3>
|
||||
<div className="my-4 overflow-x-auto">
|
||||
<table className="min-w-full border border-gray-200 text-sm">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("autoSnap.headerLabel")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("autoSnap.headerRuns")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("autoSnap.headerKept")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{snapRows.map((row) => (
|
||||
<tr key={row.label}>
|
||||
<td className="border border-gray-200 px-3 py-2">{row.label}</td>
|
||||
<td className="border border-gray-200 px-3 py-2">{row.runs}</td>
|
||||
<td className="border border-gray-200 px-3 py-2">{row.kept}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("autoSnap.conservativeTitle")}>
|
||||
{t.rich("autoSnap.conservativeBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="warning" title={t("autoSnap.onlyZfsTitle")}>
|
||||
{t("autoSnap.onlyZfsBody")}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("autoSnap.verifyTitle")}</h3>
|
||||
<CopyableCode
|
||||
code={`# List existing auto-snapshots across all ZFS datasets
|
||||
zfs list -t snapshot | grep zfs-auto-snap
|
||||
|
||||
# Check the schedules
|
||||
grep . /etc/cron.d/zfs-auto-snapshot /etc/cron.hourly/zfs-auto-snapshot \\
|
||||
/etc/cron.daily/zfs-auto-snapshot /etc/cron.weekly/zfs-auto-snapshot \\
|
||||
/etc/cron.monthly/zfs-auto-snapshot
|
||||
|
||||
# Manual rollback (removes the package + destroys no snapshots)
|
||||
apt purge zfs-auto-snapshot
|
||||
# Existing snapshots remain on your pools unless you destroy them explicitly`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("autotrim.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("autotrim.intro", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("autotrim.trimTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("autotrim.trimBody1", { strong })}
|
||||
</p>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t("autotrim.trimBody2")}
|
||||
</p>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("autotrim.trimBody3", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("autotrim.practicalTitle")}</h3>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{practicalItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`autotrim.practicalItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("autotrim.whenTitle")}</h3>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
<li>
|
||||
{t.rich("autotrim.whenIntro1", { strong })}
|
||||
</li>
|
||||
<li>
|
||||
{t.rich("autotrim.whenIntro2", { strong })}
|
||||
<ul className="list-disc pl-6 mt-1">
|
||||
{whenSkipItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`autotrim.whenSkipItems.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
{t.rich("autotrim.whenIntro3", { strong })}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Callout variant="info" title={t("autotrim.recordedTitle")}>
|
||||
{t.rich("autotrim.recordedBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("autotrim.manualTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("autotrim.manualIntro")}</p>
|
||||
<CopyableCode
|
||||
code={`# 1. List your ZFS pools
|
||||
zpool list -H -o name
|
||||
|
||||
# 2. Check the current autotrim setting on a pool
|
||||
zpool get autotrim <pool>
|
||||
|
||||
# 3. Verify the pool is backed by SSD/NVMe with TRIM support
|
||||
# For each vdev (use the device path you see in 'zpool status -P <pool>'):
|
||||
DEV=sda # replace with the actual short name (sda, nvme0n1, ...)
|
||||
cat /sys/block/\${DEV}/queue/rotational # must be 0 (SSD/NVMe, not HDD)
|
||||
cat /sys/block/\${DEV}/queue/discard_granularity # must be > 0 (TRIM supported)
|
||||
|
||||
# 4. Turn it on
|
||||
zpool set autotrim=on <pool>
|
||||
|
||||
# 5. Confirm
|
||||
zpool get autotrim <pool>`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("autotrim.verifyTitle")}</h3>
|
||||
<CopyableCode
|
||||
code={`# Verify autotrim is active on every pool ProxMenux touched
|
||||
cat /usr/local/share/proxmenux/zfs_autotrim_pools
|
||||
while read -r p; do
|
||||
zpool get autotrim "$p"
|
||||
done < /usr/local/share/proxmenux/zfs_autotrim_pools
|
||||
|
||||
# Manual rollback — disable autotrim on a specific pool
|
||||
zpool set autotrim=off <pool>
|
||||
|
||||
# Or revert all pools ProxMenux changed (manual equivalent of the Uninstall option)
|
||||
while read -r p; do
|
||||
zpool set autotrim=off "$p"
|
||||
done < /usr/local/share/proxmenux/zfs_autotrim_pools`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="tip" title={t("autotrim.oneShotTitle")}>
|
||||
{t.rich("autotrim.oneShotBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("vzdump.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("vzdump.intro")}</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("vzdump.changedTitle")}</h3>
|
||||
<CopyableCode
|
||||
code={`bwlimit: 0 # No bandwidth limit (was capped by default)
|
||||
ionice: 5 # Lower I/O priority (5 = best-effort class, lowest priority in that class)`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="warning" title={t("vzdump.noBackupTitle")}>
|
||||
{t.rich("vzdump.noBackupBody", { strong, code, em })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("vzdump.skipTitle")}>
|
||||
{t.rich("vzdump.skipBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("vzdump.verifyTitle")}</h3>
|
||||
<CopyableCode
|
||||
code={`# Check current vzdump config
|
||||
grep -E "^(bwlimit|ionice):" /etc/vzdump.conf
|
||||
|
||||
# Manual rollback (comment out the two lines — restores Proxmox defaults)
|
||||
sed -i 's/^bwlimit: 0/#bwlimit: KBPS/' /etc/vzdump.conf
|
||||
sed -i 's/^ionice: 5/#ionice: PRI/' /etc/vzdump.conf`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
238
web/app/[locale]/docs/post-install/system/page.tsx
Normal file
238
web/app/[locale]/docs/post-install/system/page.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.system.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type LimitRow = { file: string; sets: string }
|
||||
type RelatedItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function PostInstallSystemPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.system" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { postInstall: { system: {
|
||||
journald: { keyItems: string[] }
|
||||
limits: { rows: LimitRow[] }
|
||||
kexec: { installsItems: string[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const keyItems = messages.docs.postInstall.system.journald.keyItems
|
||||
const limitRows = messages.docs.postInstall.system.limits.rows
|
||||
const installsItems = messages.docs.postInstall.system.kexec.installsItems
|
||||
const relatedItems = messages.docs.postInstall.system.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const kexecLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/system" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const uninstallLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/uninstall" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
section={t("header.section")}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("journald.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("journald.intro", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("journald.keyTitle")}</h3>
|
||||
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-4">
|
||||
{keyItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`journald.keyItems.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="tip" title={t("journald.tipTitle")}>
|
||||
{t.rich("journald.tipBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("logrotate.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("logrotate.intro", { code })}
|
||||
</p>
|
||||
|
||||
<CopyableCode
|
||||
code={`# /etc/logrotate.conf — ProxMenux-optimized
|
||||
daily
|
||||
su root adm
|
||||
rotate 7
|
||||
create
|
||||
compress
|
||||
size 10M
|
||||
delaycompress
|
||||
copytruncate
|
||||
|
||||
include /etc/logrotate.d`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="tip" title={t("logrotate.tipTitle")}>
|
||||
{t.rich("logrotate.tipBody", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("limits.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("limits.intro")}</p>
|
||||
|
||||
<div className="my-4 overflow-x-auto">
|
||||
<table className="min-w-full border border-gray-200 text-sm">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("limits.headerFile")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("limits.headerSets")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{limitRows.map((row, idx) => (
|
||||
<tr key={row.file}>
|
||||
<td className="border border-gray-200 px-3 py-2"><code>{row.file}</code></td>
|
||||
<td className="border border-gray-200 px-3 py-2">{t.rich(`limits.rows.${idx}.sets`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("limits.tipTitle")}>
|
||||
{t.rich("limits.tipBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("memory.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("memory.intro", { code })}
|
||||
</p>
|
||||
|
||||
<CopyableCode
|
||||
code={`# /etc/sysctl.d/99-memory.conf
|
||||
vm.swappiness = 10 # Avoid swapping unless truly necessary
|
||||
vm.dirty_ratio = 15 # Start writeback sooner (default 20)
|
||||
vm.dirty_background_ratio = 5 # Start async writeback earlier (default 10)
|
||||
vm.overcommit_memory = 1 # Allow overcommit (needed by many applications)
|
||||
vm.max_map_count = 262144 # Enough for modern apps (ES, Docker, some games)
|
||||
vm.compaction_proactiveness = 20 # Only on kernels that support it`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="warning" title={t("memory.warnTitle")}>
|
||||
{t.rich("memory.warnBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("kexec.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("kexec.intro", { code, em })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("kexec.installsTitle")}</h3>
|
||||
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-4">
|
||||
{installsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`kexec.installsItems.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("kexec.usageIntro", { code })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`reboot-quick # kexec into the already-loaded kernel
|
||||
# Equivalent:
|
||||
systemctl kexec`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="warning" title={t("kexec.warnTitle")}>
|
||||
{t.rich("kexec.warnBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("panic.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("panic.intro", { strong })}
|
||||
</p>
|
||||
|
||||
<CopyableCode
|
||||
code={`# /etc/sysctl.d/99-kernelpanic.conf
|
||||
kernel.core_pattern = /var/crash/core.%t.%p # where to drop core dumps
|
||||
kernel.panic = 10 # reboot 10s after a panic
|
||||
kernel.panic_on_oops = 1 # oops → treated as panic
|
||||
kernel.hardlockup_panic = 1 # hard lockup → panic → reboot`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="tip" title={t("panic.tipTitle")}>
|
||||
{t.rich("panic.tipBody", { em, link: kexecLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("verify.heading")}</h2>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("verify.intro")}</p>
|
||||
<CopyableCode
|
||||
code={`# journald: actual size in use and limit
|
||||
journalctl --disk-usage
|
||||
|
||||
# logrotate: check config is active (no errors)
|
||||
logrotate -d /etc/logrotate.conf 2>&1 | head -20
|
||||
|
||||
# System limits: check a few effective values
|
||||
sysctl fs.inotify.max_user_watches fs.file-max vm.swappiness vm.dirty_ratio
|
||||
ulimit -n # inside a new root shell
|
||||
|
||||
# kexec: service enabled
|
||||
systemctl is-enabled kexec-pve
|
||||
|
||||
# kernel panic config
|
||||
sysctl kernel.panic kernel.panic_on_oops kernel.hardlockup_panic`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="tip" title={t("verify.tipTitle")}>
|
||||
{t.rich("verify.tipBody", { code, link: uninstallLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
161
web/app/[locale]/docs/post-install/uninstall/page.tsx
Normal file
161
web/app/[locale]/docs/post-install/uninstall/page.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { Steps } from "@/components/ui/steps"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.uninstall.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type Step = {
|
||||
title: string
|
||||
body1: string
|
||||
body2?: string
|
||||
items?: string[]
|
||||
}
|
||||
type ReversibleItem = { tool: string; restores: string }
|
||||
type ReversibleGroup = { title: string; items: ReversibleItem[] }
|
||||
type RelatedItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function UninstallOptimizationsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.uninstall" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { postInstall: { uninstall: {
|
||||
howWorks: { steps: Step[] }
|
||||
reversible: { groups: ReversibleGroup[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const steps = messages.docs.postInstall.uninstall.howWorks.steps
|
||||
const groups = messages.docs.postInstall.uninstall.reversible.groups
|
||||
const relatedItems = messages.docs.postInstall.uninstall.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const postInstallLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { strong, code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("openMenu.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("openMenu.body", { strong, em })}
|
||||
</p>
|
||||
|
||||
<Image
|
||||
src="/post-install/post-install-uninstall.png"
|
||||
alt={t("openMenu.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howWorks.heading")}</h2>
|
||||
|
||||
<Steps>
|
||||
{steps.map((step, idx) => (
|
||||
<Steps.Step key={idx} title={step.title}>
|
||||
<p className="mb-3 text-gray-800">
|
||||
{t.rich(`howWorks.steps.${idx}.body1`, { code, em, strong })}
|
||||
</p>
|
||||
{step.items && (
|
||||
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-3">
|
||||
{step.items.map((_, iIdx) => (
|
||||
<li key={iIdx}>{t.rich(`howWorks.steps.${idx}.items.${iIdx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{step.body2 && (
|
||||
<p className="text-gray-800">
|
||||
{t.rich(`howWorks.steps.${idx}.body2`, { code })}
|
||||
</p>
|
||||
)}
|
||||
</Steps.Step>
|
||||
))}
|
||||
</Steps>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("reversible.heading")}</h2>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t("reversible.intro")}</p>
|
||||
|
||||
{groups.map((group) => (
|
||||
<div key={group.title} className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{group.title}</h3>
|
||||
<dl className="divide-y divide-gray-200 border border-gray-200 rounded-md overflow-hidden">
|
||||
{group.items.map((item) => (
|
||||
<div key={item.tool} className="grid grid-cols-1 sm:grid-cols-3 gap-2 px-4 py-3 bg-white">
|
||||
<dt className="font-medium text-gray-900 text-sm">{item.tool}</dt>
|
||||
<dd className="sm:col-span-2 text-sm text-gray-700 leading-relaxed m-0">{item.restores}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("edge.heading")}</h2>
|
||||
|
||||
<Callout variant="warning" title={t("edge.packageTitle")}>
|
||||
{t.rich("edge.packageBody", { strong, code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="warning" title={t("edge.rebootTitle")}>
|
||||
{t.rich("edge.rebootBody", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("edge.perItemTitle")}>
|
||||
{t.rich("edge.perItemBody", { em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("inspect.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("inspect.intro")}</p>
|
||||
<CopyableCode code={`cat /usr/local/share/proxmenux/installed_tools.json | jq`} className="my-4" />
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("inspect.outro", { code })}
|
||||
</p>
|
||||
|
||||
<Callout variant="info" title={t("inspect.reinstallTitle")}>
|
||||
{t.rich("inspect.reinstallBody", { link: postInstallLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
190
web/app/[locale]/docs/post-install/updates/page.tsx
Normal file
190
web/app/[locale]/docs/post-install/updates/page.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { Steps } from "@/components/ui/steps"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.updates.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
alternates: { canonical: "https://proxmenux.com/docs/post-install/updates" },
|
||||
}
|
||||
}
|
||||
|
||||
type Step = { title: string; body: string }
|
||||
type DiffRow = { pathLabel: string; pathHref: string | null; scope: string; when: string }
|
||||
|
||||
export default async function PostInstallUpdatesPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.updates" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { postInstall: { updates: {
|
||||
detection: { steps: Step[] }
|
||||
applying: { steps: Step[] }
|
||||
differs: { rows: DiffRow[] }
|
||||
} } }
|
||||
}
|
||||
const detectionSteps = messages.docs.postInstall.updates.detection.steps
|
||||
const applyingSteps = messages.docs.postInstall.updates.applying.steps
|
||||
const diffRows = messages.docs.postInstall.updates.differs.rows
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const optimizationsLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard/settings#proxmenux-optimizations" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const settingsLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard/settings" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={4}
|
||||
scriptPath="scripts/post_install/update_post_install_function.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("why.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("why.body", { em })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("detection.heading")}</h2>
|
||||
|
||||
<Steps>
|
||||
{detectionSteps.map((step, idx) => (
|
||||
<Steps.Step key={idx} title={step.title}>
|
||||
<p className="mb-2 text-gray-800">
|
||||
{t.rich(`detection.steps.${idx}.body`, { code, em })}
|
||||
</p>
|
||||
</Steps.Step>
|
||||
))}
|
||||
</Steps>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("pathA.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("pathA.intro", { strong, em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image
|
||||
src="/post-install/post-install-updates-menu.png"
|
||||
alt={t("pathA.menuAlt")}
|
||||
width={1200}
|
||||
height={680}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("pathA.menuCaption", { em })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("pathA.checklistBody", { code })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image
|
||||
src="/post-install/post-install-updates-checklist.png"
|
||||
alt={t("pathA.checklistAlt")}
|
||||
width={1200}
|
||||
height={680}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("pathA.checklistCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("pathB.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("pathB.intro", { strong, link: optimizationsLink })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image
|
||||
src="/monitor/settings/proxmenux-optimizations-update-banner.png"
|
||||
alt={t("pathB.imageAlt")}
|
||||
width={2000}
|
||||
height={1146}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("pathB.imageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("applying.heading")}</h2>
|
||||
|
||||
<Steps>
|
||||
{applyingSteps.map((step, idx) => (
|
||||
<Steps.Step key={idx} title={step.title}>
|
||||
<p className="mb-2 text-gray-800">
|
||||
{t.rich(`applying.steps.${idx}.body`, { code, strong })}
|
||||
</p>
|
||||
</Steps.Step>
|
||||
))}
|
||||
</Steps>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("differs.heading")}</h2>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("differs.headerPath")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("differs.headerScope")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("differs.headerWhen")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{diffRows.map((row, idx) => (
|
||||
<tr key={row.pathLabel} className={idx < diffRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{row.pathHref ? (
|
||||
<Link href={row.pathHref} className="text-blue-600 hover:underline">
|
||||
{row.pathLabel}
|
||||
</Link>
|
||||
) : (
|
||||
<strong>{row.pathLabel}</strong>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`differs.rows.${idx}.scope`, { em })}</td>
|
||||
<td className="px-3 py-2 align-top">{row.when}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("notifTitle")}>
|
||||
{t.rich("notifBody", { em, link: settingsLink })}
|
||||
</Callout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
221
web/app/[locale]/docs/post-install/virtualization/page.tsx
Normal file
221
web/app/[locale]/docs/post-install/virtualization/page.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.virtualization.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type GuestRow = { detected: string; package: string }
|
||||
type BootRow = { boot: string; file: string; post: string }
|
||||
type RelatedItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function PostInstallVirtualizationPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.postInstall.virtualization" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { postInstall: { virtualization: {
|
||||
guestAgent: { rows: GuestRow[] }
|
||||
vfio: {
|
||||
whoItems: string[]
|
||||
bootRows: BootRow[]
|
||||
pathItems: string[]
|
||||
}
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const v = messages.docs.postInstall.virtualization
|
||||
const guestRows = v.guestAgent.rows
|
||||
const whoItems = v.vfio.whoItems
|
||||
const bootRows = v.vfio.bootRows
|
||||
const pathItems = v.vfio.pathItems
|
||||
const relatedItems = v.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const nvidiaHostLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/nvidia-host" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const uninstallLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/uninstall" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
section={t("header.section")}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("guestAgent.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("guestAgent.intro", { code })}
|
||||
</p>
|
||||
|
||||
<div className="my-4 overflow-x-auto">
|
||||
<table className="min-w-full border border-gray-200 text-sm">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("guestAgent.headerDetected")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("guestAgent.headerPackage")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{guestRows.map((row) => (
|
||||
<tr key={row.detected}>
|
||||
<td className="border border-gray-200 px-3 py-2">{row.detected}</td>
|
||||
<td className="border border-gray-200 px-3 py-2"><code>{row.package}</code></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("guestAgent.skipTitle")}>
|
||||
{t.rich("guestAgent.skipBody", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("vfio.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("vfio.intro", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("vfio.whoTitle")}</h3>
|
||||
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-4">
|
||||
{whoItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`vfio.whoItems.${idx}`, { em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("vfio.whoOutro", { strong, em })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("vfio.doesTitle")}</h3>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("vfio.doesIntro")}</p>
|
||||
|
||||
<div className="my-4 overflow-x-auto">
|
||||
<table className="min-w-full border border-gray-200 text-sm">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("vfio.headerBoot")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("vfio.headerFile")}</th>
|
||||
<th className="border border-gray-200 px-3 py-2 text-left text-gray-900">{t("vfio.headerPost")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{bootRows.map((row) => (
|
||||
<tr key={row.boot}>
|
||||
<td className="border border-gray-200 px-3 py-2">{row.boot}</td>
|
||||
<td className="border border-gray-200 px-3 py-2"><code>{row.file}</code></td>
|
||||
<td className="border border-gray-200 px-3 py-2"><code>{row.post}</code></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("vfio.kernelIntro")}</p>
|
||||
<CopyableCode
|
||||
code={`# Intel CPU → intel_iommu=on
|
||||
# AMD CPU → amd_iommu=on
|
||||
# Plus these in both cases:
|
||||
iommu=pt
|
||||
pcie_acs_override=downstream,multifunction`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("vfio.modulesIntro", { code })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`vfio
|
||||
vfio_iommu_type1
|
||||
vfio_pci
|
||||
vfio_virqfd # only on kernels < 6.2 (merged into vfio in 6.2+)`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("vfio.blacklistIntro", { code })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`nouveau
|
||||
lbm-nouveau
|
||||
radeon
|
||||
nvidia
|
||||
nvidiafb
|
||||
options nouveau modeset=0`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="warning" title={t("vfio.blacklistTitle")}>
|
||||
{t.rich("vfio.blacklistBody", { em, strong, link: nvidiaHostLink })}
|
||||
</Callout>
|
||||
|
||||
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-4">
|
||||
{pathItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`vfio.pathItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="warning" title={t("vfio.rebootTitle")}>
|
||||
{t.rich("vfio.rebootBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("vfio.verifyTitle")}</h3>
|
||||
<CopyableCode
|
||||
code={`# IOMMU is actually on
|
||||
dmesg | grep -E "DMAR|IOMMU" | head
|
||||
# Expect lines like "IOMMU enabled" / "DMAR: IOMMU enabled"
|
||||
|
||||
# VFIO modules loaded
|
||||
lsmod | grep vfio
|
||||
|
||||
# See your IOMMU groups — each "Group N" can be passed independently
|
||||
for d in /sys/kernel/iommu_groups/*/devices/*; do
|
||||
n=${"${d#*/iommu_groups/*}"}; n=${"${n%%/*}"}
|
||||
printf 'Group %s ' "$n"; lspci -nns "${"${d##*/}"}"
|
||||
done | sort -V`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="tip" title={t("vfio.revertTitle")}>
|
||||
{t.rich("vfio.revertBody", { code, link: uninstallLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
282
web/app/[locale]/docs/security/fail2ban/page.tsx
Normal file
282
web/app/[locale]/docs/security/fail2ban/page.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { DataFlowDiagram } from "@/components/ui/data-flow-diagram"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.security.fail2ban.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/security/fail2ban",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type JailRow = { jail: string; protects: string; retries: string; ban: string }
|
||||
type LoggerRow = { service: string; source: string; output: string }
|
||||
type ManageRow = { action: string; what: string }
|
||||
|
||||
export default async function Fail2BanPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.security.fail2ban" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { security: { fail2ban: {
|
||||
jails: { rows: JailRow[] }
|
||||
loggers: { rows: LoggerRow[] }
|
||||
manage: { rows: ManageRow[] }
|
||||
hardening: { items: string[] }
|
||||
} } }
|
||||
}
|
||||
const block = messages.docs.security.fail2ban
|
||||
const jailRows = block.jails.rows
|
||||
const loggerRows = block.loggers.rows
|
||||
const manageRows = block.manage.rows
|
||||
const hardeningItems = block.hardening.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const codeNw = (chunks: React.ReactNode) => <code className="whitespace-nowrap">{chunks}</code>
|
||||
const codeXs = (chunks: React.ReactNode) => <code className="text-xs">{chunks}</code>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={8}
|
||||
scriptPath="security/fail2ban_installer.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t("intro.body")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("firstLaunch.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t("firstLaunch.body")}
|
||||
</p>
|
||||
|
||||
<Image
|
||||
src="/security/fail2ban-install.png"
|
||||
alt={t("firstLaunch.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("jails.heading")}</h2>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("jails.headerJail")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("jails.headerProtects")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("jails.headerRetries")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("jails.headerBan")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{jailRows.map((row, idx) => (
|
||||
<tr key={row.jail} className={idx < jailRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.jail}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{row.protects}</td>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">{row.retries}</td>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">{row.ban}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("jails.outro", { code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("journald.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("journald.intro", { code, codeNw, em })}
|
||||
</p>
|
||||
|
||||
<DataFlowDiagram
|
||||
nodes={[
|
||||
{
|
||||
label: t("journald.diagram.sshLabel"),
|
||||
detail: t("journald.diagram.sshDetail"),
|
||||
variant: "source",
|
||||
},
|
||||
{
|
||||
label: t("journald.diagram.journaldLabel"),
|
||||
detail: t("journald.diagram.journaldDetail"),
|
||||
variant: "bridge",
|
||||
},
|
||||
{
|
||||
label: t("journald.diagram.fail2banLabel"),
|
||||
detail: t("journald.diagram.fail2banDetail"),
|
||||
variant: "target",
|
||||
},
|
||||
]}
|
||||
arrowLabel={t("journald.diagram.arrowLabel")}
|
||||
/>
|
||||
|
||||
<p className="mt-6 mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("journald.afterDiagram", { code, codeXs })}
|
||||
</p>
|
||||
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("journald.code") as string}</pre>
|
||||
|
||||
<p className="mt-4 mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("journald.outro", { code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("loggers.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("loggers.intro1", { code })}
|
||||
</p>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("loggers.intro2", { code })}
|
||||
</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("loggers.headerService")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("loggers.headerSource")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("loggers.headerOutput")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{loggerRows.map((row, idx) => (
|
||||
<tr key={row.service} className={idx < loggerRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs"><strong>{row.service}</strong></td>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.source}</td>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.output}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("loggers.outro", { code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("backend.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("backend.intro", { code })}
|
||||
</p>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("backend.code") as string}</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("hardening.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("hardening.intro", { code, strong })}
|
||||
</p>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t("hardening.installerIntro")}
|
||||
</p>
|
||||
<ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{hardeningItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`hardening.items.${idx}`, { code, strong })}</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("hardening.outro", { code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manage.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t("manage.intro")}
|
||||
</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("manage.headerAction")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("manage.headerWhat")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{manageRows.map((row, idx) => (
|
||||
<tr key={row.action} className={idx < manageRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.action}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{row.what}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("verify.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t("verify.intro")}
|
||||
</p>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("verify.code") as string}</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.neverBansTitle")}>
|
||||
{t.rich("troubleshoot.neverBansBody", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.monitorEmptyTitle")}>
|
||||
{t.rich("troubleshoot.monitorEmptyBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.selfBanTitle")}>
|
||||
{t("troubleshoot.selfBanIntro")}
|
||||
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{t.raw("troubleshoot.selfBanCode") as string}</pre>
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.aptFailTitle")}>
|
||||
{t.rich("troubleshoot.aptFailBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.lockoutTitle")}>
|
||||
{t.rich("troubleshoot.lockoutBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("files.heading")}</h2>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("files.code") as string}</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
<li>
|
||||
<Link href="/docs/monitor/dashboard/security" className="text-blue-600 hover:underline">
|
||||
{t("related.monitorLabel")}
|
||||
</Link>
|
||||
{t("related.monitorTail")}
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/docs/security/lynis" className="text-blue-600 hover:underline">
|
||||
{t("related.lynisLabel")}
|
||||
</Link>
|
||||
{t("related.lynisTail")}
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/docs/security" className="text-blue-600 hover:underline">
|
||||
{t("related.securityLabel")}
|
||||
</Link>
|
||||
{t("related.securityTail")}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
280
web/app/[locale]/docs/security/lynis/page.tsx
Normal file
280
web/app/[locale]/docs/security/lynis/page.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { DataFlowDiagram } from "@/components/ui/data-flow-diagram"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.security.lynis.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/security/lynis",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type WhyRow = { sourceRich: string; path: string; update: string; fresh: string }
|
||||
type ReportRow = { markerRich: string; meaning: string; action: string }
|
||||
type ReinstallRow = { actionRich: string; whatRich: string }
|
||||
type RelatedItem = { href: string; label: string; tail?: string; tailRich?: string }
|
||||
|
||||
export default async function LynisPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.security.lynis" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { security: { lynis: {
|
||||
detection: { items: string[] }
|
||||
whyUpstream: { rows: WhyRow[] }
|
||||
report: { rows: ReportRow[] }
|
||||
reinstall: { rows: ReinstallRow[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const block = messages.docs.security.lynis
|
||||
const detectionItems = block.detection.items
|
||||
const whyRows = block.whyUpstream.rows
|
||||
const reportRows = block.report.rows
|
||||
const reinstallRows = block.reinstall.rows
|
||||
const relatedItems = block.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const ok = (chunks: React.ReactNode) => <code className="text-emerald-700">{chunks}</code>
|
||||
const warn = (chunks: React.ReactNode) => <code className="text-amber-700">{chunks}</code>
|
||||
const sugg = (chunks: React.ReactNode) => <code className="text-red-700">{chunks}</code>
|
||||
const linkFail2ban = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/security/fail2ban" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const linkSecurityTab = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard/security" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={5}
|
||||
scriptPath="security/lynis_installer.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manageMenu.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("manageMenu.intro")}</p>
|
||||
|
||||
<Image
|
||||
src="/security/lynis-menu.png"
|
||||
alt={t("manageMenu.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whyUpstream.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("whyUpstream.intro", { code })}</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("whyUpstream.headerSource")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("whyUpstream.headerPath")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("whyUpstream.headerUpdate")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("whyUpstream.headerFresh")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{whyRows.map((row, idx) => (
|
||||
<tr key={idx} className={idx < whyRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">{t.rich(`whyUpstream.rows.${idx}.sourceRich`, { strong })}</td>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.path}</td>
|
||||
<td className="px-3 py-2 align-top">{row.update}</td>
|
||||
<td className="px-3 py-2 align-top">{row.fresh}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("install.heading")}</h2>
|
||||
|
||||
<DataFlowDiagram
|
||||
nodes={[
|
||||
{
|
||||
label: t("install.node1Label"),
|
||||
detail: t("install.node1Detail"),
|
||||
variant: "source",
|
||||
},
|
||||
{
|
||||
label: t("install.node2Label"),
|
||||
detail: t("install.node2Detail"),
|
||||
variant: "bridge",
|
||||
},
|
||||
{
|
||||
label: t("install.node3Label"),
|
||||
detail: t("install.node3Detail"),
|
||||
variant: "target",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<p className="mt-6 mb-4 text-gray-800 leading-relaxed">{t.rich("install.outro", { code })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("detection.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("detection.intro")}</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{detectionItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`detection.items.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t.rich("detection.outro", { code, strong })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("audit.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("audit.intro", { strong })}</p>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("audit.code") as string}</pre>
|
||||
<p className="mt-4 mb-4 text-gray-800 leading-relaxed">{t.rich("audit.outro", { code, ok, warn, sugg })}</p>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("audit.summary") as string}</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("report.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("report.intro", { code, strong })}</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("report.headerMarker")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("report.headerMeaning")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("report.headerAction")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{reportRows.map((row, idx) => (
|
||||
<tr key={idx} className={idx < reportRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">{t.rich(`report.rows.${idx}.markerRich`, { strong })}</td>
|
||||
<td className="px-3 py-2 align-top">{row.meaning}</td>
|
||||
<td className="px-3 py-2 align-top">{row.action}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t.rich("report.outro", { code })}</p>
|
||||
|
||||
<Callout variant="tip" title={t("pairFail2ban.title")}>
|
||||
{t.rich("pairFail2ban.body", { code, link: linkFail2ban })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("update.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("update.body", { code, strong })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("reinstall.heading")}</h2>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("reinstall.headerAction")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("reinstall.headerWhat")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{reinstallRows.map((_, idx) => (
|
||||
<tr key={idx} className={idx < reinstallRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">{t.rich(`reinstall.rows.${idx}.actionRich`, { strong })}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`reinstall.rows.${idx}.whatRich`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("cli.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("cli.intro")}</p>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("cli.code") as string}</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.cloneTitle")}>
|
||||
{t.rich("troubleshoot.cloneBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.notFoundTitle")}>
|
||||
{t.rich("troubleshoot.notFoundIntro", { code })}
|
||||
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{t.raw("troubleshoot.notFoundCode") as string}</pre>
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.sshTitle")}>
|
||||
{t.rich("troubleshoot.sshIntro", { code, link: linkFail2ban })}
|
||||
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{t.raw("troubleshoot.sshCode") as string}</pre>
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.scoreTitle")}>
|
||||
{t.rich("troubleshoot.scoreBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("files.heading")}</h2>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("files.code") as string}</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("sample.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("sample.intro", { link: linkSecurityTab })}</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image
|
||||
src="/monitor/security/lynis-report-pdf.png"
|
||||
alt={t("sample.imageAlt")}
|
||||
width={1414}
|
||||
height={2000}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("sample.captionPrefix")}
|
||||
<a
|
||||
href="/monitor/security/lynis-sample-report.pdf"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("sample.captionLink")}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
{t("sample.captionSuffix")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed text-sm">{t.rich("sample.cli", { code })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`related.items.${idx}.tailRich`, { code }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
200
web/app/[locale]/docs/security/page.tsx
Normal file
200
web/app/[locale]/docs/security/page.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { ArrowRight, Ban, ShieldCheck, ScanLine } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.security.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox fail2ban",
|
||||
"proxmox lynis",
|
||||
"proxmox security",
|
||||
"proxmox hardening",
|
||||
"proxmox intrusion prevention",
|
||||
"proxmox security audit",
|
||||
"proxmox ssh fail2ban",
|
||||
"proxmox web ui fail2ban",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/security" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/security",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type StringItem = string
|
||||
|
||||
interface OptionProps {
|
||||
title: string
|
||||
description: string
|
||||
Icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>
|
||||
href: string
|
||||
}
|
||||
|
||||
function OptionCard({ title, description, Icon, href }: OptionProps) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="group flex items-start gap-3 rounded-md border border-gray-200 bg-white p-3 transition-colors hover:border-blue-400 hover:bg-blue-50"
|
||||
>
|
||||
<span className="inline-flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-gray-100 text-gray-600 group-hover:bg-blue-100 group-hover:text-blue-700">
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1 text-sm font-medium text-gray-900 group-hover:text-blue-700">
|
||||
{title}
|
||||
<ArrowRight className="h-3.5 w-3.5 text-gray-400 group-hover:text-blue-600 transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-gray-600 leading-snug">{description}</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function SecurityOverviewPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.security" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { security: {
|
||||
cards: {
|
||||
fail2ban: { bullets: StringItem[] }
|
||||
lynis: { bullets: StringItem[] }
|
||||
}
|
||||
} }
|
||||
}
|
||||
const fail2banBullets = messages.docs.security.cards.fail2ban.bullets
|
||||
const lynisBullets = messages.docs.security.cards.lynis.bullets
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={4}
|
||||
scriptPath="menus/security_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { strong, em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("opening.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("opening.body", { strong })}</p>
|
||||
|
||||
<Image
|
||||
src="/security/security-menu.png"
|
||||
alt={t("opening.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("pick.heading")}</h2>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t("pick.body")}</p>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-2 mb-8 not-prose">
|
||||
<a
|
||||
href="#fail2ban"
|
||||
className="rounded-lg border-2 border-red-300 bg-red-50 p-5 flex flex-col transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-red-100 text-red-700">
|
||||
<Ban className="h-5 w-5" aria-hidden />
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900 m-0">{t("cards.fail2ban.title")}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-800 mb-3">{t("cards.fail2ban.body")}</p>
|
||||
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
|
||||
{fail2banBullets.map((_, idx) => (
|
||||
<li key={idx}>{t(`cards.fail2ban.bullets.${idx}`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#lynis"
|
||||
className="rounded-lg border-2 border-blue-300 bg-blue-50 p-5 flex flex-col transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-blue-100 text-blue-700">
|
||||
<ScanLine className="h-5 w-5" aria-hidden />
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900 m-0">{t("cards.lynis.title")}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-800 mb-3">{t("cards.lynis.body")}</p>
|
||||
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
|
||||
{lynisBullets.map((_, idx) => (
|
||||
<li key={idx}>{t(`cards.lynis.bullets.${idx}`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("workflowTip.title")}>
|
||||
{t.rich("workflowTip.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 id="fail2ban" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24">
|
||||
{t("fail2banSection.heading")}
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("fail2banSection.body")}</p>
|
||||
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
|
||||
<OptionCard
|
||||
title={t("fail2banSection.optionTitle")}
|
||||
description={t("fail2banSection.optionDescription")}
|
||||
Icon={Ban}
|
||||
href="/docs/security/fail2ban"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 id="lynis" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24">
|
||||
{t("lynisSection.heading")}
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("lynisSection.body", { code })}</p>
|
||||
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
|
||||
<OptionCard
|
||||
title={t("lynisSection.optionTitle")}
|
||||
description={t("lynisSection.optionDescription")}
|
||||
Icon={ScanLine}
|
||||
href="/docs/security/lynis"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
|
||||
<ShieldCheck className="inline h-5 w-5 mr-1 -mt-1 text-emerald-600" aria-hidden />
|
||||
{t("componentStatus.heading")}
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("componentStatus.body", { code })}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
360
web/app/[locale]/docs/security/ssl-letsencrypt/page.tsx
Normal file
360
web/app/[locale]/docs/security/ssl-letsencrypt/page.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import type { Metadata } from "next"
|
||||
import Image from "next/image"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.security.sslLetsencrypt.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/security/ssl-letsencrypt",
|
||||
},
|
||||
alternates: { canonical: "https://proxmenux.com/docs/security/ssl-letsencrypt" },
|
||||
}
|
||||
}
|
||||
|
||||
type StringItem = string
|
||||
type TableRow = {
|
||||
fileRich: string
|
||||
originRich?: string
|
||||
origin?: string
|
||||
when?: string
|
||||
whenRich?: string
|
||||
}
|
||||
|
||||
export default async function SslLetsEncryptPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.security.sslLetsencrypt" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: {
|
||||
security: {
|
||||
sslLetsencrypt: {
|
||||
twoways: {
|
||||
proxmox: { items: StringItem[] }
|
||||
custom: { items: StringItem[] }
|
||||
}
|
||||
proxmoxCert: { table: { rows: TableRow[] } }
|
||||
letsencrypt: { prereqs: { items: StringItem[] } }
|
||||
custom: { items: StringItem[] }
|
||||
trustCa: { items: StringItem[] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const block = messages.docs.security.sslLetsencrypt
|
||||
const proxmoxItems = block.twoways.proxmox.items
|
||||
const customItems = block.twoways.custom.items
|
||||
const tableRows = block.proxmoxCert.table.rows
|
||||
const prereqItems = block.letsencrypt.prereqs.items
|
||||
const customListItems = block.custom.items
|
||||
const trustCaItems = block.trustCa.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const br = () => <br />
|
||||
const extlink1 = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://pve.proxmox.com/wiki/Certificate_Management"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
const extlink2 = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://github.com/acmesh-official/acme.sh/wiki/dnsapi"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={5}
|
||||
scriptPath="AppImage/scripts/auth_manager.py"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t("intro.body")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("wheresetting.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("wheresetting.body", { strong })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<Image
|
||||
src="/monitor/security/ssl-https-card.png"
|
||||
alt={t("wheresetting.imageAlt")}
|
||||
width={2000}
|
||||
height={1124}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("wheresetting.caption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("twoways.heading")}</h2>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 mb-8 not-prose">
|
||||
<div className="rounded-lg border-2 border-green-300 bg-green-50 p-5">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t("twoways.proxmox.title")}</h3>
|
||||
<p className="text-sm text-gray-800 mb-3">{t("twoways.proxmox.summary")}</p>
|
||||
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
|
||||
{proxmoxItems.map((_, idx) => (
|
||||
<li key={idx}>{t(`twoways.proxmox.items.${idx}`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-blue-300 bg-blue-50 p-5">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t("twoways.custom.title")}</h3>
|
||||
<p className="text-sm text-gray-800 mb-3">
|
||||
{t.rich("twoways.custom.summaryRich", { code })}
|
||||
</p>
|
||||
<ul className="space-y-1 text-sm text-gray-700 list-disc pl-5 mb-0 marker:text-gray-400">
|
||||
{customItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`twoways.custom.items.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
|
||||
{t("proxmoxCert.heading")}
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("proxmoxCert.intro", { code })}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">
|
||||
{t("proxmoxCert.table.headers.file")}
|
||||
</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">
|
||||
{t("proxmoxCert.table.headers.origin")}
|
||||
</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">
|
||||
{t("proxmoxCert.table.headers.when")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{tableRows.map((row, idx) => (
|
||||
<tr
|
||||
key={idx}
|
||||
className={idx < tableRows.length - 1 ? "border-b border-gray-100" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">
|
||||
{t.rich(`proxmoxCert.table.rows.${idx}.fileRich`, { code, br })}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{row.originRich
|
||||
? t.rich(`proxmoxCert.table.rows.${idx}.originRich`, { code, strong, em })
|
||||
: t(`proxmoxCert.table.rows.${idx}.origin`)}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{row.whenRich
|
||||
? t.rich(`proxmoxCert.table.rows.${idx}.whenRich`, { code })
|
||||
: t(`proxmoxCert.table.rows.${idx}.when`)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("proxmoxCert.callout.title")}>
|
||||
{t.rich("proxmoxCert.callout.bodyRich", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
|
||||
{t("letsencrypt.heading")}
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("letsencrypt.intro", { code, em, extlink1 })}
|
||||
</p>
|
||||
|
||||
<Callout variant="info" title={t("letsencrypt.prereqs.title")}>
|
||||
<ul className="list-disc pl-5 space-y-1 mb-0">
|
||||
{prereqItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`letsencrypt.prereqs.items.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">
|
||||
{t("letsencrypt.step1.heading")}
|
||||
</h3>
|
||||
<p className="mb-2 text-gray-800 leading-relaxed">
|
||||
{t.rich("letsencrypt.step1.introRich", { code })}
|
||||
</p>
|
||||
<pre className="rounded-md bg-gray-900 text-gray-100 p-4 overflow-x-auto text-xs font-mono mb-4">
|
||||
{t("letsencrypt.step1.code")}
|
||||
</pre>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("letsencrypt.step1.afterRich", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">
|
||||
{t("letsencrypt.step2.heading")}
|
||||
</h3>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("letsencrypt.step2.http01Rich", { code, strong })}
|
||||
</p>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("letsencrypt.step2.dns01Rich", { strong })}
|
||||
</p>
|
||||
<pre className="rounded-md bg-gray-900 text-gray-100 p-4 overflow-x-auto text-xs font-mono mb-4">
|
||||
{t("letsencrypt.step2.code")}
|
||||
</pre>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("letsencrypt.step2.outroRich", { code, extlink2 })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">
|
||||
{t("letsencrypt.step3.heading")}
|
||||
</h3>
|
||||
<p className="mb-2 text-gray-800 leading-relaxed">
|
||||
{t.rich("letsencrypt.step3.http01Rich", { code })}
|
||||
</p>
|
||||
<pre className="rounded-md bg-gray-900 text-gray-100 p-4 overflow-x-auto text-xs font-mono mb-3">
|
||||
{t("letsencrypt.step3.code1")}
|
||||
</pre>
|
||||
<p className="mb-2 text-gray-800 leading-relaxed">{t("letsencrypt.step3.dns01")}</p>
|
||||
<pre className="rounded-md bg-gray-900 text-gray-100 p-4 overflow-x-auto text-xs font-mono mb-4">
|
||||
{t("letsencrypt.step3.code2")}
|
||||
</pre>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("letsencrypt.step3.wildcardRich", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">
|
||||
{t("letsencrypt.step4.heading")}
|
||||
</h3>
|
||||
<pre className="rounded-md bg-gray-900 text-gray-100 p-4 overflow-x-auto text-xs font-mono mb-3">
|
||||
{t("letsencrypt.step4.code")}
|
||||
</pre>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("letsencrypt.step4.afterRich", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">
|
||||
{t("letsencrypt.step5.heading")}
|
||||
</h3>
|
||||
<pre className="rounded-md bg-gray-900 text-gray-100 p-4 overflow-x-auto text-xs font-mono mb-3">
|
||||
{t("letsencrypt.step5.code")}
|
||||
</pre>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("letsencrypt.step5.afterRich", { code })}
|
||||
</p>
|
||||
|
||||
<Callout variant="tip" title={t("letsencrypt.gui.title")}>
|
||||
{t.rich("letsencrypt.gui.bodyRich", { em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
|
||||
{t("switchToHttps.heading")}
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("switchToHttps.bodyRich", { code, strong, em })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("custom.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("custom.intro", { strong })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-1 text-gray-800 mb-4">
|
||||
{customListItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`custom.items.${idx}`, { code, strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("custom.outro")}</p>
|
||||
|
||||
<pre className="rounded-md bg-gray-900 text-gray-100 p-4 overflow-x-auto text-xs font-mono mb-4">
|
||||
{t("custom.code")}
|
||||
</pre>
|
||||
|
||||
<Callout variant="warning" title={t("custom.symlinkCallout.title")}>
|
||||
{t.rich("custom.symlinkCallout.bodyRich", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("afterHttps.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("afterHttps.bodyRich", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">
|
||||
{t("afterHttps.reverse.heading")}
|
||||
</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("afterHttps.reverse.bodyRich", { code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("trustCa.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("trustCa.intro1Rich", { code })}
|
||||
</p>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("trustCa.intro2Rich", { code })}
|
||||
</p>
|
||||
<pre className="rounded-md bg-gray-900 text-gray-100 p-4 overflow-x-auto text-xs font-mono mb-4">
|
||||
{t("trustCa.code")}
|
||||
</pre>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("trustCa.thenImport")}</p>
|
||||
<ul className="list-disc pl-6 space-y-2 text-gray-800 mb-4">
|
||||
{trustCaItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`trustCa.items.${idx}`, { code, strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Callout variant="info" title={t("trustCa.standalone.title")}>
|
||||
{t.rich("trustCa.standalone.bodyRich", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("disable.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("disable.bodyRich", { strong })}
|
||||
</p>
|
||||
|
||||
<Callout variant="info" title={t("disable.stateCallout.title")}>
|
||||
{t.rich("disable.stateCallout.bodyRich", { code })}
|
||||
</Callout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
191
web/app/[locale]/docs/settings/beta-program/page.tsx
Normal file
191
web/app/[locale]/docs/settings/beta-program/page.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.settings.betaProgram.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/settings/beta-program",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type StringItem = string
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function BetaProgramPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.settings.betaProgram" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { settings: { betaProgram: {
|
||||
why: { items: StringItem[] }
|
||||
dialog: { options: StringItem[]; directions: StringItem[] }
|
||||
confirm: { items: StringItem[] }
|
||||
switching: { items: StringItem[] }
|
||||
feedback: { items: StringItem[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const block = messages.docs.settings.betaProgram
|
||||
const whyItems = block.why.items
|
||||
const dialogOptions = block.dialog.options
|
||||
const dialogDirections = block.dialog.directions
|
||||
const confirmItems = block.confirm.items
|
||||
const switchingItems = block.switching.items
|
||||
const feedbackItems = block.feedback.items
|
||||
const relatedItems = block.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const ul = (chunks: React.ReactNode) => <ul className="list-disc pl-6 mt-1">{chunks}</ul>
|
||||
const li = (chunks: React.ReactNode) => <li>{chunks}</li>
|
||||
const link = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/settings/show-version-information" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const ghlink = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={3}
|
||||
scriptPath="menus/config_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code, strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("why.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("why.intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{whyItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`why.items.${idx}`, { code, strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t.rich("why.outro", { code })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dialog.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("dialog.intro", { strong })}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{dialogOptions.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`dialog.options.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("dialog.behaviour")}</p>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{dialogDirections.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`dialog.directions.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("confirm.heading")}</h2>
|
||||
<ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{confirmItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`confirm.items.${idx}`, { code, strong, em, ul, li })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("switching.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("switching.intro", { strong })}</p>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{switchingItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`switching.items.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("feedback.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("feedback.intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{feedbackItems.map((_, idx) => (
|
||||
<li key={idx}>{t(`feedback.items.${idx}`)}</li>
|
||||
))}
|
||||
<li>
|
||||
<pre className="mt-2 rounded-md bg-gray-100 p-3 overflow-x-auto text-xs font-mono text-gray-800 border border-gray-200">{t.raw("feedback.logsCommand") as string}</pre>
|
||||
</li>
|
||||
<li>{t.rich("feedback.versionLine", { link })}</li>
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t.rich("feedback.issueLine", { ghlink })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("manual.intro")}</p>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`# Check current channel (returns "beta" or "stable")
|
||||
jq -r '.beta_program.status // "stable"' /usr/local/share/proxmenux/config.json
|
||||
|
||||
# Switch to Stable
|
||||
bash -c "$(wget -qLO - https://raw.githubusercontent.com/MacRimi/ProxMenux/main/install_proxmenux.sh)"
|
||||
|
||||
# Switch to Beta
|
||||
bash -c "$(wget -qLO - https://raw.githubusercontent.com/MacRimi/ProxMenux/develop/install_proxmenux_beta.sh)"`}</pre>
|
||||
|
||||
<Callout variant="info" title={t("unifiedCallout.title")}>
|
||||
{t.rich("unifiedCallout.body", { code, strong, em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.downloadTitle")}>
|
||||
{t.rich("troubleshoot.downloadBody", { code })}
|
||||
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{t.raw("troubleshoot.downloadCmd") as string}</pre>
|
||||
{t("troubleshoot.downloadOutro")}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.errorsTitle")}>
|
||||
{t("troubleshoot.errorsBody")}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.configTitle")}>
|
||||
{t.rich("troubleshoot.configBody", { code })}
|
||||
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{t.raw("troubleshoot.configCmd") as string}</pre>
|
||||
{t("troubleshoot.configOutro")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
136
web/app/[locale]/docs/settings/change-language/page.tsx
Normal file
136
web/app/[locale]/docs/settings/change-language/page.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.settings.changeLanguage.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/settings/change-language",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type LangRow = { code: string; lang: string; notes: string }
|
||||
type StringItem = string
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function ChangeLanguagePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.settings.changeLanguage" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { settings: { changeLanguage: {
|
||||
supported: { rows: LangRow[] }
|
||||
underHood: { items: StringItem[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const langRows = messages.docs.settings.changeLanguage.supported.rows
|
||||
const underHoodItems = messages.docs.settings.changeLanguage.underHood.items
|
||||
const relatedItems = messages.docs.settings.changeLanguage.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={1}
|
||||
scriptPath="menus/config_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="warning" title={t("warn.title")}>
|
||||
{t.rich("warn.body", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("supported.heading")}</h2>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("supported.headerCode")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("supported.headerLang")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("supported.headerNotes")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{langRows.map((row, idx) => (
|
||||
<tr key={row.code} className={idx < langRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap font-mono">{row.code}</td>
|
||||
<td className="px-3 py-2 align-top">{row.lang}</td>
|
||||
<td className="px-3 py-2 align-top">{row.notes}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("englishTip.title")}>
|
||||
{t("englishTip.body")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("underHood.heading")}</h2>
|
||||
<ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{underHoodItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`underHood.items.${idx}`, { code, em })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`# Set language to Spanish
|
||||
tmp=$(mktemp)
|
||||
jq --arg lang "es" '.language = $lang' /usr/local/share/proxmenux/config.json > "$tmp" \\
|
||||
&& mv "$tmp" /usr/local/share/proxmenux/config.json
|
||||
|
||||
# Verify
|
||||
jq -r '.language' /usr/local/share/proxmenux/config.json`}</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.noOptionTitle")}>
|
||||
{t("troubleshoot.noOptionBody")}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.stillEnglishTitle")}>
|
||||
{t.rich("troubleshoot.stillEnglishBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
189
web/app/[locale]/docs/settings/page.tsx
Normal file
189
web/app/[locale]/docs/settings/page.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import {
|
||||
ArrowRight,
|
||||
Activity,
|
||||
TestTube,
|
||||
Languages,
|
||||
Info,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.settings.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmenux settings",
|
||||
"proxmenux monitor activation",
|
||||
"proxmenux beta program",
|
||||
"proxmenux language",
|
||||
"proxmenux uninstall",
|
||||
"proxmenux version",
|
||||
"proxmenux configuration",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/settings" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/settings",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Option = {
|
||||
icon: string
|
||||
href: string
|
||||
title: string
|
||||
description: string
|
||||
badge?: string
|
||||
}
|
||||
type InstallRow = { type: string; bundles: string; menu?: string; menuRich?: string }
|
||||
|
||||
const ICONS: Record<string, React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>> = {
|
||||
Activity,
|
||||
TestTube,
|
||||
Languages,
|
||||
Info,
|
||||
Trash2,
|
||||
}
|
||||
|
||||
function OptionCard({ option }: { option: Option }) {
|
||||
const Icon = ICONS[option.icon] || Activity
|
||||
return (
|
||||
<Link
|
||||
href={option.href}
|
||||
className="group flex items-start gap-3 rounded-md border border-gray-200 bg-white p-3 transition-colors hover:border-blue-400 hover:bg-blue-50"
|
||||
>
|
||||
<span className="inline-flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-gray-100 text-gray-600 group-hover:bg-blue-100 group-hover:text-blue-700">
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-900 group-hover:text-blue-700">
|
||||
<span className="flex items-center gap-1">
|
||||
{option.title}
|
||||
<ArrowRight className="h-3.5 w-3.5 text-gray-400 group-hover:text-blue-600 transition-transform group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
{option.badge && (
|
||||
<span className="inline-flex items-center rounded-full border border-gray-300 bg-gray-50 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-gray-600">
|
||||
{option.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-gray-600 leading-snug">{option.description}</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function SettingsOverviewPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.settings" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { settings: {
|
||||
installTypes: { rows: InstallRow[] }
|
||||
options: { list: Option[] }
|
||||
} }
|
||||
}
|
||||
const installRows = messages.docs.settings.installTypes.rows
|
||||
const optionsList = messages.docs.settings.options.list
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const kbd = (chunks: React.ReactNode) => <kbd>{chunks}</kbd>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={3}
|
||||
scriptPath="menus/config_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t("intro.body")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("opening.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("opening.body", { kbd })}
|
||||
</p>
|
||||
|
||||
<Image
|
||||
src="/settings/settings-menu.png"
|
||||
alt={t("opening.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("installTypes.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("installTypes.intro")}</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("installTypes.headerType")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("installTypes.headerBundles")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("installTypes.headerMenu")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{installRows.map((row, idx) => (
|
||||
<tr key={row.type} className={idx < installRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.type}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{row.bundles}</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{row.menuRich ? t.rich(`installTypes.rows.${idx}.menuRich`, { strong }) : row.menu}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("installTypes.detectionTitle")}>
|
||||
{t.rich("installTypes.detectionBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("options.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("options.intro", { em })}
|
||||
</p>
|
||||
<div className="grid gap-3 md:grid-cols-2 mb-8 not-prose">
|
||||
{optionsList.map((o) => (
|
||||
<OptionCard key={o.href} option={o} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("configTip.title")}>
|
||||
{t.rich("configTip.bodyRich", { code })}
|
||||
</Callout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
181
web/app/[locale]/docs/settings/proxmenux-monitor/page.tsx
Normal file
181
web/app/[locale]/docs/settings/proxmenux-monitor/page.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.settings.proxmenuxMonitor.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/settings/proxmenux-monitor",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type StringItem = string
|
||||
type ToggleRow = { state: string; label: string; action: string }
|
||||
type RelatedItem = { href: string; label: string; tail?: string; tailRich?: string }
|
||||
|
||||
export default async function ProxmenuxMonitorPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.settings.proxmenuxMonitor" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { settings: { proxmenuxMonitor: {
|
||||
offers: { items: StringItem[] }
|
||||
toggle: { rows: ToggleRow[] }
|
||||
status: { items: StringItem[] }
|
||||
reset: { items: StringItem[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const block = messages.docs.settings.proxmenuxMonitor
|
||||
const offersItems = block.offers.items
|
||||
const toggleRows = block.toggle.rows
|
||||
const statusItems = block.status.items
|
||||
const resetItems = block.reset.items
|
||||
const relatedItems = block.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const link = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/access-auth#recovering-password" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={3}
|
||||
scriptPath="menus/config_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t("intro.body")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("offers.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{offersItems.map((_, idx) => (
|
||||
<li key={idx}>{t(`offers.items.${idx}`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("access.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("access.intro")}</p>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("access.url") as string}</pre>
|
||||
<p className="mt-4 mb-6 text-gray-800 leading-relaxed">{t.rich("access.outro", { code })}</p>
|
||||
|
||||
<Callout variant="warning" title={t("warnConditional.title")}>
|
||||
{t.rich("warnConditional.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("toggle.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("toggle.intro")}</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("toggle.headerState")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("toggle.headerLabel")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("toggle.headerAction")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{toggleRows.map((row, idx) => (
|
||||
<tr key={row.state} className={idx < toggleRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.state}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{row.label}</td>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.action}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t.rich("toggle.outro", { em })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("status.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("status.intro", { em })}</p>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{statusItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`status.items.${idx}`, { code, strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="tip" title={t("manual.title")}>
|
||||
{t("manual.intro")}
|
||||
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{t.raw("manual.code") as string}</pre>
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("reset.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("reset.intro")}</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{resetItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`reset.items.${idx}`, { code, strong, em })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<Callout variant="info" title={t("reset.preservedTitle")}>
|
||||
{t.rich("reset.preservedBody", { code, strong })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="warning" title={t("reset.trustTitle")}>
|
||||
{t.rich("reset.trustBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t.rich("reset.seeAlso", { link })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("files.heading")}</h2>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("files.code") as string}</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.missingTitle")}>
|
||||
{t.rich("troubleshoot.missingBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.unreachableTitle")}>
|
||||
{t("troubleshoot.unreachableBody")}
|
||||
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{t.raw("troubleshoot.unreachableCmd") as string}</pre>
|
||||
{t.rich("troubleshoot.unreachableOutro", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.stopsTitle")}>
|
||||
{t.rich("troubleshoot.stopsBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`related.items.${idx}.tailRich`, { code }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
156
web/app/[locale]/docs/settings/show-version-information/page.tsx
Normal file
156
web/app/[locale]/docs/settings/show-version-information/page.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.settings.showVersionInformation.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/settings/show-version-information",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type ReportRow = { section: string; source: string; content?: string; contentRich?: string }
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function ShowVersionInformationPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.settings.showVersionInformation" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { settings: { showVersionInformation: {
|
||||
reports: { rows: ReportRow[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const reportRows = messages.docs.settings.showVersionInformation.reports.rows
|
||||
const relatedItems = messages.docs.settings.showVersionInformation.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={2}
|
||||
scriptPath="menus/config_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t("intro.body")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("reports.heading")}</h2>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("reports.headerSection")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("reports.headerSource")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("reports.headerContent")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{reportRows.map((row, idx) => (
|
||||
<tr key={row.section} className={idx < reportRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.section}</strong></td>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.source}</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{row.contentRich ? t.rich(`reports.rows.${idx}.contentRich`, { code }) : row.content}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("sampleHeading")}</h2>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`Current ProxMenux version: 1.2.3
|
||||
|
||||
Installation type:
|
||||
✓ Translation Version (Multi-language support)
|
||||
|
||||
Installed components:
|
||||
✓ post_install_settings: installed
|
||||
✓ post_install_system: installed
|
||||
✓ post_install_security: installed
|
||||
✓ proxmenux_monitor: installed
|
||||
✓ fail2ban: installed
|
||||
✗ lynis: removed
|
||||
|
||||
ProxMenux files:
|
||||
✓ menu → /usr/local/bin/menu
|
||||
✓ utils.sh → /usr/local/share/proxmenux/utils.sh
|
||||
✓ config.json → /usr/local/share/proxmenux/config.json
|
||||
✓ version.txt → /usr/local/share/proxmenux/version.txt
|
||||
✓ cache.json → /usr/local/share/proxmenux/cache.json
|
||||
|
||||
Virtual Environment:
|
||||
✓ Installed → /opt/googletrans-env
|
||||
✓ pip: Installed → /opt/googletrans-env/bin/pip
|
||||
|
||||
Current language:
|
||||
es`}</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manualHeading")}</h2>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`# Version
|
||||
cat /usr/local/share/proxmenux/version.txt
|
||||
|
||||
# Install type detection (replicates the script's logic)
|
||||
[[ -d /opt/googletrans-env ]] && echo "venv: yes" || echo "venv: no"
|
||||
jq -r '.language // "missing"' /usr/local/share/proxmenux/config.json
|
||||
|
||||
# All component statuses
|
||||
jq 'to_entries[] | "\\(.key): \\(.value)"' /usr/local/share/proxmenux/config.json
|
||||
|
||||
# Current language
|
||||
jq -r '.language' /usr/local/share/proxmenux/config.json`}</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.unknownTitle")}>
|
||||
{t.rich("troubleshoot.unknownBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.noConfigTitle")}>
|
||||
{t.rich("troubleshoot.noConfigBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.wrongStatusTitle")}>
|
||||
{t.rich("troubleshoot.wrongStatusBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
153
web/app/[locale]/docs/settings/uninstall-proxmenux/page.tsx
Normal file
153
web/app/[locale]/docs/settings/uninstall-proxmenux/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.settings.uninstallProxmenux.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/settings/uninstall-proxmenux",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type StringItem = string
|
||||
type DepRow = { type: string; offered?: string; offeredRich?: string }
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function UninstallProxMenuxPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.settings.uninstallProxmenux" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { settings: { uninstallProxmenux: {
|
||||
flow: { items: StringItem[] }
|
||||
deps: { rows: DepRow[] }
|
||||
restored: { items: StringItem[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const block = messages.docs.settings.uninstallProxmenux
|
||||
const flowItems = block.flow.items
|
||||
const depRows = block.deps.rows
|
||||
const restoredItems = block.restored.items
|
||||
const relatedItems = block.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={3}
|
||||
scriptPath="menus/config_menu.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="warning" title={t("scopeWarn.title")}>
|
||||
{t.rich("scopeWarn.body", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("flow.heading")}</h2>
|
||||
<ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-2">
|
||||
{flowItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`flow.items.${idx}`, { code, strong })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("deps.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("deps.intro", { strong })}</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("deps.headerType")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("deps.headerOffered")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{depRows.map((row, idx) => (
|
||||
<tr key={row.type} className={idx < depRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.type}</strong></td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{row.offeredRich ? t.rich(`deps.rows.${idx}.offeredRich`, { code }) : row.offered}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="warning" title={t("deps.warnTitle")}>
|
||||
{t.rich("deps.warnBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("removed.heading")}</h2>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("removed.code") as string}</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("restored.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{restoredItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`restored.items.${idx}`, { code, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="info" title={t("othersCallout.title")}>
|
||||
{t.rich("othersCallout.body", { strong, em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("manual.intro", { code })}</p>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{t.raw("manual.code") as string}</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("reinstall.heading")}</h2>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t("reinstall.body")}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.hangTitle")}>
|
||||
{t.rich("troubleshoot.hangBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.aptTitle")}>
|
||||
{t.rich("troubleshoot.aptBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.motdTitle")}>
|
||||
{t.rich("troubleshoot.motdBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
265
web/app/[locale]/docs/storage-share/host-iscsi/page.tsx
Normal file
265
web/app/[locale]/docs/storage-share/host-iscsi/page.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.storageShare.hostIscsi.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/storage-share/host-iscsi",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type VocabRow = { term: string; meaningRich: string }
|
||||
type StringItem = string
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function HostIscsiPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.storageShare.hostIscsi" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { storageShare: { hostIscsi: {
|
||||
vocab: { rows: VocabRow[] }
|
||||
add: { items: StringItem[] }
|
||||
troubleshoot: { discoverItems: StringItem[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const vocabRows = messages.docs.storageShare.hostIscsi.vocab.rows
|
||||
const addItems = messages.docs.storageShare.hostIscsi.add.items
|
||||
const discoverItems = messages.docs.storageShare.hostIscsi.troubleshoot.discoverItems
|
||||
const relatedItems = messages.docs.storageShare.hostIscsi.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={10}
|
||||
scriptPath="share/iscsi_host.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("vocab.heading")}</h2>
|
||||
<div className="overflow-x-auto mb-4 rounded-md border border-gray-200">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold">{t("vocab.headerTerm")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("vocab.headerMeaning")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-800">
|
||||
{vocabRows.map((row, idx) => (
|
||||
<tr key={row.term}>
|
||||
<td className="px-4 py-2 font-mono">{row.term}</td>
|
||||
<td className="px-4 py-2">{t.rich(`vocab.rows.${idx}.meaningRich`, { code, em })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("opening.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("opening.body", { strong })}
|
||||
</p>
|
||||
|
||||
<Image
|
||||
src="/share/host-iscsi-menu.png"
|
||||
alt={t("opening.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howRuns.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("howRuns.body")}</p>
|
||||
|
||||
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
|
||||
{`┌─────────────────────────────────────────────┐
|
||||
│ PHASE 1 — Prepare initiator, discover │
|
||||
│ (nothing touched yet in storage.cfg) │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
▼
|
||||
Dependency check
|
||||
└─ iscsiadm present? (open-iscsi package)
|
||||
If missing → apt-get install open-iscsi
|
||||
+ systemctl enable --now iscsid
|
||||
│
|
||||
▼
|
||||
Portal entry (manual only)
|
||||
└─ user types <ip> or <ip>:<port>
|
||||
If no ':' → ProxMenux appends ":3260"
|
||||
│
|
||||
▼
|
||||
Reachability validation
|
||||
├─ ping -c 1 -W 3 <host> ── fail → abort
|
||||
└─ nc -z -w 3 <host> <port> ── warn but continue
|
||||
(iSCSI over alternative ports may block nc)
|
||||
│
|
||||
▼
|
||||
Target discovery
|
||||
iscsiadm --mode discovery --type sendtargets \\
|
||||
--portal <ip:port>
|
||||
Extracts IQNs from stdout (lines matching ^iqn\\.)
|
||||
│
|
||||
▼
|
||||
Target selection
|
||||
├─ 1 target found → auto-selected
|
||||
└─ 2+ targets → menu
|
||||
│
|
||||
▼
|
||||
Storage ID
|
||||
(default derived from last ':' segment of the IQN:
|
||||
"iscsi-<suffix-up-to-20-chars>")
|
||||
│
|
||||
▼
|
||||
Content type (fixed — not a checklist)
|
||||
└─ images iSCSI exposes block devices, so
|
||||
only 'images' makes sense. No
|
||||
backup/iso/vztmpl/rootdir/snippets.
|
||||
│
|
||||
┌──────── Cancel OR Confirm ────┐
|
||||
▼ ▼
|
||||
Exit, nothing ┌─────────────────┴─────────────────┐
|
||||
was changed │ PHASE 2 — Register in Proxmox │
|
||||
└─────────────────┬─────────────────┘
|
||||
▼
|
||||
If storage ID already exists:
|
||||
└─ ask "remove and recreate?"
|
||||
└─ yes → pvesm remove <id>
|
||||
└─ no → abort
|
||||
▼
|
||||
pvesm add iscsi <id> \\
|
||||
--portal <ip:port> \\
|
||||
--target <iqn> \\
|
||||
--content images
|
||||
│
|
||||
▼
|
||||
iscsid opens a persistent session to
|
||||
the target; LUNs appear in /dev/disk/
|
||||
by-path/ip-<ip>:<port>-iscsi-<iqn>-lun-N
|
||||
│
|
||||
▼
|
||||
Proxmox auto-connects on every boot
|
||||
via the node.startup=automatic flag
|
||||
written by pvesm`}
|
||||
</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("add.heading")}</h2>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
|
||||
{addItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`add.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<Callout variant="warning" title={t("add.authTitle")}>
|
||||
{t("add.authBody1")}
|
||||
<pre className="mt-2 p-2 rounded bg-white/50 text-xs overflow-x-auto"><code>cat /etc/iscsi/initiatorname.iscsi</code></pre>
|
||||
{t.rich("add.authBody2", { em, code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("manual.body")}</p>
|
||||
<CopyableCode code={`apt-get install -y open-iscsi
|
||||
systemctl enable --now iscsid
|
||||
|
||||
# 1. discover targets on a portal
|
||||
iscsiadm --mode discovery --type sendtargets \\
|
||||
--portal 10.0.0.60:3260
|
||||
|
||||
# 2. register it in Proxmox
|
||||
pvesm add iscsi myiscsi \\
|
||||
--portal 10.0.0.60:3260 \\
|
||||
--target iqn.2024-08.com.truenas:proxmox-pool \\
|
||||
--content images
|
||||
|
||||
# 3. verify + see the block devices
|
||||
pvesm status myiscsi
|
||||
ls -la /dev/disk/by-path/ | grep iscsi`} />
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("view.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("view.body", { code })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("remove.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("remove.body", { code, strong })}</p>
|
||||
|
||||
<Callout variant="warning" title={t("remove.warnTitle")}>
|
||||
{t("remove.warnBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("test.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("test.body", { code })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.portalTitle")}>
|
||||
{t.rich("troubleshoot.portalBody", { em })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.discoverTitle")}>
|
||||
{t("troubleshoot.discoverIntro")}
|
||||
<ul className="mt-2 list-disc list-inside space-y-1">
|
||||
{discoverItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`troubleshoot.discoverItems.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.noTargetTitle")}>
|
||||
{t("troubleshoot.noTargetBody")}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.noLunTitle")}>
|
||||
{t.rich("troubleshoot.noLunBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.chapTitle")}>
|
||||
{t.rich("troubleshoot.chapBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
351
web/app/[locale]/docs/storage-share/host-local-disk/page.tsx
Normal file
351
web/app/[locale]/docs/storage-share/host-local-disk/page.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.storageShare.hostLocalDisk.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/storage-share/host-local-disk",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type CompareRow = { label: string; dir?: string; zfs?: string; dirRich?: string; zfsRich?: string }
|
||||
type StringItem = string
|
||||
type PresetRow = { preset: string; content: string; use?: string; useRich?: string }
|
||||
type RelatedItem = { href: string; label: string; tail?: string }
|
||||
|
||||
export default async function HostLocalDiskPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.storageShare.hostLocalDisk" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { storageShare: { hostLocalDisk: {
|
||||
compare: { rows: CompareRow[] }
|
||||
format: { items: StringItem[] }
|
||||
reuse: { items: StringItem[] }
|
||||
presets: { rows: PresetRow[] }
|
||||
troubleshoot: { noDisksItems: StringItem[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const compareRows = messages.docs.storageShare.hostLocalDisk.compare.rows
|
||||
const formatItems = messages.docs.storageShare.hostLocalDisk.format.items
|
||||
const reuseItems = messages.docs.storageShare.hostLocalDisk.reuse.items
|
||||
const presetRows = messages.docs.storageShare.hostLocalDisk.presets.rows
|
||||
const noDisksItems = messages.docs.storageShare.hostLocalDisk.troubleshoot.noDisksItems
|
||||
const relatedItems = messages.docs.storageShare.hostLocalDisk.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={10}
|
||||
scriptPath="share/disk_host.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { em, strong })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="danger" title={t("destructive.title")}>
|
||||
{t.rich("destructive.body", { em, strong, code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("compare.heading")}</h2>
|
||||
<div className="overflow-x-auto mb-4 rounded-md border border-gray-200">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold"> </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>
|
||||
)
|
||||
}
|
||||
261
web/app/[locale]/docs/storage-share/host-local-shared/page.tsx
Normal file
261
web/app/[locale]/docs/storage-share/host-local-shared/page.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.storageShare.hostLocalShared.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/storage-share/host-local-shared",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type StringItem = string
|
||||
type BitsRow = { bit: string; effect: string; why: string }
|
||||
type RelatedItem = { href: string; label: string; tail?: string; tailRich?: string }
|
||||
|
||||
export default async function HostLocalSharedPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.storageShare.hostLocalShared" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { storageShare: { hostLocalShared: {
|
||||
why: { items: StringItem[] }
|
||||
bits: { rows: BitsRow[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const whyItems = messages.docs.storageShare.hostLocalShared.why.items
|
||||
const bitsRows = messages.docs.storageShare.hostLocalShared.bits.rows
|
||||
const relatedItems = messages.docs.storageShare.hostLocalShared.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const kbd = (chunks: React.ReactNode) => <kbd>{chunks}</kbd>
|
||||
const mountLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/storage-share/lxc-mount-points" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const diskLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/storage-share/host-local-disk" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={3}
|
||||
scriptPath="share/local-shared-manager.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { strong, code, mountLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("why.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("why.intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{whyItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`why.items.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("why.outro", { strong })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howRuns.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("howRuns.body", { strong })}</p>
|
||||
|
||||
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
|
||||
{`┌─────────────────────────────────────────────┐
|
||||
│ PHASE 1 — Pick the target path │
|
||||
│ (nothing touched yet) │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
▼
|
||||
Location picker (4 options)
|
||||
├─ 1. Create new folder in /mnt
|
||||
│ ProxMenux suggests a free name
|
||||
│ ("shared", "shared2", "shared3"…)
|
||||
├─ 2. Enter custom path
|
||||
│ Any absolute path on the host
|
||||
├─ 3. View existing folders in /mnt
|
||||
│ Read-only summary (perms, owner,
|
||||
│ free space) then back to menu
|
||||
└─ 4. Cancel
|
||||
│
|
||||
▼
|
||||
Path validation
|
||||
└─ Must start with "/" (absolute path)
|
||||
Non-absolute → reject, re-ask
|
||||
│
|
||||
▼
|
||||
Existing directory?
|
||||
└─ If /mnt/<name> already exists, ask
|
||||
"Continue with permission setup?"
|
||||
(adjusting existing dir is allowed)
|
||||
│
|
||||
┌──────── Cancel OR Confirm ────┐
|
||||
▼ ▼
|
||||
Exit, nothing ┌─────────────────┴─────────────────┐
|
||||
was changed │ PHASE 2 — Create + set perms │
|
||||
└─────────────────┬─────────────────┘
|
||||
▼
|
||||
mkdir -p <target>
|
||||
▼
|
||||
chown root:root <target>
|
||||
▼
|
||||
chmod 1777 <target>
|
||||
(sticky bit + world-rwx)
|
||||
▼
|
||||
chmod -R a+rwX <target>
|
||||
(existing content stays accessible;
|
||||
X = execute only on directories)
|
||||
▼
|
||||
find <target> -type d \\
|
||||
-exec chmod 1777 {} +
|
||||
(propagate sticky bit to subdirs)
|
||||
▼
|
||||
setfacl -b -R <target>
|
||||
(remove any restrictive ACLs)
|
||||
▼
|
||||
setfacl -R -m u::rwx,g::rwx,o::rwx,m::rwx
|
||||
(explicit rwx for user/group/other/mask)
|
||||
▼
|
||||
setfacl -R -m d:u::rwx,d:g::rwx,...
|
||||
(default ACLs so NEW files inherit rwx)
|
||||
▼
|
||||
Register in ProxMenux share map
|
||||
(pmx_share_map_set <dir> "open")
|
||||
▼
|
||||
Summary:
|
||||
• directory path
|
||||
• permissions: 1777 (rwxrwxrwt)
|
||||
• owner: root:root
|
||||
• ACL: open rwx + default inheritance
|
||||
• profile: works with priv and
|
||||
unprivileged LXCs`}
|
||||
</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("bits.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("bits.intro", { strong, code })}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto mb-4 rounded-md border border-gray-200">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold">{t("bits.headerBit")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("bits.headerEffect")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("bits.headerWhy")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-800">
|
||||
{bitsRows.map((row) => (
|
||||
<tr key={row.bit}>
|
||||
<td className="px-4 py-2 font-mono">{row.bit}</td>
|
||||
<td className="px-4 py-2">{row.effect}</td>
|
||||
<td className="px-4 py-2">{row.why}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("bits.privTitle")}>
|
||||
{t.rich("bits.privBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("where.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("where.intro")}</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 my-6">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("where.opt1Title")}</h3>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{t.rich("where.opt1Body", { code })}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("where.opt2Title")}</h3>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{t.rich("where.opt2Body", { code })}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("where.opt3Title")}</h3>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{t.rich("where.opt3Body", { code })}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-2">{t("where.opt4Title")}</h3>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">{t.rich("where.opt4Body", { kbd })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("where.tipTitle")}>
|
||||
{t.rich("where.tipBody", { code, diskLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("manual.body")}</p>
|
||||
<CopyableCode code={`# 1. create the directory
|
||||
mkdir -p /mnt/shared
|
||||
|
||||
# 2. apply 1777 (sticky + world-rwx) + open existing content
|
||||
chown root:root /mnt/shared
|
||||
chmod 1777 /mnt/shared
|
||||
chmod -R a+rwX /mnt/shared
|
||||
find /mnt/shared -type d -exec chmod 1777 {} +
|
||||
|
||||
# 3. ACLs: explicit rwx + default inheritance for new files
|
||||
setfacl -b -R /mnt/shared
|
||||
setfacl -R -m u::rwx,g::rwx,o::rwx,m::rwx /mnt/shared
|
||||
setfacl -R -m d:u::rwx,d:g::rwx,d:o::rwx,d:m::rwx /mnt/shared`} />
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("next.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("next.body", { strong, code, mountLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.mkdirTitle")}>
|
||||
{t.rich("troubleshoot.mkdirBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.writeTitle")}>
|
||||
{t.rich("troubleshoot.writeBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.aclTitle")}>
|
||||
{t.rich("troubleshoot.aclBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`related.items.${idx}.tailRich`, { code }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
332
web/app/[locale]/docs/storage-share/host-nfs/page.tsx
Normal file
332
web/app/[locale]/docs/storage-share/host-nfs/page.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.storageShare.hostNfs.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/storage-share/host-nfs",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type StringItem = string
|
||||
type ModesRow = { method: string; mount?: string; mountRich?: string; ui: string; useCase?: string; useCaseRich?: string }
|
||||
type ContentRow = { type: string; allows?: string; allowsRich?: string }
|
||||
type RelatedItem = { href: string; label: string; tail?: string; tailRich?: string }
|
||||
|
||||
export default async function HostNfsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.storageShare.hostNfs" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { storageShare: { hostNfs: {
|
||||
modes: { rows: ModesRow[] }
|
||||
pvesmBranch: { items: StringItem[]; rows: ContentRow[] }
|
||||
fstabBranch: { items: StringItem[]; applies: StringItem[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const modesRows = messages.docs.storageShare.hostNfs.modes.rows
|
||||
const pvesmItems = messages.docs.storageShare.hostNfs.pvesmBranch.items
|
||||
const contentRows = messages.docs.storageShare.hostNfs.pvesmBranch.rows
|
||||
const fstabItems = messages.docs.storageShare.hostNfs.fstabBranch.items
|
||||
const fstabAppliesItems = messages.docs.storageShare.hostNfs.fstabBranch.applies
|
||||
const relatedItems = messages.docs.storageShare.hostNfs.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const mountLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/storage-share/lxc-mount-points" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={6}
|
||||
scriptPath="share/nfs_host.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code, strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("opening.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("opening.body", { strong })}
|
||||
</p>
|
||||
|
||||
<Image
|
||||
src="/share/host-nfs-menu.png"
|
||||
alt={t("opening.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howRuns.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("howRuns.body", { code })}
|
||||
</p>
|
||||
|
||||
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
|
||||
{`┌─────────────────────────────────────────────┐
|
||||
│ PHASE 1 — Discover, validate, choose │
|
||||
│ (nothing touched yet) │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
▼
|
||||
Dependency check
|
||||
└─ nfs-common present? (showmount)
|
||||
If missing → apt-get install nfs-common
|
||||
│
|
||||
▼
|
||||
Server selection
|
||||
├─ Auto-discover (nmap -p 2049 on /24)
|
||||
└─ Manual (type IP or hostname)
|
||||
│
|
||||
▼
|
||||
Reachability + showmount validation
|
||||
│
|
||||
▼
|
||||
Export selection
|
||||
│
|
||||
▼
|
||||
╔═════════════════════════════════════╗
|
||||
║ MOUNT METHOD PICKER (checklist) ║
|
||||
║ [ ] As Proxmox storage (pvesm) ║
|
||||
║ [ ] As host fstab mount only ║
|
||||
║ (mark one or both — re-prompts ║
|
||||
║ if you press OK without marks) ║
|
||||
╚════════════════╤════════════════════╝
|
||||
│
|
||||
┌──────────────┴──────────────┐
|
||||
▼ ▼
|
||||
pvesm branch fstab branch
|
||||
├─ storage ID ├─ mount path
|
||||
├─ content types └─ mount options
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ PHASE 2 — Apply (only marked methods) │
|
||||
└──────────────────┬──────────────────────┘
|
||||
▼
|
||||
pvesm add nfs <id> ... + mkdir -p <path>
|
||||
(auto-mount at mount -t nfs ...
|
||||
/mnt/pve/<id>) append /etc/fstab
|
||||
systemctl daemon-reload
|
||||
chmod 1777 + setfacl
|
||||
(best-effort, NFS server-side)
|
||||
▼
|
||||
Summary printed`}
|
||||
</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("modes.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("modes.intro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-4 rounded-md border border-gray-200">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold">{t("modes.headerMethod")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("modes.headerMount")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("modes.headerUi")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("modes.headerUseCase")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-800 align-top">
|
||||
{modesRows.map((row, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="px-4 py-2">{t.rich(`modes.rows.${idx}.method`, { strong })}</td>
|
||||
<td className="px-4 py-2">
|
||||
{row.mountRich ? t.rich(`modes.rows.${idx}.mountRich`, { code }) : row.mount}
|
||||
</td>
|
||||
<td className="px-4 py-2">{row.ui}</td>
|
||||
<td className="px-4 py-2">
|
||||
{row.useCaseRich ? t.rich(`modes.rows.${idx}.useCaseRich`, { em }) : row.useCase}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("modes.bothTitle")}>
|
||||
{t.rich("modes.bothBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("pvesmBranch.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("pvesmBranch.intro", { em })}
|
||||
</p>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
|
||||
{pvesmItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`pvesmBranch.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<div className="overflow-x-auto mb-4 rounded-md border border-gray-200">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold">{t("pvesmBranch.headerType")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("pvesmBranch.headerAllows")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-800">
|
||||
{contentRows.map((row, idx) => (
|
||||
<tr key={row.type}>
|
||||
<td className="px-4 py-2 font-mono">{row.type}</td>
|
||||
<td className="px-4 py-2">
|
||||
{row.allowsRich
|
||||
? t.rich(`pvesmBranch.rows.${idx}.allowsRich`, { em, code })
|
||||
: row.allows}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="warning" title={t("pvesmBranch.warnTitle")}>
|
||||
{t.rich("pvesmBranch.warnBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<p className="mb-4 mt-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("pvesmBranch.result", { code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("fstabBranch.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("fstabBranch.intro", { em, code })}
|
||||
</p>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
|
||||
{fstabItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`fstabBranch.items.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("fstabBranch.appliesIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{fstabAppliesItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`fstabBranch.applies.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="info" title={t("fstabBranch.lxcTitle")}>
|
||||
{t.rich("fstabBranch.lxcBody", { code, strong, mountLink })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="warning" title={t("fstabBranch.noUiTitle")}>
|
||||
{t.rich("fstabBranch.noUiBody", { em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("manual.pvesmIntro")}</p>
|
||||
<CopyableCode code={`apt-get install -y nfs-common # one-time: NFS client tools
|
||||
pvesm add nfs mynfs \\
|
||||
--server 10.0.0.50 \\
|
||||
--export /export/proxmox \\
|
||||
--content import,backup,iso
|
||||
|
||||
pvesm status mynfs # verify it's active
|
||||
ls -la /mnt/pve/mynfs # Proxmox auto-mounts here`} />
|
||||
|
||||
<p className="mb-3 mt-6 text-gray-800 leading-relaxed">{t("manual.fstabIntro")}</p>
|
||||
<CopyableCode code={`apt-get install -y nfs-common # one-time
|
||||
|
||||
mkdir -p /mnt/data
|
||||
mount -t nfs -o "rw,hard,nofail,_netdev,rsize=131072,wsize=131072,timeo=600,retrans=2" \\
|
||||
10.0.0.50:/export/proxmox /mnt/data
|
||||
|
||||
# Persist
|
||||
echo "10.0.0.50:/export/proxmox /mnt/data nfs rw,hard,nofail,_netdev,rsize=131072,wsize=131072,timeo=600,retrans=2 0 0" \\
|
||||
>> /etc/fstab
|
||||
systemctl daemon-reload
|
||||
|
||||
# Best-effort open perms for LXC bind-mount writes (server permitting)
|
||||
chmod 1777 /mnt/data 2>/dev/null || true
|
||||
setfacl -m o::rwx /mnt/data 2>/dev/null || true
|
||||
|
||||
# Bind into an unprivileged LXC (host-side perms only — no changes inside CT)
|
||||
pct set <ctid> -mp0 /mnt/data,mp=/mnt/data,shared=1,backup=0
|
||||
pct reboot <ctid>`} />
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("view.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("view.body", { code, strong })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("remove.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("remove.body", { code, strong })}</p>
|
||||
|
||||
<Callout variant="warning" title={t("remove.warnTitle")}>
|
||||
{t("remove.warnBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("test.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("test.body", { code, em })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.noServersTitle")}>
|
||||
{t.rich("troubleshoot.noServersBody", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.portTitle")}>
|
||||
{t.rich("troubleshoot.portBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.showmountTitle")}>
|
||||
{t.rich("troubleshoot.showmountBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.inactiveTitle")}>
|
||||
{t.rich("troubleshoot.inactiveBody", { em, code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.lxcNoWriteTitle")}>
|
||||
{t.rich("troubleshoot.lxcNoWriteBody", { code, strong })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.fstabBootTitle")}>
|
||||
{t.rich("troubleshoot.fstabBootBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`related.items.${idx}.tailRich`, { code }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
355
web/app/[locale]/docs/storage-share/host-samba/page.tsx
Normal file
355
web/app/[locale]/docs/storage-share/host-samba/page.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.storageShare.hostSamba.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/storage-share/host-samba",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type StringItem = string
|
||||
type ModesRow = { method: string; mount?: string; mountRich?: string; ui: string; useCase?: string; useCaseRich?: string }
|
||||
type ContentRow = { type: string; allows?: string; allowsRich?: string }
|
||||
type RelatedItem = { href: string; label: string; tail?: string; tailRich?: string }
|
||||
|
||||
export default async function HostSambaPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.storageShare.hostSamba" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { storageShare: { hostSamba: {
|
||||
modes: { rows: ModesRow[] }
|
||||
pvesmBranch: { items: StringItem[]; rows: ContentRow[] }
|
||||
fstabBranch: { items: StringItem[]; applies: StringItem[] }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const modesRows = messages.docs.storageShare.hostSamba.modes.rows
|
||||
const pvesmItems = messages.docs.storageShare.hostSamba.pvesmBranch.items
|
||||
const contentRows = messages.docs.storageShare.hostSamba.pvesmBranch.rows
|
||||
const fstabItems = messages.docs.storageShare.hostSamba.fstabBranch.items
|
||||
const fstabAppliesItems = messages.docs.storageShare.hostSamba.fstabBranch.applies
|
||||
const relatedItems = messages.docs.storageShare.hostSamba.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const mountLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/storage-share/lxc-mount-points" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={7}
|
||||
scriptPath="share/samba_host.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code, strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("opening.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("opening.body", { strong })}
|
||||
</p>
|
||||
|
||||
<Image
|
||||
src="/share/host-samba-menu.png"
|
||||
alt={t("opening.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howRuns.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("howRuns.body", { code })}
|
||||
</p>
|
||||
|
||||
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
|
||||
{`┌─────────────────────────────────────────────┐
|
||||
│ PHASE 1 — Discover, validate, choose │
|
||||
│ (nothing touched yet) │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
▼
|
||||
Server discovery (nmap 139/445 + nmblookup)
|
||||
│
|
||||
▼
|
||||
Authentication (User or Guest)
|
||||
│
|
||||
▼
|
||||
Share selection (smbclient -L)
|
||||
│
|
||||
▼
|
||||
╔═════════════════════════════════════╗
|
||||
║ MOUNT METHOD PICKER (checklist) ║
|
||||
║ [ ] As Proxmox storage (pvesm) ║
|
||||
║ [ ] As host fstab mount only ║
|
||||
║ (mark one or both — re-prompts ║
|
||||
║ if you press OK without marks) ║
|
||||
╚════════════════╤════════════════════╝
|
||||
│
|
||||
┌──────────────┴──────────────┐
|
||||
▼ ▼
|
||||
pvesm branch fstab branch
|
||||
├─ storage ID ├─ mount path
|
||||
├─ content types ├─ mount options
|
||||
└─ (User) write
|
||||
/etc/samba/credentials/...cred
|
||||
(mode 0600)
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ PHASE 2 — Apply (only marked methods) │
|
||||
└──────────────────┬──────────────────────┘
|
||||
▼
|
||||
pvesm add cifs <id> ... + mkdir -p <path>
|
||||
(auto-mount at mount -t cifs ...
|
||||
/mnt/pve/<id> with (uid=0,gid=0,
|
||||
default options) file_mode=0777,
|
||||
dir_mode=0777)
|
||||
append /etc/fstab
|
||||
systemctl daemon-reload
|
||||
▼
|
||||
Summary printed`}
|
||||
</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("modes.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("modes.intro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-4 rounded-md border border-gray-200">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold">{t("modes.headerMethod")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("modes.headerMount")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("modes.headerUi")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("modes.headerUseCase")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-800 align-top">
|
||||
{modesRows.map((row, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="px-4 py-2">{t.rich(`modes.rows.${idx}.method`, { strong })}</td>
|
||||
<td className="px-4 py-2">
|
||||
{row.mountRich ? t.rich(`modes.rows.${idx}.mountRich`, { code }) : row.mount}
|
||||
</td>
|
||||
<td className="px-4 py-2">{row.ui}</td>
|
||||
<td className="px-4 py-2">
|
||||
{row.useCaseRich ? t.rich(`modes.rows.${idx}.useCaseRich`, { em }) : row.useCase}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("modes.bothTitle")}>
|
||||
{t.rich("modes.bothBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("pvesmBranch.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("pvesmBranch.intro", { em })}
|
||||
</p>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
|
||||
{pvesmItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`pvesmBranch.items.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<div className="overflow-x-auto mb-4 rounded-md border border-gray-200">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold">{t("pvesmBranch.headerType")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("pvesmBranch.headerAllows")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-800">
|
||||
{contentRows.map((row, idx) => (
|
||||
<tr key={row.type}>
|
||||
<td className="px-4 py-2 font-mono">{row.type}</td>
|
||||
<td className="px-4 py-2">
|
||||
{row.allowsRich
|
||||
? t.rich(`pvesmBranch.rows.${idx}.allowsRich`, { code, strong })
|
||||
: row.allows}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="warning" title={t("pvesmBranch.warnTitle")}>
|
||||
{t.rich("pvesmBranch.warnBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="info" title={t("pvesmBranch.credsTitle")}>
|
||||
{t.rich("pvesmBranch.credsBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("fstabBranch.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("fstabBranch.intro", { em, code })}
|
||||
</p>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
|
||||
{fstabItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`fstabBranch.items.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<Callout variant="info" title={t("fstabBranch.credsTitle")}>
|
||||
{t.rich("fstabBranch.credsBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("fstabBranch.appliesIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{fstabAppliesItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`fstabBranch.applies.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="info" title={t("fstabBranch.lxcTitle")}>
|
||||
{t.rich("fstabBranch.lxcBody", { code, strong, mountLink })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="warning" title={t("fstabBranch.noUiTitle")}>
|
||||
{t.rich("fstabBranch.noUiBody", { em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("manual.pvesmIntro")}</p>
|
||||
<CopyableCode code={`apt-get install -y cifs-utils smbclient # one-time: SMB client tools
|
||||
|
||||
# with user authentication
|
||||
pvesm add cifs mycifs \\
|
||||
--server 10.0.0.50 \\
|
||||
--share proxmox \\
|
||||
--username backup_user \\
|
||||
--password 's3cret' \\
|
||||
--content backup,iso
|
||||
|
||||
# guest access (no credentials)
|
||||
pvesm add cifs mycifs-guest \\
|
||||
--server 10.0.0.50 \\
|
||||
--share public \\
|
||||
--content iso,vztmpl
|
||||
|
||||
pvesm status mycifs # verify it's active
|
||||
ls -la /mnt/pve/mycifs # Proxmox auto-mounts here`} />
|
||||
|
||||
<p className="mb-3 mt-6 text-gray-800 leading-relaxed">{t("manual.fstabUserIntro")}</p>
|
||||
<CopyableCode code={`# 1. credentials file (root-only)
|
||||
mkdir -p /etc/samba/credentials && chmod 0700 /etc/samba/credentials
|
||||
cat > /etc/samba/credentials/nas01_share.cred <<'EOF'
|
||||
username=admin
|
||||
password=s3cret
|
||||
EOF
|
||||
chmod 0600 /etc/samba/credentials/nas01_share.cred
|
||||
|
||||
# 2. mount with open uid/gid/file_mode (for unpriv LXC bind-mounts)
|
||||
mkdir -p /mnt/data
|
||||
mount -t cifs //10.0.0.50/share /mnt/data \\
|
||||
-o "rw,uid=0,gid=0,file_mode=0777,dir_mode=0777,iocharset=utf8,nofail,_netdev,credentials=/etc/samba/credentials/nas01_share.cred"
|
||||
|
||||
# 3. persist
|
||||
echo "//10.0.0.50/share /mnt/data cifs rw,uid=0,gid=0,file_mode=0777,dir_mode=0777,iocharset=utf8,nofail,_netdev,credentials=/etc/samba/credentials/nas01_share.cred 0 0" \\
|
||||
>> /etc/fstab
|
||||
systemctl daemon-reload
|
||||
|
||||
# 4. bind into an unpriv LXC (no changes inside the CT)
|
||||
pct set <ctid> -mp0 /mnt/data,mp=/mnt/data,shared=1,backup=0
|
||||
pct reboot <ctid>`} />
|
||||
|
||||
<p className="mb-3 mt-6 text-gray-800 leading-relaxed">{t("manual.fstabGuestIntro")}</p>
|
||||
<CopyableCode code={`mkdir -p /mnt/public
|
||||
mount -t cifs //10.0.0.50/public /mnt/public \\
|
||||
-o "rw,uid=0,gid=0,file_mode=0777,dir_mode=0777,iocharset=utf8,nofail,_netdev,guest"
|
||||
|
||||
echo "//10.0.0.50/public /mnt/public cifs rw,uid=0,gid=0,file_mode=0777,dir_mode=0777,iocharset=utf8,nofail,_netdev,guest 0 0" \\
|
||||
>> /etc/fstab
|
||||
systemctl daemon-reload`} />
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("view.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("view.body", { code, em, strong })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("remove.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("remove.body", { code, strong })}</p>
|
||||
|
||||
<Callout variant="warning" title={t("remove.warnTitle")}>
|
||||
{t("remove.warnBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("test.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("test.body", { code })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.noServersTitle")}>
|
||||
{t.rich("troubleshoot.noServersBody", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.noSharesTitle")}>
|
||||
{t.rich("troubleshoot.noSharesBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.denyTitle")}>
|
||||
{t.rich("troubleshoot.denyBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.sleepTitle")}>
|
||||
{t.rich("troubleshoot.sleepBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.lxcNoWriteTitle")}>
|
||||
{t.rich("troubleshoot.lxcNoWriteBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.fstabBootTitle")}>
|
||||
{t.rich("troubleshoot.fstabBootBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`related.items.${idx}.tailRich`, { code }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
422
web/app/[locale]/docs/storage-share/lxc-mount-points/page.tsx
Normal file
422
web/app/[locale]/docs/storage-share/lxc-mount-points/page.tsx
Normal file
@@ -0,0 +1,422 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { DataFlowDiagram } from "@/components/ui/data-flow-diagram"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.storageShare.lxcMountPoints.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://macrimi.github.io/ProxMenux/docs/storage-share/lxc-mount-points",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type StringItem = string
|
||||
type SourceRow = { source: string; where?: string; whereRich?: string; labelRich: string }
|
||||
type StringList = string[]
|
||||
type RelatedItem = {
|
||||
href: string
|
||||
label: string
|
||||
extraHref?: string
|
||||
extraLabel?: string
|
||||
joiner?: string
|
||||
tail?: string
|
||||
tailRich?: string
|
||||
}
|
||||
|
||||
export default async function LxcMountPointsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.storageShare.lxcMountPoints" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { storageShare: { lxcMountPoints: {
|
||||
bigPicture: { items: StringItem[] }
|
||||
sources: { rows: SourceRow[] }
|
||||
troubleshoot: { nfsItems: StringList }
|
||||
related: { items: RelatedItem[] }
|
||||
} } }
|
||||
}
|
||||
const bigPictureItems = messages.docs.storageShare.lxcMountPoints.bigPicture.items
|
||||
const sourceRows = messages.docs.storageShare.lxcMountPoints.sources.rows
|
||||
const nfsItems = messages.docs.storageShare.lxcMountPoints.troubleshoot.nfsItems
|
||||
const relatedItems = messages.docs.storageShare.lxcMountPoints.related.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={10}
|
||||
scriptPath="share/lxc-mount-manager_minimal.sh"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t("intro.body")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("bigPicture.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("bigPicture.intro", { code, em })}
|
||||
</p>
|
||||
|
||||
<DataFlowDiagram
|
||||
nodes={[
|
||||
{ label: t("bigPicture.sourceLabel"), detail: t("bigPicture.sourceDetail"), variant: "source" },
|
||||
{ label: t("bigPicture.targetLabel"), detail: t("bigPicture.targetDetail"), variant: "target" },
|
||||
]}
|
||||
arrowLabel={t("bigPicture.arrowLabel")}
|
||||
bidirectional
|
||||
command={`# What the script writes:
|
||||
pct set <ctid> -mpN /mnt/data, mp=/mnt/data, shared=1, backup=0`}
|
||||
/>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("bigPicture.outro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{bigPictureItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`bigPicture.items.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("perms.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("perms.intro", { strong, em })}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto my-6 rounded-md border border-gray-200">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold align-top">{t("perms.headerType")}</th>
|
||||
<th className="px-4 py-2 font-semibold align-top">{t("perms.headerAction")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-800 align-top">
|
||||
<tr>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-semibold whitespace-nowrap">{t("perms.localType")}</div>
|
||||
<div className="text-xs text-gray-600 mt-1 font-mono">{t("perms.localTypeSub")}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">{t.rich("perms.localActionRich", { code })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-semibold whitespace-nowrap">{t("perms.cifsType")}</div>
|
||||
<div className="text-xs text-gray-600 mt-1 font-mono">{t("perms.cifsTypeSub")}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">{t.rich("perms.cifsActionRich", { code })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-semibold whitespace-nowrap">{t("perms.nfsType")}</div>
|
||||
<div className="text-xs text-gray-600 mt-1 font-mono">{t("perms.nfsTypeSub")}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">{t.rich("perms.nfsActionRich", { code })}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("perms.privTitle")}>
|
||||
{t("perms.privBody")}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="warning" title={t("perms.noCtTitle")}>
|
||||
{t.rich("perms.noCtBody", { strong, code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("writes.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("writes.intro", { code, strong })}
|
||||
</p>
|
||||
|
||||
<CopyableCode
|
||||
code={`# /etc/pve/lxc/545.conf — single line added by the script
|
||||
mp0: /mnt/NAS/hdd_cache,mp=/mnt/NAS/hdd_cache,shared=1,backup=0`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("writes.outro", { em })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("writes.twoWaysHeading")}</h3>
|
||||
|
||||
<div className="overflow-x-auto my-4 rounded-md border border-gray-200">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold align-top">{t("writes.headerApproach")}</th>
|
||||
<th className="px-4 py-2 font-semibold align-top">{t("writes.headerChanges")}</th>
|
||||
<th className="px-4 py-2 font-semibold align-top">{t("writes.headerWhen")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-800 align-top">
|
||||
<tr>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<div className="font-semibold">{t("writes.hostType")}</div>
|
||||
<div className="text-xs text-gray-600 mt-1">{t("writes.hostTypeSub")}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">{t.rich("writes.hostChangesRich", { code, em })}</td>
|
||||
<td className="px-4 py-3">{t("writes.hostWhen")}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<div className="font-semibold">{t.rich("writes.idmapTypeRich", { code })}</div>
|
||||
<div className="text-xs text-gray-600 mt-1">{t("writes.idmapTypeSub")}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">{t.rich("writes.idmapChangesRich", { code })}</td>
|
||||
<td className="px-4 py-3">{t.rich("writes.idmapWhenRich", { em, code })}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("writes.idmapTipTitle")}>
|
||||
{t.rich("writes.idmapTipBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("opening.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("opening.body", { strong })}
|
||||
</p>
|
||||
|
||||
<Image
|
||||
src="/share/lxc-mount-points-menu.png"
|
||||
alt={t("opening.imageAlt")}
|
||||
width={900}
|
||||
height={500}
|
||||
className="rounded shadow-lg my-6"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("addFlow.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("addFlow.intro")}</p>
|
||||
|
||||
<pre className="bg-gray-100 text-gray-800 p-4 rounded-md overflow-x-auto text-sm my-4 border border-gray-200 leading-snug">
|
||||
{`┌─────────────────────────────────────────────┐
|
||||
│ PHASE 1 — Pick CT, host dir, mount point │
|
||||
│ (nothing touched yet) │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
▼
|
||||
pct list — pick the target container
|
||||
│
|
||||
▼
|
||||
Unified host-directory picker
|
||||
Lists every candidate the script can detect:
|
||||
├─ Mounted CIFS / NFS shares (/proc/mounts)
|
||||
├─ fstab-inactive network mounts (defined
|
||||
│ but not currently mounted) — labelled
|
||||
│ "fstab(off)-"
|
||||
├─ Local /mnt/* directories
|
||||
├─ Proxmox-managed storages under /mnt/pve/*
|
||||
│ (NFS / CIFS shares registered via pvesm)
|
||||
│ — labelled "PVE-"
|
||||
└─ "Enter path manually" for anything else
|
||||
│
|
||||
▼
|
||||
Detect the host directory TYPE
|
||||
└─ local / cifs / nfs
|
||||
(drives the permission-fix branch later)
|
||||
│
|
||||
▼
|
||||
Container mount point picker
|
||||
├─ Create new directory in /mnt
|
||||
│ (auto-suggests basename of host dir)
|
||||
├─ Enter manual path (must be absolute)
|
||||
└─ Cancel
|
||||
Validates the path is not already used as
|
||||
a mount point in this CT.
|
||||
│
|
||||
▼
|
||||
Detect CT type:
|
||||
├─ Privileged → no UID shift
|
||||
└─ Unprivileged → +100000 (default idmap)
|
||||
│
|
||||
▼
|
||||
ACTIVE FIX FOR THE HOST DIRECTORY
|
||||
(depends on the type detected earlier)
|
||||
├─ cifs → offer remount with open uid/gid
|
||||
├─ nfs → offer chmod + setfacl on share
|
||||
└─ local → handled AFTER the bind mount
|
||||
(only if CT is unprivileged)
|
||||
│
|
||||
┌──────── Cancel OR Confirm ────┐
|
||||
▼ ▼
|
||||
Exit, nothing ┌─────────────────┴─────────────────┐
|
||||
was changed │ PHASE 2 — Apply │
|
||||
└─────────────────┬─────────────────┘
|
||||
▼
|
||||
Find next free mpN slot
|
||||
(scans /etc/pve/lxc/<ctid>.conf)
|
||||
▼
|
||||
pct set <ctid> -mpN \\
|
||||
<host-dir>,
|
||||
mp=<container-path>,
|
||||
shared=1, backup=0
|
||||
▼
|
||||
For local + unprivileged:
|
||||
└─ lmm_offer_host_permissions
|
||||
(chmod o+rwx + ACL on host dir,
|
||||
only if perms were insufficient)
|
||||
▼
|
||||
Offer to restart the container
|
||||
└─ pct reboot <ctid>
|
||||
(mounts only become active on
|
||||
the next CT start)
|
||||
▼
|
||||
Verify: pct exec <ctid> -- test -d
|
||||
<container-path> → "accessible"`}
|
||||
</pre>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("sources.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("sources.intro", { em })}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto mb-4 rounded-md border border-gray-200">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold">{t("sources.headerSource")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("sources.headerWhere")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("sources.headerLabel")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-800">
|
||||
{sourceRows.map((row, idx) => (
|
||||
<tr key={row.source}>
|
||||
<td className="px-4 py-2 font-semibold">{row.source}</td>
|
||||
<td className="px-4 py-2">
|
||||
{row.whereRich ? t.rich(`sources.rows.${idx}.whereRich`, { code }) : row.where}
|
||||
</td>
|
||||
<td className="px-4 py-2">{t.rich(`sources.rows.${idx}.labelRich`, { code, em })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("sources.tipTitle")}>
|
||||
{t.rich("sources.tipBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("manual.heading")}</h2>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("manual.privIntro")}</p>
|
||||
<CopyableCode code={`# 1. add the bind mount to the CT config
|
||||
pct set 101 -mp0 /mnt/data,mp=/mnt/data,shared=1,backup=0
|
||||
|
||||
# 2. restart the CT to activate the mount
|
||||
pct reboot 101
|
||||
|
||||
# 3. verify from inside
|
||||
pct exec 101 -- ls -la /mnt/data`} />
|
||||
|
||||
<p className="mb-3 mt-6 text-gray-800 leading-relaxed">{t("manual.unprivLocalIntro")}</p>
|
||||
<CopyableCode code={`# host: open the directory for any mapped UID
|
||||
chmod o+rwx /mnt/data
|
||||
setfacl -m o::rwx /mnt/data
|
||||
setfacl -m d:o::rwx /mnt/data # default ACL = applies to new files
|
||||
|
||||
# add the bind mount + restart
|
||||
pct set 102 -mp0 /mnt/data,mp=/mnt/data,shared=1,backup=0
|
||||
pct reboot 102`} />
|
||||
|
||||
<p className="mb-3 mt-6 text-gray-800 leading-relaxed">{t("manual.unprivCifsIntro")}</p>
|
||||
<CopyableCode code={`# host: remount the CIFS with open uid/gid
|
||||
umount /mnt/pve/cifs-nas
|
||||
mount -t cifs //10.0.0.50/share /mnt/pve/cifs-nas \\
|
||||
-o "username=user,password=pass,uid=0,gid=0,file_mode=0777,dir_mode=0777"
|
||||
|
||||
# update /etc/fstab if the mount is persistent
|
||||
sed -i 's|^\\(//10.0.0.50/share .*cifs \\).*|\\1username=user,password=pass,uid=0,gid=0,file_mode=0777,dir_mode=0777 0 0|' /etc/fstab
|
||||
|
||||
# bind mount + restart
|
||||
pct set 102 -mp0 /mnt/pve/cifs-nas,mp=/mnt/nas,shared=1,backup=0
|
||||
pct reboot 102`} />
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("view.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("view.body", { code })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("remove.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("remove.body", { code, strong })}</p>
|
||||
|
||||
<Callout variant="warning" title={t("remove.warnTitle")}>
|
||||
{t.rich("remove.warnBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.noMountTitle")}>
|
||||
{t.rich("troubleshoot.noMountBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.noWriteTitle")}>
|
||||
{t.rich("troubleshoot.noWriteBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.alreadyTitle")}>
|
||||
{t("troubleshoot.alreadyBody")}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.nfsTitle")}>
|
||||
{t("troubleshoot.nfsIntro")}
|
||||
<ul className="mt-2 list-disc list-inside space-y-1">
|
||||
{nfsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`troubleshoot.nfsItems.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
{t("troubleshoot.nfsOutro")}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.fstabOffTitle")}>
|
||||
{t.rich("troubleshoot.fstabOffBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("related.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{relatedItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.extraHref && item.extraLabel && (
|
||||
<>
|
||||
{item.joiner}
|
||||
<Link href={item.extraHref} className="text-blue-600 hover:underline">
|
||||
{item.extraLabel}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{item.tailRich ? t.rich(`related.items.${idx}.tailRich`, { code }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user