Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c855d1c864 | ||
|
|
4af5ad4f7b | ||
|
|
537d65275a | ||
|
|
ef460b5a00 | ||
|
|
87ab645231 | ||
|
|
9c44a47b3d | ||
|
|
b793c57000 | ||
|
|
6b45c41334 | ||
|
|
a8eb41e087 | ||
|
|
52adbd9f5c | ||
|
|
73d3aeec99 | ||
|
|
1635bb17da | ||
|
|
b4b8da5725 |
@@ -25,4 +25,5 @@ AUTH_USERNAME=
|
|||||||
AUTH_PASSWORD_HASH=
|
AUTH_PASSWORD_HASH=
|
||||||
AUTH_ENABLED=false
|
AUTH_ENABLED=false
|
||||||
AUTH_SETUP_COMPLETED=false
|
AUTH_SETUP_COMPLETED=false
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
|
DATABASE_URL="file:./data/database.sqlite"
|
||||||
|
|||||||
3
.github/release-drafter.yml
vendored
3
.github/release-drafter.yml
vendored
@@ -7,6 +7,9 @@ exclude-labels:
|
|||||||
- automated
|
- automated
|
||||||
|
|
||||||
categories:
|
categories:
|
||||||
|
- title: "Breaking Changes"
|
||||||
|
labels:
|
||||||
|
- breaking
|
||||||
- title: "🚀 Features"
|
- title: "🚀 Features"
|
||||||
labels:
|
labels:
|
||||||
- feature
|
- feature
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -49,4 +49,5 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# idea files
|
# idea files
|
||||||
.idea
|
.idea
|
||||||
|
/generated/prisma
|
||||||
|
|||||||
820
package-lock.json
generated
820
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,11 +22,12 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.17.1",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@t3-oss/env-nextjs": "^0.13.8",
|
"@t3-oss/env-nextjs": "^0.13.8",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/react-query": "^5.90.3",
|
"@tanstack/react-query": "^5.90.5",
|
||||||
"@trpc/client": "^11.6.0",
|
"@trpc/client": "^11.6.0",
|
||||||
"@trpc/react-query": "^11.6.0",
|
"@trpc/react-query": "^11.6.0",
|
||||||
"@trpc/server": "^11.6.0",
|
"@trpc/server": "^11.6.0",
|
||||||
@@ -36,11 +37,10 @@
|
|||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"better-sqlite3": "^12.4.1",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.546.0",
|
||||||
"next": "^15.5.5",
|
"next": "^15.5.5",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "^1.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/better-sqlite3": "^7.6.8",
|
"@types/better-sqlite3": "^7.6.8",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.7.2",
|
"@types/node": "^24.8.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/react-dom": "^19.2.2",
|
||||||
"@vitejs/plugin-react": "^5.0.2",
|
"@vitejs/plugin-react": "^5.0.2",
|
||||||
@@ -77,6 +77,7 @@
|
|||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.0",
|
"prettier-plugin-tailwindcss": "^0.7.0",
|
||||||
|
"prisma": "^6.17.1",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"typescript-eslint": "^8.46.1",
|
"typescript-eslint": "^8.46.1",
|
||||||
|
|||||||
74
prisma/migrations/20251017092130_init/migration.sql
Normal file
74
prisma/migrations/20251017092130_init/migration.sql
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "installed_scripts" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"script_name" TEXT NOT NULL,
|
||||||
|
"script_path" TEXT NOT NULL,
|
||||||
|
"container_id" TEXT,
|
||||||
|
"server_id" INTEGER,
|
||||||
|
"execution_mode" TEXT NOT NULL,
|
||||||
|
"installation_date" DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"status" TEXT NOT NULL,
|
||||||
|
"output_log" TEXT,
|
||||||
|
"web_ui_ip" TEXT,
|
||||||
|
"web_ui_port" INTEGER,
|
||||||
|
CONSTRAINT "installed_scripts_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "servers" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "servers" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"ip" TEXT NOT NULL,
|
||||||
|
"user" TEXT NOT NULL,
|
||||||
|
"password" TEXT,
|
||||||
|
"auth_type" TEXT DEFAULT 'password',
|
||||||
|
"ssh_key" TEXT,
|
||||||
|
"ssh_key_passphrase" TEXT,
|
||||||
|
"ssh_port" INTEGER DEFAULT 22,
|
||||||
|
"color" TEXT,
|
||||||
|
"created_at" DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" DATETIME,
|
||||||
|
"ssh_key_path" TEXT,
|
||||||
|
"key_generated" BOOLEAN DEFAULT false
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "lxc_configs" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"installed_script_id" INTEGER NOT NULL,
|
||||||
|
"arch" TEXT,
|
||||||
|
"cores" INTEGER,
|
||||||
|
"memory" INTEGER,
|
||||||
|
"hostname" TEXT,
|
||||||
|
"swap" INTEGER,
|
||||||
|
"onboot" INTEGER,
|
||||||
|
"ostype" TEXT,
|
||||||
|
"unprivileged" INTEGER,
|
||||||
|
"net_name" TEXT,
|
||||||
|
"net_bridge" TEXT,
|
||||||
|
"net_hwaddr" TEXT,
|
||||||
|
"net_ip_type" TEXT,
|
||||||
|
"net_ip" TEXT,
|
||||||
|
"net_gateway" TEXT,
|
||||||
|
"net_type" TEXT,
|
||||||
|
"net_vlan" INTEGER,
|
||||||
|
"rootfs_storage" TEXT,
|
||||||
|
"rootfs_size" TEXT,
|
||||||
|
"feature_keyctl" INTEGER,
|
||||||
|
"feature_nesting" INTEGER,
|
||||||
|
"feature_fuse" INTEGER,
|
||||||
|
"feature_mount" TEXT,
|
||||||
|
"tags" TEXT,
|
||||||
|
"advanced_config" TEXT,
|
||||||
|
"synced_at" DATETIME,
|
||||||
|
"config_hash" TEXT,
|
||||||
|
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "lxc_configs_installed_script_id_fkey" FOREIGN KEY ("installed_script_id") REFERENCES "installed_scripts" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "servers_name_key" ON "servers"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "lxc_configs_installed_script_id_key" ON "lxc_configs"("installed_script_id");
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "sqlite"
|
||||||
97
prisma/schema.prisma
Normal file
97
prisma/schema.prisma
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
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 stripAnsi from 'strip-ansi';
|
||||||
import { spawn as ptySpawn } from 'node-pty';
|
import { spawn as ptySpawn } from 'node-pty';
|
||||||
import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
|
import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
|
||||||
import { getDatabase } from './src/server/database.js';
|
import { getDatabase } from './src/server/database-prisma.js';
|
||||||
|
|
||||||
const dev = process.env.NODE_ENV !== 'production';
|
const dev = process.env.NODE_ENV !== 'production';
|
||||||
const hostname = '0.0.0.0';
|
const hostname = '0.0.0.0';
|
||||||
@@ -186,11 +186,11 @@ class ScriptExecutionHandler {
|
|||||||
* @param {string} scriptPath - Path to the script
|
* @param {string} scriptPath - Path to the script
|
||||||
* @param {string} executionMode - 'local' or 'ssh'
|
* @param {string} executionMode - 'local' or 'ssh'
|
||||||
* @param {number|null} serverId - Server ID for SSH executions
|
* @param {number|null} serverId - Server ID for SSH executions
|
||||||
* @returns {number|null} - Installation record ID
|
* @returns {Promise<number|null>} - Installation record ID
|
||||||
*/
|
*/
|
||||||
createInstallationRecord(scriptName, scriptPath, executionMode, serverId = null) {
|
async createInstallationRecord(scriptName, scriptPath, executionMode, serverId = null) {
|
||||||
try {
|
try {
|
||||||
const result = this.db.createInstalledScript({
|
const result = await this.db.createInstalledScript({
|
||||||
script_name: scriptName,
|
script_name: scriptName,
|
||||||
script_path: scriptPath,
|
script_path: scriptPath,
|
||||||
container_id: undefined,
|
container_id: undefined,
|
||||||
@@ -199,7 +199,7 @@ class ScriptExecutionHandler {
|
|||||||
status: 'in_progress',
|
status: 'in_progress',
|
||||||
output_log: ''
|
output_log: ''
|
||||||
});
|
});
|
||||||
return Number(result.lastInsertRowid);
|
return Number(result.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating installation record:', error);
|
console.error('Error creating installation record:', error);
|
||||||
return null;
|
return null;
|
||||||
@@ -211,9 +211,9 @@ class ScriptExecutionHandler {
|
|||||||
* @param {number} installationId - Installation record ID
|
* @param {number} installationId - Installation record ID
|
||||||
* @param {Object} updateData - Data to update
|
* @param {Object} updateData - Data to update
|
||||||
*/
|
*/
|
||||||
updateInstallationRecord(installationId, updateData) {
|
async updateInstallationRecord(installationId, updateData) {
|
||||||
try {
|
try {
|
||||||
this.db.updateInstalledScript(installationId, updateData);
|
await this.db.updateInstalledScript(installationId, updateData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating installation record:', error);
|
console.error('Error updating installation record:', error);
|
||||||
}
|
}
|
||||||
@@ -327,7 +327,7 @@ class ScriptExecutionHandler {
|
|||||||
|
|
||||||
// Create installation record
|
// Create installation record
|
||||||
const serverId = server ? (server.id ?? null) : null;
|
const serverId = server ? (server.id ?? null) : null;
|
||||||
installationId = this.createInstallationRecord(scriptName, scriptPath, mode, serverId);
|
installationId = await this.createInstallationRecord(scriptName, scriptPath, mode, serverId);
|
||||||
|
|
||||||
if (!installationId) {
|
if (!installationId) {
|
||||||
console.error('Failed to create installation record');
|
console.error('Failed to create installation record');
|
||||||
@@ -356,7 +356,7 @@ class ScriptExecutionHandler {
|
|||||||
|
|
||||||
// Update installation record with failure
|
// Update installation record with failure
|
||||||
if (installationId) {
|
if (installationId) {
|
||||||
this.updateInstallationRecord(installationId, { status: 'failed' });
|
await this.updateInstallationRecord(installationId, { status: 'failed' });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -394,7 +394,7 @@ class ScriptExecutionHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle pty data (both stdout and stderr combined)
|
// Handle pty data (both stdout and stderr combined)
|
||||||
childProcess.onData((data) => {
|
childProcess.onData(async (data) => {
|
||||||
const output = data.toString();
|
const output = data.toString();
|
||||||
|
|
||||||
// Store output in buffer for logging
|
// Store output in buffer for logging
|
||||||
@@ -410,7 +410,7 @@ class ScriptExecutionHandler {
|
|||||||
// Parse for Container ID
|
// Parse for Container ID
|
||||||
const containerId = this.parseContainerId(output);
|
const containerId = this.parseContainerId(output);
|
||||||
if (containerId && installationId) {
|
if (containerId && installationId) {
|
||||||
this.updateInstallationRecord(installationId, { container_id: containerId });
|
await this.updateInstallationRecord(installationId, { container_id: containerId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse for Web UI URL
|
// Parse for Web UI URL
|
||||||
@@ -418,7 +418,7 @@ class ScriptExecutionHandler {
|
|||||||
if (webUIUrl && installationId) {
|
if (webUIUrl && installationId) {
|
||||||
const { ip, port } = webUIUrl;
|
const { ip, port } = webUIUrl;
|
||||||
if (ip && port) {
|
if (ip && port) {
|
||||||
this.updateInstallationRecord(installationId, {
|
await this.updateInstallationRecord(installationId, {
|
||||||
web_ui_ip: ip,
|
web_ui_ip: ip,
|
||||||
web_ui_port: port
|
web_ui_port: port
|
||||||
});
|
});
|
||||||
@@ -464,7 +464,7 @@ class ScriptExecutionHandler {
|
|||||||
|
|
||||||
// Update installation record with failure
|
// Update installation record with failure
|
||||||
if (installationId) {
|
if (installationId) {
|
||||||
this.updateInstallationRecord(installationId, { status: 'failed' });
|
await this.updateInstallationRecord(installationId, { status: 'failed' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -491,7 +491,7 @@ class ScriptExecutionHandler {
|
|||||||
const execution = /** @type {ExecutionResult} */ (await sshService.executeScript(
|
const execution = /** @type {ExecutionResult} */ (await sshService.executeScript(
|
||||||
server,
|
server,
|
||||||
scriptPath,
|
scriptPath,
|
||||||
/** @param {string} data */ (data) => {
|
/** @param {string} data */ async (data) => {
|
||||||
// Store output in buffer for logging
|
// Store output in buffer for logging
|
||||||
const exec = this.activeExecutions.get(executionId);
|
const exec = this.activeExecutions.get(executionId);
|
||||||
if (exec) {
|
if (exec) {
|
||||||
@@ -505,7 +505,7 @@ class ScriptExecutionHandler {
|
|||||||
// Parse for Container ID
|
// Parse for Container ID
|
||||||
const containerId = this.parseContainerId(data);
|
const containerId = this.parseContainerId(data);
|
||||||
if (containerId && installationId) {
|
if (containerId && installationId) {
|
||||||
this.updateInstallationRecord(installationId, { container_id: containerId });
|
await this.updateInstallationRecord(installationId, { container_id: containerId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse for Web UI URL
|
// Parse for Web UI URL
|
||||||
@@ -513,7 +513,7 @@ class ScriptExecutionHandler {
|
|||||||
if (webUIUrl && installationId) {
|
if (webUIUrl && installationId) {
|
||||||
const { ip, port } = webUIUrl;
|
const { ip, port } = webUIUrl;
|
||||||
if (ip && port) {
|
if (ip && port) {
|
||||||
this.updateInstallationRecord(installationId, {
|
await this.updateInstallationRecord(installationId, {
|
||||||
web_ui_ip: ip,
|
web_ui_ip: ip,
|
||||||
web_ui_port: port
|
web_ui_port: port
|
||||||
});
|
});
|
||||||
@@ -545,13 +545,13 @@ class ScriptExecutionHandler {
|
|||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
/** @param {number} code */ (code) => {
|
/** @param {number} code */ async (code) => {
|
||||||
const exec = this.activeExecutions.get(executionId);
|
const exec = this.activeExecutions.get(executionId);
|
||||||
const isSuccess = code === 0;
|
const isSuccess = code === 0;
|
||||||
|
|
||||||
// Update installation record with final status and output
|
// Update installation record with final status and output
|
||||||
if (installationId && exec) {
|
if (installationId && exec) {
|
||||||
this.updateInstallationRecord(installationId, {
|
await this.updateInstallationRecord(installationId, {
|
||||||
status: isSuccess ? 'success' : 'failed',
|
status: isSuccess ? 'success' : 'failed',
|
||||||
output_log: exec.outputBuffer
|
output_log: exec.outputBuffer
|
||||||
});
|
});
|
||||||
@@ -586,7 +586,7 @@ class ScriptExecutionHandler {
|
|||||||
|
|
||||||
// Update installation record with failure
|
// Update installation record with failure
|
||||||
if (installationId) {
|
if (installationId) {
|
||||||
this.updateInstallationRecord(installationId, { status: 'failed' });
|
await this.updateInstallationRecord(installationId, { status: 'failed' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface HelpModalProps {
|
|||||||
initialSection?: string;
|
initialSection?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type HelpSection = 'server-settings' | 'general-settings' | 'sync-button' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'update-system';
|
type HelpSection = 'server-settings' | 'general-settings' | 'sync-button' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system';
|
||||||
|
|
||||||
export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) {
|
export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) {
|
||||||
const [activeSection, setActiveSection] = useState<HelpSection>(initialSection as HelpSection);
|
const [activeSection, setActiveSection] = useState<HelpSection>(initialSection as HelpSection);
|
||||||
@@ -24,6 +24,7 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
|
|||||||
{ id: 'available-scripts' as HelpSection, label: 'Available Scripts', icon: Package },
|
{ id: 'available-scripts' as HelpSection, label: 'Available Scripts', icon: Package },
|
||||||
{ id: 'downloaded-scripts' as HelpSection, label: 'Downloaded Scripts', icon: HardDrive },
|
{ id: 'downloaded-scripts' as HelpSection, label: 'Downloaded Scripts', icon: HardDrive },
|
||||||
{ id: 'installed-scripts' as HelpSection, label: 'Installed Scripts', icon: FolderOpen },
|
{ id: 'installed-scripts' as HelpSection, label: 'Installed Scripts', icon: FolderOpen },
|
||||||
|
{ id: 'lxc-settings' as HelpSection, label: 'LXC Settings', icon: Settings },
|
||||||
{ id: 'update-system' as HelpSection, label: 'Update System', icon: Download },
|
{ id: 'update-system' as HelpSection, label: 'Update System', icon: Download },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -501,6 +502,131 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'lxc-settings':
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold text-foreground mb-4">LXC Settings</h3>
|
||||||
|
<p className="text-muted-foreground mb-6">
|
||||||
|
Edit LXC container configuration files directly from the installed scripts interface. This feature allows you to modify container settings without manually accessing the Proxmox VE server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Overview</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
The LXC Settings modal provides a user-friendly interface to edit container configuration files. It parses common settings into editable fields while preserving advanced configurations.
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• <strong>Common Settings:</strong> Edit basic container parameters like cores, memory, network, and storage</li>
|
||||||
|
<li>• <strong>Advanced Settings:</strong> Raw text editing for lxc.* entries and other advanced configurations</li>
|
||||||
|
<li>• <strong>Database Caching:</strong> Configurations are cached locally for faster access</li>
|
||||||
|
<li>• <strong>Change Detection:</strong> Warns when cached config differs from server version</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Common Settings Tab</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h5 className="font-medium text-sm text-foreground mb-1">Basic Configuration</h5>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• <strong>Architecture:</strong> Container architecture (usually amd64)</li>
|
||||||
|
<li>• <strong>Cores:</strong> Number of CPU cores allocated to the container</li>
|
||||||
|
<li>• <strong>Memory:</strong> RAM allocation in megabytes</li>
|
||||||
|
<li>• <strong>Swap:</strong> Swap space allocation in megabytes</li>
|
||||||
|
<li>• <strong>Hostname:</strong> Container hostname</li>
|
||||||
|
<li>• <strong>OS Type:</strong> Operating system type (e.g., debian, ubuntu)</li>
|
||||||
|
<li>• <strong>Start on Boot:</strong> Whether to start container automatically on host boot</li>
|
||||||
|
<li>• <strong>Unprivileged:</strong> Whether the container runs in unprivileged mode</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h5 className="font-medium text-sm text-foreground mb-1">Network Configuration</h5>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• <strong>IP Configuration:</strong> Choose between DHCP or static IP assignment</li>
|
||||||
|
<li>• <strong>IP Address:</strong> Static IP with CIDR notation (e.g., 10.10.10.164/24)</li>
|
||||||
|
<li>• <strong>Gateway:</strong> Network gateway for static IP configuration</li>
|
||||||
|
<li>• <strong>Bridge:</strong> Network bridge interface (usually vmbr0)</li>
|
||||||
|
<li>• <strong>MAC Address:</strong> Hardware address for the network interface</li>
|
||||||
|
<li>• <strong>VLAN Tag:</strong> Optional VLAN tag for network segmentation</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h5 className="font-medium text-sm text-foreground mb-1">Storage & Features</h5>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• <strong>Root Filesystem:</strong> Storage location and disk identifier</li>
|
||||||
|
<li>• <strong>Size:</strong> Disk size allocation (e.g., 4G, 8G)</li>
|
||||||
|
<li>• <strong>Features:</strong> Container capabilities (keyctl, nesting, fuse)</li>
|
||||||
|
<li>• <strong>Tags:</strong> Comma-separated tags for organization</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Advanced Settings Tab</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
The Advanced Settings tab provides raw text editing for configurations not covered in the Common Settings tab.
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• <strong>lxc.* entries:</strong> Low-level LXC configuration options</li>
|
||||||
|
<li>• <strong>Comments:</strong> Configuration file comments and documentation</li>
|
||||||
|
<li>• <strong>Custom settings:</strong> Any other configuration parameters</li>
|
||||||
|
<li>• <strong>Preservation:</strong> All content is preserved when switching between tabs</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Saving Changes</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
To save configuration changes, you must type the container ID exactly as shown to confirm your changes.
|
||||||
|
</p>
|
||||||
|
<div className="bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3">
|
||||||
|
<h5 className="font-medium text-yellow-800 dark:text-yellow-200 mb-2">⚠️ Important Warnings</h5>
|
||||||
|
<ul className="text-sm text-yellow-700 dark:text-yellow-300 space-y-1">
|
||||||
|
<li>• Modifying LXC configuration can break your container</li>
|
||||||
|
<li>• Some changes may require container restart to take effect</li>
|
||||||
|
<li>• Always backup your configuration before making changes</li>
|
||||||
|
<li>• Test changes in a non-production environment first</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Sync from Server</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
The "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:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ScriptInstallationCard } from './ScriptInstallationCard';
|
|||||||
import { ConfirmationModal } from './ConfirmationModal';
|
import { ConfirmationModal } from './ConfirmationModal';
|
||||||
import { ErrorModal } from './ErrorModal';
|
import { ErrorModal } from './ErrorModal';
|
||||||
import { LoadingModal } from './LoadingModal';
|
import { LoadingModal } from './LoadingModal';
|
||||||
|
import { LXCSettingsModal } from './LXCSettingsModal';
|
||||||
import { getContrastColor } from '../../lib/colorUtils';
|
import { getContrastColor } from '../../lib/colorUtils';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
} from './ui/dropdown-menu';
|
} from './ui/dropdown-menu';
|
||||||
|
import { Settings } from 'lucide-react';
|
||||||
|
|
||||||
interface InstalledScript {
|
interface InstalledScript {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -91,6 +93,12 @@ export function InstalledScriptsTab() {
|
|||||||
action: string;
|
action: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
// LXC Settings modal state
|
||||||
|
const [lxcSettingsModal, setLxcSettingsModal] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
script: InstalledScript | null;
|
||||||
|
}>({ isOpen: false, script: null });
|
||||||
|
|
||||||
// Fetch installed scripts
|
// Fetch installed scripts
|
||||||
const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery();
|
const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery();
|
||||||
const { data: statsData } = api.installedScripts.getInstallationStats.useQuery();
|
const { data: statsData } = api.installedScripts.getInstallationStats.useQuery();
|
||||||
@@ -388,7 +396,7 @@ export function InstalledScriptsTab() {
|
|||||||
containerStatusMutation.mutate({ serverIds });
|
containerStatusMutation.mutate({ serverIds });
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Run cleanup when component mounts and scripts are loaded (only once)
|
// Run cleanup when component mounts and scripts are loaded (only once)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -404,7 +412,7 @@ export function InstalledScriptsTab() {
|
|||||||
console.log('Status check triggered - scripts length:', scripts.length);
|
console.log('Status check triggered - scripts length:', scripts.length);
|
||||||
fetchContainerStatuses();
|
fetchContainerStatuses();
|
||||||
}
|
}
|
||||||
}, [scripts.length, fetchContainerStatuses]);
|
}, [scripts.length]);
|
||||||
|
|
||||||
// Cleanup timeout on unmount
|
// Cleanup timeout on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -704,6 +712,10 @@ export function InstalledScriptsTab() {
|
|||||||
setEditFormData({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' });
|
setEditFormData({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLXCSettings = (script: InstalledScript) => {
|
||||||
|
setLxcSettingsModal({ isOpen: true, script });
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveEdit = () => {
|
const handleSaveEdit = () => {
|
||||||
if (!editFormData.script_name.trim()) {
|
if (!editFormData.script_name.trim()) {
|
||||||
setErrorModal({
|
setErrorModal({
|
||||||
@@ -922,7 +934,7 @@ export function InstalledScriptsTab() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={fetchContainerStatuses}
|
onClick={fetchContainerStatuses}
|
||||||
disabled={containerStatusMutation.isPending || scripts.length === 0}
|
disabled={containerStatusMutation.isPending ?? scripts.length === 0}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
>
|
>
|
||||||
@@ -1127,7 +1139,7 @@ export function InstalledScriptsTab() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleAutoDetect}
|
onClick={handleAutoDetect}
|
||||||
disabled={autoDetectMutation.isPending || !autoDetectServerId}
|
disabled={autoDetectMutation.isPending ?? !autoDetectServerId}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="default"
|
size="default"
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
@@ -1499,7 +1511,7 @@ export function InstalledScriptsTab() {
|
|||||||
{script.container_id && script.execution_mode === 'ssh' && script.web_ui_ip && (
|
{script.container_id && script.execution_mode === 'ssh' && script.web_ui_ip && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleAutoDetectWebUI(script)}
|
onClick={() => handleAutoDetectWebUI(script)}
|
||||||
disabled={autoDetectWebUIMutation.isPending || containerStatuses.get(script.id) === 'stopped'}
|
disabled={autoDetectWebUIMutation.isPending ?? containerStatuses.get(script.id) === 'stopped'}
|
||||||
className="text-blue-300 hover:text-blue-200 hover:bg-blue-900/20 focus:bg-blue-900/20"
|
className="text-blue-300 hover:text-blue-200 hover:bg-blue-900/20 focus:bg-blue-900/20"
|
||||||
>
|
>
|
||||||
{autoDetectWebUIMutation.isPending ? 'Re-detect...' : 'Re-detect IP/Port'}
|
{autoDetectWebUIMutation.isPending ? 'Re-detect...' : 'Re-detect IP/Port'}
|
||||||
@@ -1507,6 +1519,14 @@ export function InstalledScriptsTab() {
|
|||||||
)}
|
)}
|
||||||
{script.container_id && script.execution_mode === 'ssh' && (
|
{script.container_id && script.execution_mode === 'ssh' && (
|
||||||
<>
|
<>
|
||||||
|
<DropdownMenuSeparator className="bg-gray-700" />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleLXCSettings(script)}
|
||||||
|
className="text-purple-300 hover:text-purple-200 hover:bg-purple-900/20 focus:bg-purple-900/20"
|
||||||
|
>
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
LXC Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator className="bg-gray-700" />
|
<DropdownMenuSeparator className="bg-gray-700" />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleStartStop(script, (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'stop' : 'start')}
|
onClick={() => handleStartStop(script, (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'stop' : 'start')}
|
||||||
@@ -1587,6 +1607,17 @@ export function InstalledScriptsTab() {
|
|||||||
action={loadingModal.action}
|
action={loadingModal.action}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* LXC Settings Modal */}
|
||||||
|
<LXCSettingsModal
|
||||||
|
isOpen={lxcSettingsModal.isOpen}
|
||||||
|
script={lxcSettingsModal.script}
|
||||||
|
onClose={() => setLxcSettingsModal({ isOpen: false, script: null })}
|
||||||
|
onSave={() => {
|
||||||
|
setLxcSettingsModal({ isOpen: false, script: null });
|
||||||
|
void refetchScripts();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
625
src/app/_components/LXCSettingsModal.tsx
Normal file
625
src/app/_components/LXCSettingsModal.tsx
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { api } from '~/trpc/react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Badge } from './ui/badge';
|
||||||
|
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||||
|
import { LoadingModal } from './LoadingModal';
|
||||||
|
import { ConfirmationModal } from './ConfirmationModal';
|
||||||
|
import { RefreshCw, AlertTriangle, CheckCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface InstalledScript {
|
||||||
|
id: number;
|
||||||
|
script_name: string;
|
||||||
|
container_id: string | null;
|
||||||
|
server_id: number | null;
|
||||||
|
server_name: string | null;
|
||||||
|
server_ip: string | null;
|
||||||
|
server_user: string | null;
|
||||||
|
server_password: string | null;
|
||||||
|
server_auth_type: string | null;
|
||||||
|
server_ssh_key: string | null;
|
||||||
|
server_ssh_key_passphrase: string | null;
|
||||||
|
server_ssh_port: number | null;
|
||||||
|
server_color: string | null;
|
||||||
|
installation_date: string;
|
||||||
|
status: 'in_progress' | 'success' | 'failed';
|
||||||
|
output_log: string | null;
|
||||||
|
execution_mode: 'local' | 'ssh';
|
||||||
|
container_status?: 'running' | 'stopped' | 'unknown';
|
||||||
|
web_ui_ip: string | null;
|
||||||
|
web_ui_port: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LXCSettingsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
script: InstalledScript | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LXCSettingsModal({ isOpen, script, onClose, onSave }: LXCSettingsModalProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<string>('common');
|
||||||
|
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
const [forceSync] = useState(false);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<any>({
|
||||||
|
arch: '',
|
||||||
|
cores: 0,
|
||||||
|
memory: 0,
|
||||||
|
hostname: '',
|
||||||
|
swap: 0,
|
||||||
|
onboot: false,
|
||||||
|
ostype: '',
|
||||||
|
unprivileged: false,
|
||||||
|
net_name: '',
|
||||||
|
net_bridge: '',
|
||||||
|
net_hwaddr: '',
|
||||||
|
net_ip_type: 'dhcp',
|
||||||
|
net_ip: '',
|
||||||
|
net_gateway: '',
|
||||||
|
net_type: '',
|
||||||
|
net_vlan: 0,
|
||||||
|
rootfs_storage: '',
|
||||||
|
rootfs_size: '',
|
||||||
|
feature_keyctl: false,
|
||||||
|
feature_nesting: false,
|
||||||
|
feature_fuse: false,
|
||||||
|
feature_mount: '',
|
||||||
|
tags: '',
|
||||||
|
advanced_config: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// tRPC hooks
|
||||||
|
const { data: configData, isLoading } = api.installedScripts.getLXCConfig.useQuery(
|
||||||
|
{ scriptId: script?.id ?? 0, forceSync },
|
||||||
|
{ enabled: !!script && isOpen }
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveMutation = api.installedScripts.saveLXCConfig.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setSuccessMessage('LXC configuration saved successfully');
|
||||||
|
setHasChanges(false);
|
||||||
|
setShowConfirmation(false);
|
||||||
|
onSave();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(`Failed to save configuration: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncMutation = api.installedScripts.syncLXCConfig.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
populateFormData(result);
|
||||||
|
setSuccessMessage('Configuration synced from server successfully');
|
||||||
|
setHasChanges(false);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(`Failed to sync configuration: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Populate form data helper
|
||||||
|
const populateFormData = (result: any) => {
|
||||||
|
if (!result?.success) return;
|
||||||
|
const config = result.config;
|
||||||
|
setFormData({
|
||||||
|
arch: config.arch ?? '',
|
||||||
|
cores: config.cores ?? 0,
|
||||||
|
memory: config.memory ?? 0,
|
||||||
|
hostname: config.hostname ?? '',
|
||||||
|
swap: config.swap ?? 0,
|
||||||
|
onboot: config.onboot === 1,
|
||||||
|
ostype: config.ostype ?? '',
|
||||||
|
unprivileged: config.unprivileged === 1,
|
||||||
|
net_name: config.net_name ?? '',
|
||||||
|
net_bridge: config.net_bridge ?? '',
|
||||||
|
net_hwaddr: config.net_hwaddr ?? '',
|
||||||
|
net_ip_type: config.net_ip_type ?? 'dhcp',
|
||||||
|
net_ip: config.net_ip ?? '',
|
||||||
|
net_gateway: config.net_gateway ?? '',
|
||||||
|
net_type: config.net_type ?? '',
|
||||||
|
net_vlan: config.net_vlan ?? 0,
|
||||||
|
rootfs_storage: config.rootfs_storage ?? '',
|
||||||
|
rootfs_size: config.rootfs_size ?? '',
|
||||||
|
feature_keyctl: config.feature_keyctl === 1,
|
||||||
|
feature_nesting: config.feature_nesting === 1,
|
||||||
|
feature_fuse: config.feature_fuse === 1,
|
||||||
|
feature_mount: config.feature_mount ?? '',
|
||||||
|
tags: config.tags ?? '',
|
||||||
|
advanced_config: config.advanced_config ?? ''
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load config when data arrives
|
||||||
|
useEffect(() => {
|
||||||
|
if (configData?.success) {
|
||||||
|
populateFormData(configData);
|
||||||
|
setHasChanges(false);
|
||||||
|
} else if (configData && !configData.success) {
|
||||||
|
setError(String(configData.error ?? 'Failed to load configuration'));
|
||||||
|
}
|
||||||
|
}, [configData]);
|
||||||
|
|
||||||
|
const handleInputChange = (field: string, value: any): void => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
|
setFormData((prev: any) => ({ ...prev, [field]: value }));
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSyncFromServer = () => {
|
||||||
|
if (!script) return;
|
||||||
|
setError(null);
|
||||||
|
syncMutation.mutate({ scriptId: script.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
setShowConfirmation(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmSave = () => {
|
||||||
|
if (!script) return;
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
saveMutation.mutate({
|
||||||
|
scriptId: script.id,
|
||||||
|
config: {
|
||||||
|
...formData,
|
||||||
|
onboot: formData.onboot ? 1 : 0,
|
||||||
|
unprivileged: formData.unprivileged ? 1 : 0,
|
||||||
|
feature_keyctl: formData.feature_keyctl ? 1 : 0,
|
||||||
|
feature_nesting: formData.feature_nesting ? 1 : 0,
|
||||||
|
feature_fuse: formData.feature_fuse ? 1 : 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen || !script) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] overflow-hidden flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-2xl font-bold text-foreground">LXC Settings</h2>
|
||||||
|
<Badge variant="outline">{script.container_id}</Badge>
|
||||||
|
<ContextualHelpIcon section="lxc-settings" tooltip="Help with LXC Settings" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleSyncFromServer}
|
||||||
|
disabled={syncMutation.isPending ?? isLoading ?? saveMutation.isPending}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2 ${syncMutation.isPending ? 'animate-spin' : ''}`} />
|
||||||
|
Sync from Server
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning Banner */}
|
||||||
|
{configData?.has_changes && (
|
||||||
|
<div className="bg-yellow-50 dark:bg-yellow-950/20 border-b border-yellow-200 dark:border-yellow-800 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||||
|
Configuration Mismatch Detected
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
|
||||||
|
The cached configuration differs from the server. Click "Sync from Server" to get the latest version.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success Message */}
|
||||||
|
{successMessage && (
|
||||||
|
<div className="bg-green-50 dark:bg-green-950/20 border-b border-green-200 dark:border-green-800 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-green-800 dark:text-green-200">{successMessage}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSuccessMessage(null)}
|
||||||
|
className="text-green-600 dark:text-green-500 hover:text-green-700 dark:hover:text-green-400"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-950/20 border-b border-red-200 dark:border-red-800 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-red-800 dark:text-red-200">Error</p>
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-300 mt-1">{error}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
className="text-red-600 dark:text-red-500 hover:text-red-700 dark:hover:text-red-400"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div className="border-b border-border mb-6">
|
||||||
|
<nav className="flex space-x-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('common')}
|
||||||
|
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeTab === 'common'
|
||||||
|
? 'border-blue-500 text-blue-600'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Common Settings
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('advanced')}
|
||||||
|
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||||
|
activeTab === 'advanced'
|
||||||
|
? 'border-blue-500 text-blue-600'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Advanced Settings
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Common Settings Tab */}
|
||||||
|
{activeTab === 'common' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Basic Configuration */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Basic Configuration</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="arch" className="block text-sm font-medium text-foreground">Architecture *</label>
|
||||||
|
<Input
|
||||||
|
id="arch"
|
||||||
|
value={formData.arch}
|
||||||
|
onChange={(e) => handleInputChange('arch', e.target.value)}
|
||||||
|
placeholder="amd64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="cores" className="block text-sm font-medium text-foreground">Cores *</label>
|
||||||
|
<Input
|
||||||
|
id="cores"
|
||||||
|
type="number"
|
||||||
|
value={formData.cores}
|
||||||
|
onChange={(e) => handleInputChange('cores', parseInt(e.target.value) || 0)}
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="memory" className="block text-sm font-medium text-foreground">Memory (MB) *</label>
|
||||||
|
<Input
|
||||||
|
id="memory"
|
||||||
|
type="number"
|
||||||
|
value={formData.memory}
|
||||||
|
onChange={(e) => handleInputChange('memory', parseInt(e.target.value) || 0)}
|
||||||
|
min="128"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="swap" className="block text-sm font-medium text-foreground">Swap (MB)</label>
|
||||||
|
<Input
|
||||||
|
id="swap"
|
||||||
|
type="number"
|
||||||
|
value={formData.swap}
|
||||||
|
onChange={(e) => handleInputChange('swap', parseInt(e.target.value) || 0)}
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="hostname" className="block text-sm font-medium text-foreground">Hostname *</label>
|
||||||
|
<Input
|
||||||
|
id="hostname"
|
||||||
|
value={formData.hostname}
|
||||||
|
onChange={(e) => handleInputChange('hostname', e.target.value)}
|
||||||
|
placeholder="container-hostname"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="ostype" className="block text-sm font-medium text-foreground">OS Type *</label>
|
||||||
|
<Input
|
||||||
|
id="ostype"
|
||||||
|
value={formData.ostype}
|
||||||
|
onChange={(e) => handleInputChange('ostype', e.target.value)}
|
||||||
|
placeholder="debian"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="onboot"
|
||||||
|
checked={formData.onboot}
|
||||||
|
onChange={(e) => handleInputChange('onboot', e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="onboot" className="text-sm font-medium text-foreground">Start on Boot</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="unprivileged"
|
||||||
|
checked={formData.unprivileged}
|
||||||
|
onChange={(e) => handleInputChange('unprivileged', e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="unprivileged" className="text-sm font-medium text-foreground">Unprivileged Container</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Network Configuration */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Network Configuration</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="net_name" className="block text-sm font-medium text-foreground">Interface Name</label>
|
||||||
|
<Input
|
||||||
|
id="net_name"
|
||||||
|
value={formData.net_name}
|
||||||
|
onChange={(e) => handleInputChange('net_name', e.target.value)}
|
||||||
|
placeholder="eth0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="net_bridge" className="block text-sm font-medium text-foreground">Bridge</label>
|
||||||
|
<Input
|
||||||
|
id="net_bridge"
|
||||||
|
value={formData.net_bridge}
|
||||||
|
onChange={(e) => handleInputChange('net_bridge', e.target.value)}
|
||||||
|
placeholder="vmbr0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="net_hwaddr" className="block text-sm font-medium text-foreground">MAC Address</label>
|
||||||
|
<Input
|
||||||
|
id="net_hwaddr"
|
||||||
|
value={formData.net_hwaddr}
|
||||||
|
onChange={(e) => handleInputChange('net_hwaddr', e.target.value)}
|
||||||
|
placeholder="BC:24:11:2D:2D:AB"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="net_type" className="block text-sm font-medium text-foreground">Type</label>
|
||||||
|
<Input
|
||||||
|
id="net_type"
|
||||||
|
value={formData.net_type}
|
||||||
|
onChange={(e) => handleInputChange('net_type', e.target.value)}
|
||||||
|
placeholder="veth"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="net_ip_type" className="block text-sm font-medium text-foreground">IP Configuration</label>
|
||||||
|
<select
|
||||||
|
id="net_ip_type"
|
||||||
|
value={formData.net_ip_type}
|
||||||
|
onChange={(e) => handleInputChange('net_ip_type', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-input bg-background rounded-md"
|
||||||
|
>
|
||||||
|
<option value="dhcp">DHCP</option>
|
||||||
|
<option value="static">Static IP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{formData.net_ip_type === 'static' && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="net_ip" className="block text-sm font-medium text-foreground">IP Address with CIDR *</label>
|
||||||
|
<Input
|
||||||
|
id="net_ip"
|
||||||
|
value={formData.net_ip}
|
||||||
|
onChange={(e) => handleInputChange('net_ip', e.target.value)}
|
||||||
|
placeholder="10.10.10.164/24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="net_gateway" className="block text-sm font-medium text-foreground">Gateway</label>
|
||||||
|
<Input
|
||||||
|
id="net_gateway"
|
||||||
|
value={formData.net_gateway}
|
||||||
|
onChange={(e) => handleInputChange('net_gateway', e.target.value)}
|
||||||
|
placeholder="10.10.10.254"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="net_vlan" className="block text-sm font-medium text-foreground">VLAN Tag</label>
|
||||||
|
<Input
|
||||||
|
id="net_vlan"
|
||||||
|
type="number"
|
||||||
|
value={formData.net_vlan}
|
||||||
|
onChange={(e) => handleInputChange('net_vlan', parseInt(e.target.value) || 0)}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Storage */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Storage</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="rootfs_storage" className="block text-sm font-medium text-foreground">Root Filesystem *</label>
|
||||||
|
<Input
|
||||||
|
id="rootfs_storage"
|
||||||
|
value={formData.rootfs_storage}
|
||||||
|
onChange={(e) => handleInputChange('rootfs_storage', e.target.value)}
|
||||||
|
placeholder="PROX2-STORAGE2:vm-109-disk-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="rootfs_size" className="block text-sm font-medium text-foreground">Size</label>
|
||||||
|
<Input
|
||||||
|
id="rootfs_size"
|
||||||
|
value={formData.rootfs_size}
|
||||||
|
onChange={(e) => handleInputChange('rootfs_size', e.target.value)}
|
||||||
|
placeholder="4G"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Features</h3>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="feature_keyctl"
|
||||||
|
checked={formData.feature_keyctl}
|
||||||
|
onChange={(e) => handleInputChange('feature_keyctl', e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="feature_keyctl" className="text-sm font-medium text-foreground">Keyctl</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="feature_nesting"
|
||||||
|
checked={formData.feature_nesting}
|
||||||
|
onChange={(e) => handleInputChange('feature_nesting', e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="feature_nesting" className="text-sm font-medium text-foreground">Nesting</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="feature_fuse"
|
||||||
|
checked={formData.feature_fuse}
|
||||||
|
onChange={(e) => handleInputChange('feature_fuse', e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="feature_fuse" className="text-sm font-medium text-foreground">FUSE</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="feature_mount" className="block text-sm font-medium text-foreground">Additional Mount Features</label>
|
||||||
|
<Input
|
||||||
|
id="feature_mount"
|
||||||
|
value={formData.feature_mount}
|
||||||
|
onChange={(e) => handleInputChange('feature_mount', e.target.value)}
|
||||||
|
placeholder="Additional features (comma-separated)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Tags</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="tags" className="block text-sm font-medium text-foreground">Tags</label>
|
||||||
|
<Input
|
||||||
|
id="tags"
|
||||||
|
value={formData.tags}
|
||||||
|
onChange={(e) => handleInputChange('tags', e.target.value)}
|
||||||
|
placeholder="community-script;pve-scripts-local"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Advanced Settings Tab */}
|
||||||
|
{activeTab === 'advanced' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="advanced_config" className="block text-sm font-medium text-foreground">Advanced Configuration</label>
|
||||||
|
<textarea
|
||||||
|
id="advanced_config"
|
||||||
|
value={formData.advanced_config}
|
||||||
|
onChange={(e) => handleInputChange('advanced_config', e.target.value)}
|
||||||
|
placeholder="lxc.* entries, comments, and other advanced settings..."
|
||||||
|
className="w-full min-h-[400px] px-3 py-2 border border-input bg-background rounded-md font-mono text-sm resize-vertical"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
This section contains lxc.* entries, comments, and other advanced settings that are not covered in the Common Settings tab.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-end p-4 sm:p-6 border-t border-border bg-muted/30">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="outline"
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saveMutation.isPending || !hasChanges}
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? 'Saving...' : 'Save Configuration'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirmation Modal */}
|
||||||
|
<ConfirmationModal
|
||||||
|
isOpen={showConfirmation}
|
||||||
|
onClose={() => {
|
||||||
|
setShowConfirmation(false);
|
||||||
|
}}
|
||||||
|
onConfirm={handleConfirmSave}
|
||||||
|
title="Confirm LXC Configuration Changes"
|
||||||
|
message="Modifying LXC configuration can break your container and may require manual recovery. Ensure you understand these changes before proceeding. The container may need to be restarted for changes to take effect."
|
||||||
|
variant="danger"
|
||||||
|
confirmText={script.container_id ?? ''}
|
||||||
|
confirmButtonText="Save Configuration"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Loading Modal */}
|
||||||
|
<LoadingModal
|
||||||
|
isOpen={isLoading}
|
||||||
|
action="Loading LXC configuration..."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
...prev,
|
...prev,
|
||||||
ssh_key: data.privateKey ?? '',
|
ssh_key: data.privateKey ?? '',
|
||||||
ssh_key_path: keyPath,
|
ssh_key_path: keyPath,
|
||||||
key_generated: 1
|
key_generated: true
|
||||||
}));
|
}));
|
||||||
setGeneratedPublicKey(data.publicKey ?? '');
|
setGeneratedPublicKey(data.publicKey ?? '');
|
||||||
setGeneratedServerId(serverId);
|
setGeneratedServerId(serverId);
|
||||||
|
|||||||
@@ -192,8 +192,8 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
Created: {new Date(server.created_at).toLocaleDateString()}
|
Created: {server.created_at ? new Date(server.created_at).toLocaleDateString() : 'Unknown'}
|
||||||
{server.updated_at !== server.created_at && (
|
{server.updated_at && server.updated_at !== server.created_at && (
|
||||||
<span> • Updated: {new Date(server.updated_at).toLocaleDateString()}</span>
|
<span> • Updated: {new Date(server.updated_at).toLocaleDateString()}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -253,7 +253,7 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
{/* View Public Key button - only show for generated keys */}
|
{/* View Public Key button - only show for generated keys */}
|
||||||
{server.key_generated === 1 && (
|
{server.key_generated === true && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleViewPublicKey(server)}
|
onClick={() => handleViewPublicKey(server)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getDatabase } from '../../../../../server/database';
|
import { getDatabase } from '../../../../../server/database-prisma';
|
||||||
import { getSSHService } from '../../../../../server/ssh-service';
|
import { getSSHService } from '../../../../../server/ssh-service';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -18,7 +18,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const server = db.getServerById(id);
|
const server = await db.getServerById(id);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getDatabase } from '../../../../server/database';
|
import { getDatabase } from '../../../../server/database-prisma';
|
||||||
import type { CreateServerData } from '../../../../types/server';
|
import type { CreateServerData } from '../../../../types/server';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -18,7 +18,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const server = db.getServerById(id);
|
const server = await db.getServerById(id);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -95,7 +95,7 @@ export async function PUT(
|
|||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
// Check if server exists
|
// Check if server exists
|
||||||
const existingServer = db.getServerById(id);
|
const existingServer = await db.getServerById(id);
|
||||||
if (!existingServer) {
|
if (!existingServer) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Server not found' },
|
{ error: 'Server not found' },
|
||||||
@@ -103,7 +103,7 @@ export async function PUT(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = db.updateServer(id, {
|
await db.updateServer(id, {
|
||||||
name,
|
name,
|
||||||
ip,
|
ip,
|
||||||
user,
|
user,
|
||||||
@@ -113,14 +113,14 @@ export async function PUT(
|
|||||||
ssh_key_passphrase,
|
ssh_key_passphrase,
|
||||||
ssh_port: ssh_port ?? 22,
|
ssh_port: ssh_port ?? 22,
|
||||||
color,
|
color,
|
||||||
key_generated: key_generated ?? 0,
|
key_generated: key_generated ?? false,
|
||||||
ssh_key_path
|
ssh_key_path
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
message: 'Server updated successfully',
|
message: 'Server updated successfully',
|
||||||
changes: result.changes
|
changes: 1
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -158,7 +158,7 @@ export async function DELETE(
|
|||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
// Check if server exists
|
// Check if server exists
|
||||||
const existingServer = db.getServerById(id);
|
const existingServer = await db.getServerById(id);
|
||||||
if (!existingServer) {
|
if (!existingServer) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Server not found' },
|
{ error: 'Server not found' },
|
||||||
@@ -167,14 +167,14 @@ export async function DELETE(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete all installed scripts associated with this server
|
// Delete all installed scripts associated with this server
|
||||||
db.deleteInstalledScriptsByServer(id);
|
await db.deleteInstalledScriptsByServer(id);
|
||||||
|
|
||||||
const result = db.deleteServer(id);
|
await db.deleteServer(id);
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
message: 'Server deleted successfully',
|
message: 'Server deleted successfully',
|
||||||
changes: result.changes
|
changes: 1
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getDatabase } from '../../../../../server/database';
|
import { getDatabase } from '../../../../../server/database-prisma';
|
||||||
import { getSSHService } from '../../../../../server/ssh-service';
|
import { getSSHService } from '../../../../../server/ssh-service';
|
||||||
import type { Server } from '../../../../../types/server';
|
import type { Server } from '../../../../../types/server';
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const server = db.getServerById(id) as Server;
|
const server = await db.getServerById(id) as Server;
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getSSHService } from '../../../../server/ssh-service';
|
import { getSSHService } from '../../../../server/ssh-service';
|
||||||
import { getDatabase } from '../../../../server/database';
|
import { getDatabase } from '../../../../server/database-prisma';
|
||||||
|
|
||||||
export async function POST(_request: NextRequest) {
|
export async function POST(_request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -9,7 +9,7 @@ export async function POST(_request: NextRequest) {
|
|||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
// Get the next available server ID for key file naming
|
// Get the next available server ID for key file naming
|
||||||
const serverId = db.getNextServerId();
|
const serverId = await db.getNextServerId();
|
||||||
|
|
||||||
const keyPair = await sshService.generateKeyPair(serverId);
|
const keyPair = await sshService.generateKeyPair(serverId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getDatabase } from '../../../server/database';
|
import { getDatabase } from '../../../server/database-prisma';
|
||||||
import type { CreateServerData } from '../../../types/server';
|
import type { CreateServerData } from '../../../types/server';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const servers = db.getAllServers();
|
const servers = await db.getAllServers();
|
||||||
return NextResponse.json(servers);
|
return NextResponse.json(servers);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching servers:', error);
|
console.error('Error fetching servers:', error);
|
||||||
@@ -61,7 +61,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const result = db.createServer({
|
const result = await db.createServer({
|
||||||
name,
|
name,
|
||||||
ip,
|
ip,
|
||||||
user,
|
user,
|
||||||
@@ -71,14 +71,14 @@ export async function POST(request: NextRequest) {
|
|||||||
ssh_key_passphrase,
|
ssh_key_passphrase,
|
||||||
ssh_port: ssh_port ?? 22,
|
ssh_port: ssh_port ?? 22,
|
||||||
color,
|
color,
|
||||||
key_generated: key_generated ?? 0,
|
key_generated: key_generated ?? false,
|
||||||
ssh_key_path
|
ssh_key_path
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
message: 'Server created successfully',
|
message: 'Server created successfully',
|
||||||
id: result.lastInsertRowid
|
id: result.id
|
||||||
},
|
},
|
||||||
{ status: 201 }
|
{ status: 201 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { Button } from './_components/ui/button';
|
|||||||
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
|
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
|
||||||
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
|
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
|
||||||
import { Footer } from './_components/Footer';
|
import { Footer } from './_components/Footer';
|
||||||
import { Rocket, Package, HardDrive, FolderOpen } from 'lucide-react';
|
import { Package, HardDrive, FolderOpen } from 'lucide-react';
|
||||||
import { api } from '~/trpc/react';
|
import { api } from '~/trpc/react';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
|||||||
@@ -1,7 +1,146 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||||
import { getDatabase } from "~/server/database";
|
import { getDatabase } from "~/server/database-prisma";
|
||||||
// Removed unused imports
|
import { createHash } from "crypto";
|
||||||
|
import type { Server } from "~/types/server";
|
||||||
|
|
||||||
|
// Helper function to parse raw LXC config into structured data
|
||||||
|
function parseRawConfig(rawConfig: string): any {
|
||||||
|
const lines = rawConfig.split('\n');
|
||||||
|
const config: any = { advanced: [] };
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
// Preserve comments in advanced
|
||||||
|
if (trimmed.startsWith('#')) {
|
||||||
|
config.advanced.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
const [key, ...valueParts] = trimmed.split(':');
|
||||||
|
const value = valueParts.join(':').trim();
|
||||||
|
|
||||||
|
switch (key?.trim()) {
|
||||||
|
case 'arch': config.arch = value; break;
|
||||||
|
case 'cores': config.cores = parseInt(value); break;
|
||||||
|
case 'memory': config.memory = parseInt(value); break;
|
||||||
|
case 'hostname': config.hostname = value; break;
|
||||||
|
case 'swap': config.swap = parseInt(value); break;
|
||||||
|
case 'onboot': config.onboot = parseInt(value); break;
|
||||||
|
case 'ostype': config.ostype = value; break;
|
||||||
|
case 'unprivileged': config.unprivileged = parseInt(value); break;
|
||||||
|
case 'tags': config.tags = value; break;
|
||||||
|
case 'rootfs': config.rootfs = value; break;
|
||||||
|
case 'net0':
|
||||||
|
// Parse: name=eth0,bridge=vmbr0,gw=10.10.10.254,hwaddr=BC:24:11:EC:0F:F0,ip=10.10.10.164/24,type=veth
|
||||||
|
const parts = value.split(',');
|
||||||
|
for (const part of parts) {
|
||||||
|
const [k, v] = part.split('=');
|
||||||
|
if (k === 'name') config.net_name = v;
|
||||||
|
else if (k === 'bridge') config.net_bridge = v;
|
||||||
|
else if (k === 'hwaddr') config.net_hwaddr = v;
|
||||||
|
else if (k === 'ip') {
|
||||||
|
config.net_ip = v;
|
||||||
|
config.net_ip_type = v === 'dhcp' ? 'dhcp' : 'static';
|
||||||
|
}
|
||||||
|
else if (k === 'gw') config.net_gateway = v;
|
||||||
|
else if (k === 'type') config.net_type = v;
|
||||||
|
else if (k === 'tag' && v) config.net_vlan = parseInt(v);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'features':
|
||||||
|
// Parse: keyctl=1,nesting=1,fuse=1
|
||||||
|
const feats = value.split(',');
|
||||||
|
for (const feat of feats) {
|
||||||
|
const [k, v] = feat.split('=');
|
||||||
|
if (k === 'keyctl' && v) config.feature_keyctl = parseInt(v);
|
||||||
|
else if (k === 'nesting' && v) config.feature_nesting = parseInt(v);
|
||||||
|
else if (k === 'fuse' && v) config.feature_fuse = parseInt(v);
|
||||||
|
else config.feature_mount = (config.feature_mount ? config.feature_mount + ',' : '') + feat;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Advanced settings (lxc.* and unknown)
|
||||||
|
config.advanced.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse rootfs into storage and size
|
||||||
|
if (config.rootfs) {
|
||||||
|
const match = config.rootfs.match(/^([^:]+):([^,]+)(?:,size=(.+))?$/);
|
||||||
|
if (match) {
|
||||||
|
config.rootfs_storage = `${match[1]}:${match[2]}`;
|
||||||
|
config.rootfs_size = match[3] ?? '';
|
||||||
|
}
|
||||||
|
delete config.rootfs; // Remove the rootfs field since we only need rootfs_storage and rootfs_size
|
||||||
|
}
|
||||||
|
|
||||||
|
config.advanced_config = config.advanced.join('\n');
|
||||||
|
delete config.advanced; // Remove the advanced array since we only need advanced_config
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to reconstruct config from structured data
|
||||||
|
function reconstructConfig(parsed: any): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
// Add standard fields in order
|
||||||
|
if (parsed.arch) lines.push(`arch: ${parsed.arch}`);
|
||||||
|
if (parsed.cores) lines.push(`cores: ${parsed.cores}`);
|
||||||
|
|
||||||
|
// Build features line
|
||||||
|
if (parsed.feature_keyctl !== undefined || parsed.feature_nesting !== undefined || parsed.feature_fuse !== undefined) {
|
||||||
|
const feats: string[] = [];
|
||||||
|
if (parsed.feature_keyctl !== undefined) feats.push(`keyctl=${parsed.feature_keyctl}`);
|
||||||
|
if (parsed.feature_nesting !== undefined) feats.push(`nesting=${parsed.feature_nesting}`);
|
||||||
|
if (parsed.feature_fuse !== undefined) feats.push(`fuse=${parsed.feature_fuse}`);
|
||||||
|
if (parsed.feature_mount) feats.push(String(parsed.feature_mount));
|
||||||
|
lines.push(`features: ${feats.join(',')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.hostname) lines.push(`hostname: ${parsed.hostname}`);
|
||||||
|
if (parsed.memory) lines.push(`memory: ${parsed.memory}`);
|
||||||
|
|
||||||
|
// Build net0 line
|
||||||
|
if (parsed.net_name || parsed.net_bridge || parsed.net_ip) {
|
||||||
|
const netParts: string[] = [];
|
||||||
|
if (parsed.net_name) netParts.push(`name=${parsed.net_name}`);
|
||||||
|
if (parsed.net_bridge) netParts.push(`bridge=${parsed.net_bridge}`);
|
||||||
|
if (parsed.net_gateway && parsed.net_ip_type === 'static') netParts.push(`gw=${parsed.net_gateway}`);
|
||||||
|
if (parsed.net_hwaddr) netParts.push(`hwaddr=${parsed.net_hwaddr}`);
|
||||||
|
if (parsed.net_ip) netParts.push(`ip=${parsed.net_ip}`);
|
||||||
|
if (parsed.net_type) netParts.push(`type=${parsed.net_type}`);
|
||||||
|
if (parsed.net_vlan) netParts.push(`tag=${parsed.net_vlan}`);
|
||||||
|
lines.push(`net0: ${netParts.join(',')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.onboot !== undefined) lines.push(`onboot: ${parsed.onboot}`);
|
||||||
|
if (parsed.ostype) lines.push(`ostype: ${parsed.ostype}`);
|
||||||
|
if (parsed.rootfs_storage) {
|
||||||
|
const rootfs = parsed.rootfs_size
|
||||||
|
? `${parsed.rootfs_storage},size=${parsed.rootfs_size}`
|
||||||
|
: parsed.rootfs_storage;
|
||||||
|
lines.push(`rootfs: ${rootfs}`);
|
||||||
|
}
|
||||||
|
if (parsed.swap !== undefined) lines.push(`swap: ${parsed.swap}`);
|
||||||
|
if (parsed.tags) lines.push(`tags: ${parsed.tags}`);
|
||||||
|
if (parsed.unprivileged !== undefined) lines.push(`unprivileged: ${parsed.unprivileged}`);
|
||||||
|
|
||||||
|
// Add advanced config
|
||||||
|
if (parsed.advanced_config) {
|
||||||
|
lines.push(String(parsed.advanced_config));
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to calculate config hash
|
||||||
|
function calculateConfigHash(rawConfig: string): string {
|
||||||
|
return createHash('md5').update(rawConfig).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const installedScriptsRouter = createTRPCRouter({
|
export const installedScriptsRouter = createTRPCRouter({
|
||||||
@@ -10,10 +149,26 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
.query(async () => {
|
.query(async () => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const scripts = db.getAllInstalledScripts();
|
const scripts = await db.getAllInstalledScripts();
|
||||||
|
|
||||||
|
// Transform scripts to flatten server data for frontend compatibility
|
||||||
|
const transformedScripts = scripts.map(script => ({
|
||||||
|
...script,
|
||||||
|
server_name: script.server?.name ?? null,
|
||||||
|
server_ip: script.server?.ip ?? null,
|
||||||
|
server_user: script.server?.user ?? null,
|
||||||
|
server_password: script.server?.password ?? null,
|
||||||
|
server_auth_type: script.server?.auth_type ?? null,
|
||||||
|
server_ssh_key: script.server?.ssh_key ?? null,
|
||||||
|
server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null,
|
||||||
|
server_ssh_port: script.server?.ssh_port ?? null,
|
||||||
|
server_color: script.server?.color ?? null,
|
||||||
|
server: undefined // Remove nested server object
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
scripts
|
scripts: transformedScripts
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in getAllInstalledScripts:', error);
|
console.error('Error in getAllInstalledScripts:', error);
|
||||||
@@ -31,10 +186,26 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const scripts = db.getInstalledScriptsByServer(input.serverId);
|
const scripts = await db.getInstalledScriptsByServer(input.serverId);
|
||||||
|
|
||||||
|
// Transform scripts to flatten server data for frontend compatibility
|
||||||
|
const transformedScripts = scripts.map(script => ({
|
||||||
|
...script,
|
||||||
|
server_name: script.server?.name ?? null,
|
||||||
|
server_ip: script.server?.ip ?? null,
|
||||||
|
server_user: script.server?.user ?? null,
|
||||||
|
server_password: script.server?.password ?? null,
|
||||||
|
server_auth_type: script.server?.auth_type ?? null,
|
||||||
|
server_ssh_key: script.server?.ssh_key ?? null,
|
||||||
|
server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null,
|
||||||
|
server_ssh_port: script.server?.ssh_port ?? null,
|
||||||
|
server_color: script.server?.color ?? null,
|
||||||
|
server: undefined // Remove nested server object
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
scripts
|
scripts: transformedScripts
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in getInstalledScriptsByServer:', error);
|
console.error('Error in getInstalledScriptsByServer:', error);
|
||||||
@@ -52,7 +223,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const script = db.getInstalledScriptById(input.id);
|
const script = await db.getInstalledScriptById(input.id);
|
||||||
if (!script) {
|
if (!script) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -60,9 +231,24 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
script: null
|
script: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Transform script to flatten server data for frontend compatibility
|
||||||
|
const transformedScript = {
|
||||||
|
...script,
|
||||||
|
server_name: script.server?.name ?? null,
|
||||||
|
server_ip: script.server?.ip ?? null,
|
||||||
|
server_user: script.server?.user ?? null,
|
||||||
|
server_password: script.server?.password ?? null,
|
||||||
|
server_auth_type: script.server?.auth_type ?? null,
|
||||||
|
server_ssh_key: script.server?.ssh_key ?? null,
|
||||||
|
server_ssh_key_passphrase: script.server?.ssh_key_passphrase ?? null,
|
||||||
|
server_ssh_port: script.server?.ssh_port ?? null,
|
||||||
|
server_color: script.server?.color ?? null,
|
||||||
|
server: undefined // Remove nested server object
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
script
|
script: transformedScript
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in getInstalledScriptById:', error);
|
console.error('Error in getInstalledScriptById:', error);
|
||||||
@@ -90,10 +276,10 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const result = db.createInstalledScript(input);
|
const result = await db.createInstalledScript(input);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
id: result.lastInsertRowid,
|
id: result.id,
|
||||||
message: 'Installed script record created successfully'
|
message: 'Installed script record created successfully'
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -120,9 +306,9 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
try {
|
try {
|
||||||
const { id, ...updateData } = input;
|
const { id, ...updateData } = input;
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const result = db.updateInstalledScript(id, updateData);
|
const result = await db.updateInstalledScript(id, updateData);
|
||||||
|
|
||||||
if (result.changes === 0) {
|
if (!result) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'No changes made or script not found'
|
error: 'No changes made or script not found'
|
||||||
@@ -148,9 +334,9 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const result = db.deleteInstalledScript(input.id);
|
const result = await db.deleteInstalledScript(input.id);
|
||||||
|
|
||||||
if (result.changes === 0) {
|
if (!result) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Script not found or already deleted'
|
error: 'Script not found or already deleted'
|
||||||
@@ -175,7 +361,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
.query(async () => {
|
.query(async () => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const allScripts = db.getAllInstalledScripts();
|
const allScripts = await db.getAllInstalledScripts();
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
total: allScripts.length,
|
total: allScripts.length,
|
||||||
@@ -219,7 +405,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
try {
|
try {
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const server = db.getServerById(input.serverId);
|
const server = await db.getServerById(input.serverId);
|
||||||
|
|
||||||
if (!server) {
|
if (!server) {
|
||||||
console.error('Server not found for ID:', input.serverId);
|
console.error('Server not found for ID:', input.serverId);
|
||||||
@@ -238,8 +424,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
const sshExecutionService = new SSHExecutionService();
|
const sshExecutionService = new SSHExecutionService();
|
||||||
|
|
||||||
// Test SSH connection first
|
// Test SSH connection first
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
||||||
const connectionTest = await sshService.testSSHConnection(server as any);
|
const connectionTest = await sshService.testSSHConnection(server as Server);
|
||||||
|
|
||||||
if (!(connectionTest as any).success) {
|
if (!(connectionTest as any).success) {
|
||||||
return {
|
return {
|
||||||
@@ -260,8 +446,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
|
||||||
void sshExecutionService.executeCommand(
|
void sshExecutionService.executeCommand(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
||||||
server as any,
|
server as Server,
|
||||||
command,
|
command,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
commandOutput += data;
|
commandOutput += data;
|
||||||
@@ -292,8 +478,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
return new Promise<any>((readResolve) => {
|
return new Promise<any>((readResolve) => {
|
||||||
|
|
||||||
void sshExecutionService.executeCommand(
|
void sshExecutionService.executeCommand(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
||||||
server as any,
|
server as Server,
|
||||||
readCommand,
|
readCommand,
|
||||||
(configData: string) => {
|
(configData: string) => {
|
||||||
// Parse config file for hostname
|
// Parse config file for hostname
|
||||||
@@ -309,12 +495,21 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hostname) {
|
if (hostname) {
|
||||||
|
// Parse full config and store in database
|
||||||
|
const parsedConfig = parseRawConfig(configData);
|
||||||
|
const configHash = calculateConfigHash(configData);
|
||||||
|
|
||||||
const container = {
|
const container = {
|
||||||
containerId,
|
containerId,
|
||||||
hostname,
|
hostname,
|
||||||
configPath,
|
configPath,
|
||||||
serverId: Number((server as any).id),
|
serverId: Number((server as any).id),
|
||||||
serverName: (server as any).name
|
serverName: (server as any).name,
|
||||||
|
parsedConfig: {
|
||||||
|
...parsedConfig,
|
||||||
|
config_hash: configHash,
|
||||||
|
synced_at: new Date()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
readResolve(container);
|
readResolve(container);
|
||||||
} else {
|
} else {
|
||||||
@@ -350,7 +545,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
|
|
||||||
|
|
||||||
// Get existing scripts to check for duplicates
|
// Get existing scripts to check for duplicates
|
||||||
const existingScripts = db.getAllInstalledScripts();
|
const existingScripts = await db.getAllInstalledScripts();
|
||||||
|
|
||||||
// Create installed script records for detected containers (skip duplicates)
|
// Create installed script records for detected containers (skip duplicates)
|
||||||
const createdScripts = [];
|
const createdScripts = [];
|
||||||
@@ -373,7 +568,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = db.createInstalledScript({
|
const result = await db.createInstalledScript({
|
||||||
script_name: container.hostname,
|
script_name: container.hostname,
|
||||||
script_path: `detected/${container.hostname}`,
|
script_path: `detected/${container.hostname}`,
|
||||||
container_id: container.containerId,
|
container_id: container.containerId,
|
||||||
@@ -383,8 +578,13 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
output_log: `Auto-detected from LXC config: ${container.configPath}`
|
output_log: `Auto-detected from LXC config: ${container.configPath}`
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Store LXC config in database
|
||||||
|
if (container.parsedConfig) {
|
||||||
|
await db.createLXCConfig(result.id, container.parsedConfig);
|
||||||
|
}
|
||||||
|
|
||||||
createdScripts.push({
|
createdScripts.push({
|
||||||
id: result.lastInsertRowid,
|
id: result.id,
|
||||||
containerId: container.containerId,
|
containerId: container.containerId,
|
||||||
hostname: container.hostname,
|
hostname: container.hostname,
|
||||||
serverName: container.serverName
|
serverName: container.serverName
|
||||||
@@ -420,8 +620,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
try {
|
try {
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const allScripts = db.getAllInstalledScripts();
|
const allScripts = await db.getAllInstalledScripts();
|
||||||
const allServers = db.getAllServers();
|
const allServers = await db.getAllServers();
|
||||||
|
|
||||||
|
|
||||||
if (allScripts.length === 0) {
|
if (allScripts.length === 0) {
|
||||||
@@ -452,15 +652,15 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
const scriptData = script as any;
|
const scriptData = script as any;
|
||||||
const server = allServers.find((s: any) => s.id === scriptData.server_id);
|
const server = allServers.find((s: any) => s.id === scriptData.server_id);
|
||||||
if (!server) {
|
if (!server) {
|
||||||
db.deleteInstalledScript(Number(scriptData.id));
|
await db.deleteInstalledScript(Number(scriptData.id));
|
||||||
deletedScripts.push(String(scriptData.script_name));
|
deletedScripts.push(String(scriptData.script_name));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Test SSH connection
|
// Test SSH connection
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
||||||
const connectionTest = await sshService.testSSHConnection(server as any);
|
const connectionTest = await sshService.testSSHConnection(server as Server);
|
||||||
if (!(connectionTest as any).success) {
|
if (!(connectionTest as any).success) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -471,8 +671,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
const containerExists = await new Promise<boolean>((resolve) => {
|
const containerExists = await new Promise<boolean>((resolve) => {
|
||||||
|
|
||||||
void sshExecutionService.executeCommand(
|
void sshExecutionService.executeCommand(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
||||||
server as any,
|
server as Server,
|
||||||
checkCommand,
|
checkCommand,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
resolve(data.trim() === 'exists');
|
resolve(data.trim() === 'exists');
|
||||||
@@ -488,7 +688,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!containerExists) {
|
if (!containerExists) {
|
||||||
db.deleteInstalledScript(Number(scriptData.id));
|
await db.deleteInstalledScript(Number(scriptData.id));
|
||||||
deletedScripts.push(String(scriptData.script_name));
|
deletedScripts.push(String(scriptData.script_name));
|
||||||
} else {
|
} else {
|
||||||
}
|
}
|
||||||
@@ -525,7 +725,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
try {
|
try {
|
||||||
|
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const allServers = db.getAllServers();
|
const allServers = await db.getAllServers();
|
||||||
const statusMap: Record<string, 'running' | 'stopped' | 'unknown'> = {};
|
const statusMap: Record<string, 'running' | 'stopped' | 'unknown'> = {};
|
||||||
|
|
||||||
// Import SSH services
|
// Import SSH services
|
||||||
@@ -545,8 +745,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
try {
|
try {
|
||||||
|
|
||||||
// Test SSH connection
|
// Test SSH connection
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
||||||
const connectionTest = await sshService.testSSHConnection(server as any);
|
const connectionTest = await sshService.testSSHConnection(server as Server);
|
||||||
if (!(connectionTest as any).success) {
|
if (!(connectionTest as any).success) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -563,8 +763,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
await Promise.race([
|
await Promise.race([
|
||||||
new Promise<void>((resolve, reject) => {
|
new Promise<void>((resolve, reject) => {
|
||||||
void sshExecutionService.executeCommand(
|
void sshExecutionService.executeCommand(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
||||||
server as any,
|
server as Server,
|
||||||
listCommand,
|
listCommand,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
listOutput += data;
|
listOutput += data;
|
||||||
@@ -630,7 +830,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const script = db.getInstalledScriptById(input.id);
|
const script = await db.getInstalledScriptById(input.id);
|
||||||
|
|
||||||
if (!script) {
|
if (!script) {
|
||||||
return {
|
return {
|
||||||
@@ -652,7 +852,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get server info
|
// Get server info
|
||||||
const server = db.getServerById(Number(scriptData.server_id));
|
const server = await db.getServerById(Number(scriptData.server_id));
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -668,8 +868,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
const sshExecutionService = new SSHExecutionService();
|
const sshExecutionService = new SSHExecutionService();
|
||||||
|
|
||||||
// Test SSH connection first
|
// Test SSH connection first
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
||||||
const connectionTest = await sshService.testSSHConnection(server as any);
|
const connectionTest = await sshService.testSSHConnection(server as Server);
|
||||||
if (!(connectionTest as any).success) {
|
if (!(connectionTest as any).success) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -684,8 +884,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
void sshExecutionService.executeCommand(
|
void sshExecutionService.executeCommand(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
||||||
server as any,
|
server as Server,
|
||||||
statusCommand,
|
statusCommand,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
statusOutput += data;
|
statusOutput += data;
|
||||||
@@ -732,7 +932,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const script = db.getInstalledScriptById(input.id);
|
const script = await db.getInstalledScriptById(input.id);
|
||||||
|
|
||||||
if (!script) {
|
if (!script) {
|
||||||
return {
|
return {
|
||||||
@@ -752,7 +952,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get server info
|
// Get server info
|
||||||
const server = db.getServerById(Number(scriptData.server_id));
|
const server = await db.getServerById(Number(scriptData.server_id));
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -767,8 +967,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
const sshExecutionService = new SSHExecutionService();
|
const sshExecutionService = new SSHExecutionService();
|
||||||
|
|
||||||
// Test SSH connection first
|
// Test SSH connection first
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
||||||
const connectionTest = await sshService.testSSHConnection(server as any);
|
const connectionTest = await sshService.testSSHConnection(server as Server);
|
||||||
if (!(connectionTest as any).success) {
|
if (!(connectionTest as any).success) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -783,8 +983,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
void sshExecutionService.executeCommand(
|
void sshExecutionService.executeCommand(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
||||||
server as any,
|
server as Server,
|
||||||
controlCommand,
|
controlCommand,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
commandOutput += data;
|
commandOutput += data;
|
||||||
@@ -823,7 +1023,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const script = db.getInstalledScriptById(input.id);
|
const script = await db.getInstalledScriptById(input.id);
|
||||||
|
|
||||||
if (!script) {
|
if (!script) {
|
||||||
return {
|
return {
|
||||||
@@ -843,7 +1043,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get server info
|
// Get server info
|
||||||
const server = db.getServerById(Number(scriptData.server_id));
|
const server = await db.getServerById(Number(scriptData.server_id));
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -858,8 +1058,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
const sshExecutionService = new SSHExecutionService();
|
const sshExecutionService = new SSHExecutionService();
|
||||||
|
|
||||||
// Test SSH connection first
|
// Test SSH connection first
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
||||||
const connectionTest = await sshService.testSSHConnection(server as any);
|
const connectionTest = await sshService.testSSHConnection(server as Server);
|
||||||
if (!(connectionTest as any).success) {
|
if (!(connectionTest as any).success) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -874,8 +1074,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
void sshExecutionService.executeCommand(
|
void sshExecutionService.executeCommand(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
||||||
server as any,
|
server as Server,
|
||||||
statusCommand,
|
statusCommand,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
statusOutput += data;
|
statusOutput += data;
|
||||||
@@ -898,8 +1098,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
void sshExecutionService.executeCommand(
|
void sshExecutionService.executeCommand(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
||||||
server as any,
|
server as Server,
|
||||||
stopCommand,
|
stopCommand,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
stopOutput += data;
|
stopOutput += data;
|
||||||
@@ -929,8 +1129,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
void sshExecutionService.executeCommand(
|
void sshExecutionService.executeCommand(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
||||||
server as any,
|
server as Server,
|
||||||
destroyCommand,
|
destroyCommand,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
commandOutput += data;
|
commandOutput += data;
|
||||||
@@ -950,9 +1150,9 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// If destroy was successful, delete the database record
|
// If destroy was successful, delete the database record
|
||||||
const deleteResult = db.deleteInstalledScript(input.id);
|
const deleteResult = await db.deleteInstalledScript(input.id);
|
||||||
|
|
||||||
if (deleteResult.changes === 0) {
|
if (!deleteResult) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Container destroyed but failed to delete database record'
|
error: 'Container destroyed but failed to delete database record'
|
||||||
@@ -985,7 +1185,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
try {
|
try {
|
||||||
console.log('🔍 Auto-detect WebUI called with id:', input.id);
|
console.log('🔍 Auto-detect WebUI called with id:', input.id);
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const script = db.getInstalledScriptById(input.id);
|
const script = await db.getInstalledScriptById(input.id);
|
||||||
|
|
||||||
if (!script) {
|
if (!script) {
|
||||||
console.log('❌ Script not found for id:', input.id);
|
console.log('❌ Script not found for id:', input.id);
|
||||||
@@ -1013,7 +1213,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get server info
|
// Get server info
|
||||||
const server = db.getServerById(Number(scriptData.server_id));
|
const server = await db.getServerById(Number(scriptData.server_id));
|
||||||
if (!server) {
|
if (!server) {
|
||||||
console.log('❌ Server not found for id:', scriptData.server_id);
|
console.log('❌ Server not found for id:', scriptData.server_id);
|
||||||
return {
|
return {
|
||||||
@@ -1032,8 +1232,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Test SSH connection first
|
// Test SSH connection first
|
||||||
console.log('🔌 Testing SSH connection...');
|
console.log('🔌 Testing SSH connection...');
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
||||||
const connectionTest = await sshService.testSSHConnection(server as any);
|
const connectionTest = await sshService.testSSHConnection(server as Server);
|
||||||
if (!(connectionTest as any).success) {
|
if (!(connectionTest as any).success) {
|
||||||
console.log('❌ SSH connection failed:', (connectionTest as any).error);
|
console.log('❌ SSH connection failed:', (connectionTest as any).error);
|
||||||
return {
|
return {
|
||||||
@@ -1052,8 +1252,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
void sshExecutionService.executeCommand(
|
void sshExecutionService.executeCommand(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
||||||
server as any,
|
server as Server,
|
||||||
hostnameCommand,
|
hostnameCommand,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
console.log('📤 Command output chunk:', data);
|
console.log('📤 Command output chunk:', data);
|
||||||
@@ -1121,12 +1321,12 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Update the database with detected IP and port
|
// Update the database with detected IP and port
|
||||||
console.log('💾 Updating database with IP:', detectedIp, 'Port:', detectedPort);
|
console.log('💾 Updating database with IP:', detectedIp, 'Port:', detectedPort);
|
||||||
const updateResult = db.updateInstalledScript(input.id, {
|
const updateResult = await db.updateInstalledScript(input.id, {
|
||||||
web_ui_ip: detectedIp,
|
web_ui_ip: detectedIp,
|
||||||
web_ui_port: detectedPort
|
web_ui_port: detectedPort
|
||||||
});
|
});
|
||||||
|
|
||||||
if (updateResult.changes === 0) {
|
if (!updateResult) {
|
||||||
console.log('❌ Database update failed - no changes made');
|
console.log('❌ Database update failed - no changes made');
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -1150,5 +1350,249 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
error: error instanceof Error ? error.message : 'Failed to auto-detect Web UI IP'
|
error: error instanceof Error ? error.message : 'Failed to auto-detect Web UI IP'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get LXC configuration
|
||||||
|
getLXCConfig: publicProcedure
|
||||||
|
.input(z.object({
|
||||||
|
scriptId: z.number(),
|
||||||
|
forceSync: z.boolean().optional().default(false)
|
||||||
|
}))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const script = await db.getInstalledScriptById(input.scriptId);
|
||||||
|
|
||||||
|
if (!script) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Script not found'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!script.container_id || !script.server_id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Script does not have container ID or server ID'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have cached config and it's recent (5 minutes)
|
||||||
|
console.log("DB object in getLXCConfig:", Object.keys(db));
|
||||||
|
console.log("getLXCConfigByScriptId exists:", typeof db.getLXCConfigByScriptId);
|
||||||
|
const cachedConfig = await db.getLXCConfigByScriptId(input.scriptId);
|
||||||
|
const now = new Date();
|
||||||
|
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||||
|
|
||||||
|
if (cachedConfig?.synced_at && cachedConfig.synced_at > fiveMinutesAgo && !input.forceSync) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
config: cachedConfig,
|
||||||
|
source: 'cache',
|
||||||
|
has_changes: false,
|
||||||
|
synced_at: cachedConfig.synced_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read from server
|
||||||
|
const server = await db.getServerById(script.server_id);
|
||||||
|
if (!server) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Server not found'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import SSH services
|
||||||
|
const { default: SSHService } = await import('~/server/ssh-service');
|
||||||
|
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||||
|
const sshService = new SSHService();
|
||||||
|
const sshExecutionService = new SSHExecutionService();
|
||||||
|
|
||||||
|
// Test SSH connection
|
||||||
|
const connectionTest = await sshService.testSSHConnection(server as Server);
|
||||||
|
if (!(connectionTest as any).success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read config file
|
||||||
|
const configPath = `/etc/pve/lxc/${script.container_id}.conf`;
|
||||||
|
const readCommand = `cat "${configPath}" 2>/dev/null`;
|
||||||
|
let rawConfig = '';
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
void sshExecutionService.executeCommand(
|
||||||
|
server as Server,
|
||||||
|
readCommand,
|
||||||
|
(data: string) => {
|
||||||
|
rawConfig += data;
|
||||||
|
},
|
||||||
|
(error: string) => {
|
||||||
|
reject(new Error(error));
|
||||||
|
},
|
||||||
|
(exitCode: number) => {
|
||||||
|
if (exitCode === 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Command failed with exit code ${exitCode}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse config
|
||||||
|
const parsedConfig = parseRawConfig(rawConfig);
|
||||||
|
const configHash = calculateConfigHash(rawConfig);
|
||||||
|
|
||||||
|
// Check for changes if we have cached config
|
||||||
|
const hasChanges = cachedConfig ? cachedConfig.config_hash !== configHash : false;
|
||||||
|
|
||||||
|
// Update database cache
|
||||||
|
const configData = {
|
||||||
|
...parsedConfig,
|
||||||
|
config_hash: configHash,
|
||||||
|
synced_at: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.updateLXCConfig(input.scriptId, configData);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
config: configData,
|
||||||
|
source: 'server',
|
||||||
|
has_changes: hasChanges,
|
||||||
|
synced_at: configData.synced_at
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getLXCConfig:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to get LXC config'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Save LXC configuration
|
||||||
|
saveLXCConfig: publicProcedure
|
||||||
|
.input(z.object({
|
||||||
|
scriptId: z.number(),
|
||||||
|
config: z.any()
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const script = await db.getInstalledScriptById(input.scriptId);
|
||||||
|
|
||||||
|
if (!script) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Script not found'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!script.container_id || !script.server_id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Script does not have container ID or server ID'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!input.config.arch || !input.config.cores || !input.config.memory || !input.config.hostname || !input.config.ostype || !input.config.rootfs_storage) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Missing required fields: arch, cores, memory, hostname, ostype, or rootfs_storage'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct config
|
||||||
|
const rawConfig = reconstructConfig(input.config);
|
||||||
|
const configHash = calculateConfigHash(rawConfig);
|
||||||
|
|
||||||
|
// Get server info
|
||||||
|
const server = await db.getServerById(script.server_id);
|
||||||
|
if (!server) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Server not found'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import SSH services
|
||||||
|
const { default: SSHService } = await import('~/server/ssh-service');
|
||||||
|
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||||
|
const sshService = new SSHService();
|
||||||
|
const sshExecutionService = new SSHExecutionService();
|
||||||
|
|
||||||
|
// Test SSH connection
|
||||||
|
const connectionTest = await sshService.testSSHConnection(server as Server);
|
||||||
|
if (!(connectionTest as any).success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write config file using heredoc for safe escaping
|
||||||
|
const configPath = `/etc/pve/lxc/${script.container_id}.conf`;
|
||||||
|
const writeCommand = `cat > "${configPath}" << 'EOFCONFIG'
|
||||||
|
${rawConfig}
|
||||||
|
EOFCONFIG`;
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
void sshExecutionService.executeCommand(
|
||||||
|
server as Server,
|
||||||
|
writeCommand,
|
||||||
|
(_data: string) => {
|
||||||
|
// Success data
|
||||||
|
},
|
||||||
|
(error: string) => {
|
||||||
|
reject(new Error(error));
|
||||||
|
},
|
||||||
|
(exitCode: number) => {
|
||||||
|
if (exitCode === 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Command failed with exit code ${exitCode}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update database cache
|
||||||
|
const configData = {
|
||||||
|
...input.config,
|
||||||
|
config_hash: configHash,
|
||||||
|
synced_at: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.updateLXCConfig(input.scriptId, configData);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'LXC configuration saved successfully'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in saveLXCConfig:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to save LXC config'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Sync LXC configuration from server
|
||||||
|
syncLXCConfig: publicProcedure
|
||||||
|
.input(z.object({ scriptId: z.number() }))
|
||||||
|
.mutation(async ({ input }): Promise<any> => {
|
||||||
|
// This is just a wrapper around getLXCConfig with forceSync=true
|
||||||
|
const result = await installedScriptsRouter
|
||||||
|
.createCaller({ headers: new Headers() })
|
||||||
|
.getLXCConfig({ scriptId: input.scriptId, forceSync: true });
|
||||||
|
|
||||||
|
return result;
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||||
import { getDatabase } from "~/server/database";
|
import { getDatabase } from "~/server/database-prisma";
|
||||||
|
|
||||||
export const serversRouter = createTRPCRouter({
|
export const serversRouter = createTRPCRouter({
|
||||||
getAllServers: publicProcedure
|
getAllServers: publicProcedure
|
||||||
.query(async () => {
|
.query(async () => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const servers = db.getAllServers();
|
const servers = await db.getAllServers();
|
||||||
return { success: true, servers };
|
return { success: true, servers };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching servers:', error);
|
console.error('Error fetching servers:', error);
|
||||||
@@ -24,7 +24,7 @@ export const serversRouter = createTRPCRouter({
|
|||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const server = db.getServerById(input.id);
|
const server = await db.getServerById(input.id);
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return { success: false, error: 'Server not found', server: null };
|
return { success: false, error: 'Server not found', server: null };
|
||||||
}
|
}
|
||||||
|
|||||||
287
src/server/database-prisma.js
Normal file
287
src/server/database-prisma.js
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
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;
|
||||||
312
src/server/database-prisma.ts
Normal file
312
src/server/database-prisma.ts
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
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;
|
||||||
@@ -1,476 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
7
src/server/db.js
Normal file
7
src/server/db.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis;
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||||
9
src/server/db.ts
Normal file
9
src/server/db.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
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?: string;
|
||||||
ssh_key_passphrase?: string;
|
ssh_key_passphrase?: string;
|
||||||
ssh_key_path?: string;
|
ssh_key_path?: string;
|
||||||
key_generated?: number;
|
key_generated?: boolean;
|
||||||
ssh_port?: number;
|
ssh_port?: number;
|
||||||
color?: string;
|
color?: string;
|
||||||
created_at: string;
|
created_at: Date | null;
|
||||||
updated_at: string;
|
updated_at: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateServerData {
|
export interface CreateServerData {
|
||||||
@@ -24,7 +24,7 @@ export interface CreateServerData {
|
|||||||
ssh_key?: string;
|
ssh_key?: string;
|
||||||
ssh_key_passphrase?: string;
|
ssh_key_passphrase?: string;
|
||||||
ssh_key_path?: string;
|
ssh_key_path?: string;
|
||||||
key_generated?: number;
|
key_generated?: boolean;
|
||||||
ssh_port?: number;
|
ssh_port?: number;
|
||||||
color?: string;
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
59
update.sh
59
update.sh
@@ -412,6 +412,36 @@ restore_backup_files() {
|
|||||||
fi
|
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 if systemd service exists
|
||||||
check_service() {
|
check_service() {
|
||||||
# systemctl status returns 0-3 if service exists (running, exited, failed, etc.)
|
# systemctl status returns 0-3 if service exists (running, exited, failed, etc.)
|
||||||
@@ -607,6 +637,32 @@ install_and_build() {
|
|||||||
log_success "Dependencies installed successfully"
|
log_success "Dependencies installed successfully"
|
||||||
rm -f "$npm_log"
|
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..."
|
log "Building application..."
|
||||||
# Set NODE_ENV to production for build
|
# Set NODE_ENV to production for build
|
||||||
export NODE_ENV=production
|
export NODE_ENV=production
|
||||||
@@ -838,6 +894,9 @@ main() {
|
|||||||
# Restore .env and data directory before building
|
# Restore .env and data directory before building
|
||||||
restore_backup_files
|
restore_backup_files
|
||||||
|
|
||||||
|
# Ensure DATABASE_URL is set for Prisma
|
||||||
|
ensure_database_url
|
||||||
|
|
||||||
# Install dependencies and build
|
# Install dependencies and build
|
||||||
if ! install_and_build; then
|
if ! install_and_build; then
|
||||||
log_error "Install and build failed, rolling back..."
|
log_error "Install and build failed, rolling back..."
|
||||||
|
|||||||
Reference in New Issue
Block a user