From 3fdf438c6d046d84aeddba8798f81eff07d3f5e7 Mon Sep 17 00:00:00 2001 From: "CanbiZ (MickLesk)" <47820557+MickLesk@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:12:10 +0200 Subject: [PATCH] Wrap modals in portal and use zIndex Use ModalPortal and zIndex values from useRegisterModal across modal components to avoid hardcoded z-50 stacking. Updated ExecutionModeModal, LXCSettingsModal, PublicKeyModal, ReleaseNotesModal, SetupModal, StorageSelectionModal, and TextViewer to import ModalPortal, capture the zIndex returned by useRegisterModal, wrap modal markup in , and apply style={{ zIndex }} to backdrops; LXC result overlay uses zIndex + 10 for proper stacking. Also changed LoadingOverlay in VersionDisplay to render with createPortal(document.body) and added the import. These changes centralize stacking behavior and prevent z-index conflicts when multiple modals/overlays are present. --- src/app/_components/AuthGuard.tsx | 46 ++++--------------- src/app/_components/AuthProvider.tsx | 27 +++++++++++ src/app/_components/CategorySidebar.tsx | 28 +++++++++++ src/app/_components/ExecutionModeModal.tsx | 8 ++-- src/app/_components/InstalledScriptsTab.tsx | 3 ++ src/app/_components/LXCSettingsModal.tsx | 10 ++-- src/app/_components/PublicKeyModal.tsx | 8 ++-- src/app/_components/ReleaseNotesModal.tsx | 8 ++-- src/app/_components/ScriptsGrid.tsx | 7 +-- src/app/_components/SetupModal.tsx | 8 ++-- src/app/_components/StorageSelectionModal.tsx | 8 ++-- src/app/_components/TextViewer.tsx | 8 +++- src/app/_components/VersionDisplay.tsx | 7 +-- src/server/api/routers/installedScripts.ts | 13 ++++++ src/server/api/routers/scripts.ts | 1 + src/types/script.ts | 1 + 16 files changed, 128 insertions(+), 63 deletions(-) diff --git a/src/app/_components/AuthGuard.tsx b/src/app/_components/AuthGuard.tsx index 4a0aacf..2628a3e 100644 --- a/src/app/_components/AuthGuard.tsx +++ b/src/app/_components/AuthGuard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, type ReactNode } from 'react'; +import { useState, type ReactNode } from 'react'; import { useAuth } from './AuthProvider'; import { AuthModal } from './AuthModal'; import { SetupModal } from './SetupModal'; @@ -9,45 +9,17 @@ interface AuthGuardProps { children: ReactNode; } -interface AuthConfig { - username: string | null; - enabled: boolean; - hasCredentials: boolean; - setupCompleted: boolean; -} - export function AuthGuard({ children }: AuthGuardProps) { - const { isAuthenticated, isLoading } = useAuth(); - const [authConfig, setAuthConfig] = useState(null); - const [configLoading, setConfigLoading] = useState(true); - const [setupCompleted, setSetupCompleted] = useState(false); + const { isAuthenticated, isLoading, setupCompleted, authEnabled, refreshConfig } = useAuth(); + const [localSetupCompleted, setLocalSetupCompleted] = useState(false); const handleSetupComplete = async () => { - setSetupCompleted(true); - // Refresh auth config without reloading the page - await fetchAuthConfig(); + setLocalSetupCompleted(true); + await refreshConfig(); }; - const fetchAuthConfig = async () => { - try { - const response = await fetch('/api/settings/auth-credentials'); - if (response.ok) { - const config = await response.json() as AuthConfig; - setAuthConfig(config); - } - } catch (error) { - console.error('Error fetching auth config:', error); - } finally { - setConfigLoading(false); - } - }; - - useEffect(() => { - void fetchAuthConfig(); - }, []); - - // Show loading while checking auth status - if (isLoading || configLoading) { + // Show loading while AuthProvider is still checking + if (isLoading || setupCompleted === null) { return (
@@ -59,12 +31,12 @@ export function AuthGuard({ children }: AuthGuardProps) { } // Show setup modal if setup has not been completed yet - if (authConfig && !authConfig.setupCompleted && !setupCompleted) { + if (!setupCompleted && !localSetupCompleted) { return ; } // Show auth modal if auth is enabled but user is not authenticated - if (authConfig && authConfig.enabled && !isAuthenticated) { + if (authEnabled && !isAuthenticated) { return ; } diff --git a/src/app/_components/AuthProvider.tsx b/src/app/_components/AuthProvider.tsx index 0888081..22466fe 100644 --- a/src/app/_components/AuthProvider.tsx +++ b/src/app/_components/AuthProvider.tsx @@ -14,9 +14,12 @@ interface AuthContextType { username: string | null; isLoading: boolean; expirationTime: number | null; + setupCompleted: boolean | null; + authEnabled: boolean | null; login: (username: string, password: string) => Promise; logout: () => void; checkAuth: () => Promise; + refreshConfig: () => Promise; } const AuthContext = createContext(undefined); @@ -30,6 +33,8 @@ export function AuthProvider({ children }: AuthProviderProps) { const [username, setUsername] = useState(null); const [isLoading, setIsLoading] = useState(true); const [expirationTime, setExpirationTime] = useState(null); + const [setupCompleted, setSetupCompleted] = useState(null); + const [authEnabled, setAuthEnabled] = useState(null); const checkAuthInternal = async (retryCount = 0) => { try { @@ -41,6 +46,9 @@ export function AuthProvider({ children }: AuthProviderProps) { enabled: boolean; }; + setSetupCompleted(setupData.setupCompleted); + setAuthEnabled(setupData.enabled); + // If setup is not completed or auth is disabled, don't verify if (!setupData.setupCompleted || !setupData.enabled) { setIsAuthenticated(false); @@ -99,6 +107,22 @@ export function AuthProvider({ children }: AuthProviderProps) { return checkAuthInternal(0); }, []); + const refreshConfig = useCallback(async () => { + try { + const response = await fetch("/api/settings/auth-credentials"); + if (response.ok) { + const data = (await response.json()) as { + setupCompleted: boolean; + enabled: boolean; + }; + setSetupCompleted(data.setupCompleted); + setAuthEnabled(data.enabled); + } + } catch (error) { + console.error("Error refreshing auth config:", error); + } + }, []); + const login = async ( username: string, password: string, @@ -158,9 +182,12 @@ export function AuthProvider({ children }: AuthProviderProps) { username, isLoading, expirationTime, + setupCompleted, + authEnabled, login, logout, checkAuth, + refreshConfig, }} > {children} diff --git a/src/app/_components/CategorySidebar.tsx b/src/app/_components/CategorySidebar.tsx index f239e8d..22bebde 100644 --- a/src/app/_components/CategorySidebar.tsx +++ b/src/app/_components/CategorySidebar.tsx @@ -13,6 +13,34 @@ interface CategorySidebarProps { } // Icon mapping for categories +const categoryIconColorMap: Record = { + server: "text-blue-500", + monitor: "text-sky-400", + box: "text-orange-400", + shield: "text-green-500", + "shield-check": "text-green-500", + key: "text-yellow-500", + archive: "text-amber-400", + database: "text-indigo-500", + "chart-bar": "text-emerald-500", + template: "text-violet-500", + "folder-open": "text-cyan-500", + "document-text": "text-slate-400", + film: "text-rose-500", + download: "text-cyan-500", + "video-camera": "text-pink-500", + home: "text-lime-500", + wifi: "text-fuchsia-500", + "chat-alt": "text-sky-500", + clock: "text-orange-500", + code: "text-green-400", + "external-link": "text-blue-400", + sparkles: "text-purple-500", + "currency-dollar": "text-emerald-400", + puzzle: "text-pink-400", + office: "text-stone-500", +}; + const CategoryIcon = ({ iconName, className = "w-5 h-5", diff --git a/src/app/_components/ExecutionModeModal.tsx b/src/app/_components/ExecutionModeModal.tsx index fce78ca..db72fbf 100644 --- a/src/app/_components/ExecutionModeModal.tsx +++ b/src/app/_components/ExecutionModeModal.tsx @@ -7,7 +7,7 @@ import { Button } from './ui/button'; import { ColorCodedDropdown } from './ColorCodedDropdown'; import { SettingsModal } from './SettingsModal'; import { ConfigurationModal, type EnvVars } from './ConfigurationModal'; -import { useRegisterModal } from './modal/ModalStackProvider'; +import { useRegisterModal, ModalPortal } from './modal/ModalStackProvider'; interface ExecutionModeModalProps { @@ -19,7 +19,7 @@ interface ExecutionModeModalProps { } export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName, script }: ExecutionModeModalProps) { - useRegisterModal(isOpen, { id: 'execution-mode-modal', allowEscape: true, onClose }); + const zIndex = useRegisterModal(isOpen, { id: 'execution-mode-modal', allowEscape: true, onClose }); const [servers, setServers] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -95,7 +95,8 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName, scr return ( <> -
+ +
{/* Header */}
@@ -275,6 +276,7 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName, scr
+
{/* Server Settings Modal */}
@@ -2695,6 +2697,7 @@ export function InstalledScriptsTab() {
+ )} {/* Storage Selection Modal */} diff --git a/src/app/_components/LXCSettingsModal.tsx b/src/app/_components/LXCSettingsModal.tsx index 74b281f..c309ff6 100644 --- a/src/app/_components/LXCSettingsModal.tsx +++ b/src/app/_components/LXCSettingsModal.tsx @@ -9,7 +9,7 @@ import { ContextualHelpIcon } from './ContextualHelpIcon'; import { LoadingModal } from './LoadingModal'; import { ConfirmationModal } from './ConfirmationModal'; import { RefreshCw, AlertTriangle, CheckCircle } from 'lucide-react'; -import { useRegisterModal } from './modal/ModalStackProvider'; +import { useRegisterModal, ModalPortal } from './modal/ModalStackProvider'; interface InstalledScript { id: number; @@ -42,7 +42,7 @@ interface LXCSettingsModalProps { } export function LXCSettingsModal({ isOpen, script, onClose, onSave: _onSave }: LXCSettingsModalProps) { - useRegisterModal(isOpen, { id: 'lxc-settings-modal', allowEscape: true, onClose }); + const zIndex = useRegisterModal(isOpen, { id: 'lxc-settings-modal', allowEscape: true, onClose }); const [activeTab, setActiveTab] = useState('common'); const [showConfirmation, setShowConfirmation] = useState(false); const [showResultModal, setShowResultModal] = useState(false); @@ -262,7 +262,8 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave: _onSave }: L return ( <> -
+ +
{/* Header */}
@@ -667,6 +668,7 @@ export function LXCSettingsModal({ isOpen, script, onClose, onSave: _onSave }: L
+
{/* Confirmation Modal */} +
diff --git a/src/app/_components/PublicKeyModal.tsx b/src/app/_components/PublicKeyModal.tsx index 91065a5..199555d 100644 --- a/src/app/_components/PublicKeyModal.tsx +++ b/src/app/_components/PublicKeyModal.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { X, Copy, Check, Server, Globe } from 'lucide-react'; import { Button } from './ui/button'; -import { useRegisterModal } from './modal/ModalStackProvider'; +import { useRegisterModal, ModalPortal } from './modal/ModalStackProvider'; interface PublicKeyModalProps { isOpen: boolean; @@ -14,7 +14,7 @@ interface PublicKeyModalProps { } export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverIp }: PublicKeyModalProps) { - useRegisterModal(isOpen, { id: 'public-key-modal', allowEscape: true, onClose }); + const zIndex = useRegisterModal(isOpen, { id: 'public-key-modal', allowEscape: true, onClose }); const [copied, setCopied] = useState(false); const [commandCopied, setCommandCopied] = useState(false); @@ -94,7 +94,8 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI }; return ( -
+ +
{/* Header */}
@@ -215,5 +216,6 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
+
); } diff --git a/src/app/_components/ReleaseNotesModal.tsx b/src/app/_components/ReleaseNotesModal.tsx index 24668c6..ec45239 100644 --- a/src/app/_components/ReleaseNotesModal.tsx +++ b/src/app/_components/ReleaseNotesModal.tsx @@ -5,7 +5,7 @@ import { api } from '~/trpc/react'; import { Button } from './ui/button'; import { Badge } from './ui/badge'; import { X, ExternalLink, Calendar, Tag, Loader2 } from 'lucide-react'; -import { useRegisterModal } from './modal/ModalStackProvider'; +import { useRegisterModal, ModalPortal } from './modal/ModalStackProvider'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -35,7 +35,7 @@ const markVersionAsSeen = (version: string): void => { }; export function ReleaseNotesModal({ isOpen, onClose, highlightVersion }: ReleaseNotesModalProps) { - useRegisterModal(isOpen, { id: 'release-notes-modal', allowEscape: true, onClose }); + const zIndex = useRegisterModal(isOpen, { id: 'release-notes-modal', allowEscape: true, onClose }); const [currentVersion, setCurrentVersion] = useState(null); const { data: releasesData, isLoading, error } = api.version.getAllReleases.useQuery(undefined, { enabled: isOpen @@ -66,7 +66,8 @@ export function ReleaseNotesModal({ isOpen, onClose, highlightVersion }: Release const releases: Release[] = releasesData?.success ? releasesData.releases ?? [] : []; return ( -
+ +
{/* Header */}
@@ -215,6 +216,7 @@ export function ReleaseNotesModal({ isOpen, onClose, highlightVersion }: Release
+
); } diff --git a/src/app/_components/ScriptsGrid.tsx b/src/app/_components/ScriptsGrid.tsx index 0762e1d..84ea21b 100644 --- a/src/app/_components/ScriptsGrid.tsx +++ b/src/app/_components/ScriptsGrid.tsx @@ -380,13 +380,14 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) { .filter((s) => s?.date_created) .sort((a, b) => (b?.date_created ?? "").localeCompare(a?.date_created ?? ""), - ); + ) + .slice(0, 15); break; case "updated": scripts = scripts - .filter((s) => s?.updateable) + .filter((s) => s?.date_updated) .sort((a, b) => - (b?.date_created ?? "").localeCompare(a?.date_created ?? ""), + (b?.date_updated ?? "").localeCompare(a?.date_updated ?? ""), ); break; case "dev": diff --git a/src/app/_components/SetupModal.tsx b/src/app/_components/SetupModal.tsx index 77fd59e..f4f50d5 100644 --- a/src/app/_components/SetupModal.tsx +++ b/src/app/_components/SetupModal.tsx @@ -5,7 +5,7 @@ import { Button } from './ui/button'; import { Input } from './ui/input'; import { Toggle } from './ui/toggle'; import { Lock, User, Shield, AlertCircle } from 'lucide-react'; -import { useRegisterModal } from './modal/ModalStackProvider'; +import { useRegisterModal, ModalPortal } from './modal/ModalStackProvider'; interface SetupModalProps { isOpen: boolean; @@ -13,7 +13,7 @@ interface SetupModalProps { } export function SetupModal({ isOpen, onComplete }: SetupModalProps) { - useRegisterModal(isOpen, { id: 'setup-modal', allowEscape: true, onClose: () => null }); + const zIndex = useRegisterModal(isOpen, { id: 'setup-modal', allowEscape: true, onClose: () => null }); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); @@ -84,7 +84,8 @@ export function SetupModal({ isOpen, onComplete }: SetupModalProps) { if (!isOpen) return null; return ( -
+ +
{/* Header */}
@@ -202,5 +203,6 @@ export function SetupModal({ isOpen, onComplete }: SetupModalProps) {
+
); } diff --git a/src/app/_components/StorageSelectionModal.tsx b/src/app/_components/StorageSelectionModal.tsx index 02b9ab9..fe5a02a 100644 --- a/src/app/_components/StorageSelectionModal.tsx +++ b/src/app/_components/StorageSelectionModal.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { Button } from './ui/button'; import { Database, RefreshCw, CheckCircle } from 'lucide-react'; -import { useRegisterModal } from './modal/ModalStackProvider'; +import { useRegisterModal, ModalPortal } from './modal/ModalStackProvider'; import type { Storage } from '~/server/services/storageService'; interface StorageSelectionModalProps { @@ -33,7 +33,7 @@ export function StorageSelectionModal({ }: StorageSelectionModalProps) { const [selectedStorage, setSelectedStorage] = useState(null); - useRegisterModal(isOpen, { id: 'storage-selection-modal', allowEscape: true, onClose }); + const zIndex = useRegisterModal(isOpen, { id: 'storage-selection-modal', allowEscape: true, onClose }); if (!isOpen) return null; @@ -53,7 +53,8 @@ export function StorageSelectionModal({ const filteredStorages = filterFn ? storages.filter(filterFn) : storages.filter(s => s.supportsBackup); return ( -
+ +
{/* Header */}
@@ -171,6 +172,7 @@ export function StorageSelectionModal({
+
); } diff --git a/src/app/_components/TextViewer.tsx b/src/app/_components/TextViewer.tsx index 4440f77..0a66b85 100644 --- a/src/app/_components/TextViewer.tsx +++ b/src/app/_components/TextViewer.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism"; import { Button } from "./ui/button"; +import { useRegisterModal, ModalPortal } from "./modal/ModalStackProvider"; import type { Script } from "../../types/script"; interface TextViewerProps { @@ -182,11 +183,15 @@ export function TextViewer({ } }; + const zIndex = useRegisterModal(isOpen, { id: 'text-viewer-modal', allowEscape: true, onClose }); + if (!isOpen) return null; return ( +
@@ -372,5 +377,6 @@ export function TextViewer({
+ ); } diff --git a/src/app/_components/VersionDisplay.tsx b/src/app/_components/VersionDisplay.tsx index a8376fd..39c4884 100644 --- a/src/app/_components/VersionDisplay.tsx +++ b/src/app/_components/VersionDisplay.tsx @@ -8,6 +8,7 @@ import { UpdateConfirmationModal } from "./UpdateConfirmationModal"; import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react"; import { useState, useEffect, useRef, useCallback } from "react"; +import { createPortal } from "react-dom"; interface VersionDisplayProps { onOpenReleaseNotes?: () => void; @@ -29,7 +30,7 @@ function LoadingOverlay({ }, [logs]); - return ( + return createPortal(
@@ -74,10 +75,10 @@ function LoadingOverlay({
-
+
, + document.body, ); } - export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {}) { const { data: versionStatus, isLoading, error } = api.version.getVersionStatus.useQuery(); const [isUpdating, setIsUpdating] = useState(false); diff --git a/src/server/api/routers/installedScripts.ts b/src/server/api/routers/installedScripts.ts index 99a214b..b34f589 100644 --- a/src/server/api/routers/installedScripts.ts +++ b/src/server/api/routers/installedScripts.ts @@ -479,9 +479,19 @@ async function isVM(scriptId: number, containerId: string, serverId: number | nu } } +// Cache for batch container type detection – avoids repeated SSH calls +const containerTypeCacheByServer = new Map; expiry: number }>(); +const CONTAINER_TYPE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + // Helper function to batch detect container types for all containers on a server // Returns a Map of container_id -> isVM (true for VM, false for LXC) async function batchDetectContainerTypes(server: Server): Promise> { + // Check cache first + const cached = containerTypeCacheByServer.get(server.id); + if (cached && Date.now() < cached.expiry) { + return cached.data; + } + const containerTypeMap = new Map(); try { @@ -580,6 +590,9 @@ async function batchDetectContainerTypes(server: Server): Promise c.name), date_created: pb.script_created, + date_updated: pb.script_updated, interface_port: pb.port, is_dev: pb.is_dev, is_disabled: pb.is_disabled, diff --git a/src/types/script.ts b/src/types/script.ts index 7939158..049dcfa 100644 --- a/src/types/script.ts +++ b/src/types/script.ts @@ -76,6 +76,7 @@ export interface ScriptCard { /** Category names for display / filtering. */ categoryNames?: string[]; date_created?: string; + date_updated?: string; os?: string; version?: string; interface_port?: number | null;