Files
ProxmoxVE-Local/src/app/page.tsx
CanbiZ e3e4556f83 Add type annotations and improve script services
Enhanced type safety and documentation in several files, including adding explicit type annotations for script objects and function parameters. Improved error handling and code clarity in scriptDownloader.js, and updated autoSyncService.js to remove unnecessary cron job options. Refactored prisma.config.ts for schema configuration and updated server.js to support backup storage and improve parameter defaults.
2025-11-28 13:06:16 +01:00

393 lines
15 KiB
TypeScript

"use client";
import { useState, useRef, useEffect } from "react";
import { ScriptsGrid } from "./_components/ScriptsGrid";
import { DownloadedScriptsTab } from "./_components/DownloadedScriptsTab";
import { InstalledScriptsTab } from "./_components/InstalledScriptsTab";
import { BackupsTab } from "./_components/BackupsTab";
import { ResyncButton } from "./_components/ResyncButton";
import { Terminal } from "./_components/Terminal";
import { ServerSettingsButton } from "./_components/ServerSettingsButton";
import { SettingsButton } from "./_components/SettingsButton";
import { HelpButton } from "./_components/HelpButton";
import { VersionDisplay } from "./_components/VersionDisplay";
import { ThemeToggle } from "./_components/ThemeToggle";
import { Button } from "./_components/ui/button";
import { ContextualHelpIcon } from "./_components/ContextualHelpIcon";
import {
ReleaseNotesModal,
getLastSeenVersion,
} from "./_components/ReleaseNotesModal";
import { Footer } from "./_components/Footer";
import { Package, HardDrive, FolderOpen, LogOut, Archive } from "lucide-react";
import { api } from "~/trpc/react";
import { useAuth } from "./_components/AuthProvider";
import type { Server } from "~/types/server";
import type { ScriptCard } from "~/types/script";
export default function Home() {
const { isAuthenticated, logout } = useAuth();
const [runningScript, setRunningScript] = useState<{
path: string;
name: string;
mode?: "local" | "ssh";
server?: Server;
} | null>(null);
const [activeTab, setActiveTab] = useState<
"scripts" | "downloaded" | "installed" | "backups"
>(() => {
if (typeof window !== "undefined") {
const savedTab = localStorage.getItem("activeTab") as
| "scripts"
| "downloaded"
| "installed"
| "backups";
return savedTab || "scripts";
}
return "scripts";
});
const [releaseNotesOpen, setReleaseNotesOpen] = useState(false);
const [highlightVersion, setHighlightVersion] = useState<string | undefined>(
undefined,
);
const terminalRef = useRef<HTMLDivElement>(null);
// Fetch data for script counts
const { data: scriptCardsData } =
api.scripts.getScriptCardsWithCategories.useQuery();
const { data: localScriptsData } =
api.scripts.getAllDownloadedScripts.useQuery();
const { data: installedScriptsData } =
api.installedScripts.getAllInstalledScripts.useQuery();
const { data: backupsData } = api.backups.getAllBackupsGrouped.useQuery();
const { data: versionData } = api.version.getCurrentVersion.useQuery();
// Save active tab to localStorage whenever it changes
useEffect(() => {
if (typeof window !== "undefined") {
localStorage.setItem("activeTab", activeTab);
}
}, [activeTab]);
// Auto-show release notes modal after update
useEffect(() => {
if (versionData?.success && versionData.version) {
const currentVersion = versionData.version;
const lastSeenVersion = getLastSeenVersion();
// If we have a current version and either no last seen version or versions don't match
if (
currentVersion &&
(!lastSeenVersion || currentVersion !== lastSeenVersion)
) {
setHighlightVersion(currentVersion);
setReleaseNotesOpen(true);
}
}
}, [versionData]);
const handleOpenReleaseNotes = () => {
setHighlightVersion(undefined);
setReleaseNotesOpen(true);
};
const handleCloseReleaseNotes = () => {
setReleaseNotesOpen(false);
setHighlightVersion(undefined);
};
// Calculate script counts
const scriptCounts = {
available: (() => {
if (!scriptCardsData?.success) return 0;
// Deduplicate scripts using Map by slug (same logic as ScriptsGrid.tsx)
const scriptMap = new Map<string, ScriptCard>();
scriptCardsData.cards?.forEach((script: ScriptCard) => {
if (script?.name && script?.slug) {
// Use slug as unique identifier, only keep first occurrence
if (!scriptMap.has(script.slug)) {
scriptMap.set(script.slug, script);
}
}
});
return scriptMap.size;
})(),
downloaded: (() => {
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
// Helper to normalize identifiers for robust matching
const normalizeId = (s?: string): string =>
(s ?? "")
.toLowerCase()
.replace(/\.(sh|bash|py|js|ts)$/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
// First deduplicate GitHub scripts using Map by slug
const scriptMap = new Map<string, ScriptCard>();
scriptCardsData.cards?.forEach((script: ScriptCard) => {
if (script?.name && script?.slug) {
if (!scriptMap.has(script.slug)) {
scriptMap.set(script.slug, script);
}
}
});
const deduplicatedGithubScripts = Array.from(scriptMap.values());
const localScripts = (localScriptsData.scripts ?? []) as Array<{
name?: string;
slug?: string;
}>;
// Count scripts that are both in deduplicated GitHub data and have local versions
// Use the same matching logic as DownloadedScriptsTab and ScriptsGrid
return deduplicatedGithubScripts.filter((script) => {
if (!script?.name) return false;
// Check if there's a corresponding local script
return localScripts.some((local) => {
if (!local?.name) return false;
// Primary: Exact slug-to-slug matching (most reliable)
if (local.slug && script.slug) {
if (local.slug.toLowerCase() === script.slug.toLowerCase()) {
return true;
}
// Also try normalized slug matching (handles filename-based slugs vs JSON slugs)
if (
normalizeId(local.slug ?? undefined) ===
normalizeId(script.slug ?? undefined)
) {
return true;
}
}
// Secondary: Check install basenames (for edge cases where install script names differ from slugs)
const normalizedLocal = normalizeId(local.name ?? undefined);
const matchesInstallBasename =
script.install_basenames?.some(
(base) => normalizeId(String(base)) === normalizedLocal,
) ?? false;
if (matchesInstallBasename) return true;
// Tertiary: Normalized filename to normalized slug matching
if (
script.slug &&
normalizeId(local.name ?? undefined) ===
normalizeId(script.slug ?? undefined)
) {
return true;
}
return false;
});
}).length;
})(),
installed: installedScriptsData?.scripts?.length ?? 0,
backups: backupsData?.success ? backupsData.backups.length : 0,
};
const scrollToTerminal = () => {
if (terminalRef.current) {
// Get the element's position and scroll with a small offset for better mobile experience
const elementTop = terminalRef.current.offsetTop;
const offset = window.innerWidth < 768 ? 20 : 0; // Small offset on mobile
window.scrollTo({
top: elementTop - offset,
behavior: "smooth",
});
}
};
const handleRunScript = (
scriptPath: string,
scriptName: string,
mode?: "local" | "ssh",
server?: Server,
) => {
setRunningScript({ path: scriptPath, name: scriptName, mode, server });
// Scroll to terminal after a short delay to ensure it's rendered
setTimeout(scrollToTerminal, 100);
};
const handleCloseTerminal = () => {
setRunningScript(null);
};
return (
<main className="bg-background min-h-screen">
<div className="container mx-auto px-2 py-4 sm:px-4 sm:py-8">
{/* Header */}
<div className="mb-6 text-center sm:mb-8">
<div className="mb-2 flex items-start justify-between">
<div className="flex-1"></div>
<h1 className="text-foreground flex flex-1 items-center justify-center gap-2 text-2xl font-bold sm:gap-3 sm:text-3xl lg:text-4xl">
<span className="break-words">PVE Scripts Management</span>
</h1>
<div className="flex flex-1 items-center justify-end gap-2">
{isAuthenticated && (
<Button
variant="ghost"
size="icon"
onClick={logout}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Logout"
title="Logout"
>
<LogOut className="h-4 w-4" />
</Button>
)}
<ThemeToggle />
</div>
</div>
<p className="text-muted-foreground mb-4 px-2 text-sm sm:text-base">
Manage and execute Proxmox helper scripts locally with live output
streaming
</p>
<div className="flex justify-center px-2">
<VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} />
</div>
</div>
{/* Controls */}
<div className="mb-6 sm:mb-8">
<div className="bg-card border-border flex flex-col gap-4 rounded-lg border p-4 shadow-sm sm:flex-row sm:flex-wrap sm:items-center sm:p-6">
<ServerSettingsButton />
<SettingsButton />
<ResyncButton />
<HelpButton />
</div>
</div>
{/* Tab Navigation */}
<div className="mb-6 sm:mb-8">
<div className="border-border border-b">
<nav className="-mb-px flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-1">
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab("scripts")}
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
activeTab === "scripts"
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
}`}
>
<Package className="h-4 w-4" />
<span className="hidden sm:inline">Available Scripts</span>
<span className="sm:hidden">Available</span>
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
{scriptCounts.available}
</span>
<ContextualHelpIcon
section="available-scripts"
tooltip="Help with Available Scripts"
/>
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab("downloaded")}
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
activeTab === "downloaded"
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
}`}
>
<HardDrive className="h-4 w-4" />
<span className="hidden sm:inline">Downloaded Scripts</span>
<span className="sm:hidden">Downloaded</span>
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
{scriptCounts.downloaded}
</span>
<ContextualHelpIcon
section="downloaded-scripts"
tooltip="Help with Downloaded Scripts"
/>
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab("installed")}
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
activeTab === "installed"
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
}`}
>
<FolderOpen className="h-4 w-4" />
<span className="hidden sm:inline">Installed Scripts</span>
<span className="sm:hidden">Installed</span>
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
{scriptCounts.installed}
</span>
<ContextualHelpIcon
section="installed-scripts"
tooltip="Help with Installed Scripts"
/>
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab("backups")}
className={`flex w-full items-center justify-center gap-2 px-3 py-2 text-sm sm:w-auto sm:justify-start ${
activeTab === "backups"
? "bg-accent text-accent-foreground rounded-t-md rounded-b-none"
: "hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none"
}`}
>
<Archive className="h-4 w-4" />
<span className="hidden sm:inline">Backups</span>
<span className="sm:hidden">Backups</span>
<span className="bg-muted text-muted-foreground ml-1 rounded-full px-2 py-0.5 text-xs">
{scriptCounts.backups}
</span>
</Button>
</nav>
</div>
</div>
{/* Running Script Terminal */}
{runningScript && (
<div ref={terminalRef} className="mb-8">
<Terminal
scriptPath={runningScript.path}
onClose={handleCloseTerminal}
mode={runningScript.mode}
server={runningScript.server}
/>
</div>
)}
{/* Tab Content */}
{activeTab === "scripts" && (
<ScriptsGrid onInstallScript={handleRunScript} />
)}
{activeTab === "downloaded" && (
<DownloadedScriptsTab onInstallScript={handleRunScript} />
)}
{activeTab === "installed" && <InstalledScriptsTab />}
{activeTab === "backups" && <BackupsTab />}
</div>
{/* Footer */}
<Footer onOpenReleaseNotes={handleOpenReleaseNotes} />
{/* Release Notes Modal */}
<ReleaseNotesModal
isOpen={releaseNotesOpen}
onClose={handleCloseReleaseNotes}
highlightVersion={highlightVersion}
/>
</main>
);
}