"use client" import { useCallback, useEffect, useMemo, useState } from "react" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Badge } from "./ui/badge" import { ChevronDown, ChevronRight, FileCode, HelpCircle, Loader2, Package, Play, Settings2, Sparkles, Terminal, Wrench, } from "lucide-react" import { fetchApi } from "../lib/api-config" import { useT, useI18n } from "../lib/i18n/provider" /** * What ProxMenux changed on this host. * * The complaint this answers is not that the tool changes things: it is * that afterwards nobody can say what it changed. Showing the script * does not answer it either — a function of four hundred lines may alter * two values, and the reader cannot tell which two. So what is shown * here is the difference and nothing else. * * Two distinctions are kept in front of the reader, because both bear on * what they can do about what they are looking at: whether ProxMenux * authored the change or merely ran something the user asked for, and * how much of the previous state is actually known. */ interface Change { id: number recorded_at: number class: string operation: string source: string function: string function_version: string target: string before_ref: string after_ref: string capture: string revert: string exactness: string result: string recoverable: boolean detail: Record diff?: { available: boolean; reason?: string added?: number; removed?: number; truncated?: boolean; hunks?: string[] } | null } interface Summary { total: number by_class: Record functions: Array<{ function: string; source: string; version: string changes: number; last_change: number; first_change: number }> journal_started: number | null } // A file that did not exist before was created, not replaced — a sysadmin // reading this must see "new file", not "file replaced". `capture` carries // that distinction: "created" for a file born here, "present" for one that // already had contents we captured before overwriting them. function operationLabelKey(change: { operation: string; capture: string }): string { // Two truthful labels for a file: created if it did not exist, modified if // it did. The diff below shows exactly what changed either way, so there is // no need to distinguish write/edit/append in the label. if (["write_file", "edit_file", "append_file"].includes(change.operation)) { return change.capture === "created" ? "operation.file_created" : "operation.file_modified" } return `operation.${change.operation}` } // Undoing follows `revert`, not `exactness`: a created file is undone by // deleting it (there was nothing before), an overwritten one by restoring // what we captured. function undoKey(change: { revert: string; exactness: string }): string { if (change.revert === "remove") return "undo.remove" if (change.revert === "restore" && change.exactness === "exact") return "undo.restore" if (change.revert === "purge") return "undo.purge" return `exactness.${change.exactness}` } const FN_LABEL: Record = { // Internal helpers of a post-install feature show its menu name, not their // raw name — and never the source ("auto"/"customizable"). _update_existing_log2ram_auto: "Install and configure Log2RAM", _update_existing_log2ram_custom: "Install and configure Log2RAM", update_snapshot_schedule: "Install ZFS auto-snapshot", apply_amd_fixes: "Apply AMD CPU fixes", apply_network_optimizations: "Apply network optimizations", apt_upgrade: "Update and upgrade system", cleanup_duplicate_repos_pve9: "Configure Proxmox APT repositories", configure_fastfetch: "Install and configure Fastfetch", configure_figurine: "Install Figurine", configure_kernel_panic: "Enable restart on kernel panic", configure_log2ram: "Install and configure Log2RAM", configure_pigz: "Use pigz for faster gzip compression", configure_time_sync: "Synchronize time automatically", customize_bashrc: "Customize bashrc", disable_rpc: "Disable portmapper/rpcbind", enable_ha: "Enable High Availability services", enable_kexec: "Enable fast reboots", enable_tcp_fast_open: "Enable TCP BBR/Fast Open control", enable_vfio_iommu: "Enable VFIO IOMMU support", enable_zfs_autotrim: "Enable ZFS autotrim (SSD/NVMe pools)", force_apt_ipv4: "Force APT to use IPv4", increase_system_limits: "Increase various system limits", install_ceph: "Add latest Ceph support", install_guest_agent: "Install relevant guest agent", install_log2ram: "Install and configure Log2RAM", install_log2ram_auto: "Install and configure Log2RAM", install_openvswitch: "Install Open vSwitch", install_ovh_rtm: "Install OVH Real Time Monitoring", install_system_utils: "Install common system utilities", install_zfs_auto_snapshot: "Install ZFS auto-snapshot", optimize_journald: "Optimize journald", optimize_logrotate: "Optimize logrotate", optimize_memory_settings: "Optimize Memory", optimize_vzdump: "Increase vzdump backup speed", optimize_zfs_arc: "Optimize ZFS ARC size", remove_subscription_banner: "Remove subscription banner", setup_motd: "Set up custom MOTD banner", setup_persistent_network: "Interface Names (persistent)", setup_proxmox_repositories: "Configure Proxmox APT repositories", skip_apt_languages: "Skip downloading additional languages", update_pve8: "Update and upgrade system", update_pve9: "Update and upgrade system", update_pve_appliance_manager: "Update Proxmox VE Appliance Manager", } // Post-install functions run from the auto/customizable scripts; everything // else is a general host script (nvidia/tpu installers, PVE update, vfio…). const POST_INSTALL_SOURCES = new Set(["auto", "customizable"]) // A few sources deserve a friendly name instead of a raw script identifier; // everything else shows the script it came from. const FRIENDLY_SOURCE = new Set(["install_proxmenux", "monitor"]) // Which of the three sections a change belongs to: installations are their // own block, post-install optimizations another, general scripts the rest. function blockOf(c: { class: string; source: string }): "installs" | "postInstall" | "scripts" { if (c.class === "installation") return "installs" if (POST_INSTALL_SOURCES.has(c.source)) return "postInstall" return "scripts" } // A post-install function shows its menu name; anything else shows the script // that made the change. function groupLabel(fn: string, source: string): string { // A post-install change shows its feature's menu name; if the function is // not a known optimization, its own name — never the bare source. return FN_LABEL[fn] || fn || source || "—" } const CLASS_STYLE: Record = { configuration: { chip: "bg-blue-500/10 text-blue-400 border-blue-400/20", Icon: Settings2 }, installation: { chip: "bg-green-500/10 text-green-500 border-green-500/20", Icon: Package }, execution: { chip: "bg-muted text-muted-foreground border-border", Icon: Play }, registration: { chip: "bg-muted text-muted-foreground border-border", Icon: HelpCircle }, } function ChangeCard({ change, expanded, onToggle, t, when }: { change: Change; expanded: boolean; onToggle: () => void t: (k: string, params?: Record) => string when: (n: number) => string }) { const style = CLASS_STYLE[change.class] || CLASS_STYLE.registration const Icon = style.Icon const installed = String(change.detail?.installed || "") return ( {expanded && (
{t("audit.changes.source")}:{" "} {change.source || "—"} {change.revert && change.revert !== "none" && ( {t("audit.changes.reversibility")}:{" "} {t(`audit.changes.${undoKey(change)}`)} )}
{(change.operation === "enable_service" || change.operation === "disable_service") && Boolean(change.detail?.before_state || change.detail?.after_state) && (
{String(change.detail?.before_state || "—").replace(/\s+/g, " ")} {String(change.detail?.after_state || "—").replace(/\s+/g, " ")}
)} {installed && (

{t("audit.changes.packagesAdded")}

{installed.split(/\s+/).filter(Boolean).map((pkg) => ( {pkg} ))}
)} {change.class === "execution" && Boolean(change.detail?.command) && (

{t("audit.changes.commandRun")}

                {String(change.detail.command)}
              

{t("audit.changes.executionNote")}

)} {change.diff && (

{t("audit.changes.difference")}

{change.diff.available ? ( <>
                    {(change.diff.hunks || []).map((line: string, i: number) => (
                      
{line}
))}
{change.diff.truncated && (

{t("audit.changes.diffTruncated")}

)} ) : (

{t("audit.changes.diffUnavailable")}

)}
)} {change.capture === "unknown" && (

{t("audit.changes.unknownNote")}

)}
)}
) } function GroupSection({ title, icon: Icon, iconClass, groups, openFn, toggleFn, open, toggle, t, when }: { title: string icon: typeof Settings2 iconClass: string groups: { key: string; label: string; version: string; last: number; items: Change[] }[] openFn: Set; toggleFn: (k: string) => void open: Set; toggle: (id: number) => void t: (k: string, params?: Record) => string when: (n: number) => string }) { if (groups.length === 0) return null return ( // Padding on the child: the container's space-y-4 overrides any mt-*.
{/* Same type and icon size as CardTitle, so a block heading here reads exactly like a card heading on the Settings page. */}

{title}

{groups.map((g) => { const fnOpen = openFn.has(g.key) return ( {fnOpen && ( {g.items.map((change) => ( toggle(change.id)} t={t} when={when} /> ))} )} ) })}
) } export function AuditChanges() { const t = useT() const { language } = useI18n() const [changes, setChanges] = useState([]) const [summary, setSummary] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [open, setOpen] = useState>(new Set()) const load = useCallback(async () => { try { const res: any = await fetchApi("/api/audit/changes?limit=500") if (res?.success) { setChanges(res.changes || []) setSummary(res.summary || null) setError(null) } else { setError(res?.message || t("audit.changes.failed")) } } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setLoading(false) } }, [t]) useEffect(() => { load() }, [load]) const toggle = (id: number) => setOpen((prev) => { const next = new Set(prev) next.has(id) ? next.delete(id) : next.add(id) return next }) const [openFn, setOpenFn] = useState>(new Set()) const toggleFn = (fn: string) => setOpenFn((prev) => { const next = new Set(prev) next.has(fn) ? next.delete(fn) : next.add(fn) return next }) // A sysadmin reads this in three sections: what ProxMenux optimized // (post-install), what its other scripts changed, and what it installed. // Within each, changes are grouped under a card that opens to reveal them. type Group = { key: string; label: string; version: string; last: number; items: Change[] } const blocks = useMemo(() => { const mk = () => new Map() const post = mk(), scripts = mk(), installs = mk() const pick = (b: string) => b === "installs" ? installs : b === "postInstall" ? post : scripts for (const c of changes) { const b = blockOf(c) const target = pick(b) // Post-install groups by function under its menu name; the script and // install sections group by the script that made the change, and show // that script's name — not a per-function label. // The same script can appear in more than one section (it changed // config and also installed a package), so the accordion key is scoped // by section — otherwise opening one card opens its twin elsewhere. const rawKey = b === "postInstall" ? groupLabel(c.function, c.source) : (c.source || c.function || "—") const key = `${b}:${rawKey}` const label = b === "postInstall" ? groupLabel(c.function, c.source) : FRIENDLY_SOURCE.has(c.source) ? t(`audit.changes.scriptLabel.${c.source}`) : (c.source || c.function || "—") const g = target.get(key) || { key, label, version: "", last: 0, items: [] } g.items.push(c) if (c.recorded_at > g.last) g.last = c.recorded_at if (b === "postInstall" && c.function_version) g.version = c.function_version target.set(key, g) } const sort = (m: Map) => Array.from(m.values()).sort((a, b) => b.last - a.last) return { post: sort(post), scripts: sort(scripts), installs: sort(installs) } }, [changes, t]) const when = (epoch: number) => new Date(epoch * 1000).toLocaleString(language) if (loading) { return (
{t("audit.changes.loading")}
) } if (error) return

{error}

return (

{t("audit.changes.intro")}

{/* A host with nothing recorded should say why, rather than looking like a host nothing has touched. */} {summary && summary.total === 0 && (

{t("audit.changes.empty")}

)} {summary && summary.journal_started && (

{t("audit.changes.since", { date: when(summary.journal_started) })}

)}
{summary && summary.total > 0 && blocks.post.length + blocks.scripts.length + blocks.installs.length === 0 && (

{t("audit.changes.empty")}

)}
) }