Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
b0e7e94a23 chore: add VERSION v0.4.0 2025-10-13 14:37:11 +00:00
26 changed files with 453 additions and 2269 deletions

View File

@@ -1,6 +1,6 @@
# Template for release drafts
name-template: 'v$NEXT_PATCH_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION
tag-template: 'v$NEXT_PATCH_VERSION'
name-template: 'v$NEXT_MINOR_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION
tag-template: 'v$NEXT_MINOR_VERSION'
# Exclude PRs with this label from release notes
exclude-labels:

View File

@@ -1 +1 @@
0.4.1
0.4.0

922
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,10 +22,9 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.3",
"@t3-oss/env-nextjs": "^0.13.8",
"@tanstack/react-query": "^5.90.3",
"@tanstack/react-query": "^5.87.4",
"@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.6.0",
@@ -40,7 +39,7 @@
"clsx": "^2.1.1",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.545.0",
"next": "^15.5.5",
"next": "^15.5.3",
"node-pty": "^1.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -62,9 +61,9 @@
"@types/bcryptjs": "^3.0.0",
"@types/better-sqlite3": "^7.6.8",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.7.2",
"@types/node": "^24.7.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.2.2",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.2",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
@@ -76,7 +75,7 @@
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.1.14",
"typescript": "^5.8.2",
"typescript-eslint": "^8.46.1",
"typescript-eslint": "^8.27.0",
"vitest": "^3.2.4"
},
"ct3aMetadata": {

41
scripts/json/openwrt.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "OpenWrt",
"slug": "openwrt",
"categories": [
4,
2
],
"date_created": "2024-05-02",
"type": "vm",
"updateable": true,
"privileged": false,
"interface_port": null,
"documentation": "https://openwrt.org/docs/start",
"website": "https://openwrt.org/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/openwrt.webp",
"config_path": "",
"description": "OpenWrt is a powerful open-source firmware that can transform a wide range of networking devices into highly customizable and feature-rich routers, providing users with greater control and flexibility over their network infrastructure.",
"install_methods": [
{
"type": "default",
"script": "vm/openwrt.sh",
"resources": {
"cpu": 1,
"ram": 256,
"hdd": 0.5,
"os": null,
"version": null
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": [
{
"text": "If you use VLANs (default LAN is set to VLAN 999), make sure the Proxmox Linux Bridge is configured as VLAN-aware, otherwise the VM may fail to start.",
"type": "info"
}
]
}

35
scripts/json/petio.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "Petio",
"slug": "petio",
"categories": [
13
],
"date_created": "2024-06-12",
"type": "ct",
"updateable": true,
"privileged": false,
"interface_port": 7777,
"documentation": "https://docs.petio.tv/",
"website": "https://petio.tv/",
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/petio.webp",
"config_path": "",
"description": "Petio is a third party companion app available to Plex server owners to allow their users to request, review and discover content.",
"install_methods": [
{
"type": "default",
"script": "ct/petio.sh",
"resources": {
"cpu": 2,
"ram": 1024,
"hdd": 4,
"os": "ubuntu",
"version": "20.04"
}
}
],
"default_credentials": {
"username": null,
"password": null
},
"notes": []
}

217
server.js
View File

@@ -51,7 +51,6 @@ const handle = app.getRequestHandler();
* @property {string} [mode]
* @property {ServerInfo} [server]
* @property {boolean} [isUpdate]
* @property {boolean} [isShell]
* @property {string} [containerId]
*/
@@ -131,55 +130,6 @@ class ScriptExecutionHandler {
return null;
}
/**
* Parse Web UI URL from terminal output
* @param {string} output - Terminal output to parse
* @returns {{ip: string, port: number}|null} - Object with ip and port if found, null otherwise
*/
parseWebUIUrl(output) {
// First, strip ANSI color codes to make pattern matching more reliable
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
// Look for URL patterns with any valid IP address (private or public)
const patterns = [
// HTTP/HTTPS URLs with IP and port
/https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)/gi,
// URLs without explicit port (assume default ports)
/https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\/|$|\s)/gi,
// URLs with trailing slash and port
/https?:\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)\//gi,
// URLs with just IP and port (no protocol)
/(?:^|\s)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d+)(?:\s|$)/gi,
// URLs with just IP (no protocol, no port)
/(?:^|\s)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\s|$)/gi,
];
// Try patterns on both original and cleaned output
const outputsToTry = [output, cleanOutput];
for (const testOutput of outputsToTry) {
for (const pattern of patterns) {
const matches = [...testOutput.matchAll(pattern)];
for (const match of matches) {
if (match[1]) {
const ip = match[1];
const port = match[2] || (match[0].startsWith('https') ? '443' : '80');
// Validate IP address format
if (ip.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) {
return {
ip: ip,
port: parseInt(port, 10)
};
}
}
}
}
}
return null;
}
/**
* Create installation record
* @param {string} scriptName - Name of the script
@@ -257,15 +207,13 @@ class ScriptExecutionHandler {
* @param {WebSocketMessage} message
*/
async handleMessage(ws, message) {
const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, containerId } = message;
const { action, scriptPath, executionId, input, mode, server, isUpdate, containerId } = message;
switch (action) {
case 'start':
if (scriptPath && executionId) {
if (isUpdate && containerId) {
await this.startUpdateExecution(ws, containerId, executionId, mode, server);
} else if (isShell && containerId) {
await this.startShellExecution(ws, containerId, executionId, mode, server);
} else {
await this.startScriptExecution(ws, scriptPath, executionId, mode, server);
}
@@ -413,18 +361,6 @@ class ScriptExecutionHandler {
this.updateInstallationRecord(installationId, { container_id: containerId });
}
// Parse for Web UI URL
const webUIUrl = this.parseWebUIUrl(output);
if (webUIUrl && installationId) {
const { ip, port } = webUIUrl;
if (ip && port) {
this.updateInstallationRecord(installationId, {
web_ui_ip: ip,
web_ui_port: port
});
}
}
this.sendMessage(ws, {
type: 'output',
data: output,
@@ -508,18 +444,6 @@ class ScriptExecutionHandler {
this.updateInstallationRecord(installationId, { container_id: containerId });
}
// Parse for Web UI URL
const webUIUrl = this.parseWebUIUrl(data);
if (webUIUrl && installationId) {
const { ip, port } = webUIUrl;
if (ip && port) {
this.updateInstallationRecord(installationId, {
web_ui_ip: ip,
web_ui_port: port
});
}
}
// Handle data output
this.sendMessage(ws, {
type: 'output',
@@ -785,145 +709,6 @@ class ScriptExecutionHandler {
});
}
}
/**
* Start shell execution
* @param {ExtendedWebSocket} ws
* @param {string} containerId
* @param {string} executionId
* @param {string} mode
* @param {ServerInfo|null} server
*/
async startShellExecution(ws, containerId, executionId, mode = 'local', server = null) {
try {
// Send start message
this.sendMessage(ws, {
type: 'start',
data: `Starting shell session for container ${containerId}...`,
timestamp: Date.now()
});
if (mode === 'ssh' && server) {
await this.startSSHShellExecution(ws, containerId, executionId, server);
} else {
await this.startLocalShellExecution(ws, containerId, executionId);
}
} catch (error) {
this.sendMessage(ws, {
type: 'error',
data: `Failed to start shell: ${error instanceof Error ? error.message : String(error)}`,
timestamp: Date.now()
});
}
}
/**
* Start local shell execution
* @param {ExtendedWebSocket} ws
* @param {string} containerId
* @param {string} executionId
*/
async startLocalShellExecution(ws, containerId, executionId) {
const { spawn } = await import('node-pty');
// Create a shell process that will run pct enter
const childProcess = spawn('bash', ['-c', `pct enter ${containerId}`], {
name: 'xterm-color',
cols: 80,
rows: 24,
cwd: process.cwd(),
env: process.env
});
// Store the execution
this.activeExecutions.set(executionId, {
process: childProcess,
ws
});
// Handle pty data
childProcess.onData((data) => {
this.sendMessage(ws, {
type: 'output',
data: data.toString(),
timestamp: Date.now()
});
});
// Note: No automatic command is sent - user can type commands interactively
// Handle process exit
childProcess.onExit((e) => {
this.sendMessage(ws, {
type: 'end',
data: `Shell session ended with exit code: ${e.exitCode}`,
timestamp: Date.now()
});
this.activeExecutions.delete(executionId);
});
}
/**
* Start SSH shell execution
* @param {ExtendedWebSocket} ws
* @param {string} containerId
* @param {string} executionId
* @param {ServerInfo} server
*/
async startSSHShellExecution(ws, containerId, executionId, server) {
const sshService = getSSHExecutionService();
try {
const execution = await sshService.executeCommand(
server,
`pct enter ${containerId}`,
/** @param {string} data */
(data) => {
this.sendMessage(ws, {
type: 'output',
data: data,
timestamp: Date.now()
});
},
/** @param {string} error */
(error) => {
this.sendMessage(ws, {
type: 'error',
data: error,
timestamp: Date.now()
});
},
/** @param {number} code */
(code) => {
this.sendMessage(ws, {
type: 'end',
data: `Shell session ended with exit code: ${code}`,
timestamp: Date.now()
});
this.activeExecutions.delete(executionId);
}
);
// Store the execution
this.activeExecutions.set(executionId, {
process: /** @type {any} */ (execution).process,
ws
});
// Note: No automatic command is sent - user can type commands interactively
} catch (error) {
this.sendMessage(ws, {
type: 'error',
data: `SSH shell execution failed: ${error instanceof Error ? error.message : String(error)}`,
timestamp: Date.now()
});
}
}
}
// TerminalHandler removed - not used by current application

View File

@@ -2,6 +2,7 @@
import { useState } from 'react';
import { HelpModal } from './HelpModal';
import { Button } from './ui/button';
import { HelpCircle } from 'lucide-react';
interface ContextualHelpIconProps {
@@ -25,13 +26,15 @@ export function ContextualHelpIcon({
return (
<>
<div
<Button
onClick={() => setIsOpen(true)}
className={`${sizeClasses} text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer inline-flex items-center justify-center rounded-md transition-colors ${className}`}
variant="ghost"
size="icon"
className={`${sizeClasses} text-muted-foreground hover:text-foreground hover:bg-muted ${className}`}
title={tooltip}
>
<HelpCircle className="w-4 h-4" />
</div>
</Button>
<HelpModal
isOpen={isOpen}

View File

@@ -385,7 +385,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
);
}
if (!downloadedScripts?.length) {
if (!downloadedScripts || downloadedScripts.length === 0) {
return (
<div className="text-center py-12">
<div className="text-muted-foreground">

View File

@@ -12,10 +12,10 @@ export function Footer({ onOpenReleaseNotes }: FooterProps) {
const { data: versionData } = api.version.getCurrentVersion.useQuery();
return (
<footer className="sticky bottom-0 mt-auto border-t border-border bg-muted/30 py-3 backdrop-blur-sm">
<footer className="sticky bottom-0 mt-auto border-t border-border bg-muted/30 py-6 backdrop-blur-sm">
<div className="container mx-auto px-4">
<div className="flex flex-col sm:flex-row items-center justify-between gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-4">
<span>© 2024 PVE Scripts Local</span>
{versionData?.success && versionData.version && (
<Button
@@ -29,7 +29,7 @@ export function Footer({ onOpenReleaseNotes }: FooterProps) {
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"

View File

@@ -319,7 +319,6 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
<li> <strong>Installation Status:</strong> Track success, failure, or in-progress installations</li>
<li> <strong>Server Association:</strong> Know which server each script is installed on</li>
<li> <strong>Container ID:</strong> Link scripts to specific LXC containers</li>
<li> <strong>Web UI Access:</strong> Track and access Web UI IP addresses and ports</li>
<li> <strong>Execution Logs:</strong> View output and logs from script installations</li>
<li> <strong>Filtering:</strong> Filter by server, status, or search terms</li>
</ul>
@@ -336,47 +335,8 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
</ul>
</div>
<div className="p-4 border border-border rounded-lg bg-blue-900/20 border-blue-700/50">
<h4 className="font-medium text-foreground mb-2">Web UI Access </h4>
<p className="text-sm text-muted-foreground mb-3">
Automatically detect and access Web UI interfaces for your installed scripts.
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Auto-Detection:</strong> Automatically detects Web UI URLs from script installation output</li>
<li> <strong>IP & Port Tracking:</strong> Stores and displays Web UI IP addresses and ports</li>
<li> <strong>One-Click Access:</strong> Click IP:port to open Web UI in new tab</li>
<li> <strong>Manual Detection:</strong> Re-detect IP using <code>hostname -I</code> inside container</li>
<li> <strong>Port Detection:</strong> Uses script metadata to get correct port (e.g., actualbudget:5006)</li>
<li> <strong>Editable Fields:</strong> Manually edit IP and port values as needed</li>
</ul>
<div className="mt-3 p-3 bg-blue-900/30 rounded-lg border border-blue-700/30">
<p className="text-sm font-medium text-blue-300">💡 How it works:</p>
<ul className="text-sm text-muted-foreground mt-1 space-y-1">
<li> Scripts automatically detect URLs like <code>http://10.10.10.1:3000</code> during installation</li>
<li> Re-detect button runs <code>hostname -I</code> inside the container via SSH</li>
<li> Port defaults to 80, but uses script metadata when available</li>
<li> Web UI buttons are disabled when container is stopped</li>
</ul>
</div>
</div>
<div className="p-4 border border-border rounded-lg bg-accent/50 dark:bg-accent/20">
<h4 className="font-medium text-foreground mb-2">Actions Dropdown </h4>
<p className="text-sm text-muted-foreground mb-3">
Clean interface with all actions organized in a dropdown menu.
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li> <strong>Edit Button:</strong> Always visible for quick script editing</li>
<li> <strong>Actions Dropdown:</strong> Contains Update, Shell, Open UI, Start/Stop, Destroy, Delete</li>
<li> <strong>Smart Visibility:</strong> Dropdown only appears when actions are available</li>
<li> <strong>Color Coding:</strong> Start (green), Stop (red), Update (cyan), Shell (gray), Open UI (blue)</li>
<li> <strong>Auto-Close:</strong> Dropdown closes after clicking any action</li>
<li> <strong>Disabled States:</strong> Actions are disabled when container is stopped</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg bg-accent/50 dark:bg-accent/20">
<h4 className="font-medium text-foreground mb-2">Container Control</h4>
<h4 className="font-medium text-foreground mb-2">Container Control (NEW)</h4>
<p className="text-sm text-muted-foreground mb-3">
Directly control LXC containers from the installed scripts page via SSH.
</p>

View File

@@ -9,13 +9,6 @@ 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;
@@ -27,18 +20,12 @@ interface InstalledScript {
server_ip: string | null;
server_user: string | null;
server_password: string | null;
server_auth_type: string | null;
server_ssh_key: string | null;
server_ssh_key_passphrase: string | null;
server_ssh_port: number | null;
server_color: string | null;
installation_date: string;
status: 'in_progress' | 'success' | 'failed';
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() {
@@ -48,9 +35,8 @@ export function InstalledScriptsTab() {
const [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('server_name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
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<number | null>(null);
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 [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
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);
@@ -73,7 +59,6 @@ export function InstalledScriptsTab() {
} | null>(null);
const [controllingScriptId, setControllingScriptId] = useState<number | null>(null);
const scriptsRef = useRef<InstalledScript[]>([]);
const statusCheckTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Error modal state
const [errorModal, setErrorModal] = useState<{
@@ -101,7 +86,7 @@ export function InstalledScriptsTab() {
onSuccess: () => {
void refetchScripts();
setEditingScriptId(null);
setEditFormData({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' });
setEditFormData({ script_name: '', container_id: '' });
},
onError: (error) => {
alert(`Error updating script: ${error.message}`);
@@ -215,30 +200,7 @@ export function InstalledScriptsTab() {
message: error.message ?? 'Cleanup failed. Please try again.'
});
// Clear status after 5 seconds
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);
setTimeout(() => setCleanupStatus({ type: null, message: '' }), 5000);
}
});
@@ -350,34 +312,17 @@ export function InstalledScriptsTab() {
// Function to fetch container statuses - simplified to just check all servers
const fetchContainerStatuses = useCallback(() => {
console.log('fetchContainerStatuses called, isPending:', containerStatusMutation.isPending);
const currentScripts = scriptsRef.current;
// Prevent multiple simultaneous status checks
if (containerStatusMutation.isPending) {
console.log('Status check already pending, skipping');
return;
}
// Clear any existing timeout
if (statusCheckTimeoutRef.current) {
clearTimeout(statusCheckTimeoutRef.current);
}
// Debounce status checks by 500ms
statusCheckTimeoutRef.current = setTimeout(() => {
const currentScripts = scriptsRef.current;
// Get unique server IDs from scripts
const serverIds = [...new Set(currentScripts
.filter(script => script.server_id)
.map(script => script.server_id!))];
// Get unique server IDs from scripts
const serverIds = [...new Set(currentScripts
.filter(script => script.server_id)
.map(script => script.server_id!))];
console.log('Executing status check for server IDs:', serverIds);
if (serverIds.length > 0) {
containerStatusMutation.mutate({ serverIds });
}
}, 500);
}, [containerStatusMutation]);
if (serverIds.length > 0) {
containerStatusMutation.mutate({ serverIds });
}
}, []); // Empty dependency array to prevent infinite loops
// Run cleanup when component mounts and scripts are loaded (only once)
useEffect(() => {
@@ -388,22 +333,17 @@ export function InstalledScriptsTab() {
}, [scripts.length, serversData?.servers, cleanupMutation]);
// Note: Individual status fetching removed - using bulk fetchContainerStatuses instead
// Trigger status check when tab becomes active (component mounts)
useEffect(() => {
if (scripts.length > 0) {
console.log('Status check triggered - scripts length:', scripts.length);
fetchContainerStatuses();
}
}, [scripts.length, fetchContainerStatuses]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (statusCheckTimeoutRef.current) {
clearTimeout(statusCheckTimeoutRef.current);
}
};
}, []);
}, [scripts.length]); // Only depend on scripts.length to prevent infinite loops
// Update scripts with container statuses
const scriptsWithStatus = scripts.map(script => ({
...script,
container_status: script.container_id ? containerStatuses.get(script.id) ?? 'unknown' : undefined
@@ -563,17 +503,13 @@ export function InstalledScriptsTab() {
onConfirm: () => {
// Get server info if it's SSH mode
let server = null;
if (script.server_id && script.server_user) {
if (script.server_id && script.server_user && script.server_password) {
server = {
id: script.server_id,
name: script.server_name,
ip: script.server_ip,
user: script.server_user,
password: script.server_password,
auth_type: script.server_auth_type ?? 'password',
ssh_key: script.server_ssh_key,
ssh_key_passphrase: script.server_ssh_key_passphrase,
ssh_port: script.server_ssh_port ?? 22
password: script.server_password
};
}
@@ -591,104 +527,17 @@ export function InstalledScriptsTab() {
setUpdatingScript(null);
};
const handleOpenShell = (script: InstalledScript) => {
if (!script.container_id) {
setErrorModal({
isOpen: true,
title: 'Shell Access Failed',
message: 'No Container ID available for this script',
details: 'This script does not have a valid container ID and cannot be accessed via shell.'
});
return;
}
// Get server info if it's SSH mode
let server = null;
if (script.server_id && script.server_user) {
server = {
id: script.server_id,
name: script.server_name,
ip: script.server_ip,
user: script.server_user,
password: script.server_password,
auth_type: script.server_auth_type ?? 'password',
ssh_key: script.server_ssh_key,
ssh_key_passphrase: script.server_ssh_key_passphrase,
ssh_port: script.server_ssh_port ?? 22
};
}
setOpeningShell({
id: script.id,
containerId: script.container_id,
server: server
});
};
const handleCloseShellTerminal = () => {
setOpeningShell(null);
};
// Auto-scroll to terminals when they open
useEffect(() => {
if (openingShell) {
// Small delay to ensure the terminal is rendered
setTimeout(() => {
const terminalElement = document.querySelector('[data-terminal="shell"]');
if (terminalElement) {
// Scroll to the terminal with smooth animation
terminalElement.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
});
// Add a subtle highlight effect
terminalElement.classList.add('animate-pulse');
setTimeout(() => {
terminalElement.classList.remove('animate-pulse');
}, 2000);
}
}, 200);
}
}, [openingShell]);
useEffect(() => {
if (updatingScript) {
// Small delay to ensure the terminal is rendered
setTimeout(() => {
const terminalElement = document.querySelector('[data-terminal="update"]');
if (terminalElement) {
// Scroll to the terminal with smooth animation
terminalElement.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
});
// Add a subtle highlight effect
terminalElement.classList.add('animate-pulse');
setTimeout(() => {
terminalElement.classList.remove('animate-pulse');
}, 2000);
}
}, 200);
}
}, [updatingScript]);
const handleEditScript = (script: InstalledScript) => {
setEditingScriptId(script.id);
setEditFormData({
script_name: script.script_name,
container_id: script.container_id ?? '',
web_ui_ip: script.web_ui_ip ?? '',
web_ui_port: script.web_ui_port?.toString() ?? ''
container_id: script.container_id ?? ''
});
};
const handleCancelEdit = () => {
setEditingScriptId(null);
setEditFormData({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' });
setEditFormData({ script_name: '', container_id: '' });
};
const handleSaveEdit = () => {
@@ -707,13 +556,11 @@ 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' | 'web_ui_ip' | 'web_ui_port', value: string) => {
const handleInputChange = (field: 'script_name' | 'container_id', value: string) => {
setEditFormData(prev => ({
...prev,
[field]: value
@@ -775,54 +622,6 @@ 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();
@@ -840,7 +639,7 @@ export function InstalledScriptsTab() {
<div className="space-y-6">
{/* Update Terminal */}
{updatingScript && (
<div className="mb-8" data-terminal="update">
<div className="mb-8">
<Terminal
scriptPath={`update-${updatingScript.containerId}`}
onClose={handleCloseUpdateTerminal}
@@ -852,20 +651,6 @@ export function InstalledScriptsTab() {
</div>
)}
{/* Shell Terminal */}
{openingShell && (
<div className="mb-8" data-terminal="shell">
<Terminal
scriptPath={`shell-${openingShell.containerId}`}
onClose={handleCloseShellTerminal}
mode={openingShell.server ? 'ssh' : 'local'}
server={openingShell.server}
isShell={true}
containerId={openingShell.containerId}
/>
</div>
)}
{/* Header with Stats */}
<div className="bg-card rounded-lg shadow p-6">
<h2 className="text-2xl font-bold text-foreground mb-4">Installed Scripts</h2>
@@ -1187,7 +972,6 @@ export function InstalledScriptsTab() {
onSave={handleSaveEdit}
onCancel={handleCancelEdit}
onUpdate={() => handleUpdateScript(script)}
onShell={() => handleOpenShell(script)}
onDelete={() => handleDeleteScript(Number(script.id))}
isUpdating={updateScriptMutation.isPending}
isDeleting={deleteScriptMutation.isPending}
@@ -1195,9 +979,6 @@ export function InstalledScriptsTab() {
onStartStop={(action) => handleStartStop(script, action)}
onDestroy={() => handleDestroy(script)}
isControlling={controllingScriptId === script.id}
onOpenWebUI={() => handleOpenWebUI(script)}
onAutoDetectWebUI={() => handleAutoDetectWebUI(script)}
isAutoDetecting={autoDetectWebUIMutation.isPending}
/>
))}
</div>
@@ -1233,9 +1014,6 @@ export function InstalledScriptsTab() {
)}
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Web UI
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
onClick={() => handleSort('server_name')}
@@ -1289,14 +1067,15 @@ export function InstalledScriptsTab() {
>
<td className="px-6 py-4 whitespace-nowrap">
{editingScriptId === script.id ? (
<div className="flex items-center min-h-[2.5rem]">
<div className="space-y-2">
<input
type="text"
value={editFormData.script_name}
onChange={(e) => handleInputChange('script_name', e.target.value)}
className="w-full px-3 py-2 text-sm font-medium border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Script name"
/>
<div className="text-xs text-muted-foreground">{script.script_path}</div>
</div>
) : (
<div>
@@ -1307,15 +1086,13 @@ export function InstalledScriptsTab() {
</td>
<td className="px-6 py-4 whitespace-nowrap">
{editingScriptId === script.id ? (
<div className="flex items-center min-h-[2.5rem]">
<input
type="text"
value={editFormData.container_id}
onChange={(e) => handleInputChange('container_id', e.target.value)}
className="w-full 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="Container ID"
/>
</div>
<input
type="text"
value={editFormData.container_id}
onChange={(e) => handleInputChange('container_id', e.target.value)}
className="w-full 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="Container ID"
/>
) : (
script.container_id ? (
<div className="flex items-center space-x-2">
@@ -1344,62 +1121,6 @@ export function InstalledScriptsTab() {
)
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{editingScriptId === script.id ? (
<div className="flex items-center space-x-2 min-h-[2.5rem]">
<input
type="text"
value={editFormData.web_ui_ip}
onChange={(e) => 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"
/>
<span className="text-muted-foreground">:</span>
<input
type="number"
value={editFormData.web_ui_port}
onChange={(e) => 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"
/>
</div>
) : (
script.web_ui_ip ? (
<div className="flex items-center justify-between w-full">
<button
onClick={() => handleOpenWebUI(script)}
className="text-sm font-mono text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 hover:underline flex-shrink-0"
>
{script.web_ui_ip}:{script.web_ui_port ?? 80}
</button>
{script.container_id && script.execution_mode === 'ssh' && (
<button
onClick={() => handleAutoDetectWebUI(script)}
disabled={autoDetectWebUIMutation.isPending}
className="text-xs px-2 py-1 bg-blue-900 hover:bg-blue-800 text-blue-300 border border-blue-700 rounded disabled:opacity-50 transition-colors flex-shrink-0 ml-2"
title="Re-detect IP and port"
>
{autoDetectWebUIMutation.isPending ? '...' : 'Re-detect'}
</button>
)}
</div>
) : (
<div className="flex items-center space-x-2">
<span className="text-sm text-muted-foreground">-</span>
{script.container_id && script.execution_mode === 'ssh' && (
<button
onClick={() => handleAutoDetectWebUI(script)}
disabled={autoDetectWebUIMutation.isPending}
className="text-xs px-2 py-1 bg-blue-900 hover:bg-blue-800 text-blue-300 border border-blue-700 rounded disabled:opacity-50 transition-colors"
title="Re-detect IP and port"
>
{autoDetectWebUIMutation.isPending ? '...' : 'Re-detect'}
</button>
)}
</div>
)
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-left">
<span
className="text-sm px-3 py-1 rounded inline-block"
@@ -1448,81 +1169,47 @@ export function InstalledScriptsTab() {
>
Edit
</Button>
{hasActions(script) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="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"
>
Actions
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48 bg-gray-900 border-gray-700">
{script.container_id && (
<DropdownMenuItem
onClick={() => 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
</DropdownMenuItem>
)}
{script.container_id && script.execution_mode === 'ssh' && (
<DropdownMenuItem
onClick={() => 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
</DropdownMenuItem>
)}
{script.web_ui_ip && (
<DropdownMenuItem
onClick={() => 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
</DropdownMenuItem>
)}
{script.container_id && script.execution_mode === 'ssh' && (
<>
<DropdownMenuSeparator className="bg-gray-700" />
<DropdownMenuItem
onClick={() => 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'}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => 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'}
</DropdownMenuItem>
</>
)}
{(!script.container_id || script.execution_mode !== 'ssh') && (
<>
<DropdownMenuSeparator className="bg-gray-700" />
<DropdownMenuItem
onClick={() => 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'}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{script.container_id && (
<Button
onClick={() => handleUpdateScript(script)}
variant="update"
size="sm"
disabled={containerStatuses.get(script.id) === 'stopped'}
>
Update
</Button>
)}
{/* Container Control Buttons - only show for SSH scripts with container_id */}
{script.container_id && script.execution_mode === 'ssh' && (
<>
<Button
onClick={() => handleStartStop(script, (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'stop' : 'start')}
disabled={controllingScriptId === script.id || (containerStatuses.get(script.id) ?? 'unknown') === 'unknown'}
variant={(containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'destructive' : 'default'}
size="sm"
>
{controllingScriptId === script.id ? 'Working...' : (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'Stop' : 'Start'}
</Button>
<Button
onClick={() => handleDestroy(script)}
disabled={controllingScriptId === script.id}
variant="destructive"
size="sm"
>
{controllingScriptId === script.id ? 'Working...' : 'Destroy'}
</Button>
</>
)}
{/* Fallback to old Delete button for non-SSH scripts */}
{(!script.container_id || script.execution_mode !== 'ssh') && (
<Button
onClick={() => handleDeleteScript(Number(script.id))}
variant="delete"
size="sm"
disabled={deleteScriptMutation.isPending}
>
{deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
)}
</>
)}

View File

@@ -93,22 +93,9 @@ export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHK
);
if (keyLine) {
let keyType = 'Unknown';
// Check for traditional PEM format keys
if (keyLine.includes('RSA')) {
keyType = 'RSA';
} else if (keyLine.includes('ED25519')) {
keyType = 'ED25519';
} else if (keyLine.includes('ECDSA')) {
keyType = 'ECDSA';
} else if (keyLine.includes('OPENSSH PRIVATE KEY')) {
// For OpenSSH format keys, try to detect type from the key content
// This is a heuristic - OpenSSH ED25519 keys typically start with specific patterns
// We'll default to "OpenSSH" for now since we can't reliably detect the type
keyType = 'OpenSSH';
}
const keyType = keyLine.includes('RSA') ? 'RSA' :
keyLine.includes('ED25519') ? 'ED25519' :
keyLine.includes('ECDSA') ? 'ECDSA' : 'Unknown';
return `${keyType} key (${keyContent.length} characters)`;
}
@@ -155,7 +142,7 @@ export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHK
<input
ref={fileInputRef}
type="file"
accept=".pem,.key,.id_rsa,.id_ed25519,.id_ecdsa,ed25519,id_rsa,id_ed25519,id_ecdsa,*"
accept=".pem,.key,.id_rsa,.id_ed25519,.id_ecdsa"
onChange={handleFileSelect}
className="hidden"
disabled={disabled}
@@ -166,7 +153,7 @@ export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHK
Drag and drop your SSH private key here, or click to browse
</p>
<p className="text-xs text-muted-foreground">
Supported formats: RSA, ED25519, ECDSA (.pem, .key, .id_rsa, ed25519, etc.)
Supported formats: RSA, ED25519, ECDSA (.pem, .key, .id_rsa, etc.)
</p>
</div>
</div>

View File

@@ -3,13 +3,6 @@
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;
@@ -21,30 +14,23 @@ interface InstalledScript {
server_ip: string | null;
server_user: string | null;
server_password: string | null;
server_auth_type: string | null;
server_ssh_key: string | null;
server_ssh_key_passphrase: string | null;
server_ssh_port: number | null;
server_color: string | null;
installation_date: string;
status: 'in_progress' | 'success' | 'failed';
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; web_ui_ip: string; web_ui_port: string };
onInputChange: (field: 'script_name' | 'container_id' | 'web_ui_ip' | 'web_ui_port', value: string) => void;
editFormData: { script_name: string; container_id: string };
onInputChange: (field: 'script_name' | 'container_id', value: string) => void;
onEdit: () => void;
onSave: () => void;
onCancel: () => void;
onUpdate: () => void;
onShell: () => void;
onDelete: () => void;
isUpdating: boolean;
isDeleting: boolean;
@@ -53,10 +39,6 @@ interface ScriptInstallationCardProps {
onStartStop: (action: 'start' | 'stop') => void;
onDestroy: () => void;
isControlling: boolean;
// Web UI props
onOpenWebUI: () => void;
onAutoDetectWebUI: () => void;
isAutoDetecting: boolean;
}
export function ScriptInstallationCard({
@@ -68,30 +50,18 @@ export function ScriptInstallationCard({
onSave,
onCancel,
onUpdate,
onShell,
onDelete,
isUpdating,
isDeleting,
containerStatus,
onStartStop,
onDestroy,
isControlling,
onOpenWebUI,
onAutoDetectWebUI,
isAutoDetecting
isControlling
}: 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 (
<div
className="bg-card border border-border rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow"
@@ -167,70 +137,6 @@ export function ScriptInstallationCard({
)}
</div>
{/* Web UI */}
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">IP:PORT</div>
{isEditing ? (
<div className="flex items-center space-x-2">
<input
type="text"
value={editFormData.web_ui_ip}
onChange={(e) => 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"
/>
<span className="text-muted-foreground">:</span>
<input
type="number"
value={editFormData.web_ui_port}
onChange={(e) => 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"
/>
</div>
) : (
<div className="text-sm font-mono text-foreground">
{script.web_ui_ip ? (
<div className="flex items-center justify-between w-full">
<button
onClick={onOpenWebUI}
disabled={containerStatus === 'stopped'}
className={`text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 hover:underline flex-shrink-0 ${
containerStatus === 'stopped' ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{script.web_ui_ip}:{script.web_ui_port ?? 80}
</button>
{script.container_id && script.execution_mode === 'ssh' && (
<button
onClick={onAutoDetectWebUI}
disabled={isAutoDetecting}
className="text-xs px-2 py-1 bg-blue-900 hover:bg-blue-800 text-blue-300 border border-blue-700 rounded disabled:opacity-50 transition-colors flex-shrink-0 ml-2"
title="Re-detect IP and port"
>
{isAutoDetecting ? '...' : 'Re-detect'}
</button>
)}
</div>
) : (
<div className="flex items-center space-x-2">
<span className="text-muted-foreground">-</span>
{script.container_id && script.execution_mode === 'ssh' && (
<button
onClick={onAutoDetectWebUI}
disabled={isAutoDetecting}
className="text-xs px-2 py-1 bg-blue-900 hover:bg-blue-800 text-blue-300 border border-blue-700 rounded disabled:opacity-50 transition-colors"
title="Re-detect IP and port"
>
{isAutoDetecting ? '...' : 'Re-detect'}
</button>
)}
</div>
)}
</div>
)}
</div>
{/* Server */}
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">Server</div>
@@ -286,81 +192,51 @@ export function ScriptInstallationCard({
>
Edit
</Button>
{hasActions(script) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex-1 min-w-0 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"
>
Actions
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48 bg-gray-900 border-gray-700">
{script.container_id && (
<DropdownMenuItem
onClick={onUpdate}
disabled={containerStatus === 'stopped'}
className="text-cyan-300 hover:text-cyan-200 hover:bg-cyan-900/20 focus:bg-cyan-900/20"
>
Update
</DropdownMenuItem>
)}
{script.container_id && script.execution_mode === 'ssh' && (
<DropdownMenuItem
onClick={onShell}
disabled={containerStatus === 'stopped'}
className="text-gray-300 hover:text-gray-200 hover:bg-gray-800/20 focus:bg-gray-800/20"
>
Shell
</DropdownMenuItem>
)}
{script.web_ui_ip && (
<DropdownMenuItem
onClick={onOpenWebUI}
disabled={containerStatus === 'stopped'}
className="text-blue-300 hover:text-blue-200 hover:bg-blue-900/20 focus:bg-blue-900/20"
>
Open UI
</DropdownMenuItem>
)}
{script.container_id && script.execution_mode === 'ssh' && (
<>
<DropdownMenuSeparator className="bg-gray-700" />
<DropdownMenuItem
onClick={() => 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'}
</DropdownMenuItem>
<DropdownMenuItem
onClick={onDestroy}
disabled={isControlling}
className="text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20"
>
{isControlling ? 'Working...' : 'Destroy'}
</DropdownMenuItem>
</>
)}
{(!script.container_id || script.execution_mode !== 'ssh') && (
<>
<DropdownMenuSeparator className="bg-gray-700" />
<DropdownMenuItem
onClick={onDelete}
disabled={isDeleting}
className="text-red-300 hover:text-red-200 hover:bg-red-900/20 focus:bg-red-900/20"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{script.container_id && (
<Button
onClick={onUpdate}
variant="update"
size="sm"
className="flex-1 min-w-0"
disabled={containerStatus === 'stopped'}
>
Update
</Button>
)}
{/* Container Control Buttons - only show for SSH scripts with container_id */}
{script.container_id && script.execution_mode === 'ssh' && (
<>
<Button
onClick={() => onStartStop(containerStatus === 'running' ? 'stop' : 'start')}
disabled={isControlling || containerStatus === 'unknown'}
variant={containerStatus === 'running' ? 'destructive' : 'default'}
size="sm"
className="flex-1 min-w-0"
>
{isControlling ? 'Working...' : containerStatus === 'running' ? 'Stop' : 'Start'}
</Button>
<Button
onClick={onDestroy}
disabled={isControlling}
variant="destructive"
size="sm"
className="flex-1 min-w-0"
>
{isControlling ? 'Working...' : 'Destroy'}
</Button>
</>
)}
{/* Fallback to old Delete button for non-SSH scripts */}
{(!script.container_id || script.execution_mode !== 'ssh') && (
<Button
onClick={onDelete}
variant="delete"
size="sm"
disabled={isDeleting}
className="flex-1 min-w-0"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
)}
</>
)}

View File

@@ -571,7 +571,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
);
}
if (!scriptsWithStatus?.length) {
if (!scriptsWithStatus || scriptsWithStatus.length === 0) {
return (
<div className="text-center py-12">
<div className="text-muted-foreground">
@@ -626,13 +626,11 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
<Button
onClick={handleBatchDownload}
disabled={loadSingleScriptMutation.isPending}
variant="outline"
size="sm"
className="bg-blue-500/10 hover:bg-blue-500/20 border-blue-500/30 text-blue-300 hover:text-blue-200 hover:border-blue-400/50"
className="bg-blue-600 hover:bg-blue-700 text-white"
>
{loadSingleScriptMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current mr-2"></div>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Downloading...
</>
) : (
@@ -644,7 +642,6 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
onClick={handleDownloadAllFiltered}
disabled={filteredScripts.length === 0 || loadSingleScriptMutation.isPending}
variant="outline"
size="sm"
>
{loadSingleScriptMutation.isPending ? (
<>
@@ -660,8 +657,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
{selectedSlugs.size > 0 && (
<Button
onClick={clearSelection}
variant="outline"
size="default"
variant="ghost"
size="sm"
>
Clear Selection
</Button>
@@ -670,8 +667,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
{filteredScripts.length > 0 && (
<Button
onClick={selectAllVisible}
variant="outline"
size="default"
variant="ghost"
size="sm"
>
Select All Visible
</Button>

View File

@@ -4,7 +4,6 @@ import { useState } from 'react';
import type { Server, CreateServerData } from '../../types/server';
import { ServerForm } from './ServerForm';
import { Button } from './ui/button';
import { ConfirmationModal } from './ConfirmationModal';
interface ServerListProps {
servers: Server[];
@@ -16,14 +15,6 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
const [editingId, setEditingId] = useState<number | null>(null);
const [testingConnections, setTestingConnections] = useState<Set<number>>(new Set());
const [connectionResults, setConnectionResults] = useState<Map<number, { success: boolean; message: string }>>(new Map());
const [confirmationModal, setConfirmationModal] = useState<{
isOpen: boolean;
variant: 'danger';
title: string;
message: string;
confirmText: string;
onConfirm: () => void;
} | null>(null);
const handleEdit = (server: Server) => {
setEditingId(server.id);
@@ -41,20 +32,9 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
};
const handleDelete = (id: number) => {
const server = servers.find(s => s.id === id);
if (!server) return;
setConfirmationModal({
isOpen: true,
variant: 'danger',
title: 'Delete Server',
message: `This will permanently delete the server configuration "${server.name}" (${server.ip}) and all associated installed scripts. This action cannot be undone!`,
confirmText: server.name,
onConfirm: () => {
onDelete(id);
setConfirmationModal(null);
}
});
if (window.confirm('Are you sure you want to delete this server configuration?')) {
onDelete(id);
}
};
const handleTestConnection = async (server: Server) => {
@@ -248,21 +228,6 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
)}
</div>
))}
{/* Confirmation Modal */}
{confirmationModal && (
<ConfirmationModal
isOpen={confirmationModal.isOpen}
onClose={() => setConfirmationModal(null)}
onConfirm={confirmationModal.onConfirm}
title={confirmationModal.title}
message={confirmationModal.message}
variant={confirmationModal.variant}
confirmText={confirmationModal.confirmText}
confirmButtonText="Delete Server"
cancelButtonText="Cancel"
/>
)}
</div>
);
}

View File

@@ -11,7 +11,6 @@ interface TerminalProps {
mode?: 'local' | 'ssh';
server?: any;
isUpdate?: boolean;
isShell?: boolean;
containerId?: string;
}
@@ -21,7 +20,7 @@ interface TerminalMessage {
timestamp: number;
}
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, containerId }: TerminalProps) {
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, containerId }: TerminalProps) {
const [isConnected, setIsConnected] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const [isClient, setIsClient] = useState(false);
@@ -333,7 +332,6 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
mode,
server,
isUpdate,
isShell,
containerId
};
ws.send(JSON.stringify(message));
@@ -374,7 +372,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
wsRef.current.close();
}
};
}, [scriptPath, mode, server, isUpdate, isShell, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
}, [scriptPath, mode, server, isUpdate, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
const startScript = () => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
@@ -390,7 +388,6 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
mode,
server,
isUpdate,
isShell,
containerId
}));
}

View File

@@ -37,10 +37,6 @@ 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",

View File

@@ -1,198 +0,0 @@
"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<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@@ -173,9 +173,6 @@ export async function DELETE(
);
}
// Delete all installed scripts associated with this server
db.deleteInstalledScriptsByServer(id);
const result = db.deleteServer(id);
return NextResponse.json(

View File

@@ -20,13 +20,7 @@ import { api } from '~/trpc/react';
export default function Home() {
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>(() => {
if (typeof window !== 'undefined') {
const savedTab = localStorage.getItem('activeTab') as 'scripts' | 'downloaded' | 'installed';
return savedTab || 'scripts';
}
return 'scripts';
});
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>('scripts');
const [releaseNotesOpen, setReleaseNotesOpen] = useState(false);
const [highlightVersion, setHighlightVersion] = useState<string | undefined>(undefined);
const terminalRef = useRef<HTMLDivElement>(null);
@@ -37,13 +31,6 @@ export default function Home() {
const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.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) {
@@ -70,42 +57,15 @@ export default function Home() {
// 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, any>();
scriptCardsData.cards?.forEach(script => {
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;
})(),
available: scriptCardsData?.success ? scriptCardsData.cards?.length ?? 0 : 0,
downloaded: (() => {
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
// First deduplicate GitHub scripts using Map by slug
const scriptMap = new Map<string, any>();
scriptCardsData.cards?.forEach(script => {
if (script?.name && script?.slug) {
if (!scriptMap.has(script.slug)) {
scriptMap.set(script.slug, script);
}
}
});
const deduplicatedGithubScripts = Array.from(scriptMap.values());
// Count scripts that are both in GitHub data and have local versions
const githubScripts = scriptCardsData.cards ?? [];
const localScripts = localScriptsData.scripts ?? [];
// Count scripts that are both in deduplicated GitHub data and have local versions
return deduplicatedGithubScripts.filter(script => {
return githubScripts.filter(script => {
if (!script?.name) return false;
return localScripts.some(local => {
if (!local?.name) return false;
@@ -171,58 +131,64 @@ export default function Home() {
{/* Tab Navigation */}
<div className="mb-6 sm:mb-8">
<div className="border-b border-border">
<nav className="-mb-px flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-1">
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('scripts')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
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="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.available}
</span>
<nav className="-mb-px flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2 lg:space-x-8">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('scripts')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
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="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.available}
</span>
</Button>
<ContextualHelpIcon section="available-scripts" tooltip="Help with Available Scripts" />
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('downloaded')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
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="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.downloaded}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('downloaded')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
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="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.downloaded}
</span>
</Button>
<ContextualHelpIcon section="downloaded-scripts" tooltip="Help with Downloaded Scripts" />
</Button>
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('installed')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
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="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.installed}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="null"
onClick={() => setActiveTab('installed')}
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
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="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
{scriptCounts.installed}
</span>
</Button>
<ContextualHelpIcon section="installed-scripts" tooltip="Help with Installed Scripts" />
</Button>
</div>
</nav>
</div>
</div>

View File

@@ -3,7 +3,7 @@
* to ensure optimal readability based on luminance
*/
export function getContrastColor(hexColor: string): 'black' | 'white' {
if (!hexColor?.length || hexColor.length !== 7 || !hexColor.startsWith('#')) {
if (!hexColor || hexColor.length !== 7 || !hexColor.startsWith('#')) {
return 'black'; // Default to black for invalid colors
}

View File

@@ -83,9 +83,7 @@ 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(),
web_ui_ip: z.string().optional(),
web_ui_port: z.number().optional()
output_log: z.string().optional()
}))
.mutation(async ({ input }) => {
try {
@@ -112,9 +110,7 @@ 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(),
web_ui_ip: z.string().optional(),
web_ui_port: z.number().optional()
output_log: z.string().optional()
}))
.mutation(async ({ input }) => {
try {
@@ -555,31 +551,23 @@ export const installedScriptsRouter = createTRPCRouter({
const listCommand = 'pct list';
let listOutput = '';
// Add timeout to prevent hanging connections
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('SSH command timeout after 30 seconds')), 30000);
await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
server as any,
listCommand,
(data: string) => {
listOutput += data;
},
(error: string) => {
console.error(`pct list error on server ${(server as any).name}:`, error);
reject(new Error(error));
},
(_exitCode: number) => {
resolve();
}
);
});
await Promise.race([
new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
server as any,
listCommand,
(data: string) => {
listOutput += data;
},
(error: string) => {
console.error(`pct list error on server ${(server as any).name}:`, error);
reject(new Error(error));
},
(_exitCode: number) => {
resolve();
}
);
}),
timeoutPromise
]);
// Parse pct list output
const lines = listOutput.split('\n').filter(line => line.trim());
@@ -918,8 +906,9 @@ export const installedScriptsRouter = createTRPCRouter({
);
});
}
} catch {
} catch (_error) {
// If status check fails, continue with destroy attempt
// The destroy command will handle the error appropriately
}
// Execute destroy command
@@ -976,177 +965,5 @@ 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<void>((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'
};
}
})
});

View File

@@ -78,24 +78,6 @@ 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 (
@@ -108,8 +90,6 @@ class DatabaseService {
installation_date DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT NOT NULL CHECK(status IN ('in_progress', 'success', 'failed')),
output_log TEXT,
web_ui_ip TEXT,
web_ui_port INTEGER,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE SET NULL
)
`);
@@ -182,16 +162,14 @@ 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, web_ui_ip, web_ui_port } = scriptData;
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log } = scriptData;
const stmt = this.db.prepare(`
INSERT INTO installed_scripts (script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO installed_scripts (script_name, script_path, container_id, server_id, execution_mode, status, output_log)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
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);
return stmt.run(script_name, script_path, container_id || null, server_id || null, execution_mode, status, output_log || null);
}
getAllInstalledScripts() {
@@ -202,10 +180,6 @@ class DatabaseService {
s.ip as server_ip,
s.user as server_user,
s.password as server_password,
s.auth_type as server_auth_type,
s.ssh_key as server_ssh_key,
s.ssh_key_passphrase as server_ssh_key_passphrase,
s.ssh_port as server_ssh_port,
s.color as server_color
FROM installed_scripts inst
LEFT JOIN servers s ON inst.server_id = s.id
@@ -254,11 +228,9 @@ 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, web_ui_ip, web_ui_port } = updateData;
const { script_name, container_id, status, output_log } = updateData;
const updates = [];
const values = [];
@@ -278,14 +250,6 @@ 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 };
@@ -308,14 +272,6 @@ class DatabaseService {
return stmt.run(id);
}
/**
* @param {number} server_id
*/
deleteInstalledScriptsByServer(server_id) {
const stmt = this.db.prepare('DELETE FROM installed_scripts WHERE server_id = ?');
return stmt.run(server_id);
}
close() {
this.db.close();
}

View File

@@ -32,9 +32,7 @@ class SSHExecutionService {
const tempDir = mkdtempSync(join(tmpdir(), 'ssh-key-'));
const tempKeyPath = join(tempDir, 'private_key');
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
const normalizedKey = ssh_key.trimEnd() + '\n';
writeFileSync(tempKeyPath, normalizedKey);
writeFileSync(tempKeyPath, ssh_key);
chmodSync(tempKeyPath, 0o600); // Set proper permissions
return tempKeyPath;
@@ -297,7 +295,7 @@ class SSHExecutionService {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
unlinkSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
@@ -316,7 +314,7 @@ class SSHExecutionService {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
unlinkSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
@@ -330,7 +328,7 @@ class SSHExecutionService {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
unlinkSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
@@ -385,7 +383,7 @@ class SSHExecutionService {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
unlinkSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
@@ -416,7 +414,7 @@ class SSHExecutionService {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
unlinkSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}

View File

@@ -557,9 +557,7 @@ expect {
tempKeyPath = join(tempDir, 'private_key');
// Write the private key to temporary file
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
const normalizedKey = ssh_key.trimEnd() + '\n';
writeFileSync(tempKeyPath, normalizedKey);
writeFileSync(tempKeyPath, ssh_key);
chmodSync(tempKeyPath, 0o600); // Set proper permissions
// Build SSH command