Compare commits
9 Commits
update-ver
...
fix/variou
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cbb21f00d | ||
|
|
106028c2f4 | ||
|
|
08b7eecdfe | ||
|
|
c1b478ed51 | ||
|
|
4af5ad4f7b | ||
|
|
537d65275a | ||
|
|
ef460b5a00 | ||
|
|
87ab645231 | ||
|
|
9c44a47b3d |
@@ -25,4 +25,5 @@ AUTH_USERNAME=
|
||||
AUTH_PASSWORD_HASH=
|
||||
AUTH_ENABLED=false
|
||||
AUTH_SETUP_COMPLETED=false
|
||||
JWT_SECRET=
|
||||
JWT_SECRET=
|
||||
DATABASE_URL="file:./data/database.sqlite"
|
||||
|
||||
74
prisma/migrations/20251017092130_init/migration.sql
Normal file
74
prisma/migrations/20251017092130_init/migration.sql
Normal 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");
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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"
|
||||
@@ -20,6 +20,7 @@ model InstalledScript {
|
||||
web_ui_ip String?
|
||||
web_ui_port Int?
|
||||
server Server? @relation(fields: [server_id], references: [id], onDelete: SetNull)
|
||||
lxc_config LXCConfig?
|
||||
|
||||
@@map("installed_scripts")
|
||||
}
|
||||
@@ -43,3 +44,54 @@ model Server {
|
||||
|
||||
@@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")
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ interface HelpModalProps {
|
||||
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) {
|
||||
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: 'downloaded-scripts' as HelpSection, label: 'Downloaded Scripts', icon: HardDrive },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
@@ -501,6 +502,131 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
|
||||
</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 "Sync from Server" 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'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 "Sync from Server" button</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ScriptInstallationCard } from './ScriptInstallationCard';
|
||||
import { ConfirmationModal } from './ConfirmationModal';
|
||||
import { ErrorModal } from './ErrorModal';
|
||||
import { LoadingModal } from './LoadingModal';
|
||||
import { LXCSettingsModal } from './LXCSettingsModal';
|
||||
import { getContrastColor } from '../../lib/colorUtils';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from './ui/dropdown-menu';
|
||||
import { Settings } from 'lucide-react';
|
||||
|
||||
interface InstalledScript {
|
||||
id: number;
|
||||
@@ -91,6 +93,12 @@ export function InstalledScriptsTab() {
|
||||
action: string;
|
||||
} | null>(null);
|
||||
|
||||
// LXC Settings modal state
|
||||
const [lxcSettingsModal, setLxcSettingsModal] = useState<{
|
||||
isOpen: boolean;
|
||||
script: InstalledScript | null;
|
||||
}>({ isOpen: false, script: null });
|
||||
|
||||
// Fetch installed scripts
|
||||
const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery();
|
||||
const { data: statsData } = api.installedScripts.getInstallationStats.useQuery();
|
||||
@@ -388,7 +396,7 @@ export function InstalledScriptsTab() {
|
||||
containerStatusMutation.mutate({ serverIds });
|
||||
}
|
||||
}, 500);
|
||||
}, []);
|
||||
}, []);
|
||||
|
||||
// Run cleanup when component mounts and scripts are loaded (only once)
|
||||
useEffect(() => {
|
||||
@@ -404,7 +412,7 @@ export function InstalledScriptsTab() {
|
||||
console.log('Status check triggered - scripts length:', scripts.length);
|
||||
fetchContainerStatuses();
|
||||
}
|
||||
}, [scripts.length, fetchContainerStatuses]);
|
||||
}, [scripts.length]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
@@ -704,6 +712,10 @@ export function InstalledScriptsTab() {
|
||||
setEditFormData({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' });
|
||||
};
|
||||
|
||||
const handleLXCSettings = (script: InstalledScript) => {
|
||||
setLxcSettingsModal({ isOpen: true, script });
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (!editFormData.script_name.trim()) {
|
||||
setErrorModal({
|
||||
@@ -922,7 +934,7 @@ export function InstalledScriptsTab() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={fetchContainerStatuses}
|
||||
disabled={containerStatusMutation.isPending || scripts.length === 0}
|
||||
disabled={containerStatusMutation.isPending ?? scripts.length === 0}
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
@@ -1127,7 +1139,7 @@ export function InstalledScriptsTab() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAutoDetect}
|
||||
disabled={autoDetectMutation.isPending || !autoDetectServerId}
|
||||
disabled={autoDetectMutation.isPending ?? !autoDetectServerId}
|
||||
variant="default"
|
||||
size="default"
|
||||
className="w-full sm:w-auto"
|
||||
@@ -1364,7 +1376,7 @@ export function InstalledScriptsTab() {
|
||||
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"
|
||||
className="w-40 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>
|
||||
@@ -1499,7 +1511,7 @@ export function InstalledScriptsTab() {
|
||||
{script.container_id && script.execution_mode === 'ssh' && script.web_ui_ip && (
|
||||
<DropdownMenuItem
|
||||
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"
|
||||
>
|
||||
{autoDetectWebUIMutation.isPending ? 'Re-detect...' : 'Re-detect IP/Port'}
|
||||
@@ -1507,6 +1519,14 @@ export function InstalledScriptsTab() {
|
||||
)}
|
||||
{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" />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStartStop(script, (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'stop' : 'start')}
|
||||
@@ -1587,6 +1607,17 @@ export function InstalledScriptsTab() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
725
src/app/_components/LXCSettingsModal.tsx
Normal file
725
src/app/_components/LXCSettingsModal.tsx
Normal file
@@ -0,0 +1,725 @@
|
||||
'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 [showResultModal, setShowResultModal] = useState(false);
|
||||
const [resultType, setResultType] = useState<'success' | 'error' | null>(null);
|
||||
const [resultMessage, setResultMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [forceSync] = useState(false);
|
||||
const [isSaving, setIsSaving] = 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, refetch } = api.installedScripts.getLXCConfig.useQuery(
|
||||
{ scriptId: script?.id ?? 0, forceSync },
|
||||
{ enabled: !!script && isOpen }
|
||||
);
|
||||
|
||||
const saveMutation = api.installedScripts.saveLXCConfig.useMutation({
|
||||
onSuccess: (data) => {
|
||||
console.log('Save mutation success data:', data);
|
||||
setIsSaving(false);
|
||||
setShowConfirmation(false);
|
||||
|
||||
if (data.success) {
|
||||
setResultType('success');
|
||||
setResultMessage(data.message ?? 'LXC configuration saved successfully');
|
||||
setHasChanges(false);
|
||||
} else {
|
||||
console.log('Backend returned error:', data.error);
|
||||
setResultType('error');
|
||||
setResultMessage(data.error ?? 'Failed to save configuration');
|
||||
}
|
||||
setShowResultModal(true);
|
||||
},
|
||||
onError: (err) => {
|
||||
console.log('Save mutation error:', err);
|
||||
setIsSaving(false);
|
||||
setShowConfirmation(false);
|
||||
setResultType('error');
|
||||
setResultMessage(`Failed to save configuration: ${err.message}`);
|
||||
setShowResultModal(true);
|
||||
}
|
||||
});
|
||||
|
||||
const syncMutation = api.installedScripts.syncLXCConfig.useMutation({
|
||||
onSuccess: (result) => {
|
||||
populateFormData(result);
|
||||
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 validateForm = () => {
|
||||
// Check required fields
|
||||
if (!formData.arch?.trim()) {
|
||||
setError('Architecture is required');
|
||||
return false;
|
||||
}
|
||||
if (!formData.cores || formData.cores < 1) {
|
||||
setError('Cores must be at least 1');
|
||||
return false;
|
||||
}
|
||||
if (!formData.memory || formData.memory < 128) {
|
||||
setError('Memory must be at least 128 MB');
|
||||
return false;
|
||||
}
|
||||
if (!formData.hostname?.trim()) {
|
||||
setError('Hostname is required');
|
||||
return false;
|
||||
}
|
||||
if (!formData.ostype?.trim()) {
|
||||
setError('OS Type is required');
|
||||
return false;
|
||||
}
|
||||
if (!formData.rootfs_storage?.trim()) {
|
||||
setError('Root filesystem storage is required');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if trying to decrease disk size
|
||||
const currentSize = configData?.config?.rootfs_size ?? '0G';
|
||||
const newSize = formData.rootfs_size ?? '0G';
|
||||
const currentSizeGB = parseFloat(String(currentSize));
|
||||
const newSizeGB = parseFloat(String(newSize));
|
||||
|
||||
if (newSizeGB < currentSizeGB) {
|
||||
setError('Disk size cannot be decreased. Only increases are allowed for safety.');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setError(null);
|
||||
|
||||
// Validate form - only show confirmation modal if no errors
|
||||
if (validateForm()) {
|
||||
setShowConfirmation(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmSave = () => {
|
||||
if (!script) return;
|
||||
setError(null);
|
||||
setIsSaving(true);
|
||||
setShowConfirmation(false);
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleResultModalClose = () => {
|
||||
setShowResultModal(false);
|
||||
setResultType(null);
|
||||
setResultMessage(null);
|
||||
// Refresh the data to show updated values
|
||||
void refetch();
|
||||
};
|
||||
|
||||
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 "Sync from Server" to get the latest version.
|
||||
</p>
|
||||
</div>
|
||||
</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
|
||||
<span className="text-xs text-muted-foreground ml-2">(can only be increased)</span>
|
||||
</label>
|
||||
<Input
|
||||
id="rootfs_size"
|
||||
value={formData.rootfs_size}
|
||||
onChange={(e) => handleInputChange('rootfs_size', e.target.value)}
|
||||
placeholder="4G"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Disk size can only be increased for safety. Format: 4G, 8G, 16G, etc.
|
||||
</p>
|
||||
</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={isSaving || saveMutation.isPending || !hasChanges}
|
||||
variant="default"
|
||||
>
|
||||
{isSaving ? 'Saving & Resizing...' : 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.${
|
||||
formData.rootfs_size && configData?.config?.rootfs_size &&
|
||||
parseFloat(String(formData.rootfs_size)) > parseFloat(String(configData.config.rootfs_size ?? '0'))
|
||||
? `\n\n⚠️ DISK RESIZE DETECTED: The disk size will be increased from ${configData.config.rootfs_size} to ${formData.rootfs_size}. This operation will automatically resize the underlying storage and filesystem.`
|
||||
: ''
|
||||
}\n\nThe container may need to be restarted for changes to take effect.`}
|
||||
variant="danger"
|
||||
confirmText={script.container_id ?? ''}
|
||||
confirmButtonText={formData.rootfs_size && configData?.config?.rootfs_size &&
|
||||
parseFloat(String(formData.rootfs_size)) > parseFloat(String(configData.config.rootfs_size ?? '0'))
|
||||
? "Save & Resize Disk" : "Save Configuration"}
|
||||
/>
|
||||
|
||||
{/* Loading Modal */}
|
||||
<LoadingModal
|
||||
isOpen={isLoading || isSaving}
|
||||
action={isSaving ? "Saving configuration and resizing disk..." : "Loading LXC configuration..."}
|
||||
/>
|
||||
|
||||
{/* Result Modal */}
|
||||
{showResultModal && resultType && resultMessage && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="bg-card text-card-foreground rounded-lg shadow-xl max-w-md w-full mx-4 border border-border">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
{resultType === 'success' ? (
|
||||
<CheckCircle className="h-6 w-6 text-green-600 dark:text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="h-6 w-6 text-red-600 dark:text-red-500" />
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-card-foreground">
|
||||
{resultType === 'success' ? 'Success' : 'Error'}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{resultMessage}
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleResultModalClose}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground hover:bg-primary/90 rounded-md transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface PublicKeyModalProps {
|
||||
|
||||
export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverIp }: PublicKeyModalProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [commandCopied, setCommandCopied] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -54,6 +55,42 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyCommand = async () => {
|
||||
const command = `echo "${publicKey}" >> ~/.ssh/authorized_keys`;
|
||||
try {
|
||||
// Try modern clipboard API first
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(command);
|
||||
setCommandCopied(true);
|
||||
setTimeout(() => setCommandCopied(false), 2000);
|
||||
} else {
|
||||
// Fallback for older browsers or non-HTTPS
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = command;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
setCommandCopied(true);
|
||||
setTimeout(() => setCommandCopied(false), 2000);
|
||||
} catch (fallbackError) {
|
||||
console.error('Fallback copy failed:', fallbackError);
|
||||
alert('Please manually copy this command:\n\n' + command);
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy command to clipboard:', error);
|
||||
alert('Please manually copy this command:\n\n' + command);
|
||||
}
|
||||
};
|
||||
|
||||
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-2xl w-full border border-border">
|
||||
@@ -129,11 +166,44 @@ export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverI
|
||||
<textarea
|
||||
value={publicKey}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground font-mono text-xs min-h-[120px] resize-none border-border focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground font-mono text-xs min-h-[60px] resize-none border-border focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||
placeholder="Public key will appear here..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Command */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">Quick Add Command:</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyCommand}
|
||||
className="gap-2"
|
||||
>
|
||||
{commandCopied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy Command
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-3 bg-muted/50 rounded-md border border-border">
|
||||
<code className="text-sm font-mono text-foreground break-all">
|
||||
echo "{publicKey}" >> ~/.ssh/authorized_keys
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Copy and paste this command directly into your server terminal to add the key to authorized_keys
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
|
||||
@@ -30,7 +30,11 @@ export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHK
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
let content = e.target?.result as string;
|
||||
|
||||
// Auto-trim trailing whitespace and empty lines to fix import issues
|
||||
content = content.replace(/\n\s*$/, '').trimEnd();
|
||||
|
||||
if (validateSSHKey(content)) {
|
||||
onChange(content);
|
||||
onError?.('');
|
||||
@@ -72,7 +76,12 @@ export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHK
|
||||
};
|
||||
|
||||
const handlePasteChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const content = event.target.value;
|
||||
let content = event.target.value;
|
||||
|
||||
// Auto-trim trailing whitespace and empty lines to fix import issues
|
||||
// This addresses the common problem where pasted SSH keys have extra whitespace
|
||||
content = content.replace(/\n\s*$/, '').trimEnd();
|
||||
|
||||
onChange(content);
|
||||
|
||||
if (content.trim() && !validateSSHKey(content)) {
|
||||
|
||||
@@ -351,13 +351,25 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
{/* Show generated key status */}
|
||||
{formData.key_generated && (
|
||||
<div className="p-3 bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800 rounded-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
SSH key pair generated successfully
|
||||
</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
SSH key pair generated successfully
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPublicKeyModal(true)}
|
||||
className="gap-2 border-blue-500/20 text-blue-400 bg-blue-500/10 hover:bg-blue-500/20"
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
View Public Key
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-green-700 dark:text-green-300 mt-1">
|
||||
The private key has been generated and will be saved with the server.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest } 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';
|
||||
|
||||
export async function GET(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest } 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';
|
||||
|
||||
export async function GET(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest } 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 type { Server } from '../../../../../types/server';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
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) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest } 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';
|
||||
|
||||
export async function GET() {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Button } from './_components/ui/button';
|
||||
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
|
||||
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
|
||||
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';
|
||||
|
||||
export default function Home() {
|
||||
|
||||
@@ -1,7 +1,386 @@
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||
import { getDatabase } from "~/server/database-prisma.js";
|
||||
// Removed unused imports
|
||||
import { getDatabase } from "~/server/database-prisma";
|
||||
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');
|
||||
}
|
||||
|
||||
// Helper function to parse rootfs_storage and extract storage pool and disk identifier
|
||||
function parseRootfsStorage(rootfs_storage: string): { storagePool: string; diskId: string } | null {
|
||||
// Format: "PROX2-STORAGE2:vm-113-disk-0"
|
||||
const regex = /^([^:]+):(.+)$/;
|
||||
const match = regex.exec(rootfs_storage);
|
||||
if (!match?.[1] || !match?.[2]) return null;
|
||||
|
||||
return {
|
||||
storagePool: match[1],
|
||||
diskId: match[2]
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to extract size in GB from size string
|
||||
function extractSizeInGB(sizeString: string): number {
|
||||
if (!sizeString) return 0;
|
||||
|
||||
const regex = /^(\d+(?:\.\d+)?)\s*([GMK]?)$/i;
|
||||
const match = regex.exec(sizeString);
|
||||
if (!match?.[1]) return 0;
|
||||
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = (match[2] ?? '').toUpperCase();
|
||||
|
||||
switch (unit) {
|
||||
case 'T': return value * 1024;
|
||||
case 'G': return value;
|
||||
case 'M': return value / 1024;
|
||||
case 'K': return value / (1024 * 1024);
|
||||
case '': return value; // Assume GB if no unit
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Helper function to resize disk
|
||||
async function resizeDisk(
|
||||
server: Server,
|
||||
containerId: string,
|
||||
storageInfo: { storagePool: string; diskId: string },
|
||||
oldSizeGB: number,
|
||||
newSizeGB: number
|
||||
): Promise<{ success: boolean; message: string; error?: string }> {
|
||||
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||
const sshExecutionService = new SSHExecutionService();
|
||||
|
||||
try {
|
||||
// First, try using pct resize (works for most storage types)
|
||||
const pctCommand = `pct resize ${containerId} rootfs ${newSizeGB}G`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let errorOutput = '';
|
||||
let dataOutput = '';
|
||||
|
||||
console.log(`Executing pct resize command: ${pctCommand}`);
|
||||
|
||||
void sshExecutionService.executeCommand(
|
||||
server,
|
||||
pctCommand,
|
||||
(data: string) => {
|
||||
dataOutput += data;
|
||||
console.log('pct resize data:', data);
|
||||
},
|
||||
(error: string) => {
|
||||
errorOutput += error;
|
||||
console.log('pct resize error:', error);
|
||||
},
|
||||
(exitCode: number) => {
|
||||
console.log(`pct resize exit code: ${exitCode}`);
|
||||
console.log(`pct resize error output: "${errorOutput}"`);
|
||||
console.log(`pct resize data output: "${dataOutput}"`);
|
||||
|
||||
// Check for error messages in both stderr and stdout
|
||||
const hasError = errorOutput.trim() || dataOutput.toLowerCase().includes('error') || dataOutput.toLowerCase().includes('insufficient');
|
||||
|
||||
// Check both exit code and error output for failure
|
||||
if (exitCode === 0 && !hasError) {
|
||||
resolve({
|
||||
success: true,
|
||||
message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using pct resize`
|
||||
});
|
||||
} else {
|
||||
// If pct resize fails (either non-zero exit code or error output), try LVM-specific commands
|
||||
const errorMessage = errorOutput.trim() || dataOutput.trim();
|
||||
const combinedError = errorMessage ? `pct resize error: ${errorMessage}` : `pct resize failed with exit code ${exitCode}`;
|
||||
void tryLVMResize(server, containerId, storageInfo, newSizeGB, oldSizeGB, resolve, combinedError);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Resize failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error during resize'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to try LVM-specific resize
|
||||
async function tryLVMResize(
|
||||
server: Server,
|
||||
containerId: string,
|
||||
storageInfo: { storagePool: string; diskId: string },
|
||||
newSizeGB: number,
|
||||
oldSizeGB: number,
|
||||
resolve: (result: { success: boolean; message: string; error?: string }) => void,
|
||||
previousError?: string
|
||||
) {
|
||||
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||
const sshExecutionService = new SSHExecutionService();
|
||||
|
||||
// Try LVM resize commands
|
||||
const lvPath = `/dev/${storageInfo.storagePool}/${storageInfo.diskId}`;
|
||||
const lvresizeCommand = `lvresize -L ${newSizeGB}G ${lvPath}`;
|
||||
|
||||
void sshExecutionService.executeCommand(
|
||||
server,
|
||||
lvresizeCommand,
|
||||
(_data: string) => {
|
||||
// Now resize the filesystem
|
||||
const resize2fsCommand = `resize2fs ${lvPath}`;
|
||||
|
||||
void sshExecutionService.executeCommand(
|
||||
server,
|
||||
resize2fsCommand,
|
||||
(_fsData: string) => {
|
||||
resolve({
|
||||
success: true,
|
||||
message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using LVM commands`
|
||||
});
|
||||
},
|
||||
(fsError: string) => {
|
||||
// Try xfs_growfs as fallback
|
||||
const xfsCommand = `xfs_growfs ${lvPath}`;
|
||||
|
||||
void sshExecutionService.executeCommand(
|
||||
server,
|
||||
xfsCommand,
|
||||
(_xfsData: string) => {
|
||||
resolve({
|
||||
success: true,
|
||||
message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using LVM + XFS commands`
|
||||
});
|
||||
},
|
||||
(xfsError: string) => {
|
||||
resolve({
|
||||
success: false,
|
||||
message: 'Filesystem resize failed',
|
||||
error: `LVM resize succeeded but filesystem resize failed: ${fsError}, XFS fallback also failed: ${xfsError}`
|
||||
});
|
||||
},
|
||||
(xfsExitCode: number) => {
|
||||
if (xfsExitCode === 0) {
|
||||
resolve({
|
||||
success: true,
|
||||
message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using LVM + XFS commands`
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
success: false,
|
||||
message: 'Filesystem resize failed',
|
||||
error: `LVM resize succeeded but filesystem resize failed: ${fsError}, XFS fallback also failed with exit code ${xfsExitCode}`
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
(fsExitCode: number) => {
|
||||
if (fsExitCode === 0) {
|
||||
resolve({
|
||||
success: true,
|
||||
message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using LVM commands`
|
||||
});
|
||||
} else {
|
||||
// Try xfs_growfs as fallback
|
||||
const xfsCommand = `xfs_growfs ${lvPath}`;
|
||||
|
||||
void sshExecutionService.executeCommand(
|
||||
server,
|
||||
xfsCommand,
|
||||
(_xfsData: string) => {
|
||||
resolve({
|
||||
success: true,
|
||||
message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using LVM + XFS commands`
|
||||
});
|
||||
},
|
||||
(xfsError: string) => {
|
||||
resolve({
|
||||
success: false,
|
||||
message: 'Filesystem resize failed',
|
||||
error: `LVM resize succeeded but filesystem resize failed with exit code ${fsExitCode}, XFS fallback also failed: ${xfsError}`
|
||||
});
|
||||
},
|
||||
(xfsExitCode: number) => {
|
||||
if (xfsExitCode === 0) {
|
||||
resolve({
|
||||
success: true,
|
||||
message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using LVM + XFS commands`
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
success: false,
|
||||
message: 'Filesystem resize failed',
|
||||
error: `LVM resize succeeded but filesystem resize failed with exit code ${fsExitCode}, XFS fallback also failed with exit code ${xfsExitCode}`
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
(error: string) => {
|
||||
const combinedError = previousError ? `${previousError} LVM error: ${error}` : `LVM resize failed: ${error}`;
|
||||
resolve({
|
||||
success: false,
|
||||
message: 'Resize failed',
|
||||
error: `Both pct resize and LVM resize failed. ${combinedError}`
|
||||
});
|
||||
},
|
||||
(exitCode: number) => {
|
||||
if (exitCode === 0) {
|
||||
// This shouldn't happen as we're in the error callback, but handle it
|
||||
resolve({
|
||||
success: true,
|
||||
message: `Disk resized from ${oldSizeGB}G to ${newSizeGB}G using LVM commands`
|
||||
});
|
||||
} else {
|
||||
const combinedError = previousError ? `${previousError} LVM command failed with exit code ${exitCode}` : `LVM command failed with exit code ${exitCode}`;
|
||||
resolve({
|
||||
success: false,
|
||||
message: 'Resize failed',
|
||||
error: `Both pct resize and LVM resize failed. ${combinedError}`
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export const installedScriptsRouter = createTRPCRouter({
|
||||
@@ -285,8 +664,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
const sshExecutionService = new SSHExecutionService();
|
||||
|
||||
// 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) {
|
||||
return {
|
||||
@@ -307,8 +686,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
|
||||
server as Server,
|
||||
command,
|
||||
(data: string) => {
|
||||
commandOutput += data;
|
||||
@@ -339,8 +718,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
return new Promise<any>((readResolve) => {
|
||||
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
|
||||
server as Server,
|
||||
readCommand,
|
||||
(configData: string) => {
|
||||
// Parse config file for hostname
|
||||
@@ -356,12 +735,21 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
if (hostname) {
|
||||
// Parse full config and store in database
|
||||
const parsedConfig = parseRawConfig(configData);
|
||||
const configHash = calculateConfigHash(configData);
|
||||
|
||||
const container = {
|
||||
containerId,
|
||||
hostname,
|
||||
configPath,
|
||||
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);
|
||||
} else {
|
||||
@@ -430,6 +818,11 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
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({
|
||||
id: result.id,
|
||||
containerId: container.containerId,
|
||||
@@ -506,8 +899,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
|
||||
|
||||
// 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) {
|
||||
continue;
|
||||
}
|
||||
@@ -518,8 +911,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
const containerExists = await new Promise<boolean>((resolve) => {
|
||||
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
|
||||
server as Server,
|
||||
checkCommand,
|
||||
(data: string) => {
|
||||
resolve(data.trim() === 'exists');
|
||||
@@ -592,8 +985,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
try {
|
||||
|
||||
// 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) {
|
||||
continue;
|
||||
}
|
||||
@@ -610,8 +1003,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve, reject) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
|
||||
server as Server,
|
||||
listCommand,
|
||||
(data: string) => {
|
||||
listOutput += data;
|
||||
@@ -715,8 +1108,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
const sshExecutionService = new SSHExecutionService();
|
||||
|
||||
// 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) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -731,8 +1124,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
|
||||
server as Server,
|
||||
statusCommand,
|
||||
(data: string) => {
|
||||
statusOutput += data;
|
||||
@@ -814,8 +1207,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
const sshExecutionService = new SSHExecutionService();
|
||||
|
||||
// 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) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -830,8 +1223,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
|
||||
server as Server,
|
||||
controlCommand,
|
||||
(data: string) => {
|
||||
commandOutput += data;
|
||||
@@ -905,8 +1298,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
const sshExecutionService = new SSHExecutionService();
|
||||
|
||||
// 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) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -921,8 +1314,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
|
||||
server as Server,
|
||||
statusCommand,
|
||||
(data: string) => {
|
||||
statusOutput += data;
|
||||
@@ -945,8 +1338,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
|
||||
server as Server,
|
||||
stopCommand,
|
||||
(data: string) => {
|
||||
stopOutput += data;
|
||||
@@ -976,8 +1369,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
|
||||
server as Server,
|
||||
destroyCommand,
|
||||
(data: string) => {
|
||||
commandOutput += data;
|
||||
@@ -1079,8 +1472,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
|
||||
// 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);
|
||||
|
||||
const connectionTest = await sshService.testSSHConnection(server as Server);
|
||||
if (!(connectionTest as any).success) {
|
||||
console.log('❌ SSH connection failed:', (connectionTest as any).error);
|
||||
return {
|
||||
@@ -1099,8 +1492,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
|
||||
server as Server,
|
||||
hostnameCommand,
|
||||
(data: string) => {
|
||||
console.log('📤 Command output chunk:', data);
|
||||
@@ -1197,5 +1590,365 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
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'}`
|
||||
};
|
||||
}
|
||||
|
||||
// Get current config for comparison
|
||||
const currentConfig = await db.getLXCConfigByScriptId(input.scriptId);
|
||||
const oldSizeGB = currentConfig ? extractSizeInGB(String(currentConfig.rootfs_size ?? '0G')) : 0;
|
||||
const newSizeGB = extractSizeInGB(String(input.config.rootfs_size ?? '0G'));
|
||||
|
||||
// Validate size change - only allow increases
|
||||
if (newSizeGB < oldSizeGB) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Disk size cannot be decreased. Current size: ${oldSizeGB}G, requested size: ${newSizeGB}G. Only increases are allowed for safety.`
|
||||
};
|
||||
}
|
||||
|
||||
// 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}`));
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Check if disk size increased and needs resizing
|
||||
let resizeResult: { success: boolean; message: string; error?: string } | null = null;
|
||||
if (newSizeGB > oldSizeGB) {
|
||||
// Parse storage information
|
||||
const storageInfo = parseRootfsStorage(String(input.config.rootfs_storage));
|
||||
if (!storageInfo) {
|
||||
// Rollback config file
|
||||
const rollbackCommand = `cat > "${configPath}" << 'EOFCONFIG'
|
||||
${reconstructConfig(currentConfig ?? {})}
|
||||
EOFCONFIG`;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
server as Server,
|
||||
rollbackCommand,
|
||||
() => resolve(),
|
||||
(error: string) => reject(new Error(error)),
|
||||
(exitCode: number) => {
|
||||
if (exitCode === 0) resolve();
|
||||
else reject(new Error(`Rollback failed with exit code ${exitCode}`));
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid rootfs_storage format. Configuration rolled back.'
|
||||
};
|
||||
}
|
||||
|
||||
// Attempt disk resize
|
||||
try {
|
||||
console.log(`Attempting to resize disk from ${oldSizeGB}G to ${newSizeGB}G for container ${script.container_id}`);
|
||||
resizeResult = await resizeDisk(server as Server, script.container_id, storageInfo, oldSizeGB, newSizeGB);
|
||||
console.log('Resize result:', resizeResult);
|
||||
|
||||
if (!resizeResult.success) {
|
||||
console.log('Resize failed, attempting rollback...');
|
||||
// Rollback config file on resize failure
|
||||
const rollbackCommand = `cat > "${configPath}" << 'EOFCONFIG'
|
||||
${reconstructConfig(currentConfig ?? {})}
|
||||
EOFCONFIG`;
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
server as Server,
|
||||
rollbackCommand,
|
||||
() => resolve(),
|
||||
(error: string) => reject(new Error(error)),
|
||||
(exitCode: number) => {
|
||||
if (exitCode === 0) resolve();
|
||||
else reject(new Error(`Rollback failed with exit code ${exitCode}`));
|
||||
}
|
||||
);
|
||||
});
|
||||
console.log('Rollback successful');
|
||||
} catch (rollbackError) {
|
||||
console.error('Rollback failed:', rollbackError);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Configuration rolled back. Disk resize failed: ${resizeResult.error}`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Resize operation threw error:', error);
|
||||
// Rollback config file on resize error
|
||||
const rollbackCommand = `cat > "${configPath}" << 'EOFCONFIG'
|
||||
${reconstructConfig(currentConfig ?? {})}
|
||||
EOFCONFIG`;
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
server as Server,
|
||||
rollbackCommand,
|
||||
() => resolve(),
|
||||
(error: string) => reject(new Error(error)),
|
||||
(exitCode: number) => {
|
||||
if (exitCode === 0) resolve();
|
||||
else reject(new Error(`Rollback failed with exit code ${exitCode}`));
|
||||
}
|
||||
);
|
||||
});
|
||||
console.log('Rollback successful after error');
|
||||
} catch (rollbackError) {
|
||||
console.error('Rollback failed after error:', rollbackError);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Configuration rolled back. Disk resize error: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Update database cache
|
||||
const configData = {
|
||||
...input.config,
|
||||
config_hash: configHash,
|
||||
synced_at: new Date()
|
||||
};
|
||||
|
||||
await db.updateLXCConfig(input.scriptId, configData);
|
||||
|
||||
// Return success message with resize info if applicable
|
||||
const message = resizeResult
|
||||
? `LXC configuration saved successfully. ${resizeResult.message}`
|
||||
: 'LXC configuration saved successfully';
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message
|
||||
};
|
||||
} 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;
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||
import { getDatabase } from "~/server/database-prisma.js";
|
||||
import { getDatabase } from "~/server/database-prisma";
|
||||
|
||||
export const serversRouter = createTRPCRouter({
|
||||
getAllServers: publicProcedure
|
||||
|
||||
@@ -238,6 +238,39 @@ class DatabaseServicePrisma {
|
||||
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() {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
@@ -251,4 +284,4 @@ export function getDatabase() {
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
export default DatabaseServicePrisma;
|
||||
export default DatabaseServicePrisma;
|
||||
@@ -263,6 +263,39 @@ class DatabaseServicePrisma {
|
||||
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() {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user