Compare commits

...

19 Commits

Author SHA1 Message Date
Michel Roegl-Brunner
e8852ecae7 docs: add LXC Backups section to help modal 2025-11-18 09:22:16 +01:00
Michel Roegl-Brunner
3a8088ded6 chore: add missing migration for backups and pbs_storage_credentials tables 2025-11-18 09:16:31 +01:00
Michel Roegl-Brunner
5d48c7b61c Merge branch 'main' into feat/lxc_backups 2025-11-18 09:15:03 +01:00
Michel Roegl-Brunner
5be88d361f chore: cleanup debug output from backup modals 2025-11-18 09:11:56 +01:00
Michel Roegl-Brunner
81c00f5d40 Merge pull request #327 from community-scripts/dependabot/npm_and_yarn/npm_and_yarn-3c67cbb9cd 2025-11-16 23:21:24 +01:00
dependabot[bot]
9bae95d0aa build(deps-dev): Bump js-yaml
Bumps the npm_and_yarn group with 1 update in the / directory: [js-yaml](https://github.com/nodeca/js-yaml).


Updates `js-yaml` from 4.1.0 to 4.1.1
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-16 10:12:09 +00:00
Michel Roegl-Brunner
088d354fd2 Merge pull request #322 from community-scripts/dependabot/npm_and_yarn/jsdom-27.2.0 2025-11-16 11:11:11 +01:00
Michel Roegl-Brunner
0d47fa5d2a Merge pull request #324 from community-scripts/dependabot/npm_and_yarn/types/react-19.2.4 2025-11-16 11:10:59 +01:00
dependabot[bot]
57fd5f802b build(deps-dev): Bump @types/react from 19.2.2 to 19.2.4
Bumps [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) from 19.2.2 to 19.2.4.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-version: 19.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-14 19:24:46 +00:00
dependabot[bot]
a1825302fa build(deps-dev): Bump jsdom from 27.1.0 to 27.2.0
Bumps [jsdom](https://github.com/jsdom/jsdom) from 27.1.0 to 27.2.0.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Changelog](https://github.com/jsdom/jsdom/blob/main/Changelog.md)
- [Commits](https://github.com/jsdom/jsdom/compare/27.1.0...27.2.0)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 27.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-14 19:24:26 +00:00
Michel Roegl-Brunner
570eea41b9 Implement real-time restore progress updates with polling
- Add restore.log file writing in restoreService.ts for progress tracking
- Create getRestoreProgress query endpoint for polling restore logs
- Implement polling-based progress updates in BackupsTab (1 second interval)
- Update LoadingModal to display all progress logs with auto-scroll
- Remove console.log debug output from restoreService
- Add static 'Restore in progress' text under spinner
- Show success checkmark when restore completes
- Prevent modal dismissal during restore, allow ESC/X button when complete
- Remove step prefixes from log messages for cleaner output
- Keep success/error modals open until user dismisses manually
2025-11-14 15:43:33 +01:00
Michel Roegl-Brunner
33a5b8e4d0 PBS restore working :) 2025-11-14 15:19:34 +01:00
Michel Roegl-Brunner
63174d2ea1 Fix PBS backup discovery command and authentication
- Fix PBS login to use PBS_PASSWORD environment variable instead of stdin
- Change backup discovery command from 'snapshots host/<CT_ID>' to 'snapshot list ct/<CT_ID>'
- Use full repository string (root@pam@IP:DATASTORE) instead of storage name
- Parse table format output correctly (snapshot | size | files)
- Extract snapshot name, size, and date from table output
- Convert size units (MiB/GiB) to bytes for storage
- Fix TypeScript errors with proper null checks
2025-11-14 13:21:53 +01:00
Michel Roegl-Brunner
eda41e5101 Implement PBS authentication support for backup discovery
- Add PBSStorageCredential model to database schema (fingerprint now required)
- Create PBS credentials API router with CRUD operations
- Add PBS login functionality to backup service before discovery
- Create PBSCredentialsModal component for managing credentials
- Integrate PBS credentials management into ServerStoragesModal
- Update storage service to extract PBS IP and datastore info
- Add helpful hint about finding fingerprint on PBS dashboard
- Auto-accept fingerprint during login using stored credentials
2025-11-14 13:12:39 +01:00
Michel Roegl-Brunner
4a50da4968 Add backup discovery tab with support for local and storage backups
- Add Backup model to Prisma schema with fields for container_id, server_id, hostname, backup info
- Create backupService with discovery methods for local (/var/lib/vz/dump/) and storage (/mnt/pve/<storage>/dump/) backups
- Add database methods for backup CRUD operations and grouping by container
- Create backupsRouter with getAllBackupsGrouped and discoverBackups procedures
- Add BackupsTab component with collapsible cards grouped by CT_ID and hostname
- Integrate backups tab into main page navigation
- Filter storages by node hostname matching to only show applicable storages
- Skip PBS backups discovery (temporarily disabled)
- Add comprehensive logging for backup discovery process
2025-11-14 13:04:59 +01:00
Michel Roegl-Brunner
d50ea55e6d Add LXC container backup functionality
- Add backup capability before updates or as standalone action
- Implement storage service to fetch and parse backup-capable storages from PVE nodes
- Add backup storage selection modal for user choice
- Support backup+update flow with sequential execution
- Add standalone backup option in Actions menu
- Add storage viewer in server section to show available storages
- Parse /etc/pve/storage.cfg to identify backup-capable storages
- Cache storage data for performance
- Handle backup failures gracefully (warn but allow update to proceed)
2025-11-14 10:30:27 +01:00
Michel Roegl-Brunner
f558aa4f43 Fix selectedRepositories undefined error with generic filter validation (#321)
- Create filterUtils.ts with getDefaultFilters() and mergeFiltersWithDefaults()
- Update ScriptsGrid, DownloadedScriptsTab, and FilterBar to use utility functions
- Prevents crashes when loading old saved filters missing new properties
- Future-proof: new filter properties automatically get defaults
- Fixes TypeError: can't access property 'length', selectedRepositories is undefined
2025-11-14 09:37:13 +01:00
Michel Roegl-Brunner
4ea49be97d Initial for Backup function 2025-11-14 08:44:33 +01:00
github-actions[bot]
e8c27077fd chore: add VERSION v0.4.11 (#319)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-11-13 14:45:10 +00:00
32 changed files with 4489 additions and 103 deletions

View File

@@ -1 +1 @@
0.4.10.3
0.4.11

28
package-lock.json generated
View File

@@ -57,14 +57,14 @@
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.9.1",
"@types/node-cron": "^3.0.11",
"@types/react": "^19.0.0",
"@types/react": "^19.2.4",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.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",
"jsdom": "^27.2.0",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.7.1",
@@ -3776,9 +3776,9 @@
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.4.tgz",
"integrity": "sha512-tBFxBp9Nfyy5rsmefN+WXc1JeW/j2BpBHFdLZbEVfs9wn3E3NRFxwV0pJg8M1qQAexFpvz73hJXFofV0ZAu92A==",
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -8055,9 +8055,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8068,15 +8068,15 @@
}
},
"node_modules/jsdom": {
"version": "27.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.1.0.tgz",
"integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==",
"version": "27.2.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.2.0.tgz",
"integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@acemir/cssom": "^0.9.19",
"@asamuzakjp/dom-selector": "^6.7.3",
"cssstyle": "^5.3.2",
"@acemir/cssom": "^0.9.23",
"@asamuzakjp/dom-selector": "^6.7.4",
"cssstyle": "^5.3.3",
"data-urls": "^6.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^4.0.0",

View File

@@ -71,14 +71,14 @@
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.9.1",
"@types/node-cron": "^3.0.11",
"@types/react": "^19.0.0",
"@types/react": "^19.2.4",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.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",
"jsdom": "^27.2.0",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.7.1",

View File

@@ -0,0 +1,41 @@
-- 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");

View File

@@ -41,6 +41,8 @@ model Server {
ssh_key_path String?
key_generated Boolean? @default(false)
installed_scripts InstalledScript[]
backups Backup[]
pbs_credentials PBSStorageCredential[]
@@map("servers")
}
@@ -96,6 +98,42 @@ 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 Normal file
View File

@@ -0,0 +1,10 @@
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

214
server.js
View File

@@ -276,13 +276,15 @@ class ScriptExecutionHandler {
* @param {WebSocketMessage} message
*/
async handleMessage(ws, message) {
const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, containerId } = message;
const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, isBackup, containerId, storage, backupStorage } = message;
switch (action) {
case 'start':
if (scriptPath && executionId) {
if (isUpdate && containerId) {
await this.startUpdateExecution(ws, containerId, executionId, mode, server);
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);
} else if (isShell && containerId) {
await this.startShellExecution(ws, containerId, executionId, mode, server);
} else {
@@ -660,6 +662,157 @@ 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
@@ -667,11 +820,62 @@ 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) {
async startUpdateExecution(ws, containerId, executionId, mode = 'local', server = null, backupStorage = 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
// Send start message for update (only if we're actually starting an update)
this.sendMessage(ws, {
type: 'start',
data: `Starting update for container ${containerId}...`,

View File

@@ -0,0 +1,67 @@
'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>
);
}

View File

@@ -0,0 +1,503 @@
'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>
);
}

View File

@@ -10,6 +10,7 @@ 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?: (
@@ -25,14 +26,7 @@ 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>({
searchQuery: '',
showUpdatable: null,
selectedTypes: [],
selectedRepositories: [],
sortBy: 'name',
sortOrder: 'asc',
});
const [filters, setFilters] = useState<FilterState>(getDefaultFilters());
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
const gridRef = useRef<HTMLDivElement>(null);
@@ -63,7 +57,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
if (filtersResponse.ok) {
const filtersData = await filtersResponse.json();
if (filtersData.filters) {
setFilters(filtersData.filters as FilterState);
setFilters(mergeFiltersWithDefaults(filtersData.filters));
}
}
}

View File

@@ -5,6 +5,7 @@ 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;
@@ -67,14 +68,7 @@ export function FilterBar({
};
const clearAllFilters = () => {
onFiltersChange({
searchQuery: "",
showUpdatable: null,
selectedTypes: [],
selectedRepositories: [],
sortBy: "name",
sortOrder: "asc",
});
onFiltersChange(getDefaultFilters());
};
const hasActiveFilters =

View File

@@ -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 } from 'lucide-react';
import { HelpCircle, Server, Settings, RefreshCw, Clock, Package, HardDrive, FolderOpen, Search, Download, Lock, GitBranch, Archive } 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';
type HelpSection = 'server-settings' | 'general-settings' | 'auth-settings' | 'sync-button' | 'auto-sync' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system' | 'repositories' | 'backups';
export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) {
useRegisterModal(isOpen, { id: 'help-modal', allowEscape: true, onClose });
@@ -30,6 +30,7 @@ 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 },
];
@@ -925,6 +926,144 @@ 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&apos;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 &quot;Update&quot; 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&apos;ll be warned but can still choose to proceed with the update. This ensures updates aren&apos;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 &quot;Backup&quot;</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 &quot;backup&quot; 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 &quot;Fetch Storages&quot; 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 &quot;View Storages&quot; 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 &lt;CTID&gt; --storage &lt;STORAGE&gt; --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 &quot;Fetch Storages&quot; 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;
}

View File

@@ -10,6 +10,9 @@ 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,
@@ -50,8 +53,15 @@ 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 } | null>(null);
const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any; backupStorage?: string; isBackupOnly?: boolean } | 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);
@@ -244,22 +254,54 @@ export function InstalledScriptsTab() {
void refetchScripts();
setAutoDetectStatus({
type: 'success',
message: data.message ?? 'Web UI IP detected successfully!'
message: data.success ? `Detected IP: ${data.ip}` : (data.error ?? 'Failed to detect Web UI')
});
// Clear status after 5 seconds
setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000);
},
onError: (error) => {
console.error('❌ Auto-detect Web UI error:', error);
console.error('❌ Auto-detect WebUI error:', error);
setAutoDetectStatus({
type: 'error',
message: error.message ?? 'Auto-detect failed. Please try again.'
message: error.message ?? 'Failed to detect Web UI'
});
// Clear status after 5 seconds
setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000);
setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 8000);
}
});
// 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
@@ -600,38 +642,154 @@ 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: 'Update Script',
confirmButtonText: 'Continue',
onConfirm: () => {
// 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
});
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'
});
// Proceed without backup
proceedWithUpdate(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({
@@ -887,12 +1045,15 @@ export function InstalledScriptsTab() {
{updatingScript && (
<div className="mb-8" data-terminal="update">
<Terminal
scriptPath={`update-${updatingScript.containerId}`}
scriptPath={updatingScript.isBackupOnly ? `backup-${updatingScript.containerId}` : `update-${updatingScript.containerId}`}
onClose={handleCloseUpdateTerminal}
mode={updatingScript.server ? 'ssh' : 'local'}
server={updatingScript.server}
isUpdate={true}
isUpdate={!updatingScript.isBackupOnly}
isBackup={updatingScript.isBackupOnly}
containerId={updatingScript.containerId}
storage={updatingScript.isBackupOnly ? updatingScript.backupStorage : undefined}
backupStorage={!updatingScript.isBackupOnly ? updatingScript.backupStorage : undefined}
/>
</div>
)}
@@ -1252,6 +1413,7 @@ export function InstalledScriptsTab() {
onSave={handleSaveEdit}
onCancel={handleCancelEdit}
onUpdate={() => handleUpdateScript(script)}
onBackup={() => handleBackupScript(script)}
onShell={() => handleOpenShell(script)}
onDelete={() => handleDeleteScript(Number(script.id))}
isUpdating={updateScriptMutation.isPending}
@@ -1530,6 +1692,15 @@ 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)}
@@ -1656,6 +1827,79 @@ 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}

View File

@@ -1,36 +1,84 @@
'use client';
import { Loader2 } from 'lucide-react';
import { Loader2, CheckCircle, X } 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 }: LoadingModalProps) {
useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: false, onClose: () => null });
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]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border p-8">
<div className="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="flex flex-col items-center space-y-4">
<div className="relative">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
{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>
</>
)}
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-card-foreground mb-2">
Processing
</h3>
{/* Static title text */}
{title && (
<p className="text-sm text-muted-foreground">
{action}
{title}
</p>
<p className="text-xs text-muted-foreground mt-2">
Please wait...
</p>
</div>
)}
{/* 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>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,296 @@
'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>
);
}

View File

@@ -44,6 +44,7 @@ interface ScriptInstallationCardProps {
onSave: () => void;
onCancel: () => void;
onUpdate: () => void;
onBackup?: () => void;
onShell: () => void;
onDelete: () => void;
isUpdating: boolean;
@@ -68,6 +69,7 @@ export function ScriptInstallationCard({
onSave,
onCancel,
onUpdate,
onBackup,
onShell,
onDelete,
isUpdating,
@@ -307,6 +309,15 @@ 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}

View File

@@ -11,6 +11,7 @@ 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 {
@@ -25,14 +26,7 @@ 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>({
searchQuery: '',
showUpdatable: null,
selectedTypes: [],
selectedRepositories: [],
sortBy: 'name',
sortOrder: 'asc',
});
const [filters, setFilters] = useState<FilterState>(getDefaultFilters());
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
const [isNewestMinimized, setIsNewestMinimized] = useState(false);
@@ -67,7 +61,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
if (filtersResponse.ok) {
const filtersData = await filtersResponse.json();
if (filtersData.filters) {
setFilters(filtersData.filters as FilterState);
setFilters(mergeFiltersWithDefaults(filtersData.filters));
}
}
}

View File

@@ -6,7 +6,8 @@ import { ServerForm } from './ServerForm';
import { Button } from './ui/button';
import { ConfirmationModal } from './ConfirmationModal';
import { PublicKeyModal } from './PublicKeyModal';
import { Key } from 'lucide-react';
import { ServerStoragesModal } from './ServerStoragesModal';
import { Key, Database } from 'lucide-react';
interface ServerListProps {
servers: Server[];
@@ -32,6 +33,8 @@ 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);
@@ -251,6 +254,19 @@ 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 && (
@@ -324,6 +340,19 @@ 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>
);
}

View File

@@ -0,0 +1,227 @@
'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>
);
}

View File

@@ -0,0 +1,168 @@
'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>
);
}

View File

@@ -12,7 +12,10 @@ interface TerminalProps {
server?: any;
isUpdate?: boolean;
isShell?: boolean;
isBackup?: boolean;
containerId?: string;
storage?: string;
backupStorage?: string;
}
interface TerminalMessage {
@@ -21,7 +24,7 @@ interface TerminalMessage {
timestamp: number;
}
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, containerId }: TerminalProps) {
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, isBackup = false, containerId, storage, backupStorage }: TerminalProps) {
const [isConnected, setIsConnected] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const [isClient, setIsClient] = useState(false);
@@ -334,7 +337,10 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
server,
isUpdate,
isShell,
containerId
isBackup,
containerId,
storage,
backupStorage
};
ws.send(JSON.stringify(message));
}

View File

@@ -0,0 +1,44 @@
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,
};
}

View File

@@ -5,6 +5,7 @@ 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';
@@ -16,16 +17,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 } from 'lucide-react';
import { Package, HardDrive, FolderOpen, LogOut, Archive } 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'>(() => {
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed' | 'backups'>(() => {
if (typeof window !== 'undefined') {
const savedTab = localStorage.getItem('activeTab') as 'scripts' | 'downloaded' | 'installed';
const savedTab = localStorage.getItem('activeTab') as 'scripts' | 'downloaded' | 'installed' | 'backups';
return savedTab || 'scripts';
}
return 'scripts';
@@ -38,6 +39,7 @@ 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
@@ -118,7 +120,8 @@ export default function Home() {
});
}).length;
})(),
installed: installedScriptsData?.scripts?.length ?? 0
installed: installedScriptsData?.scripts?.length ?? 0,
backups: backupsData?.success ? backupsData.backups.length : 0
};
const scrollToTerminal = () => {
@@ -243,6 +246,22 @@ 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>
@@ -273,6 +292,10 @@ export default function Home() {
{activeTab === 'installed' && (
<InstalledScriptsTab />
)}
{activeTab === 'backups' && (
<BackupsTab />
)}
</div>
{/* Footer */}

View File

@@ -2,6 +2,8 @@ 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";
@@ -15,6 +17,8 @@ export const appRouter = createTRPCRouter({
installedScripts: installedScriptsRouter,
servers: serversRouter,
version: versionRouter,
backups: backupsRouter,
pbsCredentials: pbsCredentialsRouter,
repositories: repositoriesRouter,
});

View File

@@ -0,0 +1,170 @@
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: [],
};
}
}),
});

View File

@@ -3,6 +3,7 @@ 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 {
@@ -2038,5 +2039,163 @@ 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
};
}
})
});

View File

@@ -0,0 +1,153 @@
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',
};
}
}),
});

View File

@@ -271,6 +271,161 @@ 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();
}

View File

@@ -298,6 +298,197 @@ 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();
}

View File

@@ -0,0 +1,690 @@
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;
}

View File

@@ -0,0 +1,561 @@
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;
}

View File

@@ -0,0 +1,223 @@
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;
}