Compare commits
1 Commits
fix/variou
...
update-ver
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edb8b64e46 |
@@ -25,5 +25,4 @@ AUTH_USERNAME=
|
||||
AUTH_PASSWORD_HASH=
|
||||
AUTH_ENABLED=false
|
||||
AUTH_SETUP_COMPLETED=false
|
||||
JWT_SECRET=
|
||||
DATABASE_URL="file:./data/database.sqlite"
|
||||
JWT_SECRET=
|
||||
3
.github/release-drafter.yml
vendored
3
.github/release-drafter.yml
vendored
@@ -7,9 +7,6 @@ exclude-labels:
|
||||
- automated
|
||||
|
||||
categories:
|
||||
- title: "Breaking Changes"
|
||||
labels:
|
||||
- breaking
|
||||
- title: "🚀 Features"
|
||||
labels:
|
||||
- feature
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -49,5 +49,4 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
|
||||
# idea files
|
||||
.idea
|
||||
/generated/prisma
|
||||
.idea
|
||||
820
package-lock.json
generated
820
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,12 +22,11 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.17.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@tanstack/react-query": "^5.90.3",
|
||||
"@trpc/client": "^11.6.0",
|
||||
"@trpc/react-query": "^11.6.0",
|
||||
"@trpc/server": "^11.6.0",
|
||||
@@ -37,10 +36,11 @@
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.546.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"next": "^15.5.5",
|
||||
"node-pty": "^1.0.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -65,7 +65,7 @@
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.8.0",
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
@@ -77,7 +77,6 @@
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.7.0",
|
||||
"prisma": "^6.17.1",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.46.1",
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
-- 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");
|
||||
@@ -1,3 +0,0 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
@@ -1,97 +0,0 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model InstalledScript {
|
||||
id Int @id @default(autoincrement())
|
||||
script_name String
|
||||
script_path String
|
||||
container_id String?
|
||||
server_id Int?
|
||||
execution_mode String
|
||||
installation_date DateTime? @default(now())
|
||||
status String
|
||||
output_log String?
|
||||
web_ui_ip String?
|
||||
web_ui_port Int?
|
||||
server Server? @relation(fields: [server_id], references: [id], onDelete: SetNull)
|
||||
lxc_config LXCConfig?
|
||||
|
||||
@@map("installed_scripts")
|
||||
}
|
||||
|
||||
model Server {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
ip String
|
||||
user String
|
||||
password String?
|
||||
auth_type String? @default("password")
|
||||
ssh_key String?
|
||||
ssh_key_passphrase String?
|
||||
ssh_port Int? @default(22)
|
||||
color String?
|
||||
created_at DateTime? @default(now())
|
||||
updated_at DateTime? @updatedAt
|
||||
ssh_key_path String?
|
||||
key_generated Boolean? @default(false)
|
||||
installed_scripts InstalledScript[]
|
||||
|
||||
@@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")
|
||||
}
|
||||
38
server.js
38
server.js
@@ -7,7 +7,7 @@ import { join, resolve } from 'path';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import { spawn as ptySpawn } from 'node-pty';
|
||||
import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
|
||||
import { getDatabase } from './src/server/database-prisma.js';
|
||||
import { getDatabase } from './src/server/database.js';
|
||||
|
||||
const dev = process.env.NODE_ENV !== 'production';
|
||||
const hostname = '0.0.0.0';
|
||||
@@ -186,11 +186,11 @@ class ScriptExecutionHandler {
|
||||
* @param {string} scriptPath - Path to the script
|
||||
* @param {string} executionMode - 'local' or 'ssh'
|
||||
* @param {number|null} serverId - Server ID for SSH executions
|
||||
* @returns {Promise<number|null>} - Installation record ID
|
||||
* @returns {number|null} - Installation record ID
|
||||
*/
|
||||
async createInstallationRecord(scriptName, scriptPath, executionMode, serverId = null) {
|
||||
createInstallationRecord(scriptName, scriptPath, executionMode, serverId = null) {
|
||||
try {
|
||||
const result = await this.db.createInstalledScript({
|
||||
const result = this.db.createInstalledScript({
|
||||
script_name: scriptName,
|
||||
script_path: scriptPath,
|
||||
container_id: undefined,
|
||||
@@ -199,7 +199,7 @@ class ScriptExecutionHandler {
|
||||
status: 'in_progress',
|
||||
output_log: ''
|
||||
});
|
||||
return Number(result.id);
|
||||
return Number(result.lastInsertRowid);
|
||||
} catch (error) {
|
||||
console.error('Error creating installation record:', error);
|
||||
return null;
|
||||
@@ -211,9 +211,9 @@ class ScriptExecutionHandler {
|
||||
* @param {number} installationId - Installation record ID
|
||||
* @param {Object} updateData - Data to update
|
||||
*/
|
||||
async updateInstallationRecord(installationId, updateData) {
|
||||
updateInstallationRecord(installationId, updateData) {
|
||||
try {
|
||||
await this.db.updateInstalledScript(installationId, updateData);
|
||||
this.db.updateInstalledScript(installationId, updateData);
|
||||
} catch (error) {
|
||||
console.error('Error updating installation record:', error);
|
||||
}
|
||||
@@ -327,7 +327,7 @@ class ScriptExecutionHandler {
|
||||
|
||||
// Create installation record
|
||||
const serverId = server ? (server.id ?? null) : null;
|
||||
installationId = await this.createInstallationRecord(scriptName, scriptPath, mode, serverId);
|
||||
installationId = this.createInstallationRecord(scriptName, scriptPath, mode, serverId);
|
||||
|
||||
if (!installationId) {
|
||||
console.error('Failed to create installation record');
|
||||
@@ -356,7 +356,7 @@ class ScriptExecutionHandler {
|
||||
|
||||
// Update installation record with failure
|
||||
if (installationId) {
|
||||
await this.updateInstallationRecord(installationId, { status: 'failed' });
|
||||
this.updateInstallationRecord(installationId, { status: 'failed' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -394,7 +394,7 @@ class ScriptExecutionHandler {
|
||||
});
|
||||
|
||||
// Handle pty data (both stdout and stderr combined)
|
||||
childProcess.onData(async (data) => {
|
||||
childProcess.onData((data) => {
|
||||
const output = data.toString();
|
||||
|
||||
// Store output in buffer for logging
|
||||
@@ -410,7 +410,7 @@ class ScriptExecutionHandler {
|
||||
// Parse for Container ID
|
||||
const containerId = this.parseContainerId(output);
|
||||
if (containerId && installationId) {
|
||||
await this.updateInstallationRecord(installationId, { container_id: containerId });
|
||||
this.updateInstallationRecord(installationId, { container_id: containerId });
|
||||
}
|
||||
|
||||
// Parse for Web UI URL
|
||||
@@ -418,7 +418,7 @@ class ScriptExecutionHandler {
|
||||
if (webUIUrl && installationId) {
|
||||
const { ip, port } = webUIUrl;
|
||||
if (ip && port) {
|
||||
await this.updateInstallationRecord(installationId, {
|
||||
this.updateInstallationRecord(installationId, {
|
||||
web_ui_ip: ip,
|
||||
web_ui_port: port
|
||||
});
|
||||
@@ -464,7 +464,7 @@ class ScriptExecutionHandler {
|
||||
|
||||
// Update installation record with failure
|
||||
if (installationId) {
|
||||
await this.updateInstallationRecord(installationId, { status: 'failed' });
|
||||
this.updateInstallationRecord(installationId, { status: 'failed' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -491,7 +491,7 @@ class ScriptExecutionHandler {
|
||||
const execution = /** @type {ExecutionResult} */ (await sshService.executeScript(
|
||||
server,
|
||||
scriptPath,
|
||||
/** @param {string} data */ async (data) => {
|
||||
/** @param {string} data */ (data) => {
|
||||
// Store output in buffer for logging
|
||||
const exec = this.activeExecutions.get(executionId);
|
||||
if (exec) {
|
||||
@@ -505,7 +505,7 @@ class ScriptExecutionHandler {
|
||||
// Parse for Container ID
|
||||
const containerId = this.parseContainerId(data);
|
||||
if (containerId && installationId) {
|
||||
await this.updateInstallationRecord(installationId, { container_id: containerId });
|
||||
this.updateInstallationRecord(installationId, { container_id: containerId });
|
||||
}
|
||||
|
||||
// Parse for Web UI URL
|
||||
@@ -513,7 +513,7 @@ class ScriptExecutionHandler {
|
||||
if (webUIUrl && installationId) {
|
||||
const { ip, port } = webUIUrl;
|
||||
if (ip && port) {
|
||||
await this.updateInstallationRecord(installationId, {
|
||||
this.updateInstallationRecord(installationId, {
|
||||
web_ui_ip: ip,
|
||||
web_ui_port: port
|
||||
});
|
||||
@@ -545,13 +545,13 @@ class ScriptExecutionHandler {
|
||||
timestamp: Date.now()
|
||||
});
|
||||
},
|
||||
/** @param {number} code */ async (code) => {
|
||||
/** @param {number} code */ (code) => {
|
||||
const exec = this.activeExecutions.get(executionId);
|
||||
const isSuccess = code === 0;
|
||||
|
||||
// Update installation record with final status and output
|
||||
if (installationId && exec) {
|
||||
await this.updateInstallationRecord(installationId, {
|
||||
this.updateInstallationRecord(installationId, {
|
||||
status: isSuccess ? 'success' : 'failed',
|
||||
output_log: exec.outputBuffer
|
||||
});
|
||||
@@ -586,7 +586,7 @@ class ScriptExecutionHandler {
|
||||
|
||||
// Update installation record with failure
|
||||
if (installationId) {
|
||||
await this.updateInstallationRecord(installationId, { status: 'failed' });
|
||||
this.updateInstallationRecord(installationId, { status: 'failed' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ interface HelpModalProps {
|
||||
initialSection?: string;
|
||||
}
|
||||
|
||||
type HelpSection = 'server-settings' | 'general-settings' | 'sync-button' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system';
|
||||
type HelpSection = 'server-settings' | 'general-settings' | 'sync-button' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'update-system';
|
||||
|
||||
export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) {
|
||||
const [activeSection, setActiveSection] = useState<HelpSection>(initialSection as HelpSection);
|
||||
@@ -24,7 +24,6 @@ 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 },
|
||||
];
|
||||
|
||||
@@ -502,131 +501,6 @@ 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,7 +9,6 @@ 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,
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from './ui/dropdown-menu';
|
||||
import { Settings } from 'lucide-react';
|
||||
|
||||
interface InstalledScript {
|
||||
id: number;
|
||||
@@ -93,12 +91,6 @@ 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();
|
||||
@@ -396,7 +388,7 @@ export function InstalledScriptsTab() {
|
||||
containerStatusMutation.mutate({ serverIds });
|
||||
}
|
||||
}, 500);
|
||||
}, []);
|
||||
}, []);
|
||||
|
||||
// Run cleanup when component mounts and scripts are loaded (only once)
|
||||
useEffect(() => {
|
||||
@@ -412,7 +404,7 @@ export function InstalledScriptsTab() {
|
||||
console.log('Status check triggered - scripts length:', scripts.length);
|
||||
fetchContainerStatuses();
|
||||
}
|
||||
}, [scripts.length]);
|
||||
}, [scripts.length, fetchContainerStatuses]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
@@ -712,10 +704,6 @@ 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({
|
||||
@@ -934,7 +922,7 @@ export function InstalledScriptsTab() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={fetchContainerStatuses}
|
||||
disabled={containerStatusMutation.isPending ?? scripts.length === 0}
|
||||
disabled={containerStatusMutation.isPending || scripts.length === 0}
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
@@ -1139,7 +1127,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"
|
||||
@@ -1376,7 +1364,7 @@ export function InstalledScriptsTab() {
|
||||
type="text"
|
||||
value={editFormData.web_ui_ip}
|
||||
onChange={(e) => handleInputChange('web_ui_ip', e.target.value)}
|
||||
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"
|
||||
className="w-32 px-3 py-2 text-sm font-mono border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||
placeholder="IP"
|
||||
/>
|
||||
<span className="text-muted-foreground">:</span>
|
||||
@@ -1511,7 +1499,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'}
|
||||
@@ -1519,14 +1507,6 @@ 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')}
|
||||
@@ -1607,17 +1587,6 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,725 +0,0 @@
|
||||
'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,7 +14,6 @@ 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;
|
||||
|
||||
@@ -55,42 +54,6 @@ 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">
|
||||
@@ -166,44 +129,11 @@ 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-[60px] 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-[120px] 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,11 +30,7 @@ export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHK
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
let content = e.target?.result as string;
|
||||
|
||||
// Auto-trim trailing whitespace and empty lines to fix import issues
|
||||
content = content.replace(/\n\s*$/, '').trimEnd();
|
||||
|
||||
const content = e.target?.result as string;
|
||||
if (validateSSHKey(content)) {
|
||||
onChange(content);
|
||||
onError?.('');
|
||||
@@ -76,12 +72,7 @@ export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHK
|
||||
};
|
||||
|
||||
const handlePasteChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
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();
|
||||
|
||||
const content = event.target.value;
|
||||
onChange(content);
|
||||
|
||||
if (content.trim() && !validateSSHKey(content)) {
|
||||
|
||||
@@ -159,7 +159,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
...prev,
|
||||
ssh_key: data.privateKey ?? '',
|
||||
ssh_key_path: keyPath,
|
||||
key_generated: true
|
||||
key_generated: 1
|
||||
}));
|
||||
setGeneratedPublicKey(data.publicKey ?? '');
|
||||
setGeneratedServerId(serverId);
|
||||
@@ -351,25 +351,13 @@ 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 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 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>
|
||||
<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.
|
||||
|
||||
@@ -192,8 +192,8 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Created: {server.created_at ? new Date(server.created_at).toLocaleDateString() : 'Unknown'}
|
||||
{server.updated_at && server.updated_at !== server.created_at && (
|
||||
Created: {new Date(server.created_at).toLocaleDateString()}
|
||||
{server.updated_at !== server.created_at && (
|
||||
<span> • Updated: {new Date(server.updated_at).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -253,7 +253,7 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
||||
</Button>
|
||||
<div className="flex space-x-2">
|
||||
{/* View Public Key button - only show for generated keys */}
|
||||
{server.key_generated === true && (
|
||||
{server.key_generated === 1 && (
|
||||
<Button
|
||||
onClick={() => handleViewPublicKey(server)}
|
||||
variant="outline"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getDatabase } from '../../../../../server/database-prisma';
|
||||
import { getDatabase } from '../../../../../server/database';
|
||||
import { getSSHService } from '../../../../../server/ssh-service';
|
||||
|
||||
export async function GET(
|
||||
@@ -18,7 +18,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
const server = await db.getServerById(id);
|
||||
const server = db.getServerById(id);
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getDatabase } from '../../../../server/database-prisma';
|
||||
import { getDatabase } from '../../../../server/database';
|
||||
import type { CreateServerData } from '../../../../types/server';
|
||||
|
||||
export async function GET(
|
||||
@@ -18,7 +18,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
const server = await db.getServerById(id);
|
||||
const server = db.getServerById(id);
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json(
|
||||
@@ -95,7 +95,7 @@ export async function PUT(
|
||||
const db = getDatabase();
|
||||
|
||||
// Check if server exists
|
||||
const existingServer = await db.getServerById(id);
|
||||
const existingServer = db.getServerById(id);
|
||||
if (!existingServer) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Server not found' },
|
||||
@@ -103,7 +103,7 @@ export async function PUT(
|
||||
);
|
||||
}
|
||||
|
||||
await db.updateServer(id, {
|
||||
const result = db.updateServer(id, {
|
||||
name,
|
||||
ip,
|
||||
user,
|
||||
@@ -113,14 +113,14 @@ export async function PUT(
|
||||
ssh_key_passphrase,
|
||||
ssh_port: ssh_port ?? 22,
|
||||
color,
|
||||
key_generated: key_generated ?? false,
|
||||
key_generated: key_generated ?? 0,
|
||||
ssh_key_path
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Server updated successfully',
|
||||
changes: 1
|
||||
changes: result.changes
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -158,7 +158,7 @@ export async function DELETE(
|
||||
const db = getDatabase();
|
||||
|
||||
// Check if server exists
|
||||
const existingServer = await db.getServerById(id);
|
||||
const existingServer = db.getServerById(id);
|
||||
if (!existingServer) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Server not found' },
|
||||
@@ -167,14 +167,14 @@ export async function DELETE(
|
||||
}
|
||||
|
||||
// Delete all installed scripts associated with this server
|
||||
await db.deleteInstalledScriptsByServer(id);
|
||||
db.deleteInstalledScriptsByServer(id);
|
||||
|
||||
await db.deleteServer(id);
|
||||
const result = db.deleteServer(id);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Server deleted successfully',
|
||||
changes: 1
|
||||
changes: result.changes
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getDatabase } from '../../../../../server/database-prisma';
|
||||
import { getDatabase } from '../../../../../server/database';
|
||||
import { getSSHService } from '../../../../../server/ssh-service';
|
||||
import type { Server } from '../../../../../types/server';
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function POST(
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
const server = await db.getServerById(id) as Server;
|
||||
const server = db.getServerById(id) as Server;
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -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';
|
||||
import { getDatabase } from '../../../../server/database';
|
||||
|
||||
export async function POST(_request: NextRequest) {
|
||||
try {
|
||||
@@ -9,7 +9,7 @@ export async function POST(_request: NextRequest) {
|
||||
const db = getDatabase();
|
||||
|
||||
// Get the next available server ID for key file naming
|
||||
const serverId = await db.getNextServerId();
|
||||
const serverId = db.getNextServerId();
|
||||
|
||||
const keyPair = await sshService.generateKeyPair(serverId);
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getDatabase } from '../../../server/database-prisma';
|
||||
import { getDatabase } from '../../../server/database';
|
||||
import type { CreateServerData } from '../../../types/server';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const servers = await db.getAllServers();
|
||||
const servers = db.getAllServers();
|
||||
return NextResponse.json(servers);
|
||||
} catch (error) {
|
||||
console.error('Error fetching servers:', error);
|
||||
@@ -61,7 +61,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
|
||||
const db = getDatabase();
|
||||
const result = await db.createServer({
|
||||
const result = db.createServer({
|
||||
name,
|
||||
ip,
|
||||
user,
|
||||
@@ -71,14 +71,14 @@ export async function POST(request: NextRequest) {
|
||||
ssh_key_passphrase,
|
||||
ssh_port: ssh_port ?? 22,
|
||||
color,
|
||||
key_generated: key_generated ?? false,
|
||||
key_generated: key_generated ?? 0,
|
||||
ssh_key_path
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Server created successfully',
|
||||
id: result.id
|
||||
id: result.lastInsertRowid
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
|
||||
@@ -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 { Package, HardDrive, FolderOpen } from 'lucide-react';
|
||||
import { Rocket, Package, HardDrive, FolderOpen } from 'lucide-react';
|
||||
import { api } from '~/trpc/react';
|
||||
|
||||
export default function Home() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||
import { getDatabase } from "~/server/database-prisma";
|
||||
import { getDatabase } from "~/server/database";
|
||||
|
||||
export const serversRouter = createTRPCRouter({
|
||||
getAllServers: publicProcedure
|
||||
.query(async () => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const servers = await db.getAllServers();
|
||||
const servers = db.getAllServers();
|
||||
return { success: true, servers };
|
||||
} catch (error) {
|
||||
console.error('Error fetching servers:', error);
|
||||
@@ -24,7 +24,7 @@ export const serversRouter = createTRPCRouter({
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const server = await db.getServerById(input.id);
|
||||
const server = db.getServerById(input.id);
|
||||
if (!server) {
|
||||
return { success: false, error: 'Server not found', server: null };
|
||||
}
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
import { prisma } from './db.js';
|
||||
import { join } from 'path';
|
||||
import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
class DatabaseServicePrisma {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Ensure data/ssh-keys directory exists
|
||||
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||
if (!existsSync(sshKeysDir)) {
|
||||
mkdirSync(sshKeysDir, { mode: 0o700 });
|
||||
}
|
||||
}
|
||||
|
||||
// Server CRUD operations
|
||||
async createServer(serverData) {
|
||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
||||
|
||||
let ssh_key_path = null;
|
||||
|
||||
// If using SSH key authentication, create persistent key file
|
||||
if (auth_type === 'key' && ssh_key) {
|
||||
const serverId = await this.getNextServerId();
|
||||
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
|
||||
}
|
||||
|
||||
return await prisma.server.create({
|
||||
data: {
|
||||
name,
|
||||
ip,
|
||||
user,
|
||||
password,
|
||||
auth_type: auth_type ?? 'password',
|
||||
ssh_key,
|
||||
ssh_key_passphrase,
|
||||
ssh_port: ssh_port ?? 22,
|
||||
ssh_key_path,
|
||||
key_generated: Boolean(key_generated),
|
||||
color,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getAllServers() {
|
||||
return await prisma.server.findMany({
|
||||
orderBy: { created_at: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
async getServerById(id) {
|
||||
return await prisma.server.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
}
|
||||
|
||||
async updateServer(id, serverData) {
|
||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
||||
|
||||
// Get existing server to check for key changes
|
||||
const existingServer = await this.getServerById(id);
|
||||
let ssh_key_path = existingServer?.ssh_key_path;
|
||||
|
||||
// Handle SSH key changes
|
||||
if (auth_type === 'key' && ssh_key) {
|
||||
// Delete old key file if it exists
|
||||
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
|
||||
try {
|
||||
unlinkSync(existingServer.ssh_key_path);
|
||||
// Also delete public key file if it exists
|
||||
const pubKeyPath = existingServer.ssh_key_path + '.pub';
|
||||
if (existsSync(pubKeyPath)) {
|
||||
unlinkSync(pubKeyPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete old SSH key file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new key file
|
||||
ssh_key_path = this.createSSHKeyFile(id, ssh_key);
|
||||
} else if (auth_type !== 'key') {
|
||||
// If switching away from key auth, delete key files
|
||||
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
|
||||
try {
|
||||
unlinkSync(existingServer.ssh_key_path);
|
||||
const pubKeyPath = existingServer.ssh_key_path + '.pub';
|
||||
if (existsSync(pubKeyPath)) {
|
||||
unlinkSync(pubKeyPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete SSH key file:', error);
|
||||
}
|
||||
}
|
||||
ssh_key_path = null;
|
||||
}
|
||||
|
||||
return await prisma.server.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
ip,
|
||||
user,
|
||||
password,
|
||||
auth_type: auth_type ?? 'password',
|
||||
ssh_key,
|
||||
ssh_key_passphrase,
|
||||
ssh_port: ssh_port ?? 22,
|
||||
ssh_key_path,
|
||||
key_generated: key_generated !== undefined ? Boolean(key_generated) : (existingServer?.key_generated ?? false),
|
||||
color,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteServer(id) {
|
||||
// Get server info before deletion to clean up key files
|
||||
const server = await this.getServerById(id);
|
||||
|
||||
// Delete SSH key files if they exist
|
||||
if (server?.ssh_key_path && existsSync(server.ssh_key_path)) {
|
||||
try {
|
||||
unlinkSync(server.ssh_key_path);
|
||||
const pubKeyPath = server.ssh_key_path + '.pub';
|
||||
if (existsSync(pubKeyPath)) {
|
||||
unlinkSync(pubKeyPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete SSH key file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.server.delete({
|
||||
where: { id }
|
||||
});
|
||||
}
|
||||
|
||||
// Installed Scripts CRUD operations
|
||||
async createInstalledScript(scriptData) {
|
||||
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port } = scriptData;
|
||||
|
||||
return await prisma.installedScript.create({
|
||||
data: {
|
||||
script_name,
|
||||
script_path,
|
||||
container_id: container_id ?? null,
|
||||
server_id: server_id ?? null,
|
||||
execution_mode,
|
||||
status,
|
||||
output_log: output_log ?? null,
|
||||
web_ui_ip: web_ui_ip ?? null,
|
||||
web_ui_port: web_ui_port ?? null,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getAllInstalledScripts() {
|
||||
return await prisma.installedScript.findMany({
|
||||
include: {
|
||||
server: true
|
||||
},
|
||||
orderBy: { installation_date: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
async getInstalledScriptById(id) {
|
||||
return await prisma.installedScript.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
server: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getInstalledScriptsByServer(server_id) {
|
||||
return await prisma.installedScript.findMany({
|
||||
where: { server_id },
|
||||
include: {
|
||||
server: true
|
||||
},
|
||||
orderBy: { installation_date: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
async updateInstalledScript(id, updateData) {
|
||||
const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData;
|
||||
|
||||
const updateFields = {};
|
||||
if (script_name !== undefined) updateFields.script_name = script_name;
|
||||
if (container_id !== undefined) updateFields.container_id = container_id;
|
||||
if (status !== undefined) updateFields.status = status;
|
||||
if (output_log !== undefined) updateFields.output_log = output_log;
|
||||
if (web_ui_ip !== undefined) updateFields.web_ui_ip = web_ui_ip;
|
||||
if (web_ui_port !== undefined) updateFields.web_ui_port = web_ui_port;
|
||||
|
||||
if (Object.keys(updateFields).length === 0) {
|
||||
return { changes: 0 };
|
||||
}
|
||||
|
||||
return await prisma.installedScript.update({
|
||||
where: { id },
|
||||
data: updateFields
|
||||
});
|
||||
}
|
||||
|
||||
async deleteInstalledScript(id) {
|
||||
return await prisma.installedScript.delete({
|
||||
where: { id }
|
||||
});
|
||||
}
|
||||
|
||||
async deleteInstalledScriptsByServer(server_id) {
|
||||
return await prisma.installedScript.deleteMany({
|
||||
where: { server_id }
|
||||
});
|
||||
}
|
||||
|
||||
async getNextServerId() {
|
||||
const result = await prisma.server.findFirst({
|
||||
orderBy: { id: 'desc' },
|
||||
select: { id: true }
|
||||
});
|
||||
return (result?.id ?? 0) + 1;
|
||||
}
|
||||
|
||||
createSSHKeyFile(serverId, sshKey) {
|
||||
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
|
||||
|
||||
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
|
||||
const normalizedKey = sshKey.trimEnd() + '\n';
|
||||
writeFileSync(keyPath, normalizedKey);
|
||||
chmodSync(keyPath, 0o600); // Set proper permissions
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let dbInstance = null;
|
||||
|
||||
export function getDatabase() {
|
||||
dbInstance ??= new DatabaseServicePrisma();
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
export default DatabaseServicePrisma;
|
||||
@@ -1,312 +0,0 @@
|
||||
import { prisma } from './db';
|
||||
import { join } from 'path';
|
||||
import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs';
|
||||
import { existsSync } from 'fs';
|
||||
import type { CreateServerData } from '../types/server';
|
||||
|
||||
class DatabaseServicePrisma {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Ensure data/ssh-keys directory exists
|
||||
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||
if (!existsSync(sshKeysDir)) {
|
||||
mkdirSync(sshKeysDir, { mode: 0o700 });
|
||||
}
|
||||
}
|
||||
|
||||
// Server CRUD operations
|
||||
async createServer(serverData: CreateServerData) {
|
||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
||||
|
||||
let ssh_key_path = null;
|
||||
|
||||
// If using SSH key authentication, create persistent key file
|
||||
if (auth_type === 'key' && ssh_key) {
|
||||
const serverId = await this.getNextServerId();
|
||||
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
|
||||
}
|
||||
|
||||
return await prisma.server.create({
|
||||
data: {
|
||||
name,
|
||||
ip,
|
||||
user,
|
||||
password,
|
||||
auth_type: auth_type ?? 'password',
|
||||
ssh_key,
|
||||
ssh_key_passphrase,
|
||||
ssh_port: ssh_port ?? 22,
|
||||
ssh_key_path,
|
||||
key_generated: Boolean(key_generated),
|
||||
color,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getAllServers() {
|
||||
return await prisma.server.findMany({
|
||||
orderBy: { created_at: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
async getServerById(id: number) {
|
||||
return await prisma.server.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
}
|
||||
|
||||
async updateServer(id: number, serverData: CreateServerData) {
|
||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
||||
|
||||
// Get existing server to check for key changes
|
||||
const existingServer = await this.getServerById(id);
|
||||
let ssh_key_path = existingServer?.ssh_key_path;
|
||||
|
||||
// Handle SSH key changes
|
||||
if (auth_type === 'key' && ssh_key) {
|
||||
// Delete old key file if it exists
|
||||
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
|
||||
try {
|
||||
unlinkSync(existingServer.ssh_key_path);
|
||||
// Also delete public key file if it exists
|
||||
const pubKeyPath = existingServer.ssh_key_path + '.pub';
|
||||
if (existsSync(pubKeyPath)) {
|
||||
unlinkSync(pubKeyPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete old SSH key file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new key file
|
||||
ssh_key_path = this.createSSHKeyFile(id, ssh_key);
|
||||
} else if (auth_type !== 'key') {
|
||||
// If switching away from key auth, delete key files
|
||||
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
|
||||
try {
|
||||
unlinkSync(existingServer.ssh_key_path);
|
||||
const pubKeyPath = existingServer.ssh_key_path + '.pub';
|
||||
if (existsSync(pubKeyPath)) {
|
||||
unlinkSync(pubKeyPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete SSH key file:', error);
|
||||
}
|
||||
}
|
||||
ssh_key_path = null;
|
||||
}
|
||||
|
||||
return await prisma.server.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
ip,
|
||||
user,
|
||||
password,
|
||||
auth_type: auth_type ?? 'password',
|
||||
ssh_key,
|
||||
ssh_key_passphrase,
|
||||
ssh_port: ssh_port ?? 22,
|
||||
ssh_key_path,
|
||||
key_generated: key_generated !== undefined ? Boolean(key_generated) : (existingServer?.key_generated ?? false),
|
||||
color,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteServer(id: number) {
|
||||
// Get server info before deletion to clean up key files
|
||||
const server = await this.getServerById(id);
|
||||
|
||||
// Delete SSH key files if they exist
|
||||
if (server?.ssh_key_path && existsSync(server.ssh_key_path)) {
|
||||
try {
|
||||
unlinkSync(server.ssh_key_path);
|
||||
const pubKeyPath = server.ssh_key_path + '.pub';
|
||||
if (existsSync(pubKeyPath)) {
|
||||
unlinkSync(pubKeyPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete SSH key file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.server.delete({
|
||||
where: { id }
|
||||
});
|
||||
}
|
||||
|
||||
// Installed Scripts CRUD operations
|
||||
async createInstalledScript(scriptData: {
|
||||
script_name: string;
|
||||
script_path: string;
|
||||
container_id?: string;
|
||||
server_id?: number;
|
||||
execution_mode: string;
|
||||
status: 'in_progress' | 'success' | 'failed';
|
||||
output_log?: string;
|
||||
web_ui_ip?: string;
|
||||
web_ui_port?: number;
|
||||
}) {
|
||||
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port } = scriptData;
|
||||
|
||||
return await prisma.installedScript.create({
|
||||
data: {
|
||||
script_name,
|
||||
script_path,
|
||||
container_id: container_id ?? null,
|
||||
server_id: server_id ?? null,
|
||||
execution_mode,
|
||||
status,
|
||||
output_log: output_log ?? null,
|
||||
web_ui_ip: web_ui_ip ?? null,
|
||||
web_ui_port: web_ui_port ?? null,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getAllInstalledScripts() {
|
||||
return await prisma.installedScript.findMany({
|
||||
include: {
|
||||
server: true
|
||||
},
|
||||
orderBy: { installation_date: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
async getInstalledScriptById(id: number) {
|
||||
return await prisma.installedScript.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
server: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getInstalledScriptsByServer(server_id: number) {
|
||||
return await prisma.installedScript.findMany({
|
||||
where: { server_id },
|
||||
include: {
|
||||
server: true
|
||||
},
|
||||
orderBy: { installation_date: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
async updateInstalledScript(id: number, updateData: {
|
||||
script_name?: string;
|
||||
container_id?: string;
|
||||
status?: 'in_progress' | 'success' | 'failed';
|
||||
output_log?: string;
|
||||
web_ui_ip?: string;
|
||||
web_ui_port?: number;
|
||||
}) {
|
||||
const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData;
|
||||
|
||||
const updateFields: {
|
||||
script_name?: string;
|
||||
container_id?: string;
|
||||
status?: 'in_progress' | 'success' | 'failed';
|
||||
output_log?: string;
|
||||
web_ui_ip?: string;
|
||||
web_ui_port?: number;
|
||||
} = {};
|
||||
if (script_name !== undefined) updateFields.script_name = script_name;
|
||||
if (container_id !== undefined) updateFields.container_id = container_id;
|
||||
if (status !== undefined) updateFields.status = status;
|
||||
if (output_log !== undefined) updateFields.output_log = output_log;
|
||||
if (web_ui_ip !== undefined) updateFields.web_ui_ip = web_ui_ip;
|
||||
if (web_ui_port !== undefined) updateFields.web_ui_port = web_ui_port;
|
||||
|
||||
if (Object.keys(updateFields).length === 0) {
|
||||
return { changes: 0 };
|
||||
}
|
||||
|
||||
return await prisma.installedScript.update({
|
||||
where: { id },
|
||||
data: updateFields
|
||||
});
|
||||
}
|
||||
|
||||
async deleteInstalledScript(id: number) {
|
||||
return await prisma.installedScript.delete({
|
||||
where: { id }
|
||||
});
|
||||
}
|
||||
|
||||
async deleteInstalledScriptsByServer(server_id: number) {
|
||||
return await prisma.installedScript.deleteMany({
|
||||
where: { server_id }
|
||||
});
|
||||
}
|
||||
|
||||
async getNextServerId() {
|
||||
const result = await prisma.server.findFirst({
|
||||
orderBy: { id: 'desc' },
|
||||
select: { id: true }
|
||||
});
|
||||
return (result?.id ?? 0) + 1;
|
||||
}
|
||||
|
||||
createSSHKeyFile(serverId: number, sshKey: string) {
|
||||
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
|
||||
|
||||
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
|
||||
const normalizedKey = sshKey.trimEnd() + '\n';
|
||||
writeFileSync(keyPath, normalizedKey);
|
||||
chmodSync(keyPath, 0o600); // Set proper permissions
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let dbInstance: DatabaseServicePrisma | null = null;
|
||||
|
||||
export function getDatabase() {
|
||||
dbInstance ??= new DatabaseServicePrisma();
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
export default DatabaseServicePrisma;
|
||||
476
src/server/database.js
Normal file
476
src/server/database.js
Normal file
@@ -0,0 +1,476 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { join } from 'path';
|
||||
import { writeFileSync, unlinkSync, chmodSync, mkdirSync } from 'fs';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
class DatabaseService {
|
||||
constructor() {
|
||||
const dbPath = join(process.cwd(), 'data', 'settings.db');
|
||||
this.db = new Database(dbPath);
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Ensure data/ssh-keys directory exists
|
||||
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||
if (!existsSync(sshKeysDir)) {
|
||||
mkdirSync(sshKeysDir, { mode: 0o700 });
|
||||
}
|
||||
|
||||
// Create servers table if it doesn't exist
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
ip TEXT NOT NULL,
|
||||
user TEXT NOT NULL,
|
||||
password TEXT,
|
||||
auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key')),
|
||||
ssh_key TEXT,
|
||||
ssh_key_passphrase TEXT,
|
||||
ssh_port INTEGER DEFAULT 22,
|
||||
ssh_key_path TEXT,
|
||||
key_generated INTEGER DEFAULT 0,
|
||||
color TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Migration: Add new columns to existing servers table
|
||||
try {
|
||||
this.db.exec(`
|
||||
ALTER TABLE servers ADD COLUMN auth_type TEXT DEFAULT 'password' CHECK(auth_type IN ('password', 'key'))
|
||||
`);
|
||||
} catch (e) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.exec(`
|
||||
ALTER TABLE servers ADD COLUMN ssh_key TEXT
|
||||
`);
|
||||
} catch (e) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.exec(`
|
||||
ALTER TABLE servers ADD COLUMN ssh_key_passphrase TEXT
|
||||
`);
|
||||
} catch (e) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.exec(`
|
||||
ALTER TABLE servers ADD COLUMN ssh_port INTEGER DEFAULT 22
|
||||
`);
|
||||
} catch (e) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.exec(`
|
||||
ALTER TABLE servers ADD COLUMN color TEXT
|
||||
`);
|
||||
} catch (e) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.exec(`
|
||||
ALTER TABLE servers ADD COLUMN ssh_key_path TEXT
|
||||
`);
|
||||
} catch (e) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.exec(`
|
||||
ALTER TABLE servers ADD COLUMN key_generated INTEGER DEFAULT 0
|
||||
`);
|
||||
} catch (e) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
// Update existing servers to have auth_type='password' if not set
|
||||
this.db.exec(`
|
||||
UPDATE servers SET auth_type = 'password' WHERE auth_type IS NULL
|
||||
`);
|
||||
|
||||
// Update existing servers to have ssh_port=22 if not set
|
||||
this.db.exec(`
|
||||
UPDATE servers SET ssh_port = 22 WHERE ssh_port IS NULL
|
||||
`);
|
||||
|
||||
// Migration: Convert 'both' auth_type to 'key'
|
||||
this.db.exec(`
|
||||
UPDATE servers SET auth_type = 'key' WHERE auth_type = 'both'
|
||||
`);
|
||||
|
||||
// Update existing servers to have key_generated=0 if not set
|
||||
this.db.exec(`
|
||||
UPDATE servers SET key_generated = 0 WHERE key_generated IS NULL
|
||||
`);
|
||||
|
||||
// Migration: Add web_ui_ip column to existing installed_scripts table
|
||||
try {
|
||||
this.db.exec(`
|
||||
ALTER TABLE installed_scripts ADD COLUMN web_ui_ip TEXT
|
||||
`);
|
||||
} catch (e) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
// Migration: Add web_ui_port column to existing installed_scripts table
|
||||
try {
|
||||
this.db.exec(`
|
||||
ALTER TABLE installed_scripts ADD COLUMN web_ui_port INTEGER
|
||||
`);
|
||||
} catch (e) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
// Create installed_scripts table if it doesn't exist
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS installed_scripts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
script_name TEXT NOT NULL,
|
||||
script_path TEXT NOT NULL,
|
||||
container_id TEXT,
|
||||
server_id INTEGER,
|
||||
execution_mode TEXT NOT NULL CHECK(execution_mode IN ('local', 'ssh')),
|
||||
installation_date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
status TEXT NOT NULL CHECK(status IN ('in_progress', 'success', 'failed')),
|
||||
output_log TEXT,
|
||||
web_ui_ip TEXT,
|
||||
web_ui_port INTEGER,
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Create trigger to update updated_at on row update
|
||||
this.db.exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS update_servers_timestamp
|
||||
AFTER UPDATE ON servers
|
||||
BEGIN
|
||||
UPDATE servers SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END
|
||||
`);
|
||||
}
|
||||
|
||||
// Server CRUD operations
|
||||
/**
|
||||
* @param {import('../types/server').CreateServerData} serverData
|
||||
*/
|
||||
createServer(serverData) {
|
||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
||||
|
||||
let ssh_key_path = null;
|
||||
|
||||
// If using SSH key authentication, create persistent key file
|
||||
if (auth_type === 'key' && ssh_key) {
|
||||
const serverId = this.getNextServerId();
|
||||
ssh_key_path = this.createSSHKeyFile(serverId, ssh_key);
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO servers (name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, ssh_key_path, key_generated, color)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, ssh_key_path, key_generated || 0, color);
|
||||
}
|
||||
|
||||
getAllServers() {
|
||||
const stmt = this.db.prepare('SELECT * FROM servers ORDER BY created_at DESC');
|
||||
return stmt.all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
*/
|
||||
getServerById(id) {
|
||||
const stmt = this.db.prepare('SELECT * FROM servers WHERE id = ?');
|
||||
return stmt.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {import('../types/server').CreateServerData} serverData
|
||||
*/
|
||||
updateServer(id, serverData) {
|
||||
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated } = serverData;
|
||||
|
||||
// Get existing server to check for key changes
|
||||
const existingServer = this.getServerById(id);
|
||||
// @ts-ignore - Database migration adds this column
|
||||
let ssh_key_path = existingServer?.ssh_key_path;
|
||||
|
||||
// Handle SSH key changes
|
||||
if (auth_type === 'key' && ssh_key) {
|
||||
// Delete old key file if it exists
|
||||
// @ts-ignore - Database migration adds this column
|
||||
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
|
||||
try {
|
||||
// @ts-ignore - Database migration adds this column
|
||||
unlinkSync(existingServer.ssh_key_path);
|
||||
// Also delete public key file if it exists
|
||||
// @ts-ignore - Database migration adds this column
|
||||
const pubKeyPath = existingServer.ssh_key_path + '.pub';
|
||||
if (existsSync(pubKeyPath)) {
|
||||
unlinkSync(pubKeyPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete old SSH key file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new key file
|
||||
ssh_key_path = this.createSSHKeyFile(id, ssh_key);
|
||||
} else if (auth_type !== 'key') {
|
||||
// If switching away from key auth, delete key files
|
||||
// @ts-ignore - Database migration adds this column
|
||||
if (existingServer?.ssh_key_path && existsSync(existingServer.ssh_key_path)) {
|
||||
try {
|
||||
// @ts-ignore - Database migration adds this column
|
||||
unlinkSync(existingServer.ssh_key_path);
|
||||
// @ts-ignore - Database migration adds this column
|
||||
const pubKeyPath = existingServer.ssh_key_path + '.pub';
|
||||
if (existsSync(pubKeyPath)) {
|
||||
unlinkSync(pubKeyPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete SSH key file:', error);
|
||||
}
|
||||
}
|
||||
ssh_key_path = null;
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE servers
|
||||
SET name = ?, ip = ?, user = ?, password = ?, auth_type = ?, ssh_key = ?, ssh_key_passphrase = ?, ssh_port = ?, ssh_key_path = ?, key_generated = ?, color = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
// @ts-ignore - Database migration adds this column
|
||||
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, ssh_key_path, key_generated !== undefined ? key_generated : (existingServer?.key_generated || 0), color, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
*/
|
||||
deleteServer(id) {
|
||||
// Get server info before deletion to clean up key files
|
||||
const server = this.getServerById(id);
|
||||
|
||||
// Delete SSH key files if they exist
|
||||
// @ts-ignore - Database migration adds this column
|
||||
if (server?.ssh_key_path && existsSync(server.ssh_key_path)) {
|
||||
try {
|
||||
// @ts-ignore - Database migration adds this column
|
||||
unlinkSync(server.ssh_key_path);
|
||||
// @ts-ignore - Database migration adds this column
|
||||
const pubKeyPath = server.ssh_key_path + '.pub';
|
||||
if (existsSync(pubKeyPath)) {
|
||||
unlinkSync(pubKeyPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete SSH key file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare('DELETE FROM servers WHERE id = ?');
|
||||
return stmt.run(id);
|
||||
}
|
||||
|
||||
// Installed Scripts CRUD operations
|
||||
/**
|
||||
* @param {Object} scriptData
|
||||
* @param {string} scriptData.script_name
|
||||
* @param {string} scriptData.script_path
|
||||
* @param {string} [scriptData.container_id]
|
||||
* @param {number} [scriptData.server_id]
|
||||
* @param {string} scriptData.execution_mode
|
||||
* @param {string} scriptData.status
|
||||
* @param {string} [scriptData.output_log]
|
||||
* @param {string} [scriptData.web_ui_ip]
|
||||
* @param {number} [scriptData.web_ui_port]
|
||||
*/
|
||||
createInstalledScript(scriptData) {
|
||||
const { script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port } = scriptData;
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO installed_scripts (script_name, script_path, container_id, server_id, execution_mode, status, output_log, web_ui_ip, web_ui_port)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
return stmt.run(script_name, script_path, container_id || null, server_id || null, execution_mode, status, output_log || null, web_ui_ip || null, web_ui_port || null);
|
||||
}
|
||||
|
||||
getAllInstalledScripts() {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT
|
||||
inst.*,
|
||||
s.name as server_name,
|
||||
s.ip as server_ip,
|
||||
s.user as server_user,
|
||||
s.password as server_password,
|
||||
s.auth_type as server_auth_type,
|
||||
s.ssh_key as server_ssh_key,
|
||||
s.ssh_key_passphrase as server_ssh_key_passphrase,
|
||||
s.ssh_port as server_ssh_port,
|
||||
s.color as server_color
|
||||
FROM installed_scripts inst
|
||||
LEFT JOIN servers s ON inst.server_id = s.id
|
||||
ORDER BY inst.installation_date DESC
|
||||
`);
|
||||
return stmt.all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
*/
|
||||
getInstalledScriptById(id) {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT
|
||||
inst.*,
|
||||
s.name as server_name,
|
||||
s.ip as server_ip
|
||||
FROM installed_scripts inst
|
||||
LEFT JOIN servers s ON inst.server_id = s.id
|
||||
WHERE inst.id = ?
|
||||
`);
|
||||
return stmt.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} server_id
|
||||
*/
|
||||
getInstalledScriptsByServer(server_id) {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT
|
||||
inst.*,
|
||||
s.name as server_name,
|
||||
s.ip as server_ip
|
||||
FROM installed_scripts inst
|
||||
LEFT JOIN servers s ON inst.server_id = s.id
|
||||
WHERE inst.server_id = ?
|
||||
ORDER BY inst.installation_date DESC
|
||||
`);
|
||||
return stmt.all(server_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {Object} updateData
|
||||
* @param {string} [updateData.script_name]
|
||||
* @param {string} [updateData.container_id]
|
||||
* @param {string} [updateData.status]
|
||||
* @param {string} [updateData.output_log]
|
||||
* @param {string} [updateData.web_ui_ip]
|
||||
* @param {number} [updateData.web_ui_port]
|
||||
*/
|
||||
updateInstalledScript(id, updateData) {
|
||||
const { script_name, container_id, status, output_log, web_ui_ip, web_ui_port } = updateData;
|
||||
const updates = [];
|
||||
const values = [];
|
||||
|
||||
if (script_name !== undefined) {
|
||||
updates.push('script_name = ?');
|
||||
values.push(script_name);
|
||||
}
|
||||
if (container_id !== undefined) {
|
||||
updates.push('container_id = ?');
|
||||
values.push(container_id);
|
||||
}
|
||||
if (status !== undefined) {
|
||||
updates.push('status = ?');
|
||||
values.push(status);
|
||||
}
|
||||
if (output_log !== undefined) {
|
||||
updates.push('output_log = ?');
|
||||
values.push(output_log);
|
||||
}
|
||||
if (web_ui_ip !== undefined) {
|
||||
updates.push('web_ui_ip = ?');
|
||||
values.push(web_ui_ip);
|
||||
}
|
||||
if (web_ui_port !== undefined) {
|
||||
updates.push('web_ui_port = ?');
|
||||
values.push(web_ui_port);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return { changes: 0 };
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE installed_scripts
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = ?
|
||||
`);
|
||||
return stmt.run(...values);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
*/
|
||||
deleteInstalledScript(id) {
|
||||
const stmt = this.db.prepare('DELETE FROM installed_scripts WHERE id = ?');
|
||||
return stmt.run(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} server_id
|
||||
*/
|
||||
deleteInstalledScriptsByServer(server_id) {
|
||||
const stmt = this.db.prepare('DELETE FROM installed_scripts WHERE server_id = ?');
|
||||
return stmt.run(server_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next available server ID for key file naming
|
||||
* @returns {number}
|
||||
*/
|
||||
getNextServerId() {
|
||||
const stmt = this.db.prepare('SELECT MAX(id) as maxId FROM servers');
|
||||
const result = stmt.get();
|
||||
// @ts-ignore - SQL query result type
|
||||
return (result?.maxId || 0) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SSH key file and return the path
|
||||
* @param {number} serverId
|
||||
* @param {string} sshKey
|
||||
* @returns {string}
|
||||
*/
|
||||
createSSHKeyFile(serverId, sshKey) {
|
||||
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
|
||||
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
|
||||
|
||||
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
|
||||
const normalizedKey = sshKey.trimEnd() + '\n';
|
||||
writeFileSync(keyPath, normalizedKey);
|
||||
chmodSync(keyPath, 0o600); // Set proper permissions
|
||||
|
||||
return keyPath;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
/** @type {DatabaseService | null} */
|
||||
let dbInstance = null;
|
||||
|
||||
export function getDatabase() {
|
||||
if (!dbInstance) {
|
||||
dbInstance = new DatabaseService();
|
||||
}
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
export default DatabaseService;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = globalThis;
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||
@@ -1,9 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||
@@ -8,11 +8,11 @@ export interface Server {
|
||||
ssh_key?: string;
|
||||
ssh_key_passphrase?: string;
|
||||
ssh_key_path?: string;
|
||||
key_generated?: boolean;
|
||||
key_generated?: number;
|
||||
ssh_port?: number;
|
||||
color?: string;
|
||||
created_at: Date | null;
|
||||
updated_at: Date | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateServerData {
|
||||
@@ -24,7 +24,7 @@ export interface CreateServerData {
|
||||
ssh_key?: string;
|
||||
ssh_key_passphrase?: string;
|
||||
ssh_key_path?: string;
|
||||
key_generated?: boolean;
|
||||
key_generated?: number;
|
||||
ssh_port?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
59
update.sh
59
update.sh
@@ -412,36 +412,6 @@ restore_backup_files() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Ensure DATABASE_URL is set in .env file for Prisma
|
||||
ensure_database_url() {
|
||||
log "Ensuring DATABASE_URL is set in .env file..."
|
||||
|
||||
# Check if .env file exists
|
||||
if [ ! -f ".env" ]; then
|
||||
log_warning ".env file not found, creating from .env.example..."
|
||||
if [ -f ".env.example" ]; then
|
||||
cp ".env.example" ".env"
|
||||
else
|
||||
log_error ".env.example not found, cannot create .env file"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if DATABASE_URL is already set
|
||||
if grep -q "^DATABASE_URL=" .env; then
|
||||
log "DATABASE_URL already exists in .env file"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Add DATABASE_URL to .env file
|
||||
log "Adding DATABASE_URL to .env file..."
|
||||
echo "" >> .env
|
||||
echo "# Database" >> .env
|
||||
echo "DATABASE_URL=\"file:./data/database.sqlite\"" >> .env
|
||||
|
||||
log_success "DATABASE_URL added to .env file"
|
||||
}
|
||||
|
||||
# Check if systemd service exists
|
||||
check_service() {
|
||||
# systemctl status returns 0-3 if service exists (running, exited, failed, etc.)
|
||||
@@ -637,32 +607,6 @@ install_and_build() {
|
||||
log_success "Dependencies installed successfully"
|
||||
rm -f "$npm_log"
|
||||
|
||||
# Generate Prisma client
|
||||
log "Generating Prisma client..."
|
||||
if ! npx prisma generate > "$npm_log" 2>&1; then
|
||||
log_error "Failed to generate Prisma client"
|
||||
log_error "Prisma generate output:"
|
||||
cat "$npm_log" | while read -r line; do
|
||||
log_error "PRISMA: $line"
|
||||
done
|
||||
rm -f "$npm_log"
|
||||
return 1
|
||||
fi
|
||||
log_success "Prisma client generated successfully"
|
||||
|
||||
# Run Prisma migrations
|
||||
log "Running Prisma migrations..."
|
||||
if ! npx prisma migrate deploy > "$npm_log" 2>&1; then
|
||||
log_warning "Prisma migrations failed or no migrations to run"
|
||||
log "Prisma migrate output:"
|
||||
cat "$npm_log" | while read -r line; do
|
||||
log "PRISMA: $line"
|
||||
done
|
||||
else
|
||||
log_success "Prisma migrations completed successfully"
|
||||
fi
|
||||
rm -f "$npm_log"
|
||||
|
||||
log "Building application..."
|
||||
# Set NODE_ENV to production for build
|
||||
export NODE_ENV=production
|
||||
@@ -894,9 +838,6 @@ main() {
|
||||
# Restore .env and data directory before building
|
||||
restore_backup_files
|
||||
|
||||
# Ensure DATABASE_URL is set for Prisma
|
||||
ensure_database_url
|
||||
|
||||
# Install dependencies and build
|
||||
if ! install_and_build; then
|
||||
log_error "Install and build failed, rolling back..."
|
||||
|
||||
Reference in New Issue
Block a user