feat: improve LXC settings modal and fix database issues (#174)

- Fix Prisma database errors in LXC config sync (advanced and rootfs field issues)
- Remove double confirmation from LXC settings modal (keep confirmation modal, remove inline input)
- Fix dependency loop in status check useEffect
- Add LXC configuration management with proper validation
- Improve error handling and user experience
This commit is contained in:
Michel Roegl-Brunner
2025-10-17 11:38:23 +02:00
committed by GitHub
parent ef460b5a00
commit 537d65275a
16 changed files with 1425 additions and 51 deletions

View File

@@ -0,0 +1,74 @@
-- CreateTable
CREATE TABLE "installed_scripts" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"script_name" TEXT NOT NULL,
"script_path" TEXT NOT NULL,
"container_id" TEXT,
"server_id" INTEGER,
"execution_mode" TEXT NOT NULL,
"installation_date" DATETIME DEFAULT CURRENT_TIMESTAMP,
"status" TEXT NOT NULL,
"output_log" TEXT,
"web_ui_ip" TEXT,
"web_ui_port" INTEGER,
CONSTRAINT "installed_scripts_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "servers" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "servers" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"ip" TEXT NOT NULL,
"user" TEXT NOT NULL,
"password" TEXT,
"auth_type" TEXT DEFAULT 'password',
"ssh_key" TEXT,
"ssh_key_passphrase" TEXT,
"ssh_port" INTEGER DEFAULT 22,
"color" TEXT,
"created_at" DATETIME DEFAULT CURRENT_TIMESTAMP,
"updated_at" DATETIME,
"ssh_key_path" TEXT,
"key_generated" BOOLEAN DEFAULT false
);
-- CreateTable
CREATE TABLE "lxc_configs" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"installed_script_id" INTEGER NOT NULL,
"arch" TEXT,
"cores" INTEGER,
"memory" INTEGER,
"hostname" TEXT,
"swap" INTEGER,
"onboot" INTEGER,
"ostype" TEXT,
"unprivileged" INTEGER,
"net_name" TEXT,
"net_bridge" TEXT,
"net_hwaddr" TEXT,
"net_ip_type" TEXT,
"net_ip" TEXT,
"net_gateway" TEXT,
"net_type" TEXT,
"net_vlan" INTEGER,
"rootfs_storage" TEXT,
"rootfs_size" TEXT,
"feature_keyctl" INTEGER,
"feature_nesting" INTEGER,
"feature_fuse" INTEGER,
"feature_mount" TEXT,
"tags" TEXT,
"advanced_config" TEXT,
"synced_at" DATETIME,
"config_hash" TEXT,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" DATETIME NOT NULL,
CONSTRAINT "lxc_configs_installed_script_id_fkey" FOREIGN KEY ("installed_script_id") REFERENCES "installed_scripts" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "servers_name_key" ON "servers"("name");
-- CreateIndex
CREATE UNIQUE INDEX "lxc_configs_installed_script_id_key" ON "lxc_configs"("installed_script_id");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

View File

@@ -20,6 +20,7 @@ model InstalledScript {
web_ui_ip String? web_ui_ip String?
web_ui_port Int? web_ui_port Int?
server Server? @relation(fields: [server_id], references: [id], onDelete: SetNull) server Server? @relation(fields: [server_id], references: [id], onDelete: SetNull)
lxc_config LXCConfig?
@@map("installed_scripts") @@map("installed_scripts")
} }
@@ -43,3 +44,54 @@ model Server {
@@map("servers") @@map("servers")
} }
model LXCConfig {
id Int @id @default(autoincrement())
installed_script_id Int @unique
installed_script InstalledScript @relation(fields: [installed_script_id], references: [id], onDelete: Cascade)
// Basic settings
arch String?
cores Int?
memory Int?
hostname String?
swap Int?
onboot Int? // 0 or 1
ostype String?
unprivileged Int? // 0 or 1
// Network settings (net0)
net_name String?
net_bridge String?
net_hwaddr String?
net_ip_type String? // 'dhcp' or 'static'
net_ip String? // IP with CIDR for static
net_gateway String?
net_type String? // usually 'veth'
net_vlan Int?
// Storage
rootfs_storage String?
rootfs_size String?
// Features
feature_keyctl Int? // 0 or 1
feature_nesting Int? // 0 or 1
feature_fuse Int? // 0 or 1
feature_mount String? // other mount features
// Tags
tags String?
// Advanced/raw settings (lxc.* entries and other uncommon settings)
advanced_config String? // Text blob for advanced settings
// Metadata
synced_at DateTime?
config_hash String? // Hash of server config for diff detection
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@map("lxc_configs")
}

View File

@@ -10,7 +10,7 @@ interface HelpModalProps {
initialSection?: string; initialSection?: string;
} }
type HelpSection = 'server-settings' | 'general-settings' | 'sync-button' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'update-system'; type HelpSection = 'server-settings' | 'general-settings' | 'sync-button' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system';
export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) { export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) {
const [activeSection, setActiveSection] = useState<HelpSection>(initialSection as HelpSection); const [activeSection, setActiveSection] = useState<HelpSection>(initialSection as HelpSection);
@@ -24,6 +24,7 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
{ id: 'available-scripts' as HelpSection, label: 'Available Scripts', icon: Package }, { id: 'available-scripts' as HelpSection, label: 'Available Scripts', icon: Package },
{ id: 'downloaded-scripts' as HelpSection, label: 'Downloaded Scripts', icon: HardDrive }, { id: 'downloaded-scripts' as HelpSection, label: 'Downloaded Scripts', icon: HardDrive },
{ id: 'installed-scripts' as HelpSection, label: 'Installed Scripts', icon: FolderOpen }, { id: 'installed-scripts' as HelpSection, label: 'Installed Scripts', icon: FolderOpen },
{ id: 'lxc-settings' as HelpSection, label: 'LXC Settings', icon: Settings },
{ id: 'update-system' as HelpSection, label: 'Update System', icon: Download }, { id: 'update-system' as HelpSection, label: 'Update System', icon: Download },
]; ];
@@ -501,6 +502,131 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
</div> </div>
); );
case 'lxc-settings':
return (
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold text-foreground mb-4">LXC Settings</h3>
<p className="text-muted-foreground mb-6">
Edit LXC container configuration files directly from the installed scripts interface. This feature allows you to modify container settings without manually accessing the Proxmox VE server.
</p>
</div>
<div className="space-y-4">
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Overview</h4>
<p className="text-sm text-muted-foreground mb-3">
The LXC Settings modal provides a user-friendly interface to edit container configuration files. It parses common settings into editable fields while preserving advanced configurations.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> <strong>Common Settings:</strong> Edit basic container parameters like cores, memory, network, and storage</li>
<li> <strong>Advanced Settings:</strong> Raw text editing for lxc.* entries and other advanced configurations</li>
<li> <strong>Database Caching:</strong> Configurations are cached locally for faster access</li>
<li> <strong>Change Detection:</strong> Warns when cached config differs from server version</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Common Settings Tab</h4>
<div className="space-y-3">
<div>
<h5 className="font-medium text-sm text-foreground mb-1">Basic Configuration</h5>
<ul className="text-sm text-muted-foreground space-y-1">
<li> <strong>Architecture:</strong> Container architecture (usually amd64)</li>
<li> <strong>Cores:</strong> Number of CPU cores allocated to the container</li>
<li> <strong>Memory:</strong> RAM allocation in megabytes</li>
<li> <strong>Swap:</strong> Swap space allocation in megabytes</li>
<li> <strong>Hostname:</strong> Container hostname</li>
<li> <strong>OS Type:</strong> Operating system type (e.g., debian, ubuntu)</li>
<li> <strong>Start on Boot:</strong> Whether to start container automatically on host boot</li>
<li> <strong>Unprivileged:</strong> Whether the container runs in unprivileged mode</li>
</ul>
</div>
<div>
<h5 className="font-medium text-sm text-foreground mb-1">Network Configuration</h5>
<ul className="text-sm text-muted-foreground space-y-1">
<li> <strong>IP Configuration:</strong> Choose between DHCP or static IP assignment</li>
<li> <strong>IP Address:</strong> Static IP with CIDR notation (e.g., 10.10.10.164/24)</li>
<li> <strong>Gateway:</strong> Network gateway for static IP configuration</li>
<li> <strong>Bridge:</strong> Network bridge interface (usually vmbr0)</li>
<li> <strong>MAC Address:</strong> Hardware address for the network interface</li>
<li> <strong>VLAN Tag:</strong> Optional VLAN tag for network segmentation</li>
</ul>
</div>
<div>
<h5 className="font-medium text-sm text-foreground mb-1">Storage & Features</h5>
<ul className="text-sm text-muted-foreground space-y-1">
<li> <strong>Root Filesystem:</strong> Storage location and disk identifier</li>
<li> <strong>Size:</strong> Disk size allocation (e.g., 4G, 8G)</li>
<li> <strong>Features:</strong> Container capabilities (keyctl, nesting, fuse)</li>
<li> <strong>Tags:</strong> Comma-separated tags for organization</li>
</ul>
</div>
</div>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Advanced Settings Tab</h4>
<p className="text-sm text-muted-foreground mb-3">
The Advanced Settings tab provides raw text editing for configurations not covered in the Common Settings tab.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> <strong>lxc.* entries:</strong> Low-level LXC configuration options</li>
<li> <strong>Comments:</strong> Configuration file comments and documentation</li>
<li> <strong>Custom settings:</strong> Any other configuration parameters</li>
<li> <strong>Preservation:</strong> All content is preserved when switching between tabs</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Saving Changes</h4>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
To save configuration changes, you must type the container ID exactly as shown to confirm your changes.
</p>
<div className="bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3">
<h5 className="font-medium text-yellow-800 dark:text-yellow-200 mb-2"> Important Warnings</h5>
<ul className="text-sm text-yellow-700 dark:text-yellow-300 space-y-1">
<li> Modifying LXC configuration can break your container</li>
<li> Some changes may require container restart to take effect</li>
<li> Always backup your configuration before making changes</li>
<li> Test changes in a non-production environment first</li>
</ul>
</div>
</div>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Sync from Server</h4>
<p className="text-sm text-muted-foreground mb-3">
The &quot;Sync from Server&quot; button allows you to refresh the configuration from the actual server file, useful when:
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Configuration was modified outside of this interface</li>
<li> You want to discard local changes and get the latest server version</li>
<li> The warning banner indicates the cached config differs from server</li>
<li> You want to ensure you&apos;re working with the most current configuration</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Database Caching</h4>
<p className="text-sm text-muted-foreground mb-3">
LXC configurations are cached in the database for improved performance and offline access.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> <strong>Automatic caching:</strong> Configs are cached during auto-detection and after saves</li>
<li> <strong>Cache expiration:</strong> Cached configs expire after 5 minutes for freshness</li>
<li> <strong>Change detection:</strong> Hash comparison detects external modifications</li>
<li> <strong>Manual sync:</strong> Always available via the &quot;Sync from Server&quot; button</li>
</ul>
</div>
</div>
</div>
);
default: default:
return null; return null;
} }

View File

@@ -9,6 +9,7 @@ import { ScriptInstallationCard } from './ScriptInstallationCard';
import { ConfirmationModal } from './ConfirmationModal'; import { ConfirmationModal } from './ConfirmationModal';
import { ErrorModal } from './ErrorModal'; import { ErrorModal } from './ErrorModal';
import { LoadingModal } from './LoadingModal'; import { LoadingModal } from './LoadingModal';
import { LXCSettingsModal } from './LXCSettingsModal';
import { getContrastColor } from '../../lib/colorUtils'; import { getContrastColor } from '../../lib/colorUtils';
import { import {
DropdownMenu, DropdownMenu,
@@ -17,6 +18,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuSeparator, DropdownMenuSeparator,
} from './ui/dropdown-menu'; } from './ui/dropdown-menu';
import { Settings } from 'lucide-react';
interface InstalledScript { interface InstalledScript {
id: number; id: number;
@@ -91,6 +93,12 @@ export function InstalledScriptsTab() {
action: string; action: string;
} | null>(null); } | null>(null);
// LXC Settings modal state
const [lxcSettingsModal, setLxcSettingsModal] = useState<{
isOpen: boolean;
script: InstalledScript | null;
}>({ isOpen: false, script: null });
// Fetch installed scripts // Fetch installed scripts
const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery(); const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery();
const { data: statsData } = api.installedScripts.getInstallationStats.useQuery(); const { data: statsData } = api.installedScripts.getInstallationStats.useQuery();
@@ -388,7 +396,7 @@ export function InstalledScriptsTab() {
containerStatusMutation.mutate({ serverIds }); containerStatusMutation.mutate({ serverIds });
} }
}, 500); }, 500);
}, []); }, []);
// Run cleanup when component mounts and scripts are loaded (only once) // Run cleanup when component mounts and scripts are loaded (only once)
useEffect(() => { useEffect(() => {
@@ -404,7 +412,7 @@ export function InstalledScriptsTab() {
console.log('Status check triggered - scripts length:', scripts.length); console.log('Status check triggered - scripts length:', scripts.length);
fetchContainerStatuses(); fetchContainerStatuses();
} }
}, [scripts.length, fetchContainerStatuses]); }, [scripts.length]);
// Cleanup timeout on unmount // Cleanup timeout on unmount
useEffect(() => { useEffect(() => {
@@ -704,6 +712,10 @@ export function InstalledScriptsTab() {
setEditFormData({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' }); setEditFormData({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' });
}; };
const handleLXCSettings = (script: InstalledScript) => {
setLxcSettingsModal({ isOpen: true, script });
};
const handleSaveEdit = () => { const handleSaveEdit = () => {
if (!editFormData.script_name.trim()) { if (!editFormData.script_name.trim()) {
setErrorModal({ setErrorModal({
@@ -922,7 +934,7 @@ export function InstalledScriptsTab() {
</Button> </Button>
<Button <Button
onClick={fetchContainerStatuses} onClick={fetchContainerStatuses}
disabled={containerStatusMutation.isPending || scripts.length === 0} disabled={containerStatusMutation.isPending ?? scripts.length === 0}
variant="outline" variant="outline"
size="default" size="default"
> >
@@ -1127,7 +1139,7 @@ export function InstalledScriptsTab() {
</Button> </Button>
<Button <Button
onClick={handleAutoDetect} onClick={handleAutoDetect}
disabled={autoDetectMutation.isPending || !autoDetectServerId} disabled={autoDetectMutation.isPending ?? !autoDetectServerId}
variant="default" variant="default"
size="default" size="default"
className="w-full sm:w-auto" className="w-full sm:w-auto"
@@ -1499,7 +1511,7 @@ export function InstalledScriptsTab() {
{script.container_id && script.execution_mode === 'ssh' && script.web_ui_ip && ( {script.container_id && script.execution_mode === 'ssh' && script.web_ui_ip && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleAutoDetectWebUI(script)} onClick={() => handleAutoDetectWebUI(script)}
disabled={autoDetectWebUIMutation.isPending || containerStatuses.get(script.id) === 'stopped'} disabled={autoDetectWebUIMutation.isPending ?? containerStatuses.get(script.id) === 'stopped'}
className="text-blue-300 hover:text-blue-200 hover:bg-blue-900/20 focus:bg-blue-900/20" className="text-blue-300 hover:text-blue-200 hover:bg-blue-900/20 focus:bg-blue-900/20"
> >
{autoDetectWebUIMutation.isPending ? 'Re-detect...' : 'Re-detect IP/Port'} {autoDetectWebUIMutation.isPending ? 'Re-detect...' : 'Re-detect IP/Port'}
@@ -1507,6 +1519,14 @@ export function InstalledScriptsTab() {
)} )}
{script.container_id && script.execution_mode === 'ssh' && ( {script.container_id && script.execution_mode === 'ssh' && (
<> <>
<DropdownMenuSeparator className="bg-gray-700" />
<DropdownMenuItem
onClick={() => handleLXCSettings(script)}
className="text-purple-300 hover:text-purple-200 hover:bg-purple-900/20 focus:bg-purple-900/20"
>
<Settings className="mr-2 h-4 w-4" />
LXC Settings
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-gray-700" /> <DropdownMenuSeparator className="bg-gray-700" />
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleStartStop(script, (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'stop' : 'start')} onClick={() => handleStartStop(script, (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'stop' : 'start')}
@@ -1587,6 +1607,17 @@ export function InstalledScriptsTab() {
action={loadingModal.action} action={loadingModal.action}
/> />
)} )}
{/* LXC Settings Modal */}
<LXCSettingsModal
isOpen={lxcSettingsModal.isOpen}
script={lxcSettingsModal.script}
onClose={() => setLxcSettingsModal({ isOpen: false, script: null })}
onSave={() => {
setLxcSettingsModal({ isOpen: false, script: null });
void refetchScripts();
}}
/>
</div> </div>
); );
} }

View File

@@ -0,0 +1,625 @@
'use client';
import { useState, useEffect } from 'react';
import { api } from '~/trpc/react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Badge } from './ui/badge';
import { ContextualHelpIcon } from './ContextualHelpIcon';
import { LoadingModal } from './LoadingModal';
import { ConfirmationModal } from './ConfirmationModal';
import { RefreshCw, AlertTriangle, CheckCircle } from 'lucide-react';
interface InstalledScript {
id: number;
script_name: string;
container_id: string | null;
server_id: number | null;
server_name: string | null;
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 LXCSettingsModalProps {
isOpen: boolean;
script: InstalledScript | null;
onClose: () => void;
onSave: () => void;
}
export function LXCSettingsModal({ isOpen, script, onClose, onSave }: LXCSettingsModalProps) {
const [activeTab, setActiveTab] = useState<string>('common');
const [showConfirmation, setShowConfirmation] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [hasChanges, setHasChanges] = useState(false);
const [forceSync] = useState(false);
const [formData, setFormData] = useState<any>({
arch: '',
cores: 0,
memory: 0,
hostname: '',
swap: 0,
onboot: false,
ostype: '',
unprivileged: false,
net_name: '',
net_bridge: '',
net_hwaddr: '',
net_ip_type: 'dhcp',
net_ip: '',
net_gateway: '',
net_type: '',
net_vlan: 0,
rootfs_storage: '',
rootfs_size: '',
feature_keyctl: false,
feature_nesting: false,
feature_fuse: false,
feature_mount: '',
tags: '',
advanced_config: ''
});
// tRPC hooks
const { data: configData, isLoading } = api.installedScripts.getLXCConfig.useQuery(
{ scriptId: script?.id ?? 0, forceSync },
{ enabled: !!script && isOpen }
);
const saveMutation = api.installedScripts.saveLXCConfig.useMutation({
onSuccess: () => {
setSuccessMessage('LXC configuration saved successfully');
setHasChanges(false);
setShowConfirmation(false);
onSave();
},
onError: (err) => {
setError(`Failed to save configuration: ${err.message}`);
}
});
const syncMutation = api.installedScripts.syncLXCConfig.useMutation({
onSuccess: (result) => {
populateFormData(result);
setSuccessMessage('Configuration synced from server successfully');
setHasChanges(false);
},
onError: (err) => {
setError(`Failed to sync configuration: ${err.message}`);
}
});
// Populate form data helper
const populateFormData = (result: any) => {
if (!result?.success) return;
const config = result.config;
setFormData({
arch: config.arch ?? '',
cores: config.cores ?? 0,
memory: config.memory ?? 0,
hostname: config.hostname ?? '',
swap: config.swap ?? 0,
onboot: config.onboot === 1,
ostype: config.ostype ?? '',
unprivileged: config.unprivileged === 1,
net_name: config.net_name ?? '',
net_bridge: config.net_bridge ?? '',
net_hwaddr: config.net_hwaddr ?? '',
net_ip_type: config.net_ip_type ?? 'dhcp',
net_ip: config.net_ip ?? '',
net_gateway: config.net_gateway ?? '',
net_type: config.net_type ?? '',
net_vlan: config.net_vlan ?? 0,
rootfs_storage: config.rootfs_storage ?? '',
rootfs_size: config.rootfs_size ?? '',
feature_keyctl: config.feature_keyctl === 1,
feature_nesting: config.feature_nesting === 1,
feature_fuse: config.feature_fuse === 1,
feature_mount: config.feature_mount ?? '',
tags: config.tags ?? '',
advanced_config: config.advanced_config ?? ''
});
};
// Load config when data arrives
useEffect(() => {
if (configData?.success) {
populateFormData(configData);
setHasChanges(false);
} else if (configData && !configData.success) {
setError(String(configData.error ?? 'Failed to load configuration'));
}
}, [configData]);
const handleInputChange = (field: string, value: any): void => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
setFormData((prev: any) => ({ ...prev, [field]: value }));
setHasChanges(true);
};
const handleSyncFromServer = () => {
if (!script) return;
setError(null);
syncMutation.mutate({ scriptId: script.id });
};
const handleSave = () => {
setShowConfirmation(true);
};
const handleConfirmSave = () => {
if (!script) return;
setError(null);
saveMutation.mutate({
scriptId: script.id,
config: {
...formData,
onboot: formData.onboot ? 1 : 0,
unprivileged: formData.unprivileged ? 1 : 0,
feature_keyctl: formData.feature_keyctl ? 1 : 0,
feature_nesting: formData.feature_nesting ? 1 : 0,
feature_fuse: formData.feature_fuse ? 1 : 0
}
});
};
if (!isOpen || !script) return null;
return (
<>
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-border">
<div className="flex items-center gap-3">
<h2 className="text-2xl font-bold text-foreground">LXC Settings</h2>
<Badge variant="outline">{script.container_id}</Badge>
<ContextualHelpIcon section="lxc-settings" tooltip="Help with LXC Settings" />
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleSyncFromServer}
disabled={syncMutation.isPending ?? isLoading ?? saveMutation.isPending}
variant="outline"
size="sm"
>
<RefreshCw className={`h-4 w-4 mr-2 ${syncMutation.isPending ? 'animate-spin' : ''}`} />
Sync from Server
</Button>
<Button
onClick={onClose}
variant="ghost"
size="sm"
>
</Button>
</div>
</div>
{/* Warning Banner */}
{configData?.has_changes && (
<div className="bg-yellow-50 dark:bg-yellow-950/20 border-b border-yellow-200 dark:border-yellow-800 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Configuration Mismatch Detected
</p>
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
The cached configuration differs from the server. Click &quot;Sync from Server&quot; to get the latest version.
</p>
</div>
</div>
</div>
)}
{/* Success Message */}
{successMessage && (
<div className="bg-green-50 dark:bg-green-950/20 border-b border-green-200 dark:border-green-800 p-4">
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-green-800 dark:text-green-200">{successMessage}</p>
</div>
<button
onClick={() => setSuccessMessage(null)}
className="text-green-600 dark:text-green-500 hover:text-green-700 dark:hover:text-green-400"
>
</button>
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="bg-red-50 dark:bg-red-950/20 border-b border-red-200 dark:border-red-800 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-red-800 dark:text-red-200">Error</p>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">{error}</p>
</div>
<button
onClick={() => setError(null)}
className="text-red-600 dark:text-red-500 hover:text-red-700 dark:hover:text-red-400"
>
</button>
</div>
</div>
)}
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6">
{/* Tab Navigation */}
<div className="border-b border-border mb-6">
<nav className="flex space-x-8">
<button
onClick={() => setActiveTab('common')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'common'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-gray-300'
}`}
>
Common Settings
</button>
<button
onClick={() => setActiveTab('advanced')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'advanced'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-gray-300'
}`}
>
Advanced Settings
</button>
</nav>
</div>
{/* Common Settings Tab */}
{activeTab === 'common' && (
<div className="space-y-6">
{/* Basic Configuration */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-foreground">Basic Configuration</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label htmlFor="arch" className="block text-sm font-medium text-foreground">Architecture *</label>
<Input
id="arch"
value={formData.arch}
onChange={(e) => handleInputChange('arch', e.target.value)}
placeholder="amd64"
/>
</div>
<div className="space-y-2">
<label htmlFor="cores" className="block text-sm font-medium text-foreground">Cores *</label>
<Input
id="cores"
type="number"
value={formData.cores}
onChange={(e) => handleInputChange('cores', parseInt(e.target.value) || 0)}
min="1"
/>
</div>
<div className="space-y-2">
<label htmlFor="memory" className="block text-sm font-medium text-foreground">Memory (MB) *</label>
<Input
id="memory"
type="number"
value={formData.memory}
onChange={(e) => handleInputChange('memory', parseInt(e.target.value) || 0)}
min="128"
/>
</div>
<div className="space-y-2">
<label htmlFor="swap" className="block text-sm font-medium text-foreground">Swap (MB)</label>
<Input
id="swap"
type="number"
value={formData.swap}
onChange={(e) => handleInputChange('swap', parseInt(e.target.value) || 0)}
min="0"
/>
</div>
<div className="space-y-2">
<label htmlFor="hostname" className="block text-sm font-medium text-foreground">Hostname *</label>
<Input
id="hostname"
value={formData.hostname}
onChange={(e) => handleInputChange('hostname', e.target.value)}
placeholder="container-hostname"
/>
</div>
<div className="space-y-2">
<label htmlFor="ostype" className="block text-sm font-medium text-foreground">OS Type *</label>
<Input
id="ostype"
value={formData.ostype}
onChange={(e) => handleInputChange('ostype', e.target.value)}
placeholder="debian"
/>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="onboot"
checked={formData.onboot}
onChange={(e) => handleInputChange('onboot', e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="onboot" className="text-sm font-medium text-foreground">Start on Boot</label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="unprivileged"
checked={formData.unprivileged}
onChange={(e) => handleInputChange('unprivileged', e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="unprivileged" className="text-sm font-medium text-foreground">Unprivileged Container</label>
</div>
</div>
</div>
{/* Network Configuration */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-foreground">Network Configuration</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label htmlFor="net_name" className="block text-sm font-medium text-foreground">Interface Name</label>
<Input
id="net_name"
value={formData.net_name}
onChange={(e) => handleInputChange('net_name', e.target.value)}
placeholder="eth0"
/>
</div>
<div className="space-y-2">
<label htmlFor="net_bridge" className="block text-sm font-medium text-foreground">Bridge</label>
<Input
id="net_bridge"
value={formData.net_bridge}
onChange={(e) => handleInputChange('net_bridge', e.target.value)}
placeholder="vmbr0"
/>
</div>
<div className="space-y-2">
<label htmlFor="net_hwaddr" className="block text-sm font-medium text-foreground">MAC Address</label>
<Input
id="net_hwaddr"
value={formData.net_hwaddr}
onChange={(e) => handleInputChange('net_hwaddr', e.target.value)}
placeholder="BC:24:11:2D:2D:AB"
/>
</div>
<div className="space-y-2">
<label htmlFor="net_type" className="block text-sm font-medium text-foreground">Type</label>
<Input
id="net_type"
value={formData.net_type}
onChange={(e) => handleInputChange('net_type', e.target.value)}
placeholder="veth"
/>
</div>
<div className="space-y-2">
<label htmlFor="net_ip_type" className="block text-sm font-medium text-foreground">IP Configuration</label>
<select
id="net_ip_type"
value={formData.net_ip_type}
onChange={(e) => handleInputChange('net_ip_type', e.target.value)}
className="w-full px-3 py-2 border border-input bg-background rounded-md"
>
<option value="dhcp">DHCP</option>
<option value="static">Static IP</option>
</select>
</div>
{formData.net_ip_type === 'static' && (
<>
<div className="space-y-2">
<label htmlFor="net_ip" className="block text-sm font-medium text-foreground">IP Address with CIDR *</label>
<Input
id="net_ip"
value={formData.net_ip}
onChange={(e) => handleInputChange('net_ip', e.target.value)}
placeholder="10.10.10.164/24"
/>
</div>
<div className="space-y-2">
<label htmlFor="net_gateway" className="block text-sm font-medium text-foreground">Gateway</label>
<Input
id="net_gateway"
value={formData.net_gateway}
onChange={(e) => handleInputChange('net_gateway', e.target.value)}
placeholder="10.10.10.254"
/>
</div>
</>
)}
<div className="space-y-2">
<label htmlFor="net_vlan" className="block text-sm font-medium text-foreground">VLAN Tag</label>
<Input
id="net_vlan"
type="number"
value={formData.net_vlan}
onChange={(e) => handleInputChange('net_vlan', parseInt(e.target.value) || 0)}
placeholder="Optional"
/>
</div>
</div>
</div>
{/* Storage */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-foreground">Storage</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label htmlFor="rootfs_storage" className="block text-sm font-medium text-foreground">Root Filesystem *</label>
<Input
id="rootfs_storage"
value={formData.rootfs_storage}
onChange={(e) => handleInputChange('rootfs_storage', e.target.value)}
placeholder="PROX2-STORAGE2:vm-109-disk-0"
/>
</div>
<div className="space-y-2">
<label htmlFor="rootfs_size" className="block text-sm font-medium text-foreground">Size</label>
<Input
id="rootfs_size"
value={formData.rootfs_size}
onChange={(e) => handleInputChange('rootfs_size', e.target.value)}
placeholder="4G"
/>
</div>
</div>
</div>
{/* Features */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-foreground">Features</h3>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="feature_keyctl"
checked={formData.feature_keyctl}
onChange={(e) => handleInputChange('feature_keyctl', e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="feature_keyctl" className="text-sm font-medium text-foreground">Keyctl</label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="feature_nesting"
checked={formData.feature_nesting}
onChange={(e) => handleInputChange('feature_nesting', e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="feature_nesting" className="text-sm font-medium text-foreground">Nesting</label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="feature_fuse"
checked={formData.feature_fuse}
onChange={(e) => handleInputChange('feature_fuse', e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="feature_fuse" className="text-sm font-medium text-foreground">FUSE</label>
</div>
</div>
<div className="space-y-2">
<label htmlFor="feature_mount" className="block text-sm font-medium text-foreground">Additional Mount Features</label>
<Input
id="feature_mount"
value={formData.feature_mount}
onChange={(e) => handleInputChange('feature_mount', e.target.value)}
placeholder="Additional features (comma-separated)"
/>
</div>
</div>
{/* Tags */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-foreground">Tags</h3>
<div className="space-y-2">
<label htmlFor="tags" className="block text-sm font-medium text-foreground">Tags</label>
<Input
id="tags"
value={formData.tags}
onChange={(e) => handleInputChange('tags', e.target.value)}
placeholder="community-script;pve-scripts-local"
/>
</div>
</div>
</div>
)}
{/* Advanced Settings Tab */}
{activeTab === 'advanced' && (
<div className="space-y-4">
<div className="space-y-2">
<label htmlFor="advanced_config" className="block text-sm font-medium text-foreground">Advanced Configuration</label>
<textarea
id="advanced_config"
value={formData.advanced_config}
onChange={(e) => handleInputChange('advanced_config', e.target.value)}
placeholder="lxc.* entries, comments, and other advanced settings..."
className="w-full min-h-[400px] px-3 py-2 border border-input bg-background rounded-md font-mono text-sm resize-vertical"
/>
<p className="text-xs text-muted-foreground">
This section contains lxc.* entries, comments, and other advanced settings that are not covered in the Common Settings tab.
</p>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end p-4 sm:p-6 border-t border-border bg-muted/30">
<div className="flex gap-3">
<Button
onClick={onClose}
variant="outline"
disabled={saveMutation.isPending}
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={saveMutation.isPending || !hasChanges}
variant="default"
>
{saveMutation.isPending ? 'Saving...' : 'Save Configuration'}
</Button>
</div>
</div>
</div>
</div>
{/* Confirmation Modal */}
<ConfirmationModal
isOpen={showConfirmation}
onClose={() => {
setShowConfirmation(false);
}}
onConfirm={handleConfirmSave}
title="Confirm LXC Configuration Changes"
message="Modifying LXC configuration can break your container and may require manual recovery. Ensure you understand these changes before proceeding. The container may need to be restarted for changes to take effect."
variant="danger"
confirmText={script.container_id ?? ''}
confirmButtonText="Save Configuration"
/>
{/* Loading Modal */}
<LoadingModal
isOpen={isLoading}
action="Loading LXC configuration..."
/>
</>
);
}

View File

@@ -1,6 +1,6 @@
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getDatabase } from '../../../../../server/database-prisma.js'; import { getDatabase } from '../../../../../server/database-prisma';
import { getSSHService } from '../../../../../server/ssh-service'; import { getSSHService } from '../../../../../server/ssh-service';
export async function GET( export async function GET(

View File

@@ -1,6 +1,6 @@
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getDatabase } from '../../../../server/database-prisma.js'; import { getDatabase } from '../../../../server/database-prisma';
import type { CreateServerData } from '../../../../types/server'; import type { CreateServerData } from '../../../../types/server';
export async function GET( export async function GET(

View File

@@ -1,6 +1,6 @@
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getDatabase } from '../../../../../server/database-prisma.js'; import { getDatabase } from '../../../../../server/database-prisma';
import { getSSHService } from '../../../../../server/ssh-service'; import { getSSHService } from '../../../../../server/ssh-service';
import type { Server } from '../../../../../types/server'; import type { Server } from '../../../../../types/server';

View File

@@ -1,7 +1,7 @@
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getSSHService } from '../../../../server/ssh-service'; import { getSSHService } from '../../../../server/ssh-service';
import { getDatabase } from '../../../../server/database-prisma.js'; import { getDatabase } from '../../../../server/database-prisma';
export async function POST(_request: NextRequest) { export async function POST(_request: NextRequest) {
try { try {

View File

@@ -1,6 +1,6 @@
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getDatabase } from '../../../server/database-prisma.js'; import { getDatabase } from '../../../server/database-prisma';
import type { CreateServerData } from '../../../types/server'; import type { CreateServerData } from '../../../types/server';
export async function GET() { export async function GET() {

View File

@@ -15,7 +15,7 @@ import { Button } from './_components/ui/button';
import { ContextualHelpIcon } from './_components/ContextualHelpIcon'; import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal'; import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
import { Footer } from './_components/Footer'; import { Footer } from './_components/Footer';
import { Rocket, Package, HardDrive, FolderOpen } from 'lucide-react'; import { Package, HardDrive, FolderOpen } from 'lucide-react';
import { api } from '~/trpc/react'; import { api } from '~/trpc/react';
export default function Home() { export default function Home() {

View File

@@ -1,7 +1,146 @@
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { getDatabase } from "~/server/database-prisma.js"; import { getDatabase } from "~/server/database-prisma";
// Removed unused imports import { createHash } from "crypto";
import type { Server } from "~/types/server";
// Helper function to parse raw LXC config into structured data
function parseRawConfig(rawConfig: string): any {
const lines = rawConfig.split('\n');
const config: any = { advanced: [] };
for (const line of lines) {
const trimmed = line.trim();
// Preserve comments in advanced
if (trimmed.startsWith('#')) {
config.advanced.push(line);
continue;
}
if (!trimmed) continue;
const [key, ...valueParts] = trimmed.split(':');
const value = valueParts.join(':').trim();
switch (key?.trim()) {
case 'arch': config.arch = value; break;
case 'cores': config.cores = parseInt(value); break;
case 'memory': config.memory = parseInt(value); break;
case 'hostname': config.hostname = value; break;
case 'swap': config.swap = parseInt(value); break;
case 'onboot': config.onboot = parseInt(value); break;
case 'ostype': config.ostype = value; break;
case 'unprivileged': config.unprivileged = parseInt(value); break;
case 'tags': config.tags = value; break;
case 'rootfs': config.rootfs = value; break;
case 'net0':
// Parse: name=eth0,bridge=vmbr0,gw=10.10.10.254,hwaddr=BC:24:11:EC:0F:F0,ip=10.10.10.164/24,type=veth
const parts = value.split(',');
for (const part of parts) {
const [k, v] = part.split('=');
if (k === 'name') config.net_name = v;
else if (k === 'bridge') config.net_bridge = v;
else if (k === 'hwaddr') config.net_hwaddr = v;
else if (k === 'ip') {
config.net_ip = v;
config.net_ip_type = v === 'dhcp' ? 'dhcp' : 'static';
}
else if (k === 'gw') config.net_gateway = v;
else if (k === 'type') config.net_type = v;
else if (k === 'tag' && v) config.net_vlan = parseInt(v);
}
break;
case 'features':
// Parse: keyctl=1,nesting=1,fuse=1
const feats = value.split(',');
for (const feat of feats) {
const [k, v] = feat.split('=');
if (k === 'keyctl' && v) config.feature_keyctl = parseInt(v);
else if (k === 'nesting' && v) config.feature_nesting = parseInt(v);
else if (k === 'fuse' && v) config.feature_fuse = parseInt(v);
else config.feature_mount = (config.feature_mount ? config.feature_mount + ',' : '') + feat;
}
break;
default:
// Advanced settings (lxc.* and unknown)
config.advanced.push(line);
}
}
// Parse rootfs into storage and size
if (config.rootfs) {
const match = config.rootfs.match(/^([^:]+):([^,]+)(?:,size=(.+))?$/);
if (match) {
config.rootfs_storage = `${match[1]}:${match[2]}`;
config.rootfs_size = match[3] ?? '';
}
delete config.rootfs; // Remove the rootfs field since we only need rootfs_storage and rootfs_size
}
config.advanced_config = config.advanced.join('\n');
delete config.advanced; // Remove the advanced array since we only need advanced_config
return config;
}
// Helper function to reconstruct config from structured data
function reconstructConfig(parsed: any): string {
const lines: string[] = [];
// Add standard fields in order
if (parsed.arch) lines.push(`arch: ${parsed.arch}`);
if (parsed.cores) lines.push(`cores: ${parsed.cores}`);
// Build features line
if (parsed.feature_keyctl !== undefined || parsed.feature_nesting !== undefined || parsed.feature_fuse !== undefined) {
const feats: string[] = [];
if (parsed.feature_keyctl !== undefined) feats.push(`keyctl=${parsed.feature_keyctl}`);
if (parsed.feature_nesting !== undefined) feats.push(`nesting=${parsed.feature_nesting}`);
if (parsed.feature_fuse !== undefined) feats.push(`fuse=${parsed.feature_fuse}`);
if (parsed.feature_mount) feats.push(String(parsed.feature_mount));
lines.push(`features: ${feats.join(',')}`);
}
if (parsed.hostname) lines.push(`hostname: ${parsed.hostname}`);
if (parsed.memory) lines.push(`memory: ${parsed.memory}`);
// Build net0 line
if (parsed.net_name || parsed.net_bridge || parsed.net_ip) {
const netParts: string[] = [];
if (parsed.net_name) netParts.push(`name=${parsed.net_name}`);
if (parsed.net_bridge) netParts.push(`bridge=${parsed.net_bridge}`);
if (parsed.net_gateway && parsed.net_ip_type === 'static') netParts.push(`gw=${parsed.net_gateway}`);
if (parsed.net_hwaddr) netParts.push(`hwaddr=${parsed.net_hwaddr}`);
if (parsed.net_ip) netParts.push(`ip=${parsed.net_ip}`);
if (parsed.net_type) netParts.push(`type=${parsed.net_type}`);
if (parsed.net_vlan) netParts.push(`tag=${parsed.net_vlan}`);
lines.push(`net0: ${netParts.join(',')}`);
}
if (parsed.onboot !== undefined) lines.push(`onboot: ${parsed.onboot}`);
if (parsed.ostype) lines.push(`ostype: ${parsed.ostype}`);
if (parsed.rootfs_storage) {
const rootfs = parsed.rootfs_size
? `${parsed.rootfs_storage},size=${parsed.rootfs_size}`
: parsed.rootfs_storage;
lines.push(`rootfs: ${rootfs}`);
}
if (parsed.swap !== undefined) lines.push(`swap: ${parsed.swap}`);
if (parsed.tags) lines.push(`tags: ${parsed.tags}`);
if (parsed.unprivileged !== undefined) lines.push(`unprivileged: ${parsed.unprivileged}`);
// Add advanced config
if (parsed.advanced_config) {
lines.push(String(parsed.advanced_config));
}
return lines.join('\n');
}
// Helper function to calculate config hash
function calculateConfigHash(rawConfig: string): string {
return createHash('md5').update(rawConfig).digest('hex');
}
export const installedScriptsRouter = createTRPCRouter({ export const installedScriptsRouter = createTRPCRouter({
@@ -285,8 +424,8 @@ export const installedScriptsRouter = createTRPCRouter({
const sshExecutionService = new SSHExecutionService(); const sshExecutionService = new SSHExecutionService();
// Test SSH connection first // Test SSH connection first
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const connectionTest = await sshService.testSSHConnection(server as any); const connectionTest = await sshService.testSSHConnection(server as Server);
if (!(connectionTest as any).success) { if (!(connectionTest as any).success) {
return { return {
@@ -307,8 +446,8 @@ export const installedScriptsRouter = createTRPCRouter({
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand( void sshExecutionService.executeCommand(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
server as any, server as Server,
command, command,
(data: string) => { (data: string) => {
commandOutput += data; commandOutput += data;
@@ -339,8 +478,8 @@ export const installedScriptsRouter = createTRPCRouter({
return new Promise<any>((readResolve) => { return new Promise<any>((readResolve) => {
void sshExecutionService.executeCommand( void sshExecutionService.executeCommand(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
server as any, server as Server,
readCommand, readCommand,
(configData: string) => { (configData: string) => {
// Parse config file for hostname // Parse config file for hostname
@@ -356,12 +495,21 @@ export const installedScriptsRouter = createTRPCRouter({
} }
if (hostname) { if (hostname) {
// Parse full config and store in database
const parsedConfig = parseRawConfig(configData);
const configHash = calculateConfigHash(configData);
const container = { const container = {
containerId, containerId,
hostname, hostname,
configPath, configPath,
serverId: Number((server as any).id), serverId: Number((server as any).id),
serverName: (server as any).name serverName: (server as any).name,
parsedConfig: {
...parsedConfig,
config_hash: configHash,
synced_at: new Date()
}
}; };
readResolve(container); readResolve(container);
} else { } else {
@@ -430,6 +578,11 @@ export const installedScriptsRouter = createTRPCRouter({
output_log: `Auto-detected from LXC config: ${container.configPath}` output_log: `Auto-detected from LXC config: ${container.configPath}`
}); });
// Store LXC config in database
if (container.parsedConfig) {
await db.createLXCConfig(result.id, container.parsedConfig);
}
createdScripts.push({ createdScripts.push({
id: result.id, id: result.id,
containerId: container.containerId, containerId: container.containerId,
@@ -506,8 +659,8 @@ export const installedScriptsRouter = createTRPCRouter({
// Test SSH connection // Test SSH connection
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const connectionTest = await sshService.testSSHConnection(server as any); const connectionTest = await sshService.testSSHConnection(server as Server);
if (!(connectionTest as any).success) { if (!(connectionTest as any).success) {
continue; continue;
} }
@@ -518,8 +671,8 @@ export const installedScriptsRouter = createTRPCRouter({
const containerExists = await new Promise<boolean>((resolve) => { const containerExists = await new Promise<boolean>((resolve) => {
void sshExecutionService.executeCommand( void sshExecutionService.executeCommand(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
server as any, server as Server,
checkCommand, checkCommand,
(data: string) => { (data: string) => {
resolve(data.trim() === 'exists'); resolve(data.trim() === 'exists');
@@ -592,8 +745,8 @@ export const installedScriptsRouter = createTRPCRouter({
try { try {
// Test SSH connection // Test SSH connection
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const connectionTest = await sshService.testSSHConnection(server as any); const connectionTest = await sshService.testSSHConnection(server as Server);
if (!(connectionTest as any).success) { if (!(connectionTest as any).success) {
continue; continue;
} }
@@ -610,8 +763,8 @@ export const installedScriptsRouter = createTRPCRouter({
await Promise.race([ await Promise.race([
new Promise<void>((resolve, reject) => { new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand( void sshExecutionService.executeCommand(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
server as any, server as Server,
listCommand, listCommand,
(data: string) => { (data: string) => {
listOutput += data; listOutput += data;
@@ -715,8 +868,8 @@ export const installedScriptsRouter = createTRPCRouter({
const sshExecutionService = new SSHExecutionService(); const sshExecutionService = new SSHExecutionService();
// Test SSH connection first // Test SSH connection first
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const connectionTest = await sshService.testSSHConnection(server as any); const connectionTest = await sshService.testSSHConnection(server as Server);
if (!(connectionTest as any).success) { if (!(connectionTest as any).success) {
return { return {
success: false, success: false,
@@ -731,8 +884,8 @@ export const installedScriptsRouter = createTRPCRouter({
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand( void sshExecutionService.executeCommand(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
server as any, server as Server,
statusCommand, statusCommand,
(data: string) => { (data: string) => {
statusOutput += data; statusOutput += data;
@@ -814,8 +967,8 @@ export const installedScriptsRouter = createTRPCRouter({
const sshExecutionService = new SSHExecutionService(); const sshExecutionService = new SSHExecutionService();
// Test SSH connection first // Test SSH connection first
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const connectionTest = await sshService.testSSHConnection(server as any); const connectionTest = await sshService.testSSHConnection(server as Server);
if (!(connectionTest as any).success) { if (!(connectionTest as any).success) {
return { return {
success: false, success: false,
@@ -830,8 +983,8 @@ export const installedScriptsRouter = createTRPCRouter({
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand( void sshExecutionService.executeCommand(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
server as any, server as Server,
controlCommand, controlCommand,
(data: string) => { (data: string) => {
commandOutput += data; commandOutput += data;
@@ -905,8 +1058,8 @@ export const installedScriptsRouter = createTRPCRouter({
const sshExecutionService = new SSHExecutionService(); const sshExecutionService = new SSHExecutionService();
// Test SSH connection first // Test SSH connection first
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const connectionTest = await sshService.testSSHConnection(server as any); const connectionTest = await sshService.testSSHConnection(server as Server);
if (!(connectionTest as any).success) { if (!(connectionTest as any).success) {
return { return {
success: false, success: false,
@@ -921,8 +1074,8 @@ export const installedScriptsRouter = createTRPCRouter({
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand( void sshExecutionService.executeCommand(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
server as any, server as Server,
statusCommand, statusCommand,
(data: string) => { (data: string) => {
statusOutput += data; statusOutput += data;
@@ -945,8 +1098,8 @@ export const installedScriptsRouter = createTRPCRouter({
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand( void sshExecutionService.executeCommand(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
server as any, server as Server,
stopCommand, stopCommand,
(data: string) => { (data: string) => {
stopOutput += data; stopOutput += data;
@@ -976,8 +1129,8 @@ export const installedScriptsRouter = createTRPCRouter({
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand( void sshExecutionService.executeCommand(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
server as any, server as Server,
destroyCommand, destroyCommand,
(data: string) => { (data: string) => {
commandOutput += data; commandOutput += data;
@@ -1079,8 +1232,8 @@ export const installedScriptsRouter = createTRPCRouter({
// Test SSH connection first // Test SSH connection first
console.log('🔌 Testing SSH connection...'); console.log('🔌 Testing SSH connection...');
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const connectionTest = await sshService.testSSHConnection(server as any); const connectionTest = await sshService.testSSHConnection(server as Server);
if (!(connectionTest as any).success) { if (!(connectionTest as any).success) {
console.log('❌ SSH connection failed:', (connectionTest as any).error); console.log('❌ SSH connection failed:', (connectionTest as any).error);
return { return {
@@ -1099,8 +1252,8 @@ export const installedScriptsRouter = createTRPCRouter({
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand( void sshExecutionService.executeCommand(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
server as any, server as Server,
hostnameCommand, hostnameCommand,
(data: string) => { (data: string) => {
console.log('📤 Command output chunk:', data); console.log('📤 Command output chunk:', data);
@@ -1197,5 +1350,249 @@ export const installedScriptsRouter = createTRPCRouter({
error: error instanceof Error ? error.message : 'Failed to auto-detect Web UI IP' error: error instanceof Error ? error.message : 'Failed to auto-detect Web UI IP'
}; };
} }
}),
// Get LXC configuration
getLXCConfig: publicProcedure
.input(z.object({
scriptId: z.number(),
forceSync: z.boolean().optional().default(false)
}))
.query(async ({ input }) => {
try {
const db = getDatabase();
const script = await db.getInstalledScriptById(input.scriptId);
if (!script) {
return {
success: false,
error: 'Script not found'
};
}
if (!script.container_id || !script.server_id) {
return {
success: false,
error: 'Script does not have container ID or server ID'
};
}
// Check if we have cached config and it's recent (5 minutes)
console.log("DB object in getLXCConfig:", Object.keys(db));
console.log("getLXCConfigByScriptId exists:", typeof db.getLXCConfigByScriptId);
const cachedConfig = await db.getLXCConfigByScriptId(input.scriptId);
const now = new Date();
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
if (cachedConfig?.synced_at && cachedConfig.synced_at > fiveMinutesAgo && !input.forceSync) {
return {
success: true,
config: cachedConfig,
source: 'cache',
has_changes: false,
synced_at: cachedConfig.synced_at
};
}
// Read from server
const server = await db.getServerById(script.server_id);
if (!server) {
return {
success: false,
error: 'Server not found'
};
}
// 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
const connectionTest = await sshService.testSSHConnection(server as Server);
if (!(connectionTest as any).success) {
return {
success: false,
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`
};
}
// Read config file
const configPath = `/etc/pve/lxc/${script.container_id}.conf`;
const readCommand = `cat "${configPath}" 2>/dev/null`;
let rawConfig = '';
await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
server as Server,
readCommand,
(data: string) => {
rawConfig += data;
},
(error: string) => {
reject(new Error(error));
},
(exitCode: number) => {
if (exitCode === 0) {
resolve();
} else {
reject(new Error(`Command failed with exit code ${exitCode}`));
}
}
);
});
// Parse config
const parsedConfig = parseRawConfig(rawConfig);
const configHash = calculateConfigHash(rawConfig);
// Check for changes if we have cached config
const hasChanges = cachedConfig ? cachedConfig.config_hash !== configHash : false;
// Update database cache
const configData = {
...parsedConfig,
config_hash: configHash,
synced_at: new Date()
};
await db.updateLXCConfig(input.scriptId, configData);
return {
success: true,
config: configData,
source: 'server',
has_changes: hasChanges,
synced_at: configData.synced_at
};
} catch (error) {
console.error('Error in getLXCConfig:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get LXC config'
};
}
}),
// Save LXC configuration
saveLXCConfig: publicProcedure
.input(z.object({
scriptId: z.number(),
config: z.any()
}))
.mutation(async ({ input }) => {
try {
const db = getDatabase();
const script = await db.getInstalledScriptById(input.scriptId);
if (!script) {
return {
success: false,
error: 'Script not found'
};
}
if (!script.container_id || !script.server_id) {
return {
success: false,
error: 'Script does not have container ID or server ID'
};
}
// Validate required fields
if (!input.config.arch || !input.config.cores || !input.config.memory || !input.config.hostname || !input.config.ostype || !input.config.rootfs_storage) {
return {
success: false,
error: 'Missing required fields: arch, cores, memory, hostname, ostype, or rootfs_storage'
};
}
// Reconstruct config
const rawConfig = reconstructConfig(input.config);
const configHash = calculateConfigHash(rawConfig);
// Get server info
const server = await db.getServerById(script.server_id);
if (!server) {
return {
success: false,
error: 'Server not found'
};
}
// 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
const connectionTest = await sshService.testSSHConnection(server as Server);
if (!(connectionTest as any).success) {
return {
success: false,
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`
};
}
// Write config file using heredoc for safe escaping
const configPath = `/etc/pve/lxc/${script.container_id}.conf`;
const writeCommand = `cat > "${configPath}" << 'EOFCONFIG'
${rawConfig}
EOFCONFIG`;
await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
server as Server,
writeCommand,
(_data: string) => {
// Success data
},
(error: string) => {
reject(new Error(error));
},
(exitCode: number) => {
if (exitCode === 0) {
resolve();
} else {
reject(new Error(`Command failed with exit code ${exitCode}`));
}
}
);
});
// Update database cache
const configData = {
...input.config,
config_hash: configHash,
synced_at: new Date()
};
await db.updateLXCConfig(input.scriptId, configData);
return {
success: true,
message: 'LXC configuration saved successfully'
};
} catch (error) {
console.error('Error in saveLXCConfig:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to save LXC config'
};
}
}),
// Sync LXC configuration from server
syncLXCConfig: publicProcedure
.input(z.object({ scriptId: z.number() }))
.mutation(async ({ input }): Promise<any> => {
// This is just a wrapper around getLXCConfig with forceSync=true
const result = await installedScriptsRouter
.createCaller({ headers: new Headers() })
.getLXCConfig({ scriptId: input.scriptId, forceSync: true });
return result;
}) })
}); });

View File

@@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { getDatabase } from "~/server/database-prisma.js"; import { getDatabase } from "~/server/database-prisma";
export const serversRouter = createTRPCRouter({ export const serversRouter = createTRPCRouter({
getAllServers: publicProcedure getAllServers: publicProcedure

View File

@@ -238,6 +238,39 @@ class DatabaseServicePrisma {
return keyPath; return keyPath;
} }
// LXC Config CRUD operations
async createLXCConfig(scriptId, configData) {
return await prisma.lXCConfig.create({
data: {
installed_script_id: scriptId,
...configData
}
});
}
async updateLXCConfig(scriptId, configData) {
return await prisma.lXCConfig.upsert({
where: { installed_script_id: scriptId },
update: configData,
create: {
installed_script_id: scriptId,
...configData
}
});
}
async getLXCConfigByScriptId(scriptId) {
return await prisma.lXCConfig.findUnique({
where: { installed_script_id: scriptId }
});
}
async deleteLXCConfig(scriptId) {
return await prisma.lXCConfig.delete({
where: { installed_script_id: scriptId }
});
}
async close() { async close() {
await prisma.$disconnect(); await prisma.$disconnect();
} }
@@ -251,4 +284,4 @@ export function getDatabase() {
return dbInstance; return dbInstance;
} }
export default DatabaseServicePrisma; export default DatabaseServicePrisma;

View File

@@ -263,6 +263,39 @@ class DatabaseServicePrisma {
return keyPath; return keyPath;
} }
// LXC Config CRUD operations
async createLXCConfig(scriptId: number, configData: any) {
return await prisma.lXCConfig.create({
data: {
installed_script_id: scriptId,
...configData
}
});
}
async updateLXCConfig(scriptId: number, configData: any) {
return await prisma.lXCConfig.upsert({
where: { installed_script_id: scriptId },
update: configData,
create: {
installed_script_id: scriptId,
...configData
}
});
}
async getLXCConfigByScriptId(scriptId: number) {
return await prisma.lXCConfig.findUnique({
where: { installed_script_id: scriptId }
});
}
async deleteLXCConfig(scriptId: number) {
return await prisma.lXCConfig.delete({
where: { installed_script_id: scriptId }
});
}
async close() { async close() {
await prisma.$disconnect(); await prisma.$disconnect();
} }