mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-06-14 20:36:59 +00:00
complete i18n migration to /[locale]/ with EN+ES content
Full rewrite of the docs site under app/[locale]/ with next-intl in localePrefix:"always" mode. Every page now exists at both /en/<path> and /es/<path>; the root / shows a meta-refresh + JS redirect to /<defaultLocale>/ so GitHub Pages serves something on the apex URL. Highlights: - 107 doc pages migrated to file-per-page JSON namespaces under messages/en/ and messages/es/. Spanish content is fully translated (no copy-of-English placeholders). - New documentation for the Active Suppressions section in the Settings tab and the per-event Dismiss dropdown in the Health Monitor modal. - New screenshots: dismiss-duration-dropdown.png and an updated health-suppression-settings.png. - Pagefind integrated for client-side search; index is built on every CI deploy (not committed). - RSS feeds: per-locale at /<locale>/rss.xml plus root /rss.xml for backward compat. - Removed the dead app/[locale]/guides/[slug]/ route — every guide now has its own static page and no markdown source remains. - Fixed orphan link /guides/nvidia -> /guides/nvidia-manual in docs/hardware/nvidia-host. - Removed obsolete components (footer2, calendar, drawer). Verified locally with `npm ci && npm run build`: 2804 files in out/, 231 pages indexed by pagefind, root redirect intact, both locale roots and the new Active Suppressions docs render OK.
This commit is contained in:
710
web/app/[locale]/docs/monitor/access-auth/page.tsx
Normal file
710
web/app/[locale]/docs/monitor/access-auth/page.tsx
Normal file
@@ -0,0 +1,710 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.accessAuth.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox 2fa",
|
||||
"proxmox totp",
|
||||
"proxmox dashboard authentication",
|
||||
"proxmox user profile",
|
||||
"proxmox dashboard avatar",
|
||||
"proxmox api tokens",
|
||||
"proxmox reverse proxy",
|
||||
"proxmox nginx",
|
||||
"proxmox caddy",
|
||||
"proxmox traefik",
|
||||
"proxmox fail2ban dashboard",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/monitor/access-auth" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/monitor/access-auth",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Row2 = { button: string; what: string; api: string }
|
||||
type FieldRow = { field: string; required: string; notes: string }
|
||||
type EndpointRow = { endpoint: string; what: string }
|
||||
type CryptoRow = { asset: string; algorithm: string; where: string }
|
||||
type AppRow = { name: string; href: string; platforms: string; notes: string }
|
||||
type WhereNextItem = { label: string; href: string; tail?: string; tailRich?: string }
|
||||
|
||||
export default async function MonitorAccessAuthPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.accessAuth" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { accessAuth: {
|
||||
firstLaunch: {
|
||||
rows: Row2[]
|
||||
fieldRows: FieldRow[]
|
||||
endpointRows: EndpointRow[]
|
||||
}
|
||||
password: {
|
||||
items: string[]
|
||||
publicItems: string[]
|
||||
cryptoRows: CryptoRow[]
|
||||
}
|
||||
twofa: {
|
||||
apps: AppRow[]
|
||||
setupSteps: string[]
|
||||
setupStep4Sub: string[]
|
||||
lostItems: string[]
|
||||
rejectedItems: string[]
|
||||
}
|
||||
apiTokens: { generateSteps: string[]; cheatItems: string[] }
|
||||
https: { items: string[] }
|
||||
fail2ban: { items: string[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } }
|
||||
}
|
||||
const aa = messages.docs.monitor.accessAuth
|
||||
const firstLaunchRows = aa.firstLaunch.rows
|
||||
const fieldRows = aa.firstLaunch.fieldRows
|
||||
const endpointRows = aa.firstLaunch.endpointRows
|
||||
const passwordItems = aa.password.items
|
||||
const publicItems = aa.password.publicItems
|
||||
const cryptoRows = aa.password.cryptoRows
|
||||
const apps = aa.twofa.apps
|
||||
const setupSteps = aa.twofa.setupSteps
|
||||
const setupStep4Sub = aa.twofa.setupStep4Sub
|
||||
const lostItems = aa.twofa.lostItems
|
||||
const rejectedItems = aa.twofa.rejectedItems
|
||||
const generateSteps = aa.apiTokens.generateSteps
|
||||
const cheatItems = aa.apiTokens.cheatItems
|
||||
const httpsItems = aa.https.items
|
||||
const fail2banItems = aa.fail2ban.items
|
||||
const whereNextItems = aa.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const apiLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const intLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const gatewayLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const fail2banLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/security/fail2ban" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const tailscaleAnchor = (chunks: React.ReactNode) => (
|
||||
<a href="https://tailscale.com" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
|
||||
{chunks}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
)
|
||||
const tsKeysAnchor = (chunks: React.ReactNode) => (
|
||||
<a href="https://login.tailscale.com/admin/settings/keys" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
|
||||
{chunks}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={15}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { em, strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("reaching.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("reaching.intro", { code })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`# 1) Direct on the LAN
|
||||
http://<proxmox-ip>:8008
|
||||
|
||||
# 2) Behind a reverse proxy with a dedicated host name (recommended off-LAN)
|
||||
https://monitor.example.com
|
||||
|
||||
# 3) Through Secure Gateway (Tailscale) — same LAN URL, from anywhere
|
||||
http://<proxmox-lan-ip>:8008 # works from any device on your tailnet`}
|
||||
className="my-4"
|
||||
/>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("reaching.outro", { code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("firstLaunch.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("firstLaunch.intro", { code, em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img src="/monitor/auth-setup.png" alt={t("firstLaunch.imageAlt")} className="rounded-lg border border-gray-200 shadow-sm w-full" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("firstLaunch.imageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerButton")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerWhat")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerApi")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{firstLaunchRows.map((row, idx) => (
|
||||
<tr key={row.button} className={idx < firstLaunchRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.button}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`firstLaunch.rows.${idx}.what`, { em, code })}</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.api}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("firstLaunch.twofaCalloutTitle")}>
|
||||
{t.rich("firstLaunch.twofaCalloutBody", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("firstLaunch.createTitle")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("firstLaunch.createIntro", { em })}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerField")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerRequired")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerNotes")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{fieldRows.map((row, idx) => (
|
||||
<tr key={row.field} className={idx < fieldRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.field}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{row.required}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`firstLaunch.fieldRows.${idx}.notes`, { code, strong })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<figure className="my-6">
|
||||
<img src="/monitor/security/create-user-form.png" alt={t("firstLaunch.createImageAlt")} className="rounded-lg border border-gray-200 shadow-sm w-full" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("firstLaunch.createImageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<Callout variant="info" title={t("firstLaunch.saveCalloutTitle")}>
|
||||
{t.rich("firstLaunch.saveCalloutBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("firstLaunch.avatarTitle")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("firstLaunch.avatarBody1", { strong })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("firstLaunch.avatarBody2")}</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img src="/monitor/security/profile-page.png" alt={t("firstLaunch.profileImageAlt")} className="rounded-lg border border-gray-200 shadow-sm w-full" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("firstLaunch.profileImageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("firstLaunch.headerEpWhat")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{endpointRows.map((row, idx) => (
|
||||
<tr key={row.endpoint} className={idx < endpointRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`firstLaunch.endpointRows.${idx}.what`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="warning" title={t("firstLaunch.reversibleTitle")}>
|
||||
{t.rich("firstLaunch.reversibleBody", { em, strong, code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("password.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("password.intro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{passwordItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`password.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<figure className="my-6">
|
||||
<img src="/monitor/login-screen.png" alt={t("password.loginImageAlt")} className="rounded-lg border border-gray-200 shadow-sm w-full" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("password.loginImageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("password.loginFlowTitle")}</h3>
|
||||
<CopyableCode
|
||||
code={`# Without 2FA
|
||||
curl -X POST http://<host>:8008/api/auth/login \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"username":"<user>","password":"<password>"}'
|
||||
|
||||
# Response
|
||||
{
|
||||
"success": true,
|
||||
"token": "eyJhbGciOiJIUzI1NiIs..."
|
||||
}`}
|
||||
className="my-4"
|
||||
/>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("password.twofaIntro", { code })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`curl -X POST http://<host>:8008/api/auth/login \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"username":"<user>","password":"<password>","totp_token":"123456"}'`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("password.publicTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("password.publicIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{publicItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`password.publicItems.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 id="security-model" className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("password.cryptoTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("password.cryptoIntro", { code })}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto mb-4">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("password.headerAsset")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("password.headerAlgo")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("password.headerWhere")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{cryptoRows.map((row, idx) => (
|
||||
<tr key={row.asset} className={idx < cryptoRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top"><strong>{row.asset}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`password.cryptoRows.${idx}.algorithm`, { code })}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`password.cryptoRows.${idx}.where`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("password.authJsonTitle")}>
|
||||
{t.rich("password.authJsonBody", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="warning" title={t("password.rotateTitle")}>
|
||||
{t.rich("password.rotateBody", { code, strong })}
|
||||
</Callout>
|
||||
|
||||
<h3 id="recovering-password" className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("password.recoverTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("password.recoverIntro", { code })}
|
||||
</p>
|
||||
|
||||
<CopyableCode
|
||||
code={`# 1. Run the ProxMenux menu as root
|
||||
menu
|
||||
|
||||
# 2. Settings → Reset ProxMenux Monitor Password
|
||||
# The menu will:
|
||||
# - Back up auth.json to auth.json.bak-<UTC timestamp>
|
||||
# - Stop the proxmenux-monitor service
|
||||
# - Clear username / password_hash / TOTP secret / backup codes
|
||||
# - Keep jwt_secret and api_tokens intact
|
||||
# - Restart the service
|
||||
|
||||
# 3. Open the dashboard at http://<host>:8008
|
||||
# The setup wizard appears — create a new admin account.`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("password.survivesTitle")}>
|
||||
{t.rich("password.survivesBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="warning" title={t("password.physicalTitle")}>
|
||||
{t.rich("password.physicalBody", { strong, code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("twofa.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("twofa.intro", { strong })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("twofa.pickTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("twofa.pickIntro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("twofa.headerApp")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("twofa.headerPlatforms")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("twofa.headerAppNotes")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{apps.map((row, idx) => (
|
||||
<tr key={row.name} className={idx < apps.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">
|
||||
<a
|
||||
href={row.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{row.name}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">{row.platforms}</td>
|
||||
<td className="px-3 py-2 align-top">{row.notes}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("twofa.backupTitle")}>
|
||||
{t("twofa.backupBody")}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("twofa.setupTitle")}</h3>
|
||||
|
||||
<figure className="my-6">
|
||||
<img src="/monitor/2fa-setup.png" alt={t("twofa.setupImageAlt")} className="rounded-lg border border-gray-200 shadow-sm w-full" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("twofa.setupImageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-3">
|
||||
{setupSteps.map((_, idx) => (
|
||||
<li key={idx}>
|
||||
{t.rich(`twofa.setupSteps.${idx}`, { strong, em, code })}
|
||||
{idx === 3 && (
|
||||
<ul className="list-disc pl-6 mt-2 space-y-1">
|
||||
{setupStep4Sub.map((_, sIdx) => (
|
||||
<li key={sIdx}>{t.rich(`twofa.setupStep4Sub.${sIdx}`, { em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<Callout variant="warning" title={t("twofa.testTitle")}>
|
||||
{t.rich("twofa.testBody", { em, code })}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("twofa.lostTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("twofa.lostIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{lostItems.map((_, idx) => (
|
||||
<li key={idx}>
|
||||
{t.rich(`twofa.lostItems.${idx}`, { strong, code })}
|
||||
{idx === 2 && (
|
||||
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{`systemctl restart proxmenux-monitor.service`}</pre>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("twofa.lostShellOutro")}</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("twofa.disableTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("twofa.disableBody", { strong, code })}
|
||||
</p>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("twofa.rejectedTitle")}>
|
||||
{t("twofa.rejectedIntro")}
|
||||
<ul className="list-disc pl-5 mt-2 space-y-1">
|
||||
{rejectedItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`twofa.rejectedItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
{t("twofa.rejectedOutro")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("apiTokens.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("apiTokens.intro", { strong, code })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img src="/monitor/api-tokens.png" alt={t("apiTokens.imageAlt")} className="rounded-lg border border-gray-200 shadow-sm w-full" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("apiTokens.imageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("apiTokens.generateTitle")}</h3>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("apiTokens.generateIntro")}</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{generateSteps.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`apiTokens.generateSteps.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("apiTokens.generateCli")}</p>
|
||||
<CopyableCode
|
||||
code={`curl -X POST http://<host>:8008/api/auth/generate-api-token \\
|
||||
-H "Authorization: Bearer <session-token>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"password": "<your-password>",
|
||||
"totp_token": "123456",
|
||||
"token_name": "Home Assistant"
|
||||
}'
|
||||
|
||||
# Response — the "token" field is the only place the token appears.
|
||||
{
|
||||
"success": true,
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"token_name": "Home Assistant",
|
||||
"expires_in": "365 days"
|
||||
}`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("apiTokens.useTitle")}</h3>
|
||||
<CopyableCode
|
||||
code={`curl -H "Authorization: Bearer <api-token>" \\
|
||||
http://<host>:8008/api/system`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("apiTokens.revokeTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("apiTokens.revokeBody", { strong, code })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`# Same operation via API
|
||||
curl -X DELETE http://<host>:8008/api/auth/api-tokens/<token-id> \\
|
||||
-H "Authorization: Bearer <session-token>"`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="tip" title={t("apiTokens.cheatTitle")}>
|
||||
<ul className="list-disc pl-5 mt-2 space-y-1">
|
||||
{cheatItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`apiTokens.cheatItems.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Callout>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("apiTokens.outro", { apiLink, intLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("https.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("https.intro")}</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{httpsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`https.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
<Callout variant="warning" title={t("https.calloutTitle")}>
|
||||
{t("https.calloutBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("gateway.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("gateway.intro", { strong, a: tailscaleAnchor })}
|
||||
</p>
|
||||
<Callout variant="tip" title={t("gateway.calloutTitle")}>
|
||||
{t.rich("gateway.calloutBody", { code })}
|
||||
</Callout>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("gateway.deployBody", { a: tsKeysAnchor })}
|
||||
</p>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("gateway.outro", { link: gatewayLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("proxy.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("proxy.intro", { strong, code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("proxy.nginxTitle")}</h3>
|
||||
<CopyableCode
|
||||
code={`# /etc/nginx/sites-available/proxmenux-monitor.conf
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name monitor.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/monitor.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/monitor.example.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8008;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# WebSocket upgrade (terminal tab)
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Real client IP — required for the auth log + Fail2Ban hook
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
# Long-running terminal sessions
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
}`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("proxy.caddyTitle")}</h3>
|
||||
<CopyableCode
|
||||
code={`# Caddyfile
|
||||
monitor.example.com {
|
||||
reverse_proxy 127.0.0.1:8008 {
|
||||
# Caddy auto-handles WebSocket upgrades and forwards X-Forwarded-* by default.
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote}
|
||||
}
|
||||
}`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("proxy.traefikTitle")}</h3>
|
||||
<CopyableCode
|
||||
code={`# docker-compose snippet, or equivalent IngressRoute on Kubernetes
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.proxmenux.rule=Host(\`monitor.example.com\`)"
|
||||
- "traefik.http.routers.proxmenux.tls=true"
|
||||
- "traefik.http.routers.proxmenux.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.proxmenux.loadbalancer.server.port=8008"
|
||||
# WebSocket and forwarded headers are on by default in Traefik.`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="tip" title={t("proxy.subPathTitle")}>
|
||||
{t.rich("proxy.subPathBody", { code, strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("audit.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("audit.intro", { code })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`# Failed login from 192.0.2.10 (real IP recovered from X-Forwarded-For)
|
||||
2026-04-24 14:32:11 WARNING proxmenux.auth: authentication failure; rhost=192.0.2.10 user=admin
|
||||
|
||||
# Successful login
|
||||
2026-04-24 14:32:18 INFO proxmenux.auth: authentication success; rhost=192.0.2.10 user=admin`}
|
||||
className="my-4"
|
||||
/>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("audit.outro", { code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("fail2ban.heading")}</h2>
|
||||
<Callout variant="info" title={t("fail2ban.calloutTitle")}>
|
||||
{t.rich("fail2ban.calloutBody", { strong, link: fail2banLink })}
|
||||
</Callout>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("fail2ban.intro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{fail2banItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`fail2ban.items.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("fail2ban.outro", { link: fail2banLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("troubleshoot.heading")}</h2>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.noScreenTitle")}>
|
||||
{t.rich("troubleshoot.noScreenBody", { code })}
|
||||
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{`rm /root/.config/proxmenux-monitor/auth.json
|
||||
systemctl restart proxmenux-monitor.service`}</pre>
|
||||
{t.rich("troubleshoot.noScreenOutro", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.tokenTitle")}>
|
||||
{t.rich("troubleshoot.tokenBody", { code })}
|
||||
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{`curl -H "Authorization: Bearer <token>" \\
|
||||
http://<host>:8008/api/system | jq .`}</pre>
|
||||
{t.rich("troubleshoot.tokenOutro", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.no2faTitle")}>
|
||||
{t.rich("troubleshoot.no2faBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="troubleshoot" title={t("troubleshoot.wsTitle")}>
|
||||
{t.rich("troubleshoot.wsBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item, idx) => (
|
||||
<li key={item.href + idx}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
726
web/app/[locale]/docs/monitor/ai-assistant/page.tsx
Normal file
726
web/app/[locale]/docs/monitor/ai-assistant/page.tsx
Normal file
@@ -0,0 +1,726 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.aiAssistant.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox ai",
|
||||
"proxmox openai integration",
|
||||
"proxmox claude",
|
||||
"proxmox gemini",
|
||||
"proxmox ollama",
|
||||
"proxmox local ai",
|
||||
"proxmox groq",
|
||||
"proxmox openrouter",
|
||||
"proxmox notification rewrite",
|
||||
"proxmox llm",
|
||||
"proxmenux ai assistant",
|
||||
"proxmox ai prompt",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/monitor/ai-assistant" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/monitor/ai-assistant",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SYSTEM_PROMPT = `You are a notification FORMATTER for ProxMenux Monitor (Proxmox VE).
|
||||
Your job: translate alerts into {language} and enrich them with context when provided.
|
||||
|
||||
═══ ABSOLUTE CONSTRAINTS (NO EXCEPTIONS) ═══
|
||||
- NO HALLUCINATIONS: Do not invent causes, solutions, or facts not present in the provided data
|
||||
- NO SPECULATION: If something is unclear, state what IS known, not what MIGHT be
|
||||
- NO CONVERSATIONAL TEXT: Never write "Here is...", "I've translated...", "Let me explain..."
|
||||
- ONLY use information from: the message, journal context, and known error database (if provided)
|
||||
|
||||
═══ WHAT TO TRANSLATE ═══
|
||||
Translate: labels, descriptions, status words, units (GB→Go in French, etc.)
|
||||
DO NOT translate: hostnames, IPs, paths, VM/CT IDs, device names (/dev/sdX), technical identifiers
|
||||
|
||||
═══ CORE RULES ═══
|
||||
1. Plain text only — NO markdown, no **bold**, no \`code\`, no bullet lists (use "• " for packages only)
|
||||
2. Preserve severity: "failed" stays "failed", "warning" stays "warning" — never soften errors
|
||||
3. Preserve structure: keep same fields and line order, only translate content
|
||||
4. Detail level "{detail_level}" - controls AMOUNT OF EVENT INFO (not tips/suggestions):
|
||||
- brief: 1-2 lines max. Only: what happened + where
|
||||
- standard: 3-6 lines. Include: what, where, cause, affected devices
|
||||
- detailed: Full report with ALL info: what, where, cause, affected, logs, SMART data, history
|
||||
5. DEDUPLICATION: merge duplicate facts from multiple sources into one clear statement
|
||||
6. EMPTY LISTS: write translated "none" after label, never leave blank
|
||||
7. Keep "hostname:" prefix in title — translate only the descriptive part
|
||||
8. DO NOT add recommendations or suggestions UNLESS AI Suggestions mode is enabled below
|
||||
9. ENRICHED CONTEXT: You may receive additional context data including:
|
||||
- "System uptime: X days (stable system)" → helps distinguish startup issues from runtime failures
|
||||
- "Event frequency: N occurrences, first seen X ago" → indicates recurring vs one-time issues
|
||||
- "SMART Health: PASSED/FAILED" with disk attributes → critical for disk errors
|
||||
- "KNOWN PROXMOX ERROR DETECTED" with cause/solution → YOU MUST USE this exact information
|
||||
|
||||
How to use enriched context:
|
||||
- If uptime is <10min and error is service-related → mention "occurred shortly after boot"
|
||||
- If frequency shows recurring pattern → mention "recurring issue (N times in X hours)"
|
||||
- If SMART shows FAILED → treat as CRITICAL: "Disk failing - immediate attention required"
|
||||
- If KNOWN ERROR is provided → YOU MUST incorporate its Cause and Solution (translate, don't copy verbatim)
|
||||
|
||||
10. JOURNAL CONTEXT EXTRACTION: When journal logs are provided:
|
||||
- Extract specific IDs (VM/CT numbers, disk devices, service names)
|
||||
- Include relevant timestamps if they help explain the timeline
|
||||
- Identify root cause when logs clearly show it (e.g., "exit-code 255" -> "process crashed")
|
||||
- Translate technical terms: "Emask 0x10" -> "ATA bus error", "DRDY ERR" -> "drive not ready"
|
||||
- If logs show the same error repeating, state frequency: "occurred 15 times in 10 minutes"
|
||||
- IGNORE journal entries unrelated to the main event
|
||||
11. OUTPUT ONLY the final result — no "Original:", no before/after comparisons
|
||||
12. Unknown input: preserve as closely as possible, translate what you can
|
||||
13. REDUNDANCY: Never repeat the same information twice. If title says "CT 103 failed", body should not start with "Container 103 failed"
|
||||
{suggestions_addon}
|
||||
═══ PROXMOX MAPPINGS (use directly, never explain) ═══
|
||||
pve-container@XXXX → "CT XXXX" | qemu-server@XXXX → "VM XXXX" | vzdump → "backup"
|
||||
pveproxy/pvedaemon/pvestatd → "Proxmox service" | corosync → "cluster service"
|
||||
"ata8.00: exception Emask..." → "ATA error on port 8"
|
||||
"blk_update_request: I/O error, dev sdX" → "I/O error on /dev/sdX"
|
||||
{emoji_instructions}
|
||||
═══ MESSAGE FORMATS ═══
|
||||
|
||||
BACKUP: List each VM/CT with status/size/duration/storage. End with summary.
|
||||
- Partial failure (some OK, some failed) = "Backup partially failed", not "failed"
|
||||
- NEVER collapse multi-VM backup into one line — show each VM separately
|
||||
- ALWAYS include storage path and summary line
|
||||
|
||||
UPDATES: Counts on own lines. Packages use "• " under header. No redundant summary.
|
||||
|
||||
DISK/SMART: Device + specific error. Deduplicate repeated info.
|
||||
|
||||
HEALTH: Category + severity + what changed. Duration if resolved.
|
||||
|
||||
VM/CT LIFECYCLE: Confirm event with key facts (1-2 lines).
|
||||
|
||||
═══ OUTPUT FORMAT (CRITICAL - MUST FOLLOW EXACTLY) ═══
|
||||
|
||||
Your response MUST have EXACTLY this structure:
|
||||
[TITLE]
|
||||
your translated title text
|
||||
[BODY]
|
||||
your translated body text
|
||||
|
||||
ABSOLUTE RULES (violations break the parser):
|
||||
1. [TITLE] and [BODY] are INVISIBLE PARSING MARKERS — they separate title from body
|
||||
2. Your actual title/body content must NEVER contain the words "[TITLE]" or "[BODY]"
|
||||
3. Your actual title/body content must NEVER contain "Title:" or "Body:" prefixes
|
||||
4. Line 1: write exactly [TITLE]
|
||||
5. Line 2: write your title text (emoji + hostname: description)
|
||||
6. Line 3: write exactly [BODY]
|
||||
7. Line 4+: write your body text
|
||||
|
||||
- Output ONLY the formatted result — no explanations, no "Original:", no commentary`
|
||||
|
||||
const SUGGESTIONS_ADDON = `═══ AI SUGGESTIONS MODE (ENABLED) ═══
|
||||
You MAY add ONE brief, actionable tip at the END of the body using this exact format:
|
||||
|
||||
💡 Tip: [your concise suggestion here]
|
||||
|
||||
Rules for the tip:
|
||||
- ONLY include if the log context or Known Error database clearly points to a specific fix
|
||||
- Keep under 100 characters
|
||||
- Be specific: "Run 'pvecm status' to check quorum" NOT "Check cluster status"
|
||||
- If Known Error provides a solution, YOU MUST USE IT (don't invent your own)
|
||||
- Never guess — skip the tip if the cause/solution is unclear`
|
||||
|
||||
const EXAMPLE_CUSTOM_PROMPT = `You are a notification formatter for ProxMenux Monitor.
|
||||
|
||||
Your task is to translate and format server notifications.
|
||||
|
||||
RULES:
|
||||
1. Translate to the user's preferred language
|
||||
2. Use plain text only (no markdown, no bold, no italic)
|
||||
3. Be concise and factual
|
||||
4. Do not add recommendations or suggestions
|
||||
5. Present only the facts from the input
|
||||
6. Keep hostname prefix in titles (e.g., "pve01: ")
|
||||
|
||||
OUTPUT FORMAT:
|
||||
[TITLE]
|
||||
your translated title here
|
||||
[BODY]
|
||||
your translated message here
|
||||
|
||||
Detail levels:
|
||||
- brief: 2-3 lines, essential only
|
||||
- standard: short paragraph with key details
|
||||
- detailed: full technical breakdown`
|
||||
|
||||
type ContextRow = { block: string; when: string; what: string }
|
||||
type CapRow = { level: string; cap: string; consumption: string }
|
||||
type DetailRow = { level: string; label: string; cap: string; produce: string }
|
||||
type PrivacyRow = { provider: string; destination: string }
|
||||
type WhereNextItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function AIAssistantPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.aiAssistant" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { aiAssistant: {
|
||||
howItWorks: { steps: string[]; notes: string[] }
|
||||
context: { rows: ContextRow[] }
|
||||
tokens: { items: string[]; capRows: CapRow[] }
|
||||
providers: {
|
||||
groq: { items: string[] }
|
||||
openai: { items: string[] }
|
||||
anthropic: { items: string[] }
|
||||
gemini: { items: string[] }
|
||||
openrouter: { items: string[] }
|
||||
ollama: { items: string[] }
|
||||
}
|
||||
models: { consequences: string[] }
|
||||
defaultPrompt: { passages: string[] }
|
||||
customPrompt: { changes: string[] }
|
||||
suggestions: { rules: string[] }
|
||||
detailLevel: { rows: DetailRow[]; defaults: string[] }
|
||||
language: { rules: string[] }
|
||||
privacy: { rows: PrivacyRow[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } }
|
||||
}
|
||||
const ai = messages.docs.monitor.aiAssistant
|
||||
const howSteps = ai.howItWorks.steps
|
||||
const howNotes = ai.howItWorks.notes
|
||||
const contextRows = ai.context.rows
|
||||
const tokensItems = ai.tokens.items
|
||||
const tokensCapRows = ai.tokens.capRows
|
||||
const groqItems = ai.providers.groq.items
|
||||
const openaiItems = ai.providers.openai.items
|
||||
const anthropicItems = ai.providers.anthropic.items
|
||||
const geminiItems = ai.providers.gemini.items
|
||||
const openrouterItems = ai.providers.openrouter.items
|
||||
const ollamaItems = ai.providers.ollama.items
|
||||
const modelsConsequences = ai.models.consequences
|
||||
const defaultPassages = ai.defaultPrompt.passages
|
||||
const customChanges = ai.customPrompt.changes
|
||||
const suggestionsRules = ai.suggestions.rules
|
||||
const detailLevelRows = ai.detailLevel.rows
|
||||
const detailLevelDefaults = ai.detailLevel.defaults
|
||||
const languageRules = ai.language.rules
|
||||
const privacyRows = ai.privacy.rows
|
||||
const whereNextItems = ai.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const detailLink = (chunks: React.ReactNode) => (
|
||||
<Link href="#detail-level-per-channel" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const notifLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/notifications" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
const providerLink = (href: string) => (chunks: React.ReactNode) =>
|
||||
(
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={25}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howItWorks.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("howItWorks.intro")}</p>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
|
||||
{howSteps.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`howItWorks.steps.${idx}`, { strong, code, em })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("howItWorks.notesIntro")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{howNotes.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`howItWorks.notes.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("enabling.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("enabling.intro", { em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/ai-enhancement-collapsed.png" alt={t("enabling.collapsedAlt")} width={2000} height={244} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("enabling.collapsedCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/ai-enhancement-panel.png" alt={t("enabling.panelAlt")} width={2000} height={1854} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("enabling.panelCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("enabling.outro", { em })}
|
||||
</p>
|
||||
|
||||
<h2 id="what-context-the-ai-receives" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
|
||||
{t("context.heading")}
|
||||
</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("context.intro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("context.headerBlock")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("context.headerWhen")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("context.headerWhat")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{contextRows.map((row, idx) => (
|
||||
<tr key={row.block} className={idx < contextRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.block}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`context.rows.${idx}.when`, { code })}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`context.rows.${idx}.what`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("context.afterBlocks")}</p>
|
||||
|
||||
<CopyableCode
|
||||
code={`Severity: WARNING
|
||||
Title: pve01: Disk I/O error
|
||||
Message:
|
||||
I/O error on /dev/sda — 1 sector pending reallocation.
|
||||
|
||||
Journal log context:
|
||||
Event frequency: 5 occurrences, first seen 2h ago, recurring
|
||||
|
||||
SMART Health: PASSED
|
||||
SMART attribute Reallocated_Sector_Ct: 1 (raw 1)
|
||||
SMART attribute Current_Pending_Sector: 1 (raw 1)
|
||||
|
||||
Journal logs:
|
||||
ata8.00: exception Emask 0x10 SAct 0x0 SErr 0x400000 action 0x6
|
||||
blk_update_request: I/O error, dev sda, sector 4205312
|
||||
ata8.00: error: { ICRC ABRT }`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("context.calloutTitle")}>
|
||||
{t("context.calloutBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("tokens.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("tokens.intro1", { em })}</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("tokens.intro2")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{tokensItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`tokens.items.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("tokens.capsIntro", { code })}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("tokens.headerLevel")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("tokens.headerCap")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("tokens.headerConsumption")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{tokensCapRows.map((row, idx) => (
|
||||
<tr key={row.level} className={idx < tokensCapRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><code>{row.level}</code></td>
|
||||
<td className="px-3 py-2 align-top">{row.cap}</td>
|
||||
<td className="px-3 py-2 align-top">{row.consumption}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("tokens.customNote")}</p>
|
||||
|
||||
<Callout variant="tip" title={t("tokens.sizingTitle")}>
|
||||
{t.rich("tokens.sizingBody", { code, link: detailLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("providers.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("providers.intro")}</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/ai-providers-information.png" alt={t("providers.imageAlt")} width={1602} height={2138} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto mx-auto max-w-2xl" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("providers.imageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 id="provider-groq" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("providers.groq.heading")}</h3>
|
||||
<p className="mb-2 text-gray-800 leading-relaxed"><em>{t("providers.groq.tagline")}</em></p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{groqItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`providers.groq.items.${idx}`, { code, strong, a: providerLink("https://console.groq.com/keys") })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 id="provider-openai" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("providers.openai.heading")}</h3>
|
||||
<p className="mb-2 text-gray-800 leading-relaxed"><em>{t("providers.openai.tagline")}</em></p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{openaiItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`providers.openai.items.${idx}`, { code, strong, a: providerLink("https://platform.openai.com/api-keys") })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="tip" title={t("providers.openai.baseUrlTitle")}>
|
||||
{t.rich("providers.openai.baseUrlBody", { em, strong, code })}
|
||||
</Callout>
|
||||
|
||||
<h3 id="provider-anthropic" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("providers.anthropic.heading")}</h3>
|
||||
<p className="mb-2 text-gray-800 leading-relaxed"><em>{t("providers.anthropic.tagline")}</em></p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{anthropicItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`providers.anthropic.items.${idx}`, { code, strong, a: providerLink("https://console.anthropic.com/settings/keys") })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 id="provider-gemini" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("providers.gemini.heading")}</h3>
|
||||
<p className="mb-2 text-gray-800 leading-relaxed"><em>{t("providers.gemini.tagline")}</em></p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{geminiItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`providers.gemini.items.${idx}`, { code, strong, a: providerLink("https://aistudio.google.com/app/apikey") })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 id="provider-openrouter" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("providers.openrouter.heading")}</h3>
|
||||
<p className="mb-2 text-gray-800 leading-relaxed"><em>{t("providers.openrouter.tagline")}</em></p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{openrouterItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`providers.openrouter.items.${idx}`, { code, strong, a: providerLink("https://openrouter.ai/keys") })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 id="provider-ollama" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("providers.ollama.heading")}</h3>
|
||||
<p className="mb-2 text-gray-800 leading-relaxed"><em>{t("providers.ollama.tagline")}</em></p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{ollamaItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`providers.ollama.items.${idx}`, { code, strong, em, a: providerLink("https://ollama.com/download") })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("models.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("models.intro", { code })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("models.consequencesIntro")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{modelsConsequences.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`models.consequences.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="info" title={t("models.ollamaTitle")}>
|
||||
{t("models.ollamaBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("defaultPrompt.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("defaultPrompt.intro", { em, code })}
|
||||
</p>
|
||||
|
||||
<details className="mb-4 rounded-md border border-gray-200 bg-gray-50">
|
||||
<summary className="cursor-pointer px-4 py-3 font-medium text-gray-900 hover:bg-gray-100 rounded-md">
|
||||
{t("defaultPrompt.showFullSummary")}
|
||||
</summary>
|
||||
<div className="px-4 pb-4">
|
||||
<pre className="text-xs font-mono text-gray-800 whitespace-pre-wrap leading-relaxed bg-white border border-gray-200 rounded p-3 overflow-x-auto">
|
||||
{DEFAULT_SYSTEM_PROMPT}
|
||||
</pre>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("defaultPrompt.passagesIntro", { em })}
|
||||
</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{defaultPassages.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`defaultPrompt.passages.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("defaultPrompt.suggestionsPlaceholder", { code })}
|
||||
</p>
|
||||
|
||||
<details className="mb-4 rounded-md border border-gray-200 bg-gray-50">
|
||||
<summary className="cursor-pointer px-4 py-3 font-medium text-gray-900 hover:bg-gray-100 rounded-md">
|
||||
{t("defaultPrompt.showAddonSummary")}
|
||||
</summary>
|
||||
<div className="px-4 pb-4">
|
||||
<pre className="text-xs font-mono text-gray-800 whitespace-pre-wrap leading-relaxed bg-white border border-gray-200 rounded p-3 overflow-x-auto">
|
||||
{SUGGESTIONS_ADDON}
|
||||
</pre>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<h2 id="custom-prompt-mode" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("customPrompt.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("customPrompt.intro", { em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/ai-custom-prompt.png" alt={t("customPrompt.imageAlt")} width={2000} height={987} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("customPrompt.imageCaption", { em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("customPrompt.changesTitle")}</h3>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
|
||||
{customChanges.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`customPrompt.changes.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("customPrompt.starterTitle")}</h3>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("customPrompt.starterIntro", { em })}
|
||||
</p>
|
||||
|
||||
<details className="mb-4 rounded-md border border-gray-200 bg-gray-50">
|
||||
<summary className="cursor-pointer px-4 py-3 font-medium text-gray-900 hover:bg-gray-100 rounded-md">
|
||||
{t("customPrompt.showStarterSummary")}
|
||||
</summary>
|
||||
<div className="px-4 pb-4">
|
||||
<pre className="text-xs font-mono text-gray-800 whitespace-pre-wrap leading-relaxed bg-white border border-gray-200 rounded p-3 overflow-x-auto">
|
||||
{EXAMPLE_CUSTOM_PROMPT}
|
||||
</pre>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("customPrompt.shareTitle")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("customPrompt.shareIntro", { em, code })}
|
||||
</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux/discussions/categories/share-custom-prompts-for-ai-notifications"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("customPrompt.shareLinkLabel")}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("customPrompt.shareOutro")}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("suggestions.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("suggestions.intro", { strong, em })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("suggestions.formatIntro")}</p>
|
||||
|
||||
<CopyableCode
|
||||
code={`💡 Tip: Run 'pvecm status' to check quorum`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("suggestions.rulesIntro")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{suggestionsRules.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`suggestions.rules.${idx}`, { em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="warning" title={t("suggestions.betaTitle")}>
|
||||
{t("suggestions.betaBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 id="detail-level-per-channel" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
|
||||
{t("detailLevel.heading")}
|
||||
</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("detailLevel.intro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("detailLevel.headerLevel")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("detailLevel.headerLabel")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("detailLevel.headerCap")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("detailLevel.headerProduce")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{detailLevelRows.map((row, idx) => (
|
||||
<tr key={row.level} className={idx < detailLevelRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><code>{row.level}</code></td>
|
||||
<td className="px-3 py-2 align-top">{row.label}</td>
|
||||
<td className="px-3 py-2 align-top">{row.cap}</td>
|
||||
<td className="px-3 py-2 align-top">{row.produce}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("detailLevel.defaultsIntro")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{detailLevelDefaults.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`detailLevel.defaults.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="info" title={t("detailLevel.emailTitle")}>
|
||||
{t.rich("detailLevel.emailBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 id="language" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("language.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("language.intro", { code, em })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("language.list", { code })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("language.rulesIntro")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{languageRules.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`language.rules.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("language.customNote", { strong })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("templates.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("templates.body1", { code })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("templates.body2", { link: notifLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("privacy.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("privacy.intro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("privacy.headerProvider")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("privacy.headerDestination")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{privacyRows.map((row, idx) => (
|
||||
<tr key={row.provider} className={idx < privacyRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.provider}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`privacy.rows.${idx}.destination`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="warning" title={t("privacy.calloutTitle")}>
|
||||
{t("privacy.calloutBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux/discussions/categories/share-custom-prompts-for-ai-notifications"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("whereNext.communityLabel")}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
{t("whereNext.communityTail")}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
299
web/app/[locale]/docs/monitor/api/page.tsx
Normal file
299
web/app/[locale]/docs/monitor/api/page.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.apiReference.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox api",
|
||||
"proxmox rest api",
|
||||
"proxmox monitor api",
|
||||
"proxmox integration",
|
||||
"proxmox home assistant",
|
||||
"proxmox homepage",
|
||||
"proxmox grafana",
|
||||
"proxmox prometheus endpoint",
|
||||
"proxmox n8n",
|
||||
"proxmox bearer token",
|
||||
"proxmox curl example",
|
||||
"proxmenux api",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/monitor/api" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/monitor/api",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type EndpointRow = { endpoint: string; method: string; use: string }
|
||||
type WhereNextItem = { label: string; href: string; tail?: string; tailRich?: string }
|
||||
type MetricRow = { metric: string; desc: string }
|
||||
type MetricGroup = { group: string; metrics: MetricRow[] }
|
||||
|
||||
export default async function MonitorApiPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.apiReference" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { apiReference: {
|
||||
auth: { rows: EndpointRow[]; items: string[] }
|
||||
conventions: { items: string[] }
|
||||
system: { rows: EndpointRow[] }
|
||||
health: { rows: EndpointRow[] }
|
||||
storage: { rows: EndpointRow[] }
|
||||
network: { rows: EndpointRow[] }
|
||||
vms: { rows: EndpointRow[] }
|
||||
backups: { rows: EndpointRow[] }
|
||||
logs: { rows: EndpointRow[] }
|
||||
notifications: { rows: EndpointRow[] }
|
||||
security: { rows: EndpointRow[] }
|
||||
proxmenuxIntegration: { rows: EndpointRow[] }
|
||||
prometheus: { groups: MetricGroup[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } }
|
||||
}
|
||||
const api = messages.docs.monitor.apiReference
|
||||
const authRows = api.auth.rows
|
||||
const authItems = api.auth.items
|
||||
const conventionsItems = api.conventions.items
|
||||
const systemRows = api.system.rows
|
||||
const healthRows = api.health.rows
|
||||
const storageRows = api.storage.rows
|
||||
const networkRows = api.network.rows
|
||||
const vmsRows = api.vms.rows
|
||||
const backupsRows = api.backups.rows
|
||||
const logsRows = api.logs.rows
|
||||
const notifRows = api.notifications.rows
|
||||
const securityRows = api.security.rows
|
||||
const proxmenuxRows = api.proxmenuxIntegration.rows
|
||||
const metricGroups = api.prometheus.groups
|
||||
const whereNextItems = api.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const accessLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/access-auth" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const healthLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/health-monitor" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const notifLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/notifications" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const aiLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/ai-assistant" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const integrationsLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/integrations" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
const endpointTable = (rows: EndpointRow[], pathPrefix: string) => (
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("headerMethod")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("headerUse")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{rows.map((row, idx) => (
|
||||
<tr key={`${row.endpoint}-${row.method}-${idx}`} className={idx < rows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
|
||||
<td className="px-3 py-2 align-top">{row.method}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`${pathPrefix}.${idx}.use`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={22}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { strong, link: accessLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 id="authentication" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("auth.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("auth.intro")}</p>
|
||||
|
||||
<CopyableCode
|
||||
code={`curl -H "Authorization: Bearer <token>" http://<host>:8008/api/system | jq`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("auth.tokensIntro")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1 mb-4">
|
||||
{authItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`auth.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("auth.flowLink", { link: accessLink })}
|
||||
</p>
|
||||
|
||||
{endpointTable(authRows, "auth.rows")}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("conventions.heading")}</h2>
|
||||
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1 mb-6">
|
||||
{conventionsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`conventions.items.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("system.heading")}</h2>
|
||||
{endpointTable(systemRows, "system.rows")}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("health.heading")}</h2>
|
||||
{endpointTable(healthRows, "health.rows")}
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("health.outro", { link: healthLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("storage.heading")}</h2>
|
||||
{endpointTable(storageRows, "storage.rows")}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("network.heading")}</h2>
|
||||
{endpointTable(networkRows, "network.rows")}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("vms.heading")}</h2>
|
||||
{endpointTable(vmsRows, "vms.rows")}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("backups.heading")}</h2>
|
||||
{endpointTable(backupsRows, "backups.rows")}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("logs.heading")}</h2>
|
||||
{endpointTable(logsRows, "logs.rows")}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("notifications.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("notifications.intro", { notifLink, aiLink })}
|
||||
</p>
|
||||
{endpointTable(notifRows, "notifications.rows")}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("security.heading")}</h2>
|
||||
{endpointTable(securityRows, "security.rows")}
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("proxmenuxIntegration.heading")}</h2>
|
||||
{endpointTable(proxmenuxRows, "proxmenuxIntegration.rows")}
|
||||
|
||||
<h2 id="prometheus" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("prometheus.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("prometheus.intro", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("prometheus.exportedTitle")}</h3>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("prometheus.headerGroup")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("prometheus.headerMetric")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("prometheus.headerDesc")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{metricGroups.flatMap((group, gIdx) =>
|
||||
group.metrics.map((m, mIdx) => (
|
||||
<tr key={`${group.group}-${m.metric}`} className="border-b border-gray-100">
|
||||
{mIdx === 0 && (
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap" rowSpan={group.metrics.length}>
|
||||
<strong>{group.group}</strong>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{m.metric}</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t.rich(`prometheus.groups.${gIdx}.metrics.${mIdx}.desc`, { code })}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("prometheus.scrapeTitle")}</h3>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("prometheus.scrapeIntro")}</p>
|
||||
|
||||
<CopyableCode
|
||||
code={`# /etc/prometheus/prometheus.yml
|
||||
scrape_configs:
|
||||
- job_name: 'proxmenux'
|
||||
metrics_path: /api/prometheus
|
||||
scheme: https # or http if TLS isn't enabled in ProxMenux
|
||||
scrape_interval: 30s
|
||||
authorization:
|
||||
type: Bearer
|
||||
credentials: '<your-api-token>'
|
||||
static_configs:
|
||||
- targets:
|
||||
- 'pve01.lan:8008'
|
||||
- 'pve02.lan:8008'
|
||||
- 'pve03.lan:8008'`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<Callout variant="tip" title={t("prometheus.perHostTitle")}>
|
||||
{t.rich("prometheus.perHostBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("puttingItTogether.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("puttingItTogether.body", { link: integrationsLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
396
web/app/[locale]/docs/monitor/architecture/page.tsx
Normal file
396
web/app/[locale]/docs/monitor/architecture/page.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { DataFlowDiagram } from "@/components/ui/data-flow-diagram"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.architecture.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmenux architecture",
|
||||
"proxmox monitor flask",
|
||||
"proxmox dashboard sqlite",
|
||||
"proxmox appimage dashboard",
|
||||
"proxmox websocket terminal",
|
||||
"proxmox monitor blueprints",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/monitor/architecture" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/monitor/architecture",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type ThreadRow = { thread: string; cadence: string; job: string }
|
||||
type BlueprintRow = { blueprint: string; prefix: string[]; owns: string }
|
||||
type DataRow = { source: string; usedFor: string }
|
||||
type PersistenceRow = { path: string; owner: string; contents: string }
|
||||
type WhereNextItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function MonitorArchitecturePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.architecture" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { architecture: {
|
||||
requestFlow: { rows: ThreadRow[] }
|
||||
systemd: { items: string[] }
|
||||
appimage: { consequences: string[] }
|
||||
flask: { rows: BlueprintRow[] }
|
||||
dataSources: { rows: DataRow[] }
|
||||
persistence: { rows: PersistenceRow[] }
|
||||
health: { items: string[] }
|
||||
notifications: { items: string[] }
|
||||
websocket: { items: string[] }
|
||||
proxy: { items: string[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } }
|
||||
}
|
||||
const arch = messages.docs.monitor.architecture
|
||||
const threadRows = arch.requestFlow.rows
|
||||
const systemdItems = arch.systemd.items
|
||||
const consequences = arch.appimage.consequences
|
||||
const blueprintRows = arch.flask.rows
|
||||
const dataRows = arch.dataSources.rows
|
||||
const persistenceRows = arch.persistence.rows
|
||||
const healthItems = arch.health.items
|
||||
const notificationItems = arch.notifications.items
|
||||
const websocketItems = arch.websocket.items
|
||||
const proxyItems = arch.proxy.items
|
||||
const whereNextItems = arch.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const link = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const notifLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/notifications" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const aiLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/ai-assistant" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const accessLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/access-auth" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const fail2banLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/security/fail2ban" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const fail2banWarnLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/security/fail2ban" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={10}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t("intro.body")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("requestFlow.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("requestFlow.intro")}</p>
|
||||
|
||||
<DataFlowDiagram
|
||||
nodes={[
|
||||
{ variant: "source", label: t("requestFlow.nodes.clientLabel"), detail: t("requestFlow.nodes.clientDetail") },
|
||||
{ variant: "bridge", label: t("requestFlow.nodes.flaskLabel"), detail: t("requestFlow.nodes.flaskDetail") },
|
||||
{ variant: "bridge", label: t("requestFlow.nodes.hostLabel"), detail: t("requestFlow.nodes.hostDetail") },
|
||||
{ variant: "target", label: t("requestFlow.nodes.stateLabel"), detail: t("requestFlow.nodes.stateDetail") },
|
||||
]}
|
||||
arrowLabel={t("requestFlow.diagramArrowLabel")}
|
||||
caption={t("requestFlow.diagramCaption")}
|
||||
/>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("requestFlow.threadsIntro", { strong })}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("requestFlow.headerThread")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("requestFlow.headerCadence")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("requestFlow.headerJob")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{threadRows.map((row, idx) => (
|
||||
<tr key={row.thread} className={idx < threadRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.thread}</td>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">{row.cadence}</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t.rich(`requestFlow.rows.${idx}.job`, { code })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("systemd.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("systemd.intro", { code })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`[Unit]
|
||||
Description=ProxMenux Monitor - Web Dashboard
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/proxmenux-monitor
|
||||
ExecStart=/opt/proxmenux-monitor/ProxMenux-Monitor.AppImage
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
Environment="PORT=8008"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target`}
|
||||
className="my-4"
|
||||
/>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{systemdItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`systemd.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Callout variant="tip" title={t("systemd.inspectTitle")}>
|
||||
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{`systemctl cat proxmenux-monitor.service # show the unit content
|
||||
systemctl status proxmenux-monitor.service # state + recent log
|
||||
journalctl -u proxmenux-monitor.service -f # follow live`}</pre>
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("appimage.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("appimage.intro", { code })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`AppDir/
|
||||
├── AppRun # entrypoint: sets PATH/LD_LIBRARY_PATH, exec flask_server
|
||||
├── usr/
|
||||
│ ├── bin/
|
||||
│ │ ├── flask_server.py # main process
|
||||
│ │ ├── flask_*_routes.py # Flask blueprints (auth, health, terminal, …)
|
||||
│ │ ├── auth_manager.py # JWT + TOTP + API tokens
|
||||
│ │ ├── health_monitor.py # 10-category checker
|
||||
│ │ ├── health_persistence.py # SQLite layer
|
||||
│ │ ├── notification_manager.py # orchestrator
|
||||
│ │ ├── notification_channels.py # Telegram, Discord, Email, …
|
||||
│ │ ├── notification_templates.py # message rendering + AI hook
|
||||
│ │ ├── notification_events.py # JournalWatcher, TaskWatcher, …
|
||||
│ │ ├── ai_providers/ # OpenAI · Anthropic · Gemini · Groq · Ollama · OpenRouter
|
||||
│ │ ├── proxmox_storage_monitor.py # storage pool inspection
|
||||
│ │ ├── hardware_monitor.py # CPU/PCIe/GPU enumeration
|
||||
│ │ ├── ipmitool, sensors, upsc # vendored hardware tools
|
||||
│ │ └── …
|
||||
│ ├── lib/python3/ # vendored Python deps (Flask, JWT, psutil, …)
|
||||
│ └── share/ # icons + .desktop file
|
||||
└── web/ # Next.js static export
|
||||
├── index.html
|
||||
├── _next/ # JS / CSS chunks
|
||||
└── manifest.json # PWA manifest`}
|
||||
className="my-4"
|
||||
/>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t("appimage.consequencesIntro")}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{consequences.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`appimage.consequences.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("flask.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("flask.intro", { code })}
|
||||
</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("flask.headerBlueprint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("flask.headerPrefix")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("flask.headerOwns")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{blueprintRows.map((row, idx) => (
|
||||
<tr key={row.blueprint} className={idx < blueprintRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.blueprint}</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs leading-6">
|
||||
{row.prefix.map((p, pidx) => (
|
||||
<span key={pidx}>
|
||||
{p}
|
||||
{pidx < row.prefix.length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t.rich(`flask.rows.${idx}.owns`, { code })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("flask.endpointsLink", { link })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataSources.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("dataSources.intro")}</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataSources.headerSource")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataSources.headerUsedFor")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dataRows.map((row, idx) => (
|
||||
<tr key={row.source} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap font-mono text-xs">{row.source}</td>
|
||||
<td className="px-3 py-2 align-top">{row.usedFor}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("dataSources.cacheTitle")}>
|
||||
{t.rich("dataSources.cacheBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("persistence.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("persistence.intro")}</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("persistence.headerPath")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("persistence.headerOwner")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("persistence.headerContents")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{persistenceRows.map((row, idx) => (
|
||||
<tr key={row.path} className={idx < persistenceRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.path}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`persistence.rows.${idx}.owner`, { code })}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`persistence.rows.${idx}.contents`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="warning" title={t("persistence.backupTitle")}>
|
||||
{t.rich("persistence.backupBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("health.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("health.intro", { code })}
|
||||
</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{healthItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`health.items.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("health.afterIntro", { code })}
|
||||
</p>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("health.cycleEnd", { em, code, link })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("notifications.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("notifications.intro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{notificationItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`notifications.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("notifications.linksFooter", { notifLink, aiLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("websocket.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("websocket.intro", { em, code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{websocketItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`websocket.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("websocket.outro", { code })}
|
||||
</p>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("websocket.proxyNote", { code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("proxy.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("proxy.intro")}</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{proxyItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`proxy.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
<Callout variant="info" title={t("proxy.calloutTitle")}>
|
||||
{t.rich("proxy.calloutBody", { strong, code, link: fail2banWarnLink })}
|
||||
</Callout>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("proxy.outro", { accessLink, fail2banLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
518
web/app/[locale]/docs/monitor/dashboard/hardware/page.tsx
Normal file
518
web/app/[locale]/docs/monitor/dashboard/hardware/page.tsx
Normal file
@@ -0,0 +1,518 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.hardware.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type GpuTool = { vendor: string; tool: string; projectLabel: string; projectHref?: string }
|
||||
type DataRow = { section: string; endpoint: string; source: string }
|
||||
type WhereNextItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function HardwareTabPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.hardware" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { dashboard: { hardware: {
|
||||
thresholds: { items: string[] }
|
||||
sections: { systemInfoItems: string[]; thermalItems: string[] }
|
||||
graphics: { tools: GpuTool[]; whereGoItems: string[] }
|
||||
coral: { pathsItems: string[] }
|
||||
power: { items: string[] }
|
||||
dataCollected: { rows: DataRow[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } } }
|
||||
}
|
||||
const hw = messages.docs.monitor.dashboard.hardware
|
||||
const thresholdsItems = hw.thresholds.items
|
||||
const systemInfoItems = hw.sections.systemInfoItems
|
||||
const thermalItems = hw.sections.thermalItems
|
||||
const gpuTools = hw.graphics.tools
|
||||
const whereGoItems = hw.graphics.whereGoItems
|
||||
const coralPathsItems = hw.coral.pathsItems
|
||||
const powerItems = hw.power.items
|
||||
const dataRows = hw.dataCollected.rows
|
||||
const whereNextItems = hw.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const green = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-green-500 align-middle mr-1" />
|
||||
const amber = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-amber-500 align-middle mr-1" />
|
||||
const red = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-red-500 align-middle mr-1" />
|
||||
const thresholdsLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard/settings#status-colours" className="text-blue-700 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const switchModeLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/switch-gpu-mode" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const nvidiaHostLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/nvidia-host" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const nvidiaAnchor = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://www.nvidia.com/Download/index.aspx"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-amber-900 underline hover:no-underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
)
|
||||
const link1 = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/nvidia-host" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const link2 = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/switch-gpu-mode" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const link3 = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/gpu-vm-passthrough" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const link4 = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/igpu-acceleration-lxc" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const installLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/install-coral-tpu-host" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const lxcLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/coral-tpu-lxc" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const coralAnchor = (chunks: React.ReactNode) => (
|
||||
<a
|
||||
href="https://coral.ai/docs/m2/get-started/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
)
|
||||
const storageLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard/storage" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const smartLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/disk-manager/smart-disk-test" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const pciSwitchLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/hardware/switch-gpu-mode" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={14}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("thresholds.title")}>
|
||||
{t.rich("thresholds.intro", { strong, green, amber, red })}
|
||||
<ul className="list-disc pl-6 mt-2 space-y-0.5">
|
||||
{thresholdsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`thresholds.items.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
{t.rich("thresholds.outro", { link: thresholdsLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("sections.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("sections.intro", { em })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("sections.systemInfoTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("sections.systemInfoIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{systemInfoItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`sections.systemInfoItems.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("sections.memoryTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("sections.memoryBody", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("sections.thermalTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("sections.thermalIntro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{thermalItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`sections.thermalItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("graphics.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("graphics.intro", { em, strong, code, link: switchModeLink })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-graphics-cards-vfio.png"
|
||||
alt={t("graphics.vfioImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("graphics.vfioImageCaption", { code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-graphics-cards-lxc.png"
|
||||
alt={t("graphics.lxcImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("graphics.lxcImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("graphics.realtimeTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("graphics.realtimeBody", { code })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("graphics.toolsIntro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6 rounded-md border border-gray-200">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-semibold">{t("graphics.headerVendor")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("graphics.headerTool")}</th>
|
||||
<th className="px-4 py-2 font-semibold">{t("graphics.headerProject")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 text-gray-800">
|
||||
{gpuTools.map((row) => (
|
||||
<tr key={row.vendor}>
|
||||
<td className="px-4 py-2 align-top whitespace-nowrap"><strong>{row.vendor}</strong></td>
|
||||
<td className="px-4 py-2 align-top"><code>{row.tool}</code></td>
|
||||
<td className="px-4 py-2 align-top">
|
||||
{row.projectHref ? (
|
||||
<a
|
||||
href={row.projectHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{row.projectLabel}
|
||||
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
) : (
|
||||
row.projectLabel
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-gpu-nvidia-modal.png"
|
||||
alt={t("graphics.nvidiaImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("graphics.nvidiaImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-gpu-intel-modal.png"
|
||||
alt={t("graphics.intelImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("graphics.intelImageCaption", { code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-gpu-amd-modal.png"
|
||||
alt={t("graphics.amdImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("graphics.amdImageCaption", { code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("graphics.installTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("graphics.installBody", { code, strong, link: nvidiaHostLink })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-gpu-nvidia-no-driver.png"
|
||||
alt={t("graphics.noDriverAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("graphics.noDriverCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-gpu-nvidia-install-prompt.png"
|
||||
alt={t("graphics.promptAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("graphics.promptCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-gpu-nvidia-install-success.png"
|
||||
alt={t("graphics.successAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("graphics.successCaption", { code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<Callout variant="warning" title={t("graphics.warningTitle")}>
|
||||
{t.rich("graphics.warningBody", { code, em, a: nvidiaAnchor })}
|
||||
</Callout>
|
||||
|
||||
<p className="mt-4 mb-2 text-gray-800 leading-relaxed">{t("graphics.whereGoIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereGoItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`graphics.whereGoItems.${idx}`, { em, link1, link2, link3, link4 })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
|
||||
{t("coral.heading")} <em>{t("coral.subHeading")}</em>
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("coral.intro", { code })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-coral-tpu-modal.png"
|
||||
alt={t("coral.imageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("coral.imageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("coral.pathsIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{coralPathsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`coral.pathsItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("coral.outro", { installLink, lxcLink, a: coralAnchor })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("storage.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("storage.intro", { code, em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-storage-summary.png"
|
||||
alt={t("storage.imageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("storage.imageCaption", { em })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("storage.nvmeBody", { strong })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-storage-modal-nvme.png"
|
||||
alt={t("storage.nvmeModalAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("storage.nvmeModalCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("storage.outro", { em, storageLink, smartLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("pci.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("pci.intro", { strong, em, code })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-pci-devices.png"
|
||||
alt={t("pci.imageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("pci.imageCaption", { code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<Callout variant="tip" title={t("pci.bdfTitle")}>
|
||||
{t.rich("pci.bdfBody", { code, link: pciSwitchLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("usb.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("usb.intro", { code, em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-usb-devices.png"
|
||||
alt={t("usb.imageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("usb.imageCaption", { code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
|
||||
{t("power.heading")} <em>{t("power.subHeading")}</em>
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("power.intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{powerItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`power.items.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-power-supplies.png"
|
||||
alt={t("power.supplyImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("power.supplyImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/hardware/hw-cpu-power.png"
|
||||
alt={t("power.cpuImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("power.cpuImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
|
||||
{t("psu.heading")} <em>{t("psu.subHeading")}</em>
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("psu.body")}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
|
||||
{t("fans.heading")} <em>{t("fans.subHeading")}</em>
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("fans.body")}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">
|
||||
{t("ups.heading")} <em>{t("ups.subHeading")}</em>
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("ups.body", { em })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSection")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dataRows.map((row, idx) => (
|
||||
<tr key={row.section} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top">{row.section}</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`dataCollected.rows.${idx}.source`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<CopyableCode
|
||||
code={`${t("dataCollected.codeComment1")}
|
||||
lspci -nnk | grep -A2 -E 'VGA|Audio|Network|3D'
|
||||
sensors
|
||||
|
||||
${t("dataCollected.codeComment2")}
|
||||
curl -H "Authorization: Bearer <token>" \\
|
||||
http://<host>:8008/api/hardware | jq '.gpus'`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
325
web/app/[locale]/docs/monitor/dashboard/network/page.tsx
Normal file
325
web/app/[locale]/docs/monitor/dashboard/network/page.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { Download } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.network.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type TopRow = { card: string; what: string }
|
||||
type DrillRow = { block: string; contents: string }
|
||||
type ThresholdRow = { status: string; range: string; impact: string }
|
||||
type DataRow = { section: string; endpoint: string; source: string }
|
||||
type WhereNextItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function NetworkTabPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.network" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { dashboard: { network: {
|
||||
topRow: { rows: TopRow[] }
|
||||
groups: { badges: string[] }
|
||||
drillIn: { rows: DrillRow[] }
|
||||
latency: {
|
||||
targets: string[]
|
||||
mode2Items: string[]
|
||||
thresholdRows: ThresholdRow[]
|
||||
sections: string[]
|
||||
}
|
||||
dataCollected: { rows: DataRow[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } } }
|
||||
}
|
||||
const net = messages.docs.monitor.dashboard.network
|
||||
const topRows = net.topRow.rows
|
||||
const badges = net.groups.badges
|
||||
const drillRows = net.drillIn.rows
|
||||
const targets = net.latency.targets
|
||||
const mode2Items = net.latency.mode2Items
|
||||
const thresholdRows = net.latency.thresholdRows
|
||||
const sections = net.latency.sections
|
||||
const dataRows = net.dataCollected.rows
|
||||
const whereNextItems = net.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={13}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("topRow.heading")}</h2>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerCard")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerWhat")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{topRows.map((row, idx) => (
|
||||
<tr key={row.card} className={idx < topRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.card}</strong></td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t.rich(`topRow.rows.${idx}.what`, { em, strong })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("groups.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("groups.intro", { strong })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{badges.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`groups.badges.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("groups.clickable", { strong })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("groups.physicalTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("groups.physicalBody", { code, strong, em })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("groups.bridgeTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("groups.bridgeBody", { code, strong })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("groups.vmTitle")}</h3>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("groups.vmBody", { code, em })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("drillIn.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.intro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("drillIn.headerBlock")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("drillIn.headerContents")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{drillRows.map((row, idx) => (
|
||||
<tr key={row.block} className={idx < drillRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.block}</strong></td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t.rich(`drillIn.rows.${idx}.contents`, { code })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("drillIn.inactiveTitle")}>
|
||||
{t.rich("drillIn.inactiveBody", { em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("latency.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("latency.intro", { em })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("latency.targetsTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("latency.targetsIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{targets.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`latency.targets.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("latency.mode1Title")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/network-latency-historical.png"
|
||||
alt={t("latency.mode1Alt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("latency.mode1Caption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("latency.mode1Body1", { em })}
|
||||
</p>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("latency.mode1Body2", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("latency.mode2Title")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/network-latency-realtime.png"
|
||||
alt={t("latency.mode2Alt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("latency.mode2Caption", { em })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("latency.mode2Intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{mode2Items.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`latency.mode2Items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("latency.thresholdsTitle")}</h3>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("latency.headerStatus")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("latency.headerRange")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("latency.headerImpact")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{thresholdRows.map((row, idx) => (
|
||||
<tr key={row.status} className={idx < thresholdRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.status}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{row.range}</td>
|
||||
<td className="px-3 py-2 align-top">{row.impact}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("latency.reportTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("latency.reportIntro", { strong })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/network-latency-report-preview.png"
|
||||
alt={t("latency.reportPreviewAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("latency.reportPreviewCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<div className="my-6">
|
||||
<a
|
||||
href="/monitor/sample-network-latency-report.pdf"
|
||||
download
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-md border border-blue-200 bg-blue-50 text-blue-700 font-medium hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
<Download className="h-4 w-4" aria-hidden="true" />
|
||||
{t("latency.downloadLabel")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("latency.sectionsIntro")}</p>
|
||||
<ol className="list-decimal pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{sections.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`latency.sections.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<Callout variant="tip" title={t("latency.useCaseTitle")}>
|
||||
{t.rich("latency.useCaseBody", { em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("excluding.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("excluding.body1", { code })}
|
||||
</p>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("excluding.body2", { strong, em })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSection")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dataRows.map((row, idx) => (
|
||||
<tr key={row.section} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top">{row.section}</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t.rich(`dataCollected.rows.${idx}.source`, { code })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<CopyableCode
|
||||
code={`${t("dataCollected.codeComment1")}
|
||||
ip -br link
|
||||
ip -br addr
|
||||
|
||||
${t("dataCollected.codeComment2")}
|
||||
curl -H "Authorization: Bearer <token>" \\
|
||||
http://<host>:8008/api/network/latency/current | jq`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
120
web/app/[locale]/docs/monitor/dashboard/page.tsx
Normal file
120
web/app/[locale]/docs/monitor/dashboard/page.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
}
|
||||
}
|
||||
|
||||
type TabRow = { name: string; linksTo?: string; owns: string }
|
||||
type WhereNextItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function DashboardIndexPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { dashboard: {
|
||||
tabs: { rows: TabRow[] }
|
||||
headerAnatomy: { items: string[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } }
|
||||
}
|
||||
const tabRows = messages.docs.monitor.dashboard.tabs.rows
|
||||
const headerAnatomyItems = messages.docs.monitor.dashboard.headerAnatomy.items
|
||||
const whereNextItems = messages.docs.monitor.dashboard.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const link = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/health-monitor" className="text-blue-700 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={3}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("oneHeader.title")}>
|
||||
{t.rich("oneHeader.body", { link })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("tabs.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("tabs.intro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("tabs.headerTab")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("tabs.headerOwns")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{tabRows.map((row, idx) => (
|
||||
<tr
|
||||
key={row.name}
|
||||
className={idx < tabRows.length - 1 ? "border-b border-gray-100" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">
|
||||
{row.linksTo ? (
|
||||
<Link href={row.linksTo} className="text-blue-600 hover:underline font-semibold">
|
||||
{row.name}
|
||||
</Link>
|
||||
) : (
|
||||
<strong>{row.name}</strong>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t.rich(`tabs.rows.${idx}.owns`, { code })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("headerAnatomy.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{headerAnatomyItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`headerAnatomy.items.${idx}`, { code, strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
489
web/app/[locale]/docs/monitor/dashboard/security/page.tsx
Normal file
489
web/app/[locale]/docs/monitor/dashboard/security/page.tsx
Normal file
@@ -0,0 +1,489 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.security.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type DataRow = { card: string; endpoint: string; source: string }
|
||||
type WhereNextItem = { label: string; href: string; tail?: string; tailRich?: string }
|
||||
|
||||
export default async function SecurityTabPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.security" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { dashboard: { security: {
|
||||
auth: { items: string[] }
|
||||
ssl: { items: string[] }
|
||||
gateway: { step3Items: string[]; step4Items: string[] }
|
||||
firewall: { items: string[] }
|
||||
lynis: { scoreItems: string[] }
|
||||
dataCollected: { rows: DataRow[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } } }
|
||||
}
|
||||
const sec = messages.docs.monitor.dashboard.security
|
||||
const authItems = sec.auth.items
|
||||
const sslItems = sec.ssl.items
|
||||
const step3Items = sec.gateway.step3Items
|
||||
const step4Items = sec.gateway.step4Items
|
||||
const firewallItems = sec.firewall.items
|
||||
const lynisScoreItems = sec.lynis.scoreItems
|
||||
const dataRows = sec.dataCollected.rows
|
||||
const whereNextItems = sec.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const authLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/access-auth" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const sslPageLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/security/ssl-letsencrypt" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const integrationsLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/integrations" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const tailscaleHomeAnchor = (chunks: React.ReactNode) => (
|
||||
<a href="https://tailscale.com" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
|
||||
{chunks}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
)
|
||||
const tailscaleKeysAnchor = (chunks: React.ReactNode) => (
|
||||
<a href="https://login.tailscale.com/admin/settings/keys" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
|
||||
{chunks}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
)
|
||||
const tailscaleMachinesAnchor = (chunks: React.ReactNode) => (
|
||||
<a href="https://login.tailscale.com/admin/machines" target="_blank" rel="noopener noreferrer" className="text-amber-700 hover:underline inline-flex items-center gap-1">
|
||||
{chunks}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
)
|
||||
const fail2banLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/security/fail2ban" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const lynisLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/security/lynis" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const lynisSampleAnchor = (chunks: React.ReactNode) => (
|
||||
<a href="/monitor/security/lynis-sample-report.pdf" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
|
||||
{chunks}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={18}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("monitor.heading")}</h2>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t("monitor.intro")}</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("auth.heading")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/auth-card.png" alt={t("auth.imageAlt")} width={2000} height={956} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("auth.imageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("auth.intro", { link: authLink })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{authItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`auth.items.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("ssl.heading")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/ssl-https-card.png" alt={t("ssl.imageAlt")} width={2000} height={1124} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("ssl.imageCaption", { em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("ssl.intro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{sslItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`ssl.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/ssl-https-enabled.png" alt={t("ssl.enabledAlt")} width={2000} height={889} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("ssl.enabledCaption", { em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<Callout variant="info" title={t("ssl.acmeTitle")}>
|
||||
{t.rich("ssl.acmeBody", { em })}
|
||||
</Callout>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("ssl.walkthroughLink", { code, link: sslPageLink })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("apiTokens.heading")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/api-tokens-empty.png" alt={t("apiTokens.emptyAlt")} width={2000} height={855} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("apiTokens.emptyCaption", { em, code })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("apiTokens.intro")}</p>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("apiTokens.generateBody", { strong, em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/api-tokens-generate.png" alt={t("apiTokens.generateAlt")} width={2000} height={1124} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("apiTokens.generateCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("apiTokens.saveBody", { strong })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/api-tokens-generated.png" alt={t("apiTokens.generatedAlt")} width={2000} height={1468} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("apiTokens.generatedCaption", { code })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("apiTokens.outro", { em, link: integrationsLink })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("gateway.heading")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/secure-gateway-card.png" alt={t("gateway.cardAlt")} width={2000} height={434} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gateway.cardCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("gateway.intro", { code, a: tailscaleHomeAnchor })}
|
||||
</p>
|
||||
|
||||
<h4 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("gateway.wizardTitle")}</h4>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("gateway.wizardIntro", { em })}
|
||||
</p>
|
||||
|
||||
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step0Title")}</h5>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("gateway.step0Body", { em, a: tailscaleKeysAnchor })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/tailscale-auth-key-page.png" alt={t("gateway.step0Alt")} width={2000} height={1115} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("gateway.step0Caption", { em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step1Title")}</h5>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("gateway.step1Body", { em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/gateway-step-1-intro.png" alt={t("gateway.step1Alt")} width={1589} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gateway.step1Caption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step2Title")}</h5>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("gateway.step2Body", { code })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/gateway-step-3-auth.png" alt={t("gateway.step2Alt")} width={1985} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gateway.step2Caption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step3Title")}</h5>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("gateway.step3Intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-3 text-gray-800 leading-relaxed space-y-1">
|
||||
{step3Items.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`gateway.step3Items.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/gateway-step-2-scope.png" alt={t("gateway.step3Alt")} width={1934} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("gateway.step3Caption", { em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step4Title")}</h5>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("gateway.step4Intro", { strong })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-3 text-gray-800 leading-relaxed space-y-1">
|
||||
{step4Items.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`gateway.step4Items.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/gateway-step-4-advanced.png" alt={t("gateway.step4Alt")} width={1847} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gateway.step4Caption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h5 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("gateway.step5Title")}</h5>
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("gateway.step5Body", { strong })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/gateway-step-5-review.png" alt={t("gateway.step5Alt")} width={1860} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gateway.step5Caption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<Callout variant="warning" title={t("gateway.approvalTitle")}>
|
||||
{t.rich("gateway.approvalBody", { em, a: tailscaleMachinesAnchor })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("pve.heading")}</h2>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t("pve.intro")}</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("firewall.heading")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/firewall-card.png" alt={t("firewall.imageAlt")} width={2000} height={1256} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("firewall.imageCaption", { em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("firewall.intro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{firewallItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`firewall.items.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">
|
||||
{t("fail2ban.heading")} <em>{t("fail2ban.subHeading")}</em>
|
||||
</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("fail2ban.whatIs", { strong })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("fail2ban.notBundled", { strong })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/fail2ban-not-installed.png" alt={t("fail2ban.notInstalledAlt")} width={2000} height={968} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("fail2ban.notInstalledCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("fail2ban.clickBody", { em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/fail2ban-install-confirm.png" alt={t("fail2ban.confirmAlt")} width={1808} height={1678} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("fail2ban.confirmCaption", { code })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">{t("fail2ban.confirmIntro")}</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/fail2ban-install-progress.png" alt={t("fail2ban.progressAlt")} width={2000} height={1512} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("fail2ban.progressCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("fail2ban.afterInstall", { em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/fail2ban-active.png" alt={t("fail2ban.activeAlt")} width={2000} height={1614} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("fail2ban.activeCaption", { code })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("fail2ban.tuneBody", { strong, em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/fail2ban-sshd-config.png" alt={t("fail2ban.configAlt")} width={2000} height={919} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("fail2ban.configCaption", { em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("fail2ban.outro", { em, code, link: fail2banLink })}
|
||||
</p>
|
||||
|
||||
<Callout variant="info" title={t("fail2ban.calloutTitle")}>
|
||||
{t.rich("fail2ban.calloutBody", { em, code })}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-8 mb-3 text-gray-900">
|
||||
{t("lynis.heading")} <em>{t("lynis.subHeading")}</em>
|
||||
</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("lynis.whatIs", { strong })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("lynis.whyUseful", { strong, code })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/lynis-not-installed.png" alt={t("lynis.notInstalledAlt")} width={2000} height={919} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("lynis.notInstalledCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("lynis.notBundled", { strong })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/lynis-install-confirm.png" alt={t("lynis.confirmAlt")} width={1985} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("lynis.confirmCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/lynis-install-progress.png" alt={t("lynis.progressAlt")} width={1856} height={972} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("lynis.progressCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("lynis.afterInstall", { em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/lynis-installed-empty.png" alt={t("lynis.installedAlt")} width={2000} height={1131} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("lynis.installedCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/lynis-audit-running.png" alt={t("lynis.runningAlt")} width={2000} height={1131} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("lynis.runningCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("lynis.finishedBody", { em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/lynis-audit-results.png" alt={t("lynis.resultsAlt")} width={2000} height={1183} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("lynis.resultsCaption", { strong })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<Callout variant="info" title={t("lynis.scoreTitle")}>
|
||||
{t.rich("lynis.scoreIntro", { em, code })}
|
||||
<ul className="list-disc pl-6 mt-2 mb-0 space-y-1">
|
||||
{lynisScoreItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`lynis.scoreItems.${idx}`, { em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Callout>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("lynis.reportBody", { strong, em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/security/lynis-report-pdf.png" alt={t("lynis.reportAlt")} width={1414} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("lynis.reportCaption", { a: lynisSampleAnchor })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("lynis.runPeriodically")}</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("lynis.outro", { em, link: lynisLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-12 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerCard")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dataRows.map((row, idx) => (
|
||||
<tr key={row.card} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top">{row.card}</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`dataCollected.rows.${idx}.source`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<CopyableCode
|
||||
code={`# Confirm the auth log on the host (used by Fail2Ban + audit)
|
||||
journalctl -t proxmenux-auth --since '7 days ago' | tail
|
||||
|
||||
# Cross-check the firewall rules the dashboard sees
|
||||
pve-firewall status
|
||||
cat /etc/pve/firewall/host.fw
|
||||
|
||||
# Verify Fail2Ban (only if installed)
|
||||
fail2ban-client status
|
||||
fail2ban-client status sshd
|
||||
|
||||
# Verify Lynis (only if installed)
|
||||
lynis show version
|
||||
ls -lh /var/log/lynis-report.dat`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
486
web/app/[locale]/docs/monitor/dashboard/settings/page.tsx
Normal file
486
web/app/[locale]/docs/monitor/dashboard/settings/page.tsx
Normal file
@@ -0,0 +1,486 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.settings.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type ColourRow = { colour: string; range: string; meaning: string }
|
||||
type ThresholdRow = { section: string; warning: string; critical: string; gates: string }
|
||||
type DataRow = { card: string; endpoint: string; source: string }
|
||||
type WhereNextItem = { label: string; href: string; tail?: string; tailRich?: string }
|
||||
|
||||
export default async function SettingsTabPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.settings" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { dashboard: { settings: {
|
||||
health: { items: string[]; activeItems: string[] }
|
||||
thresholds: {
|
||||
whatForItems: string[]
|
||||
colourRows: ColourRow[]
|
||||
thresholdRows: ThresholdRow[]
|
||||
}
|
||||
lxcDetection: { whatRunsItems: string[] }
|
||||
storageExclusions: { items: string[] }
|
||||
interfaceExclusions: { items: string[] }
|
||||
notifications: { items: string[] }
|
||||
optimizations: { dotsItems: string[] }
|
||||
dataCollected: { rows: DataRow[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } } }
|
||||
}
|
||||
const s = messages.docs.monitor.dashboard.settings
|
||||
const healthItems = s.health.items
|
||||
const activeSuppressionItems = s.health.activeItems
|
||||
const whatForItems = s.thresholds.whatForItems
|
||||
const colourRows = s.thresholds.colourRows
|
||||
const thresholdRows = s.thresholds.thresholdRows
|
||||
const whatRunsItems = s.lxcDetection.whatRunsItems
|
||||
const storageItems = s.storageExclusions.items
|
||||
const interfaceItems = s.interfaceExclusions.items
|
||||
const notificationItems = s.notifications.items
|
||||
const dotsItems = s.optimizations.dotsItems
|
||||
const dataRows = s.dataCollected.rows
|
||||
const whereNextItems = s.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const green = () => <span className="inline-block w-2 h-2 rounded-full bg-green-500 align-middle mr-1" />
|
||||
const amber = () => <span className="inline-block w-2 h-2 rounded-full bg-amber-500 align-middle mr-1" />
|
||||
const healthLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/health-monitor#dismissing-alerts-and-the-suppression-duration" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const storageTabLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard/storage" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const networkTabLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard/network" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const notifLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/notifications" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const aiLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/ai-assistant" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const autoLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/automated" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const customLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/customizable" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const updatesLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/updates" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const uninstallLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/post-install/uninstall" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={9}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t("intro.body")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("networkUnits.heading")}</h2>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image
|
||||
src="/monitor/settings/network-units.png"
|
||||
alt={t("networkUnits.imageAlt")}
|
||||
width={2000}
|
||||
height={374}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("networkUnits.imageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("networkUnits.body", { strong })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("health.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("health.intro", { strong })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{healthItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`health.items.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<figure className="my-6">
|
||||
<Image
|
||||
src="/monitor/health-suppression-settings.png"
|
||||
alt={t("health.imageAlt")}
|
||||
width={2010}
|
||||
height={1816}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("health.imageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("health.editTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("health.editBody", { strong })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("health.activeTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("health.activeIntro", { strong, em })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{activeSuppressionItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`health.activeItems.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("health.activeReenableTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("health.activeReenableBody", { strong, code })}
|
||||
</p>
|
||||
|
||||
<Callout variant="info" title={t("health.activeAutoRefreshTitle")}>
|
||||
{t("health.activeAutoRefreshBody")}
|
||||
</Callout>
|
||||
|
||||
<p className="mt-4 mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("health.activePermanentNote", { strong, em, code })}
|
||||
</p>
|
||||
|
||||
<Callout variant="info" title={t("health.calloutTitle")}>
|
||||
{t.rich("health.calloutBody", { link: healthLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("thresholds.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("thresholds.intro", { em, strong })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("thresholds.whatForTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("thresholds.whatForIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{whatForItems.map((_, idx) => (
|
||||
<li key={idx}>{t(`thresholds.whatForItems.${idx}`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("thresholds.whatForOutro", { strong, code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900" id="status-colours">{t("thresholds.coloursTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("thresholds.coloursIntro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("thresholds.headerColour")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("thresholds.headerRange")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("thresholds.headerMeaning")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{colourRows.map((row, idx) => {
|
||||
// Color tier is identified positionally so the dot stays
|
||||
// correct in any locale (Spanish: Verde / Ámbar / Rojo).
|
||||
const dotClass = ["bg-green-500", "bg-amber-500", "bg-red-500"][idx] ?? "bg-red-500"
|
||||
return (
|
||||
<tr key={row.colour} className={idx < colourRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">
|
||||
<span className={`inline-block w-3 h-3 rounded-full ${dotClass} align-middle mr-2`} />
|
||||
<strong>{row.colour}</strong>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">{row.range}</td>
|
||||
<td className="px-3 py-2 align-top">{row.meaning}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("thresholds.sectionsTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("thresholds.sectionsIntro", { em })}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("thresholds.headerSection")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200 whitespace-nowrap bg-amber-100/60">{t("thresholds.headerWarning")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200 whitespace-nowrap bg-red-100/60">{t("thresholds.headerCritical")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("thresholds.headerGates")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{thresholdRows.map((row, idx) => (
|
||||
<tr key={row.section} className={idx < thresholdRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top"><strong>{row.section}</strong></td>
|
||||
<td className={`px-3 py-2 align-top font-mono whitespace-nowrap ${row.warning === "—" ? "text-gray-400" : ""} bg-amber-100/40`}>{row.warning}</td>
|
||||
<td className="px-3 py-2 align-top font-mono whitespace-nowrap bg-red-100/40">{row.critical}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`thresholds.thresholdRows.${idx}.gates`, { code, em })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("thresholds.defaultsTitle")}>
|
||||
{t.rich("thresholds.defaultsBody", { em, strong })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("thresholds.validationTitle")}>
|
||||
{t("thresholds.validationBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("lxcDetection.heading")}</h2>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image
|
||||
src="/monitor/settings/lxc-update-detection.png"
|
||||
alt={t("lxcDetection.imageAlt")}
|
||||
width={2000}
|
||||
height={620}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("lxcDetection.imageCaption", { code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("lxcDetection.intro", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("lxcDetection.whatRunsTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("lxcDetection.whatRunsIntro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{whatRunsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`lxcDetection.whatRunsItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="info" title={t("lxcDetection.selfUpdateTitle")}>
|
||||
{t.rich("lxcDetection.selfUpdateBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("lxcDetection.refreshTitle")}>
|
||||
{t.rich("lxcDetection.refreshBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("lxcDetection.toggleTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("lxcDetection.toggleBody", { code, strong })}
|
||||
</p>
|
||||
|
||||
<Callout variant="warning" title={t("lxcDetection.purgeTitle")}>
|
||||
{t.rich("lxcDetection.purgeBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("storageExclusions.heading")}</h2>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image
|
||||
src="/monitor/settings/storage-exclusions.png"
|
||||
alt={t("storageExclusions.imageAlt")}
|
||||
width={2000}
|
||||
height={1120}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("storageExclusions.imageCaption", { em })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("storageExclusions.intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{storageItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`storageExclusions.items.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("storageExclusions.outro", { em, code, link: storageTabLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("interfaceExclusions.heading")}</h2>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image
|
||||
src="/monitor/settings/interface-exclusions.png"
|
||||
alt={t("interfaceExclusions.imageAlt")}
|
||||
width={2000}
|
||||
height={1142}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("interfaceExclusions.imageCaption", { em })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("interfaceExclusions.intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{interfaceItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`interfaceExclusions.items.${idx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("interfaceExclusions.outro", { code, em, link: networkTabLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("notifications.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("notifications.body1", { em })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("notifications.body2")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{notificationItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`notifications.items.${idx}`, { notifLink, aiLink })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("optimizations.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("optimizations.intro", { code, autoLink, customLink })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image
|
||||
src="/monitor/settings/proxmenux-optimizations.png"
|
||||
alt={t("optimizations.imageAlt")}
|
||||
width={2000}
|
||||
height={1146}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("optimizations.imageCaption", { em })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("optimizations.dotsTitle")}</h3>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{dotsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`optimizations.dotsItems.${idx}`, { strong, em, green, amber })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("optimizations.clickTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("optimizations.clickBody", { code })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image
|
||||
src="/monitor/settings/optimization-detail.png"
|
||||
alt={t("optimizations.detailAlt")}
|
||||
width={2000}
|
||||
height={1040}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("optimizations.detailCaption", { em, code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<Callout variant="info" title={t("optimizations.whyTitle")}>
|
||||
{t("optimizations.whyBody")}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("optimizations.updatesTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("optimizations.updatesBody", { strong, em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image
|
||||
src="/monitor/settings/proxmenux-optimizations-update-banner.png"
|
||||
alt={t("optimizations.updatesAlt")}
|
||||
width={2000}
|
||||
height={1146}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("optimizations.updatesCaption", { link: updatesLink })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("optimizations.revertTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("optimizations.revertBody", { code, link: uninstallLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerCard")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dataRows.map((row, idx) => (
|
||||
<tr key={row.card} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top">{row.card}</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`dataCollected.rows.${idx}.source`, { code, notifLink, aiLink })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { customLink }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
474
web/app/[locale]/docs/monitor/dashboard/storage/page.tsx
Normal file
474
web/app/[locale]/docs/monitor/dashboard/storage/page.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { Download } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.storage.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type DataRow = { section: string; endpoint: string; source: string }
|
||||
type WhereNextItem = { label: string; href: string; tail?: string; tailRich?: string }
|
||||
|
||||
export default async function StorageTabPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.storage" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { dashboard: { storage: {
|
||||
thresholds: { items: string[] }
|
||||
topRow: { disksItems: string[] }
|
||||
pveStorage: { items: string[] }
|
||||
zfs: { items: string[] }
|
||||
physical: { items: string[] }
|
||||
drillIn: {
|
||||
overviewItems: string[]
|
||||
smartItems: string[]
|
||||
pdfSections: string[]
|
||||
historyItems: string[]
|
||||
scheduleItems: string[]
|
||||
tempShowsItems: string[]
|
||||
tempDiskTypes: string[]
|
||||
tempWhyItems: string[]
|
||||
obsWhatItems: string[]
|
||||
obsWhyItems: string[]
|
||||
}
|
||||
dataCollected: { rows: DataRow[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } } }
|
||||
}
|
||||
const s = messages.docs.monitor.dashboard.storage
|
||||
const thresholdsItems = s.thresholds.items
|
||||
const disksItems = s.topRow.disksItems
|
||||
const pveItems = s.pveStorage.items
|
||||
const zfsItems = s.zfs.items
|
||||
const physicalItems = s.physical.items
|
||||
const overviewItems = s.drillIn.overviewItems
|
||||
const smartItems = s.drillIn.smartItems
|
||||
const pdfSections = s.drillIn.pdfSections
|
||||
const historyItems = s.drillIn.historyItems
|
||||
const scheduleItems = s.drillIn.scheduleItems
|
||||
const tempShowsItems = s.drillIn.tempShowsItems
|
||||
const tempDiskTypes = s.drillIn.tempDiskTypes
|
||||
const tempWhyItems = s.drillIn.tempWhyItems
|
||||
const obsWhatItems = s.drillIn.obsWhatItems
|
||||
const obsWhyItems = s.drillIn.obsWhyItems
|
||||
const dataRows = s.dataCollected.rows
|
||||
const whereNextItems = s.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const green = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-green-500 align-middle mr-1" />
|
||||
const amber = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-amber-500 align-middle mr-1" />
|
||||
const red = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-red-500 align-middle mr-1" />
|
||||
const thresholdsLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard/settings#status-colours" className="text-blue-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const zfsHmLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/health-monitor" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const physicalWarnLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/disk-manager/format-disk" className="text-amber-700 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const hmLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/health-monitor" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={14}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("thresholds.title")}>
|
||||
{t.rich("thresholds.intro", { strong, green, amber, red })}
|
||||
<ul className="list-disc pl-6 mt-2 space-y-0.5">
|
||||
{thresholdsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`thresholds.items.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
{t.rich("thresholds.outro", { link: thresholdsLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("topRow.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("topRow.intro")}</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/storage-top-row.png"
|
||||
alt={t("topRow.imageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("topRow.imageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerCard")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerWhat")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.totalLabel")}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t("topRow.totalWhat")}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.localLabel")}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich("topRow.localWhat", { em })}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.remoteLabel")}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t("topRow.remoteWhat")}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.disksLabel")}</strong></td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t("topRow.disksIntro")}
|
||||
<ul className="list-disc pl-5 mt-2 space-y-0.5">
|
||||
{disksItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`topRow.disksItems.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("pveStorage.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("pveStorage.intro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{pveItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`pveStorage.items.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="tip" title={t("pveStorage.calloutTitle")}>
|
||||
{t.rich("pveStorage.calloutBody", { em, code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("zfs.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("zfs.intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{zfsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`zfs.items.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("zfs.outro", { em, link: zfsHmLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("physical.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("physical.intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{physicalItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`physical.items.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t("physical.clickHint")}</p>
|
||||
|
||||
<Callout variant="warning" title={t("physical.warningTitle")}>
|
||||
{t.rich("physical.warningBody", { strong, link: physicalWarnLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("external.heading")}</h2>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("external.body", { strong })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("drillIn.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.intro", { strong, em })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.overviewTitle")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/disk-modal-overview.png"
|
||||
alt={t("drillIn.overviewImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("drillIn.overviewImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.overviewIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{overviewItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.overviewItems.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.smartTitle")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/disk-modal-smart.png"
|
||||
alt={t("drillIn.smartImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("drillIn.smartImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.smartIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{smartItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.smartItems.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.pdfTitle")}</h4>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.pdfIntro", { strong })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/smart-report-preview.png"
|
||||
alt={t("drillIn.pdfPreviewAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("drillIn.pdfPreviewCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<div className="my-6">
|
||||
<a
|
||||
href="/monitor/sample-smart-report.pdf"
|
||||
download
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-md border border-blue-200 bg-blue-50 text-blue-700 font-medium hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
<Download className="h-4 w-4" aria-hidden="true" />
|
||||
{t("drillIn.pdfDownloadLabel")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.pdfSectionsIntro")}</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{pdfSections.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.pdfSections.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.pdfOutro", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.historyTitle")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/disk-modal-history.png"
|
||||
alt={t("drillIn.historyImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("drillIn.historyImageCaption", { code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.historyIntro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{historyItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.historyItems.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.scheduleTitle")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/disk-modal-schedule.png"
|
||||
alt={t("drillIn.scheduleImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("drillIn.scheduleImageCaption", { code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.scheduleIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{scheduleItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.scheduleItems.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t("drillIn.scheduleOutro")}</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.tempTitle")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.tempIntro")}</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/disk-modal-temperature.png"
|
||||
alt={t("drillIn.tempImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("drillIn.tempImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.tempShowsTitle")}</h4>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{tempShowsItems.map((_, idx) => (
|
||||
<li key={idx}>
|
||||
{t.rich(`drillIn.tempShowsItems.${idx}`, { strong, em })}
|
||||
{idx === 2 && (
|
||||
<ul className="list-disc pl-6 mt-1">
|
||||
{tempDiskTypes.map((_, didx) => (
|
||||
<li key={didx}>{t.rich(`drillIn.tempDiskTypes.${didx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.tempConfigurable", { em })}
|
||||
</p>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.tempWhyTitle")}</h4>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{tempWhyItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.tempWhyItems.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.obsTitle")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.obsIntro", { strong, em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/disk-modal-observations.png"
|
||||
alt={t("drillIn.obsImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("drillIn.obsImageCaption", { strong })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.obsWhatTitle")}</h4>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.obsWhatIntro", { strong })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{obsWhatItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.obsWhatItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.obsWhyTitle")}</h4>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{obsWhyItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.obsWhyItems.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.obsDedupTitle")}</h4>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.obsDedupBody1", { strong, code })}
|
||||
</p>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t("drillIn.obsDedupBody2")}
|
||||
</p>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.obsDismissTitle")}</h4>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.obsDismissBody1", { strong })}
|
||||
</p>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.obsDismissBody2", { link: hmLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSection")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dataRows.map((row, idx) => (
|
||||
<tr key={row.section} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top">{row.section}</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`dataCollected.rows.${idx}.source`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("dataCollected.outro")}</p>
|
||||
|
||||
<CopyableCode
|
||||
code={`${t("dataCollected.codeComment1")}
|
||||
curl -H "Authorization: Bearer <api-token>" \\
|
||||
http://<host>:8008/api/storage | jq '.disks[] | {name,model,smart_status}'
|
||||
|
||||
${t("dataCollected.codeComment2")}
|
||||
lsblk -O
|
||||
zpool status
|
||||
journalctl -t smartd --since '1 day ago' | tail`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
178
web/app/[locale]/docs/monitor/dashboard/system-logs/page.tsx
Normal file
178
web/app/[locale]/docs/monitor/dashboard/system-logs/page.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.systemLogs.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type DataRow = { subtab: string; endpoint: string; source: string }
|
||||
type WhereNextItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function SystemLogsTabPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.systemLogs" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { dashboard: { systemLogs: {
|
||||
topRow: { items: string[] }
|
||||
subtabs: { logsFilters: string[]; fields: string[] }
|
||||
dataCollected: { rows: DataRow[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } } }
|
||||
}
|
||||
const sl = messages.docs.monitor.dashboard.systemLogs
|
||||
const topRowItems = sl.topRow.items
|
||||
const logsFilters = sl.subtabs.logsFilters
|
||||
const fields = sl.subtabs.fields
|
||||
const dataRows = sl.dataCollected.rows
|
||||
const whereNextItems = sl.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const link = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/notifications" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={7}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("readOnly.title")}>
|
||||
{t.rich("readOnly.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("topRow.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{topRowItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`topRow.items.${idx}`, { code, strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("subtabs.heading")}</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("subtabs.logsTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("subtabs.logsIntro", { code })}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{logsFilters.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`subtabs.logsFilters.${idx}`, { code, strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("subtabs.logsRowsAfter", { code, strong })}
|
||||
</p>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("subtabs.logDetailsModalTitle")}</h4>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("subtabs.logDetailsBody", { code, strong })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/log-details-modal.png"
|
||||
alt={t("subtabs.logDetailsImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("subtabs.logDetailsImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("subtabs.fieldsIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{fields.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`subtabs.fields.${idx}`, { code, strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="tip" title={t("subtabs.maxLevelStoreTitle")}>
|
||||
{t.rich("subtabs.maxLevelStoreBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("subtabs.backupsTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("subtabs.backupsBody", { code, em })}</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("subtabs.notificationsTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("subtabs.notificationsBody1")}</p>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t.rich("subtabs.notificationsBody2", { link })}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSubtab")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dataRows.map((row, idx) => (
|
||||
<tr
|
||||
key={row.endpoint}
|
||||
className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 align-top">{row.subtab}</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs" dangerouslySetInnerHTML={{ __html: row.endpoint }} />
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t.rich(`dataCollected.rows.${idx}.source`, { code })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("dataCollected.apiIntro")}</p>
|
||||
<CopyableCode
|
||||
code={`${t("dataCollected.codeComment1")}
|
||||
curl -H "Authorization: Bearer <token>" \\
|
||||
"http://<host>:8008/api/logs?severity=error&since=1h&search=zfs"
|
||||
|
||||
${t("dataCollected.codeComment2")}
|
||||
curl -H "Authorization: Bearer <token>" \\
|
||||
-o pmx-journal.txt \\
|
||||
"http://<host>:8008/api/logs/download?since=6h"
|
||||
|
||||
${t("dataCollected.codeComment3")}
|
||||
curl -H "Authorization: Bearer <token>" \\
|
||||
"http://<host>:8008/api/task-log/<upid>"`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
225
web/app/[locale]/docs/monitor/dashboard/system-overview/page.tsx
Normal file
225
web/app/[locale]/docs/monitor/dashboard/system-overview/page.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.systemOverview.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type TopRow = { card: string; what: string; source: string }
|
||||
type DataRow = { card: string; endpoint: string; source: string }
|
||||
type WhereNextItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function SystemOverviewTabPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.systemOverview" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { dashboard: { systemOverview: {
|
||||
topRow: { rows: TopRow[]; thresholdsItems: string[] }
|
||||
bottom: { storageItems: string[] }
|
||||
refresh: { items: string[] }
|
||||
dataCollected: { rows: DataRow[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } } }
|
||||
}
|
||||
const so = messages.docs.monitor.dashboard.systemOverview
|
||||
const topRows = so.topRow.rows
|
||||
const thresholdsItems = so.topRow.thresholdsItems
|
||||
const storageItems = so.bottom.storageItems
|
||||
const refreshItems = so.refresh.items
|
||||
const dataRows = so.dataCollected.rows
|
||||
const whereNextItems = so.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const green = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-green-500 align-middle mr-1" />
|
||||
const amber = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-amber-500 align-middle mr-1" />
|
||||
const red = () => <span className="inline-block w-2.5 h-2.5 rounded-full bg-red-500 align-middle mr-1" />
|
||||
const thresholdsLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard/settings#status-colours" className="text-blue-700 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const storageLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const networkLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={6}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("readOnly.title")}>
|
||||
{t("readOnly.body")}
|
||||
</Callout>
|
||||
|
||||
<figure className="my-8">
|
||||
<img
|
||||
src="/monitor/dashboard-home.png"
|
||||
alt={t("captureAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("captureCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("topRow.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("topRow.intro", { code })}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerCard")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerWhat")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerSource")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{topRows.map((row, idx) => (
|
||||
<tr key={row.card} className={idx < topRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">
|
||||
<strong>{row.card}</strong>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t.rich(`topRow.rows.${idx}.what`, { code, em })}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.source}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="tip" title={t("topRow.thresholdsTitle")}>
|
||||
{t.rich("topRow.thresholdsIntro", { strong, green, amber, red })}
|
||||
<ul className="list-disc pl-6 mt-2 space-y-0.5">
|
||||
{thresholdsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`topRow.thresholdsItems.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
{t.rich("topRow.thresholdsOutro", { link: thresholdsLink })}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("topRow.sparklineTitle")}>
|
||||
{t("topRow.sparklineBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("middle.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("middle.body1", { code, em })}
|
||||
</p>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t("middle.body2")}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("bottom.heading")}</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("bottom.storageTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("bottom.storageIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{storageItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`bottom.storageItems.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("bottom.storageDrillIn", { link: storageLink })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("bottom.networkTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("bottom.networkBody1", { code })}
|
||||
</p>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("bottom.networkBody2", { link: networkLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("refresh.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("refresh.intro", { code, em })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{refreshItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`refresh.items.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerCard")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dataRows.map((row, idx) => (
|
||||
<tr key={row.card} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top">{row.card}</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t.rich(`dataCollected.rows.${idx}.source`, { code })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<CopyableCode
|
||||
code={`${t("dataCollected.codeComment1")}
|
||||
curl http://<host>:8008/api/health ${t("dataCollected.codeComment2")}
|
||||
|
||||
${t("dataCollected.codeComment3")}
|
||||
curl -H "Authorization: Bearer <token>" \\
|
||||
http://<host>:8008/api/system | jq '.cpu,.memory,.uptime'`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
319
web/app/[locale]/docs/monitor/dashboard/terminal/page.tsx
Normal file
319
web/app/[locale]/docs/monitor/dashboard/terminal/page.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.terminal.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type KeyboardRow = { button: string; sends: string; use?: string; useRich?: boolean }
|
||||
type DisconnectRow = { cause: string; fix: string }
|
||||
type WhereNextItem = { label: string; href: string; tail: string }
|
||||
|
||||
export default async function TerminalTabPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.terminal" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { dashboard: { terminal: {
|
||||
keyboard: { rows: KeyboardRow[]; ctrlItems: string[] }
|
||||
auth: { items: string[] }
|
||||
clipboard: { items: string[] }
|
||||
disconnect: { rows: DisconnectRow[] }
|
||||
fourTerminals: { items: string[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } } }
|
||||
}
|
||||
const term = messages.docs.monitor.dashboard.terminal
|
||||
const kbRows = term.keyboard.rows
|
||||
const ctrlItems = term.keyboard.ctrlItems
|
||||
const authItems = term.auth.items
|
||||
const clipboardItems = term.clipboard.items
|
||||
const disconnectRows = term.disconnect.rows
|
||||
const fourTerminalsItems = term.fourTerminals.items
|
||||
const whereNextItems = term.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const green = (chunks: React.ReactNode) => <span className="text-green-600 font-semibold">{chunks}</span>
|
||||
const red = (chunks: React.ReactNode) => <span className="text-red-600 font-semibold">{chunks}</span>
|
||||
const vmsLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard/vms-lxcs" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const vmsLinkAmber = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard/vms-lxcs" className="text-blue-700 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const authLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/access-auth" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const authLinkWarn = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/access-auth" className="text-amber-700 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
const gatewayLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/integrations" className="text-amber-700 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={7}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<figure className="my-6">
|
||||
<Image
|
||||
src="/monitor/terminal/single-terminal.png"
|
||||
alt={t("singleAlt")}
|
||||
width={1600}
|
||||
height={1000}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("singleCaption", { em })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("target.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("target.body1", { strong })}
|
||||
</p>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("target.body2", { strong, em, code, link: vmsLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("fourTerminals.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("fourTerminals.intro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{fourTerminalsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`fourTerminals.items.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("fourTerminals.outro", { strong, em, code })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<Image
|
||||
src="/monitor/terminal/grid-4-terminals.png"
|
||||
alt={t("gridAlt")}
|
||||
width={1600}
|
||||
height={1000}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("gridCaption", { code })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("keyboard.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("keyboard.intro", { code })}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("keyboard.headerButton")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("keyboard.headerSends")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("keyboard.headerUse")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{kbRows.map((row, idx) => (
|
||||
<tr key={row.button} className={idx < kbRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.button}</strong></td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.sends}</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{row.useRich ? (
|
||||
<>
|
||||
{t("keyboard.ctrlIntro")}
|
||||
<ul className="list-disc pl-5 mt-1 space-y-0.5">
|
||||
{ctrlItems.map((_, cidx) => (
|
||||
<li key={cidx}>{t.rich(`keyboard.ctrlItems.${cidx}`, { code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
t.rich(`keyboard.rows.${idx}.use`, { code })
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("keyboard.modalTitle")}>
|
||||
{t.rich("keyboard.modalBody", { code, link: vmsLinkAmber })}
|
||||
</Callout>
|
||||
|
||||
<figure className="my-6">
|
||||
<Image
|
||||
src="/monitor/terminal/lxc-console-modal.png"
|
||||
alt={t("lxcAlt")}
|
||||
width={1600}
|
||||
height={1200}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("lxcCaption", { em })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("search.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("search.intro", { code, strong, em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<Image
|
||||
src="/monitor/terminal/search-commands.png"
|
||||
alt={t("search.modalAlt")}
|
||||
width={1600}
|
||||
height={1200}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("search.modalCaption", { code, em })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
<strong>{t("search.aboutLabel")}</strong>{" "}
|
||||
<a
|
||||
href="https://cheat.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
cheat.sh
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>{" "}
|
||||
{t.rich("search.aboutBody", { code })}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("search.headerSource")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("search.headerWhen")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("search.headerWhat")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">
|
||||
<a
|
||||
href="https://cheat.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline inline-flex items-center gap-1 font-semibold"
|
||||
>
|
||||
cheat.sh
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>{" "}
|
||||
{t("search.onlineLabel")}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">{t("search.onlineWhen")}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich("search.onlineWhat", { green })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("search.fallbackLabel")}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t("search.fallbackWhen")}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich("search.fallbackWhat", { red })}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-6 text-gray-800 leading-relaxed text-sm">
|
||||
{t.rich("search.sendingNote", { strong })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("auth.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{authItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`auth.items.${idx}`, { code, link: authLink })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("clipboard.heading")}</h2>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{clipboardItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`clipboard.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("disconnect.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("disconnect.intro")}</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("disconnect.headerCause")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("disconnect.headerFix")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{disconnectRows.map((row, idx) => (
|
||||
<tr key={row.cause} className={idx < disconnectRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top">{row.cause}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`disconnect.rows.${idx}.fix`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="warning" title={t("warning.title")}>
|
||||
{t.rich("warning.body", { code, authLink: authLinkWarn, gatewayLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
437
web/app/[locale]/docs/monitor/dashboard/vms-lxcs/page.tsx
Normal file
437
web/app/[locale]/docs/monitor/dashboard/vms-lxcs/page.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.vmsLxcs.meta" })
|
||||
return { title: t("title"), description: t("description") }
|
||||
}
|
||||
|
||||
type LifecycleRow = { button: string; color: string; enabled: string; action: string }
|
||||
type DataRow = { section: string; endpoint: string; source: string }
|
||||
type WhereNextItem = { label: string; href: string; tailRich: string }
|
||||
|
||||
export default async function VmsLxcsTabPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.dashboard.vmsLxcs" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { dashboard: { vmsLxcs: {
|
||||
topRow: { memoryItems: string[] }
|
||||
inventory: { rows: string[] }
|
||||
drillIn: {
|
||||
liveItems: string[]
|
||||
ioItems: string[]
|
||||
resourcesItems: string[]
|
||||
mountTypesItems: string[]
|
||||
mountStateItems: string[]
|
||||
backupsItems: string[]
|
||||
updatesPanelItems: string[]
|
||||
firewallItems: string[]
|
||||
lifecycleRows: LifecycleRow[]
|
||||
}
|
||||
dataCollected: { rows: DataRow[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } } }
|
||||
}
|
||||
const v = messages.docs.monitor.dashboard.vmsLxcs
|
||||
const memoryItems = v.topRow.memoryItems
|
||||
const inventoryRows = v.inventory.rows
|
||||
const liveItems = v.drillIn.liveItems
|
||||
const ioItems = v.drillIn.ioItems
|
||||
const resourcesItems = v.drillIn.resourcesItems
|
||||
const mountTypesItems = v.drillIn.mountTypesItems
|
||||
const mountStateItems = v.drillIn.mountStateItems
|
||||
const backupsItems = v.drillIn.backupsItems
|
||||
const updatesPanelItems = v.drillIn.updatesPanelItems
|
||||
const firewallItems = v.drillIn.firewallItems
|
||||
const lifecycleRows = v.drillIn.lifecycleRows
|
||||
const dataRows = v.dataCollected.rows
|
||||
const whereNextItems = v.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const green = () => <span className="inline-block w-2 h-2 rounded-full bg-green-500 align-middle mr-1" />
|
||||
const amber = () => <span className="inline-block w-2 h-2 rounded-full bg-amber-500 align-middle mr-1" />
|
||||
const red = () => <span className="inline-block w-2 h-2 rounded-full bg-red-500 align-middle mr-1" />
|
||||
const greenText = (chunks: React.ReactNode) => <span className="text-green-600 font-semibold">{chunks}</span>
|
||||
const amberText = (chunks: React.ReactNode) => <span className="text-amber-600 font-semibold">{chunks}</span>
|
||||
const redText = (chunks: React.ReactNode) => <span className="text-red-600 font-semibold">{chunks}</span>
|
||||
const orangeText = (chunks: React.ReactNode) => <span className="text-orange-600 font-semibold">{chunks}</span>
|
||||
const link = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/dashboard/terminal" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
const buttonColorClass = (color: string) => {
|
||||
switch (color) {
|
||||
case "green":
|
||||
return "text-green-600 font-semibold"
|
||||
case "blue":
|
||||
return "text-blue-600 font-semibold"
|
||||
case "red":
|
||||
return "text-red-600 font-semibold"
|
||||
default:
|
||||
return "font-semibold"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={12}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("topRow.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("topRow.intro")}</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/vms-top-row.png"
|
||||
alt={t("topRow.imageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("topRow.imageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerCard")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("topRow.headerWhat")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.totalLabel")}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich("topRow.totalWhat", { em })}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.cpuLabel")}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich("topRow.cpuWhat", { em })}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.memoryLabel")}</strong></td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t("topRow.memoryIntro")}
|
||||
<ul className="list-disc pl-5 mt-2 space-y-0.5">
|
||||
{memoryItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`topRow.memoryItems.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{t("topRow.diskLabel")}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich("topRow.diskWhat", { em })}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("inventory.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("inventory.intro", { code })}
|
||||
</p>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/vms-inventory-mobile.png"
|
||||
alt={t("inventory.imageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full max-w-md mx-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("inventory.imageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("inventory.rowsIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{inventoryRows.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`inventory.rows.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t("inventory.clickHint")}</p>
|
||||
|
||||
<Callout variant="tip" title={t("inventory.mobileTitle")}>
|
||||
{t("inventory.mobileBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("drillIn.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.intro", { strong, em })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.statusTitle")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/vms-modal-status.png"
|
||||
alt={t("drillIn.statusImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("drillIn.statusImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.statusIntro")}</p>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.liveTitle")}</h4>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{liveItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.liveItems.${idx}`, { strong, em })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.ioTitle")}</h4>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{ioItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.ioItems.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.resourcesTitle")}</h4>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.resourcesIntro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{resourcesItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.resourcesItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.ipsTitle")}</h4>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">{t("drillIn.ipsBody")}</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.mountsTitle")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/vms-modal-mounts.png"
|
||||
alt={t("drillIn.mountsImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("drillIn.mountsImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.mountsIntro", { strong, code })}
|
||||
</p>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.mountTypesTitle")}</h4>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{mountTypesItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.mountTypesItems.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.mountStateTitle")}</h4>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{mountStateItems.map((_, idx) => (
|
||||
<li key={idx}>
|
||||
{t.rich(`drillIn.mountStateItems.${idx}`, { strong, em, code, green, amber, red })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="info" title={t("drillIn.mountsCalloutTitle")}>
|
||||
{t("drillIn.mountsCalloutBody")}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.backupsTitle")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/vms-modal-backups.png"
|
||||
alt={t("drillIn.backupsImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("drillIn.backupsImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.backupsIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{backupsItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.backupsItems.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.backupsOutro", { strong })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.updatesTitle")}</h3>
|
||||
|
||||
<figure className="my-4">
|
||||
<img
|
||||
src="/monitor/vms-modal-lxc-updates.png"
|
||||
alt={t("drillIn.updatesImageAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("drillIn.updatesImageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.updatesIntro", { strong, code })}
|
||||
</p>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.updatesPanelTitle")}</h4>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{updatesPanelItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`drillIn.updatesPanelItems.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.updatesScopeTitle")}</h4>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.updatesScopeBody", { strong, em, code })}
|
||||
</p>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.updatesToggleTitle")}</h4>
|
||||
<Callout variant="info" title={t("drillIn.updatesToggleCalloutTitle")}>
|
||||
{t.rich("drillIn.updatesToggleCalloutBody", { strong, code })}
|
||||
</Callout>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("drillIn.updatesApplyTitle")}</h4>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.updatesApplyBody", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.firewallTitle")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.firewallIntro")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{firewallItems.map((_, idx) => (
|
||||
<li key={idx}>
|
||||
{t.rich(`drillIn.firewallItems.${idx}`, {
|
||||
strong,
|
||||
em,
|
||||
code,
|
||||
green: greenText,
|
||||
orange: orangeText,
|
||||
red: redText,
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.firewallRefresh", { em, code })}
|
||||
</p>
|
||||
|
||||
<Callout variant="info" title={t("drillIn.firewallCalloutTitle")}>
|
||||
{t("drillIn.firewallCalloutBody")}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-2 text-gray-900">{t("drillIn.actionBarTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("drillIn.actionBarIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
<li>{t.rich("drillIn.consoleItem", { strong, code, link })}</li>
|
||||
</ul>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("drillIn.lifecycleIntro", { code })}
|
||||
</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("drillIn.headerButton")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("drillIn.headerEnabled")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("drillIn.headerAction")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{lifecycleRows.map((row, idx) => (
|
||||
<tr key={row.button} className={idx < lifecycleRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">
|
||||
<strong className={buttonColorClass(row.color)}>{row.button}</strong>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">{row.enabled}</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.action}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="warning" title={t("drillIn.forceStopTitle")}>
|
||||
{t.rich("drillIn.forceStopBody", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dataCollected.heading")}</h2>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSection")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dataCollected.headerSource")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dataRows.map((row, idx) => (
|
||||
<tr key={row.section} className={idx < dataRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top">{row.section}</td>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`dataCollected.rows.${idx}.source`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<CopyableCode
|
||||
code={`${t("dataCollected.codeComment1")}
|
||||
pvesh get /cluster/resources --type vm --output-format=json | jq
|
||||
|
||||
${t("dataCollected.codeComment2")}
|
||||
qm config 100 ${t("dataCollected.codeComment3")}
|
||||
pct config 100 ${t("dataCollected.codeComment4")}`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{t.rich(`whereNext.items.${idx}.tailRich`, { code })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
432
web/app/[locale]/docs/monitor/health-monitor/page.tsx
Normal file
432
web/app/[locale]/docs/monitor/health-monitor/page.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { DataFlowDiagram } from "@/components/ui/data-flow-diagram"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.healthMonitor.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox health monitor",
|
||||
"proxmox health check",
|
||||
"proxmox smart monitoring",
|
||||
"proxmox zfs monitoring",
|
||||
"proxmox alerts",
|
||||
"proxmox proactive monitoring",
|
||||
"proxmox disk monitoring",
|
||||
"proxmox memory monitor",
|
||||
"proxmox cpu monitor",
|
||||
"proxmenux health monitor",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/monitor/health-monitor" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/monitor/health-monitor",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type CategoryRow = { category: string; checks: string; events: string }
|
||||
type SeverityRow = { status: string; colour: string; meaning: string; notification: string }
|
||||
type DismissRow = { finding: string; why: string }
|
||||
type AutoresolveRow = { trigger: string; action: string }
|
||||
type ObservationsRow = { property: string; errors: string; obs: string }
|
||||
type RestRow = { endpoint: string; method: string; use: string }
|
||||
type WhereNextItem = { label: string; href: string; tail?: string; tailRich?: string }
|
||||
|
||||
export default async function HealthMonitorPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.healthMonitor" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { healthMonitor: {
|
||||
categories: { rows: CategoryRow[] }
|
||||
severity: { rows: SeverityRow[] }
|
||||
dashboardView: { items: string[] }
|
||||
dismiss: { rows: DismissRow[] }
|
||||
autoresolve: { rows: AutoresolveRow[] }
|
||||
observations: { rows: ObservationsRow[] }
|
||||
notification: { items: string[] }
|
||||
rest: { rows: RestRow[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } }
|
||||
}
|
||||
const hm = messages.docs.monitor.healthMonitor
|
||||
const categoryRows = hm.categories.rows
|
||||
const severityRows = hm.severity.rows
|
||||
const dashboardItems = hm.dashboardView.items
|
||||
const dismissRows = hm.dismiss.rows
|
||||
const autoresolveRows = hm.autoresolve.rows
|
||||
const observationsRows = hm.observations.rows
|
||||
const notificationItems = hm.notification.items
|
||||
const restRows = hm.rest.rows
|
||||
const whereNextItems = hm.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const notifLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/notifications" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const aiLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/ai-assistant" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={18}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { code, strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howItWorks.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("howItWorks.intro", { strong })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("howItWorks.scannerTitle")}</h3>
|
||||
|
||||
<DataFlowDiagram
|
||||
nodes={[
|
||||
{ variant: "source", label: t("howItWorks.scannerNodes.samplerLabel"), detail: t("howItWorks.scannerNodes.samplerDetail") },
|
||||
{ variant: "bridge", label: t("howItWorks.scannerNodes.cycleLabel"), detail: t("howItWorks.scannerNodes.cycleDetail") },
|
||||
{ variant: "bridge", label: t("howItWorks.scannerNodes.checksLabel"), detail: t("howItWorks.scannerNodes.checksDetail") },
|
||||
{ variant: "target", label: t("howItWorks.scannerNodes.sqliteLabel"), detail: t("howItWorks.scannerNodes.sqliteDetail") },
|
||||
]}
|
||||
arrowLabel={t("howItWorks.scannerArrowLabel")}
|
||||
caption={t("howItWorks.scannerCaption")}
|
||||
/>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-3 text-gray-900">{t("howItWorks.notifTitle")}</h3>
|
||||
|
||||
<DataFlowDiagram
|
||||
nodes={[
|
||||
{ variant: "source", label: t("howItWorks.notifNodes.errorsLabel"), detail: t("howItWorks.notifNodes.errorsDetail") },
|
||||
{ variant: "bridge", label: t("howItWorks.notifNodes.dispatcherLabel"), detail: t("howItWorks.notifNodes.dispatcherDetail") },
|
||||
{ variant: "bridge", label: t("howItWorks.notifNodes.templatesLabel"), detail: t("howItWorks.notifNodes.templatesDetail") },
|
||||
{ variant: "target", label: t("howItWorks.notifNodes.channelsLabel"), detail: t("howItWorks.notifNodes.channelsDetail") },
|
||||
]}
|
||||
arrowLabel={t("howItWorks.notifArrowLabel")}
|
||||
caption={t("howItWorks.notifCaption")}
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("categories.heading")}</h2>
|
||||
|
||||
<figure className="my-6">
|
||||
<Image
|
||||
src="/monitor/health-monitor.png"
|
||||
alt={t("categories.imageAlt")}
|
||||
width={1608}
|
||||
height={1752}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto mx-auto max-w-2xl"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("categories.imageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("categories.intro", { strong })}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("categories.headerCategory")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("categories.headerChecks")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("categories.headerEvents")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{categoryRows.map((row, idx) => (
|
||||
<tr key={row.category} className={idx < categoryRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.category}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`categories.rows.${idx}.checks`, { code })}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`categories.rows.${idx}.events`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("severity.heading")}</h2>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("severity.headerStatus")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("severity.headerColour")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("severity.headerMeaning")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("severity.headerNotification")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{severityRows.map((row, idx) => (
|
||||
<tr key={row.status} className={idx < severityRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.status}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{row.colour}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`severity.rows.${idx}.meaning`, { em })}</td>
|
||||
<td className="px-3 py-2 align-top">{row.notification}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("severity.infoNote", { strong })}
|
||||
</p>
|
||||
|
||||
<Callout variant="info" title={t("severity.unknownTitle")}>
|
||||
{t.rich("severity.unknownBody", { code, strong })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dashboardView.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("dashboardView.intro", { strong })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{dashboardItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`dashboardView.items.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Callout variant="tip" title={t("dashboardView.pillTitle")}>
|
||||
{t("dashboardView.pillBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 id="dismissing-alerts-and-the-suppression-duration" className="text-2xl font-semibold mt-10 mb-4 text-gray-900 scroll-mt-24">{t("dismiss.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("dismiss.intro", { em })}
|
||||
</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-2">
|
||||
<li>{t.rich("dismiss.step1", { strong, code })}</li>
|
||||
<li>
|
||||
{t.rich("dismiss.step2", { strong, code })}
|
||||
<pre className="mt-2 rounded-md bg-white border border-slate-200 p-3 overflow-x-auto text-xs font-mono text-gray-800">{`24 hours (default)
|
||||
72 hours
|
||||
168 hours (one week)
|
||||
720 hours (one month)
|
||||
8760 hours (one year)
|
||||
-1 (permanent — never re-fires)
|
||||
<custom> (any positive integer of hours)`}</pre>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<figure className="my-6">
|
||||
<Image
|
||||
src="/monitor/dismiss-duration-dropdown.png"
|
||||
alt={t("dismiss.dropdownImageAlt")}
|
||||
width={1540}
|
||||
height={1072}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t.rich("dismiss.dropdownImageCaption", { em })}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure className="my-6">
|
||||
<Image
|
||||
src="/monitor/health-suppression-settings.png"
|
||||
alt={t("dismiss.imageAlt")}
|
||||
width={2010}
|
||||
height={1816}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full h-auto"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("dismiss.imageCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("dismiss.outro", { code })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("dismiss.activeSuppressionsTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("dismiss.activeSuppressionsBody", { strong, em })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("dismiss.autoTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("dismiss.autoBody", { strong })}
|
||||
</p>
|
||||
|
||||
<Callout variant="warning" title={t("dismiss.tempTitle")}>
|
||||
{t.rich("dismiss.tempBody", { strong })}
|
||||
</Callout>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-2 text-gray-900">{t("dismiss.nonDismissableTitle")}</h3>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("dismiss.nonDismissableBody")}</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dismiss.headerFinding")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dismiss.headerWhy")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dismissRows.map((row, idx) => (
|
||||
<tr key={row.finding} className={idx < dismissRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.finding}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{row.why}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("dismiss.principle")}</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("autoresolve.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("autoresolve.intro")}</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("autoresolve.headerTrigger")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("autoresolve.headerAction")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{autoresolveRows.map((row, idx) => (
|
||||
<tr key={idx} className={idx < autoresolveRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`autoresolve.rows.${idx}.trigger`, { code })}</td>
|
||||
<td className="px-3 py-2 align-top">{row.action}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Callout variant="tip" title={t("autoresolve.permanentTitle")}>
|
||||
{t.rich("autoresolve.permanentBody", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("observations.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("observations.intro", { code, strong })}
|
||||
</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("observations.headerProperty")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t.rich("observations.headerErrors", { code })}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t.rich("observations.headerObs", { code })}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{observationsRows.map((row, idx) => (
|
||||
<tr key={row.property} className={idx < observationsRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.property}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`observations.rows.${idx}.errors`, { em, code, strong })}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`observations.rows.${idx}.obs`, { em, code, strong })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("observations.outro")}</p>
|
||||
|
||||
<Callout variant="warning" title={t("observations.renameTitle")}>
|
||||
{t.rich("observations.renameBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("notification.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("notification.intro")}</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{notificationItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`notification.items.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("notification.outro", { notifLink, aiLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("rest.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("rest.intro")}</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("rest.headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("rest.headerMethod")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("rest.headerUse")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{restRows.map((row, idx) => (
|
||||
<tr key={row.endpoint} className={idx < restRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
|
||||
<td className="px-3 py-2 align-top">{row.method}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`rest.rows.${idx}.use`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<CopyableCode
|
||||
code={`${t("rest.codeComment1")}
|
||||
curl -H "Authorization: Bearer <api-token>" \\
|
||||
http://<host>:8008/api/health/full | jq '.health.overall'
|
||||
|
||||
${t("rest.codeComment2")}
|
||||
curl -X POST http://<host>:8008/api/health/acknowledge \\
|
||||
-H "Authorization: Bearer <api-token>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"error_key":"smart_sdh"}'
|
||||
|
||||
${t("rest.codeComment3")}
|
||||
curl -X POST http://<host>:8008/api/health/settings \\
|
||||
-H "Authorization: Bearer <api-token>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"suppress_disks":"168"}'`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1164
web/app/[locale]/docs/monitor/integrations/page.tsx
Normal file
1164
web/app/[locale]/docs/monitor/integrations/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
769
web/app/[locale]/docs/monitor/notifications/page.tsx
Normal file
769
web/app/[locale]/docs/monitor/notifications/page.tsx
Normal file
@@ -0,0 +1,769 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import Image from "next/image"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import { DataFlowDiagram } from "@/components/ui/data-flow-diagram"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.notifications.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox notifications",
|
||||
"proxmox telegram",
|
||||
"proxmox discord",
|
||||
"proxmox email alerts",
|
||||
"proxmox gotify",
|
||||
"proxmox apprise",
|
||||
"proxmox ntfy",
|
||||
"proxmox matrix notifications",
|
||||
"proxmox alerts",
|
||||
"proxmox notification webhook",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/monitor/notifications" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/monitor/notifications",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type SourceRow = { collector: string; watches: string; events: string }
|
||||
type DispatchRow = { stage: string; what: string; tunable: string }
|
||||
type CatalogueRow = { group: string; events: string }
|
||||
type ApiRow = { endpoint: string; method: string; use: string }
|
||||
type WhereNextItem = { label: string; href: string; tail?: string; tailRich?: string }
|
||||
|
||||
export default async function NotificationsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.notifications" })
|
||||
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: { monitor: { notifications: {
|
||||
enabling: { steps: string[] }
|
||||
sources: { rows: SourceRow[] }
|
||||
telegram: {
|
||||
step1Items: string[]
|
||||
privateItems: string[]
|
||||
groupItems: string[]
|
||||
}
|
||||
discord: { items: string[] }
|
||||
gotify: { items: string[] }
|
||||
email: { gmailItems: string[]; outlookItems: string[] }
|
||||
apprise: { listItems: string[]; steps: string[] }
|
||||
rich: { togglesItems: string[] }
|
||||
quiet: { purposeItems: string[]; howItems: string[] }
|
||||
digest: {
|
||||
purposeItems: string[]
|
||||
howItems: string[]
|
||||
neverDelayedSub: string[]
|
||||
}
|
||||
dispatch: { rows: DispatchRow[] }
|
||||
pveWebhook: {
|
||||
registeredItems: string[]
|
||||
securityItems: string[]
|
||||
actionsItems: string[]
|
||||
}
|
||||
catalogue: { rows: CatalogueRow[] }
|
||||
api: { rows: ApiRow[] }
|
||||
whereNext: { items: WhereNextItem[] }
|
||||
} } }
|
||||
}
|
||||
const n = messages.docs.monitor.notifications
|
||||
const enablingSteps = n.enabling.steps
|
||||
const sourceRows = n.sources.rows
|
||||
const tgStep1 = n.telegram.step1Items
|
||||
const tgPriv = n.telegram.privateItems
|
||||
const tgGroup = n.telegram.groupItems
|
||||
const discordItems = n.discord.items
|
||||
const gotifyItems = n.gotify.items
|
||||
const gmailItems = n.email.gmailItems
|
||||
const outlookItems = n.email.outlookItems
|
||||
const appriseListItems = n.apprise.listItems
|
||||
const appriseSteps = n.apprise.steps
|
||||
const togglesItems = n.rich.togglesItems
|
||||
const quietPurpose = n.quiet.purposeItems
|
||||
const quietHow = n.quiet.howItems
|
||||
const digestPurpose = n.digest.purposeItems
|
||||
const digestHow = n.digest.howItems
|
||||
const digestNeverSub = n.digest.neverDelayedSub
|
||||
const dispatchRows = n.dispatch.rows
|
||||
const pveRegistered = n.pveWebhook.registeredItems
|
||||
const pveSecurity = n.pveWebhook.securityItems
|
||||
const pveActions = n.pveWebhook.actionsItems
|
||||
const catalogueRows = n.catalogue.rows
|
||||
const apiRows = n.api.rows
|
||||
const whereNextItems = n.whereNext.items
|
||||
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
const hmLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/health-monitor" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const pveLink = (chunks: React.ReactNode) => (
|
||||
<a href="#pve-webhook-integration" className="text-blue-600 hover:underline">{chunks}</a>
|
||||
)
|
||||
const aiLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/ai-assistant#what-context-the-ai-receives" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const aiPageLink = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor/ai-assistant" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const catalogueLink = (chunks: React.ReactNode) => (
|
||||
<Link href="#event-catalogue" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const quietLink = (chunks: React.ReactNode) => (
|
||||
<Link href="#quiet-hours" className="text-blue-600 hover:underline">{chunks}</Link>
|
||||
)
|
||||
const ext = (href: string) => (chunks: React.ReactNode) =>
|
||||
(
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline inline-flex items-center gap-1">
|
||||
{chunks}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={18}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("intro.title")}>
|
||||
{t.rich("intro.body", { link: hmLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howItWorks.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("howItWorks.intro")}</p>
|
||||
|
||||
<DataFlowDiagram
|
||||
nodes={[
|
||||
{ variant: "source", label: t("howItWorks.nodes.sourcesLabel"), detail: t("howItWorks.nodes.sourcesDetail") },
|
||||
{ variant: "bridge", label: t("howItWorks.nodes.dispatchLabel"), detail: t("howItWorks.nodes.dispatchDetail") },
|
||||
{ variant: "bridge", label: t("howItWorks.nodes.aiLabel"), detail: t("howItWorks.nodes.aiDetail") },
|
||||
{ variant: "target", label: t("howItWorks.nodes.channelsLabel"), detail: t("howItWorks.nodes.channelsDetail") },
|
||||
]}
|
||||
arrowLabel={t("howItWorks.arrowLabel")}
|
||||
caption={t("howItWorks.caption")}
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("enabling.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("enabling.intro", { em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/notifications-disabled.png" alt={t("enabling.disabledAlt")} width={2000} height={552} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("enabling.disabledCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("enabling.stepsIntro")}</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{enablingSteps.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`enabling.steps.${idx}`, { em, code, pvelink: pveLink })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/notifications-active.png" alt={t("enabling.activeAlt")} width={2000} height={1142} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("enabling.activeCaption", { em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("sources.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("sources.intro", { code })}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("sources.headerCollector")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("sources.headerWatches")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("sources.headerEvents")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{sourceRows.map((row, idx) => (
|
||||
<tr key={row.collector} className={idx < sourceRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.collector}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`sources.rows.${idx}.watches`, { code, pvelink: pveLink })}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`sources.rows.${idx}.events`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("sources.after1", { code })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("sources.after2", { code, ailink: aiLink })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("channels.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("channels.intro", { em })}
|
||||
</p>
|
||||
|
||||
<Callout variant="warning" title={t("channels.credsTitle")}>
|
||||
{t.rich("channels.credsBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<h3 id="telegram" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("telegram.heading")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("telegram.intro", { strong, em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/telegram-setup-guide.png" alt={t("telegram.guideAlt")} width={1923} height={2000} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("telegram.guideCaption", { em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("telegram.step1Title")}</h4>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{tgStep1.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`telegram.step1Items.${idx}`, { em, code, a: ext("https://t.me/BotFather") })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("telegram.step2Title")}</h4>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("telegram.step2Intro", { em })}
|
||||
</p>
|
||||
|
||||
<p className="mb-2 text-gray-800 leading-relaxed">
|
||||
<strong>{t("telegram.privateLabel")}</strong>
|
||||
</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{tgPriv.map((_, idx) => (
|
||||
<li key={idx}>
|
||||
{t.rich(`telegram.privateItems.${idx}`, {
|
||||
code,
|
||||
a1: ext("https://t.me/userinfobot"),
|
||||
a2: ext("https://t.me/myidbot"),
|
||||
a: ext("https://t.me/userinfobot"),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/telegram-private-chat.png" alt={t("telegram.privateAlt")} width={2000} height={768} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("telegram.privateCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-2 text-gray-800 leading-relaxed">
|
||||
<strong>{t("telegram.groupLabel")}</strong>
|
||||
</p>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{tgGroup.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`telegram.groupItems.${idx}`, { code, em })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/telegram-group-chat.png" alt={t("telegram.groupAlt")} width={2000} height={768} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("telegram.groupCaption", { code, em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("telegram.step3Title")}</h4>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("telegram.step3Body", { em })}
|
||||
</p>
|
||||
|
||||
<h3 id="discord" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("discord.heading")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("discord.intro", { em })}
|
||||
</p>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{discordItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`discord.items.${idx}`, { em, code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/discord-channel.png" alt={t("discord.imageAlt")} width={2000} height={435} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("discord.imageCaption", { em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 id="gotify" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("gotify.heading")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("gotify.intro", { em })}
|
||||
</p>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{gotifyItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`gotify.items.${idx}`, { em, code, a: ext("https://gotify.net/docs/install") })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/gotify-channel.png" alt={t("gotify.imageAlt")} width={2000} height={573} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("gotify.imageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 id="email" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("email.heading")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("email.intro")}</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/email-channel.png" alt={t("email.imageAlt")} width={2000} height={1248} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("email.imageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("email.appNote", { strong })}
|
||||
</p>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("email.gmailTitle")}</h4>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("email.gmailIntro", { strong, em })}
|
||||
</p>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{gmailItems.map((_, idx) => (
|
||||
<li key={idx}>
|
||||
{t.rich(`email.gmailItems.${idx}`, {
|
||||
em,
|
||||
code,
|
||||
a:
|
||||
idx === 0
|
||||
? ext("https://myaccount.google.com/security")
|
||||
: ext("https://myaccount.google.com/apppasswords"),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("email.outlookTitle")}</h4>
|
||||
|
||||
<p className="mb-3 text-gray-800 leading-relaxed">
|
||||
{t.rich("email.outlookIntro", { strong })}
|
||||
</p>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{outlookItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`email.outlookItems.${idx}`, { em, code, a: ext("https://account.microsoft.com/security") })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<Callout variant="tip" title={t("email.relayTitle")}>
|
||||
{t("email.relayBody")}
|
||||
</Callout>
|
||||
|
||||
<h3 id="apprise" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("apprise.heading")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("apprise.intro")}</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("apprise.listIntro")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{appriseListItems.map((_, idx) => (
|
||||
<li key={idx}>
|
||||
{t.rich(`apprise.listItems.${idx}`, {
|
||||
a: idx === 0 ? ext("https://github.com/caronc/apprise/wiki") : ext("https://github.com/caronc/apprise/wiki/URLBasics"),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h4 className="text-base font-semibold mt-6 mb-2 text-gray-900">{t("apprise.stepsTitle")}</h4>
|
||||
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{appriseSteps.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`apprise.steps.${idx}`, { em, code, a: ext("https://github.com/caronc/apprise/wiki") })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<Callout variant="info" title={t("apprise.deliveredTitle")}>
|
||||
{t("apprise.deliveredBody")}
|
||||
</Callout>
|
||||
|
||||
<Callout variant="tip" title={t("apprise.fanoutTitle")}>
|
||||
{t.rich("apprise.fanoutBody", { a: ext("https://github.com/caronc/apprise-api") })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("rich.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("rich.intro", { em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/notification-categories.png" alt={t("rich.imageAlt")} width={2000} height={1668} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t.rich("rich.imageCaption", { em })}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 id="rich-messages" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("rich.richTitle")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("rich.richIntro", { em })}
|
||||
</p>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4 mb-6 not-prose">
|
||||
<div className="rounded-lg border-2 border-gray-200 bg-gray-50 p-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-700 mb-2">
|
||||
{t("rich.plainHeader")}
|
||||
</div>
|
||||
<pre className="text-sm font-mono text-gray-800 whitespace-pre-wrap leading-relaxed m-0">
|
||||
{`[INFO] vm_start
|
||||
VM 101 (homeassistant) started
|
||||
on node pve-01
|
||||
host: home-lab`}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="rounded-lg border-2 border-blue-300 bg-blue-50 p-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-blue-800 mb-2">
|
||||
{t("rich.richHeader")}
|
||||
</div>
|
||||
<pre className="text-sm font-mono text-gray-800 whitespace-pre-wrap leading-relaxed m-0">
|
||||
{`🟢 VM started
|
||||
VM 101 (homeassistant) is now
|
||||
running on node pve-01
|
||||
🏠 home-lab`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("rich.richOutro")}</p>
|
||||
|
||||
<h3 id="event-toggles" className="text-xl font-semibold mt-8 mb-3 text-gray-900">{t("rich.togglesTitle")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("rich.togglesIntro")}</p>
|
||||
|
||||
<ol className="list-decimal pl-6 text-gray-800 leading-relaxed space-y-2 mb-4">
|
||||
{togglesItems.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`rich.togglesItems.${idx}`, { strong, em, code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("rich.togglesOutro", { em, code })}
|
||||
</p>
|
||||
|
||||
<h2 id="quiet-hours" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("quiet.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("quiet.intro", { strong })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/quiet-hours-and-digest-config.png" alt={t("quiet.imageAlt")} width={1600} height={1200} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("quiet.imageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("quiet.purposeTitle")}</h3>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{quietPurpose.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`quiet.purposeItems.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("quiet.howTitle")}</h3>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{quietHow.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`quiet.howItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<Callout variant="info" title={t("quiet.criticalTitle")}>
|
||||
{t.rich("quiet.criticalBody", { link: catalogueLink })}
|
||||
</Callout>
|
||||
|
||||
<h2 id="daily-digest" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("digest.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("digest.intro1", { strong })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("digest.intro2", { link: quietLink })}
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("digest.purposeTitle")}</h3>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{digestPurpose.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`digest.purposeItems.${idx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-6 mb-3 text-gray-900">{t("digest.howTitle")}</h3>
|
||||
<ol className="list-decimal pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{digestHow.map((_, idx) => (
|
||||
<li key={idx}>
|
||||
{t.rich(`digest.howItems.${idx}`, { strong, em, code })}
|
||||
{idx === 3 && (
|
||||
<ul className="list-disc pl-6 mt-1">
|
||||
{digestNeverSub.map((_, sIdx) => (
|
||||
<li key={sIdx}>{t.rich(`digest.neverDelayedSub.${sIdx}`, { strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<Callout variant="tip" title={t("digest.comboTitle")}>
|
||||
{t.rich("digest.comboBody", { em })}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("displayName.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("displayName.intro", { em, code })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/display-name.png" alt={t("displayName.imageAlt")} width={2000} height={256} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("displayName.imageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("displayName.outro", { em, code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("dispatch.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("dispatch.intro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dispatch.headerStage")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dispatch.headerWhat")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("dispatch.headerTunable")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{dispatchRows.map((row, idx) => (
|
||||
<tr key={row.stage} className={idx < dispatchRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.stage}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`dispatch.rows.${idx}.what`, { code })}</td>
|
||||
<td className="px-3 py-2 align-top">{row.tunable}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Callout variant="info" title={t("dispatch.calloutTitle")}>
|
||||
{t("dispatch.calloutBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("aiRewrite.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("aiRewrite.body1")}</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("aiRewrite.body2", { code, link: aiPageLink })}
|
||||
</p>
|
||||
|
||||
<Callout variant="warning" title={t("aiRewrite.privacyTitle")}>
|
||||
{t("aiRewrite.privacyBody")}
|
||||
</Callout>
|
||||
|
||||
<h2 id="pve-webhook-integration" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("pveWebhook.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("pveWebhook.intro1", { em, code })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("pveWebhook.intro2", { em })}
|
||||
</p>
|
||||
|
||||
<figure className="my-4">
|
||||
<Image src="/monitor/settings/pve-webhook-target.png" alt={t("pveWebhook.imageAlt")} width={1452} height={1360} className="rounded-lg border border-gray-200 shadow-sm w-full h-auto mx-auto" />
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">{t("pveWebhook.imageCaption")}</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("pveWebhook.registeredIntro")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{pveRegistered.map((_, idx) => (
|
||||
<li key={idx}>
|
||||
{t.rich(`pveWebhook.registeredItems.${idx}`, { strong, em, code })}
|
||||
{idx === 1 && (
|
||||
<code className="block mt-2 bg-gray-50 border border-gray-200 rounded p-2 text-xs whitespace-pre-wrap">{`{ "title": "{{ escape title }}",
|
||||
"message": "{{ escape message }}",
|
||||
"severity": "{{ severity }}",
|
||||
"timestamp": "{{ timestamp }}",
|
||||
"fields": {{ json fields }} }`}</code>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{t("pveWebhook.securityTitle")}</h3>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("pveWebhook.securityIntro", { code })}
|
||||
</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{pveSecurity.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`pveWebhook.securityItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="info" title={t("pveWebhook.practiceTitle")}>
|
||||
{t.rich("pveWebhook.practiceBody", { code })}
|
||||
</Callout>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("pveWebhook.actionsIntro")}</p>
|
||||
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{pveActions.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`pveWebhook.actionsItems.${idx}`, { strong, code })}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Callout variant="info" title={t("pveWebhook.clusterTitle")}>
|
||||
{t.rich("pveWebhook.clusterBody", { code, em })}
|
||||
</Callout>
|
||||
|
||||
<h2 id="event-catalogue" className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("catalogue.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("catalogue.intro")}</p>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("catalogue.headerGroup")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("catalogue.headerEvents")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{catalogueRows.map((row, idx) => (
|
||||
<tr key={row.group} className={idx < catalogueRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap"><strong>{row.group}</strong></td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`catalogue.rows.${idx}.events`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("catalogue.burstNote", { code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("history.heading")}</h2>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("history.body1", { em, code })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("history.body2", { em })}
|
||||
</p>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("history.body3", { code })}
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("api.heading")}</h2>
|
||||
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("api.headerEndpoint")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("api.headerMethod")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("api.headerUse")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{apiRows.map((row, idx) => (
|
||||
<tr key={row.endpoint} className={idx < apiRows.length - 1 ? "border-b border-gray-100" : ""}>
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.endpoint}</td>
|
||||
<td className="px-3 py-2 align-top">{row.method}</td>
|
||||
<td className="px-3 py-2 align-top">{t.rich(`api.rows.${idx}.use`, { code })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<CopyableCode
|
||||
code={`# Send a test notification to Discord
|
||||
curl -X POST http://<host>:8008/api/notifications/test \\
|
||||
-H "Authorization: Bearer <api-token>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"channel":"discord"}'
|
||||
|
||||
# Emit a custom event from a script
|
||||
curl -X POST http://<host>:8008/api/notifications/send \\
|
||||
-H "Authorization: Bearer <api-token>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"event_type":"custom","severity":"warning","data":{"message":"Cron job took >10 min"}}'
|
||||
|
||||
# Pull the last 50 history entries for one channel
|
||||
curl -H "Authorization: Bearer <api-token>" \\
|
||||
'http://<host>:8008/api/notifications/history?channel=telegram&limit=50' | jq
|
||||
|
||||
# Test an AI provider connection (verifies the API key and model)
|
||||
curl -X POST http://<host>:8008/api/notifications/test-ai \\
|
||||
-H "Authorization: Bearer <api-token>" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"provider":"openai","api_key":"sk-...","model":"gpt-4o-mini"}'`}
|
||||
className="my-4"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("whereNext.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{whereNextItems.map((item, idx) => (
|
||||
<li key={item.href}>
|
||||
<Link href={item.href} className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.tailRich ? t.rich(`whereNext.items.${idx}.tailRich`, { code }) : item.tail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
322
web/app/[locale]/docs/monitor/page.tsx
Normal file
322
web/app/[locale]/docs/monitor/page.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import type { Metadata } from "next"
|
||||
import { getTranslations, getMessages, setRequestLocale } from "next-intl/server"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
import { DocHeader } from "@/components/ui/doc-header"
|
||||
import { Callout } from "@/components/ui/callout"
|
||||
import CopyableCode from "@/components/CopyableCode"
|
||||
import { routing } from "@/i18n/routing"
|
||||
|
||||
/**
|
||||
* Pilot page for the i18n migration. Pattern used here is the same one
|
||||
* contributors should follow for every other docs page:
|
||||
*
|
||||
* - Translatable strings live under `messages/<locale>/docs/<section>/<page>.json`
|
||||
* (or `index.json` when the file represents the section's index page,
|
||||
* like this one).
|
||||
* - The page is an async Server Component that calls
|
||||
* `getTranslations({ namespace: '<namespace>' })` once and uses
|
||||
* `t()` for plain text and `t.rich()` for paragraphs containing
|
||||
* <code>, <strong>, <em> or <link> markers.
|
||||
* - Arrays of structured items (table rows, lists, etc.) are pulled
|
||||
* with `getMessages({ locale })` and iterated; that keeps the JSON readable
|
||||
* for translators.
|
||||
* - generateMetadata uses the same namespace so <title> and OG tags
|
||||
* translate with the locale.
|
||||
*/
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }))
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor.meta" })
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
keywords: [
|
||||
"proxmox monitor",
|
||||
"proxmox dashboard",
|
||||
"proxmox ve dashboard",
|
||||
"proxmox web dashboard",
|
||||
"proxmox notifications",
|
||||
"proxmox health monitor",
|
||||
"proxmox smart monitoring",
|
||||
"proxmox prometheus",
|
||||
"proxmox homepage integration",
|
||||
"proxmenux monitor",
|
||||
],
|
||||
alternates: { canonical: "https://proxmenux.com/docs/monitor" },
|
||||
openGraph: {
|
||||
title: t("ogTitle"),
|
||||
description: t("ogDescription"),
|
||||
type: "article",
|
||||
url: "https://proxmenux.com/docs/monitor",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: t("twitterTitle"),
|
||||
description: t("twitterDescription"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type CoverageSection = { name: string; description: string }
|
||||
type NextStepItem = { label: string; description: string }
|
||||
|
||||
export default async function MonitorOverviewPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
|
||||
const t = await getTranslations({ locale, namespace: "docs.monitor" })
|
||||
|
||||
// Arrays of objects can't be expressed via t(), so pull them straight
|
||||
// from the message tree. This is the recommended pattern in the
|
||||
// next-intl docs for repeating structured items like table rows.
|
||||
const messages = (await getMessages({ locale })) as unknown as {
|
||||
docs: {
|
||||
monitor: {
|
||||
coverage: { sections: CoverageSection[] }
|
||||
nextSteps: { items: NextStepItem[] }
|
||||
howItRuns: { bullets: string[] }
|
||||
}
|
||||
}
|
||||
}
|
||||
const coverageSections = messages.docs.monitor.coverage.sections
|
||||
const nextStepsItems = messages.docs.monitor.nextSteps.items
|
||||
const howItRunsBullets = messages.docs.monitor.howItRuns.bullets
|
||||
|
||||
// Inline tag renderers shared across every t.rich() call on this page.
|
||||
const code = (chunks: React.ReactNode) => <code>{chunks}</code>
|
||||
const strong = (chunks: React.ReactNode) => <strong>{chunks}</strong>
|
||||
const em = (chunks: React.ReactNode) => <em>{chunks}</em>
|
||||
// <link>...</link> defaults to "/docs/monitor" — the section index.
|
||||
// Individual t.rich() calls override this to point elsewhere when
|
||||
// the source string demands it (api section, etc.).
|
||||
const link = (chunks: React.ReactNode) => (
|
||||
<Link href="/docs/monitor" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DocHeader
|
||||
title={t("header.title")}
|
||||
description={t("header.description")}
|
||||
section={t("header.section")}
|
||||
estimatedMinutes={6}
|
||||
/>
|
||||
|
||||
<Callout variant="info" title={t("atGlance.title")}>
|
||||
{t.rich("atGlance.body", { code })}
|
||||
</Callout>
|
||||
|
||||
{/* Hero screenshot */}
|
||||
<figure className="my-8">
|
||||
<img
|
||||
src="/monitor/dashboard-home.png"
|
||||
alt={t("hero.alt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("hero.caption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
{/* ─────────────────────────── What it covers ─────────────────────────── */}
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("coverage.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("coverage.intro")}</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-sm border border-gray-200 rounded-md">
|
||||
<thead className="bg-gray-50 text-gray-900">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("coverage.tableSection")}</th>
|
||||
<th className="text-left px-3 py-2 border-b border-gray-200">{t("coverage.tableWhat")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-800">
|
||||
{coverageSections.map((row, idx) => (
|
||||
<tr
|
||||
key={row.name}
|
||||
className={idx < coverageSections.length - 1 ? "border-b border-gray-100" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 align-top whitespace-nowrap">
|
||||
<strong>{row.name}</strong>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{t.rich(`coverage.sections.${idx}.description`, { code })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("coverage.footer", { link })}
|
||||
</p>
|
||||
|
||||
{/* ─────────────────────────── How it runs ─────────────────────────── */}
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("howItRuns.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("howItRuns.intro", { code })}</p>
|
||||
<ul className="list-disc pl-6 mb-4 text-gray-800 leading-relaxed space-y-1">
|
||||
{howItRunsBullets.map((_, idx) => (
|
||||
<li key={idx}>{t.rich(`howItRuns.bullets.${idx}`, { code, strong })}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("howItRuns.footer", { link })}
|
||||
</p>
|
||||
|
||||
<Callout variant="tip" title={t("noAgent.title")}>
|
||||
{t("noAgent.body")}
|
||||
</Callout>
|
||||
|
||||
{/* ─────────────────────────── Access ─────────────────────────── */}
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("access.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("access.intro")}</p>
|
||||
<CopyableCode
|
||||
code={`${t("access.codeComment1")}
|
||||
http://<your-proxmox-ip>:8008
|
||||
|
||||
${t("access.codeComment2")}
|
||||
https://<your-domain>/proxmenux-monitor/`}
|
||||
className="my-4"
|
||||
/>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("access.afterCode", { code })}
|
||||
</p>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("access.footer", { link })}
|
||||
</p>
|
||||
|
||||
{/* ─────────────────────────── Mobile / PWA ─────────────────────────── */}
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("mobile.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t.rich("mobile.intro", { code })}</p>
|
||||
<div className="grid md:grid-cols-2 gap-6 my-6 items-start">
|
||||
<figure>
|
||||
<img
|
||||
src="/monitor/mobile-home.png"
|
||||
alt={t("mobile.phoneAlt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("mobile.phoneCaption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2 text-gray-900">{t("mobile.addHeading")}</h3>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
<li>
|
||||
<strong>{t("mobile.iosLabel")}</strong> {t.rich("mobile.iosBody", { code, em })}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{t("mobile.androidLabel")}</strong>{" "}
|
||||
{t.rich("mobile.androidBody", { em })}
|
||||
</li>
|
||||
<li>{t("mobile.afterInstall")}</li>
|
||||
</ul>
|
||||
<Callout variant="warning" title={t("mobile.onlineOnlyTitle")} className="mt-4">
|
||||
{t.rich("mobile.onlineOnlyBody", { strong })}
|
||||
</Callout>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─────────────────────────── Health Monitor ─────────────────────────── */}
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("health.heading")}</h2>
|
||||
|
||||
<figure className="my-6">
|
||||
<img
|
||||
src="/monitor/health-monitor.png"
|
||||
alt={t("health.alt")}
|
||||
className="rounded-lg border border-gray-200 shadow-sm w-full"
|
||||
/>
|
||||
<figcaption className="text-sm text-gray-500 mt-2 text-center italic">
|
||||
{t("health.caption")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("health.body1", { code, strong })}
|
||||
</p>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">{t("health.feedsIntro")}</p>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
<li>{t.rich("health.feedsHealth", { strong })}</li>
|
||||
<li>{t.rich("health.feedsChannels", { strong })}</li>
|
||||
<li>{t.rich("health.feedsAI", { strong })}</li>
|
||||
</ul>
|
||||
<Callout variant="tip" title={t("health.suppressionTitle")}>
|
||||
{t.rich("health.suppressionBody", { em })}
|
||||
</Callout>
|
||||
|
||||
{/* ─────────────────────────── API & integrations ─────────────────────────── */}
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("api.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("api.intro", { code })}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-6 text-gray-800 leading-relaxed space-y-1">
|
||||
<li>{t.rich("api.tokens", { code, strong })}</li>
|
||||
<li>{t.rich("api.bearer", { code })}</li>
|
||||
<li>
|
||||
{t.rich("api.catalog", {
|
||||
linkApi: (chunks) => (
|
||||
<Link href="/docs/monitor" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
),
|
||||
linkIntegrations: (chunks) => (
|
||||
<Link href="/docs/monitor" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
),
|
||||
})}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* ─────────────────────────── Service control ─────────────────────────── */}
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("serviceControl.heading")}</h2>
|
||||
<p className="mb-4 text-gray-800 leading-relaxed">
|
||||
{t.rich("serviceControl.intro", { em })}
|
||||
</p>
|
||||
<CopyableCode
|
||||
code={`${t("serviceControl.codeComment")}
|
||||
systemctl status proxmenux-monitor.service
|
||||
systemctl is-active proxmenux-monitor.service
|
||||
systemctl enable --now proxmenux-monitor.service
|
||||
systemctl disable --now proxmenux-monitor.service
|
||||
journalctl -u proxmenux-monitor.service -f`}
|
||||
className="my-4"
|
||||
/>
|
||||
<p className="mb-6 text-gray-800 leading-relaxed">
|
||||
{t.rich("serviceControl.footer", {
|
||||
link: (chunks) => (
|
||||
<Link href="/docs/settings/proxmenux-monitor" className="text-blue-600 hover:underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
|
||||
{/* ─────────────────────────── Where to next ─────────────────────────── */}
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("nextSteps.heading")}</h2>
|
||||
<ul className="list-disc pl-6 text-gray-800 leading-relaxed space-y-1">
|
||||
{nextStepsItems.map((item) => (
|
||||
<li key={item.label}>
|
||||
<Link href="/docs/monitor" className="text-blue-600 hover:underline">
|
||||
{item.label}
|
||||
</Link>{" "}
|
||||
{item.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user