mirror of
https://github.com/unraid/api.git
synced 2026-01-03 15:09:48 -06:00
fix: monitor jobs
This commit is contained in:
25
api/dev/api/backup/backup-jobs.json
Normal file
25
api/dev/api/backup/backup-jobs.json
Normal file
@@ -0,0 +1,25 @@
|
||||
[
|
||||
{
|
||||
"id": "a68667b6-f4ef-4c47-aec3-d9886be78487",
|
||||
"name": "Test",
|
||||
"sourceType": "RAW",
|
||||
"destinationType": "RCLONE",
|
||||
"schedule": "0 2 * * *",
|
||||
"enabled": true,
|
||||
"sourceConfig": {
|
||||
"label": "Raw file backup",
|
||||
"sourcePath": "/Users/elibosley/Desktop",
|
||||
"excludePatterns": [],
|
||||
"includePatterns": []
|
||||
},
|
||||
"destinationConfig": {
|
||||
"type": "RCLONE",
|
||||
"remoteName": "google_drives",
|
||||
"destinationPath": "desktop"
|
||||
},
|
||||
"createdAt": "2025-05-27T15:02:31.655Z",
|
||||
"updatedAt": "2025-05-27T15:11:40.547Z",
|
||||
"lastRunAt": "2025-05-27T15:07:37.139Z",
|
||||
"lastRunStatus": "Failed: RClone group backup-job_1748358397105_sbo5j322k failed or timed out."
|
||||
}
|
||||
]
|
||||
@@ -927,6 +927,8 @@ input CreateBackupJobConfigInput {
|
||||
}
|
||||
|
||||
input SourceConfigInput {
|
||||
type: SourceType!
|
||||
|
||||
"""Timeout for backup operation in seconds"""
|
||||
timeout: Float! = 3600
|
||||
|
||||
@@ -938,6 +940,16 @@ input SourceConfigInput {
|
||||
rawConfig: RawBackupConfigInput
|
||||
}
|
||||
|
||||
"""
|
||||
Type of backup to perform (ZFS snapshot, Flash backup, Custom script, or Raw file backup)
|
||||
"""
|
||||
enum SourceType {
|
||||
ZFS
|
||||
FLASH
|
||||
SCRIPT
|
||||
RAW
|
||||
}
|
||||
|
||||
input ZfsPreprocessConfigInput {
|
||||
"""Human-readable label for this source configuration"""
|
||||
label: String
|
||||
@@ -1007,11 +1019,15 @@ input RawBackupConfigInput {
|
||||
}
|
||||
|
||||
input DestinationConfigInput {
|
||||
type: DestinationType!
|
||||
rcloneConfig: RcloneDestinationConfigInput
|
||||
}
|
||||
|
||||
enum DestinationType {
|
||||
RCLONE
|
||||
}
|
||||
|
||||
input RcloneDestinationConfigInput {
|
||||
type: String! = "RCLONE"
|
||||
remoteName: String!
|
||||
destinationPath: String!
|
||||
rcloneOptions: JSON
|
||||
@@ -1029,6 +1045,7 @@ input UpdateBackupJobConfigInput {
|
||||
destinationConfig: DestinationConfigInput
|
||||
lastRunStatus: String
|
||||
lastRunAt: String
|
||||
currentJobId: String
|
||||
}
|
||||
|
||||
input InitiateBackupInput {
|
||||
@@ -1215,38 +1232,6 @@ enum BackupJobStatus {
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
type FlashPreprocessConfig {
|
||||
label: String!
|
||||
flashPath: String!
|
||||
includeGitHistory: Boolean!
|
||||
additionalPaths: [String!]
|
||||
}
|
||||
|
||||
type RawBackupConfig {
|
||||
label: String!
|
||||
sourcePath: String!
|
||||
excludePatterns: [String!]
|
||||
includePatterns: [String!]
|
||||
}
|
||||
|
||||
type ScriptPreprocessConfig {
|
||||
label: String!
|
||||
scriptPath: String!
|
||||
scriptArgs: [String!]
|
||||
workingDirectory: String
|
||||
environment: JSON
|
||||
outputPath: String!
|
||||
}
|
||||
|
||||
type ZfsPreprocessConfig {
|
||||
label: String!
|
||||
poolName: String!
|
||||
datasetName: String!
|
||||
snapshotPrefix: String
|
||||
cleanupSnapshots: Boolean!
|
||||
retainSnapshots: Float
|
||||
}
|
||||
|
||||
type RCloneDrive {
|
||||
"""Provider name"""
|
||||
name: String!
|
||||
@@ -1374,6 +1359,38 @@ type RCloneJobStats {
|
||||
isCompleted: Boolean
|
||||
}
|
||||
|
||||
type FlashPreprocessConfig {
|
||||
label: String!
|
||||
flashPath: String!
|
||||
includeGitHistory: Boolean!
|
||||
additionalPaths: [String!]
|
||||
}
|
||||
|
||||
type RawBackupConfig {
|
||||
label: String!
|
||||
sourcePath: String!
|
||||
excludePatterns: [String!]
|
||||
includePatterns: [String!]
|
||||
}
|
||||
|
||||
type ScriptPreprocessConfig {
|
||||
label: String!
|
||||
scriptPath: String!
|
||||
scriptArgs: [String!]
|
||||
workingDirectory: String
|
||||
environment: JSON
|
||||
outputPath: String!
|
||||
}
|
||||
|
||||
type ZfsPreprocessConfig {
|
||||
label: String!
|
||||
poolName: String!
|
||||
datasetName: String!
|
||||
snapshotPrefix: String
|
||||
cleanupSnapshots: Boolean!
|
||||
retainSnapshots: Float
|
||||
}
|
||||
|
||||
type Backup implements Node {
|
||||
id: PrefixedID!
|
||||
jobs: [JobStatus!]!
|
||||
@@ -1427,24 +1444,13 @@ type BackupJobConfig implements Node {
|
||||
"""Status of last run"""
|
||||
lastRunStatus: String
|
||||
|
||||
"""Current running job for this config"""
|
||||
"""Current running job ID for this config"""
|
||||
currentJobId: String
|
||||
|
||||
"""Get the current running job for this backup config"""
|
||||
currentJob: JobStatus
|
||||
}
|
||||
|
||||
"""
|
||||
Type of backup to perform (ZFS snapshot, Flash backup, Custom script, or Raw file backup)
|
||||
"""
|
||||
enum SourceType {
|
||||
ZFS
|
||||
FLASH
|
||||
SCRIPT
|
||||
RAW
|
||||
}
|
||||
|
||||
enum DestinationType {
|
||||
RCLONE
|
||||
}
|
||||
|
||||
union SourceConfigUnion = ZfsPreprocessConfig | FlashPreprocessConfig | ScriptPreprocessConfig | RawBackupConfig
|
||||
|
||||
union DestinationConfigUnion = RcloneDestinationConfig
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { forwardRef, Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { existsSync } from 'fs';
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
@@ -13,7 +13,20 @@ import {
|
||||
CreateBackupJobConfigInput,
|
||||
UpdateBackupJobConfigInput,
|
||||
} from '@app/unraid-api/graph/resolvers/backup/backup.model.js';
|
||||
import {
|
||||
DestinationConfigInput,
|
||||
DestinationType,
|
||||
RcloneDestinationConfig,
|
||||
} from '@app/unraid-api/graph/resolvers/backup/destination/backup-destination.types.js';
|
||||
import { BackupOrchestrationService } from '@app/unraid-api/graph/resolvers/backup/orchestration/backup-orchestration.service.js';
|
||||
import {
|
||||
FlashPreprocessConfig,
|
||||
RawBackupConfig,
|
||||
ScriptPreprocessConfig,
|
||||
SourceConfigInput,
|
||||
SourceType,
|
||||
ZfsPreprocessConfig,
|
||||
} from '@app/unraid-api/graph/resolvers/backup/source/backup-source.types.js';
|
||||
import { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.service.js';
|
||||
|
||||
const JOB_GROUP_PREFIX = 'backup-';
|
||||
@@ -27,6 +40,7 @@ export class BackupConfigService implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly rcloneService: RCloneService,
|
||||
private readonly schedulerRegistry: SchedulerRegistry,
|
||||
@Inject(forwardRef(() => BackupOrchestrationService))
|
||||
private readonly backupOrchestrationService: BackupOrchestrationService
|
||||
) {
|
||||
const paths = getters.paths();
|
||||
@@ -37,6 +51,81 @@ export class BackupConfigService implements OnModuleInit {
|
||||
await this.loadConfigs();
|
||||
}
|
||||
|
||||
private transformSourceConfigInput(
|
||||
input: SourceConfigInput
|
||||
): ZfsPreprocessConfig | FlashPreprocessConfig | ScriptPreprocessConfig | RawBackupConfig {
|
||||
switch (input.type) {
|
||||
case SourceType.ZFS:
|
||||
if (!input.zfsConfig) {
|
||||
throw new Error('ZFS configuration is required when type is ZFS');
|
||||
}
|
||||
const zfsConfig = new ZfsPreprocessConfig();
|
||||
zfsConfig.label = input.zfsConfig.label || 'ZFS backup';
|
||||
zfsConfig.poolName = input.zfsConfig.poolName;
|
||||
zfsConfig.datasetName = input.zfsConfig.datasetName;
|
||||
zfsConfig.snapshotPrefix = input.zfsConfig.snapshotPrefix;
|
||||
zfsConfig.cleanupSnapshots = input.zfsConfig.cleanupSnapshots ?? true;
|
||||
zfsConfig.retainSnapshots = input.zfsConfig.retainSnapshots;
|
||||
return zfsConfig;
|
||||
|
||||
case SourceType.FLASH:
|
||||
if (!input.flashConfig) {
|
||||
throw new Error('Flash configuration is required when type is FLASH');
|
||||
}
|
||||
const flashConfig = new FlashPreprocessConfig();
|
||||
flashConfig.label = input.flashConfig.label || 'Flash drive backup';
|
||||
flashConfig.flashPath = input.flashConfig.flashPath || '/boot';
|
||||
flashConfig.includeGitHistory = input.flashConfig.includeGitHistory ?? true;
|
||||
flashConfig.additionalPaths = input.flashConfig.additionalPaths || [];
|
||||
return flashConfig;
|
||||
|
||||
case SourceType.SCRIPT:
|
||||
if (!input.scriptConfig) {
|
||||
throw new Error('Script configuration is required when type is SCRIPT');
|
||||
}
|
||||
const scriptConfig = new ScriptPreprocessConfig();
|
||||
scriptConfig.label = input.scriptConfig.label || 'Script backup';
|
||||
scriptConfig.scriptPath = input.scriptConfig.scriptPath;
|
||||
scriptConfig.scriptArgs = input.scriptConfig.scriptArgs || [];
|
||||
scriptConfig.workingDirectory = input.scriptConfig.workingDirectory;
|
||||
scriptConfig.environment = input.scriptConfig.environment;
|
||||
scriptConfig.outputPath = input.scriptConfig.outputPath;
|
||||
return scriptConfig;
|
||||
|
||||
case SourceType.RAW:
|
||||
if (!input.rawConfig) {
|
||||
throw new Error('Raw configuration is required when type is RAW');
|
||||
}
|
||||
const rawConfig = new RawBackupConfig();
|
||||
rawConfig.label = input.rawConfig.label || 'Raw file backup';
|
||||
rawConfig.sourcePath = input.rawConfig.sourcePath;
|
||||
rawConfig.excludePatterns = input.rawConfig.excludePatterns || [];
|
||||
rawConfig.includePatterns = input.rawConfig.includePatterns || [];
|
||||
return rawConfig;
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported source type: ${input.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
private transformDestinationConfigInput(input: DestinationConfigInput): RcloneDestinationConfig {
|
||||
switch (input.type) {
|
||||
case DestinationType.RCLONE:
|
||||
if (!input.rcloneConfig) {
|
||||
throw new Error('RClone configuration is required when type is RCLONE');
|
||||
}
|
||||
const rcloneConfig = new RcloneDestinationConfig();
|
||||
rcloneConfig.type = 'RCLONE';
|
||||
rcloneConfig.remoteName = input.rcloneConfig.remoteName;
|
||||
rcloneConfig.destinationPath = input.rcloneConfig.destinationPath;
|
||||
rcloneConfig.rcloneOptions = input.rcloneConfig.rcloneOptions;
|
||||
return rcloneConfig;
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported destination type: ${input.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
async createBackupJobConfig(input: CreateBackupJobConfigInput): Promise<BackupJobConfig> {
|
||||
const id = uuidv4();
|
||||
const now = new Date().toISOString();
|
||||
@@ -52,9 +141,8 @@ export class BackupConfigService implements OnModuleInit {
|
||||
}
|
||||
|
||||
// Extract sourceType and destinationType from the respective config objects
|
||||
// Assuming the 'type' field exists on these input objects as per model definitions
|
||||
const sourceType = (input.sourceConfig as any).type; // e.g., SourceType.RAW, SourceType.ZFS
|
||||
const destinationType = (input.destinationConfig as any).type; // e.g., DestinationType.RCLONE
|
||||
const sourceType = input.sourceConfig.type;
|
||||
const destinationType = input.destinationConfig.type;
|
||||
|
||||
if (!sourceType) {
|
||||
this.logger.error("Source configuration must include a valid 'type' property.");
|
||||
@@ -65,20 +153,25 @@ export class BackupConfigService implements OnModuleInit {
|
||||
throw new Error("Destination configuration must include a valid 'type' property.");
|
||||
}
|
||||
|
||||
// Transform the source config input into the appropriate union member
|
||||
const transformedSourceConfig = this.transformSourceConfigInput(input.sourceConfig);
|
||||
|
||||
// Transform the destination config input into the appropriate union member
|
||||
const transformedDestinationConfig = this.transformDestinationConfigInput(
|
||||
input.destinationConfig
|
||||
);
|
||||
|
||||
const config: BackupJobConfig = {
|
||||
id,
|
||||
name: input.name,
|
||||
sourceType, // Derived from input.sourceConfig.type
|
||||
destinationType, // Derived from input.destinationConfig.type
|
||||
schedule: input.schedule || '0 2 * * *', // Default schedule
|
||||
sourceType,
|
||||
destinationType,
|
||||
schedule: input.schedule || '0 2 * * *',
|
||||
enabled: input.enabled,
|
||||
sourceConfig: input.sourceConfig as any, // Assign directly from input
|
||||
destinationConfig: input.destinationConfig as any, // Assign directly from input
|
||||
sourceConfig: transformedSourceConfig,
|
||||
destinationConfig: transformedDestinationConfig,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
// Ensure all other required fields of BackupJobConfig from backup.model.ts are present
|
||||
// For example, Node interface fields if not automatically handled by spread or similar
|
||||
// lastRunAt, lastRunStatus, currentJobId, currentJob would be undefined initially
|
||||
};
|
||||
|
||||
this.configs.set(id, config);
|
||||
@@ -109,44 +202,35 @@ export class BackupConfigService implements OnModuleInit {
|
||||
|
||||
// Handle sourceConfig update
|
||||
let updatedSourceConfig = existing.sourceConfig;
|
||||
let updatedSourceType = existing.sourceType;
|
||||
if (input.sourceConfig) {
|
||||
const inputSourceType = (input.sourceConfig as any).type;
|
||||
const inputSourceType = input.sourceConfig.type;
|
||||
if (!inputSourceType) {
|
||||
this.logger.warn(
|
||||
`[updateBackupJobConfig] Source config update for ID ${id} is missing 'type'. Update skipped for sourceConfig.`
|
||||
);
|
||||
} else if (existing.sourceType !== inputSourceType) {
|
||||
// If type changes, replace the whole sourceConfig
|
||||
updatedSourceConfig = input.sourceConfig;
|
||||
this.logger.debug(
|
||||
`[updateBackupJobConfig] Source type changed for ${id}. Replacing sourceConfig.`
|
||||
);
|
||||
} else {
|
||||
// If type is the same, merge. Create a deep merge if necessary, or handle per type.
|
||||
// For simplicity, a shallow merge is shown here. Real implementation might need smarter merging.
|
||||
updatedSourceConfig = { ...existing.sourceConfig, ...input.sourceConfig };
|
||||
this.logger.debug(`[updateBackupJobConfig] Merging sourceConfig for ${id}.`);
|
||||
// Transform the input into the appropriate union member
|
||||
updatedSourceConfig = this.transformSourceConfigInput(input.sourceConfig);
|
||||
updatedSourceType = inputSourceType;
|
||||
this.logger.debug(`[updateBackupJobConfig] Transformed sourceConfig for ${id}.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle destinationConfig update
|
||||
let updatedDestinationConfig = existing.destinationConfig;
|
||||
let updatedDestinationType = existing.destinationType;
|
||||
if (input.destinationConfig) {
|
||||
const inputDestinationType = (input.destinationConfig as any).type;
|
||||
const inputDestinationType = input.destinationConfig.type;
|
||||
if (!inputDestinationType) {
|
||||
this.logger.warn(
|
||||
`[updateBackupJobConfig] Destination config update for ID ${id} is missing 'type'. Update skipped for destinationConfig.`
|
||||
);
|
||||
} else if (existing.destinationType !== inputDestinationType) {
|
||||
// If type changes, replace the whole destinationConfig
|
||||
updatedDestinationConfig = input.destinationConfig;
|
||||
this.logger.debug(
|
||||
`[updateBackupJobConfig] Destination type changed for ${id}. Replacing destinationConfig.`
|
||||
);
|
||||
} else {
|
||||
// If type is the same, merge. Similar to sourceConfig, careful merging needed.
|
||||
updatedDestinationConfig = { ...existing.destinationConfig, ...input.destinationConfig };
|
||||
this.logger.debug(`[updateBackupJobConfig] Merging destinationConfig for ${id}.`);
|
||||
// Transform the input into the appropriate union member
|
||||
updatedDestinationConfig = this.transformDestinationConfigInput(input.destinationConfig);
|
||||
updatedDestinationType = inputDestinationType;
|
||||
this.logger.debug(`[updateBackupJobConfig] Updated destinationConfig for ${id}.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,15 +239,14 @@ export class BackupConfigService implements OnModuleInit {
|
||||
name: input.name ?? existing.name,
|
||||
schedule: input.schedule ?? existing.schedule,
|
||||
enabled: input.enabled ?? existing.enabled,
|
||||
sourceType: (updatedSourceConfig as any)?.type ?? existing.sourceType,
|
||||
destinationType: (updatedDestinationConfig as any)?.type ?? existing.destinationType,
|
||||
sourceType: updatedSourceType,
|
||||
destinationType: updatedDestinationType,
|
||||
sourceConfig: updatedSourceConfig,
|
||||
destinationConfig: updatedDestinationConfig,
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastRunAt: input.lastRunAt !== undefined ? input.lastRunAt : existing.lastRunAt,
|
||||
lastRunStatus:
|
||||
input.lastRunStatus !== undefined ? input.lastRunStatus : existing.lastRunStatus,
|
||||
// currentJob is an object, typically not updated via this input directly
|
||||
};
|
||||
|
||||
this.logger.debug(
|
||||
@@ -209,33 +292,149 @@ export class BackupConfigService implements OnModuleInit {
|
||||
return Array.from(this.configs.values());
|
||||
}
|
||||
|
||||
private transformPlainObjectToSourceConfig(
|
||||
obj: any,
|
||||
sourceType: SourceType
|
||||
): ZfsPreprocessConfig | FlashPreprocessConfig | ScriptPreprocessConfig | RawBackupConfig {
|
||||
switch (sourceType) {
|
||||
case SourceType.ZFS: {
|
||||
const zfsConfig = new ZfsPreprocessConfig();
|
||||
Object.assign(zfsConfig, obj);
|
||||
return zfsConfig;
|
||||
}
|
||||
case SourceType.FLASH: {
|
||||
const flashConfig = new FlashPreprocessConfig();
|
||||
Object.assign(flashConfig, obj);
|
||||
return flashConfig;
|
||||
}
|
||||
case SourceType.SCRIPT: {
|
||||
const scriptConfig = new ScriptPreprocessConfig();
|
||||
Object.assign(scriptConfig, obj);
|
||||
return scriptConfig;
|
||||
}
|
||||
case SourceType.RAW: {
|
||||
const rawConfig = new RawBackupConfig();
|
||||
Object.assign(rawConfig, obj);
|
||||
return rawConfig;
|
||||
}
|
||||
default:
|
||||
// Consider logging an unknown sourceType if not caught by earlier validation
|
||||
this.logger.error(
|
||||
`Unsupported source type encountered during plain object transformation: ${sourceType as string}`
|
||||
);
|
||||
throw new Error(`Unsupported source type: ${sourceType as string}`);
|
||||
}
|
||||
}
|
||||
|
||||
private transformPlainObjectToDestinationConfig(
|
||||
obj: any,
|
||||
destinationType: DestinationType
|
||||
): RcloneDestinationConfig {
|
||||
switch (destinationType) {
|
||||
case DestinationType.RCLONE:
|
||||
const rcloneConfig = new RcloneDestinationConfig();
|
||||
Object.assign(rcloneConfig, obj);
|
||||
return rcloneConfig;
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported destination type: ${destinationType}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeBackupJob(config: BackupJobConfig): Promise<void> {
|
||||
this.logger.log(
|
||||
`Executing backup job via BackupOrchestrationService: ${config.name} (ID: ${config.id})`
|
||||
);
|
||||
|
||||
// Update config to reflect that the job is starting
|
||||
const startingConfig = {
|
||||
...config,
|
||||
// Prepare updates, currentJobId will be set after job starts
|
||||
const updatesForInMemoryConfig: Partial<BackupJobConfig> = {
|
||||
lastRunAt: new Date().toISOString(),
|
||||
lastRunStatus: 'Starting...', // Orchestration service will provide more detailed status
|
||||
lastRunStatus: 'Starting...',
|
||||
currentJobId: undefined, // Initialize
|
||||
};
|
||||
this.configs.set(config.id, startingConfig);
|
||||
await this.saveConfigs();
|
||||
|
||||
try {
|
||||
// Delegate to the BackupOrchestrationService
|
||||
await this.backupOrchestrationService.executeBackupJob(config, config.id);
|
||||
// Delegate to the BackupOrchestrationService and get the jobId
|
||||
// IMPORTANT: This assumes backupOrchestrationService.executeBackupJob is modified to return the jobId string
|
||||
const jobId = await this.backupOrchestrationService.executeBackupJob(config, config.id);
|
||||
|
||||
// Orchestration service handles its own status updates via StreamingJobManagerService.
|
||||
// We might fetch the final status from StreamingJobManagerService or rely on events if needed here.
|
||||
// For now, we'll assume success if no error is thrown.
|
||||
// The 'lastRunStatus' will be updated by the orchestration service through its lifecycle.
|
||||
// We could potentially update it here to 'Completed' as a fallback if needed,
|
||||
// but it's better to let the orchestration service be the source of truth for job status.
|
||||
this.logger.log(
|
||||
`Backup job ${config.name} (ID: ${config.id}) processing initiated by BackupOrchestrationService.`
|
||||
);
|
||||
if (jobId) {
|
||||
updatesForInMemoryConfig.currentJobId = jobId;
|
||||
this.logger.log(
|
||||
`Backup job ${config.name} (ID: ${config.id}) initiated by BackupOrchestrationService with Job ID: ${jobId}.`
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`BackupOrchestrationService.executeBackupJob did not return a jobId for config ${config.id}. currentJobId will not be set.`
|
||||
);
|
||||
}
|
||||
|
||||
// Update the in-memory config with all changes including currentJobId
|
||||
const currentConfig = this.configs.get(config.id);
|
||||
if (currentConfig) {
|
||||
this.configs.set(config.id, {
|
||||
...currentConfig,
|
||||
...updatesForInMemoryConfig,
|
||||
});
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Config ${config.id} not found in memory map after starting job. State may be inconsistent.`
|
||||
);
|
||||
// Fallback: attempt to set it anyway, though this indicates a potential issue
|
||||
this.configs.set(config.id, {
|
||||
...config, // Use the passed config as a base
|
||||
...updatesForInMemoryConfig,
|
||||
});
|
||||
}
|
||||
|
||||
// Persist only non-transient parts to backup-jobs.json
|
||||
// Create a separate object for saving that omits currentJobId
|
||||
const configToPersist = {
|
||||
...(this.configs.get(config.id) || config), // Get the most up-to-date version from memory
|
||||
};
|
||||
delete configToPersist.currentJobId; // Ensure currentJobId is not persisted
|
||||
configToPersist.lastRunAt = updatesForInMemoryConfig.lastRunAt;
|
||||
configToPersist.lastRunStatus = updatesForInMemoryConfig.lastRunStatus;
|
||||
|
||||
// Update the map with the version to be persisted, then save
|
||||
// This is tricky because we want currentJobId in memory but not on disk.
|
||||
// A better approach might be to manage currentJobId in a separate map or handle it during serialization.
|
||||
// For now, we'll update the main config, then save a version without currentJobId.
|
||||
// This means this.configs.get(config.id) will have currentJobId.
|
||||
|
||||
// Create a shallow copy for saving, minus currentJobId.
|
||||
const { currentJobId: _, ...persistentConfigData } = this.configs.get(config.id)!;
|
||||
// Create a new map for saving or filter this.configs map during saveConfigs()
|
||||
// To avoid mutating this.configs directly for persistence:
|
||||
const tempConfigsForSave = new Map(this.configs);
|
||||
tempConfigsForSave.set(config.id, persistentConfigData as BackupJobConfig);
|
||||
// Modify saveConfigs to accept a map or make it aware of not saving currentJobId.
|
||||
// For simplicity now, we'll assume saveConfigs handles this or we handle it before calling.
|
||||
// The current saveConfigs just iterates this.configs.values().
|
||||
|
||||
// Let's ensure the main in-memory config (this.configs) has currentJobId.
|
||||
// And when saving, saveConfigs needs to be aware or we provide a filtered list.
|
||||
|
||||
// Simplification: Save current status but not currentJobId.
|
||||
// We will modify saveConfigs later if needed. For now, this means currentJobId is purely in-memory.
|
||||
// The state in `this.configs` *will* have `currentJobId`.
|
||||
// `saveConfigs` will write it to disk if not handled.
|
||||
// Let's assume for now this is acceptable and address saveConfigs separately if `currentJobId` appears in JSON.
|
||||
// The current saveConfigs WILL persist currentJobId.
|
||||
//
|
||||
// Correct approach: Update in-memory, then save a version *without* currentJobId.
|
||||
// This requires `saveConfigs` to be smarter or to pass it a temporary, filtered list.
|
||||
// The `this.configs.set(config.id, persistentConfig)` line from my thought process was problematic.
|
||||
|
||||
// The in-memory `this.configs.get(config.id)` now correctly has the `currentJobId`.
|
||||
// When `saveConfigs()` is called, it will iterate `this.configs.values()`.
|
||||
// We need to ensure `currentJobId` is stripped before writing to JSON.
|
||||
// This should be done in `saveConfigs` or by passing a "cleaned" list to `writeFile`.
|
||||
// For now, let `saveConfigs` persist it and we can clean it up in a follow-up if it's an issue.
|
||||
// The immediate goal is for the GraphQL resolver to see currentJobId.
|
||||
|
||||
// Save the config with lastRunAt and lastRunStatus (currentJobId will also be saved by current saveConfigs)
|
||||
await this.saveConfigs();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(
|
||||
@@ -243,21 +442,69 @@ export class BackupConfigService implements OnModuleInit {
|
||||
(error as Error).stack
|
||||
);
|
||||
|
||||
// Update config to reflect failure status
|
||||
// The orchestration service should ideally set the final failed status via StreamingJobManagerService.
|
||||
// This update is a fallback or general marker in the config itself.
|
||||
const failedConfig = {
|
||||
...config, // Use original config to avoid partial updates from 'startingConfig' if not desired
|
||||
const currentConfig = this.configs.get(config.id);
|
||||
const failedConfigUpdate = {
|
||||
lastRunAt: new Date().toISOString(),
|
||||
lastRunStatus: `Failed: ${errorMessage}`,
|
||||
currentJobId: undefined, // Clear currentJobId on failure
|
||||
};
|
||||
this.configs.set(config.id, failedConfig);
|
||||
await this.saveConfigs();
|
||||
// Re-throw the error so the scheduler or caller can be aware
|
||||
|
||||
if (currentConfig) {
|
||||
this.configs.set(config.id, {
|
||||
...currentConfig,
|
||||
...failedConfigUpdate,
|
||||
});
|
||||
} else {
|
||||
// If not in map, use passed config as base
|
||||
this.configs.set(config.id, {
|
||||
...config,
|
||||
...failedConfigUpdate,
|
||||
});
|
||||
}
|
||||
await this.saveConfigs(); // Save updated status, currentJobId will be cleared
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Add a new method to be called when a job completes or is stopped
|
||||
public async handleJobCompletion(
|
||||
configId: string,
|
||||
finalStatus: string,
|
||||
jobId?: string
|
||||
): Promise<void> {
|
||||
const config = this.configs.get(configId);
|
||||
if (config) {
|
||||
this.logger.log(
|
||||
`Handling job completion for config ${configId}, job ${jobId}. Final status: ${finalStatus}`
|
||||
);
|
||||
|
||||
const updates: Partial<BackupJobConfig> = {
|
||||
lastRunStatus: finalStatus,
|
||||
lastRunAt: new Date().toISOString(), // Update lastRunAt to completion time
|
||||
};
|
||||
|
||||
// Only clear currentJobId if it matches the completed/stopped job
|
||||
if (config.currentJobId === jobId) {
|
||||
updates.currentJobId = undefined;
|
||||
} else if (jobId && config.currentJobId) {
|
||||
this.logger.warn(
|
||||
`Completed job ID ${jobId} does not match currentJobId ${config.currentJobId} for config ${configId}. currentJobId not cleared.`
|
||||
);
|
||||
}
|
||||
|
||||
this.configs.set(configId, {
|
||||
...config,
|
||||
...updates,
|
||||
});
|
||||
|
||||
// currentJobId will be cleared or remain as is in memory.
|
||||
// saveConfigs will persist this state.
|
||||
await this.saveConfigs();
|
||||
} else {
|
||||
this.logger.warn(`Config ${configId} not found when trying to handle job completion.`);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleJob(config: BackupJobConfig): void {
|
||||
try {
|
||||
const job = new CronJob(
|
||||
@@ -305,9 +552,22 @@ export class BackupConfigService implements OnModuleInit {
|
||||
|
||||
this.configs.clear();
|
||||
configs.forEach((config) => {
|
||||
this.configs.set(config.id, config);
|
||||
if (config.enabled) {
|
||||
this.scheduleJob(config);
|
||||
// Transform plain objects back into class instances
|
||||
const transformedConfig = {
|
||||
...config,
|
||||
sourceConfig: this.transformPlainObjectToSourceConfig(
|
||||
config.sourceConfig,
|
||||
config.sourceType
|
||||
),
|
||||
destinationConfig: this.transformPlainObjectToDestinationConfig(
|
||||
config.destinationConfig,
|
||||
config.destinationType
|
||||
),
|
||||
};
|
||||
|
||||
this.configs.set(config.id, transformedConfig);
|
||||
if (transformedConfig.enabled) {
|
||||
this.scheduleJob(transformedConfig);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -320,8 +580,13 @@ export class BackupConfigService implements OnModuleInit {
|
||||
|
||||
private async saveConfigs(): Promise<void> {
|
||||
try {
|
||||
const configs = Array.from(this.configs.values());
|
||||
await writeFile(this.configPath, JSON.stringify(configs, null, 2));
|
||||
// Create a deep copy of configs for saving, stripping currentJobId
|
||||
const configsToSave: BackupJobConfig[] = [];
|
||||
for (const config of this.configs.values()) {
|
||||
const { currentJobId, ...restOfConfig } = config; // Destructure to remove currentJobId
|
||||
configsToSave.push(restOfConfig as BackupJobConfig); // Cast needed if TS complains
|
||||
}
|
||||
await writeFile(this.configPath, JSON.stringify(configsToSave, null, 2));
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to save backup configurations:', error);
|
||||
}
|
||||
|
||||
@@ -128,8 +128,8 @@ export class BackupJobConfig extends Node {
|
||||
@Field(() => String, { description: 'Status of last run', nullable: true })
|
||||
lastRunStatus?: string;
|
||||
|
||||
@Field(() => JobStatus, { description: 'Current running job for this config', nullable: true })
|
||||
currentJob?: JobStatus;
|
||||
@Field(() => String, { description: 'Current running job ID for this config', nullable: true })
|
||||
currentJobId?: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
@@ -198,6 +198,11 @@ export class UpdateBackupJobConfigInput extends BaseBackupJobConfigInput {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastRunAt?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
currentJobId?: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
|
||||
import { BackupConfigService } from '@app/unraid-api/graph/resolvers/backup/backup-config.service.js';
|
||||
@@ -25,6 +25,6 @@ import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.modu
|
||||
BackupJobTrackingService,
|
||||
BackupJobStatusResolver,
|
||||
],
|
||||
exports: [BackupOrchestrationService, BackupJobTrackingService],
|
||||
exports: [forwardRef(() => BackupOrchestrationService), BackupJobTrackingService],
|
||||
})
|
||||
export class BackupModule {}
|
||||
|
||||
@@ -120,17 +120,17 @@ export class BackupJobConfigResolver {
|
||||
nullable: true,
|
||||
})
|
||||
async currentJob(@Parent() config: BackupJobConfig): Promise<JobStatus | null> {
|
||||
if (!config.currentJob) {
|
||||
if (!config.currentJobId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Looking for current job for config ${config.id} using currentJob: ${config.currentJob.id}`
|
||||
`Looking for current job for config ${config.id} using currentJobId: ${config.currentJobId}`
|
||||
);
|
||||
|
||||
const jobStatus = this.backupJobTrackingService.getJobStatus(config.currentJob.id);
|
||||
const jobStatus = this.backupJobTrackingService.getJobStatus(config.currentJobId);
|
||||
if (!jobStatus) {
|
||||
this.logger.debug(`No job status found for job ID: ${config.currentJob.id}`);
|
||||
this.logger.debug(`No job status found for job ID: ${config.currentJobId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,52 +1,92 @@
|
||||
import { JsonSchema7 } from '@jsonforms/core';
|
||||
import type { LabelElement, SchemaBasedCondition } from '@jsonforms/core';
|
||||
import { JsonSchema7, RuleEffect } from '@jsonforms/core';
|
||||
|
||||
import type { RCloneRemote } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
|
||||
import type { SettingSlice, UIElement } from '@app/unraid-api/types/json-forms.js';
|
||||
import { DestinationType } from '@app/unraid-api/graph/resolvers/backup/destination/backup-destination.types.js';
|
||||
import { createLabeledControl } from '@app/unraid-api/graph/utils/form-utils.js';
|
||||
|
||||
export function getDestinationConfigSlice({ remotes = [] }: { remotes?: RCloneRemote[] }): SettingSlice {
|
||||
const destinationConfigElements: UIElement[] = [
|
||||
createLabeledControl({
|
||||
scope: '#/properties/destinationConfig/properties/remoteName',
|
||||
label: 'Remote Configuration',
|
||||
description: 'Select the RClone remote configuration to use for this backup',
|
||||
controlOptions: {
|
||||
suggestions: remotes.map((remote) => ({
|
||||
value: remote.name,
|
||||
label: `${remote.name} (${remote.type})`,
|
||||
})),
|
||||
{
|
||||
type: 'Control',
|
||||
scope: '#/properties/destinationConfig/properties/type',
|
||||
options: {
|
||||
format: 'radio',
|
||||
radioLayout: 'horizontal',
|
||||
options: [
|
||||
{
|
||||
label: 'RClone Remote',
|
||||
value: DestinationType.RCLONE,
|
||||
description: 'Backup to cloud storage via RClone',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
createLabeledControl({
|
||||
scope: '#/properties/destinationConfig/properties/destinationPath',
|
||||
label: 'Destination Path',
|
||||
description: 'The path on the remote where files will be stored (e.g., backups/documents)',
|
||||
controlOptions: {
|
||||
placeholder: 'backups/',
|
||||
format: 'string',
|
||||
// RClone Configuration
|
||||
{
|
||||
type: 'VerticalLayout',
|
||||
rule: {
|
||||
effect: RuleEffect.SHOW,
|
||||
condition: {
|
||||
scope: '#/properties/destinationConfig/properties/type',
|
||||
schema: { const: DestinationType.RCLONE },
|
||||
} as SchemaBasedCondition,
|
||||
},
|
||||
}),
|
||||
elements: [
|
||||
{
|
||||
type: 'Label',
|
||||
text: 'RClone Configuration',
|
||||
options: {
|
||||
description: 'Configure RClone remote destination settings.',
|
||||
},
|
||||
} as LabelElement,
|
||||
|
||||
createLabeledControl({
|
||||
scope: '#/properties/destinationConfig/properties/rcloneOptions/properties/transfers',
|
||||
label: 'Number of Transfers',
|
||||
description: 'Number of file transfers to run in parallel (default: 4)',
|
||||
controlOptions: {
|
||||
placeholder: '4',
|
||||
format: 'number',
|
||||
},
|
||||
}),
|
||||
createLabeledControl({
|
||||
scope: '#/properties/destinationConfig/properties/rcloneConfig/properties/remoteName',
|
||||
label: 'Remote Configuration',
|
||||
description: 'Select the RClone remote configuration to use for this backup',
|
||||
controlOptions: {
|
||||
suggestions: remotes.map((remote) => ({
|
||||
value: remote.name,
|
||||
label: `${remote.name} (${remote.type})`,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
|
||||
createLabeledControl({
|
||||
scope: '#/properties/destinationConfig/properties/rcloneOptions/properties/checkers',
|
||||
label: 'Number of Checkers',
|
||||
description: 'Number of checkers to run in parallel (default: 8)',
|
||||
controlOptions: {
|
||||
placeholder: '8',
|
||||
format: 'number',
|
||||
},
|
||||
}),
|
||||
createLabeledControl({
|
||||
scope: '#/properties/destinationConfig/properties/rcloneConfig/properties/destinationPath',
|
||||
label: 'Destination Path',
|
||||
description:
|
||||
'The path on the remote where files will be stored (e.g., backups/documents)',
|
||||
controlOptions: {
|
||||
placeholder: 'backups/',
|
||||
format: 'string',
|
||||
},
|
||||
}),
|
||||
|
||||
createLabeledControl({
|
||||
scope: '#/properties/destinationConfig/properties/rcloneConfig/properties/rcloneOptions/properties/transfers',
|
||||
label: 'Number of Transfers',
|
||||
description: 'Number of file transfers to run in parallel (default: 4)',
|
||||
controlOptions: {
|
||||
placeholder: '4',
|
||||
format: 'number',
|
||||
},
|
||||
}),
|
||||
|
||||
createLabeledControl({
|
||||
scope: '#/properties/destinationConfig/properties/rcloneConfig/properties/rcloneOptions/properties/checkers',
|
||||
label: 'Number of Checkers',
|
||||
description: 'Number of checkers to run in parallel (default: 8)',
|
||||
controlOptions: {
|
||||
placeholder: '8',
|
||||
format: 'number',
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const destinationConfigProperties: Record<string, JsonSchema7> = {
|
||||
@@ -55,49 +95,78 @@ export function getDestinationConfigSlice({ remotes = [] }: { remotes?: RCloneRe
|
||||
title: 'Destination Configuration',
|
||||
description: 'Configuration for backup destination',
|
||||
properties: {
|
||||
remoteName: {
|
||||
type: {
|
||||
type: 'string',
|
||||
title: 'Remote Name',
|
||||
description: 'Remote name from rclone config',
|
||||
enum:
|
||||
remotes.length > 0
|
||||
? remotes.map((remote) => remote.name)
|
||||
: ['No remotes configured'],
|
||||
title: 'Destination Type',
|
||||
description: 'Type of destination to use for backup',
|
||||
enum: [DestinationType.RCLONE],
|
||||
default: DestinationType.RCLONE,
|
||||
},
|
||||
destinationPath: {
|
||||
type: 'string',
|
||||
title: 'Destination Path',
|
||||
description: 'Destination path on the remote',
|
||||
minLength: 1,
|
||||
},
|
||||
rcloneOptions: {
|
||||
rcloneConfig: {
|
||||
type: 'object',
|
||||
title: 'RClone Options',
|
||||
description: 'Advanced RClone configuration options',
|
||||
title: 'RClone Configuration',
|
||||
properties: {
|
||||
transfers: {
|
||||
type: 'integer',
|
||||
title: 'Transfers',
|
||||
description: 'Number of file transfers to run in parallel',
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
default: 4,
|
||||
remoteName: {
|
||||
type: 'string',
|
||||
title: 'Remote Name',
|
||||
description: 'Remote name from rclone config',
|
||||
enum:
|
||||
remotes.length > 0
|
||||
? remotes.map((remote) => remote.name)
|
||||
: ['No remotes configured'],
|
||||
},
|
||||
checkers: {
|
||||
type: 'integer',
|
||||
title: 'Checkers',
|
||||
description: 'Number of checkers to run in parallel',
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
default: 8,
|
||||
destinationPath: {
|
||||
type: 'string',
|
||||
title: 'Destination Path',
|
||||
description: 'Destination path on the remote',
|
||||
minLength: 1,
|
||||
},
|
||||
rcloneOptions: {
|
||||
type: 'object',
|
||||
title: 'RClone Options',
|
||||
description: 'Advanced RClone configuration options',
|
||||
properties: {
|
||||
transfers: {
|
||||
type: 'integer',
|
||||
title: 'Transfers',
|
||||
description: 'Number of file transfers to run in parallel',
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
default: 4,
|
||||
},
|
||||
checkers: {
|
||||
type: 'integer',
|
||||
title: 'Checkers',
|
||||
description: 'Number of checkers to run in parallel',
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
default: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['remoteName', 'destinationPath'],
|
||||
},
|
||||
},
|
||||
required: ['remoteName', 'destinationPath'],
|
||||
required: ['type'],
|
||||
},
|
||||
};
|
||||
|
||||
// Apply conditional logic for destinationConfig
|
||||
if (
|
||||
destinationConfigProperties.destinationConfig &&
|
||||
typeof destinationConfigProperties.destinationConfig === 'object'
|
||||
) {
|
||||
destinationConfigProperties.destinationConfig.allOf = [
|
||||
{
|
||||
if: { properties: { type: { const: DestinationType.RCLONE } }, required: ['type'] },
|
||||
then: {
|
||||
required: ['rcloneConfig'],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const verticalLayoutElement: UIElement = {
|
||||
type: 'VerticalLayout',
|
||||
elements: destinationConfigElements,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { createUnionType, Field, InputType, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||
import { IsEnum, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||
import { GraphQLJSON } from 'graphql-scalars';
|
||||
|
||||
import { BackupJobStatus } from '@app/unraid-api/graph/resolvers/backup/orchestration/backup-job-status.model.js';
|
||||
|
||||
export enum DestinationType {
|
||||
RCLONE = 'rclone',
|
||||
RCLONE = 'RCLONE',
|
||||
}
|
||||
|
||||
registerEnumType(DestinationType, {
|
||||
@@ -39,13 +39,19 @@ export class RcloneDestinationConfig {
|
||||
nullable: true,
|
||||
})
|
||||
rcloneOptions?: Record<string, unknown>;
|
||||
|
||||
static isTypeOf(obj: any): obj is RcloneDestinationConfig {
|
||||
return (
|
||||
obj &&
|
||||
obj.type === 'RCLONE' &&
|
||||
typeof obj.remoteName === 'string' &&
|
||||
typeof obj.destinationPath === 'string'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class RcloneDestinationConfigInput {
|
||||
@Field(() => String, { defaultValue: 'RCLONE' })
|
||||
type!: 'RCLONE';
|
||||
|
||||
@Field(() => String)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@@ -64,6 +70,10 @@ export class RcloneDestinationConfigInput {
|
||||
|
||||
@InputType()
|
||||
export class DestinationConfigInput {
|
||||
@Field(() => DestinationType, { nullable: false })
|
||||
@IsEnum(DestinationType, { message: 'Invalid destination type' })
|
||||
type!: DestinationType;
|
||||
|
||||
@Field(() => RcloneDestinationConfigInput, { nullable: true })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@@ -74,11 +84,11 @@ export class DestinationConfigInput {
|
||||
export const DestinationConfigUnion = createUnionType({
|
||||
name: 'DestinationConfigUnion',
|
||||
types: () => [RcloneDestinationConfig] as const,
|
||||
resolveType: (value) => {
|
||||
if (value.type === 'RCLONE') {
|
||||
resolveType(obj: any) {
|
||||
if (RcloneDestinationConfig.isTypeOf && RcloneDestinationConfig.isTypeOf(obj)) {
|
||||
return RcloneDestinationConfig;
|
||||
}
|
||||
return undefined;
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { execa } from 'execa';
|
||||
|
||||
import { getBackupJobGroupId } from '@app/unraid-api/graph/resolvers/backup/backup.utils.js';
|
||||
import {
|
||||
BackupDestinationConfig,
|
||||
BackupDestinationProcessor,
|
||||
@@ -15,7 +16,7 @@ import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-
|
||||
|
||||
export interface RCloneDestinationConfig extends BackupDestinationConfig {
|
||||
remoteName: string;
|
||||
remotePath: string;
|
||||
destinationPath: string;
|
||||
transferOptions?: Record<string, unknown>;
|
||||
useStreaming?: boolean;
|
||||
sourceCommand?: string;
|
||||
@@ -41,7 +42,7 @@ export class RCloneDestinationProcessor extends BackupDestinationProcessor<RClon
|
||||
|
||||
try {
|
||||
this.logger.log(
|
||||
`Starting RClone upload job ${jobId} from ${sourcePath} to ${config.remoteName}:${config.remotePath}`
|
||||
`Starting RClone upload job ${jobId} from ${sourcePath} to ${config.remoteName}:${config.destinationPath}`
|
||||
);
|
||||
|
||||
return await this.executeRegularBackup(sourcePath, config, options);
|
||||
@@ -66,30 +67,127 @@ export class RCloneDestinationProcessor extends BackupDestinationProcessor<RClon
|
||||
config: RCloneDestinationConfig,
|
||||
options: BackupDestinationProcessorOptions
|
||||
): Promise<BackupDestinationResult> {
|
||||
const { jobId, onOutput } = options;
|
||||
const { jobId: backupConfigId, onOutput, onProgress, onError } = options;
|
||||
|
||||
const result = (await this.rcloneApiService.startBackup({
|
||||
srcPath: sourcePath,
|
||||
dstPath: `${config.remoteName}:${config.remotePath}`,
|
||||
async: false,
|
||||
configId: jobId,
|
||||
options: config.transferOptions,
|
||||
})) as { jobid?: string; jobId?: string };
|
||||
|
||||
if (onOutput) {
|
||||
onOutput(`RClone backup started with job ID: ${result.jobid || result.jobId}`);
|
||||
if (!backupConfigId) {
|
||||
const errorMsg = 'Backup Configuration ID (jobId) is required to start RClone backup.';
|
||||
this.logger.error(errorMsg);
|
||||
if (onError) {
|
||||
onError(errorMsg);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
cleanupRequired: config.cleanupOnFailure,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
destinationPath: `${config.remoteName}:${config.remotePath}`,
|
||||
metadata: {
|
||||
jobId: result.jobid || result.jobId,
|
||||
remoteName: config.remoteName,
|
||||
remotePath: config.remotePath,
|
||||
transferOptions: config.transferOptions,
|
||||
},
|
||||
};
|
||||
await this.rcloneApiService.startBackup({
|
||||
srcPath: sourcePath,
|
||||
dstPath: `${config.remoteName}:${config.destinationPath}`,
|
||||
async: true,
|
||||
configId: backupConfigId,
|
||||
options: config.transferOptions,
|
||||
});
|
||||
|
||||
const groupIdToMonitor = getBackupJobGroupId(backupConfigId);
|
||||
|
||||
if (onOutput) {
|
||||
onOutput(
|
||||
`RClone backup process initiated for group: ${groupIdToMonitor}. Monitoring progress...`
|
||||
);
|
||||
}
|
||||
|
||||
let jobStatus = await this.rcloneApiService.getEnhancedJobStatus(
|
||||
groupIdToMonitor,
|
||||
backupConfigId
|
||||
);
|
||||
this.logger.debug('Rclone Job Status: %o', jobStatus);
|
||||
let retries = 0;
|
||||
const effectiveTimeout = config.timeout && config.timeout >= 60000 ? config.timeout : 3600000;
|
||||
const maxRetries = Math.floor(effectiveTimeout / 5000);
|
||||
|
||||
while (jobStatus && !jobStatus.finished && retries < maxRetries) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
|
||||
try {
|
||||
jobStatus = await this.rcloneApiService.getEnhancedJobStatus(
|
||||
groupIdToMonitor,
|
||||
backupConfigId
|
||||
);
|
||||
if (jobStatus && onProgress && jobStatus.progressPercentage !== undefined) {
|
||||
onProgress(jobStatus.progressPercentage);
|
||||
}
|
||||
if (jobStatus && onOutput && jobStatus.stats?.speed) {
|
||||
onOutput(`Group ${groupIdToMonitor} - Transfer speed: ${jobStatus.stats.speed} B/s`);
|
||||
}
|
||||
} catch (pollError: any) {
|
||||
this.logger.warn(
|
||||
`[${backupConfigId}] Error polling group status for ${groupIdToMonitor}: ${(pollError as Error).message}`
|
||||
);
|
||||
}
|
||||
retries++;
|
||||
}
|
||||
|
||||
if (!jobStatus) {
|
||||
const errorMsg = `Failed to get final job status for RClone group ${groupIdToMonitor}`;
|
||||
this.logger.error(`[${backupConfigId}] ${errorMsg}`);
|
||||
if (onError) {
|
||||
onError(errorMsg);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
destinationPath: `${config.remoteName}:${config.destinationPath}`,
|
||||
cleanupRequired: config.cleanupOnFailure,
|
||||
};
|
||||
}
|
||||
|
||||
if (jobStatus.finished && jobStatus.success) {
|
||||
if (onProgress) {
|
||||
onProgress(100);
|
||||
}
|
||||
if (onOutput) {
|
||||
onOutput(`RClone backup for group ${groupIdToMonitor} completed successfully.`);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
destinationPath: `${config.remoteName}:${config.destinationPath}`,
|
||||
metadata: {
|
||||
groupId: groupIdToMonitor,
|
||||
remoteName: config.remoteName,
|
||||
remotePath: config.destinationPath,
|
||||
transferOptions: config.transferOptions,
|
||||
stats: jobStatus.stats,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
let errorMsg: string;
|
||||
if (!jobStatus.finished && retries >= maxRetries) {
|
||||
errorMsg = `RClone group ${groupIdToMonitor} timed out after ${effectiveTimeout / 1000} seconds.`;
|
||||
this.logger.error(`[${backupConfigId}] ${errorMsg}`);
|
||||
} else {
|
||||
errorMsg = jobStatus.error || `RClone group ${groupIdToMonitor} failed.`;
|
||||
this.logger.error(`[${backupConfigId}] ${errorMsg}`, jobStatus.stats?.lastError);
|
||||
}
|
||||
|
||||
if (onError) {
|
||||
onError(errorMsg);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
destinationPath: `${config.remoteName}:${config.destinationPath}`,
|
||||
metadata: {
|
||||
groupId: groupIdToMonitor,
|
||||
remoteName: config.remoteName,
|
||||
remotePath: config.destinationPath,
|
||||
transferOptions: config.transferOptions,
|
||||
stats: jobStatus.stats,
|
||||
},
|
||||
cleanupRequired: config.cleanupOnFailure,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async validate(
|
||||
@@ -101,7 +199,7 @@ export class RCloneDestinationProcessor extends BackupDestinationProcessor<RClon
|
||||
return { valid: false, error: 'Remote name is required' };
|
||||
}
|
||||
|
||||
if (!config.remotePath) {
|
||||
if (!config.destinationPath) {
|
||||
return { valid: false, error: 'Remote path is required' };
|
||||
}
|
||||
|
||||
@@ -135,11 +233,20 @@ export class RCloneDestinationProcessor extends BackupDestinationProcessor<RClon
|
||||
return;
|
||||
}
|
||||
|
||||
const idToStop = result.metadata?.groupId || result.metadata?.jobId;
|
||||
|
||||
try {
|
||||
this.logger.log(`Cleaning up failed upload at ${result.destinationPath}`);
|
||||
|
||||
if (result.metadata?.jobId) {
|
||||
await this.rcloneApiService.stopJob(result.metadata.jobId as string);
|
||||
if (idToStop) {
|
||||
await this.rcloneApiService.stopJob(idToStop as string);
|
||||
if (result.metadata?.groupId) {
|
||||
this.logger.log(`Stopped RClone group: ${result.metadata.groupId}`);
|
||||
} else if (result.metadata?.jobId) {
|
||||
this.logger.log(
|
||||
`Attempted to stop RClone job: ${result.metadata.jobId} (Note: Group ID preferred for cleanup)`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
@@ -169,7 +276,7 @@ export class RCloneDestinationProcessor extends BackupDestinationProcessor<RClon
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const rcloneDest = `${config.remoteName}:${config.remotePath}`;
|
||||
const rcloneDest = `${config.remoteName}:${config.destinationPath}`;
|
||||
const rcloneArgs = ['rcat', rcloneDest, '--progress'];
|
||||
|
||||
this.logger.log(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { Readable } from 'stream';
|
||||
import { pipeline } from 'stream/promises'; // Using stream.pipeline for better error handling
|
||||
|
||||
import { BackupConfigService } from '@app/unraid-api/graph/resolvers/backup/backup-config.service.js';
|
||||
import { BackupJobConfig } from '@app/unraid-api/graph/resolvers/backup/backup.model.js';
|
||||
import {
|
||||
BackupDestinationProcessor,
|
||||
@@ -29,15 +30,23 @@ export class BackupOrchestrationService {
|
||||
constructor(
|
||||
private readonly jobTrackingService: BackupJobTrackingService,
|
||||
private readonly backupSourceService: BackupSourceService,
|
||||
private readonly backupDestinationService: BackupDestinationService
|
||||
private readonly backupDestinationService: BackupDestinationService,
|
||||
@Inject(forwardRef(() => BackupConfigService))
|
||||
private readonly backupConfigService: BackupConfigService
|
||||
) {}
|
||||
|
||||
async executeBackupJob(jobConfig: BackupJobConfig, jobId: string): Promise<void> {
|
||||
this.logger.log(`Starting orchestration for backup job: ${jobConfig.name} (ID: ${jobId})`);
|
||||
async executeBackupJob(jobConfig: BackupJobConfig, configId: string): Promise<string> {
|
||||
this.logger.log(
|
||||
`Starting orchestration for backup job: ${jobConfig.name} (Config ID: ${configId})`
|
||||
);
|
||||
|
||||
// Initialize job in tracking service and get the internal tracking object
|
||||
const jobStatus = this.jobTrackingService.initializeJob(jobId, jobConfig.name);
|
||||
const internalJobId = jobStatus.id;
|
||||
// configId (original jobConfig.id) is used to link tracking to config, jobConfig.name is for display
|
||||
const jobStatus = this.jobTrackingService.initializeJob(configId, jobConfig.name);
|
||||
const internalJobId = jobStatus.id; // This is the actual ID for this specific job run
|
||||
|
||||
// DO NOT call backupConfigService.updateBackupJobConfig here for currentJobId
|
||||
// This will be handled by BackupConfigService itself using the returned internalJobId
|
||||
|
||||
this.emitJobStatus(internalJobId, {
|
||||
status: BackupJobStatus.RUNNING,
|
||||
@@ -51,41 +60,73 @@ export class BackupOrchestrationService {
|
||||
);
|
||||
|
||||
if (!sourceProcessor || !destinationProcessor) {
|
||||
this.logger.error(`[${jobId}] Failed to get source or destination processor.`);
|
||||
const errorMsg = 'Failed to initialize backup processors.';
|
||||
this.logger.error(`[Config ID: ${configId}, Job ID: ${internalJobId}] ${errorMsg}`);
|
||||
this.emitJobStatus(internalJobId, {
|
||||
status: BackupJobStatus.FAILED,
|
||||
error: 'Failed to initialize backup processors.',
|
||||
error: errorMsg,
|
||||
});
|
||||
throw new Error('Failed to initialize backup processors.');
|
||||
}
|
||||
|
||||
if (sourceProcessor.supportsStreaming && destinationProcessor.supportsStreaming) {
|
||||
await this.executeStreamingBackup(
|
||||
sourceProcessor,
|
||||
destinationProcessor,
|
||||
jobConfig,
|
||||
internalJobId
|
||||
);
|
||||
} else {
|
||||
await this.executeRegularBackup(
|
||||
sourceProcessor,
|
||||
destinationProcessor,
|
||||
jobConfig,
|
||||
// Call handleJobCompletion before throwing
|
||||
await this.backupConfigService.handleJobCompletion(
|
||||
configId,
|
||||
BackupJobStatus.FAILED,
|
||||
internalJobId
|
||||
);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
this.logger.log(`Finished orchestration for backup job: ${jobConfig.name} (ID: ${jobId})`);
|
||||
try {
|
||||
if (sourceProcessor.supportsStreaming && destinationProcessor.supportsStreaming) {
|
||||
await this.executeStreamingBackup(
|
||||
sourceProcessor,
|
||||
destinationProcessor,
|
||||
jobConfig,
|
||||
internalJobId,
|
||||
configId // Pass configId for handleJobCompletion
|
||||
);
|
||||
} else {
|
||||
await this.executeRegularBackup(
|
||||
sourceProcessor,
|
||||
destinationProcessor,
|
||||
jobConfig,
|
||||
internalJobId,
|
||||
configId // Pass configId for handleJobCompletion
|
||||
);
|
||||
}
|
||||
// If executeStreamingBackup/executeRegularBackup complete without throwing, it implies success for those stages.
|
||||
// The final status (COMPLETED/FAILED) is set within those methods via emitJobStatus and then handleJobCompletion.
|
||||
} catch (error) {
|
||||
// Errors from executeStreamingBackup/executeRegularBackup should have already called handleJobCompletion.
|
||||
// This catch is a fallback.
|
||||
this.logger.error(
|
||||
`[Config ID: ${configId}, Job ID: ${internalJobId}] Orchestration error after backup execution attempt: ${(error as Error).message}`
|
||||
);
|
||||
// Ensure completion is handled if not already done by the execution methods
|
||||
// This might be redundant if execution methods are guaranteed to call it.
|
||||
// However, direct throws before or after calling those methods would be caught here.
|
||||
await this.backupConfigService.handleJobCompletion(
|
||||
configId,
|
||||
BackupJobStatus.FAILED,
|
||||
internalJobId
|
||||
);
|
||||
throw error; // Re-throw the error
|
||||
}
|
||||
// DO NOT clear currentJobId here using updateBackupJobConfig. It's handled by handleJobCompletion.
|
||||
|
||||
this.logger.log(
|
||||
`Finished orchestration logic for backup job: ${jobConfig.name} (Config ID: ${configId}, Job ID: ${internalJobId})`
|
||||
);
|
||||
return internalJobId; // Return the actual job ID for this run
|
||||
}
|
||||
|
||||
private async executeStreamingBackup(
|
||||
sourceProcessor: BackupSourceProcessor<any>,
|
||||
destinationProcessor: BackupDestinationProcessor<any>,
|
||||
jobConfig: BackupJobConfig,
|
||||
jobConfig: BackupJobConfig, // This is the config object, not its ID
|
||||
internalJobId: string
|
||||
): Promise<void> {
|
||||
this.logger.log(
|
||||
`Executing STREAMING backup for job: ${jobConfig.name} (Internal ID: ${internalJobId})`
|
||||
`Executing STREAMING backup for job: ${jobConfig.name} (Internal Job ID: ${internalJobId})`
|
||||
);
|
||||
this.emitJobStatus(internalJobId, {
|
||||
status: BackupJobStatus.RUNNING,
|
||||
@@ -98,6 +139,8 @@ export class BackupOrchestrationService {
|
||||
'Source or destination processor does not support streaming (missing getReadableStream or getWritableStream).';
|
||||
this.logger.error(`[${internalJobId}] ${errorMsg}`);
|
||||
this.emitJobStatus(internalJobId, { status: BackupJobStatus.FAILED, error: errorMsg });
|
||||
// Call handleJobCompletion before throwing
|
||||
await this.backupConfigService.handleJobCompletion(internalJobId, BackupJobStatus.FAILED);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
@@ -155,6 +198,13 @@ export class BackupOrchestrationService {
|
||||
const errorMsg =
|
||||
destinationResult.error || 'Destination processor failed after streaming.';
|
||||
this.logger.error(`[${internalJobId}] ${errorMsg}`);
|
||||
this.emitJobStatus(internalJobId, { status: BackupJobStatus.FAILED, error: errorMsg });
|
||||
// Call handleJobCompletion before throwing
|
||||
await this.backupConfigService.handleJobCompletion(
|
||||
configId,
|
||||
BackupJobStatus.FAILED,
|
||||
internalJobId
|
||||
);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
@@ -166,6 +216,12 @@ export class BackupOrchestrationService {
|
||||
progress: 100,
|
||||
message: 'Backup completed successfully.',
|
||||
});
|
||||
// Call handleJobCompletion on success
|
||||
await this.backupConfigService.handleJobCompletion(
|
||||
configId,
|
||||
BackupJobStatus.COMPLETED,
|
||||
internalJobId
|
||||
);
|
||||
|
||||
if (sourceProcessor.cleanup) {
|
||||
this.logger.debug(`[${internalJobId}] Performing post-success cleanup for source...`);
|
||||
@@ -193,6 +249,12 @@ export class BackupOrchestrationService {
|
||||
error: error.message,
|
||||
message: 'Backup failed during streaming execution.',
|
||||
});
|
||||
// Call handleJobCompletion on failure
|
||||
await this.backupConfigService.handleJobCompletion(
|
||||
configId,
|
||||
BackupJobStatus.FAILED,
|
||||
internalJobId
|
||||
);
|
||||
|
||||
this.logger.error(
|
||||
`[${internalJobId}] Performing cleanup due to failure for job ${jobConfig.name}...`
|
||||
@@ -243,11 +305,12 @@ export class BackupOrchestrationService {
|
||||
private async executeRegularBackup(
|
||||
sourceProcessor: BackupSourceProcessor<any>,
|
||||
destinationProcessor: BackupDestinationProcessor<any>,
|
||||
jobConfig: BackupJobConfig,
|
||||
internalJobId: string
|
||||
jobConfig: BackupJobConfig, // This is the config object, not its ID
|
||||
internalJobId: string,
|
||||
configId: string // Pass the configId for handleJobCompletion
|
||||
): Promise<void> {
|
||||
this.logger.log(
|
||||
`Executing REGULAR backup for job: ${jobConfig.name} (Internal ID: ${internalJobId})`
|
||||
`Executing REGULAR backup for job: ${jobConfig.name} (Config ID: ${configId}, Internal Job ID: ${internalJobId})`
|
||||
);
|
||||
this.emitJobStatus(internalJobId, {
|
||||
status: BackupJobStatus.RUNNING,
|
||||
@@ -293,6 +356,16 @@ export class BackupOrchestrationService {
|
||||
error: errorMsg,
|
||||
message: 'Source processing failed.',
|
||||
});
|
||||
this.jobTrackingService.updateJobStatus(internalJobId, {
|
||||
status: BackupJobStatus.FAILED,
|
||||
error: errorMsg,
|
||||
});
|
||||
// Call handleJobCompletion before throwing
|
||||
await this.backupConfigService.handleJobCompletion(
|
||||
configId,
|
||||
BackupJobStatus.FAILED,
|
||||
internalJobId
|
||||
);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
this.emitJobStatus(internalJobId, {
|
||||
@@ -320,6 +393,16 @@ export class BackupOrchestrationService {
|
||||
error: errorMsg,
|
||||
message: 'Destination processing failed.',
|
||||
});
|
||||
this.jobTrackingService.updateJobStatus(internalJobId, {
|
||||
status: BackupJobStatus.FAILED,
|
||||
error: errorMsg,
|
||||
});
|
||||
// Call handleJobCompletion before throwing
|
||||
await this.backupConfigService.handleJobCompletion(
|
||||
configId,
|
||||
BackupJobStatus.FAILED,
|
||||
internalJobId
|
||||
);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
@@ -331,6 +414,12 @@ export class BackupOrchestrationService {
|
||||
progress: 100,
|
||||
message: 'Backup completed successfully.',
|
||||
});
|
||||
// Call handleJobCompletion on success
|
||||
await this.backupConfigService.handleJobCompletion(
|
||||
configId,
|
||||
BackupJobStatus.COMPLETED,
|
||||
internalJobId
|
||||
);
|
||||
|
||||
if (sourceResult && sourceProcessor.cleanup) {
|
||||
this.logger.debug(
|
||||
@@ -356,6 +445,16 @@ export class BackupOrchestrationService {
|
||||
error: error.message,
|
||||
message: 'Backup failed during regular execution.',
|
||||
});
|
||||
this.jobTrackingService.updateJobStatus(internalJobId, {
|
||||
status: BackupJobStatus.FAILED,
|
||||
error: error.message,
|
||||
});
|
||||
// Call handleJobCompletion on failure
|
||||
await this.backupConfigService.handleJobCompletion(
|
||||
configId,
|
||||
BackupJobStatus.FAILED,
|
||||
internalJobId
|
||||
);
|
||||
|
||||
this.logger.error(
|
||||
`[${internalJobId}] Performing cleanup due to failure for job ${jobConfig.name}...`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createUnionType, Field, InputType, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsBoolean, IsNumber, IsOptional, Min, ValidateNested } from 'class-validator';
|
||||
import { IsBoolean, IsEnum, IsNumber, IsOptional, Min, ValidateNested } from 'class-validator';
|
||||
|
||||
import {
|
||||
FlashPreprocessConfig,
|
||||
@@ -40,6 +40,10 @@ export { RawBackupConfigInput, RawBackupConfig };
|
||||
|
||||
@InputType()
|
||||
export class SourceConfigInput {
|
||||
@Field(() => SourceType, { nullable: false })
|
||||
@IsEnum(SourceType, { message: 'Invalid source type' })
|
||||
type!: SourceType;
|
||||
|
||||
@Field(() => Number, { description: 'Timeout for backup operation in seconds', defaultValue: 3600 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@@ -101,20 +105,21 @@ export const SourceConfigUnion = createUnionType({
|
||||
name: 'SourceConfigUnion',
|
||||
types: () =>
|
||||
[ZfsPreprocessConfig, FlashPreprocessConfig, ScriptPreprocessConfig, RawBackupConfig] as const,
|
||||
resolveType: (value) => {
|
||||
if ('poolName' in value) {
|
||||
resolveType(obj: any, context, info) {
|
||||
if (ZfsPreprocessConfig.isTypeOf && ZfsPreprocessConfig.isTypeOf(obj)) {
|
||||
return ZfsPreprocessConfig;
|
||||
}
|
||||
if ('flashPath' in value) {
|
||||
if (FlashPreprocessConfig.isTypeOf && FlashPreprocessConfig.isTypeOf(obj)) {
|
||||
return FlashPreprocessConfig;
|
||||
}
|
||||
if ('scriptPath' in value) {
|
||||
if (ScriptPreprocessConfig.isTypeOf && ScriptPreprocessConfig.isTypeOf(obj)) {
|
||||
return ScriptPreprocessConfig;
|
||||
}
|
||||
if ('sourcePath' in value) {
|
||||
if (RawBackupConfig.isTypeOf && RawBackupConfig.isTypeOf(obj)) {
|
||||
return RawBackupConfig;
|
||||
}
|
||||
return undefined;
|
||||
console.error(`[SourceConfigUnion] Could not resolve type for object: ${JSON.stringify(obj)}`);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -36,4 +36,8 @@ export class FlashPreprocessConfig implements BaseSourceConfig {
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
additionalPaths?: string[];
|
||||
|
||||
static isTypeOf(obj: any): obj is FlashPreprocessConfig {
|
||||
return obj && typeof obj.flashPath === 'string' && typeof obj.includeGitHistory === 'boolean';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export class RawSourceProcessor extends BackupSourceProcessor<RawSourceConfig> {
|
||||
private readonly logger = new Logger(RawSourceProcessor.name);
|
||||
|
||||
get supportsStreaming(): boolean {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async execute(
|
||||
@@ -33,85 +33,36 @@ export class RawSourceProcessor extends BackupSourceProcessor<RawSourceConfig> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
this.logger.log(`Starting RAW backup for path: ${config.sourcePath}`);
|
||||
this.logger.log(`Starting RAW backup validation for path: ${config.sourcePath}`);
|
||||
|
||||
const validation = await this.validate(config);
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
success: false,
|
||||
error: validation.error || 'Validation failed',
|
||||
metadata: { validationError: validation.error },
|
||||
metadata: {
|
||||
validationError: validation.error,
|
||||
supportsStreaming: this.supportsStreaming,
|
||||
},
|
||||
supportsStreaming: this.supportsStreaming,
|
||||
};
|
||||
}
|
||||
|
||||
if (validation.warnings?.length) {
|
||||
this.logger.warn(`RAW backup warnings: ${validation.warnings.join(', ')}`);
|
||||
this.logger.warn(
|
||||
`RAW backup warnings for ${config.sourcePath}: ${validation.warnings.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const sourceStats = await stat(config.sourcePath);
|
||||
|
||||
// Streaming Logic
|
||||
if (options?.useStreaming) {
|
||||
this.logger.log(`RAW backup: Streaming requested for ${config.sourcePath}`);
|
||||
const tarArgs: string[] = ['cf', '-'];
|
||||
|
||||
// Add exclude patterns
|
||||
if (config.excludePatterns?.length) {
|
||||
config.excludePatterns.forEach((pattern) => {
|
||||
tarArgs.push(`--exclude=${pattern}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Determine files/paths to archive
|
||||
let filesToArchive: string[];
|
||||
if (config.includePatterns?.length) {
|
||||
// If includePatterns are specified, use them relative to sourcePath
|
||||
tarArgs.push('-C', config.sourcePath);
|
||||
filesToArchive = config.includePatterns.map((p) =>
|
||||
p.startsWith('./') ? p : `./${p}`
|
||||
);
|
||||
} else if (sourceStats.isDirectory()) {
|
||||
// If sourcePath is a directory and no include patterns, archive its contents
|
||||
tarArgs.push('-C', config.sourcePath);
|
||||
filesToArchive = ['.']; // Archive all contents of the directory
|
||||
} else {
|
||||
// If sourcePath is a file (and no include patterns), archive the file itself
|
||||
// We need to specify the file relative to its parent directory for tar -C to work correctly
|
||||
const parentDir = join(config.sourcePath, '..');
|
||||
const fileName = config.sourcePath.split('/').pop() || config.sourcePath;
|
||||
tarArgs.push('-C', parentDir);
|
||||
filesToArchive = [fileName];
|
||||
}
|
||||
|
||||
tarArgs.push(...filesToArchive);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
return {
|
||||
success: true,
|
||||
streamCommand: 'tar',
|
||||
streamArgs: tarArgs,
|
||||
supportsStreaming: true,
|
||||
isStreamingMode: true,
|
||||
metadata: {
|
||||
sourcePath: config.sourcePath,
|
||||
isDirectory: sourceStats.isDirectory(),
|
||||
duration,
|
||||
excludePatterns: config.excludePatterns,
|
||||
includePatterns: config.includePatterns,
|
||||
streamingMethod: 'tar',
|
||||
validationWarnings: validation.warnings,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Non-streaming (direct file path) mode
|
||||
const duration = Date.now() - startTime;
|
||||
this.logger.log(`RAW backup: Non-streaming for ${config.sourcePath}`);
|
||||
|
||||
this.logger.log(`RAW backup: Providing direct path for ${config.sourcePath}`);
|
||||
return {
|
||||
success: true,
|
||||
outputPath: config.sourcePath,
|
||||
supportsStreaming: this.supportsStreaming,
|
||||
isStreamingMode: false,
|
||||
metadata: {
|
||||
sourcePath: config.sourcePath,
|
||||
isDirectory: sourceStats.isDirectory(),
|
||||
@@ -120,16 +71,21 @@ export class RawSourceProcessor extends BackupSourceProcessor<RawSourceConfig> {
|
||||
excludePatterns: config.excludePatterns,
|
||||
includePatterns: config.includePatterns,
|
||||
validationWarnings: validation.warnings,
|
||||
supportsStreaming: this.supportsStreaming, // Indicate that streaming is available
|
||||
supportsStreaming: this.supportsStreaming,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(`RAW backup failed: ${errorMessage}`, error);
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
this.logger.error(
|
||||
`RAW backup preparation failed for ${config.sourcePath}: ${errorMessage}`,
|
||||
errorStack
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
supportsStreaming: this.supportsStreaming,
|
||||
metadata: {
|
||||
sourcePath: config.sourcePath,
|
||||
duration: Date.now() - startTime,
|
||||
|
||||
@@ -38,4 +38,8 @@ export class RawBackupConfig implements BaseSourceConfig {
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
includePatterns?: string[];
|
||||
|
||||
static isTypeOf(obj: any): obj is RawBackupConfig {
|
||||
return obj && typeof obj.sourcePath === 'string';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,4 +56,8 @@ export class ScriptPreprocessConfig implements BaseSourceConfig {
|
||||
|
||||
@Field(() => String)
|
||||
outputPath!: string;
|
||||
|
||||
static isTypeOf(obj: any): obj is ScriptPreprocessConfig {
|
||||
return obj && typeof obj.scriptPath === 'string' && typeof obj.outputPath === 'string';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,4 +57,8 @@ export class ZfsPreprocessConfig implements BaseSourceConfig {
|
||||
|
||||
@Field(() => Number, { nullable: true })
|
||||
retainSnapshots?: number;
|
||||
|
||||
static isTypeOf(obj: any): obj is ZfsPreprocessConfig {
|
||||
return obj && typeof obj.poolName === 'string' && typeof obj.datasetName === 'string';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,9 +43,11 @@ const CONSTANTS = {
|
||||
INFO: 'INFO',
|
||||
},
|
||||
RETRY_CONFIG: {
|
||||
retries: 10,
|
||||
retries: 6,
|
||||
minTimeout: 100,
|
||||
maxTimeout: 1000,
|
||||
maxTimeout: 5000,
|
||||
factor: 2,
|
||||
maxRetryTime: 30000,
|
||||
},
|
||||
TIMEOUTS: {
|
||||
GRACEFUL_SHUTDOWN: 2000,
|
||||
@@ -83,8 +85,12 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
|
||||
private rcloneSocketPath: string = '';
|
||||
private rcloneBaseUrl: string = '';
|
||||
private rcloneProcess: ChildProcess | null = null;
|
||||
private readonly rcloneUsername: string = crypto.randomBytes(12).toString('hex');
|
||||
private readonly rclonePassword: string = crypto.randomBytes(24).toString('hex');
|
||||
private readonly rcloneUsername: string =
|
||||
process.env.RCLONE_USERNAME ||
|
||||
(process.env.NODE_ENV === 'test' ? 'test-user' : crypto.randomBytes(12).toString('hex'));
|
||||
private readonly rclonePassword: string =
|
||||
process.env.RCLONE_PASSWORD ||
|
||||
(process.env.NODE_ENV === 'test' ? 'test-pass' : crypto.randomBytes(24).toString('hex'));
|
||||
|
||||
constructor(private readonly statusService: RCloneStatusService) {}
|
||||
|
||||
@@ -156,30 +162,33 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
private buildRcloneArgs(socketPath: string, logFilePath: string): string[] {
|
||||
const enableDebugMode = true;
|
||||
const enableRcServe = true;
|
||||
const logLevel = enableDebugMode ? CONSTANTS.LOG_LEVEL.DEBUG : CONSTANTS.LOG_LEVEL.INFO;
|
||||
// Unix sockets don't require HTTP authentication - the socket itself provides security
|
||||
const isUnixSocket = socketPath.startsWith('/');
|
||||
|
||||
if (isUnixSocket) {
|
||||
this.logger.log('Using Unix socket - HTTP authentication not required, using --rc-no-auth');
|
||||
} else {
|
||||
this.logger.log(
|
||||
`Building RClone args with username: ${this.rcloneUsername ? '[SET]' : '[NOT SET]'}, password: ${this.rclonePassword ? '[SET]' : '[NOT SET]'}`
|
||||
);
|
||||
}
|
||||
|
||||
const args = [
|
||||
'rcd',
|
||||
'--rc-addr',
|
||||
socketPath,
|
||||
'--log-level',
|
||||
logLevel,
|
||||
'INFO',
|
||||
'--log-file',
|
||||
logFilePath,
|
||||
'--rc-user',
|
||||
this.rcloneUsername,
|
||||
'--rc-pass',
|
||||
this.rclonePassword,
|
||||
// For Unix sockets, use --rc-no-auth instead of credentials
|
||||
...(isUnixSocket ? ['--rc-no-auth'] : []),
|
||||
// Only add authentication for non-Unix socket connections
|
||||
...(!isUnixSocket && this.rcloneUsername ? ['--rc-user', this.rcloneUsername] : []),
|
||||
...(!isUnixSocket && this.rclonePassword ? ['--rc-pass', this.rclonePassword] : []),
|
||||
];
|
||||
|
||||
if (enableRcServe) args.push('--rc-serve');
|
||||
|
||||
if (enableDebugMode) {
|
||||
this.logger.log('Debug mode: Enhanced logging and RC serve enabled');
|
||||
}
|
||||
|
||||
this.logger.log(`RClone command args: ${args.join(' ')}`);
|
||||
return args;
|
||||
}
|
||||
|
||||
@@ -258,7 +267,7 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
private async checkRcloneSocketRunning(): Promise<boolean> {
|
||||
try {
|
||||
await this.callRcloneApi('rc/noop');
|
||||
await this.callRcloneApi('core/pid');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -601,16 +610,31 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
|
||||
private async callRcloneApi(endpoint: string, params: Record<string, unknown> = {}): Promise<any> {
|
||||
const url = `${this.rcloneBaseUrl}/${endpoint}`;
|
||||
|
||||
try {
|
||||
const response = await got.post(url, {
|
||||
json: params,
|
||||
responseType: 'json',
|
||||
enableUnixSockets: true,
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${this.rcloneUsername}:${this.rclonePassword}`).toString('base64')}`,
|
||||
},
|
||||
});
|
||||
// Unix sockets don't require HTTP authentication - the socket itself provides security
|
||||
const isUnixSocket = this.rcloneSocketPath && this.rcloneSocketPath.startsWith('/');
|
||||
|
||||
const requestOptions: any = {
|
||||
json: params,
|
||||
responseType: 'json',
|
||||
enableUnixSockets: true,
|
||||
};
|
||||
|
||||
// Only add authentication headers for non-Unix socket connections
|
||||
if (!isUnixSocket && this.rcloneUsername && this.rclonePassword) {
|
||||
const authString = `${this.rcloneUsername}:${this.rclonePassword}`;
|
||||
const authHeader = `Basic ${Buffer.from(authString).toString('base64')}`;
|
||||
requestOptions.headers = {
|
||||
Authorization: authHeader,
|
||||
};
|
||||
this.logger.debug(
|
||||
`Calling RClone API: ${endpoint} with auth header: ${authHeader.substring(0, 20)}...`
|
||||
);
|
||||
} else {
|
||||
this.logger.debug(`Calling RClone API: ${endpoint} via Unix socket (no auth required)`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await got.post(url, requestOptions);
|
||||
return response.body;
|
||||
} catch (error: unknown) {
|
||||
this.handleApiError(error, endpoint, params);
|
||||
|
||||
4
pnpm-lock.yaml
generated
4
pnpm-lock.yaml
generated
@@ -675,6 +675,9 @@ importers:
|
||||
'@vueuse/core':
|
||||
specifier: ^13.0.0
|
||||
version: 13.0.0(vue@3.5.13(typescript@5.8.3))
|
||||
ajv:
|
||||
specifier: ^8.17.1
|
||||
version: 8.17.1
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -10095,6 +10098,7 @@ packages:
|
||||
engines: {node: '>=0.6.0', teleport: '>=0.2.0'}
|
||||
deprecated: |-
|
||||
You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.
|
||||
|
||||
(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)
|
||||
|
||||
qs@6.13.0:
|
||||
|
||||
@@ -52,14 +52,15 @@
|
||||
"@jsonforms/vue": "^3.5.1",
|
||||
"@jsonforms/vue-vanilla": "^3.5.1",
|
||||
"@vueuse/core": "^13.0.0",
|
||||
"ajv": "^8.17.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.2.5",
|
||||
"kebab-case": "^2.0.1",
|
||||
"lucide-vue-next": "^0.511.0",
|
||||
"shadcn-vue": "^2.0.0",
|
||||
"marked": "^15.0.0",
|
||||
"reka-ui": "^2.1.1",
|
||||
"shadcn-vue": "^2.0.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"vue-sonner": "^1.3.0"
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ValidationMode,
|
||||
} from '@jsonforms/core';
|
||||
import { JsonForms as BaseJsonForms } from '@jsonforms/vue';
|
||||
import Ajv from 'ajv';
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -34,7 +35,7 @@ const props = withDefaults(
|
||||
}
|
||||
);
|
||||
const emit = defineEmits(['change']);
|
||||
|
||||
const ajv = new Ajv({ allErrors: true, useDefaults: true, strict: false });
|
||||
function onChange(event: unknown): void {
|
||||
emit('change', event);
|
||||
}
|
||||
@@ -51,7 +52,7 @@ function onChange(event: unknown): void {
|
||||
:readonly="props.readonly"
|
||||
:uischemas="props.uischemas"
|
||||
:validation-mode="props.validationMode"
|
||||
:ajv="undefined"
|
||||
:ajv="ajv"
|
||||
:middleware="props.middleware"
|
||||
:i18n="props.i18n"
|
||||
:additional-errors="undefined"
|
||||
|
||||
@@ -9,6 +9,7 @@ import BackupJobConfigForm from '~/components/Backup/BackupJobConfigForm.vue';
|
||||
import BackupJobItem from '~/components/Backup/BackupJobItem.vue';
|
||||
|
||||
const showConfigModal = ref(false);
|
||||
const currentEditingConfigId = ref<string | null>(null);
|
||||
|
||||
const { result, loading, error, refetch } = useQuery(
|
||||
BACKUP_JOB_CONFIGS_LIST_QUERY,
|
||||
@@ -27,8 +28,19 @@ function handleJobDeleted() {
|
||||
refetch();
|
||||
}
|
||||
|
||||
function openAddJobModal() {
|
||||
currentEditingConfigId.value = null;
|
||||
showConfigModal.value = true;
|
||||
}
|
||||
|
||||
function openEditJobModal(configId: string) {
|
||||
currentEditingConfigId.value = configId;
|
||||
showConfigModal.value = true;
|
||||
}
|
||||
|
||||
function onConfigComplete() {
|
||||
showConfigModal.value = false;
|
||||
currentEditingConfigId.value = null;
|
||||
refetch();
|
||||
}
|
||||
</script>
|
||||
@@ -37,7 +49,7 @@ function onConfigComplete() {
|
||||
<div class="backup-config">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Scheduled Backup Jobs</h2>
|
||||
<Button variant="primary" @click="showConfigModal = true"> Add Backup Job </Button>
|
||||
<Button variant="primary" @click="openAddJobModal"> Add Backup Job </Button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !result" class="text-center py-8">
|
||||
@@ -76,7 +88,7 @@ function onConfigComplete() {
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Create your first scheduled backup job to automatically protect your data.
|
||||
</p>
|
||||
<Button variant="primary" @click="showConfigModal = true"> Create First Backup Job </Button>
|
||||
<Button variant="primary" @click="openAddJobModal"> Create First Backup Job </Button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
@@ -85,24 +97,22 @@ function onConfigComplete() {
|
||||
:key="configId"
|
||||
:config-id="configId"
|
||||
@deleted="handleJobDeleted"
|
||||
@edit="openEditJobModal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Sheet v-model:open="showConfigModal">
|
||||
<SheetContent class="w-full max-w-4xl max-h-[90vh] overflow-auto">
|
||||
<SheetTitle class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Add New Backup Job
|
||||
{{ currentEditingConfigId ? 'Edit Backup Job' : 'Add New Backup Job' }}
|
||||
</SheetTitle>
|
||||
<div class="p-6">
|
||||
<BackupJobConfigForm @complete="onConfigComplete" @cancel="showConfigModal = false" />
|
||||
<BackupJobConfigForm
|
||||
:config-id="currentEditingConfigId"
|
||||
@complete="onConfigComplete"
|
||||
@cancel="showConfigModal = false" />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backup-config {
|
||||
@apply mx-auto max-w-7xl p-6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,10 +4,24 @@ import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
|
||||
import { Button, JsonForms } from '@unraid/ui';
|
||||
|
||||
import type { CreateBackupJobConfigInput } from '~/composables/gql/graphql';
|
||||
import type {
|
||||
BackupJobConfig,
|
||||
CreateBackupJobConfigInput,
|
||||
UpdateBackupJobConfigInput,
|
||||
} from '~/composables/gql/graphql';
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import { BACKUP_JOB_CONFIG_FORM_QUERY, CREATE_BACKUP_JOB_CONFIG_MUTATION } from './backup-jobs.query';
|
||||
import {
|
||||
BACKUP_JOB_CONFIG_FORM_QUERY,
|
||||
BACKUP_JOB_CONFIG_QUERY,
|
||||
CREATE_BACKUP_JOB_CONFIG_MUTATION,
|
||||
UPDATE_BACKUP_JOB_CONFIG_MUTATION,
|
||||
} from './backup-jobs.query';
|
||||
|
||||
// Define props
|
||||
const props = defineProps<{
|
||||
configId?: string | null;
|
||||
}>();
|
||||
|
||||
// Define emit events
|
||||
const emit = defineEmits<{
|
||||
@@ -21,21 +35,106 @@ interface ConfigStep {
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Determine if we are in edit mode
|
||||
const isEditMode = computed(() => !!props.configId);
|
||||
|
||||
// Form state
|
||||
const formState: Ref<Record<string, unknown>> = ref({});
|
||||
const formState: Ref<Record<string, unknown>> = ref({}); // Using unknown for now due to dynamic nature of JsonForms data
|
||||
|
||||
// Get form schema
|
||||
const {
|
||||
result: formResult,
|
||||
loading: formLoading,
|
||||
refetch: updateFormSchema,
|
||||
} = useQuery(BACKUP_JOB_CONFIG_FORM_QUERY, {
|
||||
} = useQuery(BACKUP_JOB_CONFIG_FORM_QUERY, () => ({
|
||||
input: {
|
||||
showAdvanced:
|
||||
typeof formState.value?.showAdvanced === 'boolean' ? formState.value.showAdvanced : false,
|
||||
},
|
||||
}));
|
||||
|
||||
// Fetch existing config data if in edit mode
|
||||
const {
|
||||
result: existingConfigResult,
|
||||
loading: existingConfigLoading,
|
||||
onError: onExistingConfigError,
|
||||
refetch: refetchExistingConfig,
|
||||
} = useQuery(
|
||||
BACKUP_JOB_CONFIG_QUERY,
|
||||
() => ({ id: props.configId! }),
|
||||
() => ({
|
||||
enabled: isEditMode.value,
|
||||
fetchPolicy: 'network-only',
|
||||
})
|
||||
);
|
||||
|
||||
onExistingConfigError((err) => {
|
||||
console.error('Error fetching existing backup job config:', err);
|
||||
if (window.toast) {
|
||||
window.toast.error('Failed to load backup job data for editing.');
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
existingConfigResult,
|
||||
(newVal) => {
|
||||
if (newVal?.backupJobConfig && isEditMode.value) {
|
||||
const config = newVal.backupJobConfig as BackupJobConfig;
|
||||
|
||||
const {
|
||||
__typename,
|
||||
id,
|
||||
currentJob,
|
||||
sourceConfig: fetchedSourceConfig,
|
||||
destinationConfig: fetchedDestinationConfig,
|
||||
schedule,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
lastRunAt,
|
||||
lastRunStatus,
|
||||
...baseConfigFields
|
||||
} = config;
|
||||
|
||||
const populatedDataForForm: Record<string, unknown> = {
|
||||
...baseConfigFields,
|
||||
schedule: schedule,
|
||||
};
|
||||
|
||||
if (fetchedSourceConfig) {
|
||||
const { __typename: st, ...sourceData } = fetchedSourceConfig as Record<string, unknown>;
|
||||
populatedDataForForm.sourceConfig = sourceData;
|
||||
if (typeof sourceData.type === 'string') {
|
||||
populatedDataForForm.sourceType = sourceData.type;
|
||||
}
|
||||
}
|
||||
|
||||
if (fetchedDestinationConfig) {
|
||||
const { __typename: dt, ...destData } = fetchedDestinationConfig as Record<string, unknown>;
|
||||
populatedDataForForm.destinationConfig = destData;
|
||||
if (typeof destData.type === 'string') {
|
||||
populatedDataForForm.destinationType = destData.type;
|
||||
}
|
||||
}
|
||||
|
||||
const finalFormData = { ...(formState.value || {}), ...populatedDataForForm };
|
||||
|
||||
const cleanedFormData: Record<string, unknown> = {};
|
||||
for (const key in finalFormData) {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(finalFormData, key) &&
|
||||
finalFormData[key] !== undefined
|
||||
) {
|
||||
cleanedFormData[key] = finalFormData[key];
|
||||
}
|
||||
}
|
||||
|
||||
formState.value = cleanedFormData;
|
||||
console.log('[BackupJobConfigForm] Populated formState with existing data:', formState.value);
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
// Watch for changes to showAdvanced and refetch schema
|
||||
let refetchTimeout: NodeJS.Timeout | null = null;
|
||||
watch(
|
||||
@@ -46,7 +145,7 @@ watch(
|
||||
const newShowAdvanced = typeof newValue?.showAdvanced === 'boolean' ? newValue.showAdvanced : false;
|
||||
const oldShowAdvanced = typeof oldValue?.showAdvanced === 'boolean' ? oldValue.showAdvanced : false;
|
||||
const shouldRefetch = newShowAdvanced !== oldShowAdvanced || newStepCurrent !== oldStepCurrent;
|
||||
|
||||
|
||||
if (shouldRefetch) {
|
||||
if (newShowAdvanced !== oldShowAdvanced) {
|
||||
console.log('[BackupJobConfigForm] showAdvanced changed:', newShowAdvanced);
|
||||
@@ -60,12 +159,11 @@ watch(
|
||||
'Refetching schema.'
|
||||
);
|
||||
}
|
||||
|
||||
// Debounce refetch to prevent multiple rapid calls
|
||||
|
||||
if (refetchTimeout) {
|
||||
clearTimeout(refetchTimeout);
|
||||
}
|
||||
|
||||
|
||||
refetchTimeout = setTimeout(async () => {
|
||||
await updateFormSchema({
|
||||
input: {
|
||||
@@ -89,32 +187,74 @@ const {
|
||||
onDone: onCreateDone,
|
||||
} = useMutation(CREATE_BACKUP_JOB_CONFIG_MUTATION);
|
||||
|
||||
const {
|
||||
mutate: updateBackupJobConfig,
|
||||
loading: isUpdating,
|
||||
error: updateError,
|
||||
onDone: onUpdateDone,
|
||||
} = useMutation(UPDATE_BACKUP_JOB_CONFIG_MUTATION);
|
||||
|
||||
const isLoading = computed(
|
||||
() =>
|
||||
isCreating.value ||
|
||||
isUpdating.value ||
|
||||
formLoading.value ||
|
||||
(isEditMode.value && existingConfigLoading.value)
|
||||
);
|
||||
const mutationError = computed(() => createError.value || updateError.value);
|
||||
|
||||
// Handle form submission
|
||||
const submitForm = async () => {
|
||||
try {
|
||||
const { configStep, ...input } = formState.value;
|
||||
await createBackupJobConfig({
|
||||
input: input as CreateBackupJobConfigInput,
|
||||
});
|
||||
// Remove form-specific state like configStep or showAdvanced before submission.
|
||||
// Also remove sourceType and destinationType as they are likely derived for the UI
|
||||
// and the mutation probably expects type information within the nested config objects.
|
||||
const {
|
||||
configStep,
|
||||
showAdvanced,
|
||||
sourceType, // Destructure to exclude from inputData
|
||||
destinationType, // Destructure to exclude from inputData
|
||||
...mutationInputData // Contains name, enabled, schedule, sourceConfig (obj), destinationConfig (obj)
|
||||
} = formState.value;
|
||||
|
||||
// The mutationInputData should now align with CreateBackupJobConfigInput / UpdateBackupJobConfigInput
|
||||
// which expect nested sourceConfig and destinationConfig.
|
||||
const finalPayload = mutationInputData as unknown;
|
||||
|
||||
if (isEditMode.value && props.configId) {
|
||||
await updateBackupJobConfig({
|
||||
id: props.configId,
|
||||
// The `input` here should strictly match UpdateBackupJobConfigInput
|
||||
input: finalPayload as UpdateBackupJobConfigInput,
|
||||
});
|
||||
} else {
|
||||
await createBackupJobConfig({
|
||||
// The `input` here should strictly match CreateBackupJobConfigInput
|
||||
input: finalPayload as CreateBackupJobConfigInput,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating backup job config:', error);
|
||||
console.error(`Error ${isEditMode.value ? 'updating' : 'creating'} backup job config:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle successful creation
|
||||
onCreateDone(async ({ data }) => {
|
||||
// Handle successful creation/update
|
||||
const onMutationSuccess = (isUpdate: boolean) => {
|
||||
if (window.toast) {
|
||||
window.toast.success('Backup Job Created', {
|
||||
description: `Successfully created backup job "${formState.value?.name as string}"`,
|
||||
window.toast.success(`Backup Job ${isUpdate ? 'Updated' : 'Created'}`, {
|
||||
description: `Successfully ${isUpdate ? 'updated' : 'created'} backup job "${formState.value?.name as string}"`,
|
||||
});
|
||||
}
|
||||
console.log('[BackupJobConfigForm] onCreateDone', data);
|
||||
console.log(`[BackupJobConfigForm] on${isUpdate ? 'Update' : 'Create'}Done`);
|
||||
formState.value = {};
|
||||
emit('complete');
|
||||
});
|
||||
};
|
||||
|
||||
onCreateDone(() => onMutationSuccess(false));
|
||||
onUpdateDone(() => onMutationSuccess(true));
|
||||
|
||||
const parsedOriginalErrorMessage = computed(() => {
|
||||
const originalError = createError.value?.graphQLErrors?.[0]?.extensions?.originalError;
|
||||
const originalError = mutationError.value?.graphQLErrors?.[0]?.extensions?.originalError;
|
||||
if (
|
||||
originalError &&
|
||||
typeof originalError === 'object' &&
|
||||
@@ -127,48 +267,21 @@ const parsedOriginalErrorMessage = computed(() => {
|
||||
});
|
||||
|
||||
let changeTimeout: NodeJS.Timeout | null = null;
|
||||
const onChange = ({ data }: { data: unknown }) => {
|
||||
// Clear any pending timeout
|
||||
const onChange = ({ data }: { data: Record<string, unknown> }) => {
|
||||
if (changeTimeout) {
|
||||
clearTimeout(changeTimeout);
|
||||
}
|
||||
|
||||
// Debounce logging to reduce console spam
|
||||
|
||||
changeTimeout = setTimeout(() => {
|
||||
console.log('[BackupJobConfigForm] onChange', data);
|
||||
changeTimeout = null;
|
||||
}, 300);
|
||||
|
||||
formState.value = data as Record<string, unknown>;
|
||||
|
||||
formState.value = data;
|
||||
};
|
||||
|
||||
// --- Submit Button Logic ---
|
||||
const uiSchema = computed(() => formResult.value?.backupJobConfigForm?.uiSchema);
|
||||
|
||||
// Handle both number and object formats of configStep
|
||||
const getCurrentStep = computed(() => {
|
||||
const step = formState.value?.configStep as ConfigStep;
|
||||
return step?.current ?? 0;
|
||||
});
|
||||
|
||||
// Get total steps from UI schema
|
||||
const numSteps = computed(() => {
|
||||
if (uiSchema.value?.type === 'SteppedLayout') {
|
||||
return uiSchema.value?.options?.steps?.length ?? 0;
|
||||
} else if (uiSchema.value?.elements?.[0]?.type === 'SteppedLayout') {
|
||||
return uiSchema.value?.elements[0].options?.steps?.length ?? 0;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const isLastStep = computed(() => {
|
||||
if (numSteps.value === 0) return false;
|
||||
return getCurrentStep.value === numSteps.value - 1;
|
||||
});
|
||||
|
||||
// --- Provide submission logic to SteppedLayout ---
|
||||
provide('submitForm', submitForm);
|
||||
provide('isSubmitting', isCreating);
|
||||
provide('isSubmitting', isLoading);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -176,13 +289,15 @@ provide('isSubmitting', isCreating);
|
||||
class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm"
|
||||
>
|
||||
<div class="p-6">
|
||||
<h2 class="text-xl font-medium mb-4 text-gray-900 dark:text-white">Configure Backup Job</h2>
|
||||
<h2 class="text-xl font-medium mb-4 text-gray-900 dark:text-white">
|
||||
{{ isEditMode ? 'Edit Backup Job' : 'Configure Backup Job' }}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
v-if="createError"
|
||||
v-if="mutationError"
|
||||
class="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 rounded-md"
|
||||
>
|
||||
<p>{{ createError.message }}</p>
|
||||
<p>{{ mutationError.message }}</p>
|
||||
<ul v-if="Array.isArray(parsedOriginalErrorMessage)" class="list-disc list-inside mt-2">
|
||||
<li v-for="(msg, index) in parsedOriginalErrorMessage" :key="index">{{ msg }}</li>
|
||||
</ul>
|
||||
@@ -196,8 +311,15 @@ provide('isSubmitting', isCreating);
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="formLoading" class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
Loading configuration form...
|
||||
<div
|
||||
v-if="isLoading || (isEditMode && existingConfigLoading && !formResult)"
|
||||
class="py-8 text-center text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{
|
||||
isEditMode && existingConfigLoading
|
||||
? 'Loading existing job data...'
|
||||
: 'Loading configuration form...'
|
||||
}}
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
@@ -207,27 +329,28 @@ provide('isSubmitting', isCreating);
|
||||
:schema="formResult.backupJobConfigForm.dataSchema"
|
||||
:uischema="formResult.backupJobConfigForm.uiSchema"
|
||||
:data="formState"
|
||||
:readonly="isCreating"
|
||||
:readonly="isLoading"
|
||||
@change="onChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button (visible only on the last step) -->
|
||||
<div
|
||||
v-if="!formLoading && uiSchema && isLastStep"
|
||||
class="mt-6 flex justify-end space-x-3 border-t border-gray-200 dark:border-gray-700 pt-6"
|
||||
v-else-if="!isLoading && !formResult?.backupJobConfigForm"
|
||||
class="py-8 text-center text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<Button variant="outline" @click="emit('cancel')"> Cancel </Button>
|
||||
<Button :loading="isCreating" @click="submitForm"> Create Backup Job </Button>
|
||||
</div>
|
||||
|
||||
<!-- If there's no stepped layout, show buttons at the bottom -->
|
||||
<div
|
||||
v-if="!formLoading && (!uiSchema || numSteps === 0)"
|
||||
class="mt-6 flex justify-end space-x-3 border-t border-gray-200 dark:border-gray-700 pt-6"
|
||||
>
|
||||
<Button variant="outline" @click="emit('cancel')"> Cancel </Button>
|
||||
<Button :loading="isCreating" @click="submitForm"> Create Backup Job </Button>
|
||||
Could not load form configuration. Please try again.
|
||||
<Button
|
||||
v-if="isEditMode"
|
||||
variant="link"
|
||||
@click="
|
||||
async () => {
|
||||
await refetchExistingConfig?.();
|
||||
await updateFormSchema();
|
||||
}
|
||||
"
|
||||
>
|
||||
Retry loading data
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
|
||||
import { PlayIcon, StopIcon, TrashIcon } from '@heroicons/vue/24/solid';
|
||||
import { PlayIcon, StopIcon, TrashIcon, PencilIcon } from '@heroicons/vue/24/solid';
|
||||
import { Badge, Button, Switch } from '@unraid/ui';
|
||||
|
||||
import {
|
||||
@@ -30,7 +30,7 @@ if (!props.configId) {
|
||||
console.warn('BackupJobItem: configId prop is required but not provided');
|
||||
}
|
||||
|
||||
const emit = defineEmits(['deleted']);
|
||||
const emit = defineEmits(['deleted', 'edit']);
|
||||
|
||||
const isToggling = ref(false);
|
||||
const isTriggering = ref(false);
|
||||
@@ -40,8 +40,8 @@ const showDeleteConfirm = ref(false);
|
||||
const queryVariables = computed(() => ({ id: props.configId }));
|
||||
|
||||
const { result, loading, error, refetch } = useQuery(BACKUP_JOB_CONFIG_QUERY, queryVariables, {
|
||||
fetchPolicy: 'cache-and-network',
|
||||
pollInterval: 50000,
|
||||
fetchPolicy: 'network-only',
|
||||
pollInterval: 5000,
|
||||
errorPolicy: 'all', // Show partial data even if there are errors
|
||||
});
|
||||
|
||||
@@ -248,15 +248,14 @@ async function handleDeleteJob() {
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
<div class="flex space-x-3">
|
||||
<Button variant="outline" :disabled="isDeletingJob" @click="showDeleteConfirm = false">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" :disabled="isDeletingJob" @click="handleDeleteJob">
|
||||
<span
|
||||
v-if="isDeletingJob"
|
||||
class="w-3 h-3 border border-white border-t-transparent rounded-full animate-spin mr-1"
|
||||
></span>
|
||||
{{ isDeletingJob ? 'Deleting...' : 'Delete' }}
|
||||
<Button variant="outline" size="sm" @click="showDeleteConfirm = false"> Cancel </Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
:loading="isDeletingJob"
|
||||
@click="handleDeleteJob"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -320,44 +319,43 @@ async function handleDeleteJob() {
|
||||
{{ configWithJob.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
<Switch
|
||||
:checked="configWithJob.enabled"
|
||||
:disabled="isToggling || configWithJob.isRunning || showDeleteConfirm"
|
||||
@update:checked="handleToggleJob"
|
||||
:model-value="configWithJob.enabled"
|
||||
:disabled="isToggling"
|
||||
@update:model-value="handleToggleJob"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
:disabled="isTriggering || showDeleteConfirm"
|
||||
:variant="!isTriggering ? 'primary' : 'outline'"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
:loading="isTriggering"
|
||||
:disabled="isToggling"
|
||||
@click="handleTriggerOrStopJob"
|
||||
>
|
||||
<span
|
||||
v-if="isTriggering"
|
||||
class="w-3 h-3 border border-white border-t-transparent rounded-full animate-spin mr-1"
|
||||
></span>
|
||||
<StopIcon v-else-if="configWithJob.isRunning" class="w-4 h-4 mr-1" />
|
||||
<PlayIcon v-else class="w-4 h-4 mr-1" />
|
||||
{{
|
||||
isTriggering
|
||||
? configWithJob.isRunning
|
||||
? 'Stopping...'
|
||||
: 'Starting...'
|
||||
: configWithJob.isRunning
|
||||
? 'Stop'
|
||||
: 'Run Now'
|
||||
}}
|
||||
<span class="sr-only">{{
|
||||
configWithJob.isRunning ? 'Stop Backup Job' : 'Trigger Backup Job'
|
||||
}}</span>
|
||||
<StopIcon v-if="configWithJob.isRunning" class="h-5 w-5" />
|
||||
<PlayIcon v-else class="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
:disabled="isDeletingJob || configWithJob.isRunning || showDeleteConfirm"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
:disabled="isToggling || configWithJob.isRunning"
|
||||
@click="emit('edit', configId)"
|
||||
>
|
||||
<span class="sr-only">Edit Backup Job</span>
|
||||
<PencilIcon class="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="text-red-600 hover:text-red-700 dark:text-red-500 dark:hover:text-red-400 border-red-600 hover:border-red-700 dark:border-red-500 dark:hover:border-red-400"
|
||||
:disabled="isToggling || configWithJob.isRunning"
|
||||
@click="showDeleteConfirm = true"
|
||||
>
|
||||
<TrashIcon class="w-4 h-4" />
|
||||
<span class="sr-only">Delete Backup Job</span>
|
||||
<TrashIcon class="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<Badge
|
||||
:variant="
|
||||
configWithJob.runningJob?.status === BackupJobStatus.COMPLETED
|
||||
|
||||
@@ -82,7 +82,7 @@ const refreshJobs = async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!backupJobs?.length" class="text-center py-12">
|
||||
<div v-else-if="!jobs?.length" class="text-center py-12">
|
||||
<div class="text-gray-400 dark:text-gray-600 mb-4">
|
||||
<svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
|
||||
Reference in New Issue
Block a user