Compare commits

...

24 Commits

Author SHA1 Message Date
Michel Roegl-Brunner
20c9a2ecbf feat: Add newest scripts highlighting section
- Add horizontal scrollable carousel for 6 newest scripts
- Only show when no filters are active to avoid duplication
- Exclude newest scripts from main grid when carousel is visible
- Add Clock icon and subtle left border accent for visual distinction
- Include NEW badges on script cards in carousel
- Responsive design for mobile, tablet, and desktop
- Sort by date_created field in descending order
2025-10-17 14:27:38 +02:00
Michel Roegl-Brunner
16e918e9b4 fix: improve SSH key handling and public key modal UX (#178)
* fix: increase IP input box width in installedScripts table

- Changed IP input field width from w-32 (128px) to w-40 (160px)
- Fixes truncation issue for IP addresses in format 123.123.123.123
- Affects Web UI column in desktop table view when editing scripts

* fix: improve SSH key handling and public key modal UX

- Fix SSH key import to automatically trim trailing whitespace and empty lines
- Add 'View Public Key' button in ServerForm for generated key pairs
- Reduce public key textarea size from 120px to 60px min-height
- Add quick command section with pre-filled echo command for authorized_keys
- Improve user experience with one-click copy functionality for both key and command
2025-10-17 13:44:15 +02:00
Michel Roegl-Brunner
08b7eecdfe feat: implement disk resize with improved modal flow and error handling (#177)
- Add automatic disk resizing when changing LXC disk size in settings
- Implement separate modal flow: confirmation -> loading -> result -> settings
- Add proper error detection for pct resize command (check both exit code and output)
- Add LVM fallback resize method when pct resize fails
- Implement configuration rollback on resize failure
- Update modal styling to use semantic color classes for proper dark mode support
- Add data refresh after result modal close to show updated values
- Remove success/error banners from settings modal for cleaner UI
2025-10-17 13:34:37 +02:00
github-actions[bot]
c1b478ed51 chore: add VERSION v0.4.4 (#176)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-17 09:40:26 +00:00
github-actions[bot]
4af5ad4f7b chore: add VERSION v0.4.4 (#175)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-17 09:39:36 +00:00
Michel Roegl-Brunner
537d65275a feat: improve LXC settings modal and fix database issues (#174)
- Fix Prisma database errors in LXC config sync (advanced and rootfs field issues)
- Remove double confirmation from LXC settings modal (keep confirmation modal, remove inline input)
- Fix dependency loop in status check useEffect
- Add LXC configuration management with proper validation
- Improve error handling and user experience
2025-10-17 11:38:23 +02:00
github-actions[bot]
ef460b5a00 chore: add VERSION v0.4.4 (#173)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-17 07:33:38 +00:00
Michel Roegl-Brunner
87ab645231 docs: add DATABASE_URL to .env.example (#172)
- Add DATABASE_URL example to .env.example for new installations
- Ensures new users have the required Prisma database URL configured
2025-10-17 09:33:02 +02:00
github-actions[bot]
9c44a47b3d chore: add VERSION v0.4.3 (#171)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-17 07:20:08 +00:00
Michel Roegl-Brunner
b793c57000 refactor: migrate from better-sqlite3 to Prisma (#170)
* refactor: migrate from better-sqlite3 to Prisma

- Install Prisma dependencies and initialize with SQLite
- Create Prisma schema matching existing database structure
- Replace database.js with Prisma-based database service
- Update all API routes, tRPC routers, and WebSocket handler
- Convert TypeScript types to match Prisma schema
- Update build process to include Prisma migrations
- Remove better-sqlite3 dependency

All database operations now use Prisma while maintaining SQLite backend.

* fix: flatten server data in installed scripts API responses

- Transform Prisma nested server objects to flattened fields expected by frontend
- Update getAllInstalledScripts, getInstalledScriptsByServer, and getInstalledScriptById
- Server names should now display correctly in the installed scripts table
- Use nullish coalescing operators for better null handling

* fix: ensure DATABASE_URL is set in .env for Prisma during updates

- Add ensure_database_url() function to update.sh
- Function checks if .env exists and creates from .env.example if needed
- Automatically adds DATABASE_URL if not present
- Call function after restore_backup_files() in update flow
- Fixes Prisma client generation error during updates
2025-10-17 09:17:20 +02:00
dependabot[bot]
6b45c41334 build(deps-dev): Bump @types/node from 24.7.2 to 24.8.0 (#167) 2025-10-16 22:30:27 +02:00
dependabot[bot]
a8eb41e087 build(deps): Bump lucide-react from 0.545.0 to 0.546.0 (#168) 2025-10-16 22:29:52 +02:00
Michel Roegl-Brunner
52adbd9f5c Merge pull request #169 from community-scripts/dependabot/npm_and_yarn/tanstack/react-query-5.90.5 2025-10-16 22:29:28 +02:00
dependabot[bot]
73d3aeec99 build(deps): Bump @tanstack/react-query from 5.90.3 to 5.90.5
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.90.3 to 5.90.5.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.90.5/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.90.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-16 19:56:41 +00:00
Michel Roegl-Brunner
1635bb17da Add Breaking Changes category to release drafter 2025-10-16 15:57:56 +02:00
github-actions[bot]
b4b8da5725 chore: add VERSION v0.4.2 (#165)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-16 13:53:51 +00:00
Michel Roegl-Brunner
d95a85435b Merge pull request #164 from community-scripts/fix/show_modal_during_operation
fix: add loading modal for container operations
2025-10-16 15:52:06 +02:00
Michel Roegl-Brunner
962e2877e3 feat: add loading modal for container operations
- Add LoadingModal component with spinning circle animation
- Show loading modal during start/stop/destroy container operations
- Display current action being performed (e.g., 'Starting container 101...')
- Close loading modal when operation completes (success or error)
- Maintains consistent modal styling with existing components

Fixes user experience by providing clear visual feedback during
background operations instead of silent processing.
2025-10-16 15:50:33 +02:00
Michel Roegl-Brunner
3459fe3fa4 Merge pull request #163 from community-scripts/fix/ssh_keys
fix: implement persistent SSH key storage with key generation
2025-10-16 15:46:41 +02:00
Michel Roegl-Brunner
6580f3100a Delete scripts/install/debian-install.sh 2025-10-16 15:45:14 +02:00
Michel Roegl-Brunner
15ffa98ea8 Delete scripts/ct/debian.sh 2025-10-16 15:44:36 +02:00
Michel Roegl-Brunner
4c3b66a26b Delete 2025-10-16 15:43:47 +02:00
Michel Roegl-Brunner
94e97a7366 fix: implement persistent SSH key storage with key generation
- Fix 'error in libcrypto' issue by using persistent key files instead of temporary ones
- Add SSH key pair generation feature with 'Generate Key Pair' button
- Add 'View Public Key' button for generated keys with copy-to-clipboard functionality
- Remove confusing 'both' authentication option, now only supports 'password' OR 'key'
- Add persistent storage in data/ssh-keys/ directory with proper permissions
- Update database schema with ssh_key_path and key_generated columns
- Add API endpoints for key generation and public key retrieval
- Enhance UX by hiding manual key input when key pair is generated
- Update HelpModal documentation to reflect new SSH key features
- Fix all TypeScript compilation errors and linting issues

Resolves SSH authentication failures during script execution
2025-10-16 15:42:26 +02:00
Michel Roegl-Brunner
0e95c125d3 Merge pull request #162 from community-scripts/fix/ui
UI Fixes: Modal Layout and Filter Message Positioning
2025-10-16 14:31:03 +02:00
36 changed files with 3847 additions and 1141 deletions

View File

@@ -25,4 +25,5 @@ AUTH_USERNAME=
AUTH_PASSWORD_HASH=
AUTH_ENABLED=false
AUTH_SETUP_COMPLETED=false
JWT_SECRET=
JWT_SECRET=
DATABASE_URL="file:./data/database.sqlite"

View File

@@ -7,6 +7,9 @@ exclude-labels:
- automated
categories:
- title: "Breaking Changes"
labels:
- breaking
- title: "🚀 Features"
labels:
- feature

6
.gitignore vendored
View File

@@ -16,6 +16,9 @@
db.sqlite
data/settings.db
# ssh keys (sensitive)
data/ssh-keys/
# next.js
/.next/
/out/
@@ -46,4 +49,5 @@ yarn-error.log*
*.tsbuildinfo
# idea files
.idea
.idea
/generated/prisma

View File

@@ -1 +1 @@
0.4.1
0.4.4

820
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,11 +22,12 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@prisma/client": "^6.17.1",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.3",
"@t3-oss/env-nextjs": "^0.13.8",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.3",
"@tanstack/react-query": "^5.90.5",
"@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.6.0",
@@ -36,11 +37,10 @@
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"bcryptjs": "^3.0.2",
"better-sqlite3": "^12.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.545.0",
"lucide-react": "^0.546.0",
"next": "^15.5.5",
"node-pty": "^1.0.0",
"react": "^19.0.0",
@@ -65,7 +65,7 @@
"@types/bcryptjs": "^3.0.0",
"@types/better-sqlite3": "^7.6.8",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.7.2",
"@types/node": "^24.8.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.0.2",
@@ -77,6 +77,7 @@
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.7.0",
"prisma": "^6.17.1",
"tailwindcss": "^4.1.14",
"typescript": "^5.8.2",
"typescript-eslint": "^8.46.1",

View File

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

View File

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

97
prisma/schema.prisma Normal file
View 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")
}

View File

@@ -7,7 +7,7 @@ import { join, resolve } from 'path';
import stripAnsi from 'strip-ansi';
import { spawn as ptySpawn } from 'node-pty';
import { getSSHExecutionService } from './src/server/ssh-execution-service.js';
import { getDatabase } from './src/server/database.js';
import { getDatabase } from './src/server/database-prisma.js';
const dev = process.env.NODE_ENV !== 'production';
const hostname = '0.0.0.0';
@@ -186,11 +186,11 @@ class ScriptExecutionHandler {
* @param {string} scriptPath - Path to the script
* @param {string} executionMode - 'local' or 'ssh'
* @param {number|null} serverId - Server ID for SSH executions
* @returns {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 {
const result = this.db.createInstalledScript({
const result = await this.db.createInstalledScript({
script_name: scriptName,
script_path: scriptPath,
container_id: undefined,
@@ -199,7 +199,7 @@ class ScriptExecutionHandler {
status: 'in_progress',
output_log: ''
});
return Number(result.lastInsertRowid);
return Number(result.id);
} catch (error) {
console.error('Error creating installation record:', error);
return null;
@@ -211,9 +211,9 @@ class ScriptExecutionHandler {
* @param {number} installationId - Installation record ID
* @param {Object} updateData - Data to update
*/
updateInstallationRecord(installationId, updateData) {
async updateInstallationRecord(installationId, updateData) {
try {
this.db.updateInstalledScript(installationId, updateData);
await this.db.updateInstalledScript(installationId, updateData);
} catch (error) {
console.error('Error updating installation record:', error);
}
@@ -327,7 +327,7 @@ class ScriptExecutionHandler {
// Create installation record
const serverId = server ? (server.id ?? null) : null;
installationId = this.createInstallationRecord(scriptName, scriptPath, mode, serverId);
installationId = await this.createInstallationRecord(scriptName, scriptPath, mode, serverId);
if (!installationId) {
console.error('Failed to create installation record');
@@ -356,7 +356,7 @@ class ScriptExecutionHandler {
// Update installation record with failure
if (installationId) {
this.updateInstallationRecord(installationId, { status: 'failed' });
await this.updateInstallationRecord(installationId, { status: 'failed' });
}
return;
}
@@ -394,7 +394,7 @@ class ScriptExecutionHandler {
});
// Handle pty data (both stdout and stderr combined)
childProcess.onData((data) => {
childProcess.onData(async (data) => {
const output = data.toString();
// Store output in buffer for logging
@@ -410,7 +410,7 @@ class ScriptExecutionHandler {
// Parse for Container ID
const containerId = this.parseContainerId(output);
if (containerId && installationId) {
this.updateInstallationRecord(installationId, { container_id: containerId });
await this.updateInstallationRecord(installationId, { container_id: containerId });
}
// Parse for Web UI URL
@@ -418,7 +418,7 @@ class ScriptExecutionHandler {
if (webUIUrl && installationId) {
const { ip, port } = webUIUrl;
if (ip && port) {
this.updateInstallationRecord(installationId, {
await this.updateInstallationRecord(installationId, {
web_ui_ip: ip,
web_ui_port: port
});
@@ -464,7 +464,7 @@ class ScriptExecutionHandler {
// Update installation record with failure
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(
server,
scriptPath,
/** @param {string} data */ (data) => {
/** @param {string} data */ async (data) => {
// Store output in buffer for logging
const exec = this.activeExecutions.get(executionId);
if (exec) {
@@ -505,7 +505,7 @@ class ScriptExecutionHandler {
// Parse for Container ID
const containerId = this.parseContainerId(data);
if (containerId && installationId) {
this.updateInstallationRecord(installationId, { container_id: containerId });
await this.updateInstallationRecord(installationId, { container_id: containerId });
}
// Parse for Web UI URL
@@ -513,7 +513,7 @@ class ScriptExecutionHandler {
if (webUIUrl && installationId) {
const { ip, port } = webUIUrl;
if (ip && port) {
this.updateInstallationRecord(installationId, {
await this.updateInstallationRecord(installationId, {
web_ui_ip: ip,
web_ui_port: port
});
@@ -545,13 +545,13 @@ class ScriptExecutionHandler {
timestamp: Date.now()
});
},
/** @param {number} code */ (code) => {
/** @param {number} code */ async (code) => {
const exec = this.activeExecutions.get(executionId);
const isSuccess = code === 0;
// Update installation record with final status and output
if (installationId && exec) {
this.updateInstallationRecord(installationId, {
await this.updateInstallationRecord(installationId, {
status: isSuccess ? 'success' : 'failed',
output_log: exec.outputBuffer
});
@@ -586,7 +586,7 @@ class ScriptExecutionHandler {
// Update installation record with failure
if (installationId) {
this.updateInstallationRecord(installationId, { status: 'failed' });
await this.updateInstallationRecord(installationId, { status: 'failed' });
}
}
}

View File

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

View File

@@ -8,6 +8,8 @@ import { Button } from './ui/button';
import { ScriptInstallationCard } from './ScriptInstallationCard';
import { ConfirmationModal } from './ConfirmationModal';
import { ErrorModal } from './ErrorModal';
import { LoadingModal } from './LoadingModal';
import { LXCSettingsModal } from './LXCSettingsModal';
import { getContrastColor } from '../../lib/colorUtils';
import {
DropdownMenu,
@@ -16,6 +18,7 @@ import {
DropdownMenuTrigger,
DropdownMenuSeparator,
} from './ui/dropdown-menu';
import { Settings } from 'lucide-react';
interface InstalledScript {
id: number;
@@ -84,6 +87,18 @@ export function InstalledScriptsTab() {
type?: 'error' | 'success';
} | null>(null);
// Loading modal state
const [loadingModal, setLoadingModal] = useState<{
isOpen: boolean;
action: string;
} | null>(null);
// LXC Settings modal state
const [lxcSettingsModal, setLxcSettingsModal] = useState<{
isOpen: boolean;
script: InstalledScript | null;
}>({ isOpen: false, script: null });
// Fetch installed scripts
const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery();
const { data: statsData } = api.installedScripts.getInstallationStats.useQuery();
@@ -247,6 +262,7 @@ export function InstalledScriptsTab() {
const controlContainerMutation = api.installedScripts.controlContainer.useMutation({
onSuccess: (data, variables) => {
setLoadingModal(null);
setControllingScriptId(null);
if (data.success) {
@@ -287,6 +303,7 @@ export function InstalledScriptsTab() {
},
onError: (error) => {
console.error('Container control error:', error);
setLoadingModal(null);
setControllingScriptId(null);
// Show detailed error message
@@ -302,6 +319,7 @@ export function InstalledScriptsTab() {
const destroyContainerMutation = api.installedScripts.destroyContainer.useMutation({
onSuccess: (data) => {
setLoadingModal(null);
setControllingScriptId(null);
if (data.success) {
@@ -326,6 +344,7 @@ export function InstalledScriptsTab() {
},
onError: (error) => {
console.error('Container destroy error:', error);
setLoadingModal(null);
setControllingScriptId(null);
// Show detailed error message
@@ -377,7 +396,7 @@ export function InstalledScriptsTab() {
containerStatusMutation.mutate({ serverIds });
}
}, 500);
}, []);
}, []);
// Run cleanup when component mounts and scripts are loaded (only once)
useEffect(() => {
@@ -393,7 +412,7 @@ export function InstalledScriptsTab() {
console.log('Status check triggered - scripts length:', scripts.length);
fetchContainerStatuses();
}
}, [scripts.length, fetchContainerStatuses]);
}, [scripts.length]);
// Cleanup timeout on unmount
useEffect(() => {
@@ -515,6 +534,7 @@ export function InstalledScriptsTab() {
message: `Are you sure you want to ${action} container ${script.container_id} (${script.script_name})?`,
onConfirm: () => {
setControllingScriptId(script.id);
setLoadingModal({ isOpen: true, action: `${action === 'start' ? 'Starting' : 'Stopping'} container ${script.container_id}...` });
void controlContainerMutation.mutate({ id: script.id, action });
setConfirmationModal(null);
}
@@ -535,6 +555,7 @@ export function InstalledScriptsTab() {
confirmText: script.container_id,
onConfirm: () => {
setControllingScriptId(script.id);
setLoadingModal({ isOpen: true, action: `Destroying container ${script.container_id}...` });
void destroyContainerMutation.mutate({ id: script.id });
setConfirmationModal(null);
}
@@ -691,6 +712,10 @@ export function InstalledScriptsTab() {
setEditFormData({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' });
};
const handleLXCSettings = (script: InstalledScript) => {
setLxcSettingsModal({ isOpen: true, script });
};
const handleSaveEdit = () => {
if (!editFormData.script_name.trim()) {
setErrorModal({
@@ -909,7 +934,7 @@ export function InstalledScriptsTab() {
</Button>
<Button
onClick={fetchContainerStatuses}
disabled={containerStatusMutation.isPending || scripts.length === 0}
disabled={containerStatusMutation.isPending ?? scripts.length === 0}
variant="outline"
size="default"
>
@@ -1114,7 +1139,7 @@ export function InstalledScriptsTab() {
</Button>
<Button
onClick={handleAutoDetect}
disabled={autoDetectMutation.isPending || !autoDetectServerId}
disabled={autoDetectMutation.isPending ?? !autoDetectServerId}
variant="default"
size="default"
className="w-full sm:w-auto"
@@ -1351,7 +1376,7 @@ export function InstalledScriptsTab() {
type="text"
value={editFormData.web_ui_ip}
onChange={(e) => handleInputChange('web_ui_ip', e.target.value)}
className="w-32 px-3 py-2 text-sm font-mono border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
className="w-40 px-3 py-2 text-sm font-mono border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
placeholder="IP"
/>
<span className="text-muted-foreground">:</span>
@@ -1486,7 +1511,7 @@ export function InstalledScriptsTab() {
{script.container_id && script.execution_mode === 'ssh' && script.web_ui_ip && (
<DropdownMenuItem
onClick={() => handleAutoDetectWebUI(script)}
disabled={autoDetectWebUIMutation.isPending || containerStatuses.get(script.id) === 'stopped'}
disabled={autoDetectWebUIMutation.isPending ?? containerStatuses.get(script.id) === 'stopped'}
className="text-blue-300 hover:text-blue-200 hover:bg-blue-900/20 focus:bg-blue-900/20"
>
{autoDetectWebUIMutation.isPending ? 'Re-detect...' : 'Re-detect IP/Port'}
@@ -1494,6 +1519,14 @@ export function InstalledScriptsTab() {
)}
{script.container_id && script.execution_mode === 'ssh' && (
<>
<DropdownMenuSeparator className="bg-gray-700" />
<DropdownMenuItem
onClick={() => handleLXCSettings(script)}
className="text-purple-300 hover:text-purple-200 hover:bg-purple-900/20 focus:bg-purple-900/20"
>
<Settings className="mr-2 h-4 w-4" />
LXC Settings
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-gray-700" />
<DropdownMenuItem
onClick={() => handleStartStop(script, (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'stop' : 'start')}
@@ -1566,6 +1599,25 @@ export function InstalledScriptsTab() {
type={errorModal.type ?? 'error'}
/>
)}
{/* Loading Modal */}
{loadingModal && (
<LoadingModal
isOpen={loadingModal.isOpen}
action={loadingModal.action}
/>
)}
{/* LXC Settings Modal */}
<LXCSettingsModal
isOpen={lxcSettingsModal.isOpen}
script={lxcSettingsModal.script}
onClose={() => setLxcSettingsModal({ isOpen: false, script: null })}
onSave={() => {
setLxcSettingsModal({ isOpen: false, script: null });
void refetchScripts();
}}
/>
</div>
);
}

View File

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

View File

@@ -0,0 +1,37 @@
'use client';
import { Loader2 } from 'lucide-react';
interface LoadingModalProps {
isOpen: boolean;
action: string;
}
export function LoadingModal({ isOpen, action }: LoadingModalProps) {
if (!isOpen) 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-md w-full border border-border p-8">
<div className="flex flex-col items-center space-y-4">
<div className="relative">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-card-foreground mb-2">
Processing
</h3>
<p className="text-sm text-muted-foreground">
{action}
</p>
<p className="text-xs text-muted-foreground mt-2">
Please wait...
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,217 @@
'use client';
import { useState } from 'react';
import { X, Copy, Check, Server, Globe } from 'lucide-react';
import { Button } from './ui/button';
interface PublicKeyModalProps {
isOpen: boolean;
onClose: () => void;
publicKey: string;
serverName: string;
serverIp: string;
}
export function PublicKeyModal({ isOpen, onClose, publicKey, serverName, serverIp }: PublicKeyModalProps) {
const [copied, setCopied] = useState(false);
const [commandCopied, setCommandCopied] = useState(false);
if (!isOpen) return null;
const handleCopy = async () => {
try {
// Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(publicKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
// Fallback for older browsers or non-HTTPS
const textArea = document.createElement('textarea');
textArea.value = publicKey;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (fallbackError) {
console.error('Fallback copy failed:', fallbackError);
// If all else fails, show the key in an alert
alert('Please manually copy this key:\n\n' + publicKey);
}
document.body.removeChild(textArea);
}
} catch (error) {
console.error('Failed to copy to clipboard:', error);
// Fallback: show the key in an alert
alert('Please manually copy this key:\n\n' + publicKey);
}
};
const handleCopyCommand = async () => {
const command = `echo "${publicKey}" >> ~/.ssh/authorized_keys`;
try {
// Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(command);
setCommandCopied(true);
setTimeout(() => setCommandCopied(false), 2000);
} else {
// Fallback for older browsers or non-HTTPS
const textArea = document.createElement('textarea');
textArea.value = command;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
setCommandCopied(true);
setTimeout(() => setCommandCopied(false), 2000);
} catch (fallbackError) {
console.error('Fallback copy failed:', fallbackError);
alert('Please manually copy this command:\n\n' + command);
}
document.body.removeChild(textArea);
}
} catch (error) {
console.error('Failed to copy command to clipboard:', error);
alert('Please manually copy this command:\n\n' + command);
}
};
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Server className="h-6 w-6 text-blue-600" />
</div>
<div>
<h2 className="text-xl font-semibold text-card-foreground">SSH Public Key</h2>
<p className="text-sm text-muted-foreground">Add this key to your server&apos;s authorized_keys</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Server Info */}
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Server className="h-4 w-4" />
<span className="font-medium">{serverName}</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Globe className="h-4 w-4" />
<span>{serverIp}</span>
</div>
</div>
{/* Instructions */}
<div className="space-y-2">
<h3 className="font-medium text-foreground">Instructions:</h3>
<ol className="text-sm text-muted-foreground space-y-1 list-decimal list-inside">
<li>Copy the public key below</li>
<li>SSH into your server: <code className="bg-muted px-1 rounded">ssh root@{serverIp}</code></li>
<li>Add the key to authorized_keys: <code className="bg-muted px-1 rounded">echo &quot;&lt;paste-key&gt;&quot; &gt;&gt; ~/.ssh/authorized_keys</code></li>
<li>Set proper permissions: <code className="bg-muted px-1 rounded">chmod 600 ~/.ssh/authorized_keys</code></li>
</ol>
</div>
{/* Public Key */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">Public Key:</label>
<Button
variant="outline"
size="sm"
onClick={handleCopy}
className="gap-2"
>
{copied ? (
<>
<Check className="h-4 w-4" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy
</>
)}
</Button>
</div>
<textarea
value={publicKey}
readOnly
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground font-mono text-xs min-h-[60px] resize-none border-border focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
placeholder="Public key will appear here..."
/>
</div>
{/* Quick Command */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">Quick Add Command:</label>
<Button
variant="outline"
size="sm"
onClick={handleCopyCommand}
className="gap-2"
>
{commandCopied ? (
<>
<Check className="h-4 w-4" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy Command
</>
)}
</Button>
</div>
<div className="p-3 bg-muted/50 rounded-md border border-border">
<code className="text-sm font-mono text-foreground break-all">
echo &quot;{publicKey}&quot; &gt;&gt; ~/.ssh/authorized_keys
</code>
</div>
<p className="text-xs text-muted-foreground">
Copy and paste this command directly into your server terminal to add the key to authorized_keys
</p>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 pt-4 border-t border-border">
<Button variant="outline" onClick={onClose}>
Close
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -30,7 +30,11 @@ export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHK
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
let content = e.target?.result as string;
// Auto-trim trailing whitespace and empty lines to fix import issues
content = content.replace(/\n\s*$/, '').trimEnd();
if (validateSSHKey(content)) {
onChange(content);
onError?.('');
@@ -72,7 +76,12 @@ export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHK
};
const handlePasteChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const content = event.target.value;
let content = event.target.value;
// Auto-trim trailing whitespace and empty lines to fix import issues
// This addresses the common problem where pasted SSH keys have extra whitespace
content = content.replace(/\n\s*$/, '').trimEnd();
onChange(content);
if (content.trim() && !validateSSHKey(content)) {

View File

@@ -9,6 +9,7 @@ import { CategorySidebar } from './CategorySidebar';
import { FilterBar, type FilterState } from './FilterBar';
import { ViewToggle } from './ViewToggle';
import { Button } from './ui/button';
import { Clock } from 'lucide-react';
import type { ScriptCard as ScriptCardType } from '~/types/script';
@@ -220,6 +221,31 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
});
}, [combinedScripts, localScriptsData]);
// Check if any filters are active (excluding default state)
const hasActiveFilters = React.useMemo(() => {
return (
filters.searchQuery?.trim() !== '' ||
filters.showUpdatable !== null ||
filters.selectedTypes.length > 0 ||
filters.sortBy !== 'name' ||
filters.sortOrder !== 'asc' ||
selectedCategory !== null
);
}, [filters, selectedCategory]);
// Get the 6 newest scripts based on date_created field
const newestScripts = React.useMemo((): ScriptCardType[] => {
return scriptsWithStatus
.filter(script => script?.date_created) // Only scripts with date_created
.sort((a, b) => {
const aCreated = a?.date_created ?? '';
const bCreated = b?.date_created ?? '';
// Sort by date descending (newest first)
return bCreated.localeCompare(aCreated);
})
.slice(0, 6); // Take only the first 6
}, [scriptsWithStatus]);
// Filter scripts based on all filters and category
const filteredScripts = React.useMemo((): ScriptCardType[] => {
let scripts = scriptsWithStatus;
@@ -270,6 +296,12 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
});
}
// Exclude newest scripts from main grid when no filters are active (they'll be shown in carousel)
if (!hasActiveFilters) {
const newestScriptSlugs = new Set(newestScripts.map(script => script.slug).filter(Boolean));
scripts = scripts.filter(script => !newestScriptSlugs.has(script.slug));
}
// Apply sorting
scripts.sort((a, b) => {
if (!a || !b) return 0;
@@ -309,7 +341,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
});
return scripts;
}, [scriptsWithStatus, filters, selectedCategory]);
}, [scriptsWithStatus, filters, selectedCategory, hasActiveFilters, newestScripts]);
// Calculate filter counts for FilterBar
const filterCounts = React.useMemo(() => {
@@ -619,6 +651,51 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
onViewModeChange={setViewMode}
/>
{/* Newest Scripts Carousel - Only show when no filters are active */}
{!hasActiveFilters && newestScripts.length > 0 && (
<div className="mb-8">
<div className="bg-card border-l-4 border-l-primary border border-border rounded-lg p-6 shadow-lg">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-foreground flex items-center gap-2">
<Clock className="h-6 w-6 text-primary" />
Newest Scripts
</h2>
<span className="text-sm text-muted-foreground">
{newestScripts.length} recently added
</span>
</div>
<div className="overflow-x-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent">
<div className="flex gap-4 pb-2" style={{ minWidth: 'max-content' }}>
{newestScripts.map((script, index) => {
if (!script || typeof script !== 'object') {
return null;
}
const uniqueKey = `newest-${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
return (
<div key={uniqueKey} className="flex-shrink-0 w-64 sm:w-72 md:w-80">
<div className="relative">
<ScriptCard
script={script}
onClick={handleCardClick}
isSelected={selectedSlugs.has(script.slug ?? '')}
onToggleSelect={toggleScriptSelection}
/>
{/* NEW badge */}
<div className="absolute top-2 right-2 bg-green-600 text-white text-xs font-semibold px-2 py-1 rounded-md shadow-md z-10">
NEW
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex flex-wrap gap-2 mb-4">

View File

@@ -4,6 +4,8 @@ import { useState, useEffect } from 'react';
import type { CreateServerData } from '../../types/server';
import { Button } from './ui/button';
import { SSHKeyInput } from './SSHKeyInput';
import { PublicKeyModal } from './PublicKeyModal';
import { Key } from 'lucide-react';
interface ServerFormProps {
onSubmit: (data: CreateServerData) => void;
@@ -30,6 +32,11 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
const [errors, setErrors] = useState<Partial<Record<keyof CreateServerData, string>>>({});
const [sshKeyError, setSshKeyError] = useState<string>('');
const [colorCodingEnabled, setColorCodingEnabled] = useState(false);
const [isGeneratingKey, setIsGeneratingKey] = useState(false);
const [showPublicKeyModal, setShowPublicKeyModal] = useState(false);
const [generatedPublicKey, setGeneratedPublicKey] = useState('');
const [, setIsGeneratedKey] = useState(false);
const [, setGeneratedServerId] = useState<number | null>(null);
useEffect(() => {
const loadColorCodingSetting = async () => {
@@ -75,25 +82,18 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
// Validate authentication based on auth_type
const authType = formData.auth_type ?? 'password';
if (authType === 'password' || authType === 'both') {
if (authType === 'password') {
if (!formData.password?.trim()) {
newErrors.password = 'Password is required for password authentication';
}
}
if (authType === 'key' || authType === 'both') {
if (authType === 'key') {
if (!formData.ssh_key?.trim()) {
newErrors.ssh_key = 'SSH key is required for key authentication';
}
}
// Check if at least one authentication method is provided
if (authType === 'both') {
if (!formData.password?.trim() && !formData.ssh_key?.trim()) {
newErrors.password = 'At least one authentication method (password or SSH key) is required';
newErrors.ssh_key = 'At least one authentication method (password or SSH key) is required';
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0 && !sshKeyError;
@@ -127,6 +127,54 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: undefined }));
}
// Reset generated key state when switching auth types
if (field === 'auth_type') {
setIsGeneratedKey(false);
setGeneratedPublicKey('');
}
};
const handleGenerateKeyPair = async () => {
setIsGeneratingKey(true);
try {
const response = await fetch('/api/servers/generate-keypair', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to generate key pair');
}
const data = await response.json() as { success: boolean; privateKey?: string; publicKey?: string; serverId?: number; error?: string };
if (data.success) {
const serverId = data.serverId ?? 0;
const keyPath = `data/ssh-keys/server_${serverId}_key`;
setFormData(prev => ({
...prev,
ssh_key: data.privateKey ?? '',
ssh_key_path: keyPath,
key_generated: true
}));
setGeneratedPublicKey(data.publicKey ?? '');
setGeneratedServerId(serverId);
setIsGeneratedKey(true);
setShowPublicKeyModal(true);
setSshKeyError('');
} else {
throw new Error(data.error ?? 'Failed to generate key pair');
}
} catch (error) {
console.error('Error generating key pair:', error);
setSshKeyError(error instanceof Error ? error.message : 'Failed to generate key pair');
} finally {
setIsGeneratingKey(false);
}
};
const handleSSHKeyChange = (value: string) => {
@@ -137,6 +185,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
};
return (
<>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
@@ -221,7 +270,6 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
>
<option value="password">Password Only</option>
<option value="key">SSH Key Only</option>
<option value="both">Both Password & SSH Key</option>
</select>
</div>
@@ -247,10 +295,10 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
</div>
{/* Password Authentication */}
{(formData.auth_type === 'password' || formData.auth_type === 'both') && (
{formData.auth_type === 'password' && (
<div>
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
Password {formData.auth_type === 'both' ? '(Optional)' : '*'}
Password *
</label>
<input
type="password"
@@ -267,19 +315,67 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
)}
{/* SSH Key Authentication */}
{(formData.auth_type === 'key' || formData.auth_type === 'both') && (
{formData.auth_type === 'key' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
SSH Private Key {formData.auth_type === 'both' ? '(Optional)' : '*'}
</label>
<SSHKeyInput
value={formData.ssh_key ?? ''}
onChange={handleSSHKeyChange}
onError={setSshKeyError}
/>
{errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>}
{sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>}
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-muted-foreground">
SSH Private Key *
</label>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGenerateKeyPair}
disabled={isGeneratingKey}
className="gap-2"
>
<Key className="h-4 w-4" />
{isGeneratingKey ? 'Generating...' : 'Generate Key Pair'}
</Button>
</div>
{/* Show manual key input only if no key has been generated */}
{!formData.key_generated && (
<>
<SSHKeyInput
value={formData.ssh_key ?? ''}
onChange={handleSSHKeyChange}
onError={setSshKeyError}
/>
{errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>}
{sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>}
</>
)}
{/* Show generated key status */}
{formData.key_generated && (
<div className="p-3 bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800 rounded-md">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm font-medium text-green-800 dark:text-green-200">
SSH key pair generated successfully
</span>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowPublicKeyModal(true)}
className="gap-2 border-blue-500/20 text-blue-400 bg-blue-500/10 hover:bg-blue-500/20"
>
<Key className="h-4 w-4" />
View Public Key
</Button>
</div>
<p className="text-xs text-green-700 dark:text-green-300 mt-1">
The private key has been generated and will be saved with the server.
</p>
</div>
)}
</div>
<div>
@@ -323,6 +419,16 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
</Button>
</div>
</form>
{/* Public Key Modal */}
<PublicKeyModal
isOpen={showPublicKeyModal}
onClose={() => setShowPublicKeyModal(false)}
publicKey={generatedPublicKey}
serverName={formData.name || 'New Server'}
serverIp={formData.ip}
/>
</>
);
}

View File

@@ -5,6 +5,8 @@ import type { Server, CreateServerData } from '../../types/server';
import { ServerForm } from './ServerForm';
import { Button } from './ui/button';
import { ConfirmationModal } from './ConfirmationModal';
import { PublicKeyModal } from './PublicKeyModal';
import { Key } from 'lucide-react';
interface ServerListProps {
servers: Server[];
@@ -24,6 +26,12 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
confirmText: string;
onConfirm: () => void;
} | null>(null);
const [showPublicKeyModal, setShowPublicKeyModal] = useState(false);
const [publicKeyData, setPublicKeyData] = useState<{
publicKey: string;
serverName: string;
serverIp: string;
} | null>(null);
const handleEdit = (server: Server) => {
setEditingId(server.id);
@@ -40,6 +48,32 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
setEditingId(null);
};
const handleViewPublicKey = async (server: Server) => {
try {
const response = await fetch(`/api/servers/${server.id}/public-key`);
if (!response.ok) {
throw new Error('Failed to retrieve public key');
}
const data = await response.json() as { success: boolean; publicKey?: string; serverName?: string; serverIp?: string; error?: string };
if (data.success) {
setPublicKeyData({
publicKey: data.publicKey ?? '',
serverName: data.serverName ?? '',
serverIp: data.serverIp ?? ''
});
setShowPublicKeyModal(true);
} else {
throw new Error(data.error ?? 'Failed to retrieve public key');
}
} catch (error) {
console.error('Error retrieving public key:', error);
// You could show a toast notification here
}
};
const handleDelete = (id: number) => {
const server = servers.find(s => s.id === id);
if (!server) return;
@@ -158,8 +192,8 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">
Created: {new Date(server.created_at).toLocaleDateString()}
{server.updated_at !== server.created_at && (
Created: {server.created_at ? new Date(server.created_at).toLocaleDateString() : 'Unknown'}
{server.updated_at && server.updated_at !== server.created_at && (
<span> Updated: {new Date(server.updated_at).toLocaleDateString()}</span>
)}
</div>
@@ -218,6 +252,19 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
)}
</Button>
<div className="flex space-x-2">
{/* View Public Key button - only show for generated keys */}
{server.key_generated === true && (
<Button
onClick={() => handleViewPublicKey(server)}
variant="outline"
size="sm"
className="flex-1 sm:flex-none border-blue-500/20 text-blue-400 bg-blue-500/10 hover:bg-blue-500/20"
>
<Key className="w-4 h-4 mr-1" />
<span className="hidden sm:inline">View Public Key</span>
<span className="sm:hidden">Key</span>
</Button>
)}
<Button
onClick={() => handleEdit(server)}
variant="outline"
@@ -263,6 +310,20 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
cancelButtonText="Cancel"
/>
)}
{/* Public Key Modal */}
{publicKeyData && (
<PublicKeyModal
isOpen={showPublicKeyModal}
onClose={() => {
setShowPublicKeyModal(false);
setPublicKeyData(null);
}}
publicKey={publicKeyData.publicKey}
serverName={publicKeyData.serverName}
serverIp={publicKeyData.serverIp}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,64 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getDatabase } from '../../../../../server/database-prisma';
import { getSSHService } from '../../../../../server/ssh-service';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: idParam } = await params;
const id = parseInt(idParam);
if (isNaN(id)) {
return NextResponse.json(
{ error: 'Invalid server ID' },
{ status: 400 }
);
}
const db = getDatabase();
const server = await db.getServerById(id);
if (!server) {
return NextResponse.json(
{ error: 'Server not found' },
{ status: 404 }
);
}
// Only allow viewing public key if it was generated by the system
if (!(server as any).key_generated) {
return NextResponse.json(
{ error: 'Public key not available for user-provided keys' },
{ status: 403 }
);
}
if (!(server as any).ssh_key_path) {
return NextResponse.json(
{ error: 'SSH key path not found' },
{ status: 404 }
);
}
const sshService = getSSHService();
const publicKey = sshService.getPublicKey((server as any).ssh_key_path as string);
return NextResponse.json({
success: true,
publicKey,
serverName: (server as any).name,
serverIp: (server as any).ip
});
} catch (error) {
console.error('Error retrieving public key:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : String(error)
},
{ status: 500 }
);
}
}

View File

@@ -1,6 +1,6 @@
import type { NextRequest } 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';
export async function GET(
@@ -18,7 +18,7 @@ export async function GET(
}
const db = getDatabase();
const server = db.getServerById(id);
const server = await db.getServerById(id);
if (!server) {
return NextResponse.json(
@@ -52,7 +52,7 @@ export async function PUT(
}
const body = await request.json();
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color }: CreateServerData = body;
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated, ssh_key_path }: CreateServerData = body;
// Validate required fields
if (!name || !ip || !user) {
@@ -73,7 +73,7 @@ export async function PUT(
// Validate authentication based on auth_type
const authType = auth_type ?? 'password';
if (authType === 'password' || authType === 'both') {
if (authType === 'password') {
if (!password?.trim()) {
return NextResponse.json(
{ error: 'Password is required for password authentication' },
@@ -82,7 +82,7 @@ export async function PUT(
}
}
if (authType === 'key' || authType === 'both') {
if (authType === 'key') {
if (!ssh_key?.trim()) {
return NextResponse.json(
{ error: 'SSH key is required for key authentication' },
@@ -91,20 +91,11 @@ export async function PUT(
}
}
// Check if at least one authentication method is provided
if (authType === 'both') {
if (!password?.trim() && !ssh_key?.trim()) {
return NextResponse.json(
{ error: 'At least one authentication method (password or SSH key) is required' },
{ status: 400 }
);
}
}
const db = getDatabase();
// Check if server exists
const existingServer = db.getServerById(id);
const existingServer = await db.getServerById(id);
if (!existingServer) {
return NextResponse.json(
{ error: 'Server not found' },
@@ -112,7 +103,7 @@ export async function PUT(
);
}
const result = db.updateServer(id, {
await db.updateServer(id, {
name,
ip,
user,
@@ -121,13 +112,15 @@ export async function PUT(
ssh_key,
ssh_key_passphrase,
ssh_port: ssh_port ?? 22,
color
color,
key_generated: key_generated ?? false,
ssh_key_path
});
return NextResponse.json(
{
message: 'Server updated successfully',
changes: result.changes
changes: 1
}
);
} catch (error) {
@@ -165,7 +158,7 @@ export async function DELETE(
const db = getDatabase();
// Check if server exists
const existingServer = db.getServerById(id);
const existingServer = await db.getServerById(id);
if (!existingServer) {
return NextResponse.json(
{ error: 'Server not found' },
@@ -174,14 +167,14 @@ export async function DELETE(
}
// 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(
{
message: 'Server deleted successfully',
changes: result.changes
changes: 1
}
);
} catch (error) {

View File

@@ -1,6 +1,6 @@
import type { NextRequest } 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 type { Server } from '../../../../../types/server';
@@ -19,7 +19,7 @@ export async function POST(
}
const db = getDatabase();
const server = db.getServerById(id) as Server;
const server = await db.getServerById(id) as Server;
if (!server) {
return NextResponse.json(

View File

@@ -0,0 +1,32 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getSSHService } from '../../../../server/ssh-service';
import { getDatabase } from '../../../../server/database-prisma';
export async function POST(_request: NextRequest) {
try {
const sshService = getSSHService();
const db = getDatabase();
// Get the next available server ID for key file naming
const serverId = await db.getNextServerId();
const keyPair = await sshService.generateKeyPair(serverId);
return NextResponse.json({
success: true,
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey,
serverId: serverId
});
} catch (error) {
console.error('Error generating SSH key pair:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to generate SSH key pair'
},
{ status: 500 }
);
}
}

View File

@@ -1,12 +1,12 @@
import type { NextRequest } 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';
export async function GET() {
try {
const db = getDatabase();
const servers = db.getAllServers();
const servers = await db.getAllServers();
return NextResponse.json(servers);
} catch (error) {
console.error('Error fetching servers:', error);
@@ -20,7 +20,7 @@ export async function GET() {
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color }: CreateServerData = body;
const { name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color, key_generated, ssh_key_path }: CreateServerData = body;
// Validate required fields
if (!name || !ip || !user) {
@@ -41,7 +41,7 @@ export async function POST(request: NextRequest) {
// Validate authentication based on auth_type
const authType = auth_type ?? 'password';
if (authType === 'password' || authType === 'both') {
if (authType === 'password') {
if (!password?.trim()) {
return NextResponse.json(
{ error: 'Password is required for password authentication' },
@@ -50,7 +50,7 @@ export async function POST(request: NextRequest) {
}
}
if (authType === 'key' || authType === 'both') {
if (authType === 'key') {
if (!ssh_key?.trim()) {
return NextResponse.json(
{ error: 'SSH key is required for key authentication' },
@@ -59,18 +59,9 @@ export async function POST(request: NextRequest) {
}
}
// Check if at least one authentication method is provided
if (authType === 'both') {
if (!password?.trim() && !ssh_key?.trim()) {
return NextResponse.json(
{ error: 'At least one authentication method (password or SSH key) is required' },
{ status: 400 }
);
}
}
const db = getDatabase();
const result = db.createServer({
const result = await db.createServer({
name,
ip,
user,
@@ -79,13 +70,15 @@ export async function POST(request: NextRequest) {
ssh_key,
ssh_key_passphrase,
ssh_port: ssh_port ?? 22,
color
color,
key_generated: key_generated ?? false,
ssh_key_path
});
return NextResponse.json(
{
message: 'Server created successfully',
id: result.lastInsertRowid
id: result.id
},
{ status: 201 }
);

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,13 @@
import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { getDatabase } from "~/server/database";
import { getDatabase } from "~/server/database-prisma";
export const serversRouter = createTRPCRouter({
getAllServers: publicProcedure
.query(async () => {
try {
const db = getDatabase();
const servers = db.getAllServers();
const servers = await db.getAllServers();
return { success: true, servers };
} catch (error) {
console.error('Error fetching servers:', error);
@@ -24,7 +24,7 @@ export const serversRouter = createTRPCRouter({
.query(async ({ input }) => {
try {
const db = getDatabase();
const server = db.getServerById(input.id);
const server = await db.getServerById(input.id);
if (!server) {
return { success: false, error: 'Server not found', server: null };
}

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

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

View File

@@ -1,336 +0,0 @@
import Database from 'better-sqlite3';
import { join } from 'path';
class DatabaseService {
constructor() {
const dbPath = join(process.cwd(), 'data', 'settings.db');
this.db = new Database(dbPath);
this.init();
}
init() {
// 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', 'both')),
ssh_key TEXT,
ssh_key_passphrase TEXT,
ssh_port INTEGER DEFAULT 22,
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', 'both'))
`);
} 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
}
// 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: 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 } = serverData;
const stmt = this.db.prepare(`
INSERT INTO servers (name, ip, user, password, auth_type, ssh_key, ssh_key_passphrase, ssh_port, color)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, 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 } = serverData;
const stmt = this.db.prepare(`
UPDATE servers
SET name = ?, ip = ?, user = ?, password = ?, auth_type = ?, ssh_key = ?, ssh_key_passphrase = ?, ssh_port = ?, color = ?
WHERE id = ?
`);
return stmt.run(name, ip, user, password, auth_type || 'password', ssh_key, ssh_key_passphrase, ssh_port || 22, color, id);
}
/**
* @param {number} id
*/
deleteServer(id) {
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);
}
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
View 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
View 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;

View File

@@ -1,8 +1,6 @@
import { spawn } from 'child_process';
import { spawn as ptySpawn } from 'node-pty';
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { existsSync } from 'fs';
/**
@@ -11,43 +9,22 @@ import { tmpdir } from 'os';
* @property {string} user - Username
* @property {string} [password] - Password (optional)
* @property {string} name - Server name
* @property {string} [auth_type] - Authentication type ('password', 'key', 'both')
* @property {string} [auth_type] - Authentication type ('password', 'key')
* @property {string} [ssh_key] - SSH private key content
* @property {string} [ssh_key_passphrase] - SSH key passphrase
* @property {string} [ssh_key_path] - Path to persistent SSH key file
* @property {number} [ssh_port] - SSH port (default: 22)
*/
class SSHExecutionService {
/**
* Create a temporary SSH key file for authentication
* @param {Server} server - Server configuration
* @returns {string} Path to temporary key file
*/
createTempKeyFile(server) {
const { ssh_key } = server;
if (!ssh_key) {
throw new Error('SSH key not provided');
}
const tempDir = mkdtempSync(join(tmpdir(), 'ssh-key-'));
const tempKeyPath = join(tempDir, 'private_key');
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
const normalizedKey = ssh_key.trimEnd() + '\n';
writeFileSync(tempKeyPath, normalizedKey);
chmodSync(tempKeyPath, 0o600); // Set proper permissions
return tempKeyPath;
}
/**
* Build SSH command arguments based on authentication type
* @param {Server} server - Server configuration
* @param {string|null} [tempKeyPath=null] - Path to temporary key file (if using key auth)
* @returns {{command: string, args: string[]}} Command and arguments for SSH
*/
buildSSHCommand(server, tempKeyPath = null) {
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_port = 22 } = server;
buildSSHCommand(server) {
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_key_path, ssh_port = 22 } = server;
const baseArgs = [
'-t',
@@ -69,12 +46,14 @@ class SSHExecutionService {
if (auth_type === 'key') {
// SSH key authentication
if (tempKeyPath) {
baseArgs.push('-i', tempKeyPath);
baseArgs.push('-o', 'PasswordAuthentication=no');
baseArgs.push('-o', 'PubkeyAuthentication=yes');
if (!ssh_key_path || !existsSync(ssh_key_path)) {
throw new Error('SSH key file not found');
}
baseArgs.push('-i', ssh_key_path);
baseArgs.push('-o', 'PasswordAuthentication=no');
baseArgs.push('-o', 'PubkeyAuthentication=yes');
if (ssh_key_passphrase) {
return {
command: 'sshpass',
@@ -86,35 +65,6 @@ class SSHExecutionService {
args: [...baseArgs, `${user}@${ip}`]
};
}
} else if (auth_type === 'both') {
// Try SSH key first, then password
if (tempKeyPath) {
baseArgs.push('-i', tempKeyPath);
baseArgs.push('-o', 'PasswordAuthentication=yes');
baseArgs.push('-o', 'PubkeyAuthentication=yes');
if (ssh_key_passphrase) {
return {
command: 'sshpass',
args: ['-P', 'passphrase', '-p', ssh_key_passphrase, 'ssh', ...baseArgs, `${user}@${ip}`]
};
} else {
return {
command: 'ssh',
args: [...baseArgs, `${user}@${ip}`]
};
}
} else {
// Fallback to password
if (password) {
return {
command: 'sshpass',
args: ['-p', password, 'ssh', ...baseArgs, '-o', 'PasswordAuthentication=yes', '-o', 'PubkeyAuthentication=no', `${user}@${ip}`]
};
} else {
throw new Error('Password is required for password authentication');
}
}
} else {
// Password authentication (default)
if (password) {
@@ -138,9 +88,6 @@ class SSHExecutionService {
* @returns {Promise<Object>} Process information
*/
async executeScript(server, scriptPath, onData, onError, onExit) {
/** @type {string|null} */
let tempKeyPath = null;
try {
await this.transferScriptsFolder(server, onData, onError);
@@ -148,13 +95,8 @@ class SSHExecutionService {
const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath;
try {
// Create temporary key file if using key authentication
if (server.auth_type === 'key' || server.auth_type === 'both') {
tempKeyPath = this.createTempKeyFile(server);
}
// Build SSH command based on authentication type
const { command, args } = this.buildSSHCommand(server, tempKeyPath);
const { command, args } = this.buildSSHCommand(server);
// Add the script execution command to the args
args.push(`cd /tmp/scripts && chmod +x ${relativeScriptPath} && export TERM=xterm-256color && export COLUMNS=120 && export LINES=30 && export COLORTERM=truecolor && export FORCE_COLOR=1 && export NO_COLOR=0 && export CLICOLOR=1 && export CLICOLOR_FORCE=1 && bash ${relativeScriptPath}`);
@@ -193,30 +135,10 @@ class SSHExecutionService {
process: sshCommand,
kill: () => {
sshCommand.kill('SIGTERM');
// Clean up temporary key file
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
}
});
} catch (error) {
// Clean up temporary key file on error
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
reject(error);
}
});
@@ -235,35 +157,24 @@ class SSHExecutionService {
* @returns {Promise<void>}
*/
async transferScriptsFolder(server, onData, onError) {
const { ip, user, password, auth_type = 'password', ssh_key, ssh_key_passphrase, ssh_port = 22 } = server;
/** @type {string|null} */
let tempKeyPath = null;
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_key_path, ssh_port = 22 } = server;
return new Promise((resolve, reject) => {
try {
// Create temporary key file if using key authentication
if (auth_type === 'key' || auth_type === 'both') {
if (ssh_key) {
tempKeyPath = this.createTempKeyFile(server);
}
}
// Build rsync command based on authentication type
let rshCommand;
if (auth_type === 'key' && tempKeyPath) {
if (ssh_key_passphrase) {
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
} else {
rshCommand = `ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
if (auth_type === 'key') {
if (!ssh_key_path || !existsSync(ssh_key_path)) {
throw new Error('SSH key file not found');
}
} else if (auth_type === 'both' && tempKeyPath) {
if (ssh_key_passphrase) {
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
} else {
rshCommand = `ssh -i ${tempKeyPath} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
rshCommand = `ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
}
} else {
// Fallback to password authentication
// Password authentication
rshCommand = `sshpass -p ${password} ssh -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
}
@@ -292,17 +203,6 @@ class SSHExecutionService {
});
rsyncCommand.on('close', (code) => {
// Clean up temporary key file
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
if (code === 0) {
resolve();
} else {
@@ -311,30 +211,10 @@ class SSHExecutionService {
});
rsyncCommand.on('error', (error) => {
// Clean up temporary key file on error
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
reject(error);
});
} catch (error) {
// Clean up temporary key file on error
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
reject(error);
}
});
@@ -350,18 +230,10 @@ class SSHExecutionService {
* @returns {Promise<Object>} Process information
*/
async executeCommand(server, command, onData, onError, onExit) {
/** @type {string|null} */
let tempKeyPath = null;
return new Promise((resolve, reject) => {
try {
// Create temporary key file if using key authentication
if (server.auth_type === 'key' || server.auth_type === 'both') {
tempKeyPath = this.createTempKeyFile(server);
}
// Build SSH command based on authentication type
const { command: sshCommandName, args } = this.buildSSHCommand(server, tempKeyPath);
const { command: sshCommandName, args } = this.buildSSHCommand(server);
// Add the command to execute to the args
args.push(command);
@@ -380,16 +252,6 @@ class SSHExecutionService {
});
sshCommand.onExit((e) => {
// Clean up temporary key file
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
onExit(e.exitCode);
});
@@ -397,30 +259,10 @@ class SSHExecutionService {
process: sshCommand,
kill: () => {
sshCommand.kill('SIGTERM');
// Clean up temporary key file
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
}
});
} catch (error) {
// Clean up temporary key file on error
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
reject(error);
}
});

View File

@@ -1,5 +1,5 @@
import { spawn } from 'child_process';
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync } from 'fs';
import { writeFileSync, unlinkSync, chmodSync, mkdtempSync, rmdirSync, readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
@@ -21,9 +21,6 @@ class SSHService {
let authPromise;
if (auth_type === 'key') {
authPromise = this.testWithSSHKey(server);
} else if (auth_type === 'both') {
// Try SSH key first, then password
authPromise = this.testWithSSHKey(server).catch(() => this.testWithSshpass(server));
} else {
// Default to password authentication
authPromise = this.testWithSshpass(server).catch(() => this.testWithExpect(server));
@@ -540,31 +537,20 @@ expect {
* @returns {Promise<Object>} Connection test result
*/
async testWithSSHKey(server) {
const { ip, user, ssh_key, ssh_key_passphrase, ssh_port = 22 } = server;
const { ip, user, ssh_key_path, ssh_key_passphrase, ssh_port = 22 } = server;
if (!ssh_key) {
throw new Error('SSH key not provided');
if (!ssh_key_path || !existsSync(ssh_key_path)) {
throw new Error('SSH key file not found');
}
return new Promise((resolve, reject) => {
const timeout = 10000;
let resolved = false;
let tempKeyPath = null;
try {
// Create temporary key file
const tempDir = mkdtempSync(join(tmpdir(), 'ssh-key-'));
tempKeyPath = join(tempDir, 'private_key');
// Write the private key to temporary file
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
const normalizedKey = ssh_key.trimEnd() + '\n';
writeFileSync(tempKeyPath, normalizedKey);
chmodSync(tempKeyPath, 0o600); // Set proper permissions
// Build SSH command
const sshArgs = [
'-i', tempKeyPath,
'-i', ssh_key_path,
'-p', ssh_port.toString(),
'-o', 'ConnectTimeout=10',
'-o', 'StrictHostKeyChecking=no',
@@ -662,22 +648,82 @@ expect {
resolved = true;
reject(error);
}
} finally {
// Clean up temporary key file
if (tempKeyPath) {
try {
unlinkSync(tempKeyPath);
// Also remove the temp directory
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
rmdirSync(tempDir);
} catch (cleanupError) {
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
}
}
}
});
}
/**
* Generate SSH key pair for a server
* @param {number} serverId - Server ID for key file naming
* @returns {Promise<{privateKey: string, publicKey: string}>}
*/
async generateKeyPair(serverId) {
const sshKeysDir = join(process.cwd(), 'data', 'ssh-keys');
const keyPath = join(sshKeysDir, `server_${serverId}_key`);
return new Promise((resolve, reject) => {
const sshKeygen = spawn('ssh-keygen', [
'-t', 'ed25519',
'-f', keyPath,
'-N', '', // No passphrase
'-C', 'pve-scripts-local'
], {
stdio: ['pipe', 'pipe', 'pipe']
});
let errorOutput = '';
sshKeygen.stderr.on('data', (data) => {
errorOutput += data.toString();
});
sshKeygen.on('close', (code) => {
if (code === 0) {
try {
// Read the generated private key
const privateKey = readFileSync(keyPath, 'utf8');
// Read the generated public key
const publicKeyPath = keyPath + '.pub';
const publicKey = readFileSync(publicKeyPath, 'utf8');
// Set proper permissions
chmodSync(keyPath, 0o600);
chmodSync(publicKeyPath, 0o644);
resolve({
privateKey,
publicKey: publicKey.trim()
});
} catch (error) {
reject(new Error(`Failed to read generated key files: ${error instanceof Error ? error.message : String(error)}`));
}
} else {
reject(new Error(`ssh-keygen failed: ${errorOutput}`));
}
});
sshKeygen.on('error', (error) => {
reject(new Error(`Failed to run ssh-keygen: ${error.message}`));
});
});
}
/**
* Get public key from private key file
* @param {string} keyPath - Path to private key file
* @returns {string} Public key content
*/
getPublicKey(keyPath) {
const publicKeyPath = keyPath + '.pub';
if (!existsSync(publicKeyPath)) {
throw new Error('Public key file not found');
}
return readFileSync(publicKeyPath, 'utf8').trim();
}
}
// Singleton instance

View File

@@ -4,13 +4,15 @@ export interface Server {
ip: string;
user: string;
password?: string;
auth_type?: 'password' | 'key' | 'both';
auth_type?: 'password' | 'key';
ssh_key?: string;
ssh_key_passphrase?: string;
ssh_key_path?: string;
key_generated?: boolean;
ssh_port?: number;
color?: string;
created_at: string;
updated_at: string;
created_at: Date | null;
updated_at: Date | null;
}
export interface CreateServerData {
@@ -18,9 +20,11 @@ export interface CreateServerData {
ip: string;
user: string;
password?: string;
auth_type?: 'password' | 'key' | 'both';
auth_type?: 'password' | 'key';
ssh_key?: string;
ssh_key_passphrase?: string;
ssh_key_path?: string;
key_generated?: boolean;
ssh_port?: number;
color?: string;
}

View File

@@ -412,6 +412,36 @@ restore_backup_files() {
fi
}
# Ensure DATABASE_URL is set in .env file for Prisma
ensure_database_url() {
log "Ensuring DATABASE_URL is set in .env file..."
# Check if .env file exists
if [ ! -f ".env" ]; then
log_warning ".env file not found, creating from .env.example..."
if [ -f ".env.example" ]; then
cp ".env.example" ".env"
else
log_error ".env.example not found, cannot create .env file"
return 1
fi
fi
# Check if DATABASE_URL is already set
if grep -q "^DATABASE_URL=" .env; then
log "DATABASE_URL already exists in .env file"
return 0
fi
# Add DATABASE_URL to .env file
log "Adding DATABASE_URL to .env file..."
echo "" >> .env
echo "# Database" >> .env
echo "DATABASE_URL=\"file:./data/database.sqlite\"" >> .env
log_success "DATABASE_URL added to .env file"
}
# Check if systemd service exists
check_service() {
# systemctl status returns 0-3 if service exists (running, exited, failed, etc.)
@@ -607,6 +637,32 @@ install_and_build() {
log_success "Dependencies installed successfully"
rm -f "$npm_log"
# Generate Prisma client
log "Generating Prisma client..."
if ! npx prisma generate > "$npm_log" 2>&1; then
log_error "Failed to generate Prisma client"
log_error "Prisma generate output:"
cat "$npm_log" | while read -r line; do
log_error "PRISMA: $line"
done
rm -f "$npm_log"
return 1
fi
log_success "Prisma client generated successfully"
# Run Prisma migrations
log "Running Prisma migrations..."
if ! npx prisma migrate deploy > "$npm_log" 2>&1; then
log_warning "Prisma migrations failed or no migrations to run"
log "Prisma migrate output:"
cat "$npm_log" | while read -r line; do
log "PRISMA: $line"
done
else
log_success "Prisma migrations completed successfully"
fi
rm -f "$npm_log"
log "Building application..."
# Set NODE_ENV to production for build
export NODE_ENV=production
@@ -838,6 +894,9 @@ main() {
# Restore .env and data directory before building
restore_backup_files
# Ensure DATABASE_URL is set for Prisma
ensure_database_url
# Install dependencies and build
if ! install_and_build; then
log_error "Install and build failed, rolling back..."