Compare commits

...

2 Commits

11 changed files with 131 additions and 74 deletions

View File

@@ -1 +1 @@
1.0.0-pre8
1.0.0-pre9

View File

@@ -1,6 +1,6 @@
{
"name": "pve-scripts-local",
"version": "1.0.0-pre8",
"version": "1.0.0-pre9",
"private": true,
"type": "module",
"scripts": {

View File

@@ -345,13 +345,11 @@ export function DownloadedScriptsTab() {
scripts = scripts.filter((script) => {
if (!script) return false;
const scriptType = (script.type ?? "").toLowerCase();
// Map non-standard types to standard categories
const mappedType = scriptType === "turnkey" ? "ct" : scriptType;
return filters.selectedTypes.some(
(type) => type.toLowerCase() === mappedType,
);
return filters.selectedTypes.some((type) => {
const t = type.toLowerCase();
if (t === "ct") return scriptType === "ct" || scriptType === "lxc";
return scriptType === t;
});
});
}

View File

@@ -18,6 +18,7 @@ import {
TrendingDown,
FlaskConical,
Cpu,
Settings2,
} from "lucide-react";
import { api } from "~/trpc/react";
import { getDefaultFilters } from "./filterUtils";
@@ -58,6 +59,9 @@ interface FilterBarProps {
const SCRIPT_TYPES = [
{ value: "ct", label: "LXC Container", Icon: Package },
{ value: "vm", label: "Virtual Machine", Icon: Monitor },
{ value: "pve", label: "PVE Tools", Icon: Settings2 },
{ value: "addon", label: "Addons", Icon: Sparkles },
{ value: "turnkey", label: "TurnKey", Icon: FileText },
];
export function FilterBar({

View File

@@ -253,6 +253,9 @@ export function GeneratorTab() {
if (!scriptCardsData?.success || !scriptCardsData.cards) return [];
const map = new Map<string, ScriptCard>();
for (const s of scriptCardsData.cards) {
const t = (s?.type ?? "").toLowerCase();
// Generator only supports LXC scripts in pre9.
if (!(t === "ct" || t === "lxc")) continue;
if (s?.slug && !map.has(s.slug)) map.set(s.slug, s);
}
return Array.from(map.values()).sort((a, b) =>
@@ -265,16 +268,17 @@ export function GeneratorTab() {
[allScripts, selectedSlug],
);
const isAddonScript = selectedScript?.type === "addon";
const selectedType = (selectedScript?.type ?? "").toLowerCase();
const isLxcType = selectedType === "ct" || selectedType === "lxc";
useEffect(() => {
if (
isAddonScript &&
!isLxcType &&
(installMode === "mydefaults" || installMode === "appdefaults")
) {
setInstallMode("default");
}
}, [isAddonScript, installMode]);
}, [isLxcType, installMode]);
// Fetch full script detail (with install_methods) when a script is selected
const { data: scriptDetailData } = api.scripts.getScriptBySlug.useQuery(
@@ -287,10 +291,17 @@ export function GeneratorTab() {
return scriptDetailData.script.execute_in ?? [];
}, [scriptDetailData]);
const needsContainerPicker = useMemo(
() => executeIn.some((e) => ["lxc", "vm", "pbs", "pmg"].includes(e)),
[executeIn],
);
const executionPolicy = useMemo(() => {
const has = (v: string) => executeIn.includes(v);
const allowVm = has("vm");
const allowLxcByMode =
has("pbs") || has("pmg") || (has("lxc") && !isLxcType);
const requiresContainer = allowVm || allowLxcByMode;
const pinMode = has("pbs") ? "pbs" : has("pmg") ? "pmg" : null;
return { requiresContainer, allowVm, allowLxcByMode, pinMode };
}, [executeIn, isLxcType]);
const needsContainerPicker = executionPolicy.requiresContainer;
const { data: containersData, isLoading: containersLoading } =
api.installedScripts.listContainersOnServer.useQuery(
@@ -307,14 +318,14 @@ export function GeneratorTab() {
status: string;
pinned: boolean;
}> = [];
const wantLxc = executeIn.some((e) => ["lxc", "pbs", "pmg"].includes(e));
const wantVm = executeIn.includes("vm");
const wantLxc = executionPolicy.allowLxcByMode;
const wantVm = executionPolicy.allowVm;
if (wantLxc) {
for (const c of containersData.lxc) {
const pinned =
(executeIn.includes("pbs") &&
(executionPolicy.pinMode === "pbs" &&
c.name.toLowerCase().includes("proxmox-backup-server")) ||
(executeIn.includes("pmg") &&
(executionPolicy.pinMode === "pmg" &&
c.name.toLowerCase().includes("proxmox-mail-gateway"));
opts.push({ ...c, isVm: false, pinned });
}
@@ -329,7 +340,7 @@ export function GeneratorTab() {
if (!a.pinned && b.pinned) return 1;
return parseInt(a.id, 10) - parseInt(b.id, 10);
});
}, [executeIn, containersData]);
}, [executionPolicy, containersData]);
const scriptDetail: Script | null = useMemo(
() =>
@@ -792,7 +803,8 @@ export function GeneratorTab() {
// Determine whether to execute inside the container.
// execute_in: ["lxc"] (or vm/pbs/pmg) means the script runs INSIDE the
// selected container rather than on the PVE host.
const execInContainer = needsContainerPicker && !!selectedContainerId;
const execInContainer =
executionPolicy.requiresContainer && !!selectedContainerId;
openShell({
sessionKey: `generator-${selectedScript.slug}-${Date.now()}`,
@@ -818,7 +830,7 @@ export function GeneratorTab() {
selectedScript,
selectedServer,
selectedContainerId,
needsContainerPicker,
executionPolicy,
installMode,
cpu,
ram,
@@ -1067,7 +1079,7 @@ export function GeneratorTab() {
<div className="bg-muted/40 mb-5 flex flex-wrap rounded-lg p-0.5">
{[
{ key: "default", label: "Default" },
...(!isAddonScript
...(isLxcType
? [
{ key: "mydefaults", label: "My Defaults" },
{ key: "appdefaults", label: "App Defaults" },

View File

@@ -165,10 +165,20 @@ export function InstallCommandBlock({
const [selectedServer, setSelectedServer] = useState<ServerType | null>(null);
const [serversLoading, setServersLoading] = useState(false);
// Container picker state (for addon scripts with execute_in)
const needsContainerPicker = !!executeIn?.some((e) =>
CONTAINER_TYPES.includes(e as (typeof CONTAINER_TYPES)[number]),
);
const scriptTypeNorm = (scriptType ?? "").toLowerCase();
const isLxcType = scriptTypeNorm === "ct" || scriptTypeNorm === "lxc";
const executionPolicy = (() => {
const has = (v: string) => !!executeIn?.includes(v);
const allowVm = has("vm");
const allowLxcByMode =
has("pbs") || has("pmg") || (has("lxc") && !isLxcType);
const requiresContainer = allowVm || allowLxcByMode;
const pinMode = has("pbs") ? "pbs" : has("pmg") ? "pmg" : null;
return { requiresContainer, allowVm, allowLxcByMode, pinMode };
})();
// Container picker state driven by execute_in + script type policy
const needsContainerPicker = executionPolicy.requiresContainer;
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(
null,
);
@@ -188,18 +198,18 @@ export function InstallCommandBlock({
status?: string;
pinned?: boolean;
}[] = [];
const wantLxc =
!executeIn || executeIn.some((e) => ["lxc", "pbs", "pmg"].includes(e));
const wantVm = !executeIn || executeIn.includes("vm");
const wantLxc = executionPolicy.allowLxcByMode;
const wantVm = executionPolicy.allowVm;
if (wantLxc) {
for (const c of containerQuery.data.lxc) {
const lower = (c.name ?? "").toLowerCase();
const pinned = executeIn?.includes("pbs")
? lower.includes("proxmox-backup-server")
: executeIn?.includes("pmg")
? lower.includes("proxmox-mail-gateway")
: false;
const pinned =
executionPolicy.pinMode === "pbs"
? lower.includes("proxmox-backup-server")
: executionPolicy.pinMode === "pmg"
? lower.includes("proxmox-mail-gateway")
: false;
opts.push({
id: String(c.id),
name: c.name ?? String(c.id),
@@ -259,15 +269,19 @@ export function InstallCommandBlock({
snapToStep(defaults?.hdd ?? 2, HDD_PRESETS),
);
const isAddonScript = (scriptType ?? "").toLowerCase() === "addon";
const showAdvanced =
scriptType === "ct" || scriptType === "lxc" || isAddonScript;
const isAddonScript = scriptTypeNorm === "addon";
const showAdvanced = isLxcType || isAddonScript;
const canUseDefaultsTabs = isLxcType;
useEffect(() => {
if (isAddonScript && (env === "mydefaults" || env === "appdefaults")) {
if (
!canUseDefaultsTabs &&
(env === "mydefaults" || env === "appdefaults")
) {
setEnv("default");
}
}, [isAddonScript, env]);
}, [canUseDefaultsTabs, env]);
const defaultPath = getDefaultInstallPath(scriptType, slug);
const alpinePath = getAlpineInstallPath(scriptType, slug);
@@ -355,7 +369,7 @@ export function InstallCommandBlock({
onClick={() => setEnv("default")}
label="Default"
/>
{!isAddonScript && (
{canUseDefaultsTabs && (
<OptionToggle
selected={env === "mydefaults"}
onClick={() => setEnv("mydefaults")}
@@ -363,7 +377,7 @@ export function InstallCommandBlock({
title={MODE_DESCRIPTIONS.mydefaults}
/>
)}
{!isAddonScript && (
{canUseDefaultsTabs && (
<OptionToggle
selected={env === "appdefaults"}
onClick={() => setEnv("appdefaults")}

View File

@@ -346,12 +346,21 @@ export function InstalledScriptsTab() {
// For each script, find its container status
currentScripts.forEach((script) => {
if (script.container_id && data.statusMap) {
const scopedKey = script.server_id
? `${script.server_id}:${script.container_id}`
: script.container_id;
const containerStatus = (
data.statusMap as Record<
string,
"running" | "stopped" | "unknown"
>
)[script.container_id];
)[scopedKey] ??
(
data.statusMap as Record<
string,
"running" | "stopped" | "unknown"
>
)[script.container_id];
if (containerStatus) {
statusMap.set(script.id, containerStatus);
} else {
@@ -1571,7 +1580,6 @@ export function InstalledScriptsTab() {
return (
<div className="space-y-6">
{/* Shell Terminal — now rendered as FloatingShell dialog (see ShellContext) */}
{/* Header with Stats */}

View File

@@ -238,6 +238,8 @@ export function ScriptDetailModal({
const hasDifferences =
comparisonData?.success && comparisonData.hasDifferences;
const isUpToDate = hasLocalFiles && !hasDifferences;
const scriptTypeNorm = (script.type ?? "").toLowerCase();
const isLxcType = scriptTypeNorm === "ct" || scriptTypeNorm === "lxc";
return (
<ModalPortal>
@@ -507,7 +509,7 @@ export function ScriptDetailModal({
className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-success" : "bg-muted-foreground/30"}`}
/>
<span className="text-muted-foreground">
Script:{" "}
Script File:{" "}
<span className="text-foreground font-medium">
{scriptFilesData.ctExists
? "Available"
@@ -515,19 +517,32 @@ export function ScriptDetailModal({
</span>
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<div
className={`h-2 w-2 rounded-full ${scriptFilesData.installExists ? "bg-success" : "bg-muted-foreground/30"}`}
/>
<span className="text-muted-foreground">
Install Script:{" "}
<span className="text-foreground font-medium">
{scriptFilesData.installExists
? "Available"
: "Not loaded"}
{isLxcType ? (
<div className="flex items-center gap-2 text-sm">
<div
className={`h-2 w-2 rounded-full ${scriptFilesData.installExists ? "bg-success" : "bg-muted-foreground/30"}`}
/>
<span className="text-muted-foreground">
Install Script:{" "}
<span className="text-foreground font-medium">
{scriptFilesData.installExists
? "Available"
: "Not loaded"}
</span>
</span>
</span>
</div>
</div>
) : (
<div className="flex items-center gap-2 text-sm">
<div className="bg-muted-foreground/30 h-2 w-2 rounded-full" />
<span className="text-muted-foreground">
Install Script:{" "}
<span className="text-foreground font-medium">
N/A for{" "}
{script.type?.toUpperCase() ?? "this type"}
</span>
</span>
</div>
)}
{hasLocalFiles && (
<div className="flex items-center gap-2 text-sm">
<div
@@ -557,7 +572,11 @@ export function ScriptDetailModal({
)}
{scriptFilesData.files.length > 0 && (
<div className="text-muted-foreground mt-1 text-xs break-words">
Files: {scriptFilesData.files.join(", ")}
Files ({scriptFilesData.files.length}):{" "}
{scriptFilesData.files
.slice()
.sort((a, b) => a.localeCompare(b))
.join(", ")}
</div>
)}
</div>

View File

@@ -192,7 +192,7 @@ export function ScriptsGrid() {
.filter((name): name is string => typeof name === "string");
}, [scriptCardsData]);
// Get GitHub scripts with download status (deduplicated), excluding unsupported types
// Get GitHub scripts with download status (deduplicated)
const combinedScripts = React.useMemo((): ScriptCardType[] => {
if (!scriptCardsData?.success) return [];
@@ -201,8 +201,6 @@ export function ScriptsGrid() {
scriptCardsData.cards?.forEach((script: ScriptCardType) => {
if (script?.name && script?.slug) {
// Exclude addon/pve types from the Scripts grid
if (script.type === "addon" || script.type === "pve") return;
// Use slug as unique identifier, only keep first occurrence
if (!scriptMap.has(script.slug)) {
scriptMap.set(script.slug, {
@@ -403,13 +401,11 @@ export function ScriptsGrid() {
scripts = scripts.filter((script) => {
if (!script) return false;
const scriptType = (script.type ?? "").toLowerCase();
// Map non-standard types to standard categories
const mappedType = scriptType === "turnkey" ? "ct" : scriptType;
return filters.selectedTypes.some(
(type) => type.toLowerCase() === mappedType,
);
return filters.selectedTypes.some((type) => {
const t = type.toLowerCase();
if (t === "ct") return scriptType === "ct" || scriptType === "lxc";
return scriptType === t;
});
});
}

View File

@@ -625,7 +625,7 @@ export const installedScriptsRouter = createTRPCRouter({
try {
const serverTypeMap = await batchDetectContainerTypes(server);
for (const [containerId, isVM] of serverTypeMap.entries()) {
containerTypeMap.set(containerId, isVM);
containerTypeMap.set(`${serverId}:${containerId}`, isVM);
}
} catch (error) {
console.error(`Error batch detecting types for server ${serverId}:`, error);
@@ -639,8 +639,9 @@ export const installedScriptsRouter = createTRPCRouter({
// Use SSH detection result, fall back to DB heuristic
let is_vm = false;
if (script.container_id && script.server_id) {
if (containerTypeMap.has(script.container_id)) {
is_vm = containerTypeMap.get(script.container_id) ?? false;
const key = `${script.server_id}:${script.container_id}`;
if (containerTypeMap.has(key)) {
is_vm = containerTypeMap.get(key) ?? false;
}
// If not in batch detection map, default to LXC (false) for safety
// Only the batch detection (pct list / qm list) can reliably determine VM vs LXC
@@ -1639,7 +1640,12 @@ export const installedScriptsRouter = createTRPCRouter({
const vmStatuses = parseListStatuses(qmOutput);
// Merge both status maps (VMs will overwrite containers if same ID, but that's unlikely)
Object.assign(statusMap, containerStatuses, vmStatuses);
for (const [id, status] of Object.entries(containerStatuses)) {
statusMap[`${(server as any).id}:${id}`] = status;
}
for (const [id, status] of Object.entries(vmStatuses)) {
statusMap[`${(server as any).id}:${id}`] = status;
}
} catch (error) {
console.error(`Error processing server ${(server as any).name}:`, error);
}

View File

@@ -21,7 +21,7 @@ import type { Server } from "~/types/server";
import { cacheLogos, getLocalLogoPath } from "~/server/services/logoCacheService";
// Script types not yet supported in PVE-Local
const UNSUPPORTED_TYPES = ['pve'] as const;
const UNSUPPORTED_TYPES = [] as const;
// ---------------------------------------------------------------------------
// Mapper: PocketBase record → internal Script type (used by scriptDownloader)