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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,396 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
import { DataFlowDiagram } from "@/components/ui/data-flow-diagram"
import CopyableCode from "@/components/CopyableCode"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.monitor.architecture.meta" })
return {
title: t("title"),
description: t("description"),
keywords: [
"proxmenux architecture",
"proxmox monitor flask",
"proxmox dashboard sqlite",
"proxmox appimage dashboard",
"proxmox websocket terminal",
"proxmox monitor blueprints",
],
alternates: { canonical: "https://proxmenux.com/docs/monitor/architecture" },
openGraph: {
title: t("ogTitle"),
description: t("ogDescription"),
type: "article",
url: "https://proxmenux.com/docs/monitor/architecture",
},
twitter: {
card: "summary",
title: t("twitterTitle"),
description: t("twitterDescription"),
},
}
}
type ThreadRow = { thread: string; cadence: string; job: string }
type BlueprintRow = { blueprint: string; prefix: string[]; owns: string }
type DataRow = { source: string; usedFor: string }
type PersistenceRow = { path: string; owner: string; contents: string }
type WhereNextItem = { label: string; href: string; tail: string }
export default async function MonitorArchitecturePage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.architecture" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { architecture: {
requestFlow: { rows: ThreadRow[] }
systemd: { items: string[] }
appimage: { consequences: string[] }
flask: { rows: BlueprintRow[] }
dataSources: { rows: DataRow[] }
persistence: { rows: PersistenceRow[] }
health: { items: string[] }
notifications: { items: string[] }
websocket: { items: string[] }
proxy: { items: string[] }
whereNext: { items: WhereNextItem[] }
} } }
}
const arch = messages.docs.monitor.architecture
const threadRows = arch.requestFlow.rows
const systemdItems = arch.systemd.items
const consequences = arch.appimage.consequences
const blueprintRows = arch.flask.rows
const dataRows = arch.dataSources.rows
const persistenceRows = arch.persistence.rows
const healthItems = arch.health.items
const notificationItems = arch.notifications.items
const websocketItems = arch.websocket.items
const proxyItems = arch.proxy.items
const whereNextItems = arch.whereNext.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
const link = (chunks: React.ReactNode) => (
<Link href="/docs/monitor" className="text-blue-600 hover:underline">{chunks}</Link>
)
const notifLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/notifications" className="text-blue-600 hover:underline">{chunks}</Link>
)
const aiLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/ai-assistant" className="text-blue-600 hover:underline">{chunks}</Link>
)
const accessLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/access-auth" className="text-blue-600 hover:underline">{chunks}</Link>
)
const fail2banLink = (chunks: React.ReactNode) => (
<Link href="/docs/security/fail2ban" className="text-blue-600 hover:underline">{chunks}</Link>
)
const fail2banWarnLink = (chunks: React.ReactNode) => (
<Link href="/docs/security/fail2ban" className="text-blue-700 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={10}
/>
<Callout variant="info" title={t("intro.title")}>
{t("intro.body")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("requestFlow.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("requestFlow.intro")}</p>
<DataFlowDiagram
nodes={[
{ variant: "source", label: t("requestFlow.nodes.clientLabel"), detail: t("requestFlow.nodes.clientDetail") },
{ variant: "bridge", label: t("requestFlow.nodes.flaskLabel"), detail: t("requestFlow.nodes.flaskDetail") },
{ variant: "bridge", label: t("requestFlow.nodes.hostLabel"), detail: t("requestFlow.nodes.hostDetail") },
{ variant: "target", label: t("requestFlow.nodes.stateLabel"), detail: t("requestFlow.nodes.stateDetail") },
]}
arrowLabel={t("requestFlow.diagramArrowLabel")}
caption={t("requestFlow.diagramCaption")}
/>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("requestFlow.threadsIntro", { strong })}
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("requestFlow.headerThread")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("requestFlow.headerCadence")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("requestFlow.headerJob")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{threadRows.map((row, idx) => (
<tr key={row.thread} className={idx < threadRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.thread}</td>
<td className="px-3 py-2 align-top whitespace-nowrap">{row.cadence}</td>
<td className="px-3 py-2 align-top">
{t.rich(`requestFlow.rows.${idx}.job`, { code })}
</td>
</tr>
))}
</tbody>
</table>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("systemd.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("systemd.intro", { code })}
</p>
<CopyableCode
code={`[Unit]
Description=ProxMenux Monitor - Web Dashboard
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/proxmenux-monitor
ExecStart=/opt/proxmenux-monitor/ProxMenux-Monitor.AppImage
Restart=on-failure
RestartSec=10
Environment="PORT=8008"
[Install]
WantedBy=multi-user.target`}
className="my-4"
/>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{systemdItems.map((_, idx) => (
<li key={idx}>{t.rich(`systemd.items.${idx}`, { strong, code })}</li>
))}
</ul>
<Callout variant="tip" title={t("systemd.inspectTitle")}>
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{`systemctl cat proxmenux-monitor.service # show the unit content
systemctl status proxmenux-monitor.service # state + recent log
journalctl -u proxmenux-monitor.service -f # follow live`}</pre>
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("appimage.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("appimage.intro", { code })}
</p>
<CopyableCode
code={`AppDir/
├── AppRun # entrypoint: sets PATH/LD_LIBRARY_PATH, exec flask_server
├── usr/
│ ├── bin/
│ │ ├── flask_server.py # main process
│ │ ├── flask_*_routes.py # Flask blueprints (auth, health, terminal, …)
│ │ ├── auth_manager.py # JWT + TOTP + API tokens
│ │ ├── health_monitor.py # 10-category checker
│ │ ├── health_persistence.py # SQLite layer
│ │ ├── notification_manager.py # orchestrator
│ │ ├── notification_channels.py # Telegram, Discord, Email, …
│ │ ├── notification_templates.py # message rendering + AI hook
│ │ ├── notification_events.py # JournalWatcher, TaskWatcher, …
│ │ ├── ai_providers/ # OpenAI · Anthropic · Gemini · Groq · Ollama · OpenRouter
│ │ ├── proxmox_storage_monitor.py # storage pool inspection
│ │ ├── hardware_monitor.py # CPU/PCIe/GPU enumeration
│ │ ├── ipmitool, sensors, upsc # vendored hardware tools
│ │ └── …
│ ├── lib/python3/ # vendored Python deps (Flask, JWT, psutil, …)
│ └── share/ # icons + .desktop file
└── web/ # Next.js static export
├── index.html
├── _next/ # JS / CSS chunks
└── manifest.json # PWA manifest`}
className="my-4"
/>
<p className="mb-4 text-gray-800 leading-relaxed">
{t("appimage.consequencesIntro")}
</p>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{consequences.map((_, idx) => (
<li key={idx}>{t.rich(`appimage.consequences.${idx}`, { strong, code })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("flask.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("flask.intro", { code })}
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("flask.headerBlueprint")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("flask.headerPrefix")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("flask.headerOwns")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{blueprintRows.map((row, idx) => (
<tr key={row.blueprint} className={idx < blueprintRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top font-mono text-xs">{row.blueprint}</td>
<td className="px-3 py-2 align-top font-mono text-xs leading-6">
{row.prefix.map((p, pidx) => (
<span key={pidx}>
{p}
{pidx < row.prefix.length - 1 && <br />}
</span>
))}
</td>
<td className="px-3 py-2 align-top">
{t.rich(`flask.rows.${idx}.owns`, { code })}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("flask.endpointsLink", { link })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataSources.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("dataSources.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataSources.headerSource")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataSources.headerUsedFor")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{dataRows.map((row, idx) => (
<tr key={row.source} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.source}</td>
<td className="px-3 py-2 align-top">{row.usedFor}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="tip" title={t("dataSources.cacheTitle")}>
{t.rich("dataSources.cacheBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("persistence.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("persistence.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("persistence.headerPath")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("persistence.headerOwner")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("persistence.headerContents")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{persistenceRows.map((row, idx) => (
<tr key={row.path} className={idx < persistenceRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top font-mono text-xs">{row.path}</td>
<td className="px-3 py-2 align-top">{t.rich(`persistence.rows.${idx}.owner`, { code })}</td>
<td className="px-3 py-2 align-top">{t.rich(`persistence.rows.${idx}.contents`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="warning" title={t("persistence.backupTitle")}>
{t.rich("persistence.backupBody", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("health.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("health.intro", { code })}
</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{healthItems.map((_, idx) => (
<li key={idx}>{t.rich(`health.items.${idx}`, { code })}</li>
))}
</ol>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("health.afterIntro", { code })}
</p>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("health.cycleEnd", { em, code, link })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("notifications.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("notifications.intro", { code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{notificationItems.map((_, idx) => (
<li key={idx}>{t.rich(`notifications.items.${idx}`, { strong, code })}</li>
))}
</ul>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("notifications.linksFooter", { notifLink, aiLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("websocket.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("websocket.intro", { em, code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{websocketItems.map((_, idx) => (
<li key={idx}>{t.rich(`websocket.items.${idx}`, { strong, code })}</li>
))}
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("websocket.outro", { code })}
</p>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("websocket.proxyNote", { code })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("proxy.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("proxy.intro")}</p>
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{proxyItems.map((_, idx) => (
<li key={idx}>{t.rich(`proxy.items.${idx}`, { strong, code })}</li>
))}
</ol>
<Callout variant="info" title={t("proxy.calloutTitle")}>
{t.rich("proxy.calloutBody", { strong, code, link: fail2banWarnLink })}
</Callout>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("proxy.outro", { accessLink, fail2banLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.map((item) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,518 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { ExternalLink } from "lucide-react"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
import CopyableCode from "@/components/CopyableCode"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.hardware.meta" })
return { title: t("title"), description: t("description") }
}
type GpuTool = { vendor: string; tool: string; projectLabel: string; projectHref?: string }
type DataRow = { section: string; endpoint: string; source: string }
type WhereNextItem = { label: string; href: string; tail: string }
export default async function HardwareTabPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.hardware" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { dashboard: { hardware: {
thresholds: { items: string[] }
sections: { systemInfoItems: string[]; thermalItems: string[] }
graphics: { tools: GpuTool[]; whereGoItems: string[] }
coral: { pathsItems: string[] }
power: { items: string[] }
dataCollected: { rows: DataRow[] }
whereNext: { items: WhereNextItem[] }
} } } }
}
const hw = messages.docs.monitor.dashboard.hardware
const thresholdsItems = hw.thresholds.items
const systemInfoItems = hw.sections.systemInfoItems
const thermalItems = hw.sections.thermalItems
const gpuTools = hw.graphics.tools
const whereGoItems = hw.graphics.whereGoItems
const coralPathsItems = hw.coral.pathsItems
const powerItems = hw.power.items
const dataRows = hw.dataCollected.rows
const whereNextItems = hw.whereNext.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
const green = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-green-500 align-middle mr-1" />
const amber = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-amber-500 align-middle mr-1" />
const red = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-red-500 align-middle mr-1" />
const thresholdsLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard/settings#status-colours" className="text-blue-700 hover:underline">
{chunks}
</Link>
)
const switchModeLink = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/switch-gpu-mode" className="text-blue-600 hover:underline">{chunks}</Link>
)
const nvidiaHostLink = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/nvidia-host" className="text-blue-600 hover:underline">{chunks}</Link>
)
const nvidiaAnchor = (chunks: React.ReactNode) => (
<a
href="https://www.nvidia.com/Download/index.aspx"
target="_blank"
rel="noopener noreferrer"
className="text-amber-900 underline hover:no-underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
</a>
)
const link1 = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/nvidia-host" className="text-blue-600 hover:underline">{chunks}</Link>
)
const link2 = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/switch-gpu-mode" className="text-blue-600 hover:underline">{chunks}</Link>
)
const link3 = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/gpu-vm-passthrough" className="text-blue-600 hover:underline">{chunks}</Link>
)
const link4 = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/igpu-acceleration-lxc" className="text-blue-600 hover:underline">{chunks}</Link>
)
const installLink = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/install-coral-tpu-host" className="text-blue-600 hover:underline">{chunks}</Link>
)
const lxcLink = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/coral-tpu-lxc" className="text-blue-600 hover:underline">{chunks}</Link>
)
const coralAnchor = (chunks: React.ReactNode) => (
<a
href="https://coral.ai/docs/m2/get-started/"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
</a>
)
const storageLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard/storage" className="text-blue-600 hover:underline">{chunks}</Link>
)
const smartLink = (chunks: React.ReactNode) => (
<Link href="/docs/disk-manager/smart-disk-test" className="text-blue-600 hover:underline">{chunks}</Link>
)
const pciSwitchLink = (chunks: React.ReactNode) => (
<Link href="/docs/hardware/switch-gpu-mode" className="text-blue-600 hover:underline">{chunks}</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={14}
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code })}
</Callout>
<Callout variant="tip" title={t("thresholds.title")}>
{t.rich("thresholds.intro", { strong, green, amber, red })}
<ul className="list-disc pl-6 mt-2 space-y-0.5">
{thresholdsItems.map((_, idx) => (
<li key={idx}>{t.rich(`thresholds.items.${idx}`, { strong })}</li>
))}
</ul>
{t.rich("thresholds.outro", { link: thresholdsLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("sections.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("sections.intro", { em })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("sections.systemInfoTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("sections.systemInfoIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{systemInfoItems.map((_, idx) => (
<li key={idx}>{t.rich(`sections.systemInfoItems.${idx}`, { strong })}</li>
))}
</ul>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("sections.memoryTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("sections.memoryBody", { code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("sections.thermalTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("sections.thermalIntro", { code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{thermalItems.map((_, idx) => (
<li key={idx}>{t.rich(`sections.thermalItems.${idx}`, { strong, code })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("graphics.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("graphics.intro", { em, strong, code, link: switchModeLink })}
</p>
<figure className="my-6">
<img
src="/monitor/hardware/hw-graphics-cards-vfio.png"
alt={t("graphics.vfioImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("graphics.vfioImageCaption", { code })}
</figcaption>
</figure>
<figure className="my-6">
<img
src="/monitor/hardware/hw-graphics-cards-lxc.png"
alt={t("graphics.lxcImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("graphics.lxcImageCaption")}
</figcaption>
</figure>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("graphics.realtimeTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("graphics.realtimeBody", { code })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">{t("graphics.toolsIntro")}</p>
<div className="overflow-x-auto mb-6 rounded-md border border-gray-200">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 text-left text-gray-700">
<tr>
<th className="px-4 py-2 font-semibold">{t("graphics.headerVendor")}</th>
<th className="px-4 py-2 font-semibold">{t("graphics.headerTool")}</th>
<th className="px-4 py-2 font-semibold">{t("graphics.headerProject")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-800">
{gpuTools.map((row) => (
<tr key={row.vendor}>
<td className="px-4 py-2 align-top whitespace-nowrap"><strong>{row.vendor}</strong></td>
<td className="px-4 py-2 align-top"><code>{row.tool}</code></td>
<td className="px-4 py-2 align-top">
{row.projectHref ? (
<a
href={row.projectHref}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
{row.projectLabel}
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
</a>
) : (
row.projectLabel
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<figure className="my-6">
<img
src="/monitor/hardware/hw-gpu-nvidia-modal.png"
alt={t("graphics.nvidiaImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("graphics.nvidiaImageCaption")}
</figcaption>
</figure>
<figure className="my-6">
<img
src="/monitor/hardware/hw-gpu-intel-modal.png"
alt={t("graphics.intelImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("graphics.intelImageCaption", { code })}
</figcaption>
</figure>
<figure className="my-6">
<img
src="/monitor/hardware/hw-gpu-amd-modal.png"
alt={t("graphics.amdImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("graphics.amdImageCaption", { code })}
</figcaption>
</figure>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("graphics.installTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("graphics.installBody", { code, strong, link: nvidiaHostLink })}
</p>
<figure className="my-6">
<img
src="/monitor/hardware/hw-gpu-nvidia-no-driver.png"
alt={t("graphics.noDriverAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("graphics.noDriverCaption")}
</figcaption>
</figure>
<figure className="my-6">
<img
src="/monitor/hardware/hw-gpu-nvidia-install-prompt.png"
alt={t("graphics.promptAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("graphics.promptCaption")}
</figcaption>
</figure>
<figure className="my-6">
<img
src="/monitor/hardware/hw-gpu-nvidia-install-success.png"
alt={t("graphics.successAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("graphics.successCaption", { code })}
</figcaption>
</figure>
<Callout variant="warning" title={t("graphics.warningTitle")}>
{t.rich("graphics.warningBody", { code, em, a: nvidiaAnchor })}
</Callout>
<p className="mt-4 mb-2 text-gray-800 leading-relaxed">{t("graphics.whereGoIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{whereGoItems.map((_, idx) => (
<li key={idx}>{t.rich(`graphics.whereGoItems.${idx}`, { em, link1, link2, link3, link4 })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
{t("coral.heading")} <em>{t("coral.subHeading")}</em>
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("coral.intro", { code })}
</p>
<figure className="my-6">
<img
src="/monitor/hardware/hw-coral-tpu-modal.png"
alt={t("coral.imageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("coral.imageCaption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("coral.pathsIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{coralPathsItems.map((_, idx) => (
<li key={idx}>{t.rich(`coral.pathsItems.${idx}`, { strong, code })}</li>
))}
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("coral.outro", { installLink, lxcLink, a: coralAnchor })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("storage.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("storage.intro", { code, em })}
</p>
<figure className="my-6">
<img
src="/monitor/hardware/hw-storage-summary.png"
alt={t("storage.imageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("storage.imageCaption", { em })}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("storage.nvmeBody", { strong })}
</p>
<figure className="my-6">
<img
src="/monitor/hardware/hw-storage-modal-nvme.png"
alt={t("storage.nvmeModalAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("storage.nvmeModalCaption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("storage.outro", { em, storageLink, smartLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("pci.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("pci.intro", { strong, em, code })}
</p>
<figure className="my-6">
<img
src="/monitor/hardware/hw-pci-devices.png"
alt={t("pci.imageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("pci.imageCaption", { code })}
</figcaption>
</figure>
<Callout variant="tip" title={t("pci.bdfTitle")}>
{t.rich("pci.bdfBody", { code, link: pciSwitchLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("usb.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("usb.intro", { code, em })}
</p>
<figure className="my-6">
<img
src="/monitor/hardware/hw-usb-devices.png"
alt={t("usb.imageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("usb.imageCaption", { code })}
</figcaption>
</figure>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
{t("power.heading")} <em>{t("power.subHeading")}</em>
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("power.intro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{powerItems.map((_, idx) => (
<li key={idx}>{t.rich(`power.items.${idx}`, { strong })}</li>
))}
</ul>
<figure className="my-6">
<img
src="/monitor/hardware/hw-power-supplies.png"
alt={t("power.supplyImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("power.supplyImageCaption")}
</figcaption>
</figure>
<figure className="my-6">
<img
src="/monitor/hardware/hw-cpu-power.png"
alt={t("power.cpuImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("power.cpuImageCaption")}
</figcaption>
</figure>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
{t("psu.heading")} <em>{t("psu.subHeading")}</em>
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("psu.body")}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
{t("fans.heading")} <em>{t("fans.subHeading")}</em>
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("fans.body")}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
{t("ups.heading")} <em>{t("ups.subHeading")}</em>
</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("ups.body", { em })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSection")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{dataRows.map((row, idx) => (
<tr key={row.section} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top">{row.section}</td>
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
<td className="px-3 py-2 align-top">{t.rich(`dataCollected.rows.${idx}.source`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<CopyableCode
code={`${t("dataCollected.codeComment1")}
lspci -nnk | grep -A2 -E 'VGA|Audio|Network|3D'
sensors
${t("dataCollected.codeComment2")}
curl -H "Authorization: Bearer <token>" \\
http://<host>:8008/api/hardware | jq '.gpus'`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.map((item) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,325 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { Download } from "lucide-react"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
import CopyableCode from "@/components/CopyableCode"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.network.meta" })
return { title: t("title"), description: t("description") }
}
type TopRow = { card: string; what: string }
type DrillRow = { block: string; contents: string }
type ThresholdRow = { status: string; range: string; impact: string }
type DataRow = { section: string; endpoint: string; source: string }
type WhereNextItem = { label: string; href: string; tail: string }
export default async function NetworkTabPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.network" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { dashboard: { network: {
topRow: { rows: TopRow[] }
groups: { badges: string[] }
drillIn: { rows: DrillRow[] }
latency: {
targets: string[]
mode2Items: string[]
thresholdRows: ThresholdRow[]
sections: string[]
}
dataCollected: { rows: DataRow[] }
whereNext: { items: WhereNextItem[] }
} } } }
}
const net = messages.docs.monitor.dashboard.network
const topRows = net.topRow.rows
const badges = net.groups.badges
const drillRows = net.drillIn.rows
const targets = net.latency.targets
const mode2Items = net.latency.mode2Items
const thresholdRows = net.latency.thresholdRows
const sections = net.latency.sections
const dataRows = net.dataCollected.rows
const whereNextItems = net.whereNext.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={13}
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("topRow.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerCard")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerWhat")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{topRows.map((row, idx) => (
<tr key={row.card} className={idx < topRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.card}</strong></td>
<td className="px-3 py-2 align-top">
{t.rich(`topRow.rows.${idx}.what`, { em, strong })}
</td>
</tr>
))}
</tbody>
</table>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("groups.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("groups.intro", { strong })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{badges.map((_, idx) => (
<li key={idx}>{t.rich(`groups.badges.${idx}`, { strong, code })}</li>
))}
</ul>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("groups.clickable", { strong })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("groups.physicalTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("groups.physicalBody", { code, strong, em })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("groups.bridgeTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("groups.bridgeBody", { code, strong })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("groups.vmTitle")}</h3>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("groups.vmBody", { code, em })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("drillIn.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("drillIn.headerBlock")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("drillIn.headerContents")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{drillRows.map((row, idx) => (
<tr key={row.block} className={idx < drillRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.block}</strong></td>
<td className="px-3 py-2 align-top">
{t.rich(`drillIn.rows.${idx}.contents`, { code })}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="tip" title={t("drillIn.inactiveTitle")}>
{t.rich("drillIn.inactiveBody", { em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("latency.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("latency.intro", { em })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("latency.targetsTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("latency.targetsIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{targets.map((_, idx) => (
<li key={idx}>{t.rich(`latency.targets.${idx}`, { strong })}</li>
))}
</ul>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("latency.mode1Title")}</h3>
<figure className="my-4">
<img
src="/monitor/network-latency-historical.png"
alt={t("latency.mode1Alt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("latency.mode1Caption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("latency.mode1Body1", { em })}
</p>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("latency.mode1Body2", { code })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("latency.mode2Title")}</h3>
<figure className="my-4">
<img
src="/monitor/network-latency-realtime.png"
alt={t("latency.mode2Alt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("latency.mode2Caption", { em })}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("latency.mode2Intro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{mode2Items.map((_, idx) => (
<li key={idx}>{t.rich(`latency.mode2Items.${idx}`, { strong, code })}</li>
))}
</ul>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("latency.thresholdsTitle")}</h3>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("latency.headerStatus")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("latency.headerRange")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("latency.headerImpact")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{thresholdRows.map((row, idx) => (
<tr key={row.status} className={idx < thresholdRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.status}</strong></td>
<td className="px-3 py-2 align-top">{row.range}</td>
<td className="px-3 py-2 align-top">{row.impact}</td>
</tr>
))}
</tbody>
</table>
</div>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("latency.reportTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("latency.reportIntro", { strong })}
</p>
<figure className="my-6">
<img
src="/monitor/network-latency-report-preview.png"
alt={t("latency.reportPreviewAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("latency.reportPreviewCaption")}
</figcaption>
</figure>
<div className="my-6">
<a
href="/monitor/sample-network-latency-report.pdf"
download
className="inline-flex items-center gap-2 px-4 py-2 rounded-md border border-blue-200 bg-blue-50 text-blue-700 font-medium hover:bg-blue-100 transition-colors"
>
<Download className="h-4 w-4" aria-hidden="true" />
{t("latency.downloadLabel")}
</a>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">{t("latency.sectionsIntro")}</p>
<ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{sections.map((_, idx) => (
<li key={idx}>{t.rich(`latency.sections.${idx}`, { strong })}</li>
))}
</ol>
<Callout variant="tip" title={t("latency.useCaseTitle")}>
{t.rich("latency.useCaseBody", { em })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("excluding.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("excluding.body1", { code })}
</p>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("excluding.body2", { strong, em })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSection")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{dataRows.map((row, idx) => (
<tr key={row.section} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top">{row.section}</td>
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
<td className="px-3 py-2 align-top">
{t.rich(`dataCollected.rows.${idx}.source`, { code })}
</td>
</tr>
))}
</tbody>
</table>
</div>
<CopyableCode
code={`${t("dataCollected.codeComment1")}
ip -br link
ip -br addr
${t("dataCollected.codeComment2")}
curl -H "Authorization: Bearer <token>" \\
http://<host>:8008/api/network/latency/current | jq`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.map((item) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,120 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.meta" })
return {
title: t("title"),
description: t("description"),
}
}
type TabRow = { name: string; linksTo?: string; owns: string }
type WhereNextItem = { label: string; href: string; tail: string }
export default async function DashboardIndexPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { dashboard: {
tabs: { rows: TabRow[] }
headerAnatomy: { items: string[] }
whereNext: { items: WhereNextItem[] }
} } }
}
const tabRows = messages.docs.monitor.dashboard.tabs.rows
const headerAnatomyItems = messages.docs.monitor.dashboard.headerAnatomy.items
const whereNextItems = messages.docs.monitor.dashboard.whereNext.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
const link = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/health-monitor" className="text-blue-700 hover:underline">
{chunks}
</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={3}
/>
<Callout variant="info" title={t("oneHeader.title")}>
{t.rich("oneHeader.body", { link })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("tabs.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("tabs.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("tabs.headerTab")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("tabs.headerOwns")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{tabRows.map((row, idx) => (
<tr
key={row.name}
className={idx < tabRows.length - 1 ? "border-b border-gray-100" : ""}
>
<td className="px-3 py-2 align-top whitespace-nowrap">
{row.linksTo ? (
<Link href={row.linksTo} className="text-blue-600 hover:underline font-semibold">
{row.name}
</Link>
) : (
<strong>{row.name}</strong>
)}
</td>
<td className="px-3 py-2 align-top">
{t.rich(`tabs.rows.${idx}.owns`, { code })}
</td>
</tr>
))}
</tbody>
</table>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("headerAnatomy.heading")}</h2>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{headerAnatomyItems.map((_, idx) => (
<li key={idx}>{t.rich(`headerAnatomy.items.${idx}`, { code, strong, em })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.map((item) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,489 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import Image from "next/image"
import { ExternalLink } from "lucide-react"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
import CopyableCode from "@/components/CopyableCode"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.security.meta" })
return { title: t("title"), description: t("description") }
}
type DataRow = { card: string; endpoint: string; source: string }
type WhereNextItem = { label: string; href: string; tail?: string; tailRich?: string }
export default async function SecurityTabPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.security" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { dashboard: { security: {
auth: { items: string[] }
ssl: { items: string[] }
gateway: { step3Items: string[]; step4Items: string[] }
firewall: { items: string[] }
lynis: { scoreItems: string[] }
dataCollected: { rows: DataRow[] }
whereNext: { items: WhereNextItem[] }
} } } }
}
const sec = messages.docs.monitor.dashboard.security
const authItems = sec.auth.items
const sslItems = sec.ssl.items
const step3Items = sec.gateway.step3Items
const step4Items = sec.gateway.step4Items
const firewallItems = sec.firewall.items
const lynisScoreItems = sec.lynis.scoreItems
const dataRows = sec.dataCollected.rows
const whereNextItems = sec.whereNext.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
const authLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/access-auth" className="text-blue-600 hover:underline">{chunks}</Link>
)
const sslPageLink = (chunks: React.ReactNode) => (
<Link href="/docs/security/ssl-letsencrypt" className="text-blue-600 hover:underline">{chunks}</Link>
)
const integrationsLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/integrations" className="text-blue-600 hover:underline">{chunks}</Link>
)
const tailscaleHomeAnchor = (chunks: React.ReactNode) => (
<a href="https://tailscale.com" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
{chunks}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
)
const tailscaleKeysAnchor = (chunks: React.ReactNode) => (
<a href="https://login.tailscale.com/admin/settings/keys" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
{chunks}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
)
const tailscaleMachinesAnchor = (chunks: React.ReactNode) => (
<a href="https://login.tailscale.com/admin/machines" target="_blank" rel="noopener noreferrer" className="text-amber-700 hover:underline inline-flex items-center gap-1">
{chunks}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
)
const fail2banLink = (chunks: React.ReactNode) => (
<Link href="/docs/security/fail2ban" className="text-blue-600 hover:underline">{chunks}</Link>
)
const lynisLink = (chunks: React.ReactNode) => (
<Link href="/docs/security/lynis" className="text-blue-600 hover:underline">{chunks}</Link>
)
const lynisSampleAnchor = (chunks: React.ReactNode) => (
<a href="/monitor/security/lynis-sample-report.pdf" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
{chunks}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={18}
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("monitor.heading")}</h2>
<p className="mb-6 text-gray-800 leading-relaxed">{t("monitor.intro")}</p>
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("auth.heading")}</h3>
<figure className="my-4">
<Image src="/monitor/security/auth-card.png" alt={t("auth.imageAlt")} width={2000} height={956} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("auth.imageCaption")}</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("auth.intro", { link: authLink })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{authItems.map((_, idx) => (
<li key={idx}>{t.rich(`auth.items.${idx}`, { strong, em })}</li>
))}
</ul>
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("ssl.heading")}</h3>
<figure className="my-4">
<Image src="/monitor/security/ssl-https-card.png" alt={t("ssl.imageAlt")} width={2000} height={1124} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("ssl.imageCaption", { em })}</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("ssl.intro", { code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{sslItems.map((_, idx) => (
<li key={idx}>{t.rich(`ssl.items.${idx}`, { strong, code })}</li>
))}
</ul>
<figure className="my-4">
<Image src="/monitor/security/ssl-https-enabled.png" alt={t("ssl.enabledAlt")} width={2000} height={889} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("ssl.enabledCaption", { em })}</figcaption>
</figure>
<Callout variant="info" title={t("ssl.acmeTitle")}>
{t.rich("ssl.acmeBody", { em })}
</Callout>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("ssl.walkthroughLink", { code, link: sslPageLink })}
</p>
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("apiTokens.heading")}</h3>
<figure className="my-4">
<Image src="/monitor/security/api-tokens-empty.png" alt={t("apiTokens.emptyAlt")} width={2000} height={855} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("apiTokens.emptyCaption", { em, code })}</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("apiTokens.intro")}</p>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("apiTokens.generateBody", { strong, em })}
</p>
<figure className="my-4">
<Image src="/monitor/security/api-tokens-generate.png" alt={t("apiTokens.generateAlt")} width={2000} height={1124} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("apiTokens.generateCaption")}</figcaption>
</figure>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("apiTokens.saveBody", { strong })}
</p>
<figure className="my-4">
<Image src="/monitor/security/api-tokens-generated.png" alt={t("apiTokens.generatedAlt")} width={2000} height={1468} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("apiTokens.generatedCaption", { code })}</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("apiTokens.outro", { em, link: integrationsLink })}
</p>
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("gateway.heading")}</h3>
<figure className="my-4">
<Image src="/monitor/security/secure-gateway-card.png" alt={t("gateway.cardAlt")} width={2000} height={434} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gateway.cardCaption")}</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("gateway.intro", { code, a: tailscaleHomeAnchor })}
</p>
<h4 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("gateway.wizardTitle")}</h4>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("gateway.wizardIntro", { em })}
</p>
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step0Title")}</h5>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("gateway.step0Body", { em, a: tailscaleKeysAnchor })}
</p>
<figure className="my-4">
<Image src="/monitor/security/tailscale-auth-key-page.png" alt={t("gateway.step0Alt")} width={2000} height={1115} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("gateway.step0Caption", { em })}</figcaption>
</figure>
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step1Title")}</h5>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("gateway.step1Body", { em })}
</p>
<figure className="my-4">
<Image src="/monitor/security/gateway-step-1-intro.png" alt={t("gateway.step1Alt")} width={1589} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gateway.step1Caption")}</figcaption>
</figure>
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step2Title")}</h5>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("gateway.step2Body", { code })}
</p>
<figure className="my-4">
<Image src="/monitor/security/gateway-step-3-auth.png" alt={t("gateway.step2Alt")} width={1985} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gateway.step2Caption")}</figcaption>
</figure>
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step3Title")}</h5>
<p className="mb-3 text-gray-800 leading-relaxed">{t("gateway.step3Intro")}</p>
<ul className="list-disc pl-6 mb-3 text-gray-800 leading-relaxed space-y-1">
{step3Items.map((_, idx) => (
<li key={idx}>{t.rich(`gateway.step3Items.${idx}`, { strong })}</li>
))}
</ul>
<figure className="my-4">
<Image src="/monitor/security/gateway-step-2-scope.png" alt={t("gateway.step3Alt")} width={1934} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("gateway.step3Caption", { em })}</figcaption>
</figure>
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step4Title")}</h5>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("gateway.step4Intro", { strong })}
</p>
<ul className="list-disc pl-6 mb-3 text-gray-800 leading-relaxed space-y-1">
{step4Items.map((_, idx) => (
<li key={idx}>{t.rich(`gateway.step4Items.${idx}`, { strong, em })}</li>
))}
</ul>
<figure className="my-4">
<Image src="/monitor/security/gateway-step-4-advanced.png" alt={t("gateway.step4Alt")} width={1847} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gateway.step4Caption")}</figcaption>
</figure>
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step5Title")}</h5>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("gateway.step5Body", { strong })}
</p>
<figure className="my-4">
<Image src="/monitor/security/gateway-step-5-review.png" alt={t("gateway.step5Alt")} width={1860} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gateway.step5Caption")}</figcaption>
</figure>
<Callout variant="warning" title={t("gateway.approvalTitle")}>
{t.rich("gateway.approvalBody", { em, a: tailscaleMachinesAnchor })}
</Callout>
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("pve.heading")}</h2>
<p className="mb-6 text-gray-800 leading-relaxed">{t("pve.intro")}</p>
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("firewall.heading")}</h3>
<figure className="my-4">
<Image src="/monitor/security/firewall-card.png" alt={t("firewall.imageAlt")} width={2000} height={1256} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("firewall.imageCaption", { em })}</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("firewall.intro", { code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{firewallItems.map((_, idx) => (
<li key={idx}>{t.rich(`firewall.items.${idx}`, { strong, em, code })}</li>
))}
</ul>
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">
{t("fail2ban.heading")} <em>{t("fail2ban.subHeading")}</em>
</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("fail2ban.whatIs", { strong })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("fail2ban.notBundled", { strong })}
</p>
<figure className="my-4">
<Image src="/monitor/security/fail2ban-not-installed.png" alt={t("fail2ban.notInstalledAlt")} width={2000} height={968} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("fail2ban.notInstalledCaption")}</figcaption>
</figure>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("fail2ban.clickBody", { em })}
</p>
<figure className="my-4">
<Image src="/monitor/security/fail2ban-install-confirm.png" alt={t("fail2ban.confirmAlt")} width={1808} height={1678} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("fail2ban.confirmCaption", { code })}</figcaption>
</figure>
<p className="mb-3 text-gray-800 leading-relaxed">{t("fail2ban.confirmIntro")}</p>
<figure className="my-4">
<Image src="/monitor/security/fail2ban-install-progress.png" alt={t("fail2ban.progressAlt")} width={2000} height={1512} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("fail2ban.progressCaption")}</figcaption>
</figure>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("fail2ban.afterInstall", { em })}
</p>
<figure className="my-4">
<Image src="/monitor/security/fail2ban-active.png" alt={t("fail2ban.activeAlt")} width={2000} height={1614} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("fail2ban.activeCaption", { code })}</figcaption>
</figure>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("fail2ban.tuneBody", { strong, em })}
</p>
<figure className="my-4">
<Image src="/monitor/security/fail2ban-sshd-config.png" alt={t("fail2ban.configAlt")} width={2000} height={919} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("fail2ban.configCaption", { em })}</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("fail2ban.outro", { em, code, link: fail2banLink })}
</p>
<Callout variant="info" title={t("fail2ban.calloutTitle")}>
{t.rich("fail2ban.calloutBody", { em, code })}
</Callout>
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">
{t("lynis.heading")} <em>{t("lynis.subHeading")}</em>
</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("lynis.whatIs", { strong })}
</p>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("lynis.whyUseful", { strong, code })}
</p>
<figure className="my-4">
<Image src="/monitor/security/lynis-not-installed.png" alt={t("lynis.notInstalledAlt")} width={2000} height={919} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("lynis.notInstalledCaption")}</figcaption>
</figure>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("lynis.notBundled", { strong })}
</p>
<figure className="my-4">
<Image src="/monitor/security/lynis-install-confirm.png" alt={t("lynis.confirmAlt")} width={1985} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("lynis.confirmCaption")}</figcaption>
</figure>
<figure className="my-4">
<Image src="/monitor/security/lynis-install-progress.png" alt={t("lynis.progressAlt")} width={1856} height={972} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("lynis.progressCaption")}</figcaption>
</figure>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("lynis.afterInstall", { em })}
</p>
<figure className="my-4">
<Image src="/monitor/security/lynis-installed-empty.png" alt={t("lynis.installedAlt")} width={2000} height={1131} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("lynis.installedCaption")}</figcaption>
</figure>
<figure className="my-4">
<Image src="/monitor/security/lynis-audit-running.png" alt={t("lynis.runningAlt")} width={2000} height={1131} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("lynis.runningCaption")}</figcaption>
</figure>
<p className="mb-3 text-gray-800 leading-relaxed">
{t.rich("lynis.finishedBody", { em })}
</p>
<figure className="my-4">
<Image src="/monitor/security/lynis-audit-results.png" alt={t("lynis.resultsAlt")} width={2000} height={1183} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("lynis.resultsCaption", { strong })}</figcaption>
</figure>
<Callout variant="info" title={t("lynis.scoreTitle")}>
{t.rich("lynis.scoreIntro", { em, code })}
<ul className="list-disc pl-6 mt-2 mb-0 space-y-1">
{lynisScoreItems.map((_, idx) => (
<li key={idx}>{t.rich(`lynis.scoreItems.${idx}`, { em })}</li>
))}
</ul>
</Callout>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("lynis.reportBody", { strong, em })}
</p>
<figure className="my-4">
<Image src="/monitor/security/lynis-report-pdf.png" alt={t("lynis.reportAlt")} width={1414} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("lynis.reportCaption", { a: lynisSampleAnchor })}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("lynis.runPeriodically")}</p>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("lynis.outro", { em, link: lynisLink })}
</p>
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerCard")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{dataRows.map((row, idx) => (
<tr key={row.card} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top">{row.card}</td>
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
<td className="px-3 py-2 align-top">{t.rich(`dataCollected.rows.${idx}.source`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<CopyableCode
code={`# Confirm the auth log on the host (used by Fail2Ban + audit)
journalctl -t proxmenux-auth --since '7 days ago' | tail
# Cross-check the firewall rules the dashboard sees
pve-firewall status
cat /etc/pve/firewall/host.fw
# Verify Fail2Ban (only if installed)
fail2ban-client status
fail2ban-client status sshd
# Verify Lynis (only if installed)
lynis show version
ls -lh /var/log/lynis-report.dat`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.map((item, idx) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail}
</li>
))}
</ul>
</div>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,178 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
import CopyableCode from "@/components/CopyableCode"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.systemLogs.meta" })
return { title: t("title"), description: t("description") }
}
type DataRow = { subtab: string; endpoint: string; source: string }
type WhereNextItem = { label: string; href: string; tail: string }
export default async function SystemLogsTabPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.systemLogs" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { dashboard: { systemLogs: {
topRow: { items: string[] }
subtabs: { logsFilters: string[]; fields: string[] }
dataCollected: { rows: DataRow[] }
whereNext: { items: WhereNextItem[] }
} } } }
}
const sl = messages.docs.monitor.dashboard.systemLogs
const topRowItems = sl.topRow.items
const logsFilters = sl.subtabs.logsFilters
const fields = sl.subtabs.fields
const dataRows = sl.dataCollected.rows
const whereNextItems = sl.whereNext.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
const link = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/notifications" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={7}
/>
<Callout variant="info" title={t("readOnly.title")}>
{t.rich("readOnly.body", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("topRow.heading")}</h2>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{topRowItems.map((_, idx) => (
<li key={idx}>{t.rich(`topRow.items.${idx}`, { code, strong })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("subtabs.heading")}</h2>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("subtabs.logsTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("subtabs.logsIntro", { code })}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{logsFilters.map((_, idx) => (
<li key={idx}>{t.rich(`subtabs.logsFilters.${idx}`, { code, strong })}</li>
))}
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("subtabs.logsRowsAfter", { code, strong })}
</p>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("subtabs.logDetailsModalTitle")}</h4>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("subtabs.logDetailsBody", { code, strong })}
</p>
<figure className="my-6">
<img
src="/monitor/log-details-modal.png"
alt={t("subtabs.logDetailsImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("subtabs.logDetailsImageCaption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("subtabs.fieldsIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{fields.map((_, idx) => (
<li key={idx}>{t.rich(`subtabs.fields.${idx}`, { code, strong })}</li>
))}
</ul>
<Callout variant="tip" title={t("subtabs.maxLevelStoreTitle")}>
{t.rich("subtabs.maxLevelStoreBody", { code })}
</Callout>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("subtabs.backupsTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("subtabs.backupsBody", { code, em })}</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("subtabs.notificationsTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("subtabs.notificationsBody1")}</p>
<p className="mb-6 text-gray-800 leading-relaxed">{t.rich("subtabs.notificationsBody2", { link })}</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSubtab")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{dataRows.map((row, idx) => (
<tr
key={row.endpoint}
className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}
>
<td className="px-3 py-2 align-top">{row.subtab}</td>
<td className="px-3 py-2 align-top font-mono text-xs" dangerouslySetInnerHTML={{ __html: row.endpoint }} />
<td className="px-3 py-2 align-top">
{t.rich(`dataCollected.rows.${idx}.source`, { code })}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mb-4 text-gray-800 leading-relaxed">{t("dataCollected.apiIntro")}</p>
<CopyableCode
code={`${t("dataCollected.codeComment1")}
curl -H "Authorization: Bearer <token>" \\
"http://<host>:8008/api/logs?severity=error&since=1h&search=zfs"
${t("dataCollected.codeComment2")}
curl -H "Authorization: Bearer <token>" \\
-o pmx-journal.txt \\
"http://<host>:8008/api/logs/download?since=6h"
${t("dataCollected.codeComment3")}
curl -H "Authorization: Bearer <token>" \\
"http://<host>:8008/api/task-log/<upid>"`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.map((item) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,225 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
import CopyableCode from "@/components/CopyableCode"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.systemOverview.meta" })
return { title: t("title"), description: t("description") }
}
type TopRow = { card: string; what: string; source: string }
type DataRow = { card: string; endpoint: string; source: string }
type WhereNextItem = { label: string; href: string; tail: string }
export default async function SystemOverviewTabPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.systemOverview" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { dashboard: { systemOverview: {
topRow: { rows: TopRow[]; thresholdsItems: string[] }
bottom: { storageItems: string[] }
refresh: { items: string[] }
dataCollected: { rows: DataRow[] }
whereNext: { items: WhereNextItem[] }
} } } }
}
const so = messages.docs.monitor.dashboard.systemOverview
const topRows = so.topRow.rows
const thresholdsItems = so.topRow.thresholdsItems
const storageItems = so.bottom.storageItems
const refreshItems = so.refresh.items
const dataRows = so.dataCollected.rows
const whereNextItems = so.whereNext.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
const green = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-green-500 align-middle mr-1" />
const amber = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-amber-500 align-middle mr-1" />
const red = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-red-500 align-middle mr-1" />
const thresholdsLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard/settings#status-colours" className="text-blue-700 hover:underline">
{chunks}
</Link>
)
const storageLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
const networkLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={6}
/>
<Callout variant="info" title={t("readOnly.title")}>
{t("readOnly.body")}
</Callout>
<figure className="my-8">
<img
src="/monitor/dashboard-home.png"
alt={t("captureAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("captureCaption")}
</figcaption>
</figure>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("topRow.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("topRow.intro", { code })}
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerCard")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerWhat")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerSource")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{topRows.map((row, idx) => (
<tr key={row.card} className={idx < topRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap">
<strong>{row.card}</strong>
</td>
<td className="px-3 py-2 align-top">
{t.rich(`topRow.rows.${idx}.what`, { code, em })}
</td>
<td className="px-3 py-2 align-top font-mono text-xs">{row.source}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="tip" title={t("topRow.thresholdsTitle")}>
{t.rich("topRow.thresholdsIntro", { strong, green, amber, red })}
<ul className="list-disc pl-6 mt-2 space-y-0.5">
{thresholdsItems.map((_, idx) => (
<li key={idx}>{t.rich(`topRow.thresholdsItems.${idx}`, { strong })}</li>
))}
</ul>
{t.rich("topRow.thresholdsOutro", { link: thresholdsLink })}
</Callout>
<Callout variant="tip" title={t("topRow.sparklineTitle")}>
{t("topRow.sparklineBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("middle.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("middle.body1", { code, em })}
</p>
<p className="mb-6 text-gray-800 leading-relaxed">
{t("middle.body2")}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("bottom.heading")}</h2>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("bottom.storageTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("bottom.storageIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{storageItems.map((_, idx) => (
<li key={idx}>{t.rich(`bottom.storageItems.${idx}`, { strong })}</li>
))}
</ul>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("bottom.storageDrillIn", { link: storageLink })}
</p>
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("bottom.networkTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("bottom.networkBody1", { code })}
</p>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("bottom.networkBody2", { link: networkLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("refresh.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("refresh.intro", { code, em })}
</p>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{refreshItems.map((_, idx) => (
<li key={idx}>{t.rich(`refresh.items.${idx}`, { strong })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerCard")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{dataRows.map((row, idx) => (
<tr key={row.card} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top">{row.card}</td>
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
<td className="px-3 py-2 align-top">
{t.rich(`dataCollected.rows.${idx}.source`, { code })}
</td>
</tr>
))}
</tbody>
</table>
</div>
<CopyableCode
code={`${t("dataCollected.codeComment1")}
curl http://<host>:8008/api/health ${t("dataCollected.codeComment2")}
${t("dataCollected.codeComment3")}
curl -H "Authorization: Bearer <token>" \\
http://<host>:8008/api/system | jq '.cpu,.memory,.uptime'`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.map((item) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,319 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import Image from "next/image"
import { ExternalLink } from "lucide-react"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.terminal.meta" })
return { title: t("title"), description: t("description") }
}
type KeyboardRow = { button: string; sends: string; use?: string; useRich?: boolean }
type DisconnectRow = { cause: string; fix: string }
type WhereNextItem = { label: string; href: string; tail: string }
export default async function TerminalTabPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.terminal" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { dashboard: { terminal: {
keyboard: { rows: KeyboardRow[]; ctrlItems: string[] }
auth: { items: string[] }
clipboard: { items: string[] }
disconnect: { rows: DisconnectRow[] }
fourTerminals: { items: string[] }
whereNext: { items: WhereNextItem[] }
} } } }
}
const term = messages.docs.monitor.dashboard.terminal
const kbRows = term.keyboard.rows
const ctrlItems = term.keyboard.ctrlItems
const authItems = term.auth.items
const clipboardItems = term.clipboard.items
const disconnectRows = term.disconnect.rows
const fourTerminalsItems = term.fourTerminals.items
const whereNextItems = term.whereNext.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
const green = (chunks: React.ReactNode) => <span className="text-green-600 font-semibold">{chunks}</span>
const red = (chunks: React.ReactNode) => <span className="text-red-600 font-semibold">{chunks}</span>
const vmsLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard/vms-lxcs" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
const vmsLinkAmber = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard/vms-lxcs" className="text-blue-700 hover:underline">
{chunks}
</Link>
)
const authLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/access-auth" className="text-blue-600 hover:underline">
{chunks}
</Link>
)
const authLinkWarn = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/access-auth" className="text-amber-700 hover:underline">
{chunks}
</Link>
)
const gatewayLink = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/integrations" className="text-amber-700 hover:underline">
{chunks}
</Link>
)
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={7}
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code })}
</Callout>
<figure className="my-6">
<Image
src="/monitor/terminal/single-terminal.png"
alt={t("singleAlt")}
width={1600}
height={1000}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("singleCaption", { em })}
</figcaption>
</figure>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("target.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("target.body1", { strong })}
</p>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("target.body2", { strong, em, code, link: vmsLink })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("fourTerminals.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("fourTerminals.intro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{fourTerminalsItems.map((_, idx) => (
<li key={idx}>{t.rich(`fourTerminals.items.${idx}`, { strong, em, code })}</li>
))}
</ul>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("fourTerminals.outro", { strong, em, code })}
</p>
<figure className="my-6">
<Image
src="/monitor/terminal/grid-4-terminals.png"
alt={t("gridAlt")}
width={1600}
height={1000}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("gridCaption", { code })}
</figcaption>
</figure>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("keyboard.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("keyboard.intro", { code })}
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("keyboard.headerButton")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("keyboard.headerSends")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("keyboard.headerUse")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{kbRows.map((row, idx) => (
<tr key={row.button} className={idx < kbRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.button}</strong></td>
<td className="px-3 py-2 align-top font-mono text-xs">{row.sends}</td>
<td className="px-3 py-2 align-top">
{row.useRich ? (
<>
{t("keyboard.ctrlIntro")}
<ul className="list-disc pl-5 mt-1 space-y-0.5">
{ctrlItems.map((_, cidx) => (
<li key={cidx}>{t.rich(`keyboard.ctrlItems.${cidx}`, { code })}</li>
))}
</ul>
</>
) : (
t.rich(`keyboard.rows.${idx}.use`, { code })
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="info" title={t("keyboard.modalTitle")}>
{t.rich("keyboard.modalBody", { code, link: vmsLinkAmber })}
</Callout>
<figure className="my-6">
<Image
src="/monitor/terminal/lxc-console-modal.png"
alt={t("lxcAlt")}
width={1600}
height={1200}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("lxcCaption", { em })}
</figcaption>
</figure>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("search.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("search.intro", { code, strong, em })}
</p>
<figure className="my-6">
<Image
src="/monitor/terminal/search-commands.png"
alt={t("search.modalAlt")}
width={1600}
height={1200}
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t.rich("search.modalCaption", { code, em })}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
<strong>{t("search.aboutLabel")}</strong>{" "}
<a
href="https://cheat.sh"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1"
>
cheat.sh
<ExternalLink className="w-3 h-3" />
</a>{" "}
{t.rich("search.aboutBody", { code })}
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("search.headerSource")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("search.headerWhen")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("search.headerWhat")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
<tr className="border-b border-gray-100">
<td className="px-3 py-2 align-top whitespace-nowrap">
<a
href="https://cheat.sh"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline inline-flex items-center gap-1 font-semibold"
>
cheat.sh
<ExternalLink className="w-3 h-3" />
</a>{" "}
{t("search.onlineLabel")}
</td>
<td className="px-3 py-2 align-top">{t("search.onlineWhen")}</td>
<td className="px-3 py-2 align-top">{t.rich("search.onlineWhat", { green })}</td>
</tr>
<tr>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("search.fallbackLabel")}</strong></td>
<td className="px-3 py-2 align-top">{t("search.fallbackWhen")}</td>
<td className="px-3 py-2 align-top">{t.rich("search.fallbackWhat", { red })}</td>
</tr>
</tbody>
</table>
</div>
<p className="mb-6 text-gray-800 leading-relaxed text-sm">
{t.rich("search.sendingNote", { strong })}
</p>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("auth.heading")}</h2>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{authItems.map((_, idx) => (
<li key={idx}>{t.rich(`auth.items.${idx}`, { code, link: authLink })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("clipboard.heading")}</h2>
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
{clipboardItems.map((_, idx) => (
<li key={idx}>{t.rich(`clipboard.items.${idx}`, { strong, code })}</li>
))}
</ul>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("disconnect.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("disconnect.intro")}</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("disconnect.headerCause")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("disconnect.headerFix")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{disconnectRows.map((row, idx) => (
<tr key={row.cause} className={idx < disconnectRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top">{row.cause}</td>
<td className="px-3 py-2 align-top">{t.rich(`disconnect.rows.${idx}.fix`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="warning" title={t("warning.title")}>
{t.rich("warning.body", { code, authLink: authLinkWarn, gatewayLink })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.map((item) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{item.tail}
</li>
))}
</ul>
</div>
)
}

View File

@@ -0,0 +1,437 @@
import type { Metadata } from "next"
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { DocHeader } from "@/components/ui/doc-header"
import { Callout } from "@/components/ui/callout"
import CopyableCode from "@/components/CopyableCode"
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.vmsLxcs.meta" })
return { title: t("title"), description: t("description") }
}
type LifecycleRow = { button: string; color: string; enabled: string; action: string }
type DataRow = { section: string; endpoint: string; source: string }
type WhereNextItem = { label: string; href: string; tailRich: string }
export default async function VmsLxcsTabPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.vmsLxcs" })
const messages = (await getMessages({ locale })) as unknown as {
docs: { monitor: { dashboard: { vmsLxcs: {
topRow: { memoryItems: string[] }
inventory: { rows: string[] }
drillIn: {
liveItems: string[]
ioItems: string[]
resourcesItems: string[]
mountTypesItems: string[]
mountStateItems: string[]
backupsItems: string[]
updatesPanelItems: string[]
firewallItems: string[]
lifecycleRows: LifecycleRow[]
}
dataCollected: { rows: DataRow[] }
whereNext: { items: WhereNextItem[] }
} } } }
}
const v = messages.docs.monitor.dashboard.vmsLxcs
const memoryItems = v.topRow.memoryItems
const inventoryRows = v.inventory.rows
const liveItems = v.drillIn.liveItems
const ioItems = v.drillIn.ioItems
const resourcesItems = v.drillIn.resourcesItems
const mountTypesItems = v.drillIn.mountTypesItems
const mountStateItems = v.drillIn.mountStateItems
const backupsItems = v.drillIn.backupsItems
const updatesPanelItems = v.drillIn.updatesPanelItems
const firewallItems = v.drillIn.firewallItems
const lifecycleRows = v.drillIn.lifecycleRows
const dataRows = v.dataCollected.rows
const whereNextItems = v.whereNext.items
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
const green = () => <span className="inline-block w-2 h-2 rounded-full bg-green-500 align-middle mr-1" />
const amber = () => <span className="inline-block w-2 h-2 rounded-full bg-amber-500 align-middle mr-1" />
const red = () => <span className="inline-block w-2 h-2 rounded-full bg-red-500 align-middle mr-1" />
const greenText = (chunks: React.ReactNode) => <span className="text-green-600 font-semibold">{chunks}</span>
const amberText = (chunks: React.ReactNode) => <span className="text-amber-600 font-semibold">{chunks}</span>
const redText = (chunks: React.ReactNode) => <span className="text-red-600 font-semibold">{chunks}</span>
const orangeText = (chunks: React.ReactNode) => <span className="text-orange-600 font-semibold">{chunks}</span>
const link = (chunks: React.ReactNode) => (
<Link href="/docs/monitor/dashboard/terminal" className="text-blue-600 hover:underline">{chunks}</Link>
)
const buttonColorClass = (color: string) => {
switch (color) {
case "green":
return "text-green-600 font-semibold"
case "blue":
return "text-blue-600 font-semibold"
case "red":
return "text-red-600 font-semibold"
default:
return "font-semibold"
}
}
return (
<div>
<DocHeader
title={t("header.title")}
description={t("header.description")}
section={t("header.section")}
estimatedMinutes={12}
/>
<Callout variant="info" title={t("intro.title")}>
{t.rich("intro.body", { code })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("topRow.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">{t("topRow.intro")}</p>
<figure className="my-6">
<img
src="/monitor/vms-top-row.png"
alt={t("topRow.imageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("topRow.imageCaption")}
</figcaption>
</figure>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerCard")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerWhat")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
<tr className="border-b border-gray-100">
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.totalLabel")}</strong></td>
<td className="px-3 py-2 align-top">{t.rich("topRow.totalWhat", { em })}</td>
</tr>
<tr className="border-b border-gray-100">
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.cpuLabel")}</strong></td>
<td className="px-3 py-2 align-top">{t.rich("topRow.cpuWhat", { em })}</td>
</tr>
<tr className="border-b border-gray-100">
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.memoryLabel")}</strong></td>
<td className="px-3 py-2 align-top">
{t("topRow.memoryIntro")}
<ul className="list-disc pl-5 mt-2 space-y-0.5">
{memoryItems.map((_, idx) => (
<li key={idx}>{t.rich(`topRow.memoryItems.${idx}`, { strong, em, code })}</li>
))}
</ul>
</td>
</tr>
<tr>
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.diskLabel")}</strong></td>
<td className="px-3 py-2 align-top">{t.rich("topRow.diskWhat", { em })}</td>
</tr>
</tbody>
</table>
</div>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("inventory.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("inventory.intro", { code })}
</p>
<figure className="my-6">
<img
src="/monitor/vms-inventory-mobile.png"
alt={t("inventory.imageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full max-w-md mx-auto"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("inventory.imageCaption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("inventory.rowsIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{inventoryRows.map((_, idx) => (
<li key={idx}>{t.rich(`inventory.rows.${idx}`, { strong, em })}</li>
))}
</ul>
<p className="mb-6 text-gray-800 leading-relaxed">{t("inventory.clickHint")}</p>
<Callout variant="tip" title={t("inventory.mobileTitle")}>
{t("inventory.mobileBody")}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("drillIn.heading")}</h2>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("drillIn.intro", { strong, em })}
</p>
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.statusTitle")}</h3>
<figure className="my-4">
<img
src="/monitor/vms-modal-status.png"
alt={t("drillIn.statusImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("drillIn.statusImageCaption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.statusIntro")}</p>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.liveTitle")}</h4>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{liveItems.map((_, idx) => (
<li key={idx}>{t.rich(`drillIn.liveItems.${idx}`, { strong, em })}</li>
))}
</ul>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.ioTitle")}</h4>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{ioItems.map((_, idx) => (
<li key={idx}>{t.rich(`drillIn.ioItems.${idx}`, { strong })}</li>
))}
</ul>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.resourcesTitle")}</h4>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("drillIn.resourcesIntro", { code })}
</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{resourcesItems.map((_, idx) => (
<li key={idx}>{t.rich(`drillIn.resourcesItems.${idx}`, { strong, code })}</li>
))}
</ul>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.ipsTitle")}</h4>
<p className="mb-6 text-gray-800 leading-relaxed">{t("drillIn.ipsBody")}</p>
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.mountsTitle")}</h3>
<figure className="my-4">
<img
src="/monitor/vms-modal-mounts.png"
alt={t("drillIn.mountsImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("drillIn.mountsImageCaption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("drillIn.mountsIntro", { strong, code })}
</p>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.mountTypesTitle")}</h4>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{mountTypesItems.map((_, idx) => (
<li key={idx}>{t.rich(`drillIn.mountTypesItems.${idx}`, { strong, em, code })}</li>
))}
</ul>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.mountStateTitle")}</h4>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{mountStateItems.map((_, idx) => (
<li key={idx}>
{t.rich(`drillIn.mountStateItems.${idx}`, { strong, em, code, green, amber, red })}
</li>
))}
</ul>
<Callout variant="info" title={t("drillIn.mountsCalloutTitle")}>
{t("drillIn.mountsCalloutBody")}
</Callout>
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.backupsTitle")}</h3>
<figure className="my-4">
<img
src="/monitor/vms-modal-backups.png"
alt={t("drillIn.backupsImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("drillIn.backupsImageCaption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.backupsIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{backupsItems.map((_, idx) => (
<li key={idx}>{t.rich(`drillIn.backupsItems.${idx}`, { strong })}</li>
))}
</ul>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("drillIn.backupsOutro", { strong })}
</p>
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.updatesTitle")}</h3>
<figure className="my-4">
<img
src="/monitor/vms-modal-lxc-updates.png"
alt={t("drillIn.updatesImageAlt")}
className="rounded-lg border border-gray-200 shadow-sm w-full"
/>
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
{t("drillIn.updatesImageCaption")}
</figcaption>
</figure>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("drillIn.updatesIntro", { strong, code })}
</p>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.updatesPanelTitle")}</h4>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{updatesPanelItems.map((_, idx) => (
<li key={idx}>{t.rich(`drillIn.updatesPanelItems.${idx}`, { strong })}</li>
))}
</ul>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.updatesScopeTitle")}</h4>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("drillIn.updatesScopeBody", { strong, em, code })}
</p>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.updatesToggleTitle")}</h4>
<Callout variant="info" title={t("drillIn.updatesToggleCalloutTitle")}>
{t.rich("drillIn.updatesToggleCalloutBody", { strong, code })}
</Callout>
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.updatesApplyTitle")}</h4>
<p className="mb-6 text-gray-800 leading-relaxed">
{t.rich("drillIn.updatesApplyBody", { code })}
</p>
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.firewallTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.firewallIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
{firewallItems.map((_, idx) => (
<li key={idx}>
{t.rich(`drillIn.firewallItems.${idx}`, {
strong,
em,
code,
green: greenText,
orange: orangeText,
red: redText,
})}
</li>
))}
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("drillIn.firewallRefresh", { em, code })}
</p>
<Callout variant="info" title={t("drillIn.firewallCalloutTitle")}>
{t("drillIn.firewallCalloutBody")}
</Callout>
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.actionBarTitle")}</h3>
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.actionBarIntro")}</p>
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
<li>{t.rich("drillIn.consoleItem", { strong, code, link })}</li>
</ul>
<p className="mb-4 text-gray-800 leading-relaxed">
{t.rich("drillIn.lifecycleIntro", { code })}
</p>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("drillIn.headerButton")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("drillIn.headerEnabled")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("drillIn.headerAction")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{lifecycleRows.map((row, idx) => (
<tr key={row.button} className={idx < lifecycleRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top whitespace-nowrap">
<strong className={buttonColorClass(row.color)}>{row.button}</strong>
</td>
<td className="px-3 py-2 align-top">{row.enabled}</td>
<td className="px-3 py-2 align-top font-mono text-xs">{row.action}</td>
</tr>
))}
</tbody>
</table>
</div>
<Callout variant="warning" title={t("drillIn.forceStopTitle")}>
{t.rich("drillIn.forceStopBody", { strong })}
</Callout>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSection")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
</tr>
</thead>
<tbody className="text-gray-800">
{dataRows.map((row, idx) => (
<tr key={row.section} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
<td className="px-3 py-2 align-top">{row.section}</td>
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
<td className="px-3 py-2 align-top">{t.rich(`dataCollected.rows.${idx}.source`, { code })}</td>
</tr>
))}
</tbody>
</table>
</div>
<CopyableCode
code={`${t("dataCollected.codeComment1")}
pvesh get /cluster/resources --type vm --output-format=json | jq
${t("dataCollected.codeComment2")}
qm config 100 ${t("dataCollected.codeComment3")}
pct config 100 ${t("dataCollected.codeComment4")}`}
className="my-4"
/>
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
{whereNextItems.map((item, idx) => (
<li key={item.href}>
<Link href={item.href} className="text-blue-600 hover:underline">
{item.label}
</Link>
{t.rich(`whereNext.items.${idx}.tailRich`, { code })}
</li>
))}
</ul>
</div>
)
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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