Focus the Audit & Report tab, scope API tokens

This commit is contained in:
MacRimi
2026-09-12 00:22:14 +02:00
parent f12ca3ed27
commit 0b64549230
25 changed files with 683 additions and 146 deletions
+8 -8
View File
@@ -151,7 +151,7 @@ export function AuditComparison({ runId, isBaseline, onBaselineSet }: {
{error && <p className="text-xs text-red-400">{error}</p>}
{!comparable ? (
<p className="text-xs text-muted-foreground">
<p className="text-sm text-muted-foreground">
{baseline ? t("audit.comparison.isBaseline") : t("audit.comparison.noBaseline")}
</p>
) : (
@@ -165,8 +165,8 @@ export function AuditComparison({ runId, isBaseline, onBaselineSet }: {
>
{open ? <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
<TrendingUp className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
<TrendingUp className="h-4 w-4 shrink-0 text-blue-500" />
<span className="text-sm text-muted-foreground">
{t("audit.comparison.since", {
date: baseline?.started_at
? new Date(baseline.started_at * 1000).toLocaleDateString(language)
@@ -174,12 +174,12 @@ export function AuditComparison({ runId, isBaseline, onBaselineSet }: {
})}
</span>
{counts.length === 0 ? (
<Badge variant="outline" className="text-xs">
<Badge variant="outline" className="text-sm">
{t("audit.comparison.noChange")}
</Badge>
) : counts.map(({ key, Icon, tone, n }) => (
<Badge key={key} variant="outline" className={`text-xs gap-1.5 ${tone}`}>
<Icon className="h-3 w-3" />
<Badge key={key} variant="outline" className={`text-sm gap-1.5 ${tone}`}>
<Icon className="h-3.5 w-3.5" />
{t(`audit.comparison.${key}`)}
<span className="tabular-nums font-semibold">{n}</span>
</Badge>
@@ -191,7 +191,7 @@ export function AuditComparison({ runId, isBaseline, onBaselineSet }: {
{GROUPS.filter((g) => (comparison[g.key] || []).length > 0).map(
({ key, Icon, tone }) => (
<div key={key}>
<p className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${tone}`}>
<p className={`flex items-center gap-1.5 text-sm font-medium mb-1 ${tone}`}>
<Icon className="h-3.5 w-3.5" />
{t(`audit.comparison.${key}`)}
<span className="font-normal text-muted-foreground">
@@ -209,7 +209,7 @@ export function AuditComparison({ runId, isBaseline, onBaselineSet }: {
),
)}
{(comparison.unchanged || []).length > 0 && (
<p className="text-xs text-muted-foreground">
<p className="text-sm text-muted-foreground">
{t("audit.comparison.unchanged", {
count: String(comparison.unchanged.length),
})}
+64 -31
View File
@@ -15,7 +15,6 @@ import {
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
import { AuditInventory } from "./audit-inventory"
import { AuditPolicy } from "./audit-policy"
import { AuditChanges } from "./audit-changes"
import { AuditComparison } from "./audit-comparison"
@@ -106,7 +105,7 @@ export function AuditReport() {
const { language } = useI18n()
// Assessment and inventory answer different questions and are
// read differently: one is triaged, the other is read through.
const [view, setView] = useState<"assessment" | "inventory" | "changes" | "policy">("assessment")
const [view, setView] = useState<"assessment" | "changes" | "policy">("assessment")
const [running, setRunning] = useState(false)
const [latest, setLatest] = useState<Run | null>(null)
const [findings, setFindings] = useState<Finding[]>([])
@@ -123,7 +122,10 @@ export function AuditReport() {
// The profile decides which question the page answers, so it governs
// both what an assessment runs and what the inventory documents.
const [profile, setProfile] = useState("full")
const [profiles, setProfiles] = useState<Array<{ id: string; runs_checks: boolean }>>([])
const [profiles, setProfiles] = useState<Array<{ id: string; runs_checks: boolean; areas: string[] | null; include: string[] }>>([])
// Set when a Lynis-bearing assessment is about to run and the stored
// report is missing or stale: the user decides whether to run Lynis now.
const [lynisPrompt, setLynisPrompt] = useState<null | { ageDays: number | null; stale: boolean }>(null)
const [building, setBuilding] = useState(false)
const loadRun = useCallback(async (runId: string) => {
@@ -183,12 +185,13 @@ export function AuditReport() {
return () => clearInterval(id)
}, [running, refresh])
const startRun = async () => {
const doRun = async (runLynis: boolean) => {
setLynisPrompt(null)
setError(null)
try {
const data: any = await fetchApi("/api/audit/run", {
method: "POST",
body: JSON.stringify({ profile }),
body: JSON.stringify({ profile, run_lynis: runLynis }),
})
if (data?.success) setRunning(true)
else setError(data?.message || t("audit.errors.runFailed"))
@@ -197,6 +200,28 @@ export function AuditReport() {
}
}
// A profile that includes the Lynis check asks the user before running,
// since producing a fresh Lynis report takes a few minutes. When a
// recent report already exists — or Lynis is not installed — the run
// starts straight away and reuses it.
const startRun = async () => {
setError(null)
const spec = profiles.find((p) => p.id === profile)
const runsLynis = !!spec && (spec.areas === null || spec.areas.includes("security"))
if (runsLynis) {
try {
const r: any = await fetchApi("/api/audit/lynis-readiness")
if (r?.success && r.installed && (!r.has_report || r.stale)) {
setLynisPrompt({ ageDays: r.age_days ?? null, stale: !!r.stale })
return
}
} catch {
/* Readiness is advisory; on failure run without Lynis rather than block. */
}
}
doRun(false)
}
// Accepting or revoking changes which findings are active, so the run
// is re-read afterwards rather than patched in place: the stored
// finding is what the next report will show.
@@ -308,10 +333,18 @@ export function AuditReport() {
try {
const inv: any = await fetchApi(
`/api/audit/inventory?profile=${encodeURIComponent(profile)}`)
// A focused report shows only the checks its profile runs, even
// when the findings on screen came from a full assessment: the
// report is scoped to its question, not to whichever run produced
// the data. `areas: null` (full, diagnostic) keeps everything.
const spec = profiles.find((p) => p.id === profile)
const scopedFindings = !spec || spec.areas === null
? findings
: findings.filter((f) => spec.areas!.includes(f.area) || spec.include.includes(f.check_id))
openAuditDocument({
profile,
run: latest,
findings,
findings: scopedFindings,
inventory: inv?.success ? inv.inventory : null,
t,
locale: language,
@@ -371,7 +404,7 @@ export function AuditReport() {
className="flex w-full rounded-lg border border-border bg-muted/40 p-1 gap-1
sm:inline-flex sm:w-auto"
>
{(["assessment", "inventory", "changes", "policy"] as const).map((key) => (
{(["assessment", "changes", "policy"] as const).map((key) => (
<button
key={key}
type="button"
@@ -427,30 +460,6 @@ export function AuditReport() {
)
}
if (view === "inventory") {
return (
<div className="space-y-4">
{/* A row that cannot wrap has nowhere to put the controls but
beside the title, which then squeezes into two lines. Title
and controls are separate rows until there is width for both. */}
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-6 w-6 shrink-0 text-foreground" />
<h2 className="text-xl lg:text-2xl font-bold text-foreground">{t("audit.title")}</h2>
</div>
<div className="flex flex-col gap-2 sm:ml-auto sm:flex-row sm:flex-wrap
sm:items-center sm:gap-3">
<div className="flex items-center gap-2 sm:contents">
{profilePicker}{documentButton}
</div>
{viewTabs}
</div>
</div>
<AuditInventory profile={profile} />
</div>
)
}
return (
<div className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
@@ -742,6 +751,30 @@ export function AuditReport() {
})}
</div>
<Dialog open={lynisPrompt !== null} onOpenChange={(o) => !o && setLynisPrompt(null)}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("audit.lynis.title")}</DialogTitle>
<DialogDescription>
{lynisPrompt?.stale
? t("audit.lynis.bodyStale", { days: String(lynisPrompt?.ageDays ?? "") })
: t("audit.lynis.bodyNotRun")}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex-col gap-2 sm:flex-row">
<Button variant="outline" onClick={() => setLynisPrompt(null)}>
{t("audit.lynis.cancel")}
</Button>
<Button variant="outline" onClick={() => doRun(false)}>
{t("audit.lynis.withoutLynis")}
</Button>
<Button onClick={() => doRun(true)}>
{t("audit.lynis.withLynis")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={accepting !== null} onOpenChange={(o) => !o && setAccepting(null)}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
+8 -3
View File
@@ -363,10 +363,12 @@ const generateLatencyReport = (report: ReportData, t: TFunction) => {
.top-bar-title { font-weight: 600; }
.top-bar-subtitle { font-size: 11px; color: #94a3b8; display: none; }
.top-bar button {
background: #06b6d4; color: #fff; border: none; padding: 10px 20px; border-radius: 6px;
font-size: 14px; font-weight: 600; cursor: pointer;
background: #06b6d4; color: #fff; border: none; padding: 8px 12px; border-radius: 6px;
font-size: 14px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
}
.top-bar button:hover { background: #0891b2; }
.top-bar .btn-group { display: flex; gap: 8px; }
.top-bar button svg { width: 18px; height: 18px; display: block; }
@media (min-width: 640px) {
.top-bar { padding: 12px 24px; }
.top-bar-subtitle { display: block; }
@@ -532,7 +534,10 @@ const generateLatencyReport = (report: ReportData, t: TFunction) => {
<div class="top-bar-subtitle">${t("network.latency.report.topBarSubtitle")}</div>
</div>
</div>
<button onclick="window.print()">${t("network.latency.report.printSavePdf")}</button>
<div class="btn-group">
<button onclick="window.print()" title="Print" aria-label="Print"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg></button>
<button onclick="window.print()" title="Save as PDF" aria-label="Save as PDF"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg></button>
</div>
</div>
<!-- Header -->
+20 -20
View File
@@ -305,34 +305,34 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
// that haven't been curated by hand.
const CURRENT_VERSION_FEATURES = [
{
icon: <Sparkles className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.aiCustomEndpoint",
text: "AI Assistant custom OpenAI endpoint — LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute and any self-hosted proxy on private IPs, loopback or Docker networks are recognised when loading the model catalogue. The dropdown surfaces the server's error (or the underlying network reason) directly under the Load button (#325, reported by @jorgeffonte).",
icon: <Activity className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.auditAssessment",
text: "Audit & Report — a new page that documents and assesses the node. The Assessment runs a catalogue of checks across security, backups, capacity, storage, network, hardware, guests and system, classifying each finding as critical, warning, observation or conformant and stating what it read rather than judging it.",
},
{
icon: <Wrench className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.secureGatewayArch",
text: "Secure Gateway wizard — the Alpine template download, local template selection and pct create all match the host's real architecture, so x86_64 hosts receive amd64 containers and arm64 hosts receive arm64 containers (#324, reported by @N0X4DD0).",
icon: <Sliders className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.changeJournal",
text: "Change journal — a Changes view that lists exactly what ProxMenux modified on this host: every configuration file, package and service it touched, with the prior state of each, deduplicated to a current-state view that shows the host as it stands now rather than a log of every run.",
},
{
icon: <Sparkles className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.auditReports",
text: "Reports — a full audit plus focused Security review, Backup assurance and Capacity & wear reports, each opening on its own posture header, and a printable Inventory; all export to PDF. A security assessment asks before running Lynis so a run stays fast.",
},
{
icon: <Calendar className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.auditPolicyBaseline",
text: "Policy and baseline — declare what the host is expected to be (backup requirements, firewall, root SSH login) so findings grade against it, mark a run as the reference, and see what changed since, with new, resolved and accepted findings kept apart.",
},
{
icon: <Bell className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.atomicNotifications",
text: "Notification events reserve their deduplication fingerprint atomically before AI processing and channel delivery, so concurrent collectors, completion callbacks or parallel Monitor processes cannot send the same event twice. The reservation is released when no channel succeeds, preserving retries.",
},
{
icon: <DatabaseBackup className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.borgSshPort",
text: "Borg remote target — the Add Borg destination dialog and the shell TUI accept a custom SSH port. BORG_RSH, the auto key install flow and the capacity probe all honour it. Fully backwards compatible with existing entries created without an explicit port (suggested by @songochain in discussion #236).",
key: "releaseNotes.currentFeatures.groupedAppUpdates",
text: "Grouped application update notifications — one complete message per scan instead of one per application, grouped by LXC with the installed and available versions.",
},
{
icon: <Shield className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.githubToken",
text: "Settings → GitHub API accepts an optional personal access token for release and tag checks when the anonymous quota is exhausted. The token is encrypted at rest and never returned to the browser; the rate-limit error is translated in every Monitor language (suggested by @SystemIdleProcess in discussion #306).",
},
{
icon: <RefreshCw className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.replicationContext",
text: "Native Proxmox replication failure notifications resolve the replication job ID, affected VM/LXC ID and guest name; the exact error block from Proxmox is preserved as the reason, and each replication job deduplicates independently (reported by Ale R.).",
key: "releaseNotes.currentFeatures.adminTokenScope",
text: "Administrative scope — opening a Monitor terminal and disabling authentication now require a full-admin token, so a read-only API token issued to a monitoring integration stays read-only (reported by @f3rs3n).",
},
]
+62 -3
View File
@@ -21,6 +21,7 @@ import { useI18n } from "../lib/i18n/provider"
interface ApiTokenEntry {
id: string
name: string
scope?: "read_only" | "full_admin"
token_prefix: string
created_at: string
expires_at: string
@@ -130,6 +131,10 @@ export function Security() {
const [loadingTokens, setLoadingTokens] = useState(false)
const [revokingTokenId, setRevokingTokenId] = useState<string | null>(null)
const [tokenName, setTokenName] = useState("")
// API tokens default to read-only (the safe choice for dashboards that
// only read metrics). full_admin is opt-in and carries a warning: it
// can do everything the logged-in user can (host power, updates, terminal).
const [tokenScope, setTokenScope] = useState<"read_only" | "full_admin">("read_only")
// Proxmox Firewall state
const [firewallLoading, setFirewallLoading] = useState(true)
@@ -1152,6 +1157,7 @@ export function Security() {
password: tokenPassword,
totp_token: totpEnabled ? tokenTotpCode : undefined,
token_name: tokenName || st("apiTokens.defaultName"),
scope: tokenScope,
}),
})
@@ -1170,6 +1176,7 @@ export function Security() {
setTokenPassword("")
setTokenTotpCode("")
setTokenName("")
setTokenScope("read_only")
loadApiTokens()
} catch (err) {
setError(err instanceof Error ? err.message : st("errors.generateTokenRetry"))
@@ -1346,10 +1353,12 @@ export function Security() {
.top-bar-title { font-weight: 600; }
.top-bar-subtitle { font-size: 11px; color: #94a3b8; display: none; }
.top-bar button {
background: #06b6d4; color: #fff; border: none; padding: 10px 20px; border-radius: 6px;
font-size: 14px; font-weight: 600; cursor: pointer;
background: #06b6d4; color: #fff; border: none; padding: 8px 12px; border-radius: 6px;
font-size: 14px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
}
.top-bar button:hover { background: #0891b2; }
.top-bar .btn-group { display: flex; gap: 8px; }
.top-bar button svg { width: 18px; height: 18px; display: block; }
.hide-mobile { }
@media (min-width: 640px) {
.top-bar { padding: 12px 24px; }
@@ -1472,7 +1481,10 @@ function pmxPrint(){
<strong>${st("lynis.report.brandTitle")}</strong>
<span id="pmx-print-hint" class="hide-mobile" style="font-size:11px;opacity:0.7;">${st("lynis.report.reviewHint")}</span>
</div>
<button onclick="pmxPrint()">${st("lynis.printSavePdf")}</button>
<div class="btn-group">
<button onclick="pmxPrint()" title="Print" aria-label="Print"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg></button>
<button onclick="pmxPrint()" title="Save as PDF" aria-label="Save as PDF"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg></button>
</div>
</div>
<!-- Header -->
@@ -2478,6 +2490,44 @@ ${(report.sections && report.sections.length > 0) ? `
</div>
</div>
<div className="space-y-2">
<Label>{st("apiTokens.scope.label")}</Label>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<button
type="button"
onClick={() => setTokenScope("read_only")}
disabled={generatingToken}
className={`rounded-md border px-3 py-2 text-left transition-colors ${
tokenScope === "read_only"
? "border-blue-500 bg-blue-500/10"
: "border-border hover:bg-muted/60"
}`}
>
<span className="block text-sm font-medium">{st("apiTokens.scope.readOnly")}</span>
<span className="block text-xs text-muted-foreground">{st("apiTokens.scope.readOnlyHint")}</span>
</button>
<button
type="button"
onClick={() => setTokenScope("full_admin")}
disabled={generatingToken}
className={`rounded-md border px-3 py-2 text-left transition-colors ${
tokenScope === "full_admin"
? "border-amber-500 bg-amber-500/10"
: "border-border hover:bg-muted/60"
}`}
>
<span className="block text-sm font-medium">{st("apiTokens.scope.fullAdmin")}</span>
<span className="block text-xs text-muted-foreground">{st("apiTokens.scope.fullAdminHint")}</span>
</button>
</div>
{tokenScope === "full_admin" && (
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-2.5 text-xs text-amber-600 dark:text-amber-400">
<TriangleAlert className="h-4 w-4 shrink-0 mt-0.5" />
<span>{st("apiTokens.scope.fullAdminWarning")}</span>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="token-password">{st("auth.password")}</Label>
<div className="relative">
@@ -2650,6 +2700,15 @@ ${(report.sections && report.sections.length > 0) ? `
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium truncate">{token.name}</p>
{token.scope === "read_only" ? (
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-500/15 text-blue-500 border border-blue-500/30 whitespace-nowrap">
{st("apiTokens.scope.readOnly")}
</span>
) : (
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-500/15 text-amber-500 border border-amber-500/30 whitespace-nowrap">
{st("apiTokens.scope.fullAdmin")}
</span>
)}
{isInvalid && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-500/15 text-red-500 border border-red-500/30 whitespace-nowrap">
{st("apiTokens.invalidRegenerate")}