Compare commits
1 Commits
fix/some_s
...
v0.4.11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
339be31a6a |
2
.github/workflows/node.js.yml
vendored
2
.github/workflows/node.js.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [24.x]
|
||||
node-version: [22.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
|
||||
@@ -43,10 +43,6 @@ const config = {
|
||||
'http://192.168.*',
|
||||
],
|
||||
|
||||
turbopack: {
|
||||
// Disable Turbopack and use Webpack instead for compatibility
|
||||
// This is necessary for server-side code that uses child_process
|
||||
},
|
||||
webpack: (config, { dev, isServer }) => {
|
||||
if (dev && !isServer) {
|
||||
config.watchOptions = {
|
||||
@@ -54,15 +50,12 @@ const config = {
|
||||
aggregateTimeout: 300,
|
||||
};
|
||||
}
|
||||
// Handle server-side modules
|
||||
if (isServer) {
|
||||
config.externals = config.externals || [];
|
||||
if (!config.externals.includes('child_process')) {
|
||||
config.externals.push('child_process');
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
// Ignore ESLint errors during build (they can be fixed separately)
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
// Ignore TypeScript errors during build (they can be fixed separately)
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
|
||||
2770
package-lock.json
generated
2770
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
37
package.json
37
package.json
@@ -4,11 +4,11 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "next build --webpack",
|
||||
"build": "next build",
|
||||
"check": "next lint && tsc --noEmit",
|
||||
"dev": "next dev --webpack",
|
||||
"dev": "next dev",
|
||||
"dev:server": "node server.js",
|
||||
"dev:next": "next dev --webpack",
|
||||
"dev:next": "next dev",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"lint": "next lint",
|
||||
@@ -22,7 +22,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.19.0",
|
||||
"@prisma/client": "^6.18.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
@@ -43,14 +43,14 @@
|
||||
"cron-validator": "^1.2.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.554.0",
|
||||
"next": "^16.0.4",
|
||||
"node-cron": "^4.2.1",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^15.1.6",
|
||||
"node-cron": "^3.0.3",
|
||||
"node-pty": "^1.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"refractor": "^5.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"server-only": "^0.0.1",
|
||||
@@ -69,16 +69,16 @@
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/node": "^24.9.1",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/react": "^19.2.4",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"@vitest/coverage-v8": "^4.0.13",
|
||||
"@vitest/ui": "^4.0.13",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.4",
|
||||
"jsdom": "^27.2.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint-config-next": "^15.1.6",
|
||||
"jsdom": "^27.1.0",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||
@@ -86,16 +86,13 @@
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.46.2",
|
||||
"vitest": "^4.0.13"
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.39.3"
|
||||
},
|
||||
"packageManager": "npm@10.9.3",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"prismjs": "^1.30.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "backups" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"container_id" TEXT NOT NULL,
|
||||
"server_id" INTEGER NOT NULL,
|
||||
"hostname" TEXT NOT NULL,
|
||||
"backup_name" TEXT NOT NULL,
|
||||
"backup_path" TEXT NOT NULL,
|
||||
"size" BIGINT,
|
||||
"created_at" DATETIME,
|
||||
"storage_name" TEXT NOT NULL,
|
||||
"storage_type" TEXT NOT NULL,
|
||||
"discovered_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "backups_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "servers" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "pbs_storage_credentials" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"server_id" INTEGER NOT NULL,
|
||||
"storage_name" TEXT NOT NULL,
|
||||
"pbs_ip" TEXT NOT NULL,
|
||||
"pbs_datastore" TEXT NOT NULL,
|
||||
"pbs_password" TEXT NOT NULL,
|
||||
"pbs_fingerprint" TEXT NOT NULL,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "pbs_storage_credentials_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "servers" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "backups_container_id_idx" ON "backups"("container_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "backups_server_id_idx" ON "backups"("server_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "pbs_storage_credentials_server_id_idx" ON "pbs_storage_credentials"("server_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "pbs_storage_credentials_server_id_storage_name_key" ON "pbs_storage_credentials"("server_id", "storage_name");
|
||||
@@ -41,8 +41,6 @@ model Server {
|
||||
ssh_key_path String?
|
||||
key_generated Boolean? @default(false)
|
||||
installed_scripts InstalledScript[]
|
||||
backups Backup[]
|
||||
pbs_credentials PBSStorageCredential[]
|
||||
|
||||
@@map("servers")
|
||||
}
|
||||
@@ -98,42 +96,6 @@ model LXCConfig {
|
||||
@@map("lxc_configs")
|
||||
}
|
||||
|
||||
model Backup {
|
||||
id Int @id @default(autoincrement())
|
||||
container_id String
|
||||
server_id Int
|
||||
hostname String
|
||||
backup_name String
|
||||
backup_path String
|
||||
size BigInt?
|
||||
created_at DateTime?
|
||||
storage_name String
|
||||
storage_type String // 'local', 'storage', or 'pbs'
|
||||
discovered_at DateTime @default(now())
|
||||
server Server @relation(fields: [server_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([container_id])
|
||||
@@index([server_id])
|
||||
@@map("backups")
|
||||
}
|
||||
|
||||
model PBSStorageCredential {
|
||||
id Int @id @default(autoincrement())
|
||||
server_id Int
|
||||
storage_name String
|
||||
pbs_ip String
|
||||
pbs_datastore String
|
||||
pbs_password String
|
||||
pbs_fingerprint String
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
server Server @relation(fields: [server_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([server_id, storage_name])
|
||||
@@index([server_id])
|
||||
@@map("pbs_storage_credentials")
|
||||
}
|
||||
|
||||
model Repository {
|
||||
id Int @id @default(autoincrement())
|
||||
url String @unique
|
||||
|
||||
10
restore.log
10
restore.log
@@ -1,10 +0,0 @@
|
||||
Starting restore...
|
||||
Reading container configuration...
|
||||
Stopping container...
|
||||
Destroying container...
|
||||
Logging into PBS...
|
||||
Downloading backup from PBS...
|
||||
Packing backup folder...
|
||||
Restoring container...
|
||||
Cleaning up temporary files...
|
||||
Restore completed successfully
|
||||
@@ -60,7 +60,7 @@ root_check() {
|
||||
}
|
||||
|
||||
# This function checks the version of Proxmox Virtual Environment (PVE) and exits if the version is not supported.
|
||||
# Supported: Proxmox VE 8.0.x – 8.9.x, 9.0 and 9.1
|
||||
# Supported: Proxmox VE 8.0.x – 8.9.x and 9.0 (NOT 9.1+)
|
||||
pve_check() {
|
||||
local PVE_VER
|
||||
PVE_VER="$(pveversion | awk -F'/' '{print $2}' | awk -F'-' '{print $1}')"
|
||||
@@ -76,12 +76,12 @@ pve_check() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check for Proxmox VE 9.x: allow 9.0 and 9.1
|
||||
# Check for Proxmox VE 9.x: allow ONLY 9.0
|
||||
if [[ "$PVE_VER" =~ ^9\.([0-9]+) ]]; then
|
||||
local MINOR="${BASH_REMATCH[1]}"
|
||||
if ((MINOR < 0 || MINOR > 1)); then
|
||||
msg_error "This version of Proxmox VE is not supported."
|
||||
msg_error "Supported: Proxmox VE version 9.0 – 9.1"
|
||||
if ((MINOR != 0)); then
|
||||
msg_error "This version of Proxmox VE is not yet supported."
|
||||
msg_error "Supported: Proxmox VE version 9.0"
|
||||
exit 1
|
||||
fi
|
||||
return 0
|
||||
@@ -89,7 +89,7 @@ pve_check() {
|
||||
|
||||
# All other unsupported versions
|
||||
msg_error "This version of Proxmox VE is not supported."
|
||||
msg_error "Supported versions: Proxmox VE 8.0 – 8.x or 9.0 – 9.1"
|
||||
msg_error "Supported versions: Proxmox VE 8.0 – 8.x or 9.0"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -1323,9 +1323,9 @@ EOF'
|
||||
msg_ok "Customized LXC Container"
|
||||
|
||||
if [ "$var_os" == "alpine" ]; then
|
||||
FUNCTIONS_FILE_PATH="$(cat "$CORE_DIR/core.func" && echo && cat "$CORE_DIR/tools.func" && echo && cat "$CORE_DIR/api.func" && echo && cat "$CORE_DIR/alpine-install.func")"
|
||||
FUNCTIONS_FILE_PATH="$(cat "$CORE_DIR/core.func" && echo && cat "$CORE_DIR/tools.func" && echo && cat "$CORE_DIR/alpine-install.func")"
|
||||
else
|
||||
FUNCTIONS_FILE_PATH="$(cat "$CORE_DIR/core.func" && echo && cat "$CORE_DIR/tools.func" && echo && cat "$CORE_DIR/api.func" && echo && cat "$CORE_DIR/install.func")"
|
||||
FUNCTIONS_FILE_PATH="$(cat "$CORE_DIR/core.func" && echo && cat "$CORE_DIR/tools.func" && echo && cat "$CORE_DIR/install.func")"
|
||||
fi
|
||||
|
||||
FUNCTIONS_FILE="/tmp/functions.sh"
|
||||
|
||||
@@ -392,6 +392,8 @@ cleanup_lxc() {
|
||||
|
||||
# Python pip
|
||||
if command -v pip &>/dev/null; then $STD pip cache purge || true; fi
|
||||
# Python uv
|
||||
if command -v uv &>/dev/null; then $STD uv cache clear || true; fi
|
||||
# Node.js npm
|
||||
if command -v npm &>/dev/null; then $STD npm cache clean --force || true; fi
|
||||
# Node.js yarn
|
||||
@@ -408,6 +410,7 @@ cleanup_lxc() {
|
||||
if command -v composer &>/dev/null; then $STD composer clear-cache || true; fi
|
||||
|
||||
if command -v journalctl &>/dev/null; then
|
||||
$STD journalctl --rotate || true
|
||||
$STD journalctl --vacuum-time=10m || true
|
||||
fi
|
||||
msg_ok "Cleaned"
|
||||
|
||||
272
server.js
272
server.js
@@ -79,27 +79,14 @@ class ScriptExecutionHandler {
|
||||
* @param {import('http').Server} server
|
||||
*/
|
||||
constructor(server) {
|
||||
// Create WebSocketServer without attaching to server
|
||||
// We'll handle upgrades manually to avoid interfering with Next.js HMR
|
||||
this.wss = new WebSocketServer({
|
||||
noServer: true
|
||||
server,
|
||||
path: '/ws/script-execution'
|
||||
});
|
||||
this.activeExecutions = new Map();
|
||||
this.db = getDatabase();
|
||||
this.setupWebSocket();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebSocket upgrade for our endpoint
|
||||
* @param {import('http').IncomingMessage} request
|
||||
* @param {import('stream').Duplex} socket
|
||||
* @param {Buffer} head
|
||||
*/
|
||||
handleUpgrade(request, socket, head) {
|
||||
this.wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
this.wss.emit('connection', ws, request);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Container ID from terminal output
|
||||
@@ -289,15 +276,13 @@ class ScriptExecutionHandler {
|
||||
* @param {WebSocketMessage} message
|
||||
*/
|
||||
async handleMessage(ws, message) {
|
||||
const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, isBackup, containerId, storage, backupStorage } = message;
|
||||
const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, containerId } = message;
|
||||
|
||||
switch (action) {
|
||||
case 'start':
|
||||
if (scriptPath && executionId) {
|
||||
if (isBackup && containerId && storage) {
|
||||
await this.startBackupExecution(ws, containerId, executionId, storage, mode, server);
|
||||
} else if (isUpdate && containerId) {
|
||||
await this.startUpdateExecution(ws, containerId, executionId, mode, server, backupStorage);
|
||||
if (isUpdate && containerId) {
|
||||
await this.startUpdateExecution(ws, containerId, executionId, mode, server);
|
||||
} else if (isShell && containerId) {
|
||||
await this.startShellExecution(ws, containerId, executionId, mode, server);
|
||||
} else {
|
||||
@@ -675,157 +660,6 @@ class ScriptExecutionHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start backup execution
|
||||
* @param {ExtendedWebSocket} ws
|
||||
* @param {string} containerId
|
||||
* @param {string} executionId
|
||||
* @param {string} storage
|
||||
* @param {string} mode
|
||||
* @param {ServerInfo|null} server
|
||||
*/
|
||||
async startBackupExecution(ws, containerId, executionId, storage, mode = 'local', server = null) {
|
||||
try {
|
||||
// Send start message
|
||||
this.sendMessage(ws, {
|
||||
type: 'start',
|
||||
data: `Starting backup for container ${containerId} to storage ${storage}...`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
if (mode === 'ssh' && server) {
|
||||
await this.startSSHBackupExecution(ws, containerId, executionId, storage, server);
|
||||
} else {
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: 'Backup is only supported via SSH',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: `Failed to start backup: ${error instanceof Error ? error.message : String(error)}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start SSH backup execution
|
||||
* @param {ExtendedWebSocket} ws
|
||||
* @param {string} containerId
|
||||
* @param {string} executionId
|
||||
* @param {string} storage
|
||||
* @param {ServerInfo} server
|
||||
* @param {Function} [onComplete] - Optional callback when backup completes
|
||||
*/
|
||||
startSSHBackupExecution(ws, containerId, executionId, storage, server, onComplete = null) {
|
||||
const sshService = getSSHExecutionService();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const backupCommand = `vzdump ${containerId} --storage ${storage} --mode snapshot`;
|
||||
|
||||
// Wrap the onExit callback to resolve our promise
|
||||
let promiseResolved = false;
|
||||
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
backupCommand,
|
||||
/** @param {string} data */
|
||||
(data) => {
|
||||
this.sendMessage(ws, {
|
||||
type: 'output',
|
||||
data: data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
},
|
||||
/** @param {string} error */
|
||||
(error) => {
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: error,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
},
|
||||
/** @param {number} code */
|
||||
(code) => {
|
||||
// Don't send 'end' message here if this is part of a backup+update flow
|
||||
// The update flow will handle completion messages
|
||||
const success = code === 0;
|
||||
|
||||
if (!success) {
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: `Backup failed with exit code: ${code}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
// Send a completion message (but not 'end' type to avoid stopping terminal)
|
||||
this.sendMessage(ws, {
|
||||
type: 'output',
|
||||
data: `\n[Backup ${success ? 'completed' : 'failed'} with exit code: ${code}]\n`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
if (onComplete) onComplete(success);
|
||||
|
||||
// Resolve the promise when backup completes
|
||||
// Use setImmediate to ensure resolution happens in the right execution context
|
||||
if (!promiseResolved) {
|
||||
promiseResolved = true;
|
||||
const result = { success, code };
|
||||
|
||||
// Use setImmediate to ensure promise resolution happens in the next tick
|
||||
// This ensures the await in startUpdateExecution can properly resume
|
||||
setImmediate(() => {
|
||||
try {
|
||||
resolve(result);
|
||||
} catch (resolveError) {
|
||||
console.error('Error resolving backup promise:', resolveError);
|
||||
reject(resolveError);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.activeExecutions.delete(executionId);
|
||||
}
|
||||
).then((execution) => {
|
||||
// Store the execution
|
||||
this.activeExecutions.set(executionId, {
|
||||
process: /** @type {any} */ (execution).process,
|
||||
ws
|
||||
});
|
||||
// Note: Don't resolve here - wait for onExit callback
|
||||
}).catch((error) => {
|
||||
console.error('Error starting backup execution:', error);
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: `SSH backup execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
if (onComplete) onComplete(false);
|
||||
if (!promiseResolved) {
|
||||
promiseResolved = true;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in startSSHBackupExecution:', error);
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: `SSH backup execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
if (onComplete) onComplete(false);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start update execution (pct enter + update command)
|
||||
* @param {ExtendedWebSocket} ws
|
||||
@@ -833,62 +667,11 @@ class ScriptExecutionHandler {
|
||||
* @param {string} executionId
|
||||
* @param {string} mode
|
||||
* @param {ServerInfo|null} server
|
||||
* @param {string} [backupStorage] - Optional storage to backup to before update
|
||||
*/
|
||||
async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = null, backupStorage = null) {
|
||||
async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = null) {
|
||||
try {
|
||||
// If backup storage is provided, run backup first
|
||||
if (backupStorage && mode === 'ssh' && server) {
|
||||
this.sendMessage(ws, {
|
||||
type: 'start',
|
||||
data: `Starting backup before update for container ${containerId}...`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Create a separate execution ID for backup
|
||||
const backupExecutionId = `backup_${executionId}`;
|
||||
|
||||
// Run backup and wait for it to complete
|
||||
try {
|
||||
const backupResult = await this.startSSHBackupExecution(
|
||||
ws,
|
||||
containerId,
|
||||
backupExecutionId,
|
||||
backupStorage,
|
||||
server
|
||||
);
|
||||
|
||||
// Backup completed (successfully or not)
|
||||
if (!backupResult || !backupResult.success) {
|
||||
// Backup failed, but we'll still allow update (per requirement 1b)
|
||||
this.sendMessage(ws, {
|
||||
type: 'output',
|
||||
data: '\n⚠️ Backup failed, but proceeding with update as requested...\n',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} else {
|
||||
// Backup succeeded
|
||||
this.sendMessage(ws, {
|
||||
type: 'output',
|
||||
data: '\n✅ Backup completed successfully. Starting update...\n',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Backup error before update:', error);
|
||||
// Backup failed to start, but allow update to proceed
|
||||
this.sendMessage(ws, {
|
||||
type: 'output',
|
||||
data: `\n⚠️ Backup error: ${error instanceof Error ? error.message : String(error)}. Proceeding with update...\n`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
// Small delay before starting update
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
// Send start message for update (only if we're actually starting an update)
|
||||
// Send start message
|
||||
this.sendMessage(ws, {
|
||||
type: 'start',
|
||||
data: `Starting update for container ${containerId}...`,
|
||||
@@ -1172,22 +955,12 @@ app.prepare().then(() => {
|
||||
const parsedUrl = parse(req.url || '', true);
|
||||
const { pathname, query } = parsedUrl;
|
||||
|
||||
// Check if this is a WebSocket upgrade request
|
||||
const isWebSocketUpgrade = req.headers.upgrade === 'websocket';
|
||||
|
||||
// Only intercept WebSocket upgrades for /ws/script-execution
|
||||
// Let Next.js handle all other WebSocket upgrades (like HMR) and all HTTP requests
|
||||
if (isWebSocketUpgrade && pathname === '/ws/script-execution') {
|
||||
if (pathname === '/ws/script-execution') {
|
||||
// WebSocket upgrade will be handled by the WebSocket server
|
||||
// Don't call handle() for this path - let WebSocketServer handle it
|
||||
return;
|
||||
}
|
||||
|
||||
// Let Next.js handle all other requests including:
|
||||
// - HTTP requests to /ws/script-execution (non-WebSocket)
|
||||
// - WebSocket upgrades to other paths (like /_next/webpack-hmr)
|
||||
// - All static assets (_next routes)
|
||||
// - All other routes
|
||||
// Let Next.js handle all other requests including HMR
|
||||
await handle(req, res, parsedUrl);
|
||||
} catch (err) {
|
||||
console.error('Error occurred handling', req.url, err);
|
||||
@@ -1198,33 +971,6 @@ app.prepare().then(() => {
|
||||
|
||||
// Create WebSocket handlers
|
||||
const scriptHandler = new ScriptExecutionHandler(httpServer);
|
||||
|
||||
// Handle WebSocket upgrades manually to avoid interfering with Next.js HMR
|
||||
// We need to preserve Next.js's upgrade handlers and call them for non-matching paths
|
||||
// Save any existing upgrade listeners (Next.js might have set them up)
|
||||
const existingUpgradeListeners = httpServer.listeners('upgrade').slice();
|
||||
httpServer.removeAllListeners('upgrade');
|
||||
|
||||
// Add our upgrade handler that routes based on path
|
||||
httpServer.on('upgrade', (request, socket, head) => {
|
||||
const parsedUrl = parse(request.url || '', true);
|
||||
const { pathname } = parsedUrl;
|
||||
|
||||
if (pathname === '/ws/script-execution') {
|
||||
// Handle our custom WebSocket endpoint
|
||||
scriptHandler.handleUpgrade(request, socket, head);
|
||||
} else {
|
||||
// For all other paths (including Next.js HMR), call existing listeners
|
||||
// This allows Next.js to handle its own WebSocket upgrades
|
||||
for (const listener of existingUpgradeListeners) {
|
||||
try {
|
||||
listener.call(httpServer, request, socket, head);
|
||||
} catch (err) {
|
||||
console.error('Error in upgrade listener:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Note: TerminalHandler removed as it's not being used by the current application
|
||||
|
||||
httpServer
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from './ui/button';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
|
||||
interface BackupWarningModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onProceed: () => void;
|
||||
}
|
||||
|
||||
export function BackupWarningModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onProceed
|
||||
}: BackupWarningModalProps) {
|
||||
useRegisterModal(isOpen, { id: 'backup-warning-modal', allowEscape: true, onClose });
|
||||
|
||||
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">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-center p-6 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="h-8 w-8 text-warning" />
|
||||
<h2 className="text-2xl font-bold text-card-foreground">Backup Failed</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
The backup failed, but you can still proceed with the update if you wish.
|
||||
<br /><br />
|
||||
<strong className="text-foreground">Warning:</strong> Proceeding without a backup means you won't be able to restore the container if something goes wrong during the update.
|
||||
</p>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row justify-end gap-3">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onProceed}
|
||||
variant="default"
|
||||
size="default"
|
||||
className="w-full sm:w-auto bg-warning hover:bg-warning/90"
|
||||
>
|
||||
Proceed Anyway
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,503 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { RefreshCw, ChevronDown, ChevronRight, HardDrive, Database, Server, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from './ui/dropdown-menu';
|
||||
import { ConfirmationModal } from './ConfirmationModal';
|
||||
import { LoadingModal } from './LoadingModal';
|
||||
|
||||
interface Backup {
|
||||
id: number;
|
||||
backup_name: string;
|
||||
backup_path: string;
|
||||
size: bigint | null;
|
||||
created_at: Date | null;
|
||||
storage_name: string;
|
||||
storage_type: string;
|
||||
discovered_at: Date;
|
||||
server_id: number;
|
||||
server_name: string | null;
|
||||
server_color: string | null;
|
||||
}
|
||||
|
||||
interface ContainerBackups {
|
||||
container_id: string;
|
||||
hostname: string;
|
||||
backups: Backup[];
|
||||
}
|
||||
|
||||
export function BackupsTab() {
|
||||
const [expandedContainers, setExpandedContainers] = useState<Set<string>>(new Set());
|
||||
const [hasAutoDiscovered, setHasAutoDiscovered] = useState(false);
|
||||
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false);
|
||||
const [selectedBackup, setSelectedBackup] = useState<{ backup: Backup; containerId: string } | null>(null);
|
||||
const [restoreProgress, setRestoreProgress] = useState<string[]>([]);
|
||||
const [restoreSuccess, setRestoreSuccess] = useState(false);
|
||||
const [restoreError, setRestoreError] = useState<string | null>(null);
|
||||
const [shouldPollRestore, setShouldPollRestore] = useState(false);
|
||||
|
||||
const { data: backupsData, refetch: refetchBackups, isLoading } = api.backups.getAllBackupsGrouped.useQuery();
|
||||
const discoverMutation = api.backups.discoverBackups.useMutation({
|
||||
onSuccess: () => {
|
||||
void refetchBackups();
|
||||
},
|
||||
});
|
||||
|
||||
// Poll for restore progress
|
||||
const { data: restoreLogsData } = api.backups.getRestoreProgress.useQuery(undefined, {
|
||||
enabled: shouldPollRestore,
|
||||
refetchInterval: 1000, // Poll every second
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
// Update restore progress when log data changes
|
||||
useEffect(() => {
|
||||
if (restoreLogsData?.success && restoreLogsData.logs) {
|
||||
setRestoreProgress(restoreLogsData.logs);
|
||||
|
||||
// Stop polling when restore is complete
|
||||
if (restoreLogsData.isComplete) {
|
||||
setShouldPollRestore(false);
|
||||
// Check if restore was successful or failed
|
||||
const lastLog = restoreLogsData.logs[restoreLogsData.logs.length - 1] || '';
|
||||
if (lastLog.includes('Restore completed successfully')) {
|
||||
setRestoreSuccess(true);
|
||||
setRestoreError(null);
|
||||
} else if (lastLog.includes('Error:') || lastLog.includes('failed')) {
|
||||
setRestoreError(lastLog);
|
||||
setRestoreSuccess(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [restoreLogsData]);
|
||||
|
||||
const restoreMutation = api.backups.restoreBackup.useMutation({
|
||||
onMutate: () => {
|
||||
// Start polling for progress
|
||||
setShouldPollRestore(true);
|
||||
setRestoreProgress(['Starting restore...']);
|
||||
setRestoreError(null);
|
||||
setRestoreSuccess(false);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
// Stop polling - progress will be updated from logs
|
||||
setShouldPollRestore(false);
|
||||
|
||||
if (result.success) {
|
||||
// Update progress with all messages from backend (fallback if polling didn't work)
|
||||
const progressMessages = restoreProgress.length > 0 ? restoreProgress : (result.progress?.map(p => p.message) || ['Restore completed successfully']);
|
||||
setRestoreProgress(progressMessages);
|
||||
setRestoreSuccess(true);
|
||||
setRestoreError(null);
|
||||
setRestoreConfirmOpen(false);
|
||||
setSelectedBackup(null);
|
||||
// Keep success message visible - user can dismiss manually
|
||||
} else {
|
||||
setRestoreError(result.error || 'Restore failed');
|
||||
setRestoreProgress(result.progress?.map(p => p.message) || restoreProgress);
|
||||
setRestoreSuccess(false);
|
||||
setRestoreConfirmOpen(false);
|
||||
setSelectedBackup(null);
|
||||
// Keep error message visible - user can dismiss manually
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
// Stop polling on error
|
||||
setShouldPollRestore(false);
|
||||
setRestoreError(error.message || 'Restore failed');
|
||||
setRestoreConfirmOpen(false);
|
||||
setSelectedBackup(null);
|
||||
setRestoreProgress([]);
|
||||
},
|
||||
});
|
||||
|
||||
// Update progress text in modal based on current progress
|
||||
const currentProgressText = restoreProgress.length > 0
|
||||
? restoreProgress[restoreProgress.length - 1]
|
||||
: 'Restoring backup...';
|
||||
|
||||
// Auto-discover backups when tab is first opened
|
||||
useEffect(() => {
|
||||
if (!hasAutoDiscovered && !isLoading && backupsData) {
|
||||
// Only auto-discover if there are no backups yet
|
||||
if (!backupsData.backups || backupsData.backups.length === 0) {
|
||||
handleDiscoverBackups();
|
||||
}
|
||||
setHasAutoDiscovered(true);
|
||||
}
|
||||
}, [hasAutoDiscovered, isLoading, backupsData]);
|
||||
|
||||
const handleDiscoverBackups = () => {
|
||||
discoverMutation.mutate();
|
||||
};
|
||||
|
||||
const handleRestoreClick = (backup: Backup, containerId: string) => {
|
||||
setSelectedBackup({ backup, containerId });
|
||||
setRestoreConfirmOpen(true);
|
||||
setRestoreError(null);
|
||||
setRestoreSuccess(false);
|
||||
setRestoreProgress([]);
|
||||
};
|
||||
|
||||
const handleRestoreConfirm = () => {
|
||||
if (!selectedBackup) return;
|
||||
|
||||
setRestoreConfirmOpen(false);
|
||||
setRestoreError(null);
|
||||
setRestoreSuccess(false);
|
||||
|
||||
restoreMutation.mutate({
|
||||
backupId: selectedBackup.backup.id,
|
||||
containerId: selectedBackup.containerId,
|
||||
serverId: selectedBackup.backup.server_id,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleContainer = (containerId: string) => {
|
||||
const newExpanded = new Set(expandedContainers);
|
||||
if (newExpanded.has(containerId)) {
|
||||
newExpanded.delete(containerId);
|
||||
} else {
|
||||
newExpanded.add(containerId);
|
||||
}
|
||||
setExpandedContainers(newExpanded);
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: bigint | null): string => {
|
||||
if (!bytes) return 'Unknown size';
|
||||
const b = Number(bytes);
|
||||
if (b === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(b) / Math.log(k));
|
||||
return `${(b / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const formatDate = (date: Date | null): string => {
|
||||
if (!date) return 'Unknown date';
|
||||
return new Date(date).toLocaleString();
|
||||
};
|
||||
|
||||
const getStorageTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'pbs':
|
||||
return <Database className="h-4 w-4" />;
|
||||
case 'local':
|
||||
return <HardDrive className="h-4 w-4" />;
|
||||
default:
|
||||
return <Server className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStorageTypeBadgeVariant = (type: string): 'default' | 'secondary' | 'outline' => {
|
||||
switch (type) {
|
||||
case 'pbs':
|
||||
return 'default';
|
||||
case 'local':
|
||||
return 'secondary';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
const backups = backupsData?.success ? backupsData.backups : [];
|
||||
const isDiscovering = discoverMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with refresh button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-foreground">Backups</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Discovered backups grouped by container ID
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleDiscoverBackups}
|
||||
disabled={isDiscovering}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isDiscovering ? 'animate-spin' : ''}`} />
|
||||
{isDiscovering ? 'Discovering...' : 'Discover Backups'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{(isLoading || isDiscovering) && backups.length === 0 && (
|
||||
<div className="bg-card rounded-lg border border-border p-8 text-center">
|
||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">
|
||||
{isDiscovering ? 'Discovering backups...' : 'Loading backups...'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && !isDiscovering && backups.length === 0 && (
|
||||
<div className="bg-card rounded-lg border border-border p-8 text-center">
|
||||
<HardDrive className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">No backups found</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Click "Discover Backups" to scan for backups on your servers.
|
||||
</p>
|
||||
<Button onClick={handleDiscoverBackups} disabled={isDiscovering}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isDiscovering ? 'animate-spin' : ''}`} />
|
||||
Discover Backups
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backups list */}
|
||||
{!isLoading && backups.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{backups.map((container: ContainerBackups) => {
|
||||
const isExpanded = expandedContainers.has(container.container_id);
|
||||
const backupCount = container.backups.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={container.container_id}
|
||||
className="bg-card rounded-lg border border-border shadow-sm overflow-hidden"
|
||||
>
|
||||
{/* Container header - collapsible */}
|
||||
<button
|
||||
onClick={() => toggleContainer(container.container_id)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-accent/50 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-foreground">
|
||||
CT {container.container_id}
|
||||
</span>
|
||||
{container.hostname && (
|
||||
<>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span className="text-muted-foreground">{container.hostname}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{backupCount} {backupCount === 1 ? 'backup' : 'backups'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Container content - backups list */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border">
|
||||
<div className="p-4 space-y-3">
|
||||
{container.backups.map((backup) => (
|
||||
<div
|
||||
key={backup.id}
|
||||
className="bg-muted/50 rounded-lg p-4 border border-border/50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="font-medium text-foreground break-all">
|
||||
{backup.backup_name}
|
||||
</span>
|
||||
<Badge
|
||||
variant={getStorageTypeBadgeVariant(backup.storage_type)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{getStorageTypeIcon(backup.storage_type)}
|
||||
{backup.storage_name}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
||||
{backup.size && (
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
{formatFileSize(backup.size)}
|
||||
</span>
|
||||
)}
|
||||
{backup.created_at && (
|
||||
<span>{formatDate(backup.created_at)}</span>
|
||||
)}
|
||||
{backup.server_name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Server className="h-3 w-3" />
|
||||
{backup.server_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<code className="text-xs text-muted-foreground break-all">
|
||||
{backup.backup_path}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-muted/20 hover:bg-muted/30 border border-muted text-muted-foreground hover:text-foreground hover:border-muted-foreground transition-all duration-200 hover:scale-105 hover:shadow-md"
|
||||
>
|
||||
Actions
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48 bg-card border-border">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleRestoreClick(backup, container.container_id)}
|
||||
disabled={restoreMutation.isPending}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
|
||||
>
|
||||
Restore
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled
|
||||
className="text-muted-foreground opacity-50"
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{backupsData && !backupsData.success && (
|
||||
<div className="bg-destructive/10 border border-destructive rounded-lg p-4">
|
||||
<p className="text-destructive">
|
||||
Error loading backups: {backupsData.error || 'Unknown error'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restore Confirmation Modal */}
|
||||
{selectedBackup && (
|
||||
<ConfirmationModal
|
||||
isOpen={restoreConfirmOpen}
|
||||
onClose={() => {
|
||||
setRestoreConfirmOpen(false);
|
||||
setSelectedBackup(null);
|
||||
}}
|
||||
onConfirm={handleRestoreConfirm}
|
||||
title="Restore Backup"
|
||||
message={`This will destroy the existing container and restore from backup. The container will be stopped during restore. This action cannot be undone and may result in data loss.`}
|
||||
variant="danger"
|
||||
confirmText={selectedBackup.containerId}
|
||||
confirmButtonText="Restore"
|
||||
cancelButtonText="Cancel"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Restore Progress Modal */}
|
||||
{(restoreMutation.isPending || (restoreSuccess && restoreProgress.length > 0)) && (
|
||||
<LoadingModal
|
||||
isOpen={true}
|
||||
action={currentProgressText}
|
||||
logs={restoreProgress}
|
||||
isComplete={restoreSuccess}
|
||||
title="Restore in progress"
|
||||
onClose={() => {
|
||||
setRestoreSuccess(false);
|
||||
setRestoreProgress([]);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Restore Success */}
|
||||
{restoreSuccess && (
|
||||
<div className="bg-success/10 border border-success/20 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-success" />
|
||||
<span className="font-medium text-success">Restore Completed Successfully</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRestoreSuccess(false);
|
||||
setRestoreProgress([]);
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The container has been restored from backup.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restore Error */}
|
||||
{restoreError && (
|
||||
<div className="bg-error/10 border border-error/20 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-error" />
|
||||
<span className="font-medium text-error">Restore Failed</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRestoreError(null);
|
||||
setRestoreProgress([]);
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{restoreError}
|
||||
</p>
|
||||
{restoreProgress.length > 0 && (
|
||||
<div className="space-y-1 mt-2">
|
||||
{restoreProgress.map((message, index) => (
|
||||
<p key={index} className="text-sm text-muted-foreground">
|
||||
{message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setRestoreError(null);
|
||||
setRestoreProgress([]);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -187,10 +187,9 @@ export function CategorySidebar({
|
||||
'Miscellaneous': 'box'
|
||||
};
|
||||
|
||||
// Filter categories to only show those with scripts, then sort by count (descending) and alphabetically
|
||||
// Sort categories by count (descending) and then alphabetically
|
||||
const sortedCategories = categories
|
||||
.map(category => [category, categoryCounts[category] ?? 0] as const)
|
||||
.filter(([, count]) => count > 0) // Only show categories with at least one script
|
||||
.sort(([a, countA], [b, countB]) => {
|
||||
if (countB !== countA) return countB - countA;
|
||||
return a.localeCompare(b);
|
||||
|
||||
@@ -10,7 +10,6 @@ import { FilterBar, type FilterState } from './FilterBar';
|
||||
import { ViewToggle } from './ViewToggle';
|
||||
import { Button } from './ui/button';
|
||||
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
||||
import { getDefaultFilters, mergeFiltersWithDefaults } from './filterUtils';
|
||||
|
||||
interface DownloadedScriptsTabProps {
|
||||
onInstallScript?: (
|
||||
@@ -26,7 +25,14 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
||||
const [filters, setFilters] = useState<FilterState>(getDefaultFilters());
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
searchQuery: '',
|
||||
showUpdatable: null,
|
||||
selectedTypes: [],
|
||||
selectedRepositories: [],
|
||||
sortBy: 'name',
|
||||
sortOrder: 'asc',
|
||||
});
|
||||
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
|
||||
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
@@ -57,7 +63,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
if (filtersResponse.ok) {
|
||||
const filtersData = await filtersResponse.json();
|
||||
if (filtersData.filters) {
|
||||
setFilters(mergeFiltersWithDefaults(filtersData.filters));
|
||||
setFilters(filtersData.filters as FilterState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Button } from "./ui/button";
|
||||
import { ContextualHelpIcon } from "./ContextualHelpIcon";
|
||||
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter, GitBranch } from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { getDefaultFilters } from "./filterUtils";
|
||||
|
||||
export interface FilterState {
|
||||
searchQuery: string;
|
||||
@@ -68,7 +67,14 @@ export function FilterBar({
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
onFiltersChange(getDefaultFilters());
|
||||
onFiltersChange({
|
||||
searchQuery: "",
|
||||
showUpdatable: null,
|
||||
selectedTypes: [],
|
||||
selectedRepositories: [],
|
||||
sortBy: "name",
|
||||
sortOrder: "asc",
|
||||
});
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { HelpCircle, Server, Settings, RefreshCw, Clock, Package, HardDrive, FolderOpen, Search, Download, Lock, GitBranch, Archive } from 'lucide-react';
|
||||
import { HelpCircle, Server, Settings, RefreshCw, Clock, Package, HardDrive, FolderOpen, Search, Download, Lock, GitBranch } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
|
||||
interface HelpModalProps {
|
||||
@@ -11,7 +11,7 @@ interface HelpModalProps {
|
||||
initialSection?: string;
|
||||
}
|
||||
|
||||
type HelpSection = 'server-settings' | 'general-settings' | 'auth-settings' | 'sync-button' | 'auto-sync' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system' | 'repositories' | 'backups';
|
||||
type HelpSection = 'server-settings' | 'general-settings' | 'auth-settings' | 'sync-button' | 'auto-sync' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system' | 'repositories';
|
||||
|
||||
export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) {
|
||||
useRegisterModal(isOpen, { id: 'help-modal', allowEscape: true, onClose });
|
||||
@@ -30,7 +30,6 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
|
||||
{ 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: 'backups' as HelpSection, label: 'LXC Backups', icon: Archive },
|
||||
{ id: 'update-system' as HelpSection, label: 'Update System', icon: Download },
|
||||
];
|
||||
|
||||
@@ -926,144 +925,6 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'backups':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-4">LXC Backups</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Create backups of your LXC containers before updates or on-demand. Backups are created using Proxmox VE's built-in backup system and can be stored on any backup-capable storage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-border rounded-lg bg-primary/10 border-primary/20">
|
||||
<h4 className="font-medium text-foreground mb-2">Overview</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
The backup feature allows you to create snapshots of your LXC containers before performing updates or at any time. Backups are created using the <code className="bg-muted px-1 rounded">vzdump</code> command via SSH and stored on your configured Proxmox storage.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• <strong>Pre-Update Backups:</strong> Automatically create backups before updating containers</li>
|
||||
<li>• <strong>Standalone Backups:</strong> Create backups on-demand from the Actions menu</li>
|
||||
<li>• <strong>Storage Selection:</strong> Choose from available backup-capable storages</li>
|
||||
<li>• <strong>Real-Time Progress:</strong> View backup progress in the terminal output</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Backup Before Update</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
When updating an LXC container, you can choose to create a backup first:
|
||||
</p>
|
||||
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
|
||||
<li>Click the "Update" button for an installed script</li>
|
||||
<li>Confirm that you want to update the container</li>
|
||||
<li>Choose whether to create a backup before updating</li>
|
||||
<li>If yes, select a backup-capable storage from the list</li>
|
||||
<li>The backup will be created, then the update will proceed automatically</li>
|
||||
</ol>
|
||||
<div className="mt-3 p-3 bg-info/10 rounded-md">
|
||||
<h5 className="font-medium text-info-foreground mb-2">Backup Failure Handling</h5>
|
||||
<p className="text-xs text-info/80">
|
||||
If a backup fails, you'll be warned but can still choose to proceed with the update. This ensures updates aren't blocked by backup issues.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Standalone Backup</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Create a backup at any time without updating:
|
||||
</p>
|
||||
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
|
||||
<li>Open the Actions dropdown menu for an installed script</li>
|
||||
<li>Click "Backup"</li>
|
||||
<li>Select a backup-capable storage from the list</li>
|
||||
<li>Watch the backup progress in the terminal output</li>
|
||||
</ol>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
<strong>Note:</strong> Standalone backups are only available for SSH-enabled scripts with valid container IDs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Storage Selection</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
The system automatically discovers backup-capable storages from your Proxmox servers:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Automatic Discovery:</strong> Storages are fetched from <code className="bg-muted px-1 rounded">/etc/pve/storage.cfg</code> on each server</li>
|
||||
<li>• <strong>Backup-Capable Only:</strong> Only storages with "backup" in their content are shown</li>
|
||||
<li>• <strong>Cached Results:</strong> Storage lists are cached for 1 hour to improve performance</li>
|
||||
<li>• <strong>Manual Refresh:</strong> Use the "Fetch Storages" button to refresh the list if needed</li>
|
||||
</ul>
|
||||
<div className="mt-3 p-3 bg-muted/30 rounded-md">
|
||||
<h5 className="font-medium text-foreground mb-1">Storage Types</h5>
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
<li>• <strong>Local:</strong> Backups stored on the Proxmox host</li>
|
||||
<li>• <strong>Storage:</strong> Network-attached storage (NFS, CIFS, etc.)</li>
|
||||
<li>• <strong>PBS:</strong> Proxmox Backup Server storage</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Viewing Available Storages</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
You can view all storages for a server, including which ones support backups:
|
||||
</p>
|
||||
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
|
||||
<li>Go to the Server Settings section</li>
|
||||
<li>Find the server you want to check</li>
|
||||
<li>Click the "View Storages" button (database icon)</li>
|
||||
<li>See all storages with backup-capable ones highlighted</li>
|
||||
</ol>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
This helps you identify which storages are available for backups before starting a backup operation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Backup Process</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
When a backup is initiated, the following happens:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>SSH Connection:</strong> Connects to the Proxmox server via SSH</li>
|
||||
<li>• <strong>Command Execution:</strong> Runs <code className="bg-muted px-1 rounded">vzdump <CTID> --storage <STORAGE> --mode snapshot</code></li>
|
||||
<li>• <strong>Real-Time Output:</strong> Backup progress is streamed to the terminal</li>
|
||||
<li>• <strong>Completion:</strong> Backup completes and shows success/failure status</li>
|
||||
<li>• <strong>Sequential Execution:</strong> If part of update flow, update proceeds after backup completes</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg bg-warning/10 border-warning/20">
|
||||
<h4 className="font-medium text-warning-foreground mb-2">⚠️ Important Notes</h4>
|
||||
<ul className="text-sm text-warning/80 space-y-2">
|
||||
<li>• <strong>Storage Requirements:</strong> Ensure you have sufficient storage space for backups</li>
|
||||
<li>• <strong>Backup Duration:</strong> Backup time depends on container size and storage speed</li>
|
||||
<li>• <strong>Snapshot Mode:</strong> Backups use snapshot mode, which requires sufficient disk space</li>
|
||||
<li>• <strong>SSH Access:</strong> Backups require valid SSH credentials configured for the server</li>
|
||||
<li>• <strong>Container State:</strong> Containers can be running or stopped during backup</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Backup Storage Cache</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Storage information is cached to improve performance:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• <strong>Cache Duration:</strong> Storage lists are cached for 1 hour</li>
|
||||
<li>• <strong>Automatic Refresh:</strong> Cache expires and refreshes automatically</li>
|
||||
<li>• <strong>Manual Refresh:</strong> Use "Fetch Storages" button to force refresh</li>
|
||||
<li>• <strong>Per-Server Cache:</strong> Each server has its own cached storage list</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -10,9 +10,6 @@ import { ConfirmationModal } from './ConfirmationModal';
|
||||
import { ErrorModal } from './ErrorModal';
|
||||
import { LoadingModal } from './LoadingModal';
|
||||
import { LXCSettingsModal } from './LXCSettingsModal';
|
||||
import { StorageSelectionModal } from './StorageSelectionModal';
|
||||
import { BackupWarningModal } from './BackupWarningModal';
|
||||
import type { Storage } from '~/server/services/storageService';
|
||||
import { getContrastColor } from '../../lib/colorUtils';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -53,15 +50,8 @@ export function InstalledScriptsTab() {
|
||||
const [serverFilter, setServerFilter] = useState<string>('all');
|
||||
const [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('server_name');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any; backupStorage?: string; isBackupOnly?: boolean } | null>(null);
|
||||
const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null);
|
||||
const [openingShell, setOpeningShell] = useState<{ id: number; containerId: string; server?: any } | null>(null);
|
||||
const [showBackupPrompt, setShowBackupPrompt] = useState(false);
|
||||
const [showStorageSelection, setShowStorageSelection] = useState(false);
|
||||
const [pendingUpdateScript, setPendingUpdateScript] = useState<InstalledScript | null>(null);
|
||||
const [backupStorages, setBackupStorages] = useState<Storage[]>([]);
|
||||
const [isLoadingStorages, setIsLoadingStorages] = useState(false);
|
||||
const [showBackupWarning, setShowBackupWarning] = useState(false);
|
||||
const [isPreUpdateBackup, setIsPreUpdateBackup] = useState(false); // Track if storage selection is for pre-update backup
|
||||
const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
|
||||
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string; web_ui_ip: string; web_ui_port: string }>({ script_name: '', container_id: '', web_ui_ip: '', web_ui_port: '' });
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
@@ -254,54 +244,22 @@ export function InstalledScriptsTab() {
|
||||
void refetchScripts();
|
||||
setAutoDetectStatus({
|
||||
type: 'success',
|
||||
message: data.success ? `Detected IP: ${data.ip}` : (data.error ?? 'Failed to detect Web UI')
|
||||
message: data.message ?? 'Web UI IP detected successfully!'
|
||||
});
|
||||
// Clear status after 5 seconds
|
||||
setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('❌ Auto-detect WebUI error:', error);
|
||||
console.error('❌ Auto-detect Web UI error:', error);
|
||||
setAutoDetectStatus({
|
||||
type: 'error',
|
||||
message: error.message ?? 'Failed to detect Web UI'
|
||||
message: error.message ?? 'Auto-detect failed. Please try again.'
|
||||
});
|
||||
setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 8000);
|
||||
// Clear status after 5 seconds
|
||||
setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000);
|
||||
}
|
||||
});
|
||||
|
||||
// Get backup storages query
|
||||
const getBackupStoragesQuery = api.installedScripts.getBackupStorages.useQuery(
|
||||
{ serverId: pendingUpdateScript?.server_id ?? 0, forceRefresh: false },
|
||||
{ enabled: false } // Only fetch when explicitly called
|
||||
);
|
||||
|
||||
const fetchStorages = async (serverId: number, forceRefresh = false) => {
|
||||
setIsLoadingStorages(true);
|
||||
try {
|
||||
const result = await getBackupStoragesQuery.refetch({
|
||||
queryKey: ['installedScripts.getBackupStorages', { serverId, forceRefresh }]
|
||||
});
|
||||
if (result.data?.success) {
|
||||
setBackupStorages(result.data.storages);
|
||||
} else {
|
||||
setErrorModal({
|
||||
isOpen: true,
|
||||
title: 'Failed to Fetch Storages',
|
||||
message: result.data?.error ?? 'Unknown error occurred',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorModal({
|
||||
isOpen: true,
|
||||
title: 'Failed to Fetch Storages',
|
||||
message: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingStorages(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Container control mutations
|
||||
// Note: getStatusMutation removed - using direct API calls instead
|
||||
|
||||
@@ -642,154 +600,38 @@ export function InstalledScriptsTab() {
|
||||
message: `Are you sure you want to update "${script.script_name}"?\n\n⚠️ WARNING: This will update the script and may affect the container. Consider backing up your data beforehand.`,
|
||||
variant: 'danger',
|
||||
confirmText: script.container_id,
|
||||
confirmButtonText: 'Continue',
|
||||
confirmButtonText: 'Update Script',
|
||||
onConfirm: () => {
|
||||
setConfirmationModal(null);
|
||||
// Store the script for backup flow
|
||||
setPendingUpdateScript(script);
|
||||
// Show backup prompt
|
||||
setShowBackupPrompt(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleBackupPromptResponse = (wantsBackup: boolean) => {
|
||||
setShowBackupPrompt(false);
|
||||
|
||||
if (!pendingUpdateScript) return;
|
||||
|
||||
if (wantsBackup) {
|
||||
// User wants backup - fetch storages and show selection
|
||||
if (pendingUpdateScript.server_id) {
|
||||
setIsPreUpdateBackup(true); // Mark that this is for pre-update backup
|
||||
void fetchStorages(pendingUpdateScript.server_id, false);
|
||||
setShowStorageSelection(true);
|
||||
} else {
|
||||
setErrorModal({
|
||||
isOpen: true,
|
||||
title: 'Backup Not Available',
|
||||
message: 'Backup is only available for SSH scripts with a configured server.',
|
||||
type: 'error'
|
||||
// Get server info if it's SSH mode
|
||||
let server = null;
|
||||
if (script.server_id && script.server_user) {
|
||||
server = {
|
||||
id: script.server_id,
|
||||
name: script.server_name,
|
||||
ip: script.server_ip,
|
||||
user: script.server_user,
|
||||
password: script.server_password,
|
||||
auth_type: script.server_auth_type ?? 'password',
|
||||
ssh_key: script.server_ssh_key,
|
||||
ssh_key_passphrase: script.server_ssh_key_passphrase,
|
||||
ssh_port: script.server_ssh_port ?? 22
|
||||
};
|
||||
}
|
||||
|
||||
setUpdatingScript({
|
||||
id: script.id,
|
||||
containerId: script.container_id!,
|
||||
server: server
|
||||
});
|
||||
// Proceed without backup
|
||||
proceedWithUpdate(null);
|
||||
setConfirmationModal(null);
|
||||
}
|
||||
} else {
|
||||
// User doesn't want backup - proceed directly to update
|
||||
proceedWithUpdate(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStorageSelected = (storage: Storage) => {
|
||||
setShowStorageSelection(false);
|
||||
|
||||
// Check if this is for a standalone backup or pre-update backup
|
||||
if (isPreUpdateBackup) {
|
||||
// Pre-update backup - proceed with update
|
||||
setIsPreUpdateBackup(false); // Reset flag
|
||||
proceedWithUpdate(storage.name);
|
||||
} else if (pendingUpdateScript) {
|
||||
// Standalone backup - execute backup directly
|
||||
executeStandaloneBackup(pendingUpdateScript, storage.name);
|
||||
}
|
||||
};
|
||||
|
||||
const executeStandaloneBackup = (script: InstalledScript, storageName: string) => {
|
||||
// Get server info
|
||||
let server = null;
|
||||
if (script.server_id && script.server_user) {
|
||||
server = {
|
||||
id: script.server_id,
|
||||
name: script.server_name,
|
||||
ip: script.server_ip,
|
||||
user: script.server_user,
|
||||
password: script.server_password,
|
||||
auth_type: script.server_auth_type ?? 'password',
|
||||
ssh_key: script.server_ssh_key,
|
||||
ssh_key_passphrase: script.server_ssh_key_passphrase,
|
||||
ssh_port: script.server_ssh_port ?? 22
|
||||
};
|
||||
}
|
||||
|
||||
// Start backup terminal
|
||||
setUpdatingScript({
|
||||
id: script.id,
|
||||
containerId: script.container_id!,
|
||||
server: server,
|
||||
backupStorage: storageName,
|
||||
isBackupOnly: true
|
||||
});
|
||||
|
||||
// Reset state
|
||||
setIsPreUpdateBackup(false); // Reset flag
|
||||
setPendingUpdateScript(null);
|
||||
setBackupStorages([]);
|
||||
};
|
||||
|
||||
const proceedWithUpdate = (backupStorage: string | null) => {
|
||||
if (!pendingUpdateScript) return;
|
||||
|
||||
// Get server info if it's SSH mode
|
||||
let server = null;
|
||||
if (pendingUpdateScript.server_id && pendingUpdateScript.server_user) {
|
||||
server = {
|
||||
id: pendingUpdateScript.server_id,
|
||||
name: pendingUpdateScript.server_name,
|
||||
ip: pendingUpdateScript.server_ip,
|
||||
user: pendingUpdateScript.server_user,
|
||||
password: pendingUpdateScript.server_password,
|
||||
auth_type: pendingUpdateScript.server_auth_type ?? 'password',
|
||||
ssh_key: pendingUpdateScript.server_ssh_key,
|
||||
ssh_key_passphrase: pendingUpdateScript.server_ssh_key_passphrase,
|
||||
ssh_port: pendingUpdateScript.server_ssh_port ?? 22
|
||||
};
|
||||
}
|
||||
|
||||
setUpdatingScript({
|
||||
id: pendingUpdateScript.id,
|
||||
containerId: pendingUpdateScript.container_id!,
|
||||
server: server,
|
||||
backupStorage: backupStorage ?? undefined,
|
||||
isBackupOnly: false // Explicitly set to false for update operations
|
||||
});
|
||||
|
||||
// Reset state
|
||||
setPendingUpdateScript(null);
|
||||
setBackupStorages([]);
|
||||
};
|
||||
|
||||
const handleCloseUpdateTerminal = () => {
|
||||
setUpdatingScript(null);
|
||||
};
|
||||
|
||||
const handleBackupScript = (script: InstalledScript) => {
|
||||
if (!script.container_id) {
|
||||
setErrorModal({
|
||||
isOpen: true,
|
||||
title: 'Backup Failed',
|
||||
message: 'No Container ID available for this script',
|
||||
details: 'This script does not have a valid container ID and cannot be backed up.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!script.server_id) {
|
||||
setErrorModal({
|
||||
isOpen: true,
|
||||
title: 'Backup Not Available',
|
||||
message: 'Backup is only available for SSH scripts with a configured server.',
|
||||
type: 'error'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the script and fetch storages
|
||||
setIsPreUpdateBackup(false); // This is a standalone backup, not pre-update
|
||||
setPendingUpdateScript(script);
|
||||
void fetchStorages(script.server_id, false);
|
||||
setShowStorageSelection(true);
|
||||
};
|
||||
|
||||
const handleOpenShell = (script: InstalledScript) => {
|
||||
if (!script.container_id) {
|
||||
setErrorModal({
|
||||
@@ -1045,15 +887,12 @@ export function InstalledScriptsTab() {
|
||||
{updatingScript && (
|
||||
<div className="mb-8" data-terminal="update">
|
||||
<Terminal
|
||||
scriptPath={updatingScript.isBackupOnly ? `backup-${updatingScript.containerId}` : `update-${updatingScript.containerId}`}
|
||||
scriptPath={`update-${updatingScript.containerId}`}
|
||||
onClose={handleCloseUpdateTerminal}
|
||||
mode={updatingScript.server ? 'ssh' : 'local'}
|
||||
server={updatingScript.server}
|
||||
isUpdate={!updatingScript.isBackupOnly}
|
||||
isBackup={updatingScript.isBackupOnly}
|
||||
isUpdate={true}
|
||||
containerId={updatingScript.containerId}
|
||||
storage={updatingScript.isBackupOnly ? updatingScript.backupStorage : undefined}
|
||||
backupStorage={!updatingScript.isBackupOnly ? updatingScript.backupStorage : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1413,7 +1252,6 @@ export function InstalledScriptsTab() {
|
||||
onSave={handleSaveEdit}
|
||||
onCancel={handleCancelEdit}
|
||||
onUpdate={() => handleUpdateScript(script)}
|
||||
onBackup={() => handleBackupScript(script)}
|
||||
onShell={() => handleOpenShell(script)}
|
||||
onDelete={() => handleDeleteScript(Number(script.id))}
|
||||
isUpdating={updateScriptMutation.isPending}
|
||||
@@ -1692,15 +1530,6 @@ export function InstalledScriptsTab() {
|
||||
Update
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleBackupScript(script)}
|
||||
disabled={containerStatuses.get(script.id) === 'stopped'}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
|
||||
>
|
||||
Backup
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleOpenShell(script)}
|
||||
@@ -1827,79 +1656,6 @@ export function InstalledScriptsTab() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Backup Prompt Modal */}
|
||||
{showBackupPrompt && (
|
||||
<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">
|
||||
<div className="flex items-center justify-center p-6 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="h-8 w-8 text-info" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<h2 className="text-2xl font-bold text-card-foreground">Backup Before Update?</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Would you like to create a backup before updating the container?
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row justify-end gap-3">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowBackupPrompt(false);
|
||||
handleBackupPromptResponse(false);
|
||||
}}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
No, Update Without Backup
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleBackupPromptResponse(true)}
|
||||
variant="default"
|
||||
size="default"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Yes, Backup First
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Storage Selection Modal */}
|
||||
<StorageSelectionModal
|
||||
isOpen={showStorageSelection}
|
||||
onClose={() => {
|
||||
setShowStorageSelection(false);
|
||||
setPendingUpdateScript(null);
|
||||
setBackupStorages([]);
|
||||
}}
|
||||
onSelect={handleStorageSelected}
|
||||
storages={backupStorages}
|
||||
isLoading={isLoadingStorages}
|
||||
onRefresh={() => {
|
||||
if (pendingUpdateScript?.server_id) {
|
||||
void fetchStorages(pendingUpdateScript.server_id, true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Backup Warning Modal */}
|
||||
<BackupWarningModal
|
||||
isOpen={showBackupWarning}
|
||||
onClose={() => setShowBackupWarning(false)}
|
||||
onProceed={() => {
|
||||
setShowBackupWarning(false);
|
||||
// Proceed with update even though backup failed
|
||||
if (pendingUpdateScript) {
|
||||
proceedWithUpdate(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* LXC Settings Modal */}
|
||||
<LXCSettingsModal
|
||||
isOpen={lxcSettingsModal.isOpen}
|
||||
|
||||
@@ -1,84 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2, CheckCircle, X } from 'lucide-react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface LoadingModalProps {
|
||||
isOpen: boolean;
|
||||
action: string;
|
||||
logs?: string[];
|
||||
isComplete?: boolean;
|
||||
title?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function LoadingModal({ isOpen, action, logs = [], isComplete = false, title, onClose }: LoadingModalProps) {
|
||||
// Allow dismissing with ESC only when complete, prevent during running
|
||||
useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: isComplete, onClose: onClose || (() => null) });
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
useEffect(() => {
|
||||
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [logs]);
|
||||
|
||||
export function LoadingModal({ isOpen, action }: LoadingModalProps) {
|
||||
useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: false, onClose: () => null });
|
||||
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-2xl w-full border border-border p-8 max-h-[80vh] flex flex-col relative">
|
||||
{/* Close button - only show when complete */}
|
||||
{isComplete && onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<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">
|
||||
{isComplete ? (
|
||||
<CheckCircle className="h-12 w-12 text-success" />
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{/* Static title text */}
|
||||
{title && (
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-card-foreground mb-2">
|
||||
Processing
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{title}
|
||||
{action}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Log output */}
|
||||
{logs.length > 0 && (
|
||||
<div className="w-full bg-card border border-border rounded-lg p-4 font-mono text-xs text-chart-2 max-h-[60vh] overflow-y-auto terminal-output">
|
||||
{logs.map((log, index) => (
|
||||
<div key={index} className="mb-1 whitespace-pre-wrap break-words">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isComplete && (
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Please wait...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Lock, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { api } from '~/trpc/react';
|
||||
import type { Storage } from '~/server/services/storageService';
|
||||
|
||||
interface PBSCredentialsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
serverId: number;
|
||||
serverName: string;
|
||||
storage: Storage;
|
||||
}
|
||||
|
||||
export function PBSCredentialsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
serverId,
|
||||
serverName,
|
||||
storage
|
||||
}: PBSCredentialsModalProps) {
|
||||
const [pbsIp, setPbsIp] = useState('');
|
||||
const [pbsDatastore, setPbsDatastore] = useState('');
|
||||
const [pbsPassword, setPbsPassword] = useState('');
|
||||
const [pbsFingerprint, setPbsFingerprint] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Extract PBS info from storage object
|
||||
const pbsIpFromStorage = (storage as any).server || null;
|
||||
const pbsDatastoreFromStorage = (storage as any).datastore || null;
|
||||
|
||||
// Fetch existing credentials
|
||||
const { data: credentialData, refetch } = api.pbsCredentials.getCredentialsForStorage.useQuery(
|
||||
{ serverId, storageName: storage.name },
|
||||
{ enabled: isOpen }
|
||||
);
|
||||
|
||||
// Initialize form with storage config values or existing credentials
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (credentialData?.success && credentialData.credential) {
|
||||
// Load existing credentials
|
||||
setPbsIp(credentialData.credential.pbs_ip);
|
||||
setPbsDatastore(credentialData.credential.pbs_datastore);
|
||||
setPbsPassword(''); // Don't show password
|
||||
setPbsFingerprint(credentialData.credential.pbs_fingerprint || '');
|
||||
} else {
|
||||
// Initialize with storage config values
|
||||
setPbsIp(pbsIpFromStorage || '');
|
||||
setPbsDatastore(pbsDatastoreFromStorage || '');
|
||||
setPbsPassword('');
|
||||
setPbsFingerprint('');
|
||||
}
|
||||
}
|
||||
}, [isOpen, credentialData, pbsIpFromStorage, pbsDatastoreFromStorage]);
|
||||
|
||||
const saveCredentials = api.pbsCredentials.saveCredentials.useMutation({
|
||||
onSuccess: () => {
|
||||
void refetch();
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to save PBS credentials:', error);
|
||||
alert(`Failed to save credentials: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteCredentials = api.pbsCredentials.deleteCredentials.useMutation({
|
||||
onSuccess: () => {
|
||||
void refetch();
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to delete PBS credentials:', error);
|
||||
alert(`Failed to delete credentials: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
useRegisterModal(isOpen, { id: 'pbs-credentials-modal', allowEscape: true, onClose });
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!pbsIp || !pbsDatastore || !pbsFingerprint) {
|
||||
alert('Please fill in all required fields (IP, Datastore, Fingerprint)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Password is optional when updating existing credentials
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await saveCredentials.mutateAsync({
|
||||
serverId,
|
||||
storageName: storage.name,
|
||||
pbs_ip: pbsIp,
|
||||
pbs_datastore: pbsDatastore,
|
||||
pbs_password: pbsPassword || undefined, // Undefined means keep existing password
|
||||
pbs_fingerprint: pbsFingerprint,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Are you sure you want to delete the PBS credentials for this storage?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await deleteCredentials.mutateAsync({
|
||||
serverId,
|
||||
storageName: storage.name,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const hasCredentials = credentialData?.success && credentialData.credential;
|
||||
|
||||
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 max-h-[90vh] flex flex-col border border-border">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Lock className="h-6 w-6 text-primary" />
|
||||
<h2 className="text-2xl font-bold text-card-foreground">
|
||||
PBS Credentials - {storage.name}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Storage Name (read-only) */}
|
||||
<div>
|
||||
<label htmlFor="storage-name" className="block text-sm font-medium text-foreground mb-1">
|
||||
Storage Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="storage-name"
|
||||
value={storage.name}
|
||||
disabled
|
||||
className="w-full px-3 py-2 border rounded-md shadow-sm bg-muted text-muted-foreground border-border cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* PBS IP */}
|
||||
<div>
|
||||
<label htmlFor="pbs-ip" className="block text-sm font-medium text-foreground mb-1">
|
||||
PBS Server IP <span className="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="pbs-ip"
|
||||
value={pbsIp}
|
||||
onChange={(e) => setPbsIp(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
|
||||
placeholder="e.g., 10.10.10.226"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
IP address of the Proxmox Backup Server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* PBS Datastore */}
|
||||
<div>
|
||||
<label htmlFor="pbs-datastore" className="block text-sm font-medium text-foreground mb-1">
|
||||
PBS Datastore <span className="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="pbs-datastore"
|
||||
value={pbsDatastore}
|
||||
onChange={(e) => setPbsDatastore(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
|
||||
placeholder="e.g., NAS03-ISCSI-BACKUP"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Name of the datastore on the PBS server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* PBS Password */}
|
||||
<div>
|
||||
<label htmlFor="pbs-password" className="block text-sm font-medium text-foreground mb-1">
|
||||
Password {!hasCredentials && <span className="text-error">*</span>}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="pbs-password"
|
||||
value={pbsPassword}
|
||||
onChange={(e) => setPbsPassword(e.target.value)}
|
||||
required={!hasCredentials}
|
||||
disabled={isLoading}
|
||||
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
|
||||
placeholder={hasCredentials ? "Enter new password (leave empty to keep existing)" : "Enter PBS password"}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Password for root@pam user on PBS server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* PBS Fingerprint */}
|
||||
<div>
|
||||
<label htmlFor="pbs-fingerprint" className="block text-sm font-medium text-foreground mb-1">
|
||||
Fingerprint <span className="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="pbs-fingerprint"
|
||||
value={pbsFingerprint}
|
||||
onChange={(e) => setPbsFingerprint(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
|
||||
placeholder="e.g., 7b:e5:87:38:5e:16:05:d1:12:22:7f:73:d2:e2:d0:cf:8c:cb:28:e2:74:0c:78:91:1a:71:74:2e:79:20:5a:02"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Server fingerprint for auto-acceptance. You can find this on your PBS dashboard by clicking the "Show Fingerprint" button.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status indicator */}
|
||||
{hasCredentials && (
|
||||
<div className="p-3 bg-success/10 border border-success/20 rounded-lg flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-success" />
|
||||
<span className="text-sm text-success font-medium">
|
||||
Credentials are configured for this storage
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row justify-end gap-3 pt-4">
|
||||
{hasCredentials && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
className="w-full sm:w-auto order-3"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 mr-2" />
|
||||
Delete Credentials
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
className="w-full sm:w-auto order-2"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
disabled={isLoading}
|
||||
className="w-full sm:w-auto order-1"
|
||||
>
|
||||
{isLoading ? 'Saving...' : hasCredentials ? 'Update Credentials' : 'Save Credentials'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||
@@ -9,8 +9,6 @@ export function ResyncButton() {
|
||||
const [isResyncing, setIsResyncing] = useState(false);
|
||||
const [lastSync, setLastSync] = useState<Date | null>(null);
|
||||
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
||||
const hasReloadedRef = useRef<boolean>(false);
|
||||
const isUserInitiatedRef = useRef<boolean>(false);
|
||||
|
||||
const resyncMutation = api.scripts.resyncScripts.useMutation({
|
||||
onSuccess: (data) => {
|
||||
@@ -18,38 +16,24 @@ export function ResyncButton() {
|
||||
setLastSync(new Date());
|
||||
if (data.success) {
|
||||
setSyncMessage(data.message ?? 'Scripts synced successfully');
|
||||
// Only reload if this was triggered by user action
|
||||
if (isUserInitiatedRef.current && !hasReloadedRef.current) {
|
||||
hasReloadedRef.current = true;
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000); // Wait 2 seconds to show the success message
|
||||
} else {
|
||||
// Reset flag if reload didn't happen
|
||||
isUserInitiatedRef.current = false;
|
||||
}
|
||||
// Reload the page after successful sync
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000); // Wait 2 seconds to show the success message
|
||||
} else {
|
||||
setSyncMessage(data.error ?? 'Failed to sync scripts');
|
||||
// Clear message after 3 seconds for errors
|
||||
setTimeout(() => setSyncMessage(null), 3000);
|
||||
isUserInitiatedRef.current = false;
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsResyncing(false);
|
||||
setSyncMessage(`Error: ${error.message}`);
|
||||
setTimeout(() => setSyncMessage(null), 3000);
|
||||
isUserInitiatedRef.current = false;
|
||||
},
|
||||
});
|
||||
|
||||
const handleResync = async () => {
|
||||
// Prevent multiple simultaneous sync operations
|
||||
if (isResyncing) return;
|
||||
|
||||
// Mark as user-initiated before starting
|
||||
isUserInitiatedRef.current = true;
|
||||
hasReloadedRef.current = false;
|
||||
setIsResyncing(true);
|
||||
setSyncMessage(null);
|
||||
resyncMutation.mutate();
|
||||
|
||||
@@ -61,11 +61,7 @@ export function ScriptDetailModal({
|
||||
isLoading: comparisonLoading,
|
||||
} = api.scripts.compareScriptContent.useQuery(
|
||||
{ slug: script?.slug ?? "" },
|
||||
{
|
||||
enabled: !!script && isOpen,
|
||||
refetchOnMount: true,
|
||||
staleTime: 0,
|
||||
},
|
||||
{ enabled: !!script && isOpen },
|
||||
);
|
||||
|
||||
// Load script mutation
|
||||
@@ -551,60 +547,19 @@ export function ScriptDetailModal({
|
||||
</div>
|
||||
{scriptFilesData?.success &&
|
||||
(scriptFilesData.ctExists ||
|
||||
scriptFilesData.installExists) && (
|
||||
scriptFilesData.installExists) &&
|
||||
comparisonData?.success &&
|
||||
!comparisonLoading && (
|
||||
<div className="flex items-center space-x-2">
|
||||
{comparisonData?.success ? (
|
||||
<>
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${comparisonData.hasDifferences ? "bg-warning" : "bg-success"}`}
|
||||
></div>
|
||||
<span>
|
||||
Status:{" "}
|
||||
{comparisonData.hasDifferences
|
||||
? "Update available"
|
||||
: "Up to date"}
|
||||
</span>
|
||||
</>
|
||||
) : comparisonLoading ? (
|
||||
<>
|
||||
<div className="h-2 w-2 rounded-full bg-muted animate-pulse"></div>
|
||||
<span>Checking for updates...</span>
|
||||
</>
|
||||
) : comparisonData?.error ? (
|
||||
<>
|
||||
<div className="h-2 w-2 rounded-full bg-destructive"></div>
|
||||
<span className="text-destructive">Error: {comparisonData.error}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-2 w-2 rounded-full bg-muted"></div>
|
||||
<span>Status: Unknown</span>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => void refetchComparison()}
|
||||
disabled={comparisonLoading}
|
||||
className="ml-2 p-1.5 rounded-md hover:bg-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
||||
title="Refresh comparison"
|
||||
>
|
||||
{comparisonLoading ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></div>
|
||||
) : (
|
||||
<svg
|
||||
className="h-4 w-4 text-muted-foreground hover:text-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${comparisonData.hasDifferences ? "bg-warning" : "bg-success"}`}
|
||||
></div>
|
||||
<span>
|
||||
Status:{" "}
|
||||
{comparisonData.hasDifferences
|
||||
? "Update available"
|
||||
: "Up to date"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -882,7 +837,7 @@ export function ScriptDetailModal({
|
||||
<TextViewer
|
||||
scriptName={
|
||||
script.install_methods
|
||||
?.find((method) => method.script && (method.script.startsWith("ct/") || method.script.startsWith("vm/") || method.script.startsWith("tools/")))
|
||||
?.find((method) => method.script?.startsWith("ct/"))
|
||||
?.script?.split("/")
|
||||
.pop() ?? `${script.slug}.sh`
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ interface ScriptInstallationCardProps {
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onUpdate: () => void;
|
||||
onBackup?: () => void;
|
||||
onShell: () => void;
|
||||
onDelete: () => void;
|
||||
isUpdating: boolean;
|
||||
@@ -69,7 +68,6 @@ export function ScriptInstallationCard({
|
||||
onSave,
|
||||
onCancel,
|
||||
onUpdate,
|
||||
onBackup,
|
||||
onShell,
|
||||
onDelete,
|
||||
isUpdating,
|
||||
@@ -309,15 +307,6 @@ export function ScriptInstallationCard({
|
||||
Update
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{script.container_id && script.execution_mode === 'ssh' && onBackup && (
|
||||
<DropdownMenuItem
|
||||
onClick={onBackup}
|
||||
disabled={containerStatus === 'stopped'}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
|
||||
>
|
||||
Backup
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<DropdownMenuItem
|
||||
onClick={onShell}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { ViewToggle } from './ViewToggle';
|
||||
import { Button } from './ui/button';
|
||||
import { Clock } from 'lucide-react';
|
||||
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
||||
import { getDefaultFilters, mergeFiltersWithDefaults } from './filterUtils';
|
||||
|
||||
|
||||
interface ScriptsGridProps {
|
||||
@@ -26,7 +25,14 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
||||
const [selectedSlugs, setSelectedSlugs] = useState<Set<string>>(new Set());
|
||||
const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number; currentScript: string; failed: Array<{ slug: string; error: string }> } | null>(null);
|
||||
const [filters, setFilters] = useState<FilterState>(getDefaultFilters());
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
searchQuery: '',
|
||||
showUpdatable: null,
|
||||
selectedTypes: [],
|
||||
selectedRepositories: [],
|
||||
sortBy: 'name',
|
||||
sortOrder: 'asc',
|
||||
});
|
||||
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
|
||||
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
||||
const [isNewestMinimized, setIsNewestMinimized] = useState(false);
|
||||
@@ -61,7 +67,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
if (filtersResponse.ok) {
|
||||
const filtersData = await filtersResponse.json();
|
||||
if (filtersData.filters) {
|
||||
setFilters(mergeFiltersWithDefaults(filtersData.filters));
|
||||
setFilters(filtersData.filters as FilterState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@ import { ServerForm } from './ServerForm';
|
||||
import { Button } from './ui/button';
|
||||
import { ConfirmationModal } from './ConfirmationModal';
|
||||
import { PublicKeyModal } from './PublicKeyModal';
|
||||
import { ServerStoragesModal } from './ServerStoragesModal';
|
||||
import { Key, Database } from 'lucide-react';
|
||||
import { Key } from 'lucide-react';
|
||||
|
||||
interface ServerListProps {
|
||||
servers: Server[];
|
||||
@@ -33,8 +32,6 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
||||
serverName: string;
|
||||
serverIp: string;
|
||||
} | null>(null);
|
||||
const [showStoragesModal, setShowStoragesModal] = useState(false);
|
||||
const [selectedServerForStorages, setSelectedServerForStorages] = useState<{ id: number; name: string } | null>(null);
|
||||
|
||||
const handleEdit = (server: Server) => {
|
||||
setEditingId(server.id);
|
||||
@@ -254,19 +251,6 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedServerForStorages({ id: server.id, name: server.name });
|
||||
setShowStoragesModal(true);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto border-info/20 text-info bg-info/10 hover:bg-info/20"
|
||||
>
|
||||
<Database className="w-4 h-4 mr-1" />
|
||||
<span className="hidden sm:inline">View Storages</span>
|
||||
<span className="sm:hidden">Storages</span>
|
||||
</Button>
|
||||
<div className="flex space-x-2">
|
||||
{/* View Public Key button - only show for generated keys */}
|
||||
{server.key_generated === true && (
|
||||
@@ -340,19 +324,6 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
||||
serverIp={publicKeyData.serverIp}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Server Storages Modal */}
|
||||
{selectedServerForStorages && (
|
||||
<ServerStoragesModal
|
||||
isOpen={showStoragesModal}
|
||||
onClose={() => {
|
||||
setShowStoragesModal(false);
|
||||
setSelectedServerForStorages(null);
|
||||
}}
|
||||
serverId={selectedServerForStorages.id}
|
||||
serverName={selectedServerForStorages.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Database, RefreshCw, CheckCircle, Lock, AlertCircle } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { api } from '~/trpc/react';
|
||||
import { PBSCredentialsModal } from './PBSCredentialsModal';
|
||||
import type { Storage } from '~/server/services/storageService';
|
||||
|
||||
interface ServerStoragesModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
serverId: number;
|
||||
serverName: string;
|
||||
}
|
||||
|
||||
export function ServerStoragesModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
serverId,
|
||||
serverName
|
||||
}: ServerStoragesModalProps) {
|
||||
const [forceRefresh, setForceRefresh] = useState(false);
|
||||
const [selectedPBSStorage, setSelectedPBSStorage] = useState<Storage | null>(null);
|
||||
|
||||
const { data, isLoading, refetch } = api.installedScripts.getBackupStorages.useQuery(
|
||||
{ serverId, forceRefresh },
|
||||
{ enabled: isOpen }
|
||||
);
|
||||
|
||||
// Fetch all PBS credentials for this server to show status indicators
|
||||
const { data: allCredentials } = api.pbsCredentials.getAllCredentialsForServer.useQuery(
|
||||
{ serverId },
|
||||
{ enabled: isOpen }
|
||||
);
|
||||
|
||||
const credentialsMap = new Map<string, boolean>();
|
||||
if (allCredentials?.success) {
|
||||
allCredentials.credentials.forEach(c => {
|
||||
credentialsMap.set(c.storage_name, true);
|
||||
});
|
||||
}
|
||||
|
||||
useRegisterModal(isOpen, { id: 'server-storages-modal', allowEscape: true, onClose });
|
||||
|
||||
const handleRefresh = () => {
|
||||
setForceRefresh(true);
|
||||
void refetch();
|
||||
setTimeout(() => setForceRefresh(false), 1000);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const storages = data?.success ? data.storages : [];
|
||||
const backupStorages = storages.filter(s => s.supportsBackup);
|
||||
|
||||
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-3xl w-full max-h-[90vh] flex flex-col border border-border">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="h-6 w-6 text-primary" />
|
||||
<h2 className="text-2xl font-bold text-card-foreground">
|
||||
Storages for {serverName}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div>
|
||||
<p className="text-muted-foreground">Loading storages...</p>
|
||||
</div>
|
||||
) : !data?.success ? (
|
||||
<div className="text-center py-8">
|
||||
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-foreground mb-2">Failed to load storages</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{data?.error ?? 'Unknown error occurred'}
|
||||
</p>
|
||||
<Button onClick={handleRefresh} variant="outline" size="sm">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
) : storages.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-foreground mb-2">No storages found</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Make sure your server has storages configured.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{data.cached && (
|
||||
<div className="mb-4 p-3 bg-muted/50 rounded-lg text-sm text-muted-foreground">
|
||||
Showing cached data. Click Refresh to fetch latest from server.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{storages.map((storage) => {
|
||||
const isBackupCapable = storage.supportsBackup;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={storage.name}
|
||||
className={`p-4 border rounded-lg ${
|
||||
isBackupCapable
|
||||
? 'border-success/50 bg-success/5'
|
||||
: 'border-border bg-card'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<h3 className="font-medium text-foreground">{storage.name}</h3>
|
||||
{isBackupCapable && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30 flex items-center gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Backup
|
||||
</span>
|
||||
)}
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-muted text-muted-foreground">
|
||||
{storage.type}
|
||||
</span>
|
||||
{storage.type === 'pbs' && (
|
||||
credentialsMap.has(storage.name) ? (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30 flex items-center gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Credentials Configured
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-warning/20 text-warning border border-warning/30 flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Credentials Needed
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<div>
|
||||
<span className="font-medium">Content:</span> {storage.content.join(', ')}
|
||||
</div>
|
||||
{storage.nodes && storage.nodes.length > 0 && (
|
||||
<div>
|
||||
<span className="font-medium">Nodes:</span> {storage.nodes.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(storage)
|
||||
.filter(([key]) => !['name', 'type', 'content', 'supportsBackup', 'nodes'].includes(key))
|
||||
.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<span className="font-medium capitalize">{key.replace(/_/g, ' ')}:</span> {String(value)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{storage.type === 'pbs' && (
|
||||
<div className="mt-3 pt-3 border-t border-border">
|
||||
<Button
|
||||
onClick={() => setSelectedPBSStorage(storage)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Lock className="h-4 w-4" />
|
||||
{credentialsMap.has(storage.name) ? 'Edit' : 'Configure'} Credentials
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{backupStorages.length > 0 && (
|
||||
<div className="mt-6 p-4 bg-success/10 border border-success/20 rounded-lg">
|
||||
<p className="text-sm text-success font-medium">
|
||||
{backupStorages.length} storage{backupStorages.length !== 1 ? 's' : ''} available for backups
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PBS Credentials Modal */}
|
||||
{selectedPBSStorage && (
|
||||
<PBSCredentialsModal
|
||||
isOpen={!!selectedPBSStorage}
|
||||
onClose={() => setSelectedPBSStorage(null)}
|
||||
serverId={serverId}
|
||||
serverName={serverName}
|
||||
storage={selectedPBSStorage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Database, RefreshCw, CheckCircle } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import type { Storage } from '~/server/services/storageService';
|
||||
|
||||
interface StorageSelectionModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (storage: Storage) => void;
|
||||
storages: Storage[];
|
||||
isLoading: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function StorageSelectionModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
storages,
|
||||
isLoading,
|
||||
onRefresh
|
||||
}: StorageSelectionModalProps) {
|
||||
const [selectedStorage, setSelectedStorage] = useState<Storage | null>(null);
|
||||
|
||||
useRegisterModal(isOpen, { id: 'storage-selection-modal', allowEscape: true, onClose });
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSelect = () => {
|
||||
if (selectedStorage) {
|
||||
onSelect(selectedStorage);
|
||||
setSelectedStorage(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedStorage(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Filter to show only backup-capable storages
|
||||
const backupStorages = storages.filter(s => s.supportsBackup);
|
||||
|
||||
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">
|
||||
<Database className="h-6 w-6 text-primary" />
|
||||
<h2 className="text-2xl font-bold text-card-foreground">Select Backup Storage</h2>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div>
|
||||
<p className="text-muted-foreground">Loading storages...</p>
|
||||
</div>
|
||||
) : backupStorages.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Database className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-foreground mb-2">No backup-capable storages found</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Make sure your server has storages configured with backup content type.
|
||||
</p>
|
||||
<Button onClick={onRefresh} variant="outline" size="sm">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh Storages
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Select a storage to use for the backup. Only storages that support backups are shown.
|
||||
</p>
|
||||
|
||||
{/* Storage List */}
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto mb-4">
|
||||
{backupStorages.map((storage) => (
|
||||
<div
|
||||
key={storage.name}
|
||||
onClick={() => setSelectedStorage(storage)}
|
||||
className={`p-4 border rounded-lg cursor-pointer transition-all ${
|
||||
selectedStorage?.name === storage.name
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50 hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium text-foreground">{storage.name}</h3>
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success border border-success/30">
|
||||
Backup
|
||||
</span>
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-muted text-muted-foreground">
|
||||
{storage.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span>Content: {storage.content.join(', ')}</span>
|
||||
{storage.nodes && storage.nodes.length > 0 && (
|
||||
<span className="ml-2">• Nodes: {storage.nodes.join(', ')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedStorage?.name === storage.name && (
|
||||
<CheckCircle className="h-5 w-5 text-primary flex-shrink-0 ml-2" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Refresh Button */}
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button onClick={onRefresh} variant="outline" size="sm">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Fetch Storages
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row justify-end gap-3">
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSelect}
|
||||
disabled={!selectedStorage}
|
||||
variant="default"
|
||||
size="default"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Select Storage
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,10 +12,7 @@ interface TerminalProps {
|
||||
server?: any;
|
||||
isUpdate?: boolean;
|
||||
isShell?: boolean;
|
||||
isBackup?: boolean;
|
||||
containerId?: string;
|
||||
storage?: string;
|
||||
backupStorage?: string;
|
||||
}
|
||||
|
||||
interface TerminalMessage {
|
||||
@@ -24,7 +21,7 @@ interface TerminalMessage {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, isBackup = false, containerId, storage, backupStorage }: TerminalProps) {
|
||||
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, containerId }: TerminalProps) {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
@@ -337,10 +334,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
server,
|
||||
isUpdate,
|
||||
isShell,
|
||||
isBackup,
|
||||
containerId,
|
||||
storage,
|
||||
backupStorage
|
||||
containerId
|
||||
};
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ interface TextViewerProps {
|
||||
}
|
||||
|
||||
interface ScriptContent {
|
||||
mainScript?: string;
|
||||
ctScript?: string;
|
||||
installScript?: string;
|
||||
alpineMainScript?: string;
|
||||
alpineCtScript?: string;
|
||||
alpineInstallScript?: string;
|
||||
}
|
||||
|
||||
@@ -24,27 +24,18 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
||||
const [scriptContent, setScriptContent] = useState<ScriptContent>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'main' | 'install'>('main');
|
||||
const [activeTab, setActiveTab] = useState<'ct' | 'install'>('ct');
|
||||
const [selectedVersion, setSelectedVersion] = useState<'default' | 'alpine'>('default');
|
||||
|
||||
// Extract slug from script name (remove .sh extension)
|
||||
const slug = scriptName.replace(/\.sh$/, '').replace(/^alpine-/, '');
|
||||
|
||||
// Get default and alpine install methods
|
||||
const defaultMethod = script?.install_methods?.find(method => method.type === 'default');
|
||||
const alpineMethod = script?.install_methods?.find(method => method.type === 'alpine');
|
||||
|
||||
// Check if alpine variant exists
|
||||
const hasAlpineVariant = !!alpineMethod;
|
||||
const hasAlpineVariant = script?.install_methods?.some(
|
||||
method => method.type === 'alpine' && method.script?.startsWith('ct/')
|
||||
);
|
||||
|
||||
// Get script paths from install_methods
|
||||
const defaultScriptPath = defaultMethod?.script;
|
||||
const alpineScriptPath = alpineMethod?.script;
|
||||
|
||||
// Determine if install scripts exist (only for ct/ scripts typically)
|
||||
const hasInstallScript = defaultScriptPath?.startsWith('ct/') || alpineScriptPath?.startsWith('ct/');
|
||||
|
||||
// Get script names for display
|
||||
// Get script names for default and alpine versions
|
||||
const defaultScriptName = scriptName.replace(/^alpine-/, '');
|
||||
const alpineScriptName = scriptName.startsWith('alpine-') ? scriptName : `alpine-${scriptName}`;
|
||||
|
||||
@@ -53,72 +44,116 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Build fetch requests based on actual script paths from install_methods
|
||||
// Build fetch requests for default version
|
||||
const requests: Promise<Response>[] = [];
|
||||
const requestTypes: Array<'default-main' | 'default-install' | 'alpine-main' | 'alpine-install'> = [];
|
||||
|
||||
// Default main script (ct/, vm/, tools/, etc.)
|
||||
if (defaultScriptPath) {
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: defaultScriptPath } }))}`)
|
||||
);
|
||||
requestTypes.push('default-main');
|
||||
}
|
||||
|
||||
// Default install script (only for ct/ scripts)
|
||||
if (hasInstallScript && defaultScriptPath?.startsWith('ct/')) {
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`)
|
||||
);
|
||||
requestTypes.push('default-install');
|
||||
}
|
||||
// Default CT script
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${defaultScriptName}` } }))}`)
|
||||
);
|
||||
|
||||
// Alpine main script
|
||||
if (hasAlpineVariant && alpineScriptPath) {
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: alpineScriptPath } }))}`)
|
||||
);
|
||||
requestTypes.push('alpine-main');
|
||||
}
|
||||
// Tools, VM, VW scripts
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `tools/pve/${defaultScriptName}` } }))}`)
|
||||
);
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vm/${defaultScriptName}` } }))}`)
|
||||
);
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vw/${defaultScriptName}` } }))}`)
|
||||
);
|
||||
|
||||
// Alpine install script (only for ct/ scripts)
|
||||
if (hasAlpineVariant && hasInstallScript && alpineScriptPath?.startsWith('ct/')) {
|
||||
// Default install script
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`)
|
||||
);
|
||||
|
||||
// Alpine versions if variant exists
|
||||
if (hasAlpineVariant) {
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${alpineScriptName}` } }))}`)
|
||||
);
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/alpine-${slug}-install.sh` } }))}`)
|
||||
);
|
||||
requestTypes.push('alpine-install');
|
||||
}
|
||||
|
||||
const responses = await Promise.allSettled(requests);
|
||||
const content: ScriptContent = {};
|
||||
|
||||
// Process responses based on their types
|
||||
await Promise.all(responses.map(async (response, index) => {
|
||||
if (response.status === 'fulfilled' && response.value.ok) {
|
||||
try {
|
||||
const data = await response.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
const type = requestTypes[index];
|
||||
if (data.result?.data?.json?.success && data.result.data.json.content) {
|
||||
switch (type) {
|
||||
case 'default-main':
|
||||
content.mainScript = data.result.data.json.content;
|
||||
break;
|
||||
case 'default-install':
|
||||
content.installScript = data.result.data.json.content;
|
||||
break;
|
||||
case 'alpine-main':
|
||||
content.alpineMainScript = data.result.data.json.content;
|
||||
break;
|
||||
case 'alpine-install':
|
||||
content.alpineInstallScript = data.result.data.json.content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
const content: ScriptContent = {};
|
||||
let responseIndex = 0;
|
||||
|
||||
// Default CT script
|
||||
const ctResponse = responses[responseIndex];
|
||||
if (ctResponse?.status === 'fulfilled' && ctResponse.value.ok) {
|
||||
const ctData = await ctResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (ctData.result?.data?.json?.success) {
|
||||
content.ctScript = ctData.result.data.json.content;
|
||||
}
|
||||
}
|
||||
|
||||
responseIndex++;
|
||||
// Tools script
|
||||
const toolsResponse = responses[responseIndex];
|
||||
if (toolsResponse?.status === 'fulfilled' && toolsResponse.value.ok) {
|
||||
const toolsData = await toolsResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (toolsData.result?.data?.json?.success) {
|
||||
content.ctScript = toolsData.result.data.json.content; // Use ctScript field for tools scripts too
|
||||
}
|
||||
}
|
||||
|
||||
responseIndex++;
|
||||
// VM script
|
||||
const vmResponse = responses[responseIndex];
|
||||
if (vmResponse?.status === 'fulfilled' && vmResponse.value.ok) {
|
||||
const vmData = await vmResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (vmData.result?.data?.json?.success) {
|
||||
content.ctScript = vmData.result.data.json.content; // Use ctScript field for VM scripts too
|
||||
}
|
||||
}
|
||||
|
||||
responseIndex++;
|
||||
// VW script
|
||||
const vwResponse = responses[responseIndex];
|
||||
if (vwResponse?.status === 'fulfilled' && vwResponse.value.ok) {
|
||||
const vwData = await vwResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (vwData.result?.data?.json?.success) {
|
||||
content.ctScript = vwData.result.data.json.content; // Use ctScript field for VW scripts too
|
||||
}
|
||||
}
|
||||
|
||||
responseIndex++;
|
||||
// Default install script
|
||||
const installResponse = responses[responseIndex];
|
||||
if (installResponse?.status === 'fulfilled' && installResponse.value.ok) {
|
||||
const installData = await installResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (installData.result?.data?.json?.success) {
|
||||
content.installScript = installData.result.data.json.content;
|
||||
}
|
||||
}
|
||||
responseIndex++;
|
||||
// Alpine CT script
|
||||
if (hasAlpineVariant) {
|
||||
const alpineCtResponse = responses[responseIndex];
|
||||
if (alpineCtResponse?.status === 'fulfilled' && alpineCtResponse.value.ok) {
|
||||
const alpineCtData = await alpineCtResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (alpineCtData.result?.data?.json?.success) {
|
||||
content.alpineCtScript = alpineCtData.result.data.json.content;
|
||||
}
|
||||
}
|
||||
}));
|
||||
responseIndex++;
|
||||
}
|
||||
|
||||
// Alpine install script
|
||||
if (hasAlpineVariant) {
|
||||
const alpineInstallResponse = responses[responseIndex];
|
||||
if (alpineInstallResponse?.status === 'fulfilled' && alpineInstallResponse.value.ok) {
|
||||
const alpineInstallData = await alpineInstallResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (alpineInstallData.result?.data?.json?.success) {
|
||||
content.alpineInstallScript = alpineInstallData.result.data.json.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setScriptContent(content);
|
||||
} catch (err) {
|
||||
@@ -126,7 +161,7 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [defaultScriptPath, alpineScriptPath, slug, hasAlpineVariant, hasInstallScript]);
|
||||
}, [defaultScriptName, alpineScriptName, slug, hasAlpineVariant]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && scriptName) {
|
||||
@@ -172,25 +207,23 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{((selectedVersion === 'default' && (scriptContent.mainScript || scriptContent.installScript)) ||
|
||||
(selectedVersion === 'alpine' && (scriptContent.alpineMainScript || scriptContent.alpineInstallScript))) && (
|
||||
{((selectedVersion === 'default' && (scriptContent.ctScript || scriptContent.installScript)) ||
|
||||
(selectedVersion === 'alpine' && (scriptContent.alpineCtScript || scriptContent.alpineInstallScript))) && (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={activeTab === 'main' ? 'outline' : 'ghost'}
|
||||
onClick={() => setActiveTab('main')}
|
||||
variant={activeTab === 'ct' ? 'outline' : 'ghost'}
|
||||
onClick={() => setActiveTab('ct')}
|
||||
className="px-3 py-1 text-sm"
|
||||
>
|
||||
Script
|
||||
CT Script
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'install' ? 'outline' : 'ghost'}
|
||||
onClick={() => setActiveTab('install')}
|
||||
className="px-3 py-1 text-sm"
|
||||
>
|
||||
Install Script
|
||||
</Button>
|
||||
{hasInstallScript && (
|
||||
<Button
|
||||
variant={activeTab === 'install' ? 'outline' : 'ghost'}
|
||||
onClick={() => setActiveTab('install')}
|
||||
className="px-3 py-1 text-sm"
|
||||
>
|
||||
Install Script
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -216,8 +249,8 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{activeTab === 'main' && (
|
||||
selectedVersion === 'default' && scriptContent.mainScript ? (
|
||||
{activeTab === 'ct' && (
|
||||
selectedVersion === 'default' && scriptContent.ctScript ? (
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={tomorrow}
|
||||
@@ -231,9 +264,9 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
||||
showLineNumbers={true}
|
||||
wrapLines={true}
|
||||
>
|
||||
{scriptContent.mainScript}
|
||||
{scriptContent.ctScript}
|
||||
</SyntaxHighlighter>
|
||||
) : selectedVersion === 'alpine' && scriptContent.alpineMainScript ? (
|
||||
) : selectedVersion === 'alpine' && scriptContent.alpineCtScript ? (
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={tomorrow}
|
||||
@@ -247,12 +280,12 @@ export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerPr
|
||||
showLineNumbers={true}
|
||||
wrapLines={true}
|
||||
>
|
||||
{scriptContent.alpineMainScript}
|
||||
{scriptContent.alpineCtScript}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-lg text-muted-foreground">
|
||||
{selectedVersion === 'default' ? 'Default script not found' : 'Alpine script not found'}
|
||||
{selectedVersion === 'default' ? 'Default CT script not found' : 'Alpine CT script not found'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { api } from '~/trpc/react';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { X, ExternalLink, Calendar, Tag, Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
interface UpdateConfirmationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
releaseInfo: {
|
||||
tagName: string;
|
||||
name: string;
|
||||
publishedAt: string;
|
||||
htmlUrl: string;
|
||||
body?: string;
|
||||
} | null;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
}
|
||||
|
||||
export function UpdateConfirmationModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
releaseInfo,
|
||||
currentVersion,
|
||||
latestVersion
|
||||
}: UpdateConfirmationModalProps) {
|
||||
useRegisterModal(isOpen, { id: 'update-confirmation-modal', allowEscape: true, onClose });
|
||||
|
||||
if (!isOpen || !releaseInfo) 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-4xl w-full max-h-[90vh] flex flex-col border border-border">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="h-6 w-6 text-warning" />
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-card-foreground">Confirm Update</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Review the changelog before proceeding with the update
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{/* Version Info */}
|
||||
<div className="bg-muted/50 rounded-lg p-4 border border-border">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold text-card-foreground">
|
||||
{releaseInfo.name || releaseInfo.tagName}
|
||||
</h3>
|
||||
<Badge variant="default" className="text-xs">
|
||||
Latest
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<a
|
||||
href={releaseInfo.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View on GitHub"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Tag className="h-4 w-4" />
|
||||
<span>{releaseInfo.tagName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>
|
||||
{new Date(releaseInfo.publishedAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span>Updating from </span>
|
||||
<span className="font-medium text-card-foreground">v{currentVersion}</span>
|
||||
<span> to </span>
|
||||
<span className="font-medium text-card-foreground">v{latestVersion}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Changelog */}
|
||||
{releaseInfo.body ? (
|
||||
<div className="border rounded-lg p-6 border-border bg-card">
|
||||
<h4 className="text-md font-semibold text-card-foreground mb-4">Changelog</h4>
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({children}) => <h1 className="text-2xl font-bold text-card-foreground mb-4 mt-6">{children}</h1>,
|
||||
h2: ({children}) => <h2 className="text-xl font-semibold text-card-foreground mb-3 mt-5">{children}</h2>,
|
||||
h3: ({children}) => <h3 className="text-lg font-medium text-card-foreground mb-2 mt-4">{children}</h3>,
|
||||
p: ({children}) => <p className="text-card-foreground mb-3 leading-relaxed">{children}</p>,
|
||||
ul: ({children}) => <ul className="list-disc list-inside text-card-foreground mb-3 space-y-1">{children}</ul>,
|
||||
ol: ({children}) => <ol className="list-decimal list-inside text-card-foreground mb-3 space-y-1">{children}</ol>,
|
||||
li: ({children}) => <li className="text-card-foreground">{children}</li>,
|
||||
a: ({href, children}) => <a href={href} className="text-info hover:text-info/80 underline" target="_blank" rel="noopener noreferrer">{children}</a>,
|
||||
strong: ({children}) => <strong className="font-semibold text-card-foreground">{children}</strong>,
|
||||
em: ({children}) => <em className="italic text-card-foreground">{children}</em>,
|
||||
}}
|
||||
>
|
||||
{releaseInfo.body}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg p-6 border-border bg-card">
|
||||
<p className="text-muted-foreground">No changelog available for this release.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning */}
|
||||
<div className="bg-warning/10 border border-warning/30 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-warning mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-card-foreground">
|
||||
<p className="font-medium mb-1">Important:</p>
|
||||
<p className="text-muted-foreground">
|
||||
Please review the changelog above for any breaking changes or important updates before proceeding.
|
||||
The server will restart automatically after the update completes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-6 border-t border-border bg-muted/30">
|
||||
<Button onClick={onClose} variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onConfirm} variant="destructive" className="gap-2">
|
||||
<span>Proceed with Update</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,9 @@ import { api } from "~/trpc/react";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { ContextualHelpIcon } from "./ContextualHelpIcon";
|
||||
import { UpdateConfirmationModal } from "./UpdateConfirmationModal";
|
||||
|
||||
import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
interface VersionDisplayProps {
|
||||
onOpenReleaseNotes?: () => void;
|
||||
@@ -86,12 +85,8 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
const [updateLogs, setUpdateLogs] = useState<string[]>([]);
|
||||
const [shouldSubscribe, setShouldSubscribe] = useState(false);
|
||||
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
|
||||
const [showUpdateConfirmation, setShowUpdateConfirmation] = useState(false);
|
||||
const lastLogTimeRef = useRef<number>(Date.now());
|
||||
const reconnectIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasReloadedRef = useRef<boolean>(false);
|
||||
const isUpdatingRef = useRef<boolean>(false);
|
||||
const isNetworkErrorRef = useRef<boolean>(false);
|
||||
|
||||
const executeUpdate = api.version.executeUpdate.useMutation({
|
||||
onSuccess: (result) => {
|
||||
@@ -103,13 +98,11 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
setUpdateLogs(['Update started...']);
|
||||
} else {
|
||||
setIsUpdating(false);
|
||||
setShouldSubscribe(false); // Reset subscription on failure
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setUpdateResult({ success: false, message: error.message });
|
||||
setIsUpdating(false);
|
||||
setShouldSubscribe(false); // Reset subscription on error
|
||||
}
|
||||
});
|
||||
|
||||
@@ -120,49 +113,63 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
// Attempt to reconnect and reload page when server is back
|
||||
// Memoized with useCallback to prevent recreation on every render
|
||||
// Only depends on refs to avoid stale closures
|
||||
const startReconnectAttempts = useCallback(() => {
|
||||
// CRITICAL: Stricter guard - check refs BEFORE starting reconnect attempts
|
||||
// Only start if we're actually updating and haven't already started
|
||||
// Double-check isUpdating state to prevent false triggers from stale data
|
||||
if (reconnectIntervalRef.current || !isUpdatingRef.current || hasReloadedRef.current) {
|
||||
return;
|
||||
// Update logs when data changes
|
||||
useEffect(() => {
|
||||
if (updateLogsData?.success && updateLogsData.logs) {
|
||||
lastLogTimeRef.current = Date.now();
|
||||
setUpdateLogs(updateLogsData.logs);
|
||||
|
||||
if (updateLogsData.isComplete) {
|
||||
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
|
||||
setIsNetworkError(true);
|
||||
// Start reconnection attempts when we know update is complete
|
||||
startReconnectAttempts();
|
||||
}
|
||||
}
|
||||
}, [updateLogsData]);
|
||||
|
||||
// Monitor for server connection loss and auto-reload (fallback only)
|
||||
useEffect(() => {
|
||||
if (!shouldSubscribe) return;
|
||||
|
||||
// Only use this as a fallback - the main trigger should be completion detection
|
||||
const checkInterval = setInterval(() => {
|
||||
const timeSinceLastLog = Date.now() - lastLogTimeRef.current;
|
||||
|
||||
// Only start reconnection if we've been updating for at least 3 minutes
|
||||
// and no logs for 60 seconds (very conservative fallback)
|
||||
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
|
||||
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
|
||||
|
||||
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdating && !isNetworkError) {
|
||||
setIsNetworkError(true);
|
||||
setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']);
|
||||
|
||||
// Start trying to reconnect
|
||||
startReconnectAttempts();
|
||||
}
|
||||
}, 10000); // Check every 10 seconds
|
||||
|
||||
return () => clearInterval(checkInterval);
|
||||
}, [shouldSubscribe, isUpdating, updateStartTime, isNetworkError]);
|
||||
|
||||
// Attempt to reconnect and reload page when server is back
|
||||
const startReconnectAttempts = () => {
|
||||
if (reconnectIntervalRef.current) return;
|
||||
|
||||
setUpdateLogs(prev => [...prev, 'Attempting to reconnect...']);
|
||||
|
||||
reconnectIntervalRef.current = setInterval(() => {
|
||||
void (async () => {
|
||||
// Guard: Only proceed if we're still updating and in network error state
|
||||
// Check refs directly to avoid stale closures
|
||||
if (!isUpdatingRef.current || !isNetworkErrorRef.current || hasReloadedRef.current) {
|
||||
// Clear interval if we're no longer updating
|
||||
if (!isUpdatingRef.current && reconnectIntervalRef.current) {
|
||||
clearInterval(reconnectIntervalRef.current);
|
||||
reconnectIntervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to fetch the root path to check if server is back
|
||||
const response = await fetch('/', { method: 'HEAD' });
|
||||
if (response.ok || response.status === 200) {
|
||||
// Double-check we're still updating before reloading
|
||||
if (!isUpdatingRef.current || hasReloadedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark that we're about to reload to prevent multiple reloads
|
||||
hasReloadedRef.current = true;
|
||||
setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']);
|
||||
|
||||
// Clear interval and reload
|
||||
if (reconnectIntervalRef.current) {
|
||||
clearInterval(reconnectIntervalRef.current);
|
||||
reconnectIntervalRef.current = null;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -174,101 +181,18 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
}
|
||||
})();
|
||||
}, 2000);
|
||||
}, []); // Empty deps - only uses refs which are stable
|
||||
};
|
||||
|
||||
// Update logs when data changes
|
||||
// Cleanup reconnect interval on unmount
|
||||
useEffect(() => {
|
||||
// CRITICAL: Only process update logs if we're actually updating
|
||||
// This prevents stale isComplete data from triggering reloads when not updating
|
||||
if (!isUpdating) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (updateLogsData?.success && updateLogsData.logs) {
|
||||
lastLogTimeRef.current = Date.now();
|
||||
setUpdateLogs(updateLogsData.logs);
|
||||
|
||||
// CRITICAL: Only process isComplete if we're actually updating
|
||||
// Double-check isUpdating state to prevent false triggers
|
||||
if (updateLogsData.isComplete && isUpdating) {
|
||||
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
|
||||
setIsNetworkError(true);
|
||||
// Start reconnection attempts when we know update is complete
|
||||
startReconnectAttempts();
|
||||
}
|
||||
}
|
||||
}, [updateLogsData, startReconnectAttempts, isUpdating]);
|
||||
|
||||
// Monitor for server connection loss and auto-reload (fallback only)
|
||||
useEffect(() => {
|
||||
// Early return: only run if we're actually updating
|
||||
if (!shouldSubscribe || !isUpdating) return;
|
||||
|
||||
// Only use this as a fallback - the main trigger should be completion detection
|
||||
const checkInterval = setInterval(() => {
|
||||
// Check refs first to ensure we're still updating
|
||||
if (!isUpdatingRef.current || hasReloadedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeSinceLastLog = Date.now() - lastLogTimeRef.current;
|
||||
|
||||
// Only start reconnection if we've been updating for at least 3 minutes
|
||||
// and no logs for 60 seconds (very conservative fallback)
|
||||
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
|
||||
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
|
||||
|
||||
// Additional guard: check refs again before triggering
|
||||
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdatingRef.current && !isNetworkErrorRef.current) {
|
||||
setIsNetworkError(true);
|
||||
setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']);
|
||||
|
||||
// Start trying to reconnect
|
||||
startReconnectAttempts();
|
||||
}
|
||||
}, 10000); // Check every 10 seconds
|
||||
|
||||
return () => clearInterval(checkInterval);
|
||||
}, [shouldSubscribe, isUpdating, updateStartTime, startReconnectAttempts]);
|
||||
|
||||
// Keep refs in sync with state
|
||||
useEffect(() => {
|
||||
isUpdatingRef.current = isUpdating;
|
||||
}, [isUpdating]);
|
||||
|
||||
useEffect(() => {
|
||||
isNetworkErrorRef.current = isNetworkError;
|
||||
}, [isNetworkError]);
|
||||
|
||||
// Clear reconnect interval when update completes or component unmounts
|
||||
useEffect(() => {
|
||||
// If we're no longer updating, clear the reconnect interval and reset subscription
|
||||
if (!isUpdating) {
|
||||
if (reconnectIntervalRef.current) {
|
||||
clearInterval(reconnectIntervalRef.current);
|
||||
reconnectIntervalRef.current = null;
|
||||
}
|
||||
// Reset subscription to prevent stale polling
|
||||
setShouldSubscribe(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (reconnectIntervalRef.current) {
|
||||
clearInterval(reconnectIntervalRef.current);
|
||||
reconnectIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isUpdating]);
|
||||
}, []);
|
||||
|
||||
const handleUpdate = () => {
|
||||
// Show confirmation modal instead of starting update directly
|
||||
setShowUpdateConfirmation(true);
|
||||
};
|
||||
|
||||
const handleConfirmUpdate = () => {
|
||||
// Close the confirmation modal
|
||||
setShowUpdateConfirmation(false);
|
||||
// Start the actual update process
|
||||
setIsUpdating(true);
|
||||
setUpdateResult(null);
|
||||
setIsNetworkError(false);
|
||||
@@ -276,12 +200,6 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
setShouldSubscribe(false);
|
||||
setUpdateStartTime(Date.now());
|
||||
lastLogTimeRef.current = Date.now();
|
||||
hasReloadedRef.current = false; // Reset reload flag when starting new update
|
||||
// Clear any existing reconnect interval
|
||||
if (reconnectIntervalRef.current) {
|
||||
clearInterval(reconnectIntervalRef.current);
|
||||
reconnectIntervalRef.current = null;
|
||||
}
|
||||
executeUpdate.mutate();
|
||||
};
|
||||
|
||||
@@ -315,18 +233,6 @@ export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {})
|
||||
{/* Loading overlay */}
|
||||
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />}
|
||||
|
||||
{/* Update Confirmation Modal */}
|
||||
{versionStatus?.releaseInfo && (
|
||||
<UpdateConfirmationModal
|
||||
isOpen={showUpdateConfirmation}
|
||||
onClose={() => setShowUpdateConfirmation(false)}
|
||||
onConfirm={handleConfirmUpdate}
|
||||
releaseInfo={versionStatus.releaseInfo}
|
||||
currentVersion={versionStatus.currentVersion}
|
||||
latestVersion={versionStatus.latestVersion}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-2">
|
||||
<Badge
|
||||
variant={isUpToDate ? "default" : "secondary"}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { FilterState } from "./FilterBar";
|
||||
|
||||
/**
|
||||
* Returns the default FilterState with all properties initialized.
|
||||
* This serves as the single source of truth for default filter values.
|
||||
*/
|
||||
export function getDefaultFilters(): FilterState {
|
||||
return {
|
||||
searchQuery: "",
|
||||
showUpdatable: null,
|
||||
selectedTypes: [],
|
||||
selectedRepositories: [],
|
||||
sortBy: "name",
|
||||
sortOrder: "asc",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges saved filters with defaults, ensuring all FilterState properties exist.
|
||||
* This prevents crashes when loading old saved filters that are missing new properties.
|
||||
*
|
||||
* @param savedFilters - Partial or undefined saved filters from storage
|
||||
* @returns Complete FilterState with all properties guaranteed to exist
|
||||
*/
|
||||
export function mergeFiltersWithDefaults(
|
||||
savedFilters: Partial<FilterState> | undefined
|
||||
): FilterState {
|
||||
const defaults = getDefaultFilters();
|
||||
|
||||
if (!savedFilters) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
// Merge saved filters with defaults, ensuring all properties exist
|
||||
return {
|
||||
searchQuery: savedFilters.searchQuery ?? defaults.searchQuery,
|
||||
showUpdatable: savedFilters.showUpdatable ?? defaults.showUpdatable,
|
||||
selectedTypes: savedFilters.selectedTypes ?? defaults.selectedTypes,
|
||||
selectedRepositories: savedFilters.selectedRepositories ?? defaults.selectedRepositories,
|
||||
sortBy: savedFilters.sortBy ?? defaults.sortBy,
|
||||
sortOrder: savedFilters.sortOrder ?? defaults.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useState, useRef, useEffect } from 'react';
|
||||
import { ScriptsGrid } from './_components/ScriptsGrid';
|
||||
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
|
||||
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
|
||||
import { BackupsTab } from './_components/BackupsTab';
|
||||
import { ResyncButton } from './_components/ResyncButton';
|
||||
import { Terminal } from './_components/Terminal';
|
||||
import { ServerSettingsButton } from './_components/ServerSettingsButton';
|
||||
@@ -17,16 +16,16 @@ import { Button } from './_components/ui/button';
|
||||
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
|
||||
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
|
||||
import { Footer } from './_components/Footer';
|
||||
import { Package, HardDrive, FolderOpen, LogOut, Archive } from 'lucide-react';
|
||||
import { Package, HardDrive, FolderOpen, LogOut } from 'lucide-react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { useAuth } from './_components/AuthProvider';
|
||||
|
||||
export default function Home() {
|
||||
const { isAuthenticated, logout } = useAuth();
|
||||
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed' | 'backups'>(() => {
|
||||
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedTab = localStorage.getItem('activeTab') as 'scripts' | 'downloaded' | 'installed' | 'backups';
|
||||
const savedTab = localStorage.getItem('activeTab') as 'scripts' | 'downloaded' | 'installed';
|
||||
return savedTab || 'scripts';
|
||||
}
|
||||
return 'scripts';
|
||||
@@ -39,7 +38,6 @@ export default function Home() {
|
||||
const { data: scriptCardsData } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||
const { data: localScriptsData } = api.scripts.getAllDownloadedScripts.useQuery();
|
||||
const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery();
|
||||
const { data: backupsData } = api.backups.getAllBackupsGrouped.useQuery();
|
||||
const { data: versionData } = api.version.getCurrentVersion.useQuery();
|
||||
|
||||
// Save active tab to localStorage whenever it changes
|
||||
@@ -95,13 +93,6 @@ export default function Home() {
|
||||
downloaded: (() => {
|
||||
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
|
||||
|
||||
// Helper to normalize identifiers for robust matching
|
||||
const normalizeId = (s?: string): string => (s ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/\.(sh|bash|py|js|ts)$/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
// First deduplicate GitHub scripts using Map by slug
|
||||
const scriptMap = new Map<string, any>();
|
||||
|
||||
@@ -117,41 +108,17 @@ export default function Home() {
|
||||
const localScripts = localScriptsData.scripts ?? [];
|
||||
|
||||
// Count scripts that are both in deduplicated GitHub data and have local versions
|
||||
// Use the same matching logic as DownloadedScriptsTab and ScriptsGrid
|
||||
return deduplicatedGithubScripts.filter(script => {
|
||||
if (!script?.name) return false;
|
||||
|
||||
// Check if there's a corresponding local script
|
||||
return localScripts.some(local => {
|
||||
if (!local?.name) return false;
|
||||
|
||||
// Primary: Exact slug-to-slug matching (most reliable)
|
||||
if (local.slug && script.slug) {
|
||||
if (local.slug.toLowerCase() === script.slug.toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
// Also try normalized slug matching (handles filename-based slugs vs JSON slugs)
|
||||
if (normalizeId(local.slug) === normalizeId(script.slug)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary: Check install basenames (for edge cases where install script names differ from slugs)
|
||||
const normalizedLocal = normalizeId(local.name);
|
||||
const matchesInstallBasename = (script as any)?.install_basenames?.some((base: string) => normalizeId(base) === normalizedLocal) ?? false;
|
||||
if (matchesInstallBasename) return true;
|
||||
|
||||
// Tertiary: Normalized filename to normalized slug matching
|
||||
if (script.slug && normalizeId(local.name) === normalizeId(script.slug)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
const localName = local.name.replace(/\.sh$/, '');
|
||||
return localName.toLowerCase() === script.name.toLowerCase() ||
|
||||
localName.toLowerCase() === (script.slug ?? '').toLowerCase();
|
||||
});
|
||||
}).length;
|
||||
})(),
|
||||
installed: installedScriptsData?.scripts?.length ?? 0,
|
||||
backups: backupsData?.success ? backupsData.backups.length : 0
|
||||
installed: installedScriptsData?.scripts?.length ?? 0
|
||||
};
|
||||
|
||||
const scrollToTerminal = () => {
|
||||
@@ -276,22 +243,6 @@ export default function Home() {
|
||||
</span>
|
||||
<ContextualHelpIcon section="installed-scripts" tooltip="Help with Installed Scripts" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="null"
|
||||
onClick={() => setActiveTab('backups')}
|
||||
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||
activeTab === 'backups'
|
||||
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
|
||||
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
|
||||
}`}>
|
||||
<Archive className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Backups</span>
|
||||
<span className="sm:hidden">Backups</span>
|
||||
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||
{scriptCounts.backups}
|
||||
</span>
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@@ -322,10 +273,6 @@ export default function Home() {
|
||||
{activeTab === 'installed' && (
|
||||
<InstalledScriptsTab />
|
||||
)}
|
||||
|
||||
{activeTab === 'backups' && (
|
||||
<BackupsTab />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -2,8 +2,6 @@ import { scriptsRouter } from "~/server/api/routers/scripts";
|
||||
import { installedScriptsRouter } from "~/server/api/routers/installedScripts";
|
||||
import { serversRouter } from "~/server/api/routers/servers";
|
||||
import { versionRouter } from "~/server/api/routers/version";
|
||||
import { backupsRouter } from "~/server/api/routers/backups";
|
||||
import { pbsCredentialsRouter } from "~/server/api/routers/pbsCredentials";
|
||||
import { repositoriesRouter } from "~/server/api/routers/repositories";
|
||||
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||
|
||||
@@ -17,8 +15,6 @@ export const appRouter = createTRPCRouter({
|
||||
installedScripts: installedScriptsRouter,
|
||||
servers: serversRouter,
|
||||
version: versionRouter,
|
||||
backups: backupsRouter,
|
||||
pbsCredentials: pbsCredentialsRouter,
|
||||
repositories: repositoriesRouter,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import { createTRPCRouter, publicProcedure } from '~/server/api/trpc';
|
||||
import { getDatabase } from '~/server/database-prisma';
|
||||
import { getBackupService } from '~/server/services/backupService';
|
||||
import { getRestoreService } from '~/server/services/restoreService';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
|
||||
export const backupsRouter = createTRPCRouter({
|
||||
// Get all backups grouped by container ID
|
||||
getAllBackupsGrouped: publicProcedure
|
||||
.query(async () => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const groupedBackups = await db.getBackupsGroupedByContainer();
|
||||
|
||||
// Convert Map to array format for frontend
|
||||
const result: Array<{
|
||||
container_id: string;
|
||||
hostname: string;
|
||||
backups: Array<{
|
||||
id: number;
|
||||
backup_name: string;
|
||||
backup_path: string;
|
||||
size: bigint | null;
|
||||
created_at: Date | null;
|
||||
storage_name: string;
|
||||
storage_type: string;
|
||||
discovered_at: Date;
|
||||
server_name: string | null;
|
||||
server_color: string | null;
|
||||
}>;
|
||||
}> = [];
|
||||
|
||||
for (const [containerId, backups] of groupedBackups.entries()) {
|
||||
if (backups.length === 0) continue;
|
||||
|
||||
// Get hostname from first backup (all backups for same container should have same hostname)
|
||||
const hostname = backups[0]?.hostname || '';
|
||||
|
||||
result.push({
|
||||
container_id: containerId,
|
||||
hostname,
|
||||
backups: backups.map(backup => ({
|
||||
id: backup.id,
|
||||
backup_name: backup.backup_name,
|
||||
backup_path: backup.backup_path,
|
||||
size: backup.size,
|
||||
created_at: backup.created_at,
|
||||
storage_name: backup.storage_name,
|
||||
storage_type: backup.storage_type,
|
||||
discovered_at: backup.discovered_at,
|
||||
server_id: backup.server_id,
|
||||
server_name: backup.server?.name ?? null,
|
||||
server_color: backup.server?.color ?? null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
backups: result,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in getAllBackupsGrouped:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch backups',
|
||||
backups: [],
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Discover backups for all containers
|
||||
discoverBackups: publicProcedure
|
||||
.mutation(async () => {
|
||||
try {
|
||||
const backupService = getBackupService();
|
||||
await backupService.discoverAllBackups();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Backup discovery completed successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in discoverBackups:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to discover backups',
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Get restore progress from log file
|
||||
getRestoreProgress: publicProcedure
|
||||
.query(async () => {
|
||||
try {
|
||||
const logPath = join(process.cwd(), 'restore.log');
|
||||
|
||||
if (!existsSync(logPath)) {
|
||||
return {
|
||||
success: true,
|
||||
logs: [],
|
||||
isComplete: false
|
||||
};
|
||||
}
|
||||
|
||||
const logs = await readFile(logPath, 'utf-8');
|
||||
const logLines = logs.split('\n')
|
||||
.filter(line => line.trim())
|
||||
.map(line => stripAnsi(line)); // Strip ANSI color codes
|
||||
|
||||
// Check if restore is complete by looking for completion indicators
|
||||
const isComplete = logLines.some(line =>
|
||||
line.includes('complete: Restore completed successfully') ||
|
||||
line.includes('error: Error:') ||
|
||||
line.includes('Restore completed successfully') ||
|
||||
line.includes('Restore failed')
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
logs: logLines,
|
||||
isComplete
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error reading restore logs:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to read restore logs',
|
||||
logs: [],
|
||||
isComplete: false
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Restore backup
|
||||
restoreBackup: publicProcedure
|
||||
.input(z.object({
|
||||
backupId: z.number(),
|
||||
containerId: z.string(),
|
||||
serverId: z.number(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const restoreService = getRestoreService();
|
||||
const result = await restoreService.executeRestore(
|
||||
input.backupId,
|
||||
input.containerId,
|
||||
input.serverId
|
||||
);
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
error: result.error,
|
||||
progress: result.progress,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in restoreBackup:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to restore backup',
|
||||
progress: [],
|
||||
};
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||
import { getDatabase } from "~/server/database-prisma";
|
||||
import { createHash } from "crypto";
|
||||
import type { Server } from "~/types/server";
|
||||
import { getStorageService } from "~/server/services/storageService";
|
||||
|
||||
// Helper function to parse raw LXC config into structured data
|
||||
function parseRawConfig(rawConfig: string): any {
|
||||
@@ -2039,163 +2038,5 @@ EOFCONFIG`;
|
||||
.getLXCConfig({ scriptId: input.scriptId, forceSync: true });
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
// Get backup-capable storages for a server
|
||||
getBackupStorages: publicProcedure
|
||||
.input(z.object({
|
||||
serverId: z.number(),
|
||||
forceRefresh: z.boolean().optional().default(false)
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const server = await db.getServerById(input.serverId);
|
||||
|
||||
if (!server) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Server not found',
|
||||
storages: [],
|
||||
cached: false
|
||||
};
|
||||
}
|
||||
|
||||
const storageService = getStorageService();
|
||||
const { default: SSHService } = await import('~/server/ssh-service');
|
||||
const { getSSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||
const sshService = new SSHService();
|
||||
const sshExecutionService = getSSHExecutionService();
|
||||
|
||||
// Test SSH connection first
|
||||
const connectionTest = await sshService.testSSHConnection(server as Server);
|
||||
if (!(connectionTest as any).success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`,
|
||||
storages: [],
|
||||
cached: false
|
||||
};
|
||||
}
|
||||
|
||||
// Get server hostname to filter storages
|
||||
let serverHostname = '';
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sshExecutionService.executeCommand(
|
||||
server as Server,
|
||||
'hostname',
|
||||
(data: string) => {
|
||||
serverHostname += data;
|
||||
},
|
||||
(error: string) => {
|
||||
reject(new Error(`Failed to get hostname: ${error}`));
|
||||
},
|
||||
(exitCode: number) => {
|
||||
if (exitCode === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`hostname command failed with exit code ${exitCode}`));
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting server hostname:', error);
|
||||
// Continue without filtering if hostname can't be retrieved
|
||||
}
|
||||
|
||||
const normalizedHostname = serverHostname.trim().toLowerCase();
|
||||
|
||||
// Check if we have cached data
|
||||
const wasCached = !input.forceRefresh;
|
||||
|
||||
// Fetch storages (will use cache if not forcing refresh)
|
||||
const allStorages = await storageService.getStorages(server as Server, input.forceRefresh);
|
||||
|
||||
// Filter storages by node hostname matching
|
||||
const applicableStorages = allStorages.filter(storage => {
|
||||
// If storage has no nodes specified, it's available on all nodes
|
||||
if (!storage.nodes || storage.nodes.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we couldn't get hostname, include all storages (fallback)
|
||||
if (!normalizedHostname) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if server hostname is in the nodes array (case-insensitive, trimmed)
|
||||
const normalizedNodes = storage.nodes.map(node => node.trim().toLowerCase());
|
||||
return normalizedNodes.includes(normalizedHostname);
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
storages: applicableStorages,
|
||||
cached: wasCached && applicableStorages.length > 0
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in getBackupStorages:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch storages',
|
||||
storages: [],
|
||||
cached: false
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Execute backup for a container
|
||||
executeBackup: publicProcedure
|
||||
.input(z.object({
|
||||
containerId: z.string(),
|
||||
storage: z.string(),
|
||||
serverId: z.number()
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const server = await db.getServerById(input.serverId);
|
||||
|
||||
if (!server) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Server not found',
|
||||
executionId: null
|
||||
};
|
||||
}
|
||||
|
||||
const { default: SSHService } = await import('~/server/ssh-service');
|
||||
const sshService = new SSHService();
|
||||
|
||||
// Test SSH connection first
|
||||
const connectionTest = await sshService.testSSHConnection(server as Server);
|
||||
if (!(connectionTest as any).success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`,
|
||||
executionId: null
|
||||
};
|
||||
}
|
||||
|
||||
// Generate execution ID for websocket tracking
|
||||
const executionId = `backup_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
executionId,
|
||||
containerId: input.containerId,
|
||||
storage: input.storage,
|
||||
server: server as Server
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in executeBackup:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to execute backup',
|
||||
executionId: null
|
||||
};
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
import { createTRPCRouter, publicProcedure } from '~/server/api/trpc';
|
||||
import { getDatabase } from '~/server/database-prisma';
|
||||
|
||||
export const pbsCredentialsRouter = createTRPCRouter({
|
||||
// Get credentials for a specific storage
|
||||
getCredentialsForStorage: publicProcedure
|
||||
.input(z.object({
|
||||
serverId: z.number(),
|
||||
storageName: z.string(),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const credential = await db.getPBSCredential(input.serverId, input.storageName);
|
||||
|
||||
if (!credential) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'PBS credentials not found',
|
||||
credential: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
server_id: credential.server_id,
|
||||
storage_name: credential.storage_name,
|
||||
pbs_ip: credential.pbs_ip,
|
||||
pbs_datastore: credential.pbs_datastore,
|
||||
pbs_fingerprint: credential.pbs_fingerprint,
|
||||
// Don't return password for security
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in getCredentialsForStorage:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch PBS credentials',
|
||||
credential: null,
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Get all PBS credentials for a server
|
||||
getAllCredentialsForServer: publicProcedure
|
||||
.input(z.object({
|
||||
serverId: z.number(),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const credentials = await db.getPBSCredentialsByServer(input.serverId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
credentials: credentials.map(c => ({
|
||||
id: c.id,
|
||||
server_id: c.server_id,
|
||||
storage_name: c.storage_name,
|
||||
pbs_ip: c.pbs_ip,
|
||||
pbs_datastore: c.pbs_datastore,
|
||||
pbs_fingerprint: c.pbs_fingerprint,
|
||||
// Don't return password for security
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in getAllCredentialsForServer:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch PBS credentials',
|
||||
credentials: [],
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Save/update PBS credentials
|
||||
saveCredentials: publicProcedure
|
||||
.input(z.object({
|
||||
serverId: z.number(),
|
||||
storageName: z.string(),
|
||||
pbs_ip: z.string(),
|
||||
pbs_datastore: z.string(),
|
||||
pbs_password: z.string().optional(), // Optional to allow updating without changing password
|
||||
pbs_fingerprint: z.string(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
|
||||
// If password is not provided, fetch existing credential to preserve password
|
||||
let passwordToSave = input.pbs_password;
|
||||
if (!passwordToSave) {
|
||||
const existing = await db.getPBSCredential(input.serverId, input.storageName);
|
||||
if (existing) {
|
||||
passwordToSave = existing.pbs_password;
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Password is required for new credentials',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await db.createOrUpdatePBSCredential({
|
||||
server_id: input.serverId,
|
||||
storage_name: input.storageName,
|
||||
pbs_ip: input.pbs_ip,
|
||||
pbs_datastore: input.pbs_datastore,
|
||||
pbs_password: passwordToSave,
|
||||
pbs_fingerprint: input.pbs_fingerprint,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'PBS credentials saved successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in saveCredentials:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to save PBS credentials',
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Delete PBS credentials
|
||||
deleteCredentials: publicProcedure
|
||||
.input(z.object({
|
||||
serverId: z.number(),
|
||||
storageName: z.string(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
await db.deletePBSCredential(input.serverId, input.storageName);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'PBS credentials deleted successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in deleteCredentials:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete PBS credentials',
|
||||
};
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -111,8 +111,7 @@ export const versionRouter = createTRPCRouter({
|
||||
tagName: release.tag_name,
|
||||
name: release.name,
|
||||
publishedAt: release.published_at,
|
||||
htmlUrl: release.html_url,
|
||||
body: release.body
|
||||
htmlUrl: release.html_url
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -271,161 +271,6 @@ class DatabaseServicePrisma {
|
||||
});
|
||||
}
|
||||
|
||||
// Backup CRUD operations
|
||||
async createOrUpdateBackup(backupData) {
|
||||
// Find existing backup by container_id, server_id, and backup_path
|
||||
const existing = await prisma.backup.findFirst({
|
||||
where: {
|
||||
container_id: backupData.container_id,
|
||||
server_id: backupData.server_id,
|
||||
backup_path: backupData.backup_path,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// Update existing backup
|
||||
return await prisma.backup.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
hostname: backupData.hostname,
|
||||
backup_name: backupData.backup_name,
|
||||
size: backupData.size,
|
||||
created_at: backupData.created_at,
|
||||
storage_name: backupData.storage_name,
|
||||
storage_type: backupData.storage_type,
|
||||
discovered_at: new Date(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Create new backup
|
||||
return await prisma.backup.create({
|
||||
data: {
|
||||
container_id: backupData.container_id,
|
||||
server_id: backupData.server_id,
|
||||
hostname: backupData.hostname,
|
||||
backup_name: backupData.backup_name,
|
||||
backup_path: backupData.backup_path,
|
||||
size: backupData.size,
|
||||
created_at: backupData.created_at,
|
||||
storage_name: backupData.storage_name,
|
||||
storage_type: backupData.storage_type,
|
||||
discovered_at: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getAllBackups() {
|
||||
return await prisma.backup.findMany({
|
||||
include: {
|
||||
server: true,
|
||||
},
|
||||
orderBy: [
|
||||
{ container_id: 'asc' },
|
||||
{ created_at: 'desc' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async getBackupById(id) {
|
||||
return await prisma.backup.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
server: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getBackupsByContainerId(containerId) {
|
||||
return await prisma.backup.findMany({
|
||||
where: { container_id: containerId },
|
||||
include: {
|
||||
server: true,
|
||||
},
|
||||
orderBy: { created_at: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBackupsForContainer(containerId, serverId) {
|
||||
return await prisma.backup.deleteMany({
|
||||
where: {
|
||||
container_id: containerId,
|
||||
server_id: serverId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getBackupsGroupedByContainer() {
|
||||
const backups = await this.getAllBackups();
|
||||
const grouped = new Map();
|
||||
|
||||
for (const backup of backups) {
|
||||
const key = backup.container_id;
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, []);
|
||||
}
|
||||
grouped.get(key).push(backup);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
// PBS Credentials CRUD operations
|
||||
async createOrUpdatePBSCredential(credentialData) {
|
||||
return await prisma.pBSStorageCredential.upsert({
|
||||
where: {
|
||||
server_id_storage_name: {
|
||||
server_id: credentialData.server_id,
|
||||
storage_name: credentialData.storage_name,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
pbs_ip: credentialData.pbs_ip,
|
||||
pbs_datastore: credentialData.pbs_datastore,
|
||||
pbs_password: credentialData.pbs_password,
|
||||
pbs_fingerprint: credentialData.pbs_fingerprint,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
create: {
|
||||
server_id: credentialData.server_id,
|
||||
storage_name: credentialData.storage_name,
|
||||
pbs_ip: credentialData.pbs_ip,
|
||||
pbs_datastore: credentialData.pbs_datastore,
|
||||
pbs_password: credentialData.pbs_password,
|
||||
pbs_fingerprint: credentialData.pbs_fingerprint,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getPBSCredential(serverId, storageName) {
|
||||
return await prisma.pBSStorageCredential.findUnique({
|
||||
where: {
|
||||
server_id_storage_name: {
|
||||
server_id: serverId,
|
||||
storage_name: storageName,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getPBSCredentialsByServer(serverId) {
|
||||
return await prisma.pBSStorageCredential.findMany({
|
||||
where: { server_id: serverId },
|
||||
orderBy: { storage_name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async deletePBSCredential(serverId, storageName) {
|
||||
return await prisma.pBSStorageCredential.delete({
|
||||
where: {
|
||||
server_id_storage_name: {
|
||||
server_id: serverId,
|
||||
storage_name: storageName,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async close() {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
@@ -298,197 +298,6 @@ class DatabaseServicePrisma {
|
||||
});
|
||||
}
|
||||
|
||||
// Backup CRUD operations
|
||||
async createOrUpdateBackup(backupData: {
|
||||
container_id: string;
|
||||
server_id: number;
|
||||
hostname: string;
|
||||
backup_name: string;
|
||||
backup_path: string;
|
||||
size?: bigint;
|
||||
created_at?: Date;
|
||||
storage_name: string;
|
||||
storage_type: 'local' | 'storage' | 'pbs';
|
||||
}) {
|
||||
// Find existing backup by container_id, server_id, and backup_path
|
||||
const existing = await prisma.backup.findFirst({
|
||||
where: {
|
||||
container_id: backupData.container_id,
|
||||
server_id: backupData.server_id,
|
||||
backup_path: backupData.backup_path,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// Update existing backup
|
||||
return await prisma.backup.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
hostname: backupData.hostname,
|
||||
backup_name: backupData.backup_name,
|
||||
size: backupData.size,
|
||||
created_at: backupData.created_at,
|
||||
storage_name: backupData.storage_name,
|
||||
storage_type: backupData.storage_type,
|
||||
discovered_at: new Date(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Create new backup
|
||||
return await prisma.backup.create({
|
||||
data: {
|
||||
container_id: backupData.container_id,
|
||||
server_id: backupData.server_id,
|
||||
hostname: backupData.hostname,
|
||||
backup_name: backupData.backup_name,
|
||||
backup_path: backupData.backup_path,
|
||||
size: backupData.size,
|
||||
created_at: backupData.created_at,
|
||||
storage_name: backupData.storage_name,
|
||||
storage_type: backupData.storage_type,
|
||||
discovered_at: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getAllBackups() {
|
||||
return await prisma.backup.findMany({
|
||||
include: {
|
||||
server: true,
|
||||
},
|
||||
orderBy: [
|
||||
{ container_id: 'asc' },
|
||||
{ created_at: 'desc' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async getBackupById(id: number) {
|
||||
return await prisma.backup.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
server: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getBackupsByContainerId(containerId: string) {
|
||||
return await prisma.backup.findMany({
|
||||
where: { container_id: containerId },
|
||||
include: {
|
||||
server: true,
|
||||
},
|
||||
orderBy: { created_at: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBackupsForContainer(containerId: string, serverId: number) {
|
||||
return await prisma.backup.deleteMany({
|
||||
where: {
|
||||
container_id: containerId,
|
||||
server_id: serverId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getBackupsGroupedByContainer(): Promise<Map<string, Array<{
|
||||
id: number;
|
||||
container_id: string;
|
||||
server_id: number;
|
||||
hostname: string;
|
||||
backup_name: string;
|
||||
backup_path: string;
|
||||
size: bigint | null;
|
||||
created_at: Date | null;
|
||||
storage_name: string;
|
||||
storage_type: string;
|
||||
discovered_at: Date;
|
||||
server: {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
user: string;
|
||||
color: string | null;
|
||||
} | null;
|
||||
}>>> {
|
||||
const backups = await this.getAllBackups();
|
||||
const grouped = new Map<string, typeof backups>();
|
||||
|
||||
for (const backup of backups) {
|
||||
const key = backup.container_id;
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, []);
|
||||
}
|
||||
grouped.get(key)!.push(backup);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
// PBS Credentials CRUD operations
|
||||
async createOrUpdatePBSCredential(credentialData: {
|
||||
server_id: number;
|
||||
storage_name: string;
|
||||
pbs_ip: string;
|
||||
pbs_datastore: string;
|
||||
pbs_password: string;
|
||||
pbs_fingerprint: string;
|
||||
}) {
|
||||
return await prisma.pBSStorageCredential.upsert({
|
||||
where: {
|
||||
server_id_storage_name: {
|
||||
server_id: credentialData.server_id,
|
||||
storage_name: credentialData.storage_name,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
pbs_ip: credentialData.pbs_ip,
|
||||
pbs_datastore: credentialData.pbs_datastore,
|
||||
pbs_password: credentialData.pbs_password,
|
||||
pbs_fingerprint: credentialData.pbs_fingerprint,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
create: {
|
||||
server_id: credentialData.server_id,
|
||||
storage_name: credentialData.storage_name,
|
||||
pbs_ip: credentialData.pbs_ip,
|
||||
pbs_datastore: credentialData.pbs_datastore,
|
||||
pbs_password: credentialData.pbs_password,
|
||||
pbs_fingerprint: credentialData.pbs_fingerprint,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getPBSCredential(serverId: number, storageName: string) {
|
||||
return await prisma.pBSStorageCredential.findUnique({
|
||||
where: {
|
||||
server_id_storage_name: {
|
||||
server_id: serverId,
|
||||
storage_name: storageName,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getPBSCredentialsByServer(serverId: number) {
|
||||
return await prisma.pBSStorageCredential.findMany({
|
||||
where: { server_id: serverId },
|
||||
orderBy: { storage_name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async deletePBSCredential(serverId: number, storageName: string) {
|
||||
return await prisma.pBSStorageCredential.delete({
|
||||
where: {
|
||||
server_id_storage_name: {
|
||||
server_id: serverId,
|
||||
storage_name: storageName,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async close() {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
@@ -1,690 +0,0 @@
|
||||
import { getSSHExecutionService } from '../ssh-execution-service';
|
||||
import { getStorageService } from './storageService';
|
||||
import { getDatabase } from '../database-prisma';
|
||||
import type { Server } from '~/types/server';
|
||||
import type { Storage } from './storageService';
|
||||
|
||||
export interface BackupData {
|
||||
container_id: string;
|
||||
server_id: number;
|
||||
hostname: string;
|
||||
backup_name: string;
|
||||
backup_path: string;
|
||||
size?: bigint;
|
||||
created_at?: Date;
|
||||
storage_name: string;
|
||||
storage_type: 'local' | 'storage' | 'pbs';
|
||||
}
|
||||
|
||||
class BackupService {
|
||||
/**
|
||||
* Get server hostname via SSH
|
||||
*/
|
||||
async getServerHostname(server: Server): Promise<string> {
|
||||
const sshService = getSSHExecutionService();
|
||||
let hostname = '';
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
'hostname',
|
||||
(data: string) => {
|
||||
hostname += data;
|
||||
},
|
||||
(error: string) => {
|
||||
reject(new Error(`Failed to get hostname: ${error}`));
|
||||
},
|
||||
(exitCode: number) => {
|
||||
if (exitCode === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`hostname command failed with exit code ${exitCode}`));
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return hostname.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover local backups in /var/lib/vz/dump/
|
||||
*/
|
||||
async discoverLocalBackups(server: Server, ctId: string, hostname: string): Promise<BackupData[]> {
|
||||
const sshService = getSSHExecutionService();
|
||||
const backups: BackupData[] = [];
|
||||
|
||||
// Find backup files matching pattern (with timeout)
|
||||
const findCommand = `timeout 10 find /var/lib/vz/dump/ -type f -name "vzdump-lxc-${ctId}-*.tar*" 2>/dev/null`;
|
||||
let findOutput = '';
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
findCommand,
|
||||
(data: string) => {
|
||||
findOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
// Ignore errors - directory might not exist
|
||||
resolve();
|
||||
},
|
||||
(exitCode: number) => {
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
}),
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, 15000); // 15 second timeout
|
||||
})
|
||||
]);
|
||||
|
||||
const backupPaths = findOutput.trim().split('\n').filter(path => path.trim());
|
||||
|
||||
// Get detailed info for each backup file
|
||||
for (const backupPath of backupPaths) {
|
||||
if (!backupPath.trim()) continue;
|
||||
|
||||
try {
|
||||
// Get file size and modification time
|
||||
const statCommand = `stat -c "%s|%Y|%n" "${backupPath}" 2>/dev/null || stat -f "%z|%m|%N" "${backupPath}" 2>/dev/null || echo ""`;
|
||||
let statOutput = '';
|
||||
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
statCommand,
|
||||
(data: string) => {
|
||||
statOutput += data;
|
||||
},
|
||||
() => resolve(),
|
||||
() => resolve()
|
||||
);
|
||||
}),
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), 5000); // 5 second timeout for stat
|
||||
})
|
||||
]);
|
||||
|
||||
const statParts = statOutput.trim().split('|');
|
||||
const fileName = backupPath.split('/').pop() || backupPath;
|
||||
|
||||
if (statParts.length >= 2 && statParts[0] && statParts[1]) {
|
||||
const size = BigInt(statParts[0] || '0');
|
||||
const mtime = parseInt(statParts[1] || '0', 10);
|
||||
|
||||
backups.push({
|
||||
container_id: ctId,
|
||||
server_id: server.id,
|
||||
hostname,
|
||||
backup_name: fileName,
|
||||
backup_path: backupPath,
|
||||
size,
|
||||
created_at: mtime > 0 ? new Date(mtime * 1000) : undefined,
|
||||
storage_name: 'local',
|
||||
storage_type: 'local',
|
||||
});
|
||||
} else {
|
||||
// If stat fails, still add the backup with minimal info
|
||||
backups.push({
|
||||
container_id: ctId,
|
||||
server_id: server.id,
|
||||
hostname,
|
||||
backup_name: fileName,
|
||||
backup_path: backupPath,
|
||||
size: undefined,
|
||||
created_at: undefined,
|
||||
storage_name: 'local',
|
||||
storage_type: 'local',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Still try to add the backup even if stat fails
|
||||
const fileName = backupPath.split('/').pop() || backupPath;
|
||||
backups.push({
|
||||
container_id: ctId,
|
||||
server_id: server.id,
|
||||
hostname,
|
||||
backup_name: fileName,
|
||||
backup_path: backupPath,
|
||||
size: undefined,
|
||||
created_at: undefined,
|
||||
storage_name: 'local',
|
||||
storage_type: 'local',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error discovering local backups for CT ${ctId}:`, error);
|
||||
}
|
||||
|
||||
return backups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover backups in mounted storage (/mnt/pve/<storage>/dump/)
|
||||
*/
|
||||
async discoverStorageBackups(server: Server, storage: Storage, ctId: string, hostname: string): Promise<BackupData[]> {
|
||||
const sshService = getSSHExecutionService();
|
||||
const backups: BackupData[] = [];
|
||||
|
||||
const dumpPath = `/mnt/pve/${storage.name}/dump/`;
|
||||
const findCommand = `timeout 10 find "${dumpPath}" -type f -name "vzdump-lxc-${ctId}-*.tar*" 2>/dev/null`;
|
||||
let findOutput = '';
|
||||
|
||||
console.log(`[BackupService] Discovering storage backups for CT ${ctId} on ${storage.name}`);
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
findCommand,
|
||||
(data: string) => {
|
||||
findOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
// Ignore errors - storage might not be mounted
|
||||
resolve();
|
||||
},
|
||||
(exitCode: number) => {
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
}),
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
console.log(`[BackupService] Storage backup discovery timeout for ${storage.name}`);
|
||||
resolve();
|
||||
}, 15000); // 15 second timeout
|
||||
})
|
||||
]);
|
||||
|
||||
const backupPaths = findOutput.trim().split('\n').filter(path => path.trim());
|
||||
console.log(`[BackupService] Found ${backupPaths.length} backup files for CT ${ctId} on storage ${storage.name}`);
|
||||
|
||||
// Get detailed info for each backup file
|
||||
for (const backupPath of backupPaths) {
|
||||
if (!backupPath.trim()) continue;
|
||||
|
||||
try {
|
||||
const statCommand = `stat -c "%s|%Y|%n" "${backupPath}" 2>/dev/null || stat -f "%z|%m|%N" "${backupPath}" 2>/dev/null || echo ""`;
|
||||
let statOutput = '';
|
||||
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
statCommand,
|
||||
(data: string) => {
|
||||
statOutput += data;
|
||||
},
|
||||
() => resolve(),
|
||||
() => resolve()
|
||||
);
|
||||
}),
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), 5000); // 5 second timeout for stat
|
||||
})
|
||||
]);
|
||||
|
||||
const statParts = statOutput.trim().split('|');
|
||||
const fileName = backupPath.split('/').pop() || backupPath;
|
||||
|
||||
if (statParts.length >= 2 && statParts[0] && statParts[1]) {
|
||||
const size = BigInt(statParts[0] || '0');
|
||||
const mtime = parseInt(statParts[1] || '0', 10);
|
||||
|
||||
backups.push({
|
||||
container_id: ctId,
|
||||
server_id: server.id,
|
||||
hostname,
|
||||
backup_name: fileName,
|
||||
backup_path: backupPath,
|
||||
size,
|
||||
created_at: mtime > 0 ? new Date(mtime * 1000) : undefined,
|
||||
storage_name: storage.name,
|
||||
storage_type: 'storage',
|
||||
});
|
||||
console.log(`[BackupService] Added storage backup: ${fileName} from ${storage.name}`);
|
||||
} else {
|
||||
// If stat fails, still add the backup with minimal info
|
||||
console.log(`[BackupService] Stat failed for ${fileName}, adding backup without size/date`);
|
||||
backups.push({
|
||||
container_id: ctId,
|
||||
server_id: server.id,
|
||||
hostname,
|
||||
backup_name: fileName,
|
||||
backup_path: backupPath,
|
||||
size: undefined,
|
||||
created_at: undefined,
|
||||
storage_name: storage.name,
|
||||
storage_type: 'storage',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing backup ${backupPath}:`, error);
|
||||
// Still try to add the backup even if stat fails
|
||||
const fileName = backupPath.split('/').pop() || backupPath;
|
||||
backups.push({
|
||||
container_id: ctId,
|
||||
server_id: server.id,
|
||||
hostname,
|
||||
backup_name: fileName,
|
||||
backup_path: backupPath,
|
||||
size: undefined,
|
||||
created_at: undefined,
|
||||
storage_name: storage.name,
|
||||
storage_type: 'storage',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[BackupService] Total storage backups found for CT ${ctId} on ${storage.name}: ${backups.length}`);
|
||||
} catch (error) {
|
||||
console.error(`Error discovering storage backups for CT ${ctId} on ${storage.name}:`, error);
|
||||
}
|
||||
|
||||
return backups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login to PBS using stored credentials
|
||||
*/
|
||||
async loginToPBS(server: Server, storage: Storage): Promise<boolean> {
|
||||
const db = getDatabase();
|
||||
const credential = await db.getPBSCredential(server.id, storage.name);
|
||||
|
||||
if (!credential) {
|
||||
console.log(`[BackupService] No PBS credentials found for storage ${storage.name}, skipping PBS discovery`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const sshService = getSSHExecutionService();
|
||||
const storageService = getStorageService();
|
||||
const pbsInfo = storageService.getPBSStorageInfo(storage);
|
||||
|
||||
// Use IP and datastore from credentials (they override config if different)
|
||||
const pbsIp = credential.pbs_ip || pbsInfo.pbs_ip;
|
||||
const pbsDatastore = credential.pbs_datastore || pbsInfo.pbs_datastore;
|
||||
|
||||
if (!pbsIp || !pbsDatastore) {
|
||||
console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build login command
|
||||
// Format: proxmox-backup-client login --repository root@pam@<IP>:<DATASTORE>
|
||||
// PBS supports PBS_PASSWORD and PBS_REPOSITORY environment variables for non-interactive login
|
||||
const repository = `root@pam@${pbsIp}:${pbsDatastore}`;
|
||||
|
||||
// Escape password for shell safety (single quotes)
|
||||
const escapedPassword = credential.pbs_password.replace(/'/g, "'\\''");
|
||||
|
||||
// Use PBS_PASSWORD environment variable for non-interactive authentication
|
||||
// Auto-accept fingerprint by piping "y" to stdin
|
||||
// PBS will use PBS_PASSWORD env var if available, avoiding interactive prompt
|
||||
const fullCommand = `echo "y" | PBS_PASSWORD='${escapedPassword}' PBS_REPOSITORY='${repository}' timeout 10 proxmox-backup-client login --repository ${repository} 2>&1`;
|
||||
|
||||
console.log(`[BackupService] Logging into PBS: ${repository}`);
|
||||
|
||||
let loginOutput = '';
|
||||
let loginSuccess = false;
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
fullCommand,
|
||||
(data: string) => {
|
||||
loginOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
console.log(`[BackupService] PBS login error: ${error}`);
|
||||
resolve();
|
||||
},
|
||||
(exitCode: number) => {
|
||||
loginSuccess = exitCode === 0;
|
||||
if (loginSuccess) {
|
||||
console.log(`[BackupService] Successfully logged into PBS: ${repository}`);
|
||||
} else {
|
||||
console.log(`[BackupService] PBS login failed with exit code ${exitCode}`);
|
||||
console.log(`[BackupService] Login output: ${loginOutput}`);
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
}),
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
console.log(`[BackupService] PBS login timeout`);
|
||||
resolve();
|
||||
}, 15000); // 15 second timeout
|
||||
})
|
||||
]);
|
||||
|
||||
// Check if login was successful (look for success indicators in output)
|
||||
if (loginSuccess || loginOutput.includes('successfully') || loginOutput.includes('logged in')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error(`[BackupService] Error during PBS login:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover PBS backups using proxmox-backup-client
|
||||
*/
|
||||
async discoverPBSBackups(server: Server, storage: Storage, ctId: string, hostname: string): Promise<BackupData[]> {
|
||||
const sshService = getSSHExecutionService();
|
||||
const backups: BackupData[] = [];
|
||||
|
||||
// Login to PBS first
|
||||
const loggedIn = await this.loginToPBS(server, storage);
|
||||
if (!loggedIn) {
|
||||
console.log(`[BackupService] Failed to login to PBS for storage ${storage.name}, skipping backup discovery`);
|
||||
return backups;
|
||||
}
|
||||
|
||||
// Get PBS credentials to build full repository string
|
||||
const db = getDatabase();
|
||||
const credential = await db.getPBSCredential(server.id, storage.name);
|
||||
if (!credential) {
|
||||
console.log(`[BackupService] No PBS credentials found for storage ${storage.name}`);
|
||||
return backups;
|
||||
}
|
||||
|
||||
const storageService = getStorageService();
|
||||
const pbsInfo = storageService.getPBSStorageInfo(storage);
|
||||
const pbsIp = credential.pbs_ip || pbsInfo.pbs_ip;
|
||||
const pbsDatastore = credential.pbs_datastore || pbsInfo.pbs_datastore;
|
||||
|
||||
if (!pbsIp || !pbsDatastore) {
|
||||
console.log(`[BackupService] Missing PBS IP or datastore for storage ${storage.name}`);
|
||||
return backups;
|
||||
}
|
||||
|
||||
// Build full repository string: root@pam@<IP>:<DATASTORE>
|
||||
const repository = `root@pam@${pbsIp}:${pbsDatastore}`;
|
||||
|
||||
// Use correct command: snapshot list ct/<CT_ID> --repository <full_repo_string>
|
||||
const command = `timeout 30 proxmox-backup-client snapshot list ct/${ctId} --repository ${repository} 2>&1 || echo "PBS_ERROR"`;
|
||||
let output = '';
|
||||
|
||||
console.log(`[BackupService] Discovering PBS backups for CT ${ctId} on repository ${repository}`);
|
||||
|
||||
try {
|
||||
// Add timeout to prevent hanging
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve, reject) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
command,
|
||||
(data: string) => {
|
||||
output += data;
|
||||
},
|
||||
(error: string) => {
|
||||
console.log(`[BackupService] PBS command error: ${error}`);
|
||||
resolve();
|
||||
},
|
||||
(exitCode: number) => {
|
||||
console.log(`[BackupService] PBS command completed with exit code ${exitCode}`);
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
}),
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
console.log(`[BackupService] PBS discovery timeout, continuing...`);
|
||||
resolve();
|
||||
}, 35000); // 35 second timeout (command has 30s timeout, so this is a safety net)
|
||||
})
|
||||
]);
|
||||
|
||||
// Check if PBS command failed
|
||||
if (output.includes('PBS_ERROR') || output.includes('error') || output.includes('Error')) {
|
||||
console.log(`[BackupService] PBS discovery failed or no backups found for CT ${ctId}`);
|
||||
return backups;
|
||||
}
|
||||
|
||||
// Parse PBS snapshot list output (table format)
|
||||
// Format: snapshot | size | files
|
||||
// Example: ct/148/2025-10-21T19:14:55Z | 994.944 MiB | catalog.pcat1 client.log ...
|
||||
const lines = output.trim().split('\n').filter(line => line.trim());
|
||||
|
||||
console.log(`[BackupService] Parsing ${lines.length} lines from PBS output`);
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip header lines, separators, or error messages
|
||||
if (line.includes('snapshot') && line.includes('size') && line.includes('files')) {
|
||||
continue; // Skip header row
|
||||
}
|
||||
if (line.includes('═') || line.includes('─') || line.includes('│') && line.match(/^[│═─╞╪╡├┼┤└┴┘]+$/)) {
|
||||
continue; // Skip table separator lines
|
||||
}
|
||||
if (line.includes('repository') || line.includes('error') || line.includes('Error') || line.includes('PBS_ERROR')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse table row - format: snapshot | size | files
|
||||
// Example: │ ct/148/2025-10-21T19:14:55Z │ 994.944 MiB │ catalog.pcat1 client.log index.json pct.conf root.pxar │
|
||||
const parts = line.split('│').map(p => p.trim()).filter(p => p);
|
||||
|
||||
if (parts.length >= 2) {
|
||||
const snapshotPath = parts[0]; // e.g., "ct/148/2025-10-21T19:14:55Z"
|
||||
const sizeStr = parts[1]; // e.g., "994.944 MiB"
|
||||
|
||||
if (!snapshotPath) {
|
||||
continue; // Skip if no snapshot path
|
||||
}
|
||||
|
||||
// Extract snapshot name (last part after /)
|
||||
const snapshotParts = snapshotPath.split('/');
|
||||
const snapshotName = snapshotParts[snapshotParts.length - 1] || snapshotPath;
|
||||
|
||||
if (!snapshotName) {
|
||||
continue; // Skip if no snapshot name
|
||||
}
|
||||
|
||||
// Parse date from snapshot name (format: 2025-10-21T19:14:55Z)
|
||||
let createdAt: Date | undefined;
|
||||
const dateMatch = snapshotName.match(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)/);
|
||||
if (dateMatch && dateMatch[1]) {
|
||||
try {
|
||||
createdAt = new Date(dateMatch[1]);
|
||||
} catch (e) {
|
||||
// Invalid date, leave undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Parse size (convert MiB/GiB to bytes)
|
||||
let size: bigint | undefined;
|
||||
if (sizeStr) {
|
||||
const sizeMatch = sizeStr.match(/([\d.]+)\s*(MiB|GiB|KiB|B)/i);
|
||||
if (sizeMatch && sizeMatch[1] && sizeMatch[2]) {
|
||||
const sizeValue = parseFloat(sizeMatch[1]);
|
||||
const unit = sizeMatch[2].toUpperCase();
|
||||
let bytes = sizeValue;
|
||||
|
||||
if (unit === 'KIB') bytes = sizeValue * 1024;
|
||||
else if (unit === 'MIB') bytes = sizeValue * 1024 * 1024;
|
||||
else if (unit === 'GIB') bytes = sizeValue * 1024 * 1024 * 1024;
|
||||
|
||||
size = BigInt(Math.floor(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
backups.push({
|
||||
container_id: ctId,
|
||||
server_id: server.id,
|
||||
hostname,
|
||||
backup_name: snapshotName,
|
||||
backup_path: `pbs://${repository}/${snapshotPath}`,
|
||||
size,
|
||||
created_at: createdAt,
|
||||
storage_name: storage.name,
|
||||
storage_type: 'pbs',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[BackupService] Found ${backups.length} PBS backups for CT ${ctId}`);
|
||||
} catch (error) {
|
||||
console.error(`Error discovering PBS backups for CT ${ctId}:`, error);
|
||||
}
|
||||
|
||||
return backups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all backups for a container across all backup-capable storages
|
||||
*/
|
||||
async discoverAllBackupsForContainer(server: Server, ctId: string, hostname: string): Promise<BackupData[]> {
|
||||
const allBackups: BackupData[] = [];
|
||||
|
||||
try {
|
||||
// Get server hostname to filter storages
|
||||
const serverHostname = await this.getServerHostname(server);
|
||||
const normalizedHostname = serverHostname.trim().toLowerCase();
|
||||
console.log(`[BackupService] Discovering backups for server ${server.name} (hostname: ${serverHostname}, normalized: ${normalizedHostname})`);
|
||||
|
||||
// Get all backup-capable storages (force refresh to get latest node assignments)
|
||||
const storageService = getStorageService();
|
||||
const allStorages = await storageService.getBackupStorages(server, true); // Force refresh
|
||||
|
||||
console.log(`[BackupService] Found ${allStorages.length} backup-capable storages total`);
|
||||
|
||||
// Filter storages by node hostname matching
|
||||
const applicableStorages = allStorages.filter(storage => {
|
||||
// If storage has no nodes specified, it's available on all nodes
|
||||
if (!storage.nodes || storage.nodes.length === 0) {
|
||||
console.log(`[BackupService] Storage ${storage.name} has no nodes specified, including it`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Normalize all nodes for comparison
|
||||
const normalizedNodes = storage.nodes.map(node => node.trim().toLowerCase());
|
||||
const isApplicable = normalizedNodes.includes(normalizedHostname);
|
||||
|
||||
if (!isApplicable) {
|
||||
console.log(`[BackupService] EXCLUDING Storage ${storage.name} (nodes: ${storage.nodes.join(', ')}) - not applicable for hostname: ${serverHostname}`);
|
||||
} else {
|
||||
console.log(`[BackupService] INCLUDING Storage ${storage.name} (nodes: ${storage.nodes.join(', ')}) - applicable for hostname: ${serverHostname}`);
|
||||
}
|
||||
|
||||
return isApplicable;
|
||||
});
|
||||
|
||||
console.log(`[BackupService] Filtered to ${applicableStorages.length} applicable storages for ${serverHostname}`);
|
||||
|
||||
// Discover local backups
|
||||
const localBackups = await this.discoverLocalBackups(server, ctId, hostname);
|
||||
allBackups.push(...localBackups);
|
||||
|
||||
// Discover backups from each applicable storage
|
||||
for (const storage of applicableStorages) {
|
||||
try {
|
||||
if (storage.type === 'pbs') {
|
||||
// PBS storage
|
||||
const pbsBackups = await this.discoverPBSBackups(server, storage, ctId, hostname);
|
||||
allBackups.push(...pbsBackups);
|
||||
} else {
|
||||
// Regular storage (dir, nfs, etc.)
|
||||
const storageBackups = await this.discoverStorageBackups(server, storage, ctId, hostname);
|
||||
allBackups.push(...storageBackups);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[BackupService] Error discovering backups from storage ${storage.name}:`, error);
|
||||
// Continue with other storages
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[BackupService] Total backups discovered for CT ${ctId}: ${allBackups.length}`);
|
||||
} catch (error) {
|
||||
console.error(`Error discovering backups for container ${ctId}:`, error);
|
||||
}
|
||||
|
||||
return allBackups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover backups for all installed scripts with container_id
|
||||
*/
|
||||
async discoverAllBackups(): Promise<void> {
|
||||
const db = getDatabase();
|
||||
const scripts = await db.getAllInstalledScripts();
|
||||
|
||||
// Filter scripts that have container_id and server_id
|
||||
const scriptsWithContainers = scripts.filter(
|
||||
(script: any) => script.container_id && script.server_id && script.server
|
||||
);
|
||||
|
||||
// Clear all existing backups first to ensure we start fresh
|
||||
console.log('[BackupService] Clearing all existing backups before rediscovery...');
|
||||
const allBackups = await db.getAllBackups();
|
||||
for (const backup of allBackups) {
|
||||
await db.deleteBackupsForContainer(backup.container_id, backup.server_id);
|
||||
}
|
||||
console.log('[BackupService] Cleared all existing backups');
|
||||
|
||||
for (const script of scriptsWithContainers) {
|
||||
if (!script.container_id || !script.server_id || !script.server) continue;
|
||||
|
||||
const containerId = script.container_id;
|
||||
const serverId = script.server_id;
|
||||
const server = script.server as Server;
|
||||
|
||||
try {
|
||||
// Get hostname from LXC config if available, otherwise use script name
|
||||
let hostname = script.script_name || `CT-${script.container_id}`;
|
||||
try {
|
||||
const lxcConfig = await db.getLXCConfigByScriptId(script.id);
|
||||
if (lxcConfig?.hostname) {
|
||||
hostname = lxcConfig.hostname;
|
||||
}
|
||||
} catch (error) {
|
||||
// LXC config might not exist, use script name
|
||||
console.debug(`No LXC config found for script ${script.id}, using script name as hostname`);
|
||||
}
|
||||
|
||||
console.log(`[BackupService] Discovering backups for script ${script.id}, CT ${containerId} on server ${server.name}`);
|
||||
|
||||
// Discover backups for this container
|
||||
const backups = await this.discoverAllBackupsForContainer(
|
||||
server,
|
||||
containerId,
|
||||
hostname
|
||||
);
|
||||
|
||||
console.log(`[BackupService] Found ${backups.length} backups for CT ${containerId} on server ${server.name}`);
|
||||
|
||||
// Save discovered backups
|
||||
for (const backup of backups) {
|
||||
await db.createOrUpdateBackup(backup);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error discovering backups for script ${script.id} (CT ${script.container_id}):`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let backupServiceInstance: BackupService | null = null;
|
||||
|
||||
export function getBackupService(): BackupService {
|
||||
if (!backupServiceInstance) {
|
||||
backupServiceInstance = new BackupService();
|
||||
}
|
||||
return backupServiceInstance;
|
||||
}
|
||||
|
||||
@@ -1,561 +0,0 @@
|
||||
import { getSSHExecutionService } from '../ssh-execution-service';
|
||||
import { getBackupService } from './backupService';
|
||||
import { getStorageService } from './storageService';
|
||||
import { getDatabase } from '../database-prisma';
|
||||
import type { Server } from '~/types/server';
|
||||
import type { Storage } from './storageService';
|
||||
import { writeFile, readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
export interface RestoreProgress {
|
||||
step: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RestoreResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
progress?: RestoreProgress[];
|
||||
}
|
||||
|
||||
class RestoreService {
|
||||
/**
|
||||
* Get rootfs storage from LXC config or installed scripts database
|
||||
*/
|
||||
async getRootfsStorage(server: Server, ctId: string): Promise<string | null> {
|
||||
const sshService = getSSHExecutionService();
|
||||
const db = getDatabase();
|
||||
const configPath = `/etc/pve/lxc/${ctId}.conf`;
|
||||
const readCommand = `cat "${configPath}" 2>/dev/null || echo ""`;
|
||||
let rawConfig = '';
|
||||
|
||||
try {
|
||||
// Try to read config file (container might not exist, so don't fail on error)
|
||||
await new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
readCommand,
|
||||
(data: string) => {
|
||||
rawConfig += data;
|
||||
},
|
||||
() => resolve(), // Don't fail on error
|
||||
() => resolve() // Always resolve
|
||||
);
|
||||
});
|
||||
|
||||
// If we got config content, parse it
|
||||
if (rawConfig.trim()) {
|
||||
// Parse rootfs line: rootfs: PROX2-STORAGE2:vm-148-disk-0,size=4G
|
||||
const lines = rawConfig.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('rootfs:')) {
|
||||
const match = trimmed.match(/^rootfs:\s*([^:]+):/);
|
||||
if (match && match[1]) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If config file doesn't exist or doesn't have rootfs, try to get from installed scripts database
|
||||
const installedScripts = await db.getAllInstalledScripts();
|
||||
const script = installedScripts.find((s: any) => s.container_id === ctId && s.server_id === server.id);
|
||||
|
||||
if (script) {
|
||||
// Try to get LXC config from database
|
||||
const lxcConfig = await db.getLXCConfigByScriptId(script.id);
|
||||
if (lxcConfig?.rootfs_storage) {
|
||||
// Extract storage from rootfs_storage format: "STORAGE:vm-148-disk-0"
|
||||
const match = lxcConfig.rootfs_storage.match(/^([^:]+):/);
|
||||
if (match && match[1]) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Try fallback to database
|
||||
try {
|
||||
const installedScripts = await db.getAllInstalledScripts();
|
||||
const script = installedScripts.find((s: any) => s.container_id === ctId && s.server_id === server.id);
|
||||
if (script) {
|
||||
const lxcConfig = await db.getLXCConfigByScriptId(script.id);
|
||||
if (lxcConfig?.rootfs_storage) {
|
||||
const match = lxcConfig.rootfs_storage.match(/^([^:]+):/);
|
||||
if (match && match[1]) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (dbError) {
|
||||
// Ignore database error
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop container (continue if already stopped)
|
||||
*/
|
||||
async stopContainer(server: Server, ctId: string): Promise<void> {
|
||||
const sshService = getSSHExecutionService();
|
||||
const command = `pct stop ${ctId} 2>&1 || true`; // Continue even if already stopped
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
command,
|
||||
() => {},
|
||||
() => resolve(),
|
||||
() => resolve() // Always resolve, don't fail if already stopped
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy container
|
||||
*/
|
||||
async destroyContainer(server: Server, ctId: string): Promise<void> {
|
||||
const sshService = getSSHExecutionService();
|
||||
const command = `pct destroy ${ctId} 2>&1`;
|
||||
let output = '';
|
||||
let exitCode = 0;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
command,
|
||||
(data: string) => {
|
||||
output += data;
|
||||
},
|
||||
(error: string) => {
|
||||
// Check if error is about container not existing
|
||||
if (error.includes('does not exist') || error.includes('not found')) {
|
||||
resolve(); // Container doesn't exist, that's fine
|
||||
} else {
|
||||
reject(new Error(`Destroy failed: ${error}`));
|
||||
}
|
||||
},
|
||||
(code: number) => {
|
||||
exitCode = code;
|
||||
if (exitCode === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
// Check if error is about container not existing
|
||||
if (output.includes('does not exist') || output.includes('not found') || output.includes('No such file')) {
|
||||
resolve(); // Container doesn't exist, that's fine
|
||||
} else {
|
||||
reject(new Error(`Destroy failed with exit code ${exitCode}: ${output}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore from local/storage backup
|
||||
*/
|
||||
async restoreLocalBackup(
|
||||
server: Server,
|
||||
ctId: string,
|
||||
backupPath: string,
|
||||
storage: string
|
||||
): Promise<void> {
|
||||
const sshService = getSSHExecutionService();
|
||||
const command = `pct restore ${ctId} "${backupPath}" --storage=${storage}`;
|
||||
let output = '';
|
||||
let exitCode = 0;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
command,
|
||||
(data: string) => {
|
||||
output += data;
|
||||
},
|
||||
(error: string) => {
|
||||
reject(new Error(`Restore failed: ${error}`));
|
||||
},
|
||||
(code: number) => {
|
||||
exitCode = code;
|
||||
if (exitCode === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Restore failed with exit code ${exitCode}: ${output}`));
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore from PBS backup
|
||||
*/
|
||||
async restorePBSBackup(
|
||||
server: Server,
|
||||
storage: Storage,
|
||||
ctId: string,
|
||||
snapshotPath: string,
|
||||
storageName: string,
|
||||
onProgress?: (step: string, message: string) => Promise<void>
|
||||
): Promise<void> {
|
||||
const backupService = getBackupService();
|
||||
const sshService = getSSHExecutionService();
|
||||
const db = getDatabase();
|
||||
|
||||
// Get PBS credentials
|
||||
const credential = await db.getPBSCredential(server.id, storage.name);
|
||||
if (!credential) {
|
||||
throw new Error(`No PBS credentials found for storage ${storage.name}`);
|
||||
}
|
||||
|
||||
const storageService = getStorageService();
|
||||
const pbsInfo = storageService.getPBSStorageInfo(storage);
|
||||
const pbsIp = credential.pbs_ip || pbsInfo.pbs_ip;
|
||||
const pbsDatastore = credential.pbs_datastore || pbsInfo.pbs_datastore;
|
||||
|
||||
if (!pbsIp || !pbsDatastore) {
|
||||
throw new Error(`Missing PBS IP or datastore for storage ${storage.name}`);
|
||||
}
|
||||
|
||||
const repository = `root@pam@${pbsIp}:${pbsDatastore}`;
|
||||
|
||||
// Extract snapshot name from path (e.g., "2025-10-21T19:14:55Z" from "ct/148/2025-10-21T19:14:55Z")
|
||||
const snapshotParts = snapshotPath.split('/');
|
||||
const snapshotName = snapshotParts[snapshotParts.length - 1] || snapshotPath;
|
||||
// Replace colons with underscores for file paths (tar doesn't like colons in filenames)
|
||||
const snapshotNameForPath = snapshotName.replace(/:/g, '_');
|
||||
|
||||
// Determine file extension - try common extensions
|
||||
const extensions = ['.tar', '.tar.zst', '.pxar'];
|
||||
let downloadedPath = '';
|
||||
let downloadSuccess = false;
|
||||
|
||||
// Login to PBS first
|
||||
if (onProgress) await onProgress('pbs_login', 'Logging into PBS...');
|
||||
const loggedIn = await backupService.loginToPBS(server, storage);
|
||||
if (!loggedIn) {
|
||||
throw new Error(`Failed to login to PBS for storage ${storage.name}`);
|
||||
}
|
||||
|
||||
// Download backup from PBS
|
||||
// proxmox-backup-client restore outputs a folder, not a file
|
||||
if (onProgress) await onProgress('pbs_download', 'Downloading backup from PBS...');
|
||||
|
||||
// Target folder for PBS restore (without extension)
|
||||
// Use sanitized snapshot name (colons replaced with underscores) for file paths
|
||||
const targetFolder = `/var/lib/vz/dump/vzdump-lxc-${ctId}-${snapshotNameForPath}`;
|
||||
const targetTar = `${targetFolder}.tar`;
|
||||
|
||||
// Use PBS_PASSWORD env var and add timeout for long downloads
|
||||
const escapedPassword = credential.pbs_password.replace(/'/g, "'\\''");
|
||||
const restoreCommand = `PBS_PASSWORD='${escapedPassword}' PBS_REPOSITORY='${repository}' timeout 300 proxmox-backup-client restore "${snapshotPath}" root.pxar "${targetFolder}" --repository '${repository}' 2>&1`;
|
||||
|
||||
let output = '';
|
||||
let exitCode = 0;
|
||||
|
||||
try {
|
||||
// Download from PBS (creates a folder)
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve, reject) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
restoreCommand,
|
||||
(data: string) => {
|
||||
output += data;
|
||||
},
|
||||
(error: string) => {
|
||||
reject(new Error(`Download failed: ${error}`));
|
||||
},
|
||||
(code: number) => {
|
||||
exitCode = code;
|
||||
if (exitCode === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Download failed with exit code ${exitCode}: ${output}`));
|
||||
}
|
||||
}
|
||||
);
|
||||
}),
|
||||
new Promise<void>((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error('Download timeout after 5 minutes'));
|
||||
}, 300000); // 5 minute timeout
|
||||
})
|
||||
]);
|
||||
|
||||
// Check if folder exists
|
||||
const checkCommand = `test -d "${targetFolder}" && echo "exists" || echo "notfound"`;
|
||||
let checkOutput = '';
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
checkCommand,
|
||||
(data: string) => {
|
||||
checkOutput += data;
|
||||
},
|
||||
() => resolve(),
|
||||
() => resolve()
|
||||
);
|
||||
});
|
||||
|
||||
if (!checkOutput.includes('exists')) {
|
||||
throw new Error(`Downloaded folder ${targetFolder} does not exist`);
|
||||
}
|
||||
|
||||
// Pack the folder into a tar file
|
||||
if (onProgress) await onProgress('pbs_pack', 'Packing backup folder...');
|
||||
|
||||
// Use -C to change to the folder directory, then pack all contents (.) into the tar file
|
||||
const packCommand = `tar -cf "${targetTar}" -C "${targetFolder}" . 2>&1`;
|
||||
let packOutput = '';
|
||||
let packExitCode = 0;
|
||||
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve, reject) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
packCommand,
|
||||
(data: string) => {
|
||||
packOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
reject(new Error(`Pack failed: ${error}`));
|
||||
},
|
||||
(code: number) => {
|
||||
packExitCode = code;
|
||||
if (packExitCode === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Pack failed with exit code ${packExitCode}: ${packOutput}`));
|
||||
}
|
||||
}
|
||||
);
|
||||
}),
|
||||
new Promise<void>((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error('Pack timeout after 2 minutes'));
|
||||
}, 120000); // 2 minute timeout for packing
|
||||
})
|
||||
]);
|
||||
|
||||
// Check if tar file exists
|
||||
const checkTarCommand = `test -f "${targetTar}" && echo "exists" || echo "notfound"`;
|
||||
let checkTarOutput = '';
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
checkTarCommand,
|
||||
(data: string) => {
|
||||
checkTarOutput += data;
|
||||
},
|
||||
() => resolve(),
|
||||
() => resolve()
|
||||
);
|
||||
});
|
||||
|
||||
if (!checkTarOutput.includes('exists')) {
|
||||
throw new Error(`Packed tar file ${targetTar} does not exist`);
|
||||
}
|
||||
|
||||
downloadedPath = targetTar;
|
||||
downloadSuccess = true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!downloadSuccess || !downloadedPath) {
|
||||
throw new Error(`Failed to download and pack backup from PBS`);
|
||||
}
|
||||
|
||||
// Restore from packed tar file
|
||||
if (onProgress) await onProgress('restoring', 'Restoring container...');
|
||||
try {
|
||||
await this.restoreLocalBackup(server, ctId, downloadedPath, storageName);
|
||||
} finally {
|
||||
// Cleanup: delete downloaded folder and tar file
|
||||
if (onProgress) await onProgress('cleanup', 'Cleaning up temporary files...');
|
||||
const cleanupCommand = `rm -rf "${targetFolder}" "${targetTar}" 2>&1 || true`;
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
cleanupCommand,
|
||||
() => {},
|
||||
() => {},
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute full restore flow
|
||||
*/
|
||||
async executeRestore(
|
||||
backupId: number,
|
||||
containerId: string,
|
||||
serverId: number,
|
||||
onProgress?: (progress: RestoreProgress) => void
|
||||
): Promise<RestoreResult> {
|
||||
const progress: RestoreProgress[] = [];
|
||||
const logPath = join(process.cwd(), 'restore.log');
|
||||
|
||||
// Clear log file at start of restore
|
||||
const clearLogFile = async () => {
|
||||
try {
|
||||
await writeFile(logPath, '', 'utf-8');
|
||||
} catch (error) {
|
||||
// Ignore log file errors
|
||||
}
|
||||
};
|
||||
|
||||
// Write progress to log file
|
||||
const writeProgressToLog = async (message: string) => {
|
||||
try {
|
||||
const logLine = `${message}\n`;
|
||||
await writeFile(logPath, logLine, { flag: 'a', encoding: 'utf-8' });
|
||||
} catch (error) {
|
||||
// Ignore log file errors
|
||||
}
|
||||
};
|
||||
|
||||
const addProgress = async (step: string, message: string) => {
|
||||
const p = { step, message };
|
||||
progress.push(p);
|
||||
|
||||
// Write to log file (just the message, without step prefix)
|
||||
await writeProgressToLog(message);
|
||||
|
||||
// Call callback if provided
|
||||
if (onProgress) {
|
||||
onProgress(p);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Clear log file at start
|
||||
await clearLogFile();
|
||||
|
||||
const db = getDatabase();
|
||||
const sshService = getSSHExecutionService();
|
||||
|
||||
await addProgress('starting', 'Starting restore...');
|
||||
|
||||
// Get backup details
|
||||
const backup = await db.getBackupById(backupId);
|
||||
if (!backup) {
|
||||
throw new Error(`Backup with ID ${backupId} not found`);
|
||||
}
|
||||
|
||||
// Get server details
|
||||
const server = await db.getServerById(serverId);
|
||||
if (!server) {
|
||||
throw new Error(`Server with ID ${serverId} not found`);
|
||||
}
|
||||
|
||||
// Get rootfs storage
|
||||
await addProgress('reading_config', 'Reading container configuration...');
|
||||
const rootfsStorage = await this.getRootfsStorage(server, containerId);
|
||||
|
||||
if (!rootfsStorage) {
|
||||
// Try to check if container exists, if not we can proceed without stopping/destroying
|
||||
const checkCommand = `pct list ${containerId} 2>&1 | grep -q "^${containerId}" && echo "exists" || echo "notfound"`;
|
||||
let checkOutput = '';
|
||||
await new Promise<void>((resolve) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
checkCommand,
|
||||
(data: string) => {
|
||||
checkOutput += data;
|
||||
},
|
||||
() => resolve(),
|
||||
() => resolve()
|
||||
);
|
||||
});
|
||||
|
||||
if (checkOutput.includes('notfound')) {
|
||||
// Container doesn't exist, we can't determine storage - need user input or use default
|
||||
throw new Error(`Container ${containerId} does not exist and storage could not be determined. Please ensure the container exists or specify the storage manually.`);
|
||||
}
|
||||
|
||||
throw new Error(`Could not determine rootfs storage for container ${containerId}. Please ensure the container exists and has a valid configuration.`);
|
||||
}
|
||||
|
||||
// Try to stop and destroy container - if it doesn't exist, continue anyway
|
||||
await addProgress('stopping', 'Stopping container...');
|
||||
try {
|
||||
await this.stopContainer(server, containerId);
|
||||
} catch (error) {
|
||||
// Continue even if stop fails
|
||||
}
|
||||
|
||||
// Try to destroy container - if it doesn't exist, continue anyway
|
||||
await addProgress('destroying', 'Destroying container...');
|
||||
try {
|
||||
await this.destroyContainer(server, containerId);
|
||||
} catch (error) {
|
||||
// Container might not exist, which is fine - continue with restore
|
||||
await addProgress('skipping', 'Container does not exist or already destroyed, continuing...');
|
||||
}
|
||||
|
||||
// Restore based on backup type
|
||||
if (backup.storage_type === 'pbs') {
|
||||
// Get storage info for PBS
|
||||
const storageService = getStorageService();
|
||||
const storages = await storageService.getStorages(server, false);
|
||||
const storage = storages.find(s => s.name === backup.storage_name);
|
||||
|
||||
if (!storage) {
|
||||
throw new Error(`Storage ${backup.storage_name} not found`);
|
||||
}
|
||||
|
||||
// Parse snapshot path from backup_path (format: pbs://root@pam@IP:DATASTORE/ct/148/2025-10-21T19:14:55Z)
|
||||
const snapshotPathMatch = backup.backup_path.match(/pbs:\/\/[^/]+\/(.+)$/);
|
||||
if (!snapshotPathMatch || !snapshotPathMatch[1]) {
|
||||
throw new Error(`Invalid PBS backup path format: ${backup.backup_path}`);
|
||||
}
|
||||
|
||||
const snapshotPath = snapshotPathMatch[1];
|
||||
|
||||
await this.restorePBSBackup(server, storage, containerId, snapshotPath, rootfsStorage, async (step, message) => {
|
||||
await addProgress(step, message);
|
||||
});
|
||||
} else {
|
||||
// Local or storage backup
|
||||
await addProgress('restoring', 'Restoring container...');
|
||||
await this.restoreLocalBackup(server, containerId, backup.backup_path, rootfsStorage);
|
||||
}
|
||||
|
||||
await addProgress('complete', 'Restore completed successfully');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
progress,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
await addProgress('error', `Error: ${errorMessage}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let restoreServiceInstance: RestoreService | null = null;
|
||||
|
||||
export function getRestoreService(): RestoreService {
|
||||
if (!restoreServiceInstance) {
|
||||
restoreServiceInstance = new RestoreService();
|
||||
}
|
||||
return restoreServiceInstance;
|
||||
}
|
||||
|
||||
@@ -519,16 +519,13 @@ export class ScriptDownloaderService {
|
||||
comparisonPromises.push(
|
||||
this.compareSingleFile(script, scriptPath, `${finalTargetDir}/${fileName}`)
|
||||
.then(result => {
|
||||
if (result.error) {
|
||||
console.error(`[Comparison] Error comparing ${result.filePath}: ${result.error}`);
|
||||
}
|
||||
if (result.hasDifferences) {
|
||||
hasDifferences = true;
|
||||
differences.push(result.filePath);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`[Comparison] Promise error for ${scriptPath}:`, error);
|
||||
.catch(() => {
|
||||
// Don't add to differences if there's an error reading files
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -544,16 +541,13 @@ export class ScriptDownloaderService {
|
||||
comparisonPromises.push(
|
||||
this.compareSingleFile(script, installScriptPath, installScriptPath)
|
||||
.then(result => {
|
||||
if (result.error) {
|
||||
console.error(`[Comparison] Error comparing ${result.filePath}: ${result.error}`);
|
||||
}
|
||||
if (result.hasDifferences) {
|
||||
hasDifferences = true;
|
||||
differences.push(result.filePath);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`[Comparison] Promise error for ${installScriptPath}:`, error);
|
||||
.catch(() => {
|
||||
// Don't add to differences if there's an error reading files
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -573,16 +567,13 @@ export class ScriptDownloaderService {
|
||||
comparisonPromises.push(
|
||||
this.compareSingleFile(script, alpineInstallScriptPath, alpineInstallScriptPath)
|
||||
.then(result => {
|
||||
if (result.error) {
|
||||
console.error(`[Comparison] Error comparing ${result.filePath}: ${result.error}`);
|
||||
}
|
||||
if (result.hasDifferences) {
|
||||
hasDifferences = true;
|
||||
differences.push(result.filePath);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`[Comparison] Promise error for ${alpineInstallScriptPath}:`, error);
|
||||
.catch(() => {
|
||||
// Don't add to differences if there's an error reading files
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
@@ -593,11 +584,10 @@ export class ScriptDownloaderService {
|
||||
// Wait for all comparisons to complete
|
||||
await Promise.all(comparisonPromises);
|
||||
|
||||
console.log(`[Comparison] Completed comparison for ${script.slug}: hasDifferences=${hasDifferences}, differences=${differences.length}`);
|
||||
return { hasDifferences, differences };
|
||||
} catch (error) {
|
||||
console.error(`[Comparison] Error comparing script content for ${script.slug}:`, error);
|
||||
return { hasDifferences: false, differences: [], error: error.message };
|
||||
console.error('Error comparing script content:', error);
|
||||
return { hasDifferences: false, differences: [] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,21 +597,16 @@ export class ScriptDownloaderService {
|
||||
const repoUrl = this.getRepoUrlForScript(script);
|
||||
const branch = process.env.REPO_BRANCH || 'main';
|
||||
|
||||
console.log(`[Comparison] Comparing ${filePath} from ${repoUrl} (branch: ${branch})`);
|
||||
|
||||
// Read local content
|
||||
const localContent = await readFile(localPath, 'utf-8');
|
||||
console.log(`[Comparison] Local file size: ${localContent.length} bytes`);
|
||||
|
||||
// Download remote content from the script's repository
|
||||
const remoteContent = await this.downloadFileFromGitHub(repoUrl, remotePath, branch);
|
||||
console.log(`[Comparison] Remote file size: ${remoteContent.length} bytes`);
|
||||
|
||||
// Apply modification only for CT scripts, not for other script types
|
||||
let modifiedRemoteContent;
|
||||
if (remotePath.startsWith('ct/')) {
|
||||
modifiedRemoteContent = this.modifyScriptContent(remoteContent);
|
||||
console.log(`[Comparison] Applied CT script modifications`);
|
||||
} else {
|
||||
modifiedRemoteContent = remoteContent; // Don't modify tools or vm scripts
|
||||
}
|
||||
@@ -629,17 +614,10 @@ export class ScriptDownloaderService {
|
||||
// Compare content
|
||||
const hasDifferences = localContent !== modifiedRemoteContent;
|
||||
|
||||
if (hasDifferences) {
|
||||
console.log(`[Comparison] Differences found in ${filePath}`);
|
||||
} else {
|
||||
console.log(`[Comparison] No differences in ${filePath}`);
|
||||
}
|
||||
|
||||
return { hasDifferences, filePath };
|
||||
} catch (error) {
|
||||
console.error(`[Comparison] Error comparing file ${filePath}:`, error.message);
|
||||
// Return error information so it can be handled upstream
|
||||
return { hasDifferences: false, filePath, error: error.message };
|
||||
console.error(`Error comparing file ${filePath}:`, error);
|
||||
return { hasDifferences: false, filePath };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
import { getSSHExecutionService } from '../ssh-execution-service';
|
||||
import type { Server } from '~/types/server';
|
||||
|
||||
export interface Storage {
|
||||
name: string;
|
||||
type: string;
|
||||
content: string[];
|
||||
supportsBackup: boolean;
|
||||
nodes?: string[];
|
||||
[key: string]: any; // For additional storage-specific properties
|
||||
}
|
||||
|
||||
interface CachedStorageData {
|
||||
storages: Storage[];
|
||||
lastFetched: Date;
|
||||
}
|
||||
|
||||
class StorageService {
|
||||
private cache: Map<number, CachedStorageData> = new Map();
|
||||
private readonly CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
/**
|
||||
* Parse storage.cfg content and extract storage information
|
||||
*/
|
||||
private parseStorageConfig(configContent: string): Storage[] {
|
||||
const storages: Storage[] = [];
|
||||
const lines = configContent.split('\n');
|
||||
|
||||
let currentStorage: Partial<Storage> | null = null;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const rawLine = lines[i];
|
||||
if (!rawLine) continue;
|
||||
|
||||
// Check if line is indented (has leading whitespace/tabs) BEFORE trimming
|
||||
const isIndented = /^[\s\t]/.test(rawLine);
|
||||
const line = rawLine.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is a storage definition line (format: "type: name")
|
||||
// Storage definitions are NOT indented
|
||||
if (!isIndented) {
|
||||
const storageMatch = line.match(/^(\w+):\s*(.+)$/);
|
||||
if (storageMatch && storageMatch[1] && storageMatch[2]) {
|
||||
// Save previous storage if exists
|
||||
if (currentStorage && currentStorage.name) {
|
||||
storages.push(this.finalizeStorage(currentStorage));
|
||||
}
|
||||
|
||||
// Start new storage
|
||||
currentStorage = {
|
||||
type: storageMatch[1],
|
||||
name: storageMatch[2],
|
||||
content: [],
|
||||
supportsBackup: false,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse storage properties (indented lines - can be tabs or spaces)
|
||||
if (currentStorage && isIndented) {
|
||||
// Split on first whitespace (space or tab) to separate key and value
|
||||
const match = line.match(/^(\S+)\s+(.+)$/);
|
||||
|
||||
if (match && match[1] && match[2]) {
|
||||
const key = match[1];
|
||||
const value = match[2].trim();
|
||||
|
||||
switch (key) {
|
||||
case 'content':
|
||||
// Content can be comma-separated: "images,rootdir" or "backup"
|
||||
currentStorage.content = value.split(',').map(c => c.trim());
|
||||
currentStorage.supportsBackup = currentStorage.content.includes('backup');
|
||||
break;
|
||||
case 'nodes':
|
||||
// Nodes can be comma-separated: "prox5" or "prox5,prox6"
|
||||
currentStorage.nodes = value.split(',').map(n => n.trim());
|
||||
break;
|
||||
default:
|
||||
// Store other properties
|
||||
if (key) {
|
||||
(currentStorage as any)[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last storage
|
||||
if (currentStorage && currentStorage.name) {
|
||||
storages.push(this.finalizeStorage(currentStorage));
|
||||
}
|
||||
|
||||
return storages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize storage object with proper typing
|
||||
*/
|
||||
private finalizeStorage(storage: Partial<Storage>): Storage {
|
||||
return {
|
||||
name: storage.name!,
|
||||
type: storage.type!,
|
||||
content: storage.content || [],
|
||||
supportsBackup: storage.supportsBackup || false,
|
||||
nodes: storage.nodes,
|
||||
...Object.fromEntries(
|
||||
Object.entries(storage).filter(([key]) =>
|
||||
!['name', 'type', 'content', 'supportsBackup', 'nodes'].includes(key)
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch storage configuration from server via SSH
|
||||
*/
|
||||
async fetchStoragesFromServer(server: Server, forceRefresh = false): Promise<Storage[]> {
|
||||
const serverId = server.id;
|
||||
|
||||
// Check cache first (unless force refresh)
|
||||
if (!forceRefresh && this.cache.has(serverId)) {
|
||||
const cached = this.cache.get(serverId)!;
|
||||
const age = Date.now() - cached.lastFetched.getTime();
|
||||
|
||||
if (age < this.CACHE_TTL_MS) {
|
||||
return cached.storages;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from server
|
||||
const sshService = getSSHExecutionService();
|
||||
let configContent = '';
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
'cat /etc/pve/storage.cfg',
|
||||
(data: string) => {
|
||||
configContent += data;
|
||||
},
|
||||
(error: string) => {
|
||||
reject(new Error(`Failed to read storage config: ${error}`));
|
||||
},
|
||||
(exitCode: number) => {
|
||||
if (exitCode === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Command failed with exit code ${exitCode}`));
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Parse and cache
|
||||
const storages = this.parseStorageConfig(configContent);
|
||||
this.cache.set(serverId, {
|
||||
storages,
|
||||
lastFetched: new Date(),
|
||||
});
|
||||
|
||||
return storages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all storages for a server (cached or fresh)
|
||||
*/
|
||||
async getStorages(server: Server, forceRefresh = false): Promise<Storage[]> {
|
||||
return this.fetchStoragesFromServer(server, forceRefresh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only backup-capable storages
|
||||
*/
|
||||
async getBackupStorages(server: Server, forceRefresh = false): Promise<Storage[]> {
|
||||
const allStorages = await this.getStorages(server, forceRefresh);
|
||||
return allStorages.filter(s => s.supportsBackup);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PBS storage information (IP and datastore) from storage config
|
||||
*/
|
||||
getPBSStorageInfo(storage: Storage): { pbs_ip: string | null; pbs_datastore: string | null } {
|
||||
if (storage.type !== 'pbs') {
|
||||
return { pbs_ip: null, pbs_datastore: null };
|
||||
}
|
||||
|
||||
return {
|
||||
pbs_ip: (storage as any).server || null,
|
||||
pbs_datastore: (storage as any).datastore || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for a specific server
|
||||
*/
|
||||
clearCache(serverId: number): void {
|
||||
this.cache.delete(serverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all caches
|
||||
*/
|
||||
clearAllCaches(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let storageServiceInstance: StorageService | null = null;
|
||||
|
||||
export function getStorageService(): StorageService {
|
||||
if (!storageServiceInstance) {
|
||||
storageServiceInstance = new StorageService();
|
||||
}
|
||||
return storageServiceInstance;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"jsx": "preserve",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
|
||||
Reference in New Issue
Block a user