feat: backups working

This commit is contained in:
Eli Bosley
2025-05-24 19:56:53 -04:00
parent 333093a20d
commit 8da7c6e586
28 changed files with 2015 additions and 773 deletions

View File

@@ -1,5 +1,5 @@
[api]
version="4.8.0"
version="4.4.1"
extraOrigins="https://google.com,https://test.com"
[local]
sandbox="yes"

View File

@@ -6,9 +6,12 @@
"remoteName": "FlashBackup",
"destinationPath": "backup",
"schedule": "0 2 * * *",
"enabled": true,
"enabled": false,
"rcloneOptions": {},
"createdAt": "2025-05-24T12:19:29.150Z",
"updatedAt": "2025-05-24T12:19:29.150Z"
"updatedAt": "2025-05-24T23:54:07.561Z",
"lastRunStatus": "Started with job ID: 40",
"currentJobId": 40,
"lastRunAt": "2025-05-24T23:54:07.561Z"
}
]

View File

@@ -1,3 +0,0 @@
{
"demo": "hello.unraider"
}

View File

@@ -903,6 +903,12 @@ type BackupMutations {
"""Manually trigger a backup job using existing configuration"""
triggerJob(id: PrefixedID!): BackupStatus!
"""Stop all running backup jobs"""
stopAllBackupJobs: BackupStatus!
"""Forget all finished backup jobs to clean up the job list"""
forgetFinishedBackupJobs: BackupStatus!
}
input CreateBackupJobConfigInput {
@@ -923,6 +929,7 @@ input UpdateBackupJobConfigInput {
schedule: String
enabled: Boolean
rcloneOptions: JSON
lastRunStatus: String
}
input InitiateBackupInput {
@@ -1092,9 +1099,121 @@ type RCloneRemote {
config: JSON!
}
type RCloneJobStats {
"""Bytes transferred"""
bytes: Float
"""Transfer speed in bytes/sec"""
speed: Float
"""Estimated time to completion in seconds"""
eta: Float
"""Elapsed time in seconds"""
elapsedTime: Float
"""Progress percentage (0-100)"""
percentage: Float
"""Number of checks completed"""
checks: Float
"""Number of deletes completed"""
deletes: Float
"""Number of errors encountered"""
errors: Float
"""Whether a fatal error occurred"""
fatalError: Boolean
"""Last error message"""
lastError: String
"""Number of renames completed"""
renames: Float
"""Whether there is a retry error"""
retryError: Boolean
"""Number of server-side copies"""
serverSideCopies: Float
"""Bytes in server-side copies"""
serverSideCopyBytes: Float
"""Number of server-side moves"""
serverSideMoves: Float
"""Bytes in server-side moves"""
serverSideMoveBytes: Float
"""Total bytes to transfer"""
totalBytes: Float
"""Total checks to perform"""
totalChecks: Float
"""Total transfers to perform"""
totalTransfers: Float
"""Time spent transferring in seconds"""
transferTime: Float
"""Number of transfers completed"""
transfers: Float
"""Currently transferring files"""
transferring: JSON
"""Currently checking files"""
checking: JSON
"""Human-readable bytes transferred"""
formattedBytes: String
"""Human-readable transfer speed"""
formattedSpeed: String
"""Human-readable elapsed time"""
formattedElapsedTime: String
"""Human-readable ETA"""
formattedEta: String
}
type RCloneJob {
"""Job ID"""
id: PrefixedID!
"""RClone group for the job"""
group: String
"""Job status and statistics"""
stats: RCloneJobStats
"""Progress percentage (0-100)"""
progressPercentage: Float
"""Configuration ID that triggered this job"""
configId: PrefixedID
"""Detailed status of the job"""
detailedStatus: String
"""Whether the job is finished"""
finished: Boolean
"""Whether the job was successful"""
success: Boolean
"""Error message if job failed"""
error: String
}
type Backup implements Node {
id: PrefixedID!
jobs(showSystemJobs: Boolean = false): [BackupJob!]!
jobs: [RCloneJob!]!
configs: [BackupJobConfig!]!
"""Get the status for the backup service"""
@@ -1109,38 +1228,6 @@ type BackupStatus {
jobId: String
}
type BackupJob {
"""Job ID"""
id: PrefixedID!
"""RClone group for the job"""
group: String
"""Job status and statistics"""
stats: JSON!
"""Formatted bytes transferred"""
formattedBytes: String
"""Formatted transfer speed"""
formattedSpeed: String
"""Formatted elapsed time"""
formattedElapsedTime: String
"""Formatted ETA"""
formattedEta: String
"""Progress percentage (0-100)"""
progressPercentage: Float
"""Configuration ID that triggered this job"""
configId: PrefixedID
"""Detailed status of the job"""
detailedStatus: String
}
type BackupJobConfig implements Node {
id: PrefixedID!
@@ -1176,6 +1263,9 @@ type BackupJobConfig implements Node {
"""Status of last run"""
lastRunStatus: String
"""Current running job ID"""
currentJobId: String
}
type BackupJobConfigForm {
@@ -1741,7 +1831,7 @@ type Query {
backupJobConfig(id: String!): BackupJobConfig
"""Get status of a specific backup job"""
backupJob(jobId: PrefixedID!): BackupJob
backupJob(jobId: PrefixedID!): RCloneJob
"""Get the JSON schema for backup job configuration form"""
backupJobConfigForm(input: BackupJobConfigFormInput): BackupJobConfigForm!
@@ -1905,7 +1995,7 @@ type Subscription {
arraySubscription: UnraidArray!
"""Subscribe to real-time backup job progress updates"""
backupJobProgress(jobId: PrefixedID!): BackupJob
backupJobProgress(jobId: PrefixedID!): RCloneJob
}
"""Available authentication action verbs"""

View File

@@ -11,6 +11,7 @@ import {
RCloneStartBackupInput,
UpdateRCloneRemoteDto,
} from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
import { FormatService } from '@app/unraid-api/utils/format.service.js';
vi.mock('got');
vi.mock('execa');
@@ -55,6 +56,8 @@ describe('RCloneApiService', () => {
let mockExeca: any;
let mockPRetry: any;
let mockExistsSync: any;
let mockFormatService: FormatService;
let mockCacheManager: any;
beforeEach(async () => {
vi.clearAllMocks();
@@ -79,7 +82,19 @@ describe('RCloneApiService', () => {
mockPRetry.mockResolvedValue(undefined);
mockExistsSync.mockReturnValue(false);
service = new RCloneApiService();
mockFormatService = {
formatBytes: vi.fn(),
formatDuration: vi.fn(),
} as any;
// Mock cache manager
mockCacheManager = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
del: vi.fn().mockResolvedValue(undefined),
};
service = new RCloneApiService(mockFormatService, mockCacheManager);
await service.onModuleInit();
});

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { existsSync } from 'fs';
import { readFile, writeFile } from 'fs/promises';
@@ -15,6 +15,8 @@ import {
} from '@app/unraid-api/graph/resolvers/backup/backup.model.js';
import { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.service.js';
const JOB_GROUP_PREFIX = 'backup-';
interface BackupJobConfigData {
id: string;
name: string;
@@ -28,10 +30,11 @@ interface BackupJobConfigData {
updatedAt: string;
lastRunAt?: string;
lastRunStatus?: string;
currentJobId?: string;
}
@Injectable()
export class BackupConfigService {
export class BackupConfigService implements OnModuleInit {
private readonly logger = new Logger(BackupConfigService.name);
private readonly configPath: string;
private configs: Map<string, BackupJobConfigData> = new Map();
@@ -42,7 +45,10 @@ export class BackupConfigService {
) {
const paths = getters.paths();
this.configPath = join(paths.backupBase, 'backup-jobs.json');
this.loadConfigs();
}
async onModuleInit(): Promise<void> {
await this.loadConfigs();
}
async createBackupJobConfig(input: CreateBackupJobConfigInput): Promise<BackupJobConfig> {
@@ -113,18 +119,19 @@ export class BackupConfigService {
this.logger.log(`Executing backup job: ${config.name}`);
try {
const result = await this.rcloneService['rcloneApiService'].startBackup({
const result = (await this.rcloneService['rcloneApiService'].startBackup({
srcPath: config.sourcePath,
dstPath: `${config.remoteName}:${config.destinationPath}`,
async: true,
group: `backup/${config.id}`,
configId: config.id,
options: config.rcloneOptions || {},
});
})) as { jobId?: string; jobid?: string };
const jobId = result.jobId || result.jobid;
config.lastRunAt = new Date().toISOString();
config.lastRunStatus = `Started with job ID: ${jobId}`;
config.currentJobId = jobId;
this.configs.set(config.id, config);
await this.saveConfigs();
@@ -133,6 +140,7 @@ export class BackupConfigService {
const errorMessage = error instanceof Error ? error.message : String(error);
config.lastRunAt = new Date().toISOString();
config.lastRunStatus = `Failed: ${errorMessage}`;
config.currentJobId = undefined;
this.configs.set(config.id, config);
await this.saveConfigs();
@@ -150,7 +158,7 @@ export class BackupConfigService {
'UTC'
);
this.schedulerRegistry.addCronJob(`backup-${config.id}`, job);
this.schedulerRegistry.addCronJob(`${JOB_GROUP_PREFIX}${config.id}`, job);
job.start();
this.logger.log(`Scheduled backup job: ${config.name} with schedule: ${config.schedule}`);
} catch (error) {
@@ -160,22 +168,42 @@ export class BackupConfigService {
private unscheduleJob(id: string): void {
try {
const jobName = `backup-${id}`;
const jobName = `${JOB_GROUP_PREFIX}${id}`;
if (this.schedulerRegistry.doesExist('cron', jobName)) {
this.schedulerRegistry.deleteCronJob(jobName);
this.logger.log(`Unscheduled backup job: ${id}`);
} else {
this.logger.debug(`No existing cron job found to unschedule for backup job: ${id}`);
}
} catch (error) {
this.logger.error(`Failed to unschedule backup job ${id}:`, error);
}
}
addCronJob(name: string, seconds: string) {
const job = new CronJob(`${seconds} * * * * *`, () => {
this.logger.warn(`time (${seconds}) for job ${name} to run!`);
});
this.schedulerRegistry.addCronJob(name, job);
job.start();
this.logger.warn(`job ${name} added for each minute at ${seconds} seconds!`);
}
private async loadConfigs(): Promise<void> {
try {
if (existsSync(this.configPath)) {
const data = await readFile(this.configPath, 'utf-8');
const configs: BackupJobConfigData[] = JSON.parse(data);
// First, unschedule any existing jobs before clearing the config map
this.configs.forEach((config) => {
if (config.enabled) {
this.unscheduleJob(config.id);
}
});
this.configs.clear();
configs.forEach((config) => {
this.configs.set(config.id, config);
@@ -214,6 +242,7 @@ export class BackupConfigService {
updatedAt: new Date(config.updatedAt),
lastRunAt: config.lastRunAt ? new Date(config.lastRunAt) : undefined,
lastRunStatus: config.lastRunStatus,
currentJobId: config.currentJobId,
};
}
}

View File

@@ -33,20 +33,18 @@ export class BackupMutationsResolver {
remoteName: string,
destinationPath: string,
options: Record<string, any> = {},
group: string
configId?: string
): Promise<BackupStatus> {
try {
this.logger.log(
`Executing backup: ${sourcePath} -> ${remoteName}:${destinationPath} (group: ${group})`
);
this.logger.log(`Executing backup: ${sourcePath} -> ${remoteName}:${destinationPath}`);
const result = await this.rcloneService['rcloneApiService'].startBackup({
const result = (await this.rcloneService['rcloneApiService'].startBackup({
srcPath: sourcePath,
dstPath: `${remoteName}:${destinationPath}`,
async: true,
group: group,
configId: configId,
options: options,
});
})) as { jobId?: string; jobid?: string };
this.logger.debug(`RClone startBackup result: ${JSON.stringify(result)}`);
@@ -126,8 +124,7 @@ export class BackupMutationsResolver {
input.sourcePath,
input.remoteName,
input.destinationPath,
input.options || {},
'backup/manual'
input.options || {}
);
}
@@ -166,12 +163,91 @@ export class BackupMutationsResolver {
};
}
return this.executeBackup(
const result = await this.executeBackup(
config.sourcePath,
config.remoteName,
config.destinationPath,
config.rcloneOptions || {},
`backup/${id}`
config.id
);
// Store the job ID in the config if successful
if (result.jobId) {
await this.backupConfigService.updateBackupJobConfig(id, {
lastRunStatus: `Started with job ID: ${result.jobId}`,
});
// Update the currentJobId in the config
const configData = this.backupConfigService['configs'].get(id);
if (configData) {
configData.currentJobId = result.jobId;
configData.lastRunAt = new Date().toISOString();
this.backupConfigService['configs'].set(id, configData);
await this.backupConfigService['saveConfigs']();
}
}
return result;
}
@ResolveField(() => BackupStatus, {
description: 'Stop all running backup jobs',
})
@UsePermissions({
action: AuthActionVerb.DELETE,
resource: Resource.BACKUP,
possession: AuthPossession.ANY,
})
async stopAllBackupJobs(): Promise<BackupStatus> {
try {
const result = await this.rcloneService['rcloneApiService'].stopAllJobs();
const stoppedCount = result.stopped.length;
const errorCount = result.errors.length;
if (stoppedCount > 0) {
this.logger.log(`Stopped ${stoppedCount} backup jobs`);
}
if (errorCount > 0) {
this.logger.warn(`Failed operations on ${errorCount} jobs: ${result.errors.join(', ')}`);
}
return {
status: `Stopped ${stoppedCount} jobs${errorCount > 0 ? `, ${errorCount} errors` : ''}`,
jobId: undefined,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to stop backup jobs: ${errorMessage}`);
return {
status: `Failed to stop backup jobs: ${errorMessage}`,
jobId: undefined,
};
}
}
@ResolveField(() => BackupStatus, {
description: 'Forget all finished backup jobs to clean up the job list',
})
@UsePermissions({
action: AuthActionVerb.DELETE,
resource: Resource.BACKUP,
possession: AuthPossession.ANY,
})
async forgetFinishedBackupJobs(): Promise<BackupStatus> {
try {
this.logger.log('Forgetting finished backup jobs is handled automatically by RClone');
return {
status: 'Finished jobs are automatically cleaned up by RClone',
jobId: undefined,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to forget finished backup jobs: ${errorMessage}`);
return {
status: `Failed to forget finished backup jobs: ${errorMessage}`,
jobId: undefined,
};
}
}
}

View File

@@ -107,6 +107,9 @@ export class BackupJobConfig extends Node {
@Field(() => String, { description: 'Status of last run', nullable: true })
lastRunStatus?: string;
@Field(() => String, { description: 'Current running job ID', nullable: true })
currentJobId?: string;
}
@InputType()
@@ -199,6 +202,11 @@ export class UpdateBackupJobConfigInput {
@IsOptional()
@IsObject()
rcloneOptions?: Record<string, unknown>;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
lastRunStatus?: string;
}
@ObjectType()

View File

@@ -4,12 +4,11 @@ import { ScheduleModule } from '@nestjs/schedule';
import { BackupConfigService } from '@app/unraid-api/graph/resolvers/backup/backup-config.service.js';
import { BackupMutationsResolver } from '@app/unraid-api/graph/resolvers/backup/backup-mutations.resolver.js';
import { BackupResolver } from '@app/unraid-api/graph/resolvers/backup/backup.resolver.js';
import { FormatService } from '@app/unraid-api/graph/resolvers/backup/format.service.js';
import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js';
@Module({
imports: [RCloneModule, ScheduleModule.forRoot()],
providers: [BackupResolver, BackupMutationsResolver, BackupConfigService, FormatService],
providers: [BackupResolver, BackupMutationsResolver, BackupConfigService],
exports: [],
})
export class BackupModule {}

View File

@@ -5,17 +5,18 @@ import { pubsub } from '@app/core/pubsub.js';
import { BackupConfigService } from '@app/unraid-api/graph/resolvers/backup/backup-config.service.js';
import {
Backup,
BackupJob,
BackupJobConfig,
BackupJobConfigForm,
BackupJobConfigFormInput,
BackupStatus,
} from '@app/unraid-api/graph/resolvers/backup/backup.model.js';
import { FormatService } from '@app/unraid-api/graph/resolvers/backup/format.service.js';
import { buildBackupJobConfigSchema } from '@app/unraid-api/graph/resolvers/backup/jsonforms/backup-jsonforms-config.js';
import { RCloneJob } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
import { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.service.js';
import { PrefixedID } from '@app/unraid-api/graph/scalars/graphql-type-prefixed-id.js';
import { FormatService } from '@app/unraid-api/utils/format.service.js';
const JOB_GROUP_PREFIX = 'backup-';
@Resolver(() => Backup)
export class BackupResolver {
@@ -38,14 +39,11 @@ export class BackupResolver {
};
}
@ResolveField(() => [BackupJob], {
description: 'Get all running backup jobs',
@ResolveField(() => [RCloneJob], {
description: 'Get all running jobs (filtering should be done on frontend)',
})
async jobs(
@Args('showSystemJobs', { type: () => Boolean, nullable: true, defaultValue: false })
showSystemJobs?: boolean
): Promise<BackupJob[]> {
return this.backupJobs(showSystemJobs);
async jobs(): Promise<RCloneJob[]> {
return this.backupJobs();
}
@ResolveField(() => [BackupJobConfig], {
@@ -63,22 +61,28 @@ export class BackupResolver {
return this.backupConfigService.getBackupJobConfig(id);
}
@Query(() => BackupJob, {
@Query(() => RCloneJob, {
description: 'Get status of a specific backup job',
nullable: true,
})
async backupJob(
@Args('jobId', { type: () => PrefixedID }) jobId: string
): Promise<BackupJob | null> {
): Promise<RCloneJob | null> {
try {
const status = await this.rcloneService['rcloneApiService'].getJobStatus({ jobId });
console.log(status);
return {
id: jobId,
group: status.group || '',
stats: status,
group: status.group || undefined,
stats: status.stats,
finished: status.finished,
success: status.success,
error: status.error || undefined,
progressPercentage: status.stats?.percentage,
detailedStatus: status.error ? 'Error' : status.finished ? 'Completed' : 'Running',
};
} catch (error) {
this.logger.error(`Failed to fetch backup job ${jobId}:`, error);
this.logger.error(`Failed to fetch backup job ${jobId}: %o`, error);
return null;
}
}
@@ -114,7 +118,7 @@ export class BackupResolver {
};
}
@Subscription(() => BackupJob, {
@Subscription(() => RCloneJob, {
description: 'Subscribe to real-time backup job progress updates',
nullable: true,
})
@@ -122,65 +126,17 @@ export class BackupResolver {
return pubsub.asyncIterableIterator(`BACKUP_JOB_PROGRESS:${jobId}`);
}
private async backupJobs(showSystemJobs: boolean = false): Promise<RCloneJob[]> {
private async backupJobs(): Promise<RCloneJob[]> {
try {
this.logger.debug(`backupJobs called with showSystemJobs: ${showSystemJobs}`);
this.logger.debug('backupJobs called - returning all jobs for frontend filtering');
let jobs;
if (showSystemJobs) {
// Get all jobs when showing system jobs
jobs = await this.rcloneService['rcloneApiService'].getAllJobsWithStats();
this.logger.debug(`All jobs with stats: ${JSON.stringify(jobs)}`);
} else {
// Get only backup jobs with enhanced stats when not showing system jobs
jobs = await this.rcloneService['rcloneApiService'].getBackupJobsWithStats();
this.logger.debug(`Backup jobs with enhanced stats: ${JSON.stringify(jobs)}`);
}
const jobs = (await this.rcloneService['rcloneApiService'].getAllJobsWithStats()).filter(
(job) => job.group?.startsWith(JOB_GROUP_PREFIX)
);
// Filter and map jobs
const allJobs =
jobs.jobids?.map((jobId: string | number, index: number) => {
const stats = jobs.stats?.[index] || {};
const group = stats.group || '';
this.logger.debug(`Returning ${jobs.length} jobs total for frontend filtering`);
this.logger.debug(
`Processing job ${jobId}: group="${group}", stats keys: [${Object.keys(stats).join(', ')}]`
);
return {
id: String(jobId),
group: group,
stats,
};
}) || [];
this.logger.debug(`Mapped ${allJobs.length} jobs total`);
// Log all job groups for analysis
const jobGroupSummary = allJobs.map((job) => ({ id: job.id, group: job.group }));
this.logger.debug(`All job groups: ${JSON.stringify(jobGroupSummary)}`);
// Filter based on showSystemJobs flag
if (showSystemJobs) {
this.logger.debug(`Returning all ${allJobs.length} jobs (showSystemJobs=true)`);
return allJobs;
} else {
// When not showing system jobs, we already filtered to backup jobs in getBackupJobsWithStats
// But let's double-check the filtering for safety
const filteredJobs = allJobs.filter((job) => job.group.startsWith('backup/'));
this.logger.debug(
`Filtered to ${filteredJobs.length} backup jobs (group starts with 'backup/')`
);
const nonBackupJobs = allJobs.filter((job) => !job.group.startsWith('backup/'));
if (nonBackupJobs.length > 0) {
this.logger.debug(
`Excluded ${nonBackupJobs.length} non-backup jobs: ${JSON.stringify(nonBackupJobs.map((j) => ({ id: j.id, group: j.group })))}`
);
}
return filteredJobs;
}
return jobs;
} catch (error) {
this.logger.error('Failed to fetch backup jobs:', error);
return [];

View File

@@ -1,20 +0,0 @@
import { Injectable } from '@nestjs/common';
import { convert } from 'convert';
@Injectable()
export class FormatService {
formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const result = convert(bytes, 'bytes').to('best');
return result.toString();
}
formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
const result = convert(seconds, 'seconds').to('best');
return result.toString();
}
}

View File

@@ -10,19 +10,16 @@ import got, { HTTPError } from 'got';
import pRetry from 'p-retry';
import { sanitizeParams } from '@app/core/log.js';
import { FormatService } from '@app/unraid-api/graph/resolvers/backup/format.service.js';
import { RCloneStatusService } from '@app/unraid-api/graph/resolvers/rclone/rclone-status.service.js';
import {
CreateRCloneRemoteDto,
DeleteRCloneRemoteDto,
GetRCloneJobStatusDto,
GetRCloneRemoteConfigDto,
GetRCloneRemoteDetailsDto,
RCloneJob,
RCloneJobListResponse,
RCloneJobStats,
RCloneJobStatusResponse,
RCloneJobsWithStatsResponse,
RCloneJobWithStats,
RCloneProviderOptionResponse,
RCloneProviderResponse,
RCloneRemoteConfig,
RCloneStartBackupInput,
@@ -30,6 +27,50 @@ import {
} from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js';
// Internal interface for job status response from RClone API
interface RCloneJobStatusResponse {
id?: string | number;
group?: string;
stats?: RCloneJobStats;
finished?: boolean;
error?: string;
[key: string]: any;
}
interface BackupStatusResult {
isRunning: boolean;
stats: RCloneJobStats | null;
jobCount: number;
activeJobs: RCloneJobStatusResponse[];
}
interface JobOperationResult {
stopped: string[];
forgotten?: string[];
errors: string[];
}
const CONSTANTS = {
RETRY_CONFIG: {
retries: 6,
minTimeout: 100,
maxTimeout: 5000,
factor: 2,
maxRetryTime: 30000,
},
TIMEOUTS: {
GRACEFUL_SHUTDOWN: 2000,
PROCESS_CLEANUP: 1000,
STOP_JOBS_WAIT: 1000,
},
LOG_LEVEL: {
DEBUG: 'DEBUG',
INFO: 'INFO',
},
} as const;
const JOB_GROUP_PREFIX = 'backup-';
@Injectable()
export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
private isInitialized: boolean = false;
@@ -41,40 +82,12 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
process.env.RCLONE_USERNAME || crypto.randomBytes(12).toString('base64');
private readonly rclonePassword: string =
process.env.RCLONE_PASSWORD || crypto.randomBytes(24).toString('base64');
constructor(private readonly formatService: FormatService) {}
constructor(private readonly statusService: RCloneStatusService) {}
async onModuleInit(): Promise<void> {
try {
const { getters } = await import('@app/store/index.js');
this.rcloneSocketPath = getters.paths()['rclone-socket'];
const logFilePath = join(getters.paths()['log-base'], 'rclone-unraid-api.log');
this.logger.log(`RClone socket path: ${this.rcloneSocketPath}`);
this.logger.log(`RClone log file path: ${logFilePath}`);
this.rcloneBaseUrl = `http://unix:${this.rcloneSocketPath}:`;
const socketExists = await this.checkRcloneSocketExists(this.rcloneSocketPath);
if (socketExists) {
const isRunning = await this.checkRcloneSocketRunning();
if (isRunning) {
this.isInitialized = true;
return;
} else {
this.logger.warn(
'RClone socket is not running but socket exists, removing socket before starting...'
);
await rm(this.rcloneSocketPath, { force: true });
}
this.logger.warn('RClone socket is not running, starting it...');
this.isInitialized = await this.startRcloneSocket(this.rcloneSocketPath, logFilePath);
return;
} else {
this.logger.warn('RClone socket does not exist, creating it...');
this.isInitialized = await this.startRcloneSocket(this.rcloneSocketPath, logFilePath);
return;
}
await this.initializeRCloneService();
} catch (error: unknown) {
this.logger.error(`Error initializing RCloneApiService: ${error}`);
this.isInitialized = false;
@@ -86,92 +99,144 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
this.logger.log('RCloneApiService module destroyed');
}
private async initializeRCloneService(): Promise<void> {
const { getters } = await import('@app/store/index.js');
this.rcloneSocketPath = getters.paths()['rclone-socket'];
const logFilePath = join(getters.paths()['log-base'], 'rclone-unraid-api.log');
this.rcloneBaseUrl = `http://unix:${this.rcloneSocketPath}:`;
this.logger.log(`RClone socket path: ${this.rcloneSocketPath}`);
const socketExists = await this.checkRcloneSocketExists(this.rcloneSocketPath);
if (socketExists && (await this.checkRcloneSocketRunning())) {
this.isInitialized = true;
return;
}
if (socketExists) {
this.logger.warn('RClone socket exists but not running, removing before restart...');
await rm(this.rcloneSocketPath, { force: true });
}
this.logger.warn('Starting new RClone socket...');
await this.killExistingRcloneProcesses();
this.isInitialized = await this.startRcloneSocket(this.rcloneSocketPath, logFilePath);
}
private async startRcloneSocket(socketPath: string, logFilePath: string): Promise<boolean> {
try {
if (!existsSync(logFilePath)) {
await mkdir(dirname(logFilePath), { recursive: true });
await writeFile(logFilePath, '', 'utf-8');
}
await this.ensureLogFileExists(logFilePath);
const rcloneArgs = this.buildRcloneArgs(socketPath, logFilePath);
this.logger.log(`Starting RClone RC daemon on socket: ${socketPath}`);
this.rcloneProcess = execa(
'rclone',
[
'rcd',
'--rc-addr',
socketPath,
'--log-level',
'INFO',
'--log-file',
logFilePath,
...(this.rcloneUsername ? ['--rc-user', this.rcloneUsername] : []),
...(this.rclonePassword ? ['--rc-pass', this.rclonePassword] : []),
],
{ detached: false }
);
this.rcloneProcess.on('error', (error: Error) => {
this.logger.error(`RClone process failed to start: ${error.message}`);
this.rcloneProcess = null;
this.isInitialized = false;
});
this.rcloneProcess.on('exit', (code, signal) => {
this.logger.warn(
`RClone process exited unexpectedly with code: ${code}, signal: ${signal}`
);
this.rcloneProcess = null;
this.isInitialized = false;
});
await pRetry(
async () => {
const isRunning = await this.checkRcloneSocketRunning();
if (!isRunning) throw new Error('Rclone socket not ready');
},
{
retries: 6,
minTimeout: 100,
maxTimeout: 5000,
factor: 2,
maxRetryTime: 30000,
}
);
this.rcloneProcess = execa('rclone', rcloneArgs, { detached: false });
this.setupProcessListeners();
await this.waitForSocketReady();
return true;
} catch (error: unknown) {
this.logger.error(`Error starting RClone RC daemon: ${error}`);
this.rcloneProcess?.kill();
this.rcloneProcess = null;
this.cleanupFailedProcess();
return false;
}
}
private async stopRcloneSocket(): Promise<void> {
if (this.rcloneProcess && !this.rcloneProcess.killed) {
this.logger.log(`Stopping RClone RC daemon process (PID: ${this.rcloneProcess.pid})...`);
try {
const killed = this.rcloneProcess.kill('SIGTERM');
if (!killed) {
this.logger.warn('Failed to kill RClone process with SIGTERM, trying SIGKILL.');
this.rcloneProcess.kill('SIGKILL');
}
this.logger.log('RClone process stopped.');
} catch (error: unknown) {
this.logger.error(`Error stopping RClone process: ${error}`);
} finally {
this.rcloneProcess = null;
}
} else {
this.logger.log('RClone process not running or already stopped.');
private async ensureLogFileExists(logFilePath: string): Promise<void> {
if (!existsSync(logFilePath)) {
await mkdir(dirname(logFilePath), { recursive: true });
await writeFile(logFilePath, '', 'utf-8');
}
}
private buildRcloneArgs(socketPath: string, logFilePath: string): string[] {
const enableDebugMode = true;
const enableRcServe = true;
const logLevel = enableDebugMode ? CONSTANTS.LOG_LEVEL.DEBUG : CONSTANTS.LOG_LEVEL.INFO;
const args = [
'rcd',
'--rc-addr',
socketPath,
'--log-level',
logLevel,
'--log-file',
logFilePath,
];
if (this.rcloneUsername) args.push('--rc-user', this.rcloneUsername);
if (this.rclonePassword) args.push('--rc-pass', this.rclonePassword);
if (enableRcServe) args.push('--rc-serve');
if (enableDebugMode) {
this.logger.log('Debug mode: Enhanced logging and RC serve enabled');
}
return args;
}
private setupProcessListeners(): void {
if (!this.rcloneProcess) return;
this.rcloneProcess.on('error', (error: Error) => {
this.logger.error(`RClone process failed to start: ${error.message}`);
this.cleanupFailedProcess();
});
this.rcloneProcess.on('exit', (code, signal) => {
this.logger.warn(`RClone process exited unexpectedly with code: ${code}, signal: ${signal}`);
this.cleanupFailedProcess();
});
}
private cleanupFailedProcess(): void {
this.rcloneProcess = null;
this.isInitialized = false;
}
private async waitForSocketReady(): Promise<void> {
await pRetry(async () => {
const isRunning = await this.checkRcloneSocketRunning();
if (!isRunning) throw new Error('Rclone socket not ready');
}, CONSTANTS.RETRY_CONFIG);
}
private async stopRcloneSocket(): Promise<void> {
if (this.rcloneProcess && !this.rcloneProcess.killed) {
await this.terminateProcess();
}
await this.killExistingRcloneProcesses();
await this.removeSocketFile();
}
private async terminateProcess(): Promise<void> {
if (!this.rcloneProcess) return;
this.logger.log(`Stopping RClone RC daemon process (PID: ${this.rcloneProcess.pid})...`);
try {
const killed = this.rcloneProcess.kill('SIGTERM');
if (!killed) {
this.logger.warn('Failed to kill with SIGTERM, using SIGKILL');
this.rcloneProcess.kill('SIGKILL');
}
this.logger.log('RClone process stopped');
} catch (error: unknown) {
this.logger.error(`Error stopping RClone process: ${error}`);
} finally {
this.rcloneProcess = null;
}
}
private async removeSocketFile(): Promise<void> {
if (this.rcloneSocketPath && existsSync(this.rcloneSocketPath)) {
this.logger.log(`Removing RClone socket file: ${this.rcloneSocketPath}`);
try {
await rm(this.rcloneSocketPath, { force: true });
} catch (error: unknown) {
this.logger.error(`Error removing RClone socket file: ${error}`);
this.logger.error(`Error removing socket file: ${error}`);
}
}
}
@@ -180,16 +245,15 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
const socketExists = existsSync(socketPath);
if (!socketExists) {
this.logger.warn(`RClone socket does not exist at: ${socketPath}`);
return false;
}
return true;
return socketExists;
}
private async checkRcloneSocketRunning(): Promise<boolean> {
try {
await this.callRcloneApi('core/pid');
await this.callRcloneApi('rc/noop');
return true;
} catch (error: unknown) {
} catch {
return false;
}
}
@@ -208,8 +272,7 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
async getRemoteDetails(input: GetRCloneRemoteDetailsDto): Promise<RCloneRemoteConfig> {
await validateObject(GetRCloneRemoteDetailsDto, input);
const config = (await this.getRemoteConfig({ name: input.name })) || {};
return config as RCloneRemoteConfig;
return this.getRemoteConfig({ name: input.name });
}
async getRemoteConfig(input: GetRCloneRemoteConfigDto): Promise<RCloneRemoteConfig> {
@@ -217,249 +280,254 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
return this.callRcloneApi('config/get', { name: input.name });
}
async createRemote(input: CreateRCloneRemoteDto): Promise<any> {
async createRemote(input: CreateRCloneRemoteDto): Promise<unknown> {
await validateObject(CreateRCloneRemoteDto, input);
this.logger.log(`Creating new remote: ${input.name} of type: ${input.type}`);
const params = {
this.logger.log(`Creating remote: ${input.name} (${input.type})`);
const result = await this.callRcloneApi('config/create', {
name: input.name,
type: input.type,
parameters: input.parameters,
};
const result = await this.callRcloneApi('config/create', params);
});
this.logger.log(`Successfully created remote: ${input.name}`);
return result;
}
async updateRemote(input: UpdateRCloneRemoteDto): Promise<any> {
async updateRemote(input: UpdateRCloneRemoteDto): Promise<unknown> {
await validateObject(UpdateRCloneRemoteDto, input);
this.logger.log(`Updating remote: ${input.name}`);
const params = {
return this.callRcloneApi('config/update', {
name: input.name,
...input.parameters,
};
return this.callRcloneApi('config/update', params);
});
}
async deleteRemote(input: DeleteRCloneRemoteDto): Promise<any> {
async deleteRemote(input: DeleteRCloneRemoteDto): Promise<unknown> {
await validateObject(DeleteRCloneRemoteDto, input);
this.logger.log(`Deleting remote: ${input.name}`);
return this.callRcloneApi('config/delete', { name: input.name });
}
async startBackup(input: RCloneStartBackupInput): Promise<any> {
async startBackup(input: RCloneStartBackupInput): Promise<unknown> {
await validateObject(RCloneStartBackupInput, input);
this.logger.log(
`Starting backup from ${input.srcPath} to ${input.dstPath} with group: ${input.group}`
);
this.logger.log(`Starting backup: ${input.srcPath} ${input.dstPath}`);
const group = input.configId
? `${JOB_GROUP_PREFIX}${input.configId}`
: JOB_GROUP_PREFIX + 'manual';
const params = {
srcFs: input.srcPath,
dstFs: input.dstPath,
...(input.async && { _async: input.async }),
...(input.group && { _group: input.group }),
_group: group,
...(input.options || {}),
};
const result = await this.callRcloneApi('sync/copy', params);
this.logger.log(
`Backup job created with ID: ${result.jobid || result.jobId || 'unknown'}, group: ${input.group}`
);
const jobId = result.jobid || result.jobId || 'unknown';
this.logger.log(`Backup job created with ID: ${jobId} in group: ${group}`);
return result;
}
async getJobStatus(input: GetRCloneJobStatusDto): Promise<RCloneJobStatusResponse> {
async getJobStatus(input: GetRCloneJobStatusDto): Promise<RCloneJob> {
await validateObject(GetRCloneJobStatusDto, input);
const result = await this.callRcloneApi('job/status', { jobid: input.jobId });
if (result.error) {
this.logger.warn(`Job ${input.jobId} has error: ${result.error}`);
}
if (!result.stats && result.group) {
// If the jobId looks like a group name (starts with backup-), get group stats
if (input.jobId.startsWith(JOB_GROUP_PREFIX)) {
try {
const groupStats = await this.getGroupStats(result.group);
if (groupStats && typeof groupStats === 'object') {
result.stats = { ...groupStats };
}
} catch (groupError) {
this.logger.warn(`Failed to get group stats for job ${input.jobId}: ${groupError}`);
const stats = await this.callRcloneApi('core/stats', { group: input.jobId });
const enhancedStats = this.statusService.enhanceStatsWithFormattedFields({
...stats,
group: input.jobId,
});
const job = this.statusService.transformStatsToJob(input.jobId, enhancedStats);
job.configId = input.jobId.substring(JOB_GROUP_PREFIX.length);
return job;
} catch (error) {
this.logger.warn(`Failed to get group stats for ${input.jobId}: ${error}`);
}
}
if (result.stats) {
result.stats = this.enhanceStatsWithFormattedFields(result.stats);
// Fallback to individual job status
const jobStatus = await this.getIndividualJobStatus(input.jobId);
return this.statusService.parseJobWithStats(input.jobId, jobStatus);
}
async getIndividualJobStatus(jobId: string): Promise<RCloneJobStatusResponse> {
this.logger.debug(`Fetching status for job ${jobId}`);
const result = await this.callRcloneApi('job/status', { jobid: jobId });
if (result.error) {
this.logger.warn(`Job ${jobId} has error: ${result.error}`);
}
return result;
}
async listRunningJobs(): Promise<RCloneJobListResponse> {
const result = await this.callRcloneApi('job/list');
return result;
this.logger.debug('Fetching job list from RClone API');
return this.callRcloneApi('job/list');
}
async getGroupStats(group: string): Promise<any> {
const result = await this.callRcloneApi('core/stats', { group });
return result;
}
async getAllJobsWithStats(): Promise<RCloneJob[]> {
try {
// Get both the job list and group list
const [runningJobs, groupList] = await Promise.all([
this.listRunningJobs(),
this.callRcloneApi('core/group-list'),
]);
async getBackupJobsWithStats(): Promise<RCloneJobsWithStatsResponse> {
const jobList = await this.listRunningJobs();
this.logger.debug(`Running jobs: ${JSON.stringify(runningJobs)}`);
this.logger.debug(`Group list: ${JSON.stringify(groupList)}`);
if (!jobList.jobids || jobList.jobids.length === 0) {
this.logger.log('No active jobs found in RClone');
return { jobids: [], stats: [] };
}
// Safety check: if too many groups, something is wrong
if (groupList.groups && groupList.groups.length > 100) {
this.logger.error(
`DANGER: Found ${groupList.groups.length} groups, aborting to prevent job explosion`
);
return [];
}
this.logger.log(
`Found ${jobList.jobids.length} active jobs in RClone, processing all jobs with stats`
);
// Safety check: if too many individual jobs, something is wrong
if (runningJobs.jobids && runningJobs.jobids.length > 1000) {
this.logger.error(
`DANGER: Found ${runningJobs.jobids.length} individual jobs, aborting to prevent performance issues`
);
return [];
}
const allJobs: RCloneJobWithStats[] = [];
let successfulJobQueries = 0;
if (!runningJobs.jobids?.length) {
this.logger.debug('No running jobs found');
return [];
}
for (const jobId of jobList.jobids) {
try {
const jobStatus = await this.getJobStatus({ jobId: String(jobId) });
const group = jobStatus.group || '';
const backupGroups = (groupList.groups || []).filter((group: string) =>
group.startsWith(JOB_GROUP_PREFIX)
);
let detailedStats = {};
if (group) {
if (backupGroups.length === 0) {
this.logger.debug('No backup groups found');
return [];
}
// Get group stats for all backup groups to get proper stats and group info
const groupStatsMap = new Map<string, any>();
await Promise.all(
backupGroups.map(async (group: string) => {
try {
const groupStats = await this.getGroupStats(group);
if (groupStats && typeof groupStats === 'object') {
detailedStats = { ...groupStats };
}
} catch (groupError) {
this.logger.warn(
`Failed to get core/stats for job ${jobId}, group ${group}: ${groupError}`
);
const stats = await this.callRcloneApi('core/stats', { group });
groupStatsMap.set(group, stats);
} catch (error) {
this.logger.warn(`Failed to get stats for group ${group}: ${error}`);
}
}
})
);
const enhancedStats = {
...jobStatus.stats,
...detailedStats,
};
const jobs: RCloneJob[] = [];
const finalStats = this.enhanceStatsWithFormattedFields(enhancedStats);
// For each backup group, create a job entry with proper stats
backupGroups.forEach((group) => {
const groupStats = groupStatsMap.get(group);
if (!groupStats) return;
allJobs.push({
jobId,
stats: finalStats,
this.logger.debug(`Processing group ${group}: stats=${JSON.stringify(groupStats)}`);
const configId = group.startsWith(JOB_GROUP_PREFIX)
? group.substring(JOB_GROUP_PREFIX.length)
: undefined;
// Use the group name as the job ID for consistency, but add group info to stats
const enhancedStats = this.statusService.enhanceStatsWithFormattedFields({
...groupStats,
group, // Add group to stats so it gets picked up in transformStatsToJob
});
successfulJobQueries++;
} catch (error) {
this.logger.error(`Failed to get status for job ${jobId}: ${error}`);
}
}
const job = this.statusService.transformStatsToJob(group, enhancedStats);
job.configId = configId;
this.logger.log(
`Successfully queried ${successfulJobQueries} jobs from ${jobList.jobids.length} total jobs`
);
// Only include jobs that are truly active (not completed)
const isActivelyTransferring = groupStats.transferring?.length > 0;
const isActivelyChecking = groupStats.checking?.length > 0;
const hasActiveSpeed = groupStats.speed > 0;
const isNotFinished = !groupStats.finished && groupStats.fatalError !== true;
const result: RCloneJobsWithStatsResponse = {
jobids: allJobs.map((job) => job.jobId),
stats: allJobs.map((job) => job.stats),
};
return result;
}
async getAllJobsWithStats(): Promise<RCloneJobsWithStatsResponse> {
const jobList = await this.listRunningJobs();
if (!jobList.jobids || jobList.jobids.length === 0) {
this.logger.log('No active jobs found in RClone');
return { jobids: [], stats: [] };
}
this.logger.log(
`Found ${jobList.jobids.length} active jobs in RClone: [${jobList.jobids.join(', ')}]`
);
const allJobs: RCloneJobWithStats[] = [];
let successfulJobQueries = 0;
for (const jobId of jobList.jobids) {
try {
const jobStatus = await this.getJobStatus({ jobId: String(jobId) });
const group = jobStatus.group || '';
let detailedStats = {};
if (group) {
try {
const groupStats = await this.getGroupStats(group);
if (groupStats && typeof groupStats === 'object') {
detailedStats = { ...groupStats };
}
} catch (groupError) {
this.logger.warn(
`Failed to get core/stats for job ${jobId}, group ${group}: ${groupError}`
);
}
if ((isActivelyTransferring || isActivelyChecking || hasActiveSpeed) && isNotFinished) {
jobs.push(job);
}
});
const enhancedStats = {
...jobStatus.stats,
...detailedStats,
};
this.logger.debug(
`Found ${jobs.length} active backup jobs from ${backupGroups.length} groups`
);
return jobs;
} catch (error) {
this.logger.error('Failed to get jobs with stats:', error);
return [];
}
}
const finalStats = this.enhanceStatsWithFormattedFields(enhancedStats);
async stopAllJobs(): Promise<JobOperationResult> {
const runningJobs = await this.listRunningJobs();
allJobs.push({
jobId,
stats: finalStats,
});
successfulJobQueries++;
} catch (error) {
this.logger.error(`Failed to get status for job ${jobId}: ${error}`);
}
if (!runningJobs.jobids?.length) {
this.logger.log('No running jobs to stop');
return { stopped: [], errors: [] };
}
this.logger.log(
`Successfully queried ${successfulJobQueries}/${jobList.jobids.length} jobs for detailed stats`
this.logger.log(`Stopping ${runningJobs.jobids.length} running jobs`);
return this.executeJobOperation(runningJobs.jobids, 'stop');
}
private async executeJobOperation(
jobIds: (string | number)[],
operation: 'stop'
): Promise<JobOperationResult> {
const stopped: string[] = [];
const errors: string[] = [];
const promises = jobIds.map(async (jobId) => {
try {
await this.callRcloneApi(`job/${operation}`, { jobid: jobId });
stopped.push(String(jobId));
this.logger.log(`${operation}ped job: ${jobId}`);
} catch (error) {
const errorMsg = `Failed to ${operation} job ${jobId}: ${error}`;
errors.push(errorMsg);
this.logger.error(errorMsg);
}
});
await Promise.allSettled(promises);
return { stopped, errors };
}
async getBackupStatus(): Promise<BackupStatusResult> {
const runningJobs = await this.listRunningJobs();
if (!runningJobs.jobids?.length) {
return this.statusService.parseBackupStatus(runningJobs, []);
}
const jobStatuses = await Promise.allSettled(
runningJobs.jobids.map((jobId) => this.getIndividualJobStatus(String(jobId)))
);
const result: RCloneJobsWithStatsResponse = {
jobids: allJobs.map((job) => job.jobId),
stats: allJobs.map((job) => job.stats),
};
return result;
return this.statusService.parseBackupStatus(runningJobs, jobStatuses);
}
private enhanceStatsWithFormattedFields(stats: RCloneJobStats): RCloneJobStats {
const enhancedStats = { ...stats };
if (stats.bytes !== undefined && stats.bytes !== null) {
enhancedStats.formattedBytes = this.formatService.formatBytes(stats.bytes);
}
if (stats.speed !== undefined && stats.speed !== null && stats.speed > 0) {
enhancedStats.formattedSpeed = this.formatService.formatBytes(stats.speed);
}
if (stats.elapsedTime !== undefined && stats.elapsedTime !== null) {
enhancedStats.formattedElapsedTime = this.formatService.formatDuration(stats.elapsedTime);
}
if (stats.eta !== undefined && stats.eta !== null && stats.eta > 0) {
enhancedStats.formattedEta = this.formatService.formatDuration(stats.eta);
}
return enhancedStats;
}
private async callRcloneApi(endpoint: string, params: Record<string, any> = {}): Promise<any> {
private async callRcloneApi(endpoint: string, params: Record<string, unknown> = {}): Promise<any> {
const url = `${this.rcloneBaseUrl}/${endpoint}`;
const finalParams = { _async: false, ...params };
try {
const response = await got.post(url, {
json: params,
json: finalParams,
responseType: 'json',
enableUnixSockets: true,
headers: {
@@ -469,59 +537,113 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
return response.body;
} catch (error: unknown) {
this.handleApiError(error, endpoint, params);
this.handleApiError(error, endpoint, finalParams);
}
}
private handleApiError(error: unknown, endpoint: string, params: Record<string, unknown>): never {
const sanitizedParams = sanitizeParams(params);
if (error instanceof HTTPError) {
const statusCode = error.response.statusCode;
const rcloneError = this.extractRcloneError(error.response.body, params);
const detailedErrorMessage = `Rclone API Error (${endpoint}, HTTP ${statusCode}): ${rcloneError}`;
const message = `Rclone API Error (${endpoint}, HTTP ${statusCode}): ${rcloneError}`;
const sanitizedParams = sanitizeParams(params);
this.logger.error(
`Original ${detailedErrorMessage} | Params: ${JSON.stringify(sanitizedParams)}`,
error.stack
);
throw new Error(detailedErrorMessage);
} else if (error instanceof Error) {
const detailedErrorMessage = `Error calling RClone API (${endpoint}) with params ${JSON.stringify(sanitizeParams(params))}: ${error.message}`;
this.logger.error(detailedErrorMessage, error.stack);
throw error;
} else {
const detailedErrorMessage = `Unknown error calling RClone API (${endpoint}) with params ${JSON.stringify(sanitizeParams(params))}: ${String(error)}`;
this.logger.error(detailedErrorMessage);
throw new Error(detailedErrorMessage);
this.logger.error(`${message} | Params: ${JSON.stringify(sanitizedParams)}`, error.stack);
throw new Error(message);
}
const message =
error instanceof Error
? `Error calling RClone API (${endpoint}): ${error.message}`
: `Unknown error calling RClone API (${endpoint}): ${String(error)}`;
this.logger.error(
`${message} | Params: ${JSON.stringify(sanitizedParams)}`,
error instanceof Error ? error.stack : undefined
);
throw error instanceof Error ? error : new Error(message);
}
private extractRcloneError(responseBody: unknown, fallbackParams: Record<string, unknown>): string {
try {
let errorBody: unknown;
if (typeof responseBody === 'string') {
errorBody = JSON.parse(responseBody);
} else if (typeof responseBody === 'object' && responseBody !== null) {
errorBody = responseBody;
}
const errorBody = typeof responseBody === 'string' ? JSON.parse(responseBody) : responseBody;
if (errorBody && typeof errorBody === 'object' && 'error' in errorBody) {
const typedErrorBody = errorBody as { error: unknown; input?: unknown };
let rcloneError = `Rclone Error: ${String(typedErrorBody.error)}`;
if (typedErrorBody.input) {
rcloneError += ` | Input: ${JSON.stringify(typedErrorBody.input)}`;
} else if (fallbackParams) {
rcloneError += ` | Original Params: ${JSON.stringify(fallbackParams)}`;
const typedError = errorBody as { error: unknown; input?: unknown };
let message = `Rclone Error: ${String(typedError.error)}`;
if (typedError.input) {
message += ` | Input: ${JSON.stringify(typedError.input)}`;
} else {
message += ` | Params: ${JSON.stringify(fallbackParams)}`;
}
return rcloneError;
} else if (responseBody) {
return `Non-standard error response body: ${typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)}`;
} else {
return 'Empty error response body received.';
return message;
}
} catch (parseOrAccessError) {
return `Failed to process error response body. Raw body: ${typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)}`;
return responseBody
? `Non-standard error response: ${typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)}`
: 'Empty error response received';
} catch {
return `Failed to process error response: ${typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)}`;
}
}
private async killExistingRcloneProcesses(): Promise<void> {
try {
this.logger.log('Checking for existing rclone processes...');
const { stdout } = await execa('pgrep', ['-f', 'rclone.*rcd'], { reject: false });
if (!stdout.trim()) {
this.logger.log('No existing rclone processes found');
return;
}
const pids = stdout
.trim()
.split('\n')
.filter((pid) => pid.trim());
this.logger.log(`Found ${pids.length} existing rclone process(es): ${pids.join(', ')}`);
await this.terminateProcesses(pids);
await this.cleanupStaleSocket();
} catch (error) {
this.logger.warn(`Error during rclone process cleanup: ${error}`);
}
}
private async terminateProcesses(pids: string[]): Promise<void> {
for (const pid of pids) {
try {
this.logger.log(`Terminating rclone process PID: ${pid}`);
await execa('kill', ['-TERM', pid], { reject: false });
await new Promise((resolve) =>
setTimeout(resolve, CONSTANTS.TIMEOUTS.GRACEFUL_SHUTDOWN)
);
const { exitCode } = await execa('kill', ['-0', pid], { reject: false });
if (exitCode === 0) {
this.logger.warn(`Process ${pid} still running, using SIGKILL`);
await execa('kill', ['-KILL', pid], { reject: false });
await new Promise((resolve) =>
setTimeout(resolve, CONSTANTS.TIMEOUTS.PROCESS_CLEANUP)
);
}
this.logger.log(`Successfully terminated process ${pid}`);
} catch (error) {
this.logger.warn(`Failed to kill process ${pid}: ${error}`);
}
}
}
private async cleanupStaleSocket(): Promise<void> {
if (this.rcloneSocketPath && existsSync(this.rcloneSocketPath)) {
await rm(this.rcloneSocketPath, { force: true });
this.logger.log('Removed stale socket file');
}
}
}

View File

@@ -0,0 +1,432 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { RCloneStatusService } from '@app/unraid-api/graph/resolvers/rclone/rclone-status.service.js';
import { RCloneJobStats } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
import { FormatService } from '@app/unraid-api/utils/format.service.js';
// Mock NestJS Logger to suppress logs during tests
vi.mock('@nestjs/common', async (importOriginal) => {
const original = await importOriginal<typeof import('@nestjs/common')>();
return {
...original,
Logger: vi.fn(() => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
})),
};
});
describe('RCloneStatusService', () => {
let service: RCloneStatusService;
let mockFormatService: FormatService;
beforeEach(() => {
vi.clearAllMocks();
mockFormatService = {
formatBytes: vi.fn().mockImplementation((bytes: number) => `${bytes} B`),
formatSpeed: vi.fn().mockImplementation((bytesPerSecond: number) => `${bytesPerSecond} B/s`),
formatDuration: vi.fn().mockImplementation((seconds: number) => `${seconds}s`),
} as any;
service = new RCloneStatusService(mockFormatService);
});
describe('enhanceStatsWithFormattedFields', () => {
it('should add formatted fields for all numeric stats', () => {
const stats: RCloneJobStats = {
bytes: 1024,
speed: 512,
elapsedTime: 60,
eta: 120,
};
const result = service.enhanceStatsWithFormattedFields(stats);
expect(result).toEqual({
bytes: 1024,
speed: 512,
elapsedTime: 60,
eta: 120,
formattedBytes: '1024 B',
formattedSpeed: '512 B/s',
formattedElapsedTime: '60s',
formattedEta: '120s',
});
expect(mockFormatService.formatBytes).toHaveBeenCalledWith(1024);
expect(mockFormatService.formatSpeed).toHaveBeenCalledWith(512);
expect(mockFormatService.formatDuration).toHaveBeenCalledWith(60);
expect(mockFormatService.formatDuration).toHaveBeenCalledWith(120);
});
it('should not add formatted fields for undefined values', () => {
const stats: RCloneJobStats = {
bytes: undefined,
speed: undefined,
elapsedTime: undefined,
eta: undefined,
};
const result = service.enhanceStatsWithFormattedFields(stats);
expect(result).toEqual(stats);
expect(mockFormatService.formatBytes).not.toHaveBeenCalled();
expect(mockFormatService.formatDuration).not.toHaveBeenCalled();
});
it('should not add formatted fields for null values', () => {
const stats: RCloneJobStats = {
bytes: null as any,
speed: null as any,
elapsedTime: null as any,
eta: null as any,
};
const result = service.enhanceStatsWithFormattedFields(stats);
expect(result).toEqual(stats);
expect(mockFormatService.formatBytes).not.toHaveBeenCalled();
expect(mockFormatService.formatDuration).not.toHaveBeenCalled();
});
it('should not add formatted speed for zero speed', () => {
const stats: RCloneJobStats = {
speed: 0,
};
const result = service.enhanceStatsWithFormattedFields(stats);
expect(result).toEqual({ speed: 0 });
expect(mockFormatService.formatSpeed).not.toHaveBeenCalled();
});
it('should not add formatted eta for zero eta', () => {
const stats: RCloneJobStats = {
eta: 0,
};
const result = service.enhanceStatsWithFormattedFields(stats);
expect(result).toEqual({ eta: 0 });
expect(mockFormatService.formatDuration).not.toHaveBeenCalled();
});
});
describe('transformStatsToJob', () => {
it('should create RCloneJob with completed status when transfers match total', () => {
const stats: RCloneJobStats = {
group: 'unraid-backup',
fatalError: false,
transfers: 5,
totalTransfers: 5,
errors: 0,
percentage: 100,
};
const result = service.transformStatsToJob('123', stats);
expect(result).toEqual({
id: '123',
group: 'unraid-backup',
stats,
finished: true,
success: true,
error: undefined,
progressPercentage: 100,
detailedStatus: 'Completed',
});
});
it('should create RCloneJob with running status when transfers incomplete', () => {
const stats: RCloneJobStats = {
group: 'unraid-backup',
fatalError: false,
transfers: 3,
totalTransfers: 5,
errors: 0,
percentage: 60,
};
const result = service.transformStatsToJob('123', stats);
expect(result).toEqual({
id: '123',
group: 'unraid-backup',
stats,
finished: false,
success: true,
error: undefined,
progressPercentage: 60,
detailedStatus: 'Running',
});
});
it('should create RCloneJob with error status when lastError exists', () => {
const stats: RCloneJobStats = {
group: 'unraid-backup',
fatalError: false,
transfers: 0,
totalTransfers: 5,
errors: 1,
percentage: 0,
lastError: 'Connection timeout',
};
const result = service.transformStatsToJob('123', stats);
expect(result).toEqual({
id: '123',
group: 'unraid-backup',
stats,
finished: false,
success: false,
error: 'Connection timeout',
progressPercentage: 0,
detailedStatus: 'Error',
});
});
it('should handle numeric job ID', () => {
const stats: RCloneJobStats = {
fatalError: false,
transfers: 0,
totalTransfers: 0,
};
const result = service.transformStatsToJob(456, stats);
expect(result.id).toBe('456');
});
it('should handle missing group', () => {
const stats: RCloneJobStats = {
fatalError: false,
transfers: 0,
totalTransfers: 0,
};
const result = service.transformStatsToJob('123', stats);
expect(result.group).toBeUndefined();
});
});
describe('calculateCombinedStats', () => {
it('should combine stats from multiple jobs', () => {
const mockActiveJobs = [
{
stats: {
bytes: 1024,
checks: 2,
transfers: 3,
totalBytes: 2048,
totalChecks: 4,
totalTransfers: 6,
speed: 100,
eta: 120,
},
},
{
stats: {
bytes: 512,
checks: 1,
transfers: 2,
totalBytes: 1024,
totalChecks: 2,
totalTransfers: 4,
speed: 200,
eta: 60,
},
},
];
const result = service.calculateCombinedStats(mockActiveJobs);
expect(result).toEqual({
bytes: 1536,
checks: 3,
transfers: 5,
totalBytes: 3072,
totalChecks: 6,
totalTransfers: 10,
speed: 200, // Max speed
eta: 120, // Max eta
});
});
it('should return null for empty jobs array', () => {
const result = service.calculateCombinedStats([]);
expect(result).toBeNull();
});
it('should return null when no valid stats', () => {
const mockActiveJobs = [{ stats: null as any }, { stats: undefined as any }];
const result = service.calculateCombinedStats(mockActiveJobs);
expect(result).toBeNull();
});
});
describe('parseActiveJobs', () => {
it('should return active jobs that are not finished', () => {
const mockJobStatuses = [
{ status: 'fulfilled', value: { id: '1', finished: false } },
{ status: 'fulfilled', value: { id: '2', finished: true } },
{ status: 'rejected', reason: 'Error' },
] as PromiseSettledResult<any>[];
const result = service.parseActiveJobs(mockJobStatuses);
expect(result).toEqual([{ id: '1', finished: false }]);
});
it('should return empty array when all jobs are finished', () => {
const mockJobStatuses = [
{ status: 'fulfilled', value: { id: '1', finished: true } },
] as PromiseSettledResult<any>[];
const result = service.parseActiveJobs(mockJobStatuses);
expect(result).toEqual([]);
});
});
describe('parseBackupStatus', () => {
it('should return running status when active jobs exist', () => {
const mockRunningJobs = { jobids: ['123', '456'] };
const mockJobStatuses = [
{ status: 'fulfilled', value: { id: '123', finished: false, stats: { bytes: 1024 } } },
{ status: 'fulfilled', value: { id: '456', finished: false, stats: { bytes: 512 } } },
] as PromiseSettledResult<any>[];
const result = service.parseBackupStatus(mockRunningJobs, mockJobStatuses);
expect(result).toEqual({
isRunning: true,
stats: expect.objectContaining({ bytes: 1536 }),
jobCount: 2,
activeJobs: expect.arrayContaining([
expect.objectContaining({ id: '123', finished: false }),
expect.objectContaining({ id: '456', finished: false }),
]),
});
});
it('should return not running when no job IDs', () => {
const mockRunningJobs = { jobids: [] };
const mockJobStatuses = [] as PromiseSettledResult<any>[];
const result = service.parseBackupStatus(mockRunningJobs, mockJobStatuses);
expect(result).toEqual({
isRunning: false,
stats: null,
jobCount: 0,
activeJobs: [],
});
});
});
describe('parseJobWithStats', () => {
it('should parse job with enhanced stats', () => {
const mockJobStatus = {
stats: { bytes: 1024, speed: 512 },
};
const result = service.parseJobWithStats('123', mockJobStatus);
expect(result).toEqual(
expect.objectContaining({
id: '123',
stats: expect.objectContaining({
bytes: 1024,
speed: 512,
formattedBytes: '1024 B',
formattedSpeed: '512 B/s',
}),
})
);
});
it('should handle missing stats', () => {
const mockJobStatus = {};
const result = service.parseJobWithStats('123', mockJobStatus);
expect(result.id).toBe('123');
expect(result.stats).toEqual({});
});
});
describe('parseAllJobsWithStats', () => {
it('should return jobs when job IDs exist', () => {
const mockRunningJobs = { jobids: ['123', '456'] };
const mockJobs = [
{ id: '123', group: 'unraid-backup' },
{ id: '456', group: 'unraid-backup' },
] as any[];
const result = service.parseAllJobsWithStats(mockRunningJobs, mockJobs);
expect(result).toEqual(mockJobs);
});
it('should return empty array when no job IDs', () => {
const mockRunningJobs = { jobids: [] };
const mockJobs = [] as any[];
const result = service.parseAllJobsWithStats(mockRunningJobs, mockJobs);
expect(result).toEqual([]);
});
});
describe('parseJobsWithStats', () => {
it('should parse fulfilled job statuses', () => {
const mockJobStatuses = [
{ status: 'fulfilled', value: { id: '123', stats: { bytes: 1024 } } },
{ status: 'fulfilled', value: { id: '456', stats: { bytes: 512 } } },
{ status: 'rejected', reason: 'Error' },
] as PromiseSettledResult<any>[];
const result = service.parseJobsWithStats(mockJobStatuses);
expect(result).toHaveLength(2);
expect(result[0]).toEqual(
expect.objectContaining({
id: '123',
stats: expect.objectContaining({ bytes: 1024, formattedBytes: '1024 B' }),
})
);
expect(result[1]).toEqual(
expect.objectContaining({
id: '456',
stats: expect.objectContaining({ bytes: 512, formattedBytes: '512 B' }),
})
);
});
it('should handle rejected statuses gracefully', () => {
const mockJobStatuses = [
{ status: 'rejected', reason: 'Error' },
] as PromiseSettledResult<any>[];
const result = service.parseJobsWithStats(mockJobStatuses);
expect(result).toEqual([]);
});
});
describe('getBackupStatus', () => {
it('should return default backup status', () => {
const result = service.getBackupStatus();
expect(result).toEqual({
isRunning: false,
stats: null,
jobCount: 0,
});
});
});
});

View File

@@ -0,0 +1,204 @@
import { Injectable, Logger } from '@nestjs/common';
import {
RCloneJob,
RCloneJobListResponse,
RCloneJobStats,
RCloneJobWithStats,
} from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
import { FormatService } from '@app/unraid-api/utils/format.service.js';
// Internal interface for job status response from RClone API
interface RCloneJobStatusResponse {
id?: string | number;
group?: string;
stats?: RCloneJobStats;
finished?: boolean;
error?: string;
[key: string]: any;
}
interface BackupStatusResult {
isRunning: boolean;
stats: RCloneJobStats | null;
jobCount: number;
activeJobs: RCloneJobStatusResponse[];
}
@Injectable()
export class RCloneStatusService {
private readonly logger = new Logger(RCloneStatusService.name);
constructor(private readonly formatService: FormatService) {}
enhanceStatsWithFormattedFields(stats: RCloneJobStats): RCloneJobStats {
const enhancedStats = { ...stats };
if (stats.bytes !== undefined && stats.bytes !== null) {
enhancedStats.formattedBytes = this.formatService.formatBytes(stats.bytes);
}
if (stats.speed !== undefined && stats.speed !== null && stats.speed > 0) {
enhancedStats.formattedSpeed = this.formatService.formatSpeed(stats.speed);
}
if (stats.elapsedTime !== undefined && stats.elapsedTime !== null) {
enhancedStats.formattedElapsedTime = this.formatService.formatDuration(stats.elapsedTime);
}
if (stats.eta !== undefined && stats.eta !== null && stats.eta > 0) {
enhancedStats.formattedEta = this.formatService.formatDuration(stats.eta);
}
return enhancedStats;
}
transformStatsToJob(jobId: string | number, stats: RCloneJobStats): RCloneJob {
this.logger.debug(`Stats for job ${jobId}: %o`, stats);
const group = stats.group || undefined;
this.logger.debug(`Processing job ${jobId}: group="${group}", stats: ${JSON.stringify(stats)}`);
return {
id: String(jobId),
group: group,
stats,
finished:
stats.fatalError === false &&
stats.transfers === (stats.totalTransfers || 0) &&
(stats.totalTransfers || 0) > 0,
success: stats.fatalError === false && (stats.errors || 0) === 0,
error: stats.lastError || undefined,
progressPercentage: stats.percentage,
detailedStatus: stats.lastError
? 'Error'
: stats.percentage === 100
? 'Completed'
: 'Running',
};
}
calculateCombinedStats(activeJobs: RCloneJobStatusResponse[]): RCloneJobStats | null {
if (activeJobs.length === 0) return null;
const validStats = activeJobs
.map((job) => job.stats)
.filter((stats): stats is RCloneJobStats => Boolean(stats));
if (validStats.length === 0) return null;
return validStats.reduce(
(combined, stats) => ({
bytes: (combined.bytes || 0) + (stats.bytes || 0),
checks: (combined.checks || 0) + (stats.checks || 0),
transfers: (combined.transfers || 0) + (stats.transfers || 0),
totalBytes: (combined.totalBytes || 0) + (stats.totalBytes || 0),
totalChecks: (combined.totalChecks || 0) + (stats.totalChecks || 0),
totalTransfers: (combined.totalTransfers || 0) + (stats.totalTransfers || 0),
speed: Math.max(combined.speed || 0, stats.speed || 0),
eta: Math.max(combined.eta || 0, stats.eta || 0),
}),
{} as RCloneJobStats
);
}
parseActiveJobs(
jobStatuses: PromiseSettledResult<RCloneJobStatusResponse>[]
): RCloneJobStatusResponse[] {
const activeJobs: RCloneJobStatusResponse[] = [];
this.logger.debug(`Job statuses: ${JSON.stringify(jobStatuses)}`);
jobStatuses.forEach((result, index) => {
if (result.status === 'fulfilled' && !result.value.finished) {
activeJobs.push(result.value);
} else if (result.status === 'rejected') {
this.logger.warn(`Failed to get status for job ${index}: ${result.reason}`);
}
});
return activeJobs;
}
parseBackupStatus(
runningJobs: RCloneJobListResponse,
jobStatuses: PromiseSettledResult<RCloneJobStatusResponse>[]
): BackupStatusResult {
if (!runningJobs.jobids?.length) {
return {
isRunning: false,
stats: null,
jobCount: 0,
activeJobs: [],
};
}
const activeJobs = this.parseActiveJobs(jobStatuses);
const combinedStats = this.calculateCombinedStats(activeJobs);
return {
isRunning: activeJobs.length > 0,
stats: combinedStats,
jobCount: activeJobs.length,
activeJobs,
};
}
parseJobWithStats(jobId: string, jobStatus: RCloneJobStatusResponse): RCloneJob {
const stats = jobStatus.stats ? this.enhanceStatsWithFormattedFields(jobStatus.stats) : {};
return this.transformStatsToJob(jobId, stats);
}
parseAllJobsWithStats(runningJobs: RCloneJobListResponse, jobs: RCloneJob[]): RCloneJob[] {
if (!runningJobs.jobids?.length) {
this.logger.log('No active jobs found in RClone');
return [];
}
this.logger.log(
`Found ${runningJobs.jobids.length} active jobs in RClone: [${runningJobs.jobids.join(', ')}]`
);
return jobs;
}
parseJobsWithStats(jobStatuses: PromiseSettledResult<RCloneJobStatusResponse>[]): RCloneJob[] {
const allJobs: RCloneJob[] = [];
jobStatuses.forEach((result, index) => {
if (result.status === 'fulfilled') {
const jobStatus = result.value;
const stats = jobStatus.stats
? this.enhanceStatsWithFormattedFields(jobStatus.stats)
: {};
const job = this.transformStatsToJob(jobStatus.id || index, stats);
allJobs.push(job);
} else {
this.logger.error(`Failed to get status for job ${index}: ${result.reason}`);
}
});
return allJobs;
}
getBackupStatus(): {
isRunning: boolean;
stats: RCloneJobStats | null;
jobCount: number;
} {
try {
return {
isRunning: false,
stats: null,
jobCount: 0,
};
} catch (error) {
this.logger.debug(`Error getting backup status: ${error}`);
return {
isRunning: false,
stats: null,
jobCount: 0,
};
}
}
}

View File

@@ -153,10 +153,13 @@ export class RCloneStartBackupInput {
@IsBoolean()
async?: boolean;
@Field(() => String, { nullable: true })
@Field(() => String, {
nullable: true,
description: 'Configuration ID for job grouping and identification',
})
@IsOptional()
@IsString()
group?: string;
configId?: string;
@Field(() => GraphQLJSON, { nullable: true })
@IsOptional()
@@ -336,20 +339,41 @@ export class RCloneJob {
error?: string;
}
@ObjectType()
export class RCloneJobStatusDto {
@Field(() => Number, { description: 'Job ID' })
id!: number;
@Field(() => String, { description: 'RClone group for the job' })
group!: string;
@Field(() => Boolean, { description: 'Whether the job is finished' })
finished!: boolean;
@Field(() => Boolean, { description: 'Whether the job was successful' })
success!: boolean;
@Field(() => String, { description: 'Error message if any' })
error!: string;
@Field(() => Number, { description: 'Job duration in seconds' })
duration!: number;
@Field(() => String, { description: 'Job start time in ISO format' })
startTime!: string;
@Field(() => String, { description: 'Job end time in ISO format' })
endTime!: string;
@Field(() => GraphQLJSON, { description: 'Job output data', nullable: true })
output?: Record<string, any>;
}
// API Response Types (for internal use)
export interface RCloneJobListResponse {
jobids: (string | number)[];
}
export interface RCloneJobStatusResponse {
group?: string;
finished?: boolean;
success?: boolean;
error?: string;
stats?: RCloneJobStats;
[key: string]: any;
}
export interface RCloneJobWithStats {
jobId: string | number;
stats: RCloneJobStats;

View File

@@ -2,19 +2,22 @@ import { Module } from '@nestjs/common';
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
import { RCloneFormService } from '@app/unraid-api/graph/resolvers/rclone/rclone-form.service.js';
import { RCloneStatusService } from '@app/unraid-api/graph/resolvers/rclone/rclone-status.service.js';
import { RCloneMutationsResolver } from '@app/unraid-api/graph/resolvers/rclone/rclone.mutation.resolver.js';
import { RCloneBackupSettingsResolver } from '@app/unraid-api/graph/resolvers/rclone/rclone.resolver.js';
import { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.service.js';
import { UtilsModule } from '@app/unraid-api/utils/utils.module.js';
@Module({
imports: [],
imports: [UtilsModule],
providers: [
RCloneService,
RCloneApiService,
RCloneStatusService,
RCloneFormService,
RCloneBackupSettingsResolver,
RCloneMutationsResolver,
],
exports: [RCloneService, RCloneApiService],
exports: [RCloneService, RCloneApiService, RCloneStatusService],
})
export class RCloneModule {}

View File

@@ -32,9 +32,11 @@ import { VmsService } from '@app/unraid-api/graph/resolvers/vms/vms.service.js';
import { ServicesResolver } from '@app/unraid-api/graph/services/services.resolver.js';
import { SharesResolver } from '@app/unraid-api/graph/shares/shares.resolver.js';
import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js';
import { UtilsModule } from '@app/unraid-api/utils/utils.module.js';
@Module({
imports: [
UtilsModule,
ArrayModule,
ApiKeyModule,
AuthModule,

View File

@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { FormatService } from '@app/unraid-api/utils/format.service.js';
@Global()
@Module({
providers: [FormatService],
exports: [FormatService],
})
export class UtilsModule {}

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { FormatService } from '@app/unraid-api/utils/format.service.js';
describe('FormatService', () => {
const service = new FormatService();
describe('formatBytes', () => {
it('should format zero bytes', () => {
expect(service.formatBytes(0)).toBe('0 B');
});
it('should format bytes to best unit', () => {
expect(service.formatBytes(1024)).toBe('1.02 KB');
expect(service.formatBytes(1048576)).toBe('1.05 MB');
expect(service.formatBytes(1073741824)).toBe('1.07 GB');
});
it('should format with decimals when needed', () => {
expect(service.formatBytes(1536)).toBe('1.54 KB');
expect(service.formatBytes(9636529)).toBe('9.64 MB');
});
});
describe('formatSpeed', () => {
it('should format zero speed', () => {
expect(service.formatSpeed(0)).toBe('0 B/s');
});
it('should format speed with /s suffix', () => {
expect(service.formatSpeed(1024)).toBe('1.02 KB/s');
expect(service.formatSpeed(1048576)).toBe('1.05 MB/s');
expect(service.formatSpeed(1073741824)).toBe('1.07 GB/s');
});
it('should format with decimals when needed', () => {
expect(service.formatSpeed(1536)).toBe('1.54 KB/s');
expect(service.formatSpeed(9636529.183648435)).toBe('9.64 MB/s');
});
});
describe('formatDuration', () => {
it('should format small durations in seconds', () => {
expect(service.formatDuration(30)).toBe('30s');
expect(service.formatDuration(45.5)).toBe('45.5s');
});
it('should format longer durations to best unit', () => {
expect(service.formatDuration(60)).toBe('60 s');
expect(service.formatDuration(3600)).toBe('60 min');
expect(service.formatDuration(86400)).toBe('24 h');
});
it('should format with decimals when needed', () => {
expect(service.formatDuration(90)).toBe('1.5 min');
expect(service.formatDuration(11.615060290966666 * 60)).toBe('11.62 min');
});
});
});

View File

@@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import { convert } from 'convert';
@Injectable()
export class FormatService {
formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const result = convert(bytes, 'bytes').to('best');
const value =
typeof result.quantity === 'number' ? Number(result.quantity.toFixed(2)) : result.quantity;
return `${value} ${result.unit}`;
}
formatSpeed(bytesPerSecond: number): string {
if (bytesPerSecond === 0) return '0 B/s';
const result = convert(bytesPerSecond, 'bytes').to('best');
const value =
typeof result.quantity === 'number' ? Number(result.quantity.toFixed(2)) : result.quantity;
return `${value} ${result.unit}/s`;
}
formatDuration(seconds: number): string {
if (seconds < 60) return `${Math.round(seconds * 100) / 100}s`;
const result = convert(seconds, 'seconds').to('best');
const value =
typeof result.quantity === 'number' ? Number(result.quantity.toFixed(2)) : result.quantity;
return `${value} ${result.unit}`;
}
}

View File

@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { FormatService } from '@app/unraid-api/utils/format.service.js';
@Global()
@Module({
providers: [FormatService],
exports: [FormatService],
})
export class UtilsModule {}

View File

@@ -0,0 +1,138 @@
<script setup lang="ts">
import type { BackupJobsQuery } from '~/composables/gql/graphql';
import { useFragment } from '~/composables/gql/fragment-masking';
import { BACKUP_STATS_FRAGMENT } from './backup-jobs.query';
import { computed } from 'vue';
interface Props {
job: NonNullable<BackupJobsQuery['backup']>['jobs'][0];
}
const props = defineProps<Props>();
const stats = useFragment(BACKUP_STATS_FRAGMENT, props.job.stats);
// Calculate percentage if it's null but we have bytes and totalBytes
const calculatedPercentage = computed(() => {
if (stats?.percentage !== null) {
return stats?.percentage;
}
if (stats?.bytes && stats?.totalBytes) {
return Math.round((stats.bytes / stats.totalBytes) * 100);
}
return null;
});
// Determine job status based on job properties
const jobStatus = computed(() => {
if (props.job.error) return 'error';
if (props.job.finished && props.job.success) return 'completed';
if (props.job.finished && !props.job.success) return 'failed';
return 'running';
});
const statusColor = computed(() => {
switch (jobStatus.value) {
case 'error':
case 'failed':
return 'red';
case 'completed':
return 'green';
case 'running':
default:
return 'blue';
}
});
const statusText = computed(() => {
switch (jobStatus.value) {
case 'error':
return 'Error';
case 'failed':
return 'Failed';
case 'completed':
return 'Completed';
case 'running':
default:
return 'Running';
}
});
</script>
<template>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 shadow-sm">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
<div class="w-3 h-3 bg-green-400 rounded-full animate-pulse"></div>
</div>
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
Backup Job
</h3>
<div class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
<p>Job ID: {{ job.id }}</p>
<p v-if="job.configId">Config ID: {{ job.configId }}</p>
<p v-if="job.group">Group: {{ job.group }}</p>
<p v-if="job.detailedStatus">Status: {{ job.detailedStatus }}</p>
<p v-if="job.error" class="text-red-600 dark:text-red-400">Error: {{ job.error }}</p>
</div>
</div>
</div>
<span
:class="`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusColor === 'green' ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400' : statusColor === 'red' ? 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400' : 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400'}`"
>
{{ statusText }}
</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-if="stats?.formattedBytes" class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Bytes Transferred</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{{ stats.formattedBytes }}
</dd>
</div>
<div v-if="stats?.transfers" class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Files Transferred</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{{ stats.transfers }}
</dd>
</div>
<div v-if="stats?.formattedSpeed" class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Transfer Speed</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{{ stats.formattedSpeed }}</dd>
</div>
<div v-if="stats?.formattedElapsedTime" class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Elapsed Time</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{{ stats.formattedElapsedTime }}
</dd>
</div>
<div v-if="stats?.formattedEta" class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">ETA</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{{ stats.formattedEta }}
</dd>
</div>
<div v-if="calculatedPercentage" class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Progress</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{{ calculatedPercentage }}%</dd>
</div>
</div>
<div v-if="calculatedPercentage" class="mt-4">
<div class="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
:style="{ width: `${calculatedPercentage}%` }"
></div>
</div>
</div>
</div>
</template>

View File

@@ -1,56 +1,72 @@
<script setup lang="ts">
import { ref, computed, onUnmounted } from 'vue';
import { useQuery, useMutation, useSubscription } from '@vue/apollo-composable';
import {
Switch,
Button,
Badge,
Spinner,
Sheet,
SheetContent,
SheetTitle
} from '@unraid/ui';
import { computed, ref } from 'vue';
import { useMutation, useQuery } from '@vue/apollo-composable';
import { useFragment } from '~/composables/gql/fragment-masking';
import {
import { Badge, Button, Sheet, SheetContent, SheetTitle, Spinner, Switch } from '@unraid/ui';
import {
BACKUP_JOB_CONFIGS_QUERY,
BACKUP_JOBS_QUERY,
BACKUP_STATS_FRAGMENT,
TOGGLE_BACKUP_JOB_CONFIG_MUTATION,
TRIGGER_BACKUP_JOB_MUTATION,
BACKUP_JOB_PROGRESS_SUBSCRIPTION
} from '~/components/Backup/backup-jobs.query';
import BackupJobConfigForm from '~/components/Backup/BackupJobConfigForm.vue';
const showConfigModal = ref(false);
const togglingJobs = ref<Set<string>>(new Set<string>());
const triggeringJobs = ref<Set<string>>(new Set<string>());
const activeJobId = ref<string | null>(null);
const { result, loading, error, refetch } = useQuery(BACKUP_JOB_CONFIGS_QUERY);
const { result, loading, error, refetch } = useQuery(BACKUP_JOB_CONFIGS_QUERY, {}, {
fetchPolicy: 'cache-and-network',
});
const { result: jobsResult, refetch: refetchJobs } = useQuery(BACKUP_JOBS_QUERY, {}, {
fetchPolicy: 'cache-and-network',
pollInterval: 5000, // Poll every 5 seconds for real-time updates
});
const { mutate: toggleJobConfig } = useMutation(TOGGLE_BACKUP_JOB_CONFIG_MUTATION);
const { mutate: triggerJob } = useMutation(TRIGGER_BACKUP_JOB_MUTATION);
const { result: progressResult } = useSubscription(
BACKUP_JOB_PROGRESS_SUBSCRIPTION,
{ jobId: activeJobId.value || '' },
{ enabled: computed(() => !!activeJobId.value) }
);
const backupConfigs = computed(() => result.value?.backup?.configs || []);
const runningJobs = computed(() => jobsResult.value?.backup?.jobs || []);
const currentJobProgress = computed(() => {
if (!progressResult.value?.backupJobProgress) return null;
const job = progressResult.value.backupJobProgress;
const percentage = job.stats?.percentage || 0;
return {
jobId: job.id,
percentage: Math.round(percentage),
transferredBytes: job.formattedBytes || '0 B',
speed: job.formattedSpeed || '0 B/s',
elapsedTime: job.formattedElapsedTime || '0s',
eta: job.formattedEta || 'Unknown'
};
// Match running jobs to configs and create combined data
const configsWithJobs = computed(() => {
return backupConfigs.value.map(config => {
// Find running job that matches this config using the configId field
const runningJob = runningJobs.value.find(job => job.configId === config.id);
let jobStats = null;
if (runningJob?.stats) {
const stats = useFragment(BACKUP_STATS_FRAGMENT, runningJob.stats);
// Calculate percentage if it's null but we have bytes and totalBytes
let calculatedPercentage = stats?.percentage;
if (calculatedPercentage === null && stats?.bytes && stats?.totalBytes) {
calculatedPercentage = Math.round((stats.bytes / stats.totalBytes) * 100);
}
jobStats = {
percentage: Math.round(calculatedPercentage || 0),
transferredBytes: stats?.formattedBytes || '0 B',
speed: stats?.formattedSpeed || '0 B/s',
elapsedTime: stats?.formattedElapsedTime || '0s',
eta: stats?.formattedEta || 'Unknown',
transfers: stats?.transfers || 0,
checks: stats?.checks || 0,
errors: stats?.errors || 0,
};
}
return {
...config,
runningJob,
jobStats,
isRunning: !!runningJob,
};
});
});
function formatDate(dateString: string): string {
@@ -64,9 +80,9 @@ function onConfigComplete() {
async function handleToggleJob(jobId: string) {
if (togglingJobs.value.has(jobId)) return;
togglingJobs.value.add(jobId);
try {
await toggleJobConfig({ id: jobId });
await refetch();
@@ -79,15 +95,17 @@ async function handleToggleJob(jobId: string) {
async function handleTriggerJob(jobId: string) {
if (triggeringJobs.value.has(jobId)) return;
triggeringJobs.value.add(jobId);
try {
const result = await triggerJob({ id: jobId });
if (result?.data?.backup?.triggerJob?.jobId) {
const backupJobId = result.data.backup.triggerJob.jobId;
activeJobId.value = backupJobId;
console.log('Backup job triggered:', result.data.backup.triggerJob);
// Wait a moment for the RClone API to register the job
await new Promise(resolve => setTimeout(resolve, 500));
// Refetch both configs and jobs to show the running job immediately
await Promise.all([refetch(), refetchJobs()]);
}
} catch (error) {
console.error('Failed to trigger backup job:', error);
@@ -95,73 +113,13 @@ async function handleTriggerJob(jobId: string) {
triggeringJobs.value.delete(jobId);
}
}
function stopProgressMonitoring() {
activeJobId.value = null;
}
// Cleanup on unmount
onUnmounted(() => {
activeJobId.value = null;
});
</script>
<template>
<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>
</div>
<!-- Progress monitoring banner -->
<div
v-if="currentJobProgress"
class="mb-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4"
>
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">
Backup in Progress
</h3>
<Button
variant="ghost"
size="icon"
@click="stopProgressMonitoring"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
<div class="mb-3">
<div class="flex justify-between text-sm text-blue-700 dark:text-blue-300 mb-1">
<span>{{ currentJobProgress.percentage }}% complete</span>
<span>{{ currentJobProgress.speed }}</span>
</div>
<div class="w-full bg-blue-200 dark:bg-blue-700 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
:style="{ width: `${currentJobProgress.percentage}%` }"
></div>
</div>
</div>
<div class="grid grid-cols-3 gap-4 text-sm text-blue-700 dark:text-blue-300">
<div>
<span class="font-medium">Transferred:</span> {{ currentJobProgress.transferredBytes }}
</div>
<div>
<span class="font-medium">Elapsed:</span> {{ currentJobProgress.elapsedTime }}
</div>
<div>
<span class="font-medium">ETA:</span> {{ currentJobProgress.eta }}
</div>
</div>
<Button variant="primary" @click="showConfigModal = true"> Add Backup Job </Button>
</div>
<div v-if="loading && !result" class="text-center py-8">
@@ -200,18 +158,13 @@ onUnmounted(() => {
<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="showConfigModal = true"> Create First Backup Job </Button>
</div>
<div v-else class="space-y-4">
<div
v-for="config in backupConfigs"
:key="config.id"
v-for="configWithJob in configsWithJobs"
:key="configWithJob.id"
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 shadow-sm"
>
<div class="flex items-center justify-between mb-4">
@@ -219,86 +172,135 @@ onUnmounted(() => {
<div class="flex-shrink-0">
<div
:class="[
'w-3 h-3 rounded-full',
config.enabled ? 'bg-green-400' : 'bg-gray-400',
currentJobProgress?.jobId === activeJobId ? 'animate-pulse' : ''
'w-3 h-3 rounded-full',
configWithJob.isRunning
? 'bg-blue-400 animate-pulse'
: configWithJob.enabled
? 'bg-green-400'
: 'bg-gray-400',
]"
></div>
</div>
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
{{ config.name }}
<span v-if="currentJobProgress?.jobId === activeJobId" class="text-sm text-blue-600 dark:text-blue-400 ml-2">
{{ configWithJob.name }}
<span
v-if="configWithJob.isRunning"
class="text-sm text-blue-600 dark:text-blue-400 ml-2"
>
(Running)
</span>
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ config.sourcePath }} {{ config.remoteName }}:{{ config.destinationPath }}
{{ configWithJob.sourcePath }} {{ configWithJob.remoteName }}:{{ configWithJob.destinationPath }}
</p>
</div>
</div>
<div class="flex items-center space-x-3">
<!-- Toggle Switch -->
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{ config.enabled ? 'Enabled' : 'Disabled' }}
{{ configWithJob.enabled ? 'Enabled' : 'Disabled' }}
</span>
<Switch
:checked="config.enabled"
:disabled="togglingJobs.has(config.id)"
@update:checked="() => handleToggleJob(config.id)"
:checked="configWithJob.enabled"
:disabled="togglingJobs.has(configWithJob.id) || configWithJob.isRunning"
@update:checked="() => handleToggleJob(configWithJob.id)"
/>
</div>
<!-- Run Now Button -->
<Button
:disabled="!config.enabled || triggeringJobs.has(config.id)"
:variant="config.enabled && !triggeringJobs.has(config.id) ? 'primary' : 'outline'"
:disabled="triggeringJobs.has(configWithJob.id) || configWithJob.isRunning"
:variant="!triggeringJobs.has(configWithJob.id) && !configWithJob.isRunning ? 'primary' : 'outline'"
size="sm"
@click="handleTriggerJob(config.id)"
@click="handleTriggerJob(configWithJob.id)"
>
<span v-if="triggeringJobs.has(config.id)" class="w-3 h-3 border border-white border-t-transparent rounded-full animate-spin mr-1"></span>
<span
v-if="triggeringJobs.has(configWithJob.id)"
class="w-3 h-3 border border-white border-t-transparent rounded-full animate-spin mr-1"
></span>
<svg v-else class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-6-4h8a2 2 0 012 2v8a2 2 0 01-2 2H8a2 2 0 01-2-2V8a2 2 0 012-2z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-6-4h8a2 2 0 012 2v8a2 2 0 01-2 2H8a2 2 0 01-2-2V8a2 2 0 012-2z"
/>
</svg>
{{ triggeringJobs.has(config.id) ? 'Starting...' : 'Run Now' }}
{{
configWithJob.isRunning
? 'Running'
: triggeringJobs.has(configWithJob.id)
? 'Starting...'
: 'Run Now'
}}
</Button>
<Badge
:variant="config.enabled ? 'green' : 'gray'"
<Badge
:variant="configWithJob.isRunning ? 'blue' : configWithJob.enabled ? 'green' : 'gray'"
size="sm"
>
{{ config.enabled ? 'Active' : 'Inactive' }}
{{ configWithJob.isRunning ? 'Running' : configWithJob.enabled ? 'Active' : 'Inactive' }}
</Badge>
</div>
</div>
<!-- Progress information for running jobs -->
<div
v-if="configWithJob.isRunning && configWithJob.jobStats"
class="mb-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4"
>
<div class="flex justify-between text-sm text-blue-700 dark:text-blue-300 mb-3">
<span class="font-medium">{{ configWithJob.jobStats.percentage }}% complete</span>
<span>{{ configWithJob.jobStats.speed }}</span>
</div>
<div class="w-full bg-blue-200 dark:bg-blue-700 rounded-full h-2 mb-3">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
:style="{ width: `${configWithJob.jobStats.percentage}%` }"
></div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm text-blue-700 dark:text-blue-300">
<div>
<span class="font-medium">Transferred:</span> {{ configWithJob.jobStats.transferredBytes }}
</div>
<div>
<span class="font-medium">Elapsed:</span> {{ configWithJob.jobStats.elapsedTime }}
</div>
<div>
<span class="font-medium">ETA:</span> {{ configWithJob.jobStats.eta }}
</div>
<div>
<span class="font-medium">Files:</span> {{ configWithJob.jobStats.transfers }}
</div>
</div>
</div>
<!-- Schedule and status information -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Schedule</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{{ config.schedule }}
{{ configWithJob.schedule }}
</dd>
</div>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Run</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{{ config.lastRunAt ? formatDate(config.lastRunAt) : 'Never' }}
{{ configWithJob.lastRunAt ? formatDate(configWithJob.lastRunAt) : 'Never' }}
</dd>
</div>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Status</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{{ config.lastRunStatus || 'Not run yet' }}
{{ configWithJob.isRunning ? 'Running' : configWithJob.lastRunStatus || 'Not run yet' }}
</dd>
</div>
</div>
</div>
</div>
<!-- Modal for adding new backup job -->
<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">

View File

@@ -4,15 +4,29 @@ import { useQuery } from '@vue/apollo-composable';
import { BACKUP_JOBS_QUERY } from './backup-jobs.query';
import BackupJobConfig from './BackupJobConfig.vue';
import BackupEntry from './BackupEntry.vue';
const showSystemJobs = ref(false);
const { result, loading, error, refetch } = useQuery(
BACKUP_JOBS_QUERY,
() => ({ showSystemJobs: showSystemJobs.value }),
);
const { result, loading, error, refetch } = useQuery(BACKUP_JOBS_QUERY, {}, {
fetchPolicy: 'cache-and-network',
pollInterval: 5000, // Poll every 5 seconds for real-time updates
});
const backupJobs = computed(() => result.value?.backup?.jobs || []);
const backupJobs = computed(() => {
const allJobs = result.value?.backup?.jobs || [];
if (showSystemJobs.value) {
return allJobs;
}
return allJobs.filter(job => !job.group || job.group.toLowerCase() !== 'system');
});
// Enhanced refresh function that forces a network request
const refreshJobs = async () => {
await refetch();
};
</script>
<template>
@@ -22,7 +36,7 @@ const backupJobs = computed(() => result.value?.backup?.jobs || []);
<button
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
:disabled="loading"
@click="() => refetch()"
@click="refreshJobs"
>
{{ loading ? 'Refreshing...' : 'Refresh' }}
</button>
@@ -83,79 +97,11 @@ const backupJobs = computed(() => result.value?.backup?.jobs || []);
</div>
<div v-else class="space-y-4">
<div
<BackupEntry
v-for="job in backupJobs"
:key="job.id"
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 shadow-sm"
>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
<div class="w-3 h-3 bg-green-400 rounded-full animate-pulse"></div>
</div>
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
{{ job.type || 'Backup Job' }}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Job ID: {{ job.id }}</p>
</div>
</div>
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400"
>
Running
</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-if="job.stats?.formattedBytes" class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Bytes Transferred</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{{ job.stats.formattedBytes }}
</dd>
</div>
<div v-if="job.stats?.transfers" class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Files Transferred</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{{ job.stats.transfers }}
</dd>
</div>
<div v-if="job.stats?.formattedSpeed" class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Transfer Speed</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{{ job.stats.formattedSpeed }}/s</dd>
</div>
<div v-if="job.stats?.formattedElapsedTime" class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Elapsed Time</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{{ job.stats.formattedElapsedTime }}
</dd>
</div>
<div v-if="job.stats?.formattedEta" class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">ETA</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{{ job.stats.formattedEta }}
</dd>
</div>
<div v-if="job.stats?.percentage" class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Progress</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{{ job.stats.percentage }}%</dd>
</div>
</div>
<div v-if="job.stats?.percentage" class="mt-4">
<div class="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
:style="{ width: `${job.stats.percentage}%` }"
></div>
</div>
</div>
</div>
:job="job"
/>
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@ import { graphql } from '~/composables/gql/gql';
export const BACKUP_STATS_FRAGMENT = graphql(/* GraphQL */ `
fragment BackupStats on BackupJobStats {
fragment BackupStats on RCloneJobStats {
bytes
speed
eta
@@ -30,20 +30,21 @@ export const BACKUP_STATS_FRAGMENT = graphql(/* GraphQL */ `
formattedSpeed
formattedElapsedTime
formattedEta
group
finished
success
error
}
`);
export const BACKUP_JOBS_QUERY = graphql(/* GraphQL */ `
query BackupJobs($showSystemJobs: Boolean) {
query BackupJobs {
backup {
id
jobs(showSystemJobs: $showSystemJobs) {
jobs {
id
group
configId
finished
success
error
detailedStatus
stats {
...BackupStats
}
@@ -57,6 +58,11 @@ export const BACKUP_JOB_QUERY = graphql(/* GraphQL */ `
backupJob(jobId: $jobId) {
id
group
configId
finished
success
error
detailedStatus
stats {
...BackupStats
}
@@ -80,6 +86,7 @@ export const BACKUP_JOB_CONFIGS_QUERY = graphql(/* GraphQL */ `
updatedAt
lastRunAt
lastRunStatus
currentJobId
}
}
}
@@ -108,6 +115,7 @@ export const CREATE_BACKUP_JOB_CONFIG_MUTATION = graphql(/* GraphQL */ `
enabled
createdAt
updatedAt
currentJobId
}
}
}
@@ -128,6 +136,7 @@ export const UPDATE_BACKUP_JOB_CONFIG_MUTATION = graphql(/* GraphQL */ `
updatedAt
lastRunAt
lastRunStatus
currentJobId
}
}
}
@@ -156,6 +165,7 @@ export const TOGGLE_BACKUP_JOB_CONFIG_MUTATION = graphql(/* GraphQL */ `
updatedAt
lastRunAt
lastRunStatus
currentJobId
}
}
}
@@ -187,7 +197,6 @@ export const BACKUP_JOB_PROGRESS_SUBSCRIPTION = graphql(/* GraphQL */ `
subscription BackupJobProgress($jobId: PrefixedID!) {
backupJobProgress(jobId: $jobId) {
id
type
stats {
...BackupStats
}

View File

@@ -20,17 +20,18 @@ type Documents = {
"\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n }\n }\n": typeof types.CreateApiKeyDocument,
"\n mutation DeleteApiKey($input: DeleteApiKeyInput!) {\n apiKey {\n delete(input: $input)\n }\n }\n": typeof types.DeleteApiKeyDocument,
"\n query ApiKeyMeta {\n apiKeyPossibleRoles\n apiKeyPossiblePermissions {\n resource\n actions\n }\n }\n": typeof types.ApiKeyMetaDocument,
"\n query BackupJobs($showSystemJobs: Boolean) {\n backup {\n id\n jobs(showSystemJobs: $showSystemJobs) {\n id\n group\n stats\n formattedBytes\n formattedSpeed\n formattedElapsedTime\n formattedEta\n }\n }\n }\n": typeof types.BackupJobsDocument,
"\n query BackupJob($jobId: PrefixedID!) {\n backupJob(jobId: $jobId) {\n id\n group\n stats\n }\n }\n": typeof types.BackupJobDocument,
"\n query BackupJobConfigs {\n backup {\n id\n configs {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n }\n }\n }\n": typeof types.BackupJobConfigsDocument,
"\n fragment BackupStats on RCloneJobStats {\n bytes\n speed\n eta\n elapsedTime\n percentage\n checks\n deletes\n errors\n fatalError\n lastError\n renames\n retryError\n serverSideCopies\n serverSideCopyBytes\n serverSideMoves\n serverSideMoveBytes\n totalBytes\n totalChecks\n totalTransfers\n transferTime\n transfers\n transferring\n checking\n formattedBytes\n formattedSpeed\n formattedElapsedTime\n formattedEta\n }\n": typeof types.BackupStatsFragmentDoc,
"\n query BackupJobs {\n backup {\n id\n jobs {\n id\n group\n configId\n finished\n success\n error\n detailedStatus\n stats {\n ...BackupStats\n }\n }\n }\n }\n": typeof types.BackupJobsDocument,
"\n query BackupJob($jobId: PrefixedID!) {\n backupJob(jobId: $jobId) {\n id\n group\n configId\n finished\n success\n error\n detailedStatus\n stats {\n ...BackupStats\n }\n }\n }\n": typeof types.BackupJobDocument,
"\n query BackupJobConfigs {\n backup {\n id\n configs {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n currentJobId\n }\n }\n }\n": typeof types.BackupJobConfigsDocument,
"\n query BackupJobConfigForm($input: BackupJobConfigFormInput) {\n backupJobConfigForm(input: $input) {\n id\n dataSchema\n uiSchema\n }\n }\n": typeof types.BackupJobConfigFormDocument,
"\n mutation CreateBackupJobConfig($input: CreateBackupJobConfigInput!) {\n backup {\n createBackupJobConfig(input: $input) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n }\n }\n }\n": typeof types.CreateBackupJobConfigDocument,
"\n mutation UpdateBackupJobConfig($id: String!, $input: UpdateBackupJobConfigInput!) {\n backup {\n updateBackupJobConfig(id: $id, input: $input) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n }\n }\n }\n": typeof types.UpdateBackupJobConfigDocument,
"\n mutation CreateBackupJobConfig($input: CreateBackupJobConfigInput!) {\n backup {\n createBackupJobConfig(input: $input) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n currentJobId\n }\n }\n }\n": typeof types.CreateBackupJobConfigDocument,
"\n mutation UpdateBackupJobConfig($id: String!, $input: UpdateBackupJobConfigInput!) {\n backup {\n updateBackupJobConfig(id: $id, input: $input) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n currentJobId\n }\n }\n }\n": typeof types.UpdateBackupJobConfigDocument,
"\n mutation DeleteBackupJobConfig($id: String!) {\n backup {\n deleteBackupJobConfig(id: $id)\n }\n }\n": typeof types.DeleteBackupJobConfigDocument,
"\n mutation ToggleBackupJobConfig($id: String!) {\n backup {\n toggleJobConfig(id: $id) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n }\n }\n }\n": typeof types.ToggleBackupJobConfigDocument,
"\n mutation ToggleBackupJobConfig($id: String!) {\n backup {\n toggleJobConfig(id: $id) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n currentJobId\n }\n }\n }\n": typeof types.ToggleBackupJobConfigDocument,
"\n mutation TriggerBackupJob($id: PrefixedID!) {\n backup {\n triggerJob(id: $id) {\n status\n jobId\n }\n }\n }\n": typeof types.TriggerBackupJobDocument,
"\n mutation InitiateBackup($input: InitiateBackupInput!) {\n backup {\n initiateBackup(input: $input) {\n status\n jobId\n }\n }\n }\n": typeof types.InitiateBackupDocument,
"\n subscription BackupJobProgress($jobId: PrefixedID!) {\n backupJobProgress(jobId: $jobId) {\n id\n type\n stats\n formattedBytes\n formattedSpeed\n formattedElapsedTime\n formattedEta\n }\n }\n": typeof types.BackupJobProgressDocument,
"\n subscription BackupJobProgress($jobId: PrefixedID!) {\n backupJobProgress(jobId: $jobId) {\n id\n stats {\n ...BackupStats\n }\n }\n }\n": typeof types.BackupJobProgressDocument,
"\n query GetConnectSettingsForm {\n connect {\n id\n settings {\n id\n dataSchema\n uiSchema\n values {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n ssoUserIds\n }\n }\n }\n }\n": typeof types.GetConnectSettingsFormDocument,
"\n mutation UpdateConnectSettings($input: ApiSettingsInput!) {\n updateApiSettings(input: $input) {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n ssoUserIds\n }\n }\n": typeof types.UpdateConnectSettingsDocument,
"\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n": typeof types.LogFilesDocument,
@@ -68,17 +69,18 @@ const documents: Documents = {
"\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n }\n }\n": types.CreateApiKeyDocument,
"\n mutation DeleteApiKey($input: DeleteApiKeyInput!) {\n apiKey {\n delete(input: $input)\n }\n }\n": types.DeleteApiKeyDocument,
"\n query ApiKeyMeta {\n apiKeyPossibleRoles\n apiKeyPossiblePermissions {\n resource\n actions\n }\n }\n": types.ApiKeyMetaDocument,
"\n query BackupJobs($showSystemJobs: Boolean) {\n backup {\n id\n jobs(showSystemJobs: $showSystemJobs) {\n id\n group\n stats\n formattedBytes\n formattedSpeed\n formattedElapsedTime\n formattedEta\n }\n }\n }\n": types.BackupJobsDocument,
"\n query BackupJob($jobId: PrefixedID!) {\n backupJob(jobId: $jobId) {\n id\n group\n stats\n }\n }\n": types.BackupJobDocument,
"\n query BackupJobConfigs {\n backup {\n id\n configs {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n }\n }\n }\n": types.BackupJobConfigsDocument,
"\n fragment BackupStats on RCloneJobStats {\n bytes\n speed\n eta\n elapsedTime\n percentage\n checks\n deletes\n errors\n fatalError\n lastError\n renames\n retryError\n serverSideCopies\n serverSideCopyBytes\n serverSideMoves\n serverSideMoveBytes\n totalBytes\n totalChecks\n totalTransfers\n transferTime\n transfers\n transferring\n checking\n formattedBytes\n formattedSpeed\n formattedElapsedTime\n formattedEta\n }\n": types.BackupStatsFragmentDoc,
"\n query BackupJobs {\n backup {\n id\n jobs {\n id\n group\n configId\n finished\n success\n error\n detailedStatus\n stats {\n ...BackupStats\n }\n }\n }\n }\n": types.BackupJobsDocument,
"\n query BackupJob($jobId: PrefixedID!) {\n backupJob(jobId: $jobId) {\n id\n group\n configId\n finished\n success\n error\n detailedStatus\n stats {\n ...BackupStats\n }\n }\n }\n": types.BackupJobDocument,
"\n query BackupJobConfigs {\n backup {\n id\n configs {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n currentJobId\n }\n }\n }\n": types.BackupJobConfigsDocument,
"\n query BackupJobConfigForm($input: BackupJobConfigFormInput) {\n backupJobConfigForm(input: $input) {\n id\n dataSchema\n uiSchema\n }\n }\n": types.BackupJobConfigFormDocument,
"\n mutation CreateBackupJobConfig($input: CreateBackupJobConfigInput!) {\n backup {\n createBackupJobConfig(input: $input) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n }\n }\n }\n": types.CreateBackupJobConfigDocument,
"\n mutation UpdateBackupJobConfig($id: String!, $input: UpdateBackupJobConfigInput!) {\n backup {\n updateBackupJobConfig(id: $id, input: $input) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n }\n }\n }\n": types.UpdateBackupJobConfigDocument,
"\n mutation CreateBackupJobConfig($input: CreateBackupJobConfigInput!) {\n backup {\n createBackupJobConfig(input: $input) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n currentJobId\n }\n }\n }\n": types.CreateBackupJobConfigDocument,
"\n mutation UpdateBackupJobConfig($id: String!, $input: UpdateBackupJobConfigInput!) {\n backup {\n updateBackupJobConfig(id: $id, input: $input) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n currentJobId\n }\n }\n }\n": types.UpdateBackupJobConfigDocument,
"\n mutation DeleteBackupJobConfig($id: String!) {\n backup {\n deleteBackupJobConfig(id: $id)\n }\n }\n": types.DeleteBackupJobConfigDocument,
"\n mutation ToggleBackupJobConfig($id: String!) {\n backup {\n toggleJobConfig(id: $id) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n }\n }\n }\n": types.ToggleBackupJobConfigDocument,
"\n mutation ToggleBackupJobConfig($id: String!) {\n backup {\n toggleJobConfig(id: $id) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n currentJobId\n }\n }\n }\n": types.ToggleBackupJobConfigDocument,
"\n mutation TriggerBackupJob($id: PrefixedID!) {\n backup {\n triggerJob(id: $id) {\n status\n jobId\n }\n }\n }\n": types.TriggerBackupJobDocument,
"\n mutation InitiateBackup($input: InitiateBackupInput!) {\n backup {\n initiateBackup(input: $input) {\n status\n jobId\n }\n }\n }\n": types.InitiateBackupDocument,
"\n subscription BackupJobProgress($jobId: PrefixedID!) {\n backupJobProgress(jobId: $jobId) {\n id\n type\n stats\n formattedBytes\n formattedSpeed\n formattedElapsedTime\n formattedEta\n }\n }\n": types.BackupJobProgressDocument,
"\n subscription BackupJobProgress($jobId: PrefixedID!) {\n backupJobProgress(jobId: $jobId) {\n id\n stats {\n ...BackupStats\n }\n }\n }\n": types.BackupJobProgressDocument,
"\n query GetConnectSettingsForm {\n connect {\n id\n settings {\n id\n dataSchema\n uiSchema\n values {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n ssoUserIds\n }\n }\n }\n }\n": types.GetConnectSettingsFormDocument,
"\n mutation UpdateConnectSettings($input: ApiSettingsInput!) {\n updateApiSettings(input: $input) {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n ssoUserIds\n }\n }\n": types.UpdateConnectSettingsDocument,
"\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n": types.LogFilesDocument,
@@ -151,15 +153,19 @@ export function graphql(source: "\n query ApiKeyMeta {\n apiKeyPossibleRoles
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query BackupJobs($showSystemJobs: Boolean) {\n backup {\n id\n jobs(showSystemJobs: $showSystemJobs) {\n id\n group\n stats\n formattedBytes\n formattedSpeed\n formattedElapsedTime\n formattedEta\n }\n }\n }\n"): (typeof documents)["\n query BackupJobs($showSystemJobs: Boolean) {\n backup {\n id\n jobs(showSystemJobs: $showSystemJobs) {\n id\n group\n stats\n formattedBytes\n formattedSpeed\n formattedElapsedTime\n formattedEta\n }\n }\n }\n"];
export function graphql(source: "\n fragment BackupStats on RCloneJobStats {\n bytes\n speed\n eta\n elapsedTime\n percentage\n checks\n deletes\n errors\n fatalError\n lastError\n renames\n retryError\n serverSideCopies\n serverSideCopyBytes\n serverSideMoves\n serverSideMoveBytes\n totalBytes\n totalChecks\n totalTransfers\n transferTime\n transfers\n transferring\n checking\n formattedBytes\n formattedSpeed\n formattedElapsedTime\n formattedEta\n }\n"): (typeof documents)["\n fragment BackupStats on RCloneJobStats {\n bytes\n speed\n eta\n elapsedTime\n percentage\n checks\n deletes\n errors\n fatalError\n lastError\n renames\n retryError\n serverSideCopies\n serverSideCopyBytes\n serverSideMoves\n serverSideMoveBytes\n totalBytes\n totalChecks\n totalTransfers\n transferTime\n transfers\n transferring\n checking\n formattedBytes\n formattedSpeed\n formattedElapsedTime\n formattedEta\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query BackupJob($jobId: PrefixedID!) {\n backupJob(jobId: $jobId) {\n id\n group\n stats\n }\n }\n"): (typeof documents)["\n query BackupJob($jobId: PrefixedID!) {\n backupJob(jobId: $jobId) {\n id\n group\n stats\n }\n }\n"];
export function graphql(source: "\n query BackupJobs {\n backup {\n id\n jobs {\n id\n group\n configId\n finished\n success\n error\n detailedStatus\n stats {\n ...BackupStats\n }\n }\n }\n }\n"): (typeof documents)["\n query BackupJobs {\n backup {\n id\n jobs {\n id\n group\n configId\n finished\n success\n error\n detailedStatus\n stats {\n ...BackupStats\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query BackupJobConfigs {\n backup {\n id\n configs {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n }\n }\n }\n"): (typeof documents)["\n query BackupJobConfigs {\n backup {\n id\n configs {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n }\n }\n }\n"];
export function graphql(source: "\n query BackupJob($jobId: PrefixedID!) {\n backupJob(jobId: $jobId) {\n id\n group\n configId\n finished\n success\n error\n detailedStatus\n stats {\n ...BackupStats\n }\n }\n }\n"): (typeof documents)["\n query BackupJob($jobId: PrefixedID!) {\n backupJob(jobId: $jobId) {\n id\n group\n configId\n finished\n success\n error\n detailedStatus\n stats {\n ...BackupStats\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query BackupJobConfigs {\n backup {\n id\n configs {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n currentJobId\n }\n }\n }\n"): (typeof documents)["\n query BackupJobConfigs {\n backup {\n id\n configs {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n currentJobId\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -167,11 +173,11 @@ export function graphql(source: "\n query BackupJobConfigForm($input: BackupJob
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation CreateBackupJobConfig($input: CreateBackupJobConfigInput!) {\n backup {\n createBackupJobConfig(input: $input) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n }\n }\n }\n"): (typeof documents)["\n mutation CreateBackupJobConfig($input: CreateBackupJobConfigInput!) {\n backup {\n createBackupJobConfig(input: $input) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n }\n }\n }\n"];
export function graphql(source: "\n mutation CreateBackupJobConfig($input: CreateBackupJobConfigInput!) {\n backup {\n createBackupJobConfig(input: $input) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n currentJobId\n }\n }\n }\n"): (typeof documents)["\n mutation CreateBackupJobConfig($input: CreateBackupJobConfigInput!) {\n backup {\n createBackupJobConfig(input: $input) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n currentJobId\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation UpdateBackupJobConfig($id: String!, $input: UpdateBackupJobConfigInput!) {\n backup {\n updateBackupJobConfig(id: $id, input: $input) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateBackupJobConfig($id: String!, $input: UpdateBackupJobConfigInput!) {\n backup {\n updateBackupJobConfig(id: $id, input: $input) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n }\n }\n }\n"];
export function graphql(source: "\n mutation UpdateBackupJobConfig($id: String!, $input: UpdateBackupJobConfigInput!) {\n backup {\n updateBackupJobConfig(id: $id, input: $input) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n currentJobId\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateBackupJobConfig($id: String!, $input: UpdateBackupJobConfigInput!) {\n backup {\n updateBackupJobConfig(id: $id, input: $input) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n currentJobId\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -179,7 +185,7 @@ export function graphql(source: "\n mutation DeleteBackupJobConfig($id: String!
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation ToggleBackupJobConfig($id: String!) {\n backup {\n toggleJobConfig(id: $id) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n }\n }\n }\n"): (typeof documents)["\n mutation ToggleBackupJobConfig($id: String!) {\n backup {\n toggleJobConfig(id: $id) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n }\n }\n }\n"];
export function graphql(source: "\n mutation ToggleBackupJobConfig($id: String!) {\n backup {\n toggleJobConfig(id: $id) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n currentJobId\n }\n }\n }\n"): (typeof documents)["\n mutation ToggleBackupJobConfig($id: String!) {\n backup {\n toggleJobConfig(id: $id) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n lastRunAt\n lastRunStatus\n currentJobId\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -191,7 +197,7 @@ export function graphql(source: "\n mutation InitiateBackup($input: InitiateBac
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n subscription BackupJobProgress($jobId: PrefixedID!) {\n backupJobProgress(jobId: $jobId) {\n id\n type\n stats\n formattedBytes\n formattedSpeed\n formattedElapsedTime\n formattedEta\n }\n }\n"): (typeof documents)["\n subscription BackupJobProgress($jobId: PrefixedID!) {\n backupJobProgress(jobId: $jobId) {\n id\n type\n stats\n formattedBytes\n formattedSpeed\n formattedElapsedTime\n formattedEta\n }\n }\n"];
export function graphql(source: "\n subscription BackupJobProgress($jobId: PrefixedID!) {\n backupJobProgress(jobId: $jobId) {\n id\n stats {\n ...BackupStats\n }\n }\n }\n"): (typeof documents)["\n subscription BackupJobProgress($jobId: PrefixedID!) {\n backupJobProgress(jobId: $jobId) {\n id\n stats {\n ...BackupStats\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

View File

@@ -377,46 +377,17 @@ export type Backup = Node & {
__typename?: 'Backup';
configs: Array<BackupJobConfig>;
id: Scalars['PrefixedID']['output'];
jobs: Array<BackupJob>;
jobs: Array<RCloneJob>;
/** Get the status for the backup service */
status: BackupStatus;
};
export type BackupJobsArgs = {
showSystemJobs?: InputMaybe<Scalars['Boolean']['input']>;
};
export type BackupJob = {
__typename?: 'BackupJob';
/** Configuration ID that triggered this job */
configId?: Maybe<Scalars['PrefixedID']['output']>;
/** Detailed status of the job */
detailedStatus?: Maybe<Scalars['String']['output']>;
/** Formatted bytes transferred */
formattedBytes?: Maybe<Scalars['String']['output']>;
/** Formatted elapsed time */
formattedElapsedTime?: Maybe<Scalars['String']['output']>;
/** Formatted ETA */
formattedEta?: Maybe<Scalars['String']['output']>;
/** Formatted transfer speed */
formattedSpeed?: Maybe<Scalars['String']['output']>;
/** RClone group for the job */
group?: Maybe<Scalars['String']['output']>;
/** Job ID */
id: Scalars['PrefixedID']['output'];
/** Progress percentage (0-100) */
progressPercentage?: Maybe<Scalars['Float']['output']>;
/** Job status and statistics */
stats: Scalars['JSON']['output'];
/** Job type (e.g., sync/copy) */
type: Scalars['String']['output'];
};
export type BackupJobConfig = Node & {
__typename?: 'BackupJobConfig';
/** When this config was created */
createdAt: Scalars['DateTime']['output'];
/** Current running job ID */
currentJobId?: Maybe<Scalars['String']['output']>;
/** Destination path on the remote */
destinationPath: Scalars['String']['output'];
/** Whether this backup job is enabled */
@@ -458,8 +429,12 @@ export type BackupMutations = {
createBackupJobConfig: BackupJobConfig;
/** Delete a backup job configuration */
deleteBackupJobConfig: Scalars['Boolean']['output'];
/** Forget all finished backup jobs to clean up the job list */
forgetFinishedBackupJobs: BackupStatus;
/** Initiates a backup using a configured remote. */
initiateBackup: BackupStatus;
/** Stop all running backup jobs */
stopAllBackupJobs: BackupStatus;
/** Toggle a backup job configuration enabled/disabled */
toggleJobConfig?: Maybe<BackupJobConfig>;
/** Manually trigger a backup job using existing configuration */
@@ -1349,7 +1324,7 @@ export type Query = {
/** Get backup service information */
backup: Backup;
/** Get status of a specific backup job */
backupJob?: Maybe<BackupJob>;
backupJob?: Maybe<RCloneJob>;
/** Get a specific backup job configuration */
backupJobConfig?: Maybe<BackupJobConfig>;
/** Get the JSON schema for backup job configuration form */
@@ -1455,6 +1430,86 @@ export type RCloneDrive = {
options: Scalars['JSON']['output'];
};
export type RCloneJob = {
__typename?: 'RCloneJob';
/** Configuration ID that triggered this job */
configId?: Maybe<Scalars['PrefixedID']['output']>;
/** Detailed status of the job */
detailedStatus?: Maybe<Scalars['String']['output']>;
/** Error message if job failed */
error?: Maybe<Scalars['String']['output']>;
/** Whether the job is finished */
finished?: Maybe<Scalars['Boolean']['output']>;
/** RClone group for the job */
group?: Maybe<Scalars['String']['output']>;
/** Job ID */
id: Scalars['PrefixedID']['output'];
/** Progress percentage (0-100) */
progressPercentage?: Maybe<Scalars['Float']['output']>;
/** Job status and statistics */
stats?: Maybe<RCloneJobStats>;
/** Whether the job was successful */
success?: Maybe<Scalars['Boolean']['output']>;
};
export type RCloneJobStats = {
__typename?: 'RCloneJobStats';
/** Bytes transferred */
bytes?: Maybe<Scalars['Float']['output']>;
/** Currently checking files */
checking?: Maybe<Scalars['JSON']['output']>;
/** Number of checks completed */
checks?: Maybe<Scalars['Float']['output']>;
/** Number of deletes completed */
deletes?: Maybe<Scalars['Float']['output']>;
/** Elapsed time in seconds */
elapsedTime?: Maybe<Scalars['Float']['output']>;
/** Number of errors encountered */
errors?: Maybe<Scalars['Float']['output']>;
/** Estimated time to completion in seconds */
eta?: Maybe<Scalars['Float']['output']>;
/** Whether a fatal error occurred */
fatalError?: Maybe<Scalars['Boolean']['output']>;
/** Human-readable bytes transferred */
formattedBytes?: Maybe<Scalars['String']['output']>;
/** Human-readable elapsed time */
formattedElapsedTime?: Maybe<Scalars['String']['output']>;
/** Human-readable ETA */
formattedEta?: Maybe<Scalars['String']['output']>;
/** Human-readable transfer speed */
formattedSpeed?: Maybe<Scalars['String']['output']>;
/** Last error message */
lastError?: Maybe<Scalars['String']['output']>;
/** Progress percentage (0-100) */
percentage?: Maybe<Scalars['Float']['output']>;
/** Number of renames completed */
renames?: Maybe<Scalars['Float']['output']>;
/** Whether there is a retry error */
retryError?: Maybe<Scalars['Boolean']['output']>;
/** Number of server-side copies */
serverSideCopies?: Maybe<Scalars['Float']['output']>;
/** Bytes in server-side copies */
serverSideCopyBytes?: Maybe<Scalars['Float']['output']>;
/** Bytes in server-side moves */
serverSideMoveBytes?: Maybe<Scalars['Float']['output']>;
/** Number of server-side moves */
serverSideMoves?: Maybe<Scalars['Float']['output']>;
/** Transfer speed in bytes/sec */
speed?: Maybe<Scalars['Float']['output']>;
/** Total bytes to transfer */
totalBytes?: Maybe<Scalars['Float']['output']>;
/** Total checks to perform */
totalChecks?: Maybe<Scalars['Float']['output']>;
/** Total transfers to perform */
totalTransfers?: Maybe<Scalars['Float']['output']>;
/** Time spent transferring in seconds */
transferTime?: Maybe<Scalars['Float']['output']>;
/** Currently transferring files */
transferring?: Maybe<Scalars['JSON']['output']>;
/** Number of transfers completed */
transfers?: Maybe<Scalars['Float']['output']>;
};
/** RClone related mutations */
export type RCloneMutations = {
__typename?: 'RCloneMutations';
@@ -1664,7 +1719,7 @@ export type Subscription = {
__typename?: 'Subscription';
arraySubscription: UnraidArray;
/** Subscribe to real-time backup job progress updates */
backupJobProgress?: Maybe<BackupJob>;
backupJobProgress?: Maybe<RCloneJob>;
displaySubscription: Display;
infoSubscription: Info;
logFile: LogFileContent;
@@ -1758,6 +1813,7 @@ export type UnraidArray = Node & {
export type UpdateBackupJobConfigInput = {
destinationPath?: InputMaybe<Scalars['String']['input']>;
enabled?: InputMaybe<Scalars['Boolean']['input']>;
lastRunStatus?: InputMaybe<Scalars['String']['input']>;
name?: InputMaybe<Scalars['String']['input']>;
rcloneOptions?: InputMaybe<Scalars['JSON']['input']>;
remoteName?: InputMaybe<Scalars['String']['input']>;
@@ -2127,24 +2183,30 @@ export type ApiKeyMetaQueryVariables = Exact<{ [key: string]: never; }>;
export type ApiKeyMetaQuery = { __typename?: 'Query', apiKeyPossibleRoles: Array<Role>, apiKeyPossiblePermissions: Array<{ __typename?: 'Permission', resource: Resource, actions: Array<string> }> };
export type BackupJobsQueryVariables = Exact<{
showSystemJobs?: InputMaybe<Scalars['Boolean']['input']>;
}>;
export type BackupStatsFragment = { __typename?: 'RCloneJobStats', bytes?: number | null, speed?: number | null, eta?: number | null, elapsedTime?: number | null, percentage?: number | null, checks?: number | null, deletes?: number | null, errors?: number | null, fatalError?: boolean | null, lastError?: string | null, renames?: number | null, retryError?: boolean | null, serverSideCopies?: number | null, serverSideCopyBytes?: number | null, serverSideMoves?: number | null, serverSideMoveBytes?: number | null, totalBytes?: number | null, totalChecks?: number | null, totalTransfers?: number | null, transferTime?: number | null, transfers?: number | null, transferring?: any | null, checking?: any | null, formattedBytes?: string | null, formattedSpeed?: string | null, formattedElapsedTime?: string | null, formattedEta?: string | null } & { ' $fragmentName'?: 'BackupStatsFragment' };
export type BackupJobsQueryVariables = Exact<{ [key: string]: never; }>;
export type BackupJobsQuery = { __typename?: 'Query', backup: { __typename?: 'Backup', id: string, jobs: Array<{ __typename?: 'BackupJob', id: string, group?: string | null, stats: any, formattedBytes?: string | null, formattedSpeed?: string | null, formattedElapsedTime?: string | null, formattedEta?: string | null }> } };
export type BackupJobsQuery = { __typename?: 'Query', backup: { __typename?: 'Backup', id: string, jobs: Array<{ __typename?: 'RCloneJob', id: string, group?: string | null, configId?: string | null, finished?: boolean | null, success?: boolean | null, error?: string | null, detailedStatus?: string | null, stats?: (
{ __typename?: 'RCloneJobStats' }
& { ' $fragmentRefs'?: { 'BackupStatsFragment': BackupStatsFragment } }
) | null }> } };
export type BackupJobQueryVariables = Exact<{
jobId: Scalars['PrefixedID']['input'];
}>;
export type BackupJobQuery = { __typename?: 'Query', backupJob?: { __typename?: 'BackupJob', id: string, group?: string | null, stats: any } | null };
export type BackupJobQuery = { __typename?: 'Query', backupJob?: { __typename?: 'RCloneJob', id: string, group?: string | null, configId?: string | null, finished?: boolean | null, success?: boolean | null, error?: string | null, detailedStatus?: string | null, stats?: (
{ __typename?: 'RCloneJobStats' }
& { ' $fragmentRefs'?: { 'BackupStatsFragment': BackupStatsFragment } }
) | null } | null };
export type BackupJobConfigsQueryVariables = Exact<{ [key: string]: never; }>;
export type BackupJobConfigsQuery = { __typename?: 'Query', backup: { __typename?: 'Backup', id: string, configs: Array<{ __typename?: 'BackupJobConfig', id: string, name: string, sourcePath: string, remoteName: string, destinationPath: string, schedule: string, enabled: boolean, createdAt: string, updatedAt: string, lastRunAt?: string | null, lastRunStatus?: string | null }> } };
export type BackupJobConfigsQuery = { __typename?: 'Query', backup: { __typename?: 'Backup', id: string, configs: Array<{ __typename?: 'BackupJobConfig', id: string, name: string, sourcePath: string, remoteName: string, destinationPath: string, schedule: string, enabled: boolean, createdAt: string, updatedAt: string, lastRunAt?: string | null, lastRunStatus?: string | null, currentJobId?: string | null }> } };
export type BackupJobConfigFormQueryVariables = Exact<{
input?: InputMaybe<BackupJobConfigFormInput>;
@@ -2158,7 +2220,7 @@ export type CreateBackupJobConfigMutationVariables = Exact<{
}>;
export type CreateBackupJobConfigMutation = { __typename?: 'Mutation', backup: { __typename?: 'BackupMutations', createBackupJobConfig: { __typename?: 'BackupJobConfig', id: string, name: string, sourcePath: string, remoteName: string, destinationPath: string, schedule: string, enabled: boolean, createdAt: string, updatedAt: string } } };
export type CreateBackupJobConfigMutation = { __typename?: 'Mutation', backup: { __typename?: 'BackupMutations', createBackupJobConfig: { __typename?: 'BackupJobConfig', id: string, name: string, sourcePath: string, remoteName: string, destinationPath: string, schedule: string, enabled: boolean, createdAt: string, updatedAt: string, currentJobId?: string | null } } };
export type UpdateBackupJobConfigMutationVariables = Exact<{
id: Scalars['String']['input'];
@@ -2166,7 +2228,7 @@ export type UpdateBackupJobConfigMutationVariables = Exact<{
}>;
export type UpdateBackupJobConfigMutation = { __typename?: 'Mutation', backup: { __typename?: 'BackupMutations', updateBackupJobConfig?: { __typename?: 'BackupJobConfig', id: string, name: string, sourcePath: string, remoteName: string, destinationPath: string, schedule: string, enabled: boolean, createdAt: string, updatedAt: string, lastRunAt?: string | null, lastRunStatus?: string | null } | null } };
export type UpdateBackupJobConfigMutation = { __typename?: 'Mutation', backup: { __typename?: 'BackupMutations', updateBackupJobConfig?: { __typename?: 'BackupJobConfig', id: string, name: string, sourcePath: string, remoteName: string, destinationPath: string, schedule: string, enabled: boolean, createdAt: string, updatedAt: string, lastRunAt?: string | null, lastRunStatus?: string | null, currentJobId?: string | null } | null } };
export type DeleteBackupJobConfigMutationVariables = Exact<{
id: Scalars['String']['input'];
@@ -2180,7 +2242,7 @@ export type ToggleBackupJobConfigMutationVariables = Exact<{
}>;
export type ToggleBackupJobConfigMutation = { __typename?: 'Mutation', backup: { __typename?: 'BackupMutations', toggleJobConfig?: { __typename?: 'BackupJobConfig', id: string, name: string, sourcePath: string, remoteName: string, destinationPath: string, schedule: string, enabled: boolean, createdAt: string, updatedAt: string, lastRunAt?: string | null, lastRunStatus?: string | null } | null } };
export type ToggleBackupJobConfigMutation = { __typename?: 'Mutation', backup: { __typename?: 'BackupMutations', toggleJobConfig?: { __typename?: 'BackupJobConfig', id: string, name: string, sourcePath: string, remoteName: string, destinationPath: string, schedule: string, enabled: boolean, createdAt: string, updatedAt: string, lastRunAt?: string | null, lastRunStatus?: string | null, currentJobId?: string | null } | null } };
export type TriggerBackupJobMutationVariables = Exact<{
id: Scalars['PrefixedID']['input'];
@@ -2201,7 +2263,10 @@ export type BackupJobProgressSubscriptionVariables = Exact<{
}>;
export type BackupJobProgressSubscription = { __typename?: 'Subscription', backupJobProgress?: { __typename?: 'BackupJob', id: string, type: string, stats: any, formattedBytes?: string | null, formattedSpeed?: string | null, formattedElapsedTime?: string | null, formattedEta?: string | null } | null };
export type BackupJobProgressSubscription = { __typename?: 'Subscription', backupJobProgress?: { __typename?: 'RCloneJob', id: string, stats?: (
{ __typename?: 'RCloneJobStats' }
& { ' $fragmentRefs'?: { 'BackupStatsFragment': BackupStatsFragment } }
) | null } | null };
export type GetConnectSettingsFormQueryVariables = Exact<{ [key: string]: never; }>;
@@ -2390,6 +2455,7 @@ export type SetupRemoteAccessMutationVariables = Exact<{
export type SetupRemoteAccessMutation = { __typename?: 'Mutation', setupRemoteAccess: boolean };
export const BackupStatsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BackupStats"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RCloneJobStats"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bytes"}},{"kind":"Field","name":{"kind":"Name","value":"speed"}},{"kind":"Field","name":{"kind":"Name","value":"eta"}},{"kind":"Field","name":{"kind":"Name","value":"elapsedTime"}},{"kind":"Field","name":{"kind":"Name","value":"percentage"}},{"kind":"Field","name":{"kind":"Name","value":"checks"}},{"kind":"Field","name":{"kind":"Name","value":"deletes"}},{"kind":"Field","name":{"kind":"Name","value":"errors"}},{"kind":"Field","name":{"kind":"Name","value":"fatalError"}},{"kind":"Field","name":{"kind":"Name","value":"lastError"}},{"kind":"Field","name":{"kind":"Name","value":"renames"}},{"kind":"Field","name":{"kind":"Name","value":"retryError"}},{"kind":"Field","name":{"kind":"Name","value":"serverSideCopies"}},{"kind":"Field","name":{"kind":"Name","value":"serverSideCopyBytes"}},{"kind":"Field","name":{"kind":"Name","value":"serverSideMoves"}},{"kind":"Field","name":{"kind":"Name","value":"serverSideMoveBytes"}},{"kind":"Field","name":{"kind":"Name","value":"totalBytes"}},{"kind":"Field","name":{"kind":"Name","value":"totalChecks"}},{"kind":"Field","name":{"kind":"Name","value":"totalTransfers"}},{"kind":"Field","name":{"kind":"Name","value":"transferTime"}},{"kind":"Field","name":{"kind":"Name","value":"transfers"}},{"kind":"Field","name":{"kind":"Name","value":"transferring"}},{"kind":"Field","name":{"kind":"Name","value":"checking"}},{"kind":"Field","name":{"kind":"Name","value":"formattedBytes"}},{"kind":"Field","name":{"kind":"Name","value":"formattedSpeed"}},{"kind":"Field","name":{"kind":"Name","value":"formattedElapsedTime"}},{"kind":"Field","name":{"kind":"Name","value":"formattedEta"}}]}}]} as unknown as DocumentNode<BackupStatsFragment, unknown>;
export const NotificationFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NotificationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Notification"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"importance"}},{"kind":"Field","name":{"kind":"Name","value":"link"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"formattedTimestamp"}}]}}]} as unknown as DocumentNode<NotificationFragmentFragment, unknown>;
export const NotificationCountFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NotificationCountFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NotificationCounts"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"total"}},{"kind":"Field","name":{"kind":"Name","value":"info"}},{"kind":"Field","name":{"kind":"Name","value":"warning"}},{"kind":"Field","name":{"kind":"Name","value":"alert"}}]}}]} as unknown as DocumentNode<NotificationCountFragmentFragment, unknown>;
export const PartialCloudFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PartialCloud"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Cloud"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cloud"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"minigraphql"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"relay"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]} as unknown as DocumentNode<PartialCloudFragment, unknown>;
@@ -2399,17 +2465,17 @@ export const ApiKeysDocument = {"kind":"Document","definitions":[{"kind":"Operat
export const CreateApiKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateApiKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateApiKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]}}]}}]} as unknown as DocumentNode<CreateApiKeyMutation, CreateApiKeyMutationVariables>;
export const DeleteApiKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteApiKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteApiKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"delete"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]} as unknown as DocumentNode<DeleteApiKeyMutation, DeleteApiKeyMutationVariables>;
export const ApiKeyMetaDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ApiKeyMeta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKeyPossibleRoles"}},{"kind":"Field","name":{"kind":"Name","value":"apiKeyPossiblePermissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<ApiKeyMetaQuery, ApiKeyMetaQueryVariables>;
export const BackupJobsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"BackupJobs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"showSystemJobs"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"backup"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"jobs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"showSystemJobs"},"value":{"kind":"Variable","name":{"kind":"Name","value":"showSystemJobs"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"group"}},{"kind":"Field","name":{"kind":"Name","value":"stats"}},{"kind":"Field","name":{"kind":"Name","value":"formattedBytes"}},{"kind":"Field","name":{"kind":"Name","value":"formattedSpeed"}},{"kind":"Field","name":{"kind":"Name","value":"formattedElapsedTime"}},{"kind":"Field","name":{"kind":"Name","value":"formattedEta"}}]}}]}}]}}]} as unknown as DocumentNode<BackupJobsQuery, BackupJobsQueryVariables>;
export const BackupJobDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"BackupJob"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"jobId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PrefixedID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"backupJob"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"jobId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"jobId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"group"}},{"kind":"Field","name":{"kind":"Name","value":"stats"}}]}}]}}]} as unknown as DocumentNode<BackupJobQuery, BackupJobQueryVariables>;
export const BackupJobConfigsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"BackupJobConfigs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"backup"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"configs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePath"}},{"kind":"Field","name":{"kind":"Name","value":"remoteName"}},{"kind":"Field","name":{"kind":"Name","value":"destinationPath"}},{"kind":"Field","name":{"kind":"Name","value":"schedule"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastRunAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastRunStatus"}}]}}]}}]}}]} as unknown as DocumentNode<BackupJobConfigsQuery, BackupJobConfigsQueryVariables>;
export const BackupJobsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"BackupJobs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"backup"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"jobs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"group"}},{"kind":"Field","name":{"kind":"Name","value":"configId"}},{"kind":"Field","name":{"kind":"Name","value":"finished"}},{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"detailedStatus"}},{"kind":"Field","name":{"kind":"Name","value":"stats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BackupStats"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BackupStats"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RCloneJobStats"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bytes"}},{"kind":"Field","name":{"kind":"Name","value":"speed"}},{"kind":"Field","name":{"kind":"Name","value":"eta"}},{"kind":"Field","name":{"kind":"Name","value":"elapsedTime"}},{"kind":"Field","name":{"kind":"Name","value":"percentage"}},{"kind":"Field","name":{"kind":"Name","value":"checks"}},{"kind":"Field","name":{"kind":"Name","value":"deletes"}},{"kind":"Field","name":{"kind":"Name","value":"errors"}},{"kind":"Field","name":{"kind":"Name","value":"fatalError"}},{"kind":"Field","name":{"kind":"Name","value":"lastError"}},{"kind":"Field","name":{"kind":"Name","value":"renames"}},{"kind":"Field","name":{"kind":"Name","value":"retryError"}},{"kind":"Field","name":{"kind":"Name","value":"serverSideCopies"}},{"kind":"Field","name":{"kind":"Name","value":"serverSideCopyBytes"}},{"kind":"Field","name":{"kind":"Name","value":"serverSideMoves"}},{"kind":"Field","name":{"kind":"Name","value":"serverSideMoveBytes"}},{"kind":"Field","name":{"kind":"Name","value":"totalBytes"}},{"kind":"Field","name":{"kind":"Name","value":"totalChecks"}},{"kind":"Field","name":{"kind":"Name","value":"totalTransfers"}},{"kind":"Field","name":{"kind":"Name","value":"transferTime"}},{"kind":"Field","name":{"kind":"Name","value":"transfers"}},{"kind":"Field","name":{"kind":"Name","value":"transferring"}},{"kind":"Field","name":{"kind":"Name","value":"checking"}},{"kind":"Field","name":{"kind":"Name","value":"formattedBytes"}},{"kind":"Field","name":{"kind":"Name","value":"formattedSpeed"}},{"kind":"Field","name":{"kind":"Name","value":"formattedElapsedTime"}},{"kind":"Field","name":{"kind":"Name","value":"formattedEta"}}]}}]} as unknown as DocumentNode<BackupJobsQuery, BackupJobsQueryVariables>;
export const BackupJobDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"BackupJob"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"jobId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PrefixedID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"backupJob"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"jobId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"jobId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"group"}},{"kind":"Field","name":{"kind":"Name","value":"configId"}},{"kind":"Field","name":{"kind":"Name","value":"finished"}},{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"detailedStatus"}},{"kind":"Field","name":{"kind":"Name","value":"stats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BackupStats"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BackupStats"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RCloneJobStats"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bytes"}},{"kind":"Field","name":{"kind":"Name","value":"speed"}},{"kind":"Field","name":{"kind":"Name","value":"eta"}},{"kind":"Field","name":{"kind":"Name","value":"elapsedTime"}},{"kind":"Field","name":{"kind":"Name","value":"percentage"}},{"kind":"Field","name":{"kind":"Name","value":"checks"}},{"kind":"Field","name":{"kind":"Name","value":"deletes"}},{"kind":"Field","name":{"kind":"Name","value":"errors"}},{"kind":"Field","name":{"kind":"Name","value":"fatalError"}},{"kind":"Field","name":{"kind":"Name","value":"lastError"}},{"kind":"Field","name":{"kind":"Name","value":"renames"}},{"kind":"Field","name":{"kind":"Name","value":"retryError"}},{"kind":"Field","name":{"kind":"Name","value":"serverSideCopies"}},{"kind":"Field","name":{"kind":"Name","value":"serverSideCopyBytes"}},{"kind":"Field","name":{"kind":"Name","value":"serverSideMoves"}},{"kind":"Field","name":{"kind":"Name","value":"serverSideMoveBytes"}},{"kind":"Field","name":{"kind":"Name","value":"totalBytes"}},{"kind":"Field","name":{"kind":"Name","value":"totalChecks"}},{"kind":"Field","name":{"kind":"Name","value":"totalTransfers"}},{"kind":"Field","name":{"kind":"Name","value":"transferTime"}},{"kind":"Field","name":{"kind":"Name","value":"transfers"}},{"kind":"Field","name":{"kind":"Name","value":"transferring"}},{"kind":"Field","name":{"kind":"Name","value":"checking"}},{"kind":"Field","name":{"kind":"Name","value":"formattedBytes"}},{"kind":"Field","name":{"kind":"Name","value":"formattedSpeed"}},{"kind":"Field","name":{"kind":"Name","value":"formattedElapsedTime"}},{"kind":"Field","name":{"kind":"Name","value":"formattedEta"}}]}}]} as unknown as DocumentNode<BackupJobQuery, BackupJobQueryVariables>;
export const BackupJobConfigsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"BackupJobConfigs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"backup"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"configs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePath"}},{"kind":"Field","name":{"kind":"Name","value":"remoteName"}},{"kind":"Field","name":{"kind":"Name","value":"destinationPath"}},{"kind":"Field","name":{"kind":"Name","value":"schedule"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastRunAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastRunStatus"}},{"kind":"Field","name":{"kind":"Name","value":"currentJobId"}}]}}]}}]}}]} as unknown as DocumentNode<BackupJobConfigsQuery, BackupJobConfigsQueryVariables>;
export const BackupJobConfigFormDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"BackupJobConfigForm"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BackupJobConfigFormInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"backupJobConfigForm"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dataSchema"}},{"kind":"Field","name":{"kind":"Name","value":"uiSchema"}}]}}]}}]} as unknown as DocumentNode<BackupJobConfigFormQuery, BackupJobConfigFormQueryVariables>;
export const CreateBackupJobConfigDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateBackupJobConfig"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateBackupJobConfigInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"backup"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createBackupJobConfig"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePath"}},{"kind":"Field","name":{"kind":"Name","value":"remoteName"}},{"kind":"Field","name":{"kind":"Name","value":"destinationPath"}},{"kind":"Field","name":{"kind":"Name","value":"schedule"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]}}]} as unknown as DocumentNode<CreateBackupJobConfigMutation, CreateBackupJobConfigMutationVariables>;
export const UpdateBackupJobConfigDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateBackupJobConfig"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateBackupJobConfigInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"backup"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateBackupJobConfig"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePath"}},{"kind":"Field","name":{"kind":"Name","value":"remoteName"}},{"kind":"Field","name":{"kind":"Name","value":"destinationPath"}},{"kind":"Field","name":{"kind":"Name","value":"schedule"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastRunAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastRunStatus"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateBackupJobConfigMutation, UpdateBackupJobConfigMutationVariables>;
export const CreateBackupJobConfigDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateBackupJobConfig"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateBackupJobConfigInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"backup"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createBackupJobConfig"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePath"}},{"kind":"Field","name":{"kind":"Name","value":"remoteName"}},{"kind":"Field","name":{"kind":"Name","value":"destinationPath"}},{"kind":"Field","name":{"kind":"Name","value":"schedule"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"currentJobId"}}]}}]}}]}}]} as unknown as DocumentNode<CreateBackupJobConfigMutation, CreateBackupJobConfigMutationVariables>;
export const UpdateBackupJobConfigDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateBackupJobConfig"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateBackupJobConfigInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"backup"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateBackupJobConfig"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePath"}},{"kind":"Field","name":{"kind":"Name","value":"remoteName"}},{"kind":"Field","name":{"kind":"Name","value":"destinationPath"}},{"kind":"Field","name":{"kind":"Name","value":"schedule"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastRunAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastRunStatus"}},{"kind":"Field","name":{"kind":"Name","value":"currentJobId"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateBackupJobConfigMutation, UpdateBackupJobConfigMutationVariables>;
export const DeleteBackupJobConfigDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteBackupJobConfig"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"backup"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteBackupJobConfig"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]}}]} as unknown as DocumentNode<DeleteBackupJobConfigMutation, DeleteBackupJobConfigMutationVariables>;
export const ToggleBackupJobConfigDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ToggleBackupJobConfig"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"backup"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"toggleJobConfig"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePath"}},{"kind":"Field","name":{"kind":"Name","value":"remoteName"}},{"kind":"Field","name":{"kind":"Name","value":"destinationPath"}},{"kind":"Field","name":{"kind":"Name","value":"schedule"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastRunAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastRunStatus"}}]}}]}}]}}]} as unknown as DocumentNode<ToggleBackupJobConfigMutation, ToggleBackupJobConfigMutationVariables>;
export const ToggleBackupJobConfigDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ToggleBackupJobConfig"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"backup"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"toggleJobConfig"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePath"}},{"kind":"Field","name":{"kind":"Name","value":"remoteName"}},{"kind":"Field","name":{"kind":"Name","value":"destinationPath"}},{"kind":"Field","name":{"kind":"Name","value":"schedule"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastRunAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastRunStatus"}},{"kind":"Field","name":{"kind":"Name","value":"currentJobId"}}]}}]}}]}}]} as unknown as DocumentNode<ToggleBackupJobConfigMutation, ToggleBackupJobConfigMutationVariables>;
export const TriggerBackupJobDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"TriggerBackupJob"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PrefixedID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"backup"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"triggerJob"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"jobId"}}]}}]}}]}}]} as unknown as DocumentNode<TriggerBackupJobMutation, TriggerBackupJobMutationVariables>;
export const InitiateBackupDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InitiateBackup"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"InitiateBackupInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"backup"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"initiateBackup"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"jobId"}}]}}]}}]}}]} as unknown as DocumentNode<InitiateBackupMutation, InitiateBackupMutationVariables>;
export const BackupJobProgressDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"BackupJobProgress"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"jobId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PrefixedID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"backupJobProgress"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"jobId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"jobId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"stats"}},{"kind":"Field","name":{"kind":"Name","value":"formattedBytes"}},{"kind":"Field","name":{"kind":"Name","value":"formattedSpeed"}},{"kind":"Field","name":{"kind":"Name","value":"formattedElapsedTime"}},{"kind":"Field","name":{"kind":"Name","value":"formattedEta"}}]}}]}}]} as unknown as DocumentNode<BackupJobProgressSubscription, BackupJobProgressSubscriptionVariables>;
export const BackupJobProgressDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"BackupJobProgress"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"jobId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PrefixedID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"backupJobProgress"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"jobId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"jobId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"stats"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BackupStats"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BackupStats"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RCloneJobStats"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bytes"}},{"kind":"Field","name":{"kind":"Name","value":"speed"}},{"kind":"Field","name":{"kind":"Name","value":"eta"}},{"kind":"Field","name":{"kind":"Name","value":"elapsedTime"}},{"kind":"Field","name":{"kind":"Name","value":"percentage"}},{"kind":"Field","name":{"kind":"Name","value":"checks"}},{"kind":"Field","name":{"kind":"Name","value":"deletes"}},{"kind":"Field","name":{"kind":"Name","value":"errors"}},{"kind":"Field","name":{"kind":"Name","value":"fatalError"}},{"kind":"Field","name":{"kind":"Name","value":"lastError"}},{"kind":"Field","name":{"kind":"Name","value":"renames"}},{"kind":"Field","name":{"kind":"Name","value":"retryError"}},{"kind":"Field","name":{"kind":"Name","value":"serverSideCopies"}},{"kind":"Field","name":{"kind":"Name","value":"serverSideCopyBytes"}},{"kind":"Field","name":{"kind":"Name","value":"serverSideMoves"}},{"kind":"Field","name":{"kind":"Name","value":"serverSideMoveBytes"}},{"kind":"Field","name":{"kind":"Name","value":"totalBytes"}},{"kind":"Field","name":{"kind":"Name","value":"totalChecks"}},{"kind":"Field","name":{"kind":"Name","value":"totalTransfers"}},{"kind":"Field","name":{"kind":"Name","value":"transferTime"}},{"kind":"Field","name":{"kind":"Name","value":"transfers"}},{"kind":"Field","name":{"kind":"Name","value":"transferring"}},{"kind":"Field","name":{"kind":"Name","value":"checking"}},{"kind":"Field","name":{"kind":"Name","value":"formattedBytes"}},{"kind":"Field","name":{"kind":"Name","value":"formattedSpeed"}},{"kind":"Field","name":{"kind":"Name","value":"formattedElapsedTime"}},{"kind":"Field","name":{"kind":"Name","value":"formattedEta"}}]}}]} as unknown as DocumentNode<BackupJobProgressSubscription, BackupJobProgressSubscriptionVariables>;
export const GetConnectSettingsFormDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetConnectSettingsForm"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connect"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"settings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dataSchema"}},{"kind":"Field","name":{"kind":"Name","value":"uiSchema"}},{"kind":"Field","name":{"kind":"Name","value":"values"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sandbox"}},{"kind":"Field","name":{"kind":"Name","value":"extraOrigins"}},{"kind":"Field","name":{"kind":"Name","value":"accessType"}},{"kind":"Field","name":{"kind":"Name","value":"forwardType"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"ssoUserIds"}}]}}]}}]}}]}}]} as unknown as DocumentNode<GetConnectSettingsFormQuery, GetConnectSettingsFormQueryVariables>;
export const UpdateConnectSettingsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateConnectSettings"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ApiSettingsInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateApiSettings"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sandbox"}},{"kind":"Field","name":{"kind":"Name","value":"extraOrigins"}},{"kind":"Field","name":{"kind":"Name","value":"accessType"}},{"kind":"Field","name":{"kind":"Name","value":"forwardType"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"ssoUserIds"}}]}}]}}]} as unknown as DocumentNode<UpdateConnectSettingsMutation, UpdateConnectSettingsMutationVariables>;
export const LogFilesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"LogFiles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logFiles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"modifiedAt"}}]}}]}}]} as unknown as DocumentNode<LogFilesQuery, LogFilesQueryVariables>;

View File

@@ -34,6 +34,29 @@ import { mergeAndDedup } from './merge';
const defaultCacheConfig: InMemoryCacheConfig = {
typePolicies: {
Query: {
fields: {
backup: {
merge(_, incoming) {
return incoming;
},
},
},
},
Backup: {
fields: {
jobs: {
merge(_, incoming) {
return incoming;
},
},
configs: {
merge(_, incoming) {
return incoming;
},
},
},
},
Notifications: {
fields: {
list: {