• Installation Status: Track success, failure, or in-progress installations
• Server Association: Know which server each script is installed on
• Container ID: Link scripts to specific LXC containers
+ • Web UI Access: Track and access Web UI IP addresses and ports
• Execution Logs: View output and logs from script installations
• Filtering: Filter by server, status, or search terms
@@ -335,8 +336,47 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
+
+
Web UI Access
+
+ Automatically detect and access Web UI interfaces for your installed scripts.
+
+
+ - • Auto-Detection: Automatically detects Web UI URLs from script installation output
+ - • IP & Port Tracking: Stores and displays Web UI IP addresses and ports
+ - • One-Click Access: Click IP:port to open Web UI in new tab
+ - • Manual Detection: Re-detect IP using
hostname -I inside container
+ - • Port Detection: Uses script metadata to get correct port (e.g., actualbudget:5006)
+ - • Editable Fields: Manually edit IP and port values as needed
+
+
+
💡 How it works:
+
+ - • Scripts automatically detect URLs like
http://10.10.10.1:3000 during installation
+ - • Re-detect button runs
hostname -I inside the container via SSH
+ - • Port defaults to 80, but uses script metadata when available
+ - • Web UI buttons are disabled when container is stopped
+
+
+
+
+
+
Actions Dropdown
+
+ Clean interface with all actions organized in a dropdown menu.
+
+
+ - • Edit Button: Always visible for quick script editing
+ - • Actions Dropdown: Contains Update, Shell, Open UI, Start/Stop, Destroy, Delete
+ - • Smart Visibility: Dropdown only appears when actions are available
+ - • Color Coding: Start (green), Stop (red), Update (cyan), Shell (gray), Open UI (blue)
+ - • Auto-Close: Dropdown closes after clicking any action
+ - • Disabled States: Actions are disabled when container is stopped
+
+
+
-
Container Control (NEW)
+
Container Control
Directly control LXC containers from the installed scripts page via SSH.
diff --git a/src/app/_components/InstalledScriptsTab.tsx b/src/app/_components/InstalledScriptsTab.tsx
index 12f1477..8aca574 100644
--- a/src/app/_components/InstalledScriptsTab.tsx
+++ b/src/app/_components/InstalledScriptsTab.tsx
@@ -9,6 +9,13 @@ import { ScriptInstallationCard } from './ScriptInstallationCard';
import { ConfirmationModal } from './ConfirmationModal';
import { ErrorModal } from './ErrorModal';
import { getContrastColor } from '../../lib/colorUtils';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ DropdownMenuSeparator,
+} from './ui/dropdown-menu';
interface InstalledScript {
id: number;
@@ -30,6 +37,8 @@ interface InstalledScript {
output_log: string | null;
execution_mode: 'local' | 'ssh';
container_status?: 'running' | 'stopped' | 'unknown';
+ web_ui_ip: string | null;
+ web_ui_port: number | null;
}
export function InstalledScriptsTab() {
@@ -41,7 +50,7 @@ export function InstalledScriptsTab() {
const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null);
const [openingShell, setOpeningShell] = useState<{ id: number; containerId: string; server?: any } | null>(null);
const [editingScriptId, setEditingScriptId] = useState
(null);
- const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
+ const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string; web_ui_ip: string; web_ui_port: string }>({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' });
const [showAddForm, setShowAddForm] = useState(false);
const [addFormData, setAddFormData] = useState<{ script_name: string; container_id: string; server_id: string }>({ script_name: '', container_id: '', server_id: 'local' });
const [showAutoDetectForm, setShowAutoDetectForm] = useState(false);
@@ -92,7 +101,7 @@ export function InstalledScriptsTab() {
onSuccess: () => {
void refetchScripts();
setEditingScriptId(null);
- setEditFormData({ script_name: '', container_id: '' });
+ setEditFormData({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' });
},
onError: (error) => {
alert(`Error updating script: ${error.message}`);
@@ -206,7 +215,30 @@ export function InstalledScriptsTab() {
message: error.message ?? 'Cleanup failed. Please try again.'
});
// Clear status after 5 seconds
- setTimeout(() => setCleanupStatus({ type: null, message: '' }), 5000);
+ setTimeout(() => setCleanupStatus({ type: null, message: '' }), 8000);
+ }
+ });
+
+ // Auto-detect Web UI mutation
+ const autoDetectWebUIMutation = api.installedScripts.autoDetectWebUI.useMutation({
+ onSuccess: (data) => {
+ console.log('✅ Auto-detect WebUI success:', data);
+ void refetchScripts();
+ setAutoDetectStatus({
+ type: 'success',
+ message: data.message ?? 'Web UI IP detected successfully!'
+ });
+ // Clear status after 5 seconds
+ setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000);
+ },
+ onError: (error) => {
+ console.error('❌ Auto-detect Web UI error:', error);
+ setAutoDetectStatus({
+ type: 'error',
+ message: error.message ?? 'Auto-detect failed. Please try again.'
+ });
+ // Clear status after 5 seconds
+ setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000);
}
});
@@ -648,13 +680,15 @@ export function InstalledScriptsTab() {
setEditingScriptId(script.id);
setEditFormData({
script_name: script.script_name,
- container_id: script.container_id ?? ''
+ container_id: script.container_id ?? '',
+ web_ui_ip: script.web_ui_ip ?? '',
+ web_ui_port: script.web_ui_port?.toString() ?? ''
});
};
const handleCancelEdit = () => {
setEditingScriptId(null);
- setEditFormData({ script_name: '', container_id: '' });
+ setEditFormData({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' });
};
const handleSaveEdit = () => {
@@ -673,11 +707,13 @@ export function InstalledScriptsTab() {
id: editingScriptId,
script_name: editFormData.script_name.trim(),
container_id: editFormData.container_id.trim() || undefined,
+ web_ui_ip: editFormData.web_ui_ip.trim() || undefined,
+ web_ui_port: editFormData.web_ui_port.trim() ? parseInt(editFormData.web_ui_port, 10) : undefined,
});
}
};
- const handleInputChange = (field: 'script_name' | 'container_id', value: string) => {
+ const handleInputChange = (field: 'script_name' | 'container_id' | 'web_ui_ip' | 'web_ui_port', value: string) => {
setEditFormData(prev => ({
...prev,
[field]: value
@@ -739,6 +775,54 @@ export function InstalledScriptsTab() {
}
};
+ const handleAutoDetectWebUI = (script: InstalledScript) => {
+ console.log('🔍 Auto-detect WebUI clicked for script:', script);
+ console.log('Script validation:', {
+ hasContainerId: !!script.container_id,
+ isSSHMode: script.execution_mode === 'ssh',
+ containerId: script.container_id,
+ executionMode: script.execution_mode
+ });
+
+ if (!script.container_id || script.execution_mode !== 'ssh') {
+ console.log('❌ Auto-detect validation failed');
+ setErrorModal({
+ isOpen: true,
+ title: 'Auto-Detect Failed',
+ message: 'Auto-detect only works for SSH mode scripts with container ID',
+ details: 'This script does not have a valid container ID or is not in SSH mode.'
+ });
+ return;
+ }
+
+ console.log('✅ Calling autoDetectWebUIMutation.mutate with id:', script.id);
+ autoDetectWebUIMutation.mutate({ id: script.id });
+ };
+
+ const handleOpenWebUI = (script: InstalledScript) => {
+ if (!script.web_ui_ip) {
+ setErrorModal({
+ isOpen: true,
+ title: 'Web UI Access Failed',
+ message: 'No IP address configured for this script',
+ details: 'Please set the Web UI IP address before opening the interface.'
+ });
+ return;
+ }
+
+ const port = script.web_ui_port ?? 80;
+ const url = `http://${script.web_ui_ip}:${port}`;
+ window.open(url, '_blank', 'noopener,noreferrer');
+ };
+
+ // Helper function to check if a script has any actions available
+ const hasActions = (script: InstalledScript) => {
+ if (script.container_id && script.execution_mode === 'ssh') return true;
+ if (script.web_ui_ip != null) return true;
+ if (!script.container_id || script.execution_mode !== 'ssh') return true;
+ return false;
+ };
+
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
@@ -1111,6 +1195,9 @@ export function InstalledScriptsTab() {
onStartStop={(action) => handleStartStop(script, action)}
onDestroy={() => handleDestroy(script)}
isControlling={controllingScriptId === script.id}
+ onOpenWebUI={() => handleOpenWebUI(script)}
+ onAutoDetectWebUI={() => handleAutoDetectWebUI(script)}
+ isAutoDetecting={autoDetectWebUIMutation.isPending}
/>
))}
@@ -1146,6 +1233,9 @@ export function InstalledScriptsTab() {
)}
+
+ Web UI
+ |
handleSort('server_name')}
@@ -1254,6 +1344,62 @@ export function InstalledScriptsTab() {
)
)}
+ |
+ {editingScriptId === script.id ? (
+
+ handleInputChange('web_ui_ip', e.target.value)}
+ className="w-32 px-3 py-2 text-sm font-mono border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
+ placeholder="IP"
+ />
+ :
+ handleInputChange('web_ui_port', e.target.value)}
+ className="w-20 px-3 py-2 text-sm font-mono border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
+ placeholder="Port"
+ />
+
+ ) : (
+ script.web_ui_ip ? (
+
+
+ {script.container_id && script.execution_mode === 'ssh' && (
+
+ )}
+
+ ) : (
+
+ -
+ {script.container_id && script.execution_mode === 'ssh' && (
+
+ )}
+
+ )
+ )}
+ |
Edit
- {script.container_id && (
-
- )}
- {/* Shell button - only show for SSH scripts with container_id */}
- {script.container_id && script.execution_mode === 'ssh' && (
-
- )}
- {/* Container Control Buttons - only show for SSH scripts with container_id */}
- {script.container_id && script.execution_mode === 'ssh' && (
- <>
-
-
- >
- )}
- {/* Fallback to old Delete button for non-SSH scripts */}
- {(!script.container_id || script.execution_mode !== 'ssh') && (
-
+ {hasActions(script) && (
+
+
+
+
+
+ {script.container_id && (
+ handleUpdateScript(script)}
+ disabled={containerStatuses.get(script.id) === 'stopped'}
+ className="text-cyan-300 hover:text-cyan-200 hover:bg-cyan-900/20 focus:bg-cyan-900/20"
+ >
+ Update
+
+ )}
+ {script.container_id && script.execution_mode === 'ssh' && (
+ handleOpenShell(script)}
+ disabled={containerStatuses.get(script.id) === 'stopped'}
+ className="text-gray-300 hover:text-gray-200 hover:bg-gray-800/20 focus:bg-gray-800/20"
+ >
+ Shell
+
+ )}
+ {script.web_ui_ip && (
+ handleOpenWebUI(script)}
+ disabled={containerStatuses.get(script.id) === 'stopped'}
+ className="text-blue-300 hover:text-blue-200 hover:bg-blue-900/20 focus:bg-blue-900/20"
+ >
+ Open UI
+
+ )}
+ {script.container_id && script.execution_mode === 'ssh' && (
+ <>
+
+ handleStartStop(script, (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'stop' : 'start')}
+ disabled={controllingScriptId === script.id || (containerStatuses.get(script.id) ?? 'unknown') === 'unknown'}
+ className={(containerStatuses.get(script.id) ?? 'unknown') === 'running'
+ ? "text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20"
+ : "text-green-300 hover:text-green-200 hover:bg-green-900/20 focus:bg-green-900/20"
+ }
+ >
+ {controllingScriptId === script.id ? 'Working...' : (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'Stop' : 'Start'}
+
+ handleDestroy(script)}
+ disabled={controllingScriptId === script.id}
+ className="text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20"
+ >
+ {controllingScriptId === script.id ? 'Working...' : 'Destroy'}
+
+ >
+ )}
+ {(!script.container_id || script.execution_mode !== 'ssh') && (
+ <>
+
+ handleDeleteScript(Number(script.id))}
+ disabled={deleteScriptMutation.isPending}
+ className="text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20"
+ >
+ {deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'}
+
+ >
+ )}
+
+
)}
>
)}
diff --git a/src/app/_components/ScriptInstallationCard.tsx b/src/app/_components/ScriptInstallationCard.tsx
index 37350d5..864d97e 100644
--- a/src/app/_components/ScriptInstallationCard.tsx
+++ b/src/app/_components/ScriptInstallationCard.tsx
@@ -3,6 +3,13 @@
import { Button } from './ui/button';
import { StatusBadge } from './Badge';
import { getContrastColor } from '../../lib/colorUtils';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ DropdownMenuSeparator,
+} from './ui/dropdown-menu';
interface InstalledScript {
id: number;
@@ -24,13 +31,15 @@ interface InstalledScript {
output_log: string | null;
execution_mode: 'local' | 'ssh';
container_status?: 'running' | 'stopped' | 'unknown';
+ web_ui_ip: string | null;
+ web_ui_port: number | null;
}
interface ScriptInstallationCardProps {
script: InstalledScript;
isEditing: boolean;
- editFormData: { script_name: string; container_id: string };
- onInputChange: (field: 'script_name' | 'container_id', value: string) => void;
+ editFormData: { script_name: string; container_id: string; web_ui_ip: string; web_ui_port: string };
+ onInputChange: (field: 'script_name' | 'container_id' | 'web_ui_ip' | 'web_ui_port', value: string) => void;
onEdit: () => void;
onSave: () => void;
onCancel: () => void;
@@ -44,6 +53,10 @@ interface ScriptInstallationCardProps {
onStartStop: (action: 'start' | 'stop') => void;
onDestroy: () => void;
isControlling: boolean;
+ // Web UI props
+ onOpenWebUI: () => void;
+ onAutoDetectWebUI: () => void;
+ isAutoDetecting: boolean;
}
export function ScriptInstallationCard({
@@ -62,12 +75,23 @@ export function ScriptInstallationCard({
containerStatus,
onStartStop,
onDestroy,
- isControlling
+ isControlling,
+ onOpenWebUI,
+ onAutoDetectWebUI,
+ isAutoDetecting
}: ScriptInstallationCardProps) {
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
+ // Helper function to check if a script has any actions available
+ const hasActions = (script: InstalledScript) => {
+ if (script.container_id && script.execution_mode === 'ssh') return true;
+ if (script.web_ui_ip != null) return true;
+ if (!script.container_id || script.execution_mode !== 'ssh') return true;
+ return false;
+ };
+
return (
+ {/* Web UI */}
+
+ IP:PORT
+ {isEditing ? (
+
+ onInputChange('web_ui_ip', e.target.value)}
+ className="flex-1 px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
+ placeholder="IP"
+ />
+ :
+ onInputChange('web_ui_port', e.target.value)}
+ className="w-20 px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
+ placeholder="Port"
+ />
+
+ ) : (
+
+ {script.web_ui_ip ? (
+
+
+ {script.container_id && script.execution_mode === 'ssh' && (
+
+ )}
+
+ ) : (
+
+ -
+ {script.container_id && script.execution_mode === 'ssh' && (
+
+ )}
+
+ )}
+
+ )}
+
+
{/* Server */}
Server
@@ -198,63 +286,81 @@ export function ScriptInstallationCard({
>
Edit
- {script.container_id && (
-
- )}
- {/* Shell button - only show for SSH scripts with container_id */}
- {script.container_id && script.execution_mode === 'ssh' && (
-
- )}
- {/* Container Control Buttons - only show for SSH scripts with container_id */}
- {script.container_id && script.execution_mode === 'ssh' && (
- <>
-
-
- >
- )}
- {/* Fallback to old Delete button for non-SSH scripts */}
- {(!script.container_id || script.execution_mode !== 'ssh') && (
-
+ {hasActions(script) && (
+
+
+
+
+
+ {script.container_id && (
+
+ Update
+
+ )}
+ {script.container_id && script.execution_mode === 'ssh' && (
+
+ Shell
+
+ )}
+ {script.web_ui_ip && (
+
+ Open UI
+
+ )}
+ {script.container_id && script.execution_mode === 'ssh' && (
+ <>
+
+ onStartStop(containerStatus === 'running' ? 'stop' : 'start')}
+ disabled={isControlling || containerStatus === 'unknown'}
+ className={containerStatus === 'running'
+ ? "text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20"
+ : "text-green-300 hover:text-green-200 hover:bg-green-900/20 focus:bg-green-900/20"
+ }
+ >
+ {isControlling ? 'Working...' : containerStatus === 'running' ? 'Stop' : 'Start'}
+
+
+ {isControlling ? 'Working...' : 'Destroy'}
+
+ >
+ )}
+ {(!script.container_id || script.execution_mode !== 'ssh') && (
+ <>
+
+
+ {isDeleting ? 'Deleting...' : 'Delete'}
+
+ >
+ )}
+
+
)}
>
)}
diff --git a/src/app/_components/ui/button.tsx b/src/app/_components/ui/button.tsx
index 8fb2a4b..78b54f4 100644
--- a/src/app/_components/ui/button.tsx
+++ b/src/app/_components/ui/button.tsx
@@ -37,6 +37,10 @@ const buttonVariants = cva(
// Dark theme action button variants
edit: "bg-blue-900/20 hover:bg-blue-900/30 border border-blue-700/50 text-blue-300 hover:text-blue-200 hover:border-blue-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
update: "bg-cyan-900/20 hover:bg-cyan-900/30 border border-cyan-700/50 text-cyan-300 hover:text-cyan-200 hover:border-cyan-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
+ shell: "bg-gray-800/20 hover:bg-gray-800/30 border border-gray-600/50 text-gray-300 hover:text-gray-200 hover:border-gray-500/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
+ openui: "bg-blue-900/20 hover:bg-blue-900/30 border border-blue-700/50 text-blue-300 hover:text-blue-200 hover:border-blue-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
+ start: "bg-green-900/20 hover:bg-green-900/30 border border-green-700/50 text-green-300 hover:text-green-200 hover:border-green-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
+ stop: "bg-red-900/20 hover:bg-red-900/30 border border-red-700/50 text-red-300 hover:text-red-200 hover:border-red-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
delete: "bg-red-900/20 hover:bg-red-900/30 border border-red-700/50 text-red-300 hover:text-red-200 hover:border-red-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md disabled:hover:scale-100",
save: "bg-green-900/20 hover:bg-green-900/30 border border-green-700/50 text-green-300 hover:text-green-200 hover:border-green-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md disabled:hover:scale-100",
cancel: "bg-gray-800/20 hover:bg-gray-800/30 border border-gray-600/50 text-gray-300 hover:text-gray-200 hover:border-gray-500/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
diff --git a/src/app/_components/ui/dropdown-menu.tsx b/src/app/_components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..54d818d
--- /dev/null
+++ b/src/app/_components/ui/dropdown-menu.tsx
@@ -0,0 +1,198 @@
+"use client";
+
+import * as React from "react";
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
+import { Check, ChevronRight, Circle } from "lucide-react";
+import { cn } from "~/lib/utils";
+
+const DropdownMenu = DropdownMenuPrimitive.Root;
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group;
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub;
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef ,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+));
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName;
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName;
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+));
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName;
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+
+));
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ );
+};
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+};
diff --git a/src/server/api/routers/installedScripts.ts b/src/server/api/routers/installedScripts.ts
index c6c0b1a..3a25704 100644
--- a/src/server/api/routers/installedScripts.ts
+++ b/src/server/api/routers/installedScripts.ts
@@ -83,7 +83,9 @@ export const installedScriptsRouter = createTRPCRouter({
server_id: z.number().optional(),
execution_mode: z.enum(['local', 'ssh']),
status: z.enum(['in_progress', 'success', 'failed']),
- output_log: z.string().optional()
+ output_log: z.string().optional(),
+ web_ui_ip: z.string().optional(),
+ web_ui_port: z.number().optional()
}))
.mutation(async ({ input }) => {
try {
@@ -110,7 +112,9 @@ export const installedScriptsRouter = createTRPCRouter({
script_name: z.string().optional(),
container_id: z.string().optional(),
status: z.enum(['in_progress', 'success', 'failed']).optional(),
- output_log: z.string().optional()
+ output_log: z.string().optional(),
+ web_ui_ip: z.string().optional(),
+ web_ui_port: z.number().optional()
}))
.mutation(async ({ input }) => {
try {
@@ -972,5 +976,177 @@ export const installedScriptsRouter = createTRPCRouter({
error: error instanceof Error ? error.message : 'Failed to destroy container'
};
}
+ }),
+
+ // Auto-detect Web UI IP and port
+ autoDetectWebUI: publicProcedure
+ .input(z.object({ id: z.number() }))
+ .mutation(async ({ input }) => {
+ try {
+ console.log('🔍 Auto-detect WebUI called with id:', input.id);
+ const db = getDatabase();
+ const script = db.getInstalledScriptById(input.id);
+
+ if (!script) {
+ console.log('❌ Script not found for id:', input.id);
+ return {
+ success: false,
+ error: 'Script not found'
+ };
+ }
+
+ const scriptData = script as any;
+ console.log('📋 Script data:', {
+ id: scriptData.id,
+ execution_mode: scriptData.execution_mode,
+ server_id: scriptData.server_id,
+ container_id: scriptData.container_id
+ });
+
+ // Only works for SSH mode scripts with container_id
+ if (scriptData.execution_mode !== 'ssh' || !scriptData.server_id || !scriptData.container_id) {
+ console.log('❌ Validation failed - not SSH mode or missing server/container ID');
+ return {
+ success: false,
+ error: 'Auto-detect only works for SSH mode scripts with container ID'
+ };
+ }
+
+ // Get server info
+ const server = db.getServerById(Number(scriptData.server_id));
+ if (!server) {
+ console.log('❌ Server not found for id:', scriptData.server_id);
+ return {
+ success: false,
+ error: 'Server not found'
+ };
+ }
+
+ console.log('🖥️ Server found:', { id: (server as any).id, name: (server as any).name, ip: (server as any).ip });
+
+ // Import SSH services
+ const { default: SSHService } = await import('~/server/ssh-service');
+ const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
+ const sshService = new SSHService();
+ const sshExecutionService = new SSHExecutionService();
+
+ // Test SSH connection first
+ console.log('🔌 Testing SSH connection...');
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+ const connectionTest = await sshService.testSSHConnection(server as any);
+ if (!(connectionTest as any).success) {
+ console.log('❌ SSH connection failed:', (connectionTest as any).error);
+ return {
+ success: false,
+ error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`
+ };
+ }
+
+ console.log('✅ SSH connection successful');
+
+ // Run hostname -I inside the container
+ // Use pct exec instead of pct enter -c (which doesn't exist)
+ const hostnameCommand = `pct exec ${scriptData.container_id} -- hostname -I`;
+ console.log('🚀 Running command:', hostnameCommand);
+ let commandOutput = '';
+
+ await new Promise((resolve, reject) => {
+ void sshExecutionService.executeCommand(
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+ server as any,
+ hostnameCommand,
+ (data: string) => {
+ console.log('📤 Command output chunk:', data);
+ commandOutput += data;
+ },
+ (error: string) => {
+ console.log('❌ Command error:', error);
+ reject(new Error(error));
+ },
+ (exitCode: number) => {
+ console.log('🏁 Command finished with exit code:', exitCode);
+ if (exitCode !== 0) {
+ reject(new Error(`Command failed with exit code ${exitCode}`));
+ } else {
+ resolve();
+ }
+ }
+ );
+ });
+
+ // Parse output to get first IP address
+ console.log('📝 Full command output:', commandOutput);
+ const ips = commandOutput.trim().split(/\s+/);
+ const detectedIp = ips[0];
+ console.log('🔍 Parsed IPs:', ips);
+ console.log('🎯 Detected IP:', detectedIp);
+
+ if (!detectedIp || !/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.exec(detectedIp)) {
+ console.log('❌ Invalid IP address detected:', detectedIp);
+ return {
+ success: false,
+ error: 'Could not detect valid IP address from container'
+ };
+ }
+
+ // Get the script's interface_port from metadata (prioritize metadata over existing database values)
+ let detectedPort = 80; // Default fallback
+
+ try {
+ // Import localScriptsService to get script metadata
+ const { localScriptsService } = await import('~/server/services/localScripts');
+
+ // Get all scripts and find the one matching our script name
+ const allScripts = await localScriptsService.getAllScripts();
+
+ // Extract script slug from script_name (remove .sh extension)
+ const scriptSlug = scriptData.script_name.replace(/\.sh$/, '');
+ console.log('🔍 Looking for script with slug:', scriptSlug);
+
+ const scriptMetadata = allScripts.find(script => script.slug === scriptSlug);
+
+ if (scriptMetadata?.interface_port) {
+ detectedPort = scriptMetadata.interface_port;
+ console.log('📋 Found interface_port in metadata:', detectedPort);
+ } else {
+ console.log('📋 No interface_port found in metadata, using default port 80');
+ detectedPort = 80; // Default to port 80 if no metadata port found
+ }
+ } catch (error) {
+ console.log('⚠️ Error getting script metadata, using default port 80:', error);
+ detectedPort = 80; // Default to port 80 if metadata lookup fails
+ }
+
+ console.log('🎯 Final detected port:', detectedPort);
+
+ // Update the database with detected IP and port
+ console.log('💾 Updating database with IP:', detectedIp, 'Port:', detectedPort);
+ const updateResult = db.updateInstalledScript(input.id, {
+ web_ui_ip: detectedIp,
+ web_ui_port: detectedPort
+ });
+
+ if (updateResult.changes === 0) {
+ console.log('❌ Database update failed - no changes made');
+ return {
+ success: false,
+ error: 'Failed to update database with detected IP'
+ };
+ }
+
+ console.log('✅ Successfully updated database');
+ return {
+ success: true,
+ message: `Successfully detected IP: ${detectedIp}:${detectedPort}`,
+ detectedIp,
+ detectedPort: detectedPort
+ };
+ } catch (error) {
+ console.error('Error in autoDetectWebUI:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to auto-detect Web UI IP'
+ };
+ }
})
});
diff --git a/src/server/database.js b/src/server/database.js
index ff6ceb4..aa9fe46 100644
--- a/src/server/database.js
+++ b/src/server/database.js
@@ -78,6 +78,24 @@ class DatabaseService {
UPDATE servers SET ssh_port = 22 WHERE ssh_port IS NULL
`);
+ // Migration: Add web_ui_ip column to existing installed_scripts table
+ try {
+ this.db.exec(`
+ ALTER TABLE installed_scripts ADD COLUMN web_ui_ip TEXT
+ `);
+ } catch (e) {
+ // Column already exists, ignore error
+ }
+
+ // Migration: Add web_ui_port column to existing installed_scripts table
+ try {
+ this.db.exec(`
+ ALTER TABLE installed_scripts ADD COLUMN web_ui_port INTEGER
+ `);
+ } catch (e) {
+ // Column already exists, ignore error
+ }
+
// Create installed_scripts table if it doesn't exist
this.db.exec(`
CREATE TABLE IF NOT EXISTS installed_scripts (
@@ -90,7 +108,9 @@ class DatabaseService {
installation_date DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT NOT NULL CHECK(status IN ('in_progress', 'success', 'failed')),
output_log TEXT,
- FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
+ web_ui_ip TEXT,
+ web_ui_port INTEGER,
+ FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE SET NULL
)
`);
@@ -162,14 +182,16 @@ class DatabaseService {
* @param {string} scriptData.execution_mode
* @param {string} scriptData.status
* @param {string} [scriptData.output_log]
+ * @param {string} [scriptData.web_ui_ip]
+ * @param {number} [scriptData.web_ui_port]
*/
createInstalledScript(scriptData) {
- const { script_name, script_path, container_id, server_id, execution_mode, status, output_log } = scriptData;
+ const { script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port } = scriptData;
const stmt = this.db.prepare(`
- INSERT INTO installed_scripts (script_name, script_path, container_id, server_id, execution_mode, status, output_log)
- VALUES (?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO installed_scripts (script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
- return stmt.run(script_name, script_path, container_id || null, server_id || null, execution_mode, status, output_log || null);
+ return stmt.run(script_name, script_path, container_id || null, server_id || null, execution_mode, status, output_log || null, web_ui_ip || null, web_ui_port || null);
}
getAllInstalledScripts() {
@@ -232,9 +254,11 @@ class DatabaseService {
* @param {string} [updateData.container_id]
* @param {string} [updateData.status]
* @param {string} [updateData.output_log]
+ * @param {string} [updateData.web_ui_ip]
+ * @param {number} [updateData.web_ui_port]
*/
updateInstalledScript(id, updateData) {
- const { script_name, container_id, status, output_log } = updateData;
+ const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData;
const updates = [];
const values = [];
@@ -254,6 +278,14 @@ class DatabaseService {
updates.push('output_log = ?');
values.push(output_log);
}
+ if (web_ui_ip !== undefined) {
+ updates.push('web_ui_ip = ?');
+ values.push(web_ui_ip);
+ }
+ if (web_ui_port !== undefined) {
+ updates.push('web_ui_port = ?');
+ values.push(web_ui_port);
+ }
if (updates.length === 0) {
return { changes: 0 };
|