Compare commits

...

4 Commits

Author SHA1 Message Date
Michel Roegl-Brunner
0292034900 feat: implement disk resize with improved modal flow and error handling
- Add automatic disk resizing when changing LXC disk size in settings
- Implement separate modal flow: confirmation -> loading -> result -> settings
- Add proper error detection for pct resize command (check both exit code and output)
- Add LVM fallback resize method when pct resize fails
- Implement configuration rollback on resize failure
- Update modal styling to use semantic color classes for proper dark mode support
- Add data refresh after result modal close to show updated values
- Remove success/error banners from settings modal for cleaner UI
2025-10-17 13:32:28 +02:00
github-actions[bot]
c1b478ed51 chore: add VERSION v0.4.4 (#176)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-17 09:40:26 +00:00
github-actions[bot]
4af5ad4f7b chore: add VERSION v0.4.4 (#175)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-17 09:39:36 +00:00
Michel Roegl-Brunner
537d65275a feat: improve LXC settings modal and fix database issues (#174)
- Fix Prisma database errors in LXC config sync (advanced and rootfs field issues)
- Remove double confirmation from LXC settings modal (keep confirmation modal, remove inline input)
- Fix dependency loop in status check useEffect
- Add LXC configuration management with proper validation
- Improve error handling and user experience
2025-10-17 11:38:23 +02:00
16 changed files with 1881 additions and 51 deletions

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ model InstalledScript {
web_ui_ip String?
web_ui_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")
}

View File

@@ -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 &quot;Sync from Server&quot; button allows you to refresh the configuration from the actual server file, useful when:
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Configuration was modified outside of this interface</li>
<li> You want to discard local changes and get the latest server version</li>
<li> The warning banner indicates the cached config differs from server</li>
<li> You want to ensure you&apos;re working with the most current configuration</li>
</ul>
</div>
<div className="p-4 border border-border rounded-lg">
<h4 className="font-medium text-foreground mb-2">Database Caching</h4>
<p className="text-sm text-muted-foreground mb-3">
LXC configurations are cached in the database for improved performance and offline access.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> <strong>Automatic caching:</strong> Configs are cached during auto-detection and after saves</li>
<li> <strong>Cache expiration:</strong> Cached configs expire after 5 minutes for freshness</li>
<li> <strong>Change detection:</strong> Hash comparison detects external modifications</li>
<li> <strong>Manual sync:</strong> Always available via the &quot;Sync from Server&quot; button</li>
</ul>
</div>
</div>
</div>
);
default:
return null;
}

View File

@@ -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"
@@ -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>
);
}

View 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 &quot;Sync from Server&quot; 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>
)}
</>
);
}

View File

@@ -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(

View File

@@ -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(

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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;
})
});

View File

@@ -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

View File

@@ -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;

View File

@@ -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();
}