chore: begin setting up new views and mutations for flash backup

This commit is contained in:
Eli Bosley
2025-05-24 07:42:39 -04:00
parent 8befa23b4d
commit 69359902cb
19 changed files with 2100 additions and 170 deletions

View File

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

View File

@@ -999,6 +999,124 @@ A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date
"""
scalar DateTime
type RCloneDrive {
"""Provider name"""
name: String!
"""Provider options and configuration schema"""
options: JSON!
}
type RCloneBackupConfigForm {
id: ID!
dataSchema: JSON!
uiSchema: JSON!
}
type RCloneBackupSettings {
configForm(formOptions: RCloneConfigFormInput): RCloneBackupConfigForm!
drives: [RCloneDrive!]!
remotes: [RCloneRemote!]!
}
input RCloneConfigFormInput {
providerType: String
showAdvanced: Boolean = false
parameters: JSON
}
type RCloneRemote {
name: String!
type: String!
parameters: JSON!
"""Complete remote configuration"""
config: JSON!
}
type Backup implements Node {
id: PrefixedID!
jobs: [BackupJob!]!
configs: [BackupJobConfig!]!
"""Get the status for the backup service"""
status: BackupStatus!
}
type BackupStatus {
"""Status message indicating the outcome of the backup initiation."""
status: String!
"""Job ID if available, can be used to check job status."""
jobId: String
}
type BackupJob {
"""Job ID"""
id: String!
"""Job type (e.g., sync/copy)"""
type: 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
}
type BackupJobConfig {
id: PrefixedID!
"""Human-readable name for this backup job"""
name: String!
"""Source path to backup"""
sourcePath: String!
"""Remote name from rclone config"""
remoteName: String!
"""Destination path on the remote"""
destinationPath: String!
"""Cron schedule expression (e.g., "0 2 * * *" for daily at 2AM)"""
schedule: String!
"""Whether this backup job is enabled"""
enabled: Boolean!
"""RClone options (e.g., --transfers, --checkers)"""
rcloneOptions: JSON
"""When this config was created"""
createdAt: DateTime!
"""When this config was last updated"""
updatedAt: DateTime!
"""Last time this job ran"""
lastRunAt: DateTime
"""Status of last run"""
lastRunStatus: String
}
type BackupJobConfigForm {
id: ID!
dataSchema: JSON!
uiSchema: JSON!
}
type Config implements Node {
id: PrefixedID!
valid: Boolean
@@ -1356,49 +1474,6 @@ type Docker implements Node {
networks(skipCache: Boolean! = false): [DockerNetwork!]!
}
type FlashBackupStatus {
"""Status message indicating the outcome of the backup initiation."""
status: String!
"""Job ID if available, can be used to check job status."""
jobId: String
}
type RCloneDrive {
"""Provider name"""
name: String!
"""Provider options and configuration schema"""
options: JSON!
}
type RCloneBackupConfigForm {
id: ID!
dataSchema: JSON!
uiSchema: JSON!
}
type RCloneBackupSettings {
configForm(formOptions: RCloneConfigFormInput): RCloneBackupConfigForm!
drives: [RCloneDrive!]!
remotes: [RCloneRemote!]!
}
input RCloneConfigFormInput {
providerType: String
showAdvanced: Boolean = false
parameters: JSON
}
type RCloneRemote {
name: String!
type: String!
parameters: JSON!
"""Complete remote configuration"""
config: JSON!
}
type Flash implements Node {
id: PrefixedID!
guid: String!
@@ -1591,6 +1666,19 @@ type Query {
"""All possible permissions for API keys"""
apiKeyPossiblePermissions: [Permission!]!
"""Get backup service information"""
backup: Backup!
"""Get a specific backup job configuration"""
backupJobConfig(id: String!): BackupJobConfig
"""Get status of a specific backup job"""
backupJob(jobId: String!): BackupJob
"""Get the JSON schema for backup job configuration form"""
backupJobConfigForm(input: BackupJobConfigFormInput): BackupJobConfigForm!
rclone: RCloneBackupSettings!
connect: Connect!
remoteAccess: RemoteAccess!
extraAllowedOrigins: [String!]!
@@ -1600,11 +1688,14 @@ type Query {
docker: Docker!
disks: [Disk!]!
disk(id: PrefixedID!): Disk!
rclone: RCloneBackupSettings!
health: String!
getDemo: String!
}
input BackupJobConfigFormInput {
showAdvanced: Boolean! = false
}
type Mutation {
"""Creates a new notification record"""
createNotification(input: NotificationData!): Notification!
@@ -1631,15 +1722,24 @@ type Mutation {
parityCheck: ParityCheckMutations!
apiKey: ApiKeyMutations!
rclone: RCloneMutations!
"""Create a new backup job configuration"""
createBackupJobConfig(input: CreateBackupJobConfigInput!): BackupJobConfig!
"""Update a backup job configuration"""
updateBackupJobConfig(id: String!, input: UpdateBackupJobConfigInput!): BackupJobConfig
"""Delete a backup job configuration"""
deleteBackupJobConfig(id: String!): Boolean!
"""Initiates a backup using a configured remote."""
initiateBackup(input: InitiateBackupInput!): BackupStatus!
updateApiSettings(input: ApiSettingsInput!): ConnectSettingsValues!
connectSignIn(input: ConnectSignInInput!): Boolean!
connectSignOut: Boolean!
setupRemoteAccess(input: SetupRemoteAccessInput!): Boolean!
setAdditionalAllowedOrigins(input: AllowedOriginInput!): [String!]!
enableDynamicRemoteAccess(input: EnableDynamicRemoteAccessInput!): Boolean!
"""Initiates a flash drive backup using a configured remote."""
initiateFlashBackup(input: InitiateFlashBackupInput!): FlashBackupStatus!
setDemo: String!
}
@@ -1651,6 +1751,42 @@ input NotificationData {
link: String
}
input CreateBackupJobConfigInput {
name: String!
sourcePath: String!
remoteName: String!
destinationPath: String!
schedule: String!
enabled: Boolean! = true
rcloneOptions: JSON
}
input UpdateBackupJobConfigInput {
name: String
sourcePath: String
remoteName: String
destinationPath: String
schedule: String
enabled: Boolean
rcloneOptions: JSON
}
input InitiateBackupInput {
"""The name of the remote configuration to use for the backup."""
remoteName: String!
"""Source path to backup."""
sourcePath: String!
"""Destination path on the remote."""
destinationPath: String!
"""
Additional options for the backup operation, such as --dry-run or --transfers.
"""
options: JSON
}
input ApiSettingsInput {
"""
If true, the GraphQL sandbox will be enabled and available at /graphql. If false, the GraphQL sandbox will be disabled and only the production API will be available.
@@ -1736,22 +1872,6 @@ input AccessUrlInput {
ipv6: URL
}
input InitiateFlashBackupInput {
"""The name of the remote configuration to use for the backup."""
remoteName: String!
"""Source path to backup (typically the flash drive)."""
sourcePath: String!
"""Destination path on the remote."""
destinationPath: String!
"""
Additional options for the backup operation, such as --dry-run or --transfers.
"""
options: JSON
}
type Subscription {
displaySubscription: Display!
infoSubscription: Info!

View File

@@ -0,0 +1,211 @@
import { Injectable, Logger } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { existsSync } from 'fs';
import { readFile, writeFile } from 'fs/promises';
import { CronJob } from 'cron';
import { v4 as uuidv4 } from 'uuid';
import {
BackupJobConfig,
CreateBackupJobConfigInput,
UpdateBackupJobConfigInput,
} from '@app/unraid-api/graph/resolvers/backup/backup.model.js';
import { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.service.js';
interface BackupJobConfigData {
id: string;
name: string;
sourcePath: string;
remoteName: string;
destinationPath: string;
schedule: string;
enabled: boolean;
rcloneOptions?: Record<string, unknown>;
createdAt: string;
updatedAt: string;
lastRunAt?: string;
lastRunStatus?: string;
}
@Injectable()
export class BackupConfigService {
private readonly logger = new Logger(BackupConfigService.name);
private readonly configPath = '/boot/config/backup-jobs.json';
private configs: Map<string, BackupJobConfigData> = new Map();
constructor(
private readonly rcloneService: RCloneService,
private readonly schedulerRegistry: SchedulerRegistry
) {
this.loadConfigs();
}
async createBackupJobConfig(input: CreateBackupJobConfigInput): Promise<BackupJobConfig> {
const id = uuidv4();
const now = new Date().toISOString();
const config: BackupJobConfigData = {
id,
...input,
createdAt: now,
updatedAt: now,
};
this.configs.set(id, config);
await this.saveConfigs();
if (config.enabled) {
this.scheduleJob(config);
}
return this.mapToGraphQL(config);
}
async updateBackupJobConfig(
id: string,
input: UpdateBackupJobConfigInput
): Promise<BackupJobConfig | null> {
const existing = this.configs.get(id);
if (!existing) return null;
const updated: BackupJobConfigData = {
...existing,
...input,
updatedAt: new Date().toISOString(),
};
this.configs.set(id, updated);
await this.saveConfigs();
this.unscheduleJob(id);
if (updated.enabled) {
this.scheduleJob(updated);
}
return this.mapToGraphQL(updated);
}
async deleteBackupJobConfig(id: string): Promise<boolean> {
const config = this.configs.get(id);
if (!config) return false;
this.unscheduleJob(id);
this.configs.delete(id);
await this.saveConfigs();
return true;
}
async getBackupJobConfig(id: string): Promise<BackupJobConfig | null> {
const config = this.configs.get(id);
return config ? this.mapToGraphQL(config) : null;
}
async getAllBackupJobConfigs(): Promise<BackupJobConfig[]> {
return Array.from(this.configs.values()).map((config) => this.mapToGraphQL(config));
}
private async executeBackupJob(config: BackupJobConfigData): Promise<void> {
this.logger.log(`Executing backup job: ${config.name}`);
try {
const result = await this.rcloneService['rcloneApiService'].startBackup({
srcPath: config.sourcePath,
dstPath: `${config.remoteName}:${config.destinationPath}`,
options: config.rcloneOptions,
});
config.lastRunAt = new Date().toISOString();
config.lastRunStatus = `Started with job ID: ${result.jobId}`;
this.configs.set(config.id, config);
await this.saveConfigs();
this.logger.log(`Backup job ${config.name} started successfully: ${result.jobId}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
config.lastRunAt = new Date().toISOString();
config.lastRunStatus = `Failed: ${errorMessage}`;
this.configs.set(config.id, config);
await this.saveConfigs();
this.logger.error(`Backup job ${config.name} failed:`, error);
}
}
private scheduleJob(config: BackupJobConfigData): void {
try {
const job = new CronJob(
config.schedule,
() => this.executeBackupJob(config),
null,
false,
'UTC'
);
this.schedulerRegistry.addCronJob(`backup-${config.id}`, job);
job.start();
this.logger.log(`Scheduled backup job: ${config.name} with schedule: ${config.schedule}`);
} catch (error) {
this.logger.error(`Failed to schedule backup job ${config.name}:`, error);
}
}
private unscheduleJob(id: string): void {
try {
const jobName = `backup-${id}`;
if (this.schedulerRegistry.doesExist('cron', jobName)) {
this.schedulerRegistry.deleteCronJob(jobName);
this.logger.log(`Unscheduled backup job: ${id}`);
}
} catch (error) {
this.logger.error(`Failed to unschedule backup job ${id}:`, error);
}
}
private async loadConfigs(): Promise<void> {
try {
if (existsSync(this.configPath)) {
const data = await readFile(this.configPath, 'utf-8');
const configs: BackupJobConfigData[] = JSON.parse(data);
this.configs.clear();
configs.forEach((config) => {
this.configs.set(config.id, config);
if (config.enabled) {
this.scheduleJob(config);
}
});
this.logger.log(`Loaded ${configs.length} backup job configurations`);
}
} catch (error) {
this.logger.error('Failed to load backup configurations:', error);
}
}
private async saveConfigs(): Promise<void> {
try {
const configs = Array.from(this.configs.values());
await writeFile(this.configPath, JSON.stringify(configs, null, 2));
} catch (error) {
this.logger.error('Failed to save backup configurations:', error);
}
}
private mapToGraphQL(config: BackupJobConfigData): BackupJobConfig {
return {
id: config.id,
name: config.name,
sourcePath: config.sourcePath,
remoteName: config.remoteName,
destinationPath: config.destinationPath,
schedule: config.schedule,
enabled: config.enabled,
rcloneOptions: config.rcloneOptions,
createdAt: new Date(config.createdAt),
updatedAt: new Date(config.updatedAt),
lastRunAt: config.lastRunAt ? new Date(config.lastRunAt) : undefined,
lastRunStatus: config.lastRunStatus,
};
}
}

View File

@@ -0,0 +1,242 @@
import { Field, ID, InputType, ObjectType } from '@nestjs/graphql';
import { type Layout } from '@jsonforms/core';
import { IsBoolean, IsNotEmpty, IsObject, IsOptional, IsString, Matches } from 'class-validator';
import { GraphQLJSON } from 'graphql-scalars';
import { Node } from '@app/unraid-api/graph/resolvers/base.model.js';
import { DataSlice } from '@app/unraid-api/types/json-forms.js';
@ObjectType({
implements: () => Node,
})
export class Backup extends Node {
@Field(() => [BackupJob])
jobs!: BackupJob[];
@Field(() => [BackupJobConfig])
configs!: BackupJobConfig[];
}
@InputType()
export class InitiateBackupInput {
@Field(() => String, { description: 'The name of the remote configuration to use for the backup.' })
@IsString()
@IsNotEmpty()
remoteName!: string;
@Field(() => String, { description: 'Source path to backup.' })
@IsString()
@IsNotEmpty()
sourcePath!: string;
@Field(() => String, { description: 'Destination path on the remote.' })
@IsString()
@IsNotEmpty()
destinationPath!: string;
@Field(() => GraphQLJSON, {
description: 'Additional options for the backup operation, such as --dry-run or --transfers.',
nullable: true,
})
@IsOptional()
@IsObject()
options?: Record<string, unknown>;
}
@ObjectType()
export class BackupStatus {
@Field(() => String, {
description: 'Status message indicating the outcome of the backup initiation.',
})
status!: string;
@Field(() => String, {
description: 'Job ID if available, can be used to check job status.',
nullable: true,
})
jobId?: string;
}
@ObjectType()
export class BackupJob {
@Field(() => String, { description: 'Job ID' })
id!: string;
@Field(() => String, { description: 'Job type (e.g., sync/copy)' })
type!: string;
@Field(() => GraphQLJSON, { description: 'Job status and statistics' })
stats!: Record<string, unknown>;
@Field(() => String, { description: 'Formatted bytes transferred', nullable: true })
formattedBytes?: string;
@Field(() => String, { description: 'Formatted transfer speed', nullable: true })
formattedSpeed?: string;
@Field(() => String, { description: 'Formatted elapsed time', nullable: true })
formattedElapsedTime?: string;
@Field(() => String, { description: 'Formatted ETA', nullable: true })
formattedEta?: string;
}
@ObjectType()
export class RCloneWebGuiInfo {
@Field()
url!: string;
}
@ObjectType()
export class BackupJobConfig extends Node {
@Field(() => String, { description: 'Human-readable name for this backup job' })
name!: string;
@Field(() => String, { description: 'Source path to backup' })
sourcePath!: string;
@Field(() => String, { description: 'Remote name from rclone config' })
remoteName!: string;
@Field(() => String, { description: 'Destination path on the remote' })
destinationPath!: string;
@Field(() => String, {
description: 'Cron schedule expression (e.g., "0 2 * * *" for daily at 2AM)',
})
schedule!: string;
@Field(() => Boolean, { description: 'Whether this backup job is enabled' })
enabled!: boolean;
@Field(() => GraphQLJSON, {
description: 'RClone options (e.g., --transfers, --checkers)',
nullable: true,
})
rcloneOptions?: Record<string, unknown>;
@Field(() => Date, { description: 'When this config was created' })
createdAt!: Date;
@Field(() => Date, { description: 'When this config was last updated' })
updatedAt!: Date;
@Field(() => Date, { description: 'Last time this job ran', nullable: true })
lastRunAt?: Date;
@Field(() => String, { description: 'Status of last run', nullable: true })
lastRunStatus?: string;
}
@InputType()
export class CreateBackupJobConfigInput {
@Field(() => String)
@IsString()
@IsNotEmpty()
name!: string;
@Field(() => String)
@IsString()
@IsNotEmpty()
sourcePath!: string;
@Field(() => String)
@IsString()
@IsNotEmpty()
remoteName!: string;
@Field(() => String)
@IsString()
@IsNotEmpty()
destinationPath!: string;
@Field(() => String)
@IsString()
@IsNotEmpty()
@Matches(
/^(\*|[0-5]?\d)(\s+(\*|[0-1]?\d|2[0-3]))(\s+(\*|[1-2]?\d|3[0-1]))(\s+(\*|[1-9]|1[0-2]))(\s+(\*|[0-6]))$/,
{
message: 'schedule must be a valid cron expression',
}
)
schedule!: string;
@Field(() => Boolean, { defaultValue: true })
@IsBoolean()
enabled!: boolean;
@Field(() => GraphQLJSON, { nullable: true })
@IsOptional()
@IsObject()
rcloneOptions?: Record<string, unknown>;
}
@InputType()
export class UpdateBackupJobConfigInput {
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
@IsNotEmpty()
name?: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
@IsNotEmpty()
sourcePath?: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
@IsNotEmpty()
remoteName?: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
@IsNotEmpty()
destinationPath?: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
@IsNotEmpty()
@Matches(
/^(\*|[0-5]?\d)(\s+(\*|[0-1]?\d|2[0-3]))(\s+(\*|[1-2]?\d|3[0-1]))(\s+(\*|[1-9]|1[0-2]))(\s+(\*|[0-6]))$/,
{
message: 'schedule must be a valid cron expression',
}
)
schedule?: string;
@Field(() => Boolean, { nullable: true })
@IsOptional()
@IsBoolean()
enabled?: boolean;
@Field(() => GraphQLJSON, { nullable: true })
@IsOptional()
@IsObject()
rcloneOptions?: Record<string, unknown>;
}
@ObjectType()
export class BackupJobConfigForm {
@Field(() => ID)
id!: string;
@Field(() => GraphQLJSON)
dataSchema!: { properties: DataSlice; type: 'object' };
@Field(() => GraphQLJSON)
uiSchema!: Layout;
}
@InputType()
export class BackupJobConfigFormInput {
@Field(() => Boolean, { defaultValue: false })
@IsOptional()
@IsBoolean()
showAdvanced?: boolean;
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { BackupConfigService } from '@app/unraid-api/graph/resolvers/backup/backup-config.service.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, BackupConfigService, FormatService],
exports: [],
})
export class BackupModule {}

View File

@@ -0,0 +1,201 @@
import { Inject, Logger } from '@nestjs/common';
import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { BackupConfigService } from '@app/unraid-api/graph/resolvers/backup/backup-config.service.js';
import {
Backup,
BackupJob,
BackupJobConfig,
BackupJobConfigForm,
BackupJobConfigFormInput,
BackupStatus,
CreateBackupJobConfigInput,
InitiateBackupInput,
UpdateBackupJobConfigInput,
} 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 { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.service.js';
@Resolver(() => Backup)
export class BackupResolver {
private readonly logger = new Logger(BackupResolver.name);
constructor(
private readonly rcloneService: RCloneService,
private readonly backupConfigService: BackupConfigService,
private readonly formatService: FormatService
) {}
@Query(() => Backup, {
description: 'Get backup service information',
})
async backup(): Promise<Backup> {
return {
id: 'backup',
jobs: [],
configs: [],
};
}
@ResolveField(() => [BackupJob], {
description: 'Get all running backup jobs',
})
async jobs(): Promise<BackupJob[]> {
return this.backupJobs();
}
@ResolveField(() => [BackupJobConfig], {
description: 'Get all backup job configurations',
})
async configs(): Promise<BackupJobConfig[]> {
return this.backupConfigService.getAllBackupJobConfigs();
}
@Query(() => BackupJobConfig, {
description: 'Get a specific backup job configuration',
nullable: true,
})
async backupJobConfig(@Args('id') id: string): Promise<BackupJobConfig | null> {
return this.backupConfigService.getBackupJobConfig(id);
}
@Mutation(() => BackupJobConfig, {
description: 'Create a new backup job configuration',
})
async createBackupJobConfig(
@Args('input') input: CreateBackupJobConfigInput
): Promise<BackupJobConfig> {
return this.backupConfigService.createBackupJobConfig(input);
}
@Mutation(() => BackupJobConfig, {
description: 'Update a backup job configuration',
nullable: true,
})
async updateBackupJobConfig(
@Args('id') id: string,
@Args('input') input: UpdateBackupJobConfigInput
): Promise<BackupJobConfig | null> {
return this.backupConfigService.updateBackupJobConfig(id, input);
}
@Mutation(() => Boolean, {
description: 'Delete a backup job configuration',
})
async deleteBackupJobConfig(@Args('id') id: string): Promise<boolean> {
return this.backupConfigService.deleteBackupJobConfig(id);
}
private async backupJobs(): Promise<BackupJob[]> {
try {
const jobs = await this.rcloneService['rcloneApiService'].listRunningJobs();
return (
jobs.jobids?.map((jobId: string, index: number) => {
const stats = jobs.stats?.[index] || {};
return {
id: jobId,
type: 'backup',
stats,
formattedBytes: stats.bytes
? this.formatService.formatBytes(stats.bytes)
: undefined,
formattedSpeed: stats.speed
? this.formatService.formatBytes(stats.speed)
: undefined,
formattedElapsedTime: stats.elapsedTime
? this.formatService.formatDuration(stats.elapsedTime)
: undefined,
formattedEta: stats.eta
? this.formatService.formatDuration(stats.eta)
: undefined,
};
}) || []
);
} catch (error) {
this.logger.error('Failed to fetch backup jobs:', error);
return [];
}
}
@Query(() => BackupJob, {
description: 'Get status of a specific backup job',
nullable: true,
})
async backupJob(@Args('jobId') jobId: string): Promise<BackupJob | null> {
try {
const status = await this.rcloneService['rcloneApiService'].getJobStatus({ jobId });
return {
id: jobId,
type: status.group || 'backup',
stats: status,
formattedBytes: status.bytes ? this.formatService.formatBytes(status.bytes) : undefined,
formattedSpeed: status.speed ? this.formatService.formatBytes(status.speed) : undefined,
formattedElapsedTime: status.elapsedTime
? this.formatService.formatDuration(status.elapsedTime)
: undefined,
formattedEta: status.eta ? this.formatService.formatDuration(status.eta) : undefined,
};
} catch (error) {
this.logger.error(`Failed to fetch backup job ${jobId}:`, error);
return null;
}
}
@Mutation(() => BackupStatus, {
description: 'Initiates a backup using a configured remote.',
})
async initiateBackup(@Args('input') input: InitiateBackupInput): Promise<BackupStatus> {
try {
const result = await this.rcloneService['rcloneApiService'].startBackup({
srcPath: input.sourcePath,
dstPath: `${input.remoteName}:${input.destinationPath}`,
options: input.options,
});
return {
status: 'Backup initiated successfully',
jobId: result.jobid || result.jobId,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error('Failed to initiate backup:', error);
return {
status: `Failed to initiate backup: ${errorMessage}`,
jobId: undefined,
};
}
}
@ResolveField(() => BackupStatus, {
description: 'Get the status for the backup service',
})
async status(): Promise<BackupStatus> {
return {
status: 'Available',
jobId: undefined,
};
}
@Query(() => BackupJobConfigForm, {
description: 'Get the JSON schema for backup job configuration form',
})
async backupJobConfigForm(
@Args('input', { nullable: true }) input?: BackupJobConfigFormInput
): Promise<BackupJobConfigForm> {
const remoteNames = await this.rcloneService.getConfiguredRemotes();
const showAdvanced = input?.showAdvanced ?? false;
const { dataSchema, uiSchema } = buildBackupJobConfigSchema({
remoteNames,
showAdvanced,
});
return {
id: 'backup-job-config-form',
dataSchema,
uiSchema,
};
}
}

View File

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

@@ -0,0 +1,346 @@
import type { LabelElement, Layout } from '@jsonforms/core';
import { JsonSchema7 } from '@jsonforms/core';
import type { DataSlice, SettingSlice, UIElement } from '@app/unraid-api/types/json-forms.js';
import { createLabeledControl } from '@app/unraid-api/graph/utils/form-utils.js';
import { mergeSettingSlices } from '@app/unraid-api/types/json-forms.js';
function getBasicBackupConfigSlice({ remoteNames = [] }: { remoteNames?: string[] }): SettingSlice {
const basicConfigElements: UIElement[] = [
createLabeledControl({
scope: '#/properties/name',
label: 'Backup Job Name',
description: 'A descriptive name for this backup job (e.g., "Weekly Documents Backup")',
controlOptions: {
placeholder: 'Enter backup job name',
format: 'string',
},
}),
createLabeledControl({
scope: '#/properties/sourcePath',
label: 'Source Path',
description: 'The local path to backup (e.g., /mnt/user/Documents)',
controlOptions: {
placeholder: '/mnt/user/',
format: 'string',
},
}),
createLabeledControl({
scope: '#/properties/remoteName',
label: 'Remote Configuration',
description: 'Select the RClone remote configuration to use for this backup',
controlOptions: {
suggestions: remoteNames.map((name) => ({
value: name,
label: name,
})),
},
}),
createLabeledControl({
scope: '#/properties/destinationPath',
label: 'Destination Path',
description: 'The path on the remote where files will be stored (e.g., backups/documents)',
controlOptions: {
placeholder: 'backups/',
format: 'string',
},
}),
createLabeledControl({
scope: '#/properties/schedule',
label: 'Schedule (Cron Expression)',
description:
'When to run this backup job. Examples: "0 2 * * *" (daily at 2AM), "0 2 * * 0" (weekly on Sunday at 2AM)',
controlOptions: {
placeholder: '0 2 * * *',
format: 'string',
suggestions: [
{
value: '0 2 * * *',
label: 'Daily at 2:00 AM',
tooltip: 'Runs every day at 2:00 AM',
},
{
value: '0 2 * * 0',
label: 'Weekly (Sunday 2:00 AM)',
tooltip: 'Runs every Sunday at 2:00 AM',
},
{
value: '0 2 1 * *',
label: 'Monthly (1st at 2:00 AM)',
tooltip: 'Runs on the 1st of every month at 2:00 AM',
},
{
value: '0 2 * * 1-5',
label: 'Weekdays at 2:00 AM',
tooltip: 'Runs Monday through Friday at 2:00 AM',
},
],
},
}),
createLabeledControl({
scope: '#/properties/enabled',
label: 'Enable Backup Job',
description: 'Whether this backup job should run automatically according to the schedule',
controlOptions: {
toggle: true,
},
}),
{
type: 'Label',
text: 'Advanced Options',
options: {
description: 'Optional RClone-specific settings for this backup job.',
},
} as LabelElement,
createLabeledControl({
scope: '#/properties/showAdvanced',
label: 'Show Advanced RClone Options',
description: 'Display additional RClone configuration options',
controlOptions: {
toggle: true,
},
}),
];
const basicConfigProperties: Record<string, JsonSchema7> = {
name: {
type: 'string',
title: 'Backup Job Name',
description: 'Human-readable name for this backup job',
minLength: 1,
maxLength: 100,
},
sourcePath: {
type: 'string',
title: 'Source Path',
description: 'Source path to backup',
minLength: 1,
},
remoteName: {
type: 'string',
title: 'Remote Name',
description: 'Remote name from rclone config',
enum: remoteNames.length > 0 ? remoteNames : ['No remotes configured'],
},
destinationPath: {
type: 'string',
title: 'Destination Path',
description: 'Destination path on the remote',
minLength: 1,
},
schedule: {
type: 'string',
title: 'Cron Schedule',
description: 'Cron schedule expression',
pattern: '^\\s*(\\S+\\s+){4}\\S+\\s*$',
errorMessage:
'Please enter a valid cron expression (5 fields: minute hour day month weekday)',
},
enabled: {
type: 'boolean',
title: 'Enabled',
description: 'Whether this backup job is enabled',
default: true,
},
showAdvanced: {
type: 'boolean',
title: 'Show Advanced Options',
description: 'Whether to show advanced RClone options',
default: false,
},
};
const verticalLayoutElement: UIElement = {
type: 'VerticalLayout',
elements: basicConfigElements,
options: { step: 0 },
};
return {
properties: basicConfigProperties as unknown as DataSlice,
elements: [verticalLayoutElement],
};
}
function getAdvancedBackupConfigSlice({ showAdvanced }: { showAdvanced: boolean }): SettingSlice {
if (!showAdvanced) {
return {
properties: {},
elements: [],
};
}
const advancedConfigElements: UIElement[] = [
createLabeledControl({
scope: '#/properties/rcloneOptions/properties/transfers',
label: 'Number of Transfers',
description: 'Number of file transfers to run in parallel (default: 4)',
controlOptions: {
placeholder: '4',
format: 'number',
},
}),
createLabeledControl({
scope: '#/properties/rcloneOptions/properties/checkers',
label: 'Number of Checkers',
description: 'Number of checkers to run in parallel (default: 8)',
controlOptions: {
placeholder: '8',
format: 'number',
},
}),
createLabeledControl({
scope: '#/properties/rcloneOptions/properties/dryRun',
label: 'Dry Run',
description: 'Do a trial run with no permanent changes',
controlOptions: {
toggle: true,
},
}),
createLabeledControl({
scope: '#/properties/rcloneOptions/properties/progress',
label: 'Show Progress',
description: 'Show progress during transfer',
controlOptions: {
toggle: true,
},
}),
createLabeledControl({
scope: '#/properties/rcloneOptions/properties/verbose',
label: 'Verbose Logging',
description: 'Enable verbose logging for debugging',
controlOptions: {
toggle: true,
},
}),
];
const rcloneOptionsProperties: Record<string, JsonSchema7> = {
transfers: {
type: 'integer',
title: 'Transfers',
description: 'Number of file transfers to run in parallel',
minimum: 1,
maximum: 100,
default: 4,
},
checkers: {
type: 'integer',
title: 'Checkers',
description: 'Number of checkers to run in parallel',
minimum: 1,
maximum: 100,
default: 8,
},
dryRun: {
type: 'boolean',
title: 'Dry Run',
description: 'Do a trial run with no permanent changes',
default: false,
},
progress: {
type: 'boolean',
title: 'Show Progress',
description: 'Show progress during transfer',
default: true,
},
verbose: {
type: 'boolean',
title: 'Verbose Logging',
description: 'Enable verbose logging',
default: false,
},
};
const configProperties: DataSlice = {
rcloneOptions: {
type: 'object',
title: 'RClone Options',
description: 'Advanced RClone configuration options',
properties: rcloneOptionsProperties as unknown as DataSlice,
} as any,
};
const verticalLayoutElement: UIElement = {
type: 'VerticalLayout',
elements: advancedConfigElements,
options: { step: 1, showDividers: true },
};
return {
properties: configProperties,
elements: [verticalLayoutElement],
};
}
export function buildBackupJobConfigSchema({
remoteNames = [],
showAdvanced = false,
}: {
remoteNames?: string[];
showAdvanced?: boolean;
}): {
dataSchema: { properties: DataSlice; type: 'object' };
uiSchema: Layout;
} {
const slicesToMerge: SettingSlice[] = [];
const basicSlice = getBasicBackupConfigSlice({ remoteNames });
slicesToMerge.push(basicSlice);
const advancedSlice = getAdvancedBackupConfigSlice({ showAdvanced });
if (
showAdvanced &&
(advancedSlice.elements.length > 0 || Object.keys(advancedSlice.properties).length > 0)
) {
slicesToMerge.push(advancedSlice);
}
const mergedSlices = mergeSettingSlices(slicesToMerge);
const dataSchema: { properties: DataSlice; type: 'object' } = {
type: 'object',
properties: mergedSlices.properties,
};
const steps = [{ label: 'Backup Configuration', description: 'Basic backup job settings' }];
if (showAdvanced) {
steps.push({ label: 'Advanced Options', description: 'RClone-specific settings' });
}
const steppedLayoutElement: UIElement = {
type: 'SteppedLayout',
options: {
steps: steps,
},
elements: mergedSlices.elements,
};
const titleLabel: UIElement = {
type: 'Label',
text: 'Create Backup Job',
options: {
format: 'title',
description: 'Configure a new scheduled backup job with RClone.',
},
};
const uiSchema: Layout = {
type: 'VerticalLayout',
elements: [titleLabel, steppedLayoutElement],
};
return { dataSchema, uiSchema };
}

View File

@@ -1,53 +0,0 @@
import { Field, InputType, ObjectType } from '@nestjs/graphql';
import { GraphQLJSON } from 'graphql-scalars';
@InputType()
export class InitiateFlashBackupInput {
@Field(() => String, { description: 'The name of the remote configuration to use for the backup.' })
remoteName!: string;
@Field(() => String, { description: 'Source path to backup (typically the flash drive).' })
sourcePath!: string;
@Field(() => String, { description: 'Destination path on the remote.' })
destinationPath!: string;
@Field(() => GraphQLJSON, {
description: 'Additional options for the backup operation, such as --dry-run or --transfers.',
nullable: true,
})
options?: Record<string, unknown>;
}
@ObjectType()
export class FlashBackupStatus {
@Field(() => String, {
description: 'Status message indicating the outcome of the backup initiation.',
})
status!: string;
@Field(() => String, {
description: 'Job ID if available, can be used to check job status.',
nullable: true,
})
jobId?: string;
}
@ObjectType()
export class FlashBackupJob {
@Field(() => String, { description: 'Job ID' })
id!: string;
@Field(() => String, { description: 'Job type (e.g., sync/copy)' })
type!: string;
@Field(() => GraphQLJSON, { description: 'Job status and statistics' })
stats!: Record<string, unknown>;
}
@ObjectType()
export class RCloneWebGuiInfo {
@Field()
url!: string;
}

View File

@@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { FlashBackupResolver } from '@app/unraid-api/graph/resolvers/flash-backup/flash-backup.resolver.js';
import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js';
@Module({
imports: [RCloneModule],
providers: [FlashBackupResolver],
exports: [],
})
export class FlashBackupModule {}

View File

@@ -1,24 +0,0 @@
import { Inject, Logger } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import {
FlashBackupStatus,
InitiateFlashBackupInput,
} from '@app/unraid-api/graph/resolvers/flash-backup/flash-backup.model.js';
import { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.service.js';
@Resolver()
export class FlashBackupResolver {
private readonly logger = new Logger(FlashBackupResolver.name);
constructor(private readonly rcloneService: RCloneService) {}
@Mutation(() => FlashBackupStatus, {
description: 'Initiates a flash drive backup using a configured remote.',
})
async initiateFlashBackup(
@Args('input') input: InitiateFlashBackupInput
): Promise<FlashBackupStatus> {
throw new Error('Not implemented');
}
}

View File

@@ -4,6 +4,7 @@ import { AuthModule } from '@app/unraid-api/auth/auth.module.js';
import { ApiKeyModule } from '@app/unraid-api/graph/resolvers/api-key/api-key.module.js';
import { ApiKeyResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.resolver.js';
import { ArrayModule } from '@app/unraid-api/graph/resolvers/array/array.module.js';
import { BackupModule } from '@app/unraid-api/graph/resolvers/backup/backup.module.js';
import { CloudResolver } from '@app/unraid-api/graph/resolvers/cloud/cloud.resolver.js';
import { ConfigResolver } from '@app/unraid-api/graph/resolvers/config/config.resolver.js';
import { ConnectModule } from '@app/unraid-api/graph/resolvers/connect/connect.module.js';
@@ -11,7 +12,6 @@ import { CustomizationModule } from '@app/unraid-api/graph/resolvers/customizati
import { DisksModule } from '@app/unraid-api/graph/resolvers/disks/disks.module.js';
import { DisplayResolver } from '@app/unraid-api/graph/resolvers/display/display.resolver.js';
import { DockerModule } from '@app/unraid-api/graph/resolvers/docker/docker.module.js';
import { FlashBackupModule } from '@app/unraid-api/graph/resolvers/flash-backup/flash-backup.module.js';
import { FlashResolver } from '@app/unraid-api/graph/resolvers/flash/flash.resolver.js';
import { InfoResolver } from '@app/unraid-api/graph/resolvers/info/info.resolver.js';
import { LogsResolver } from '@app/unraid-api/graph/resolvers/logs/logs.resolver.js';
@@ -38,11 +38,11 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js';
ArrayModule,
ApiKeyModule,
AuthModule,
BackupModule,
ConnectModule,
CustomizationModule,
DockerModule,
DisksModule,
FlashBackupModule,
RCloneModule,
],
providers: [

View File

@@ -0,0 +1,183 @@
<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
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
@click="showConfigModal = true"
>
Add Backup Job
</button>
</div>
<div v-if="loading && !result" class="text-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600 mx-auto"></div>
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading backup configurations...</p>
</div>
<div v-else-if="error" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800 dark:text-red-200">
Error loading backup configurations
</h3>
<div class="mt-2 text-sm text-red-700 dark:text-red-300">
{{ error.message }}
</div>
</div>
</div>
</div>
<div v-else-if="!backupConfigs?.length" class="text-center py-12">
<div class="text-gray-400 dark:text-gray-600 mb-4">
<svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3a4 4 0 118 0v4m-4 8l-4-4 4-4m0 8h8a2 2 0 002-2V5a2 2 0 00-2-2H4a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">
No backup jobs configured
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
Create your first scheduled backup job to automatically protect your data.
</p>
<button
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
@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"
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 rounded-full',
config.enabled ? 'bg-green-400' : 'bg-gray-400'
]"
></div>
</div>
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
{{ config.name }}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ config.sourcePath }} {{ config.remoteName }}:{{ config.destinationPath }}
</p>
</div>
</div>
<div class="flex items-center space-x-2">
<span
:class="[
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
config.enabled
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400'
]"
>
{{ config.enabled ? 'Enabled' : 'Disabled' }}
</span>
</div>
</div>
<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 }}
</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' }}
</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' }}
</dd>
</div>
</div>
</div>
</div>
<!-- Modal for adding new backup job -->
<div
v-if="showConfigModal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-auto">
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<h2 id="modal-title" class="text-xl font-semibold text-gray-900 dark:text-white">
Add New Backup Job
</h2>
<button
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
aria-label="Close dialog"
@click="showConfigModal = false"
>
</button>
</div>
<div class="p-6">
<BackupJobConfigForm @complete="onConfigComplete" @cancel="showConfigModal = false" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useQuery } from '@vue/apollo-composable'
import { BACKUP_JOB_CONFIGS_QUERY } from './backup-jobs.query'
import BackupJobConfigForm from './BackupJobConfigForm.vue'
const showConfigModal = ref(false)
const { result, loading, error, refetch } = useQuery(BACKUP_JOB_CONFIGS_QUERY)
const backupConfigs = computed(() => result.value?.backup?.configs || [])
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleString()
}
function onConfigComplete() {
showConfigModal.value = false
refetch()
}
</script>
<style scoped>
.backup-config {
@apply max-w-7xl mx-auto p-6;
}
</style>

View File

@@ -0,0 +1,240 @@
<template>
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm">
<div class="p-6">
<h2 class="text-xl font-medium mb-4 text-gray-900 dark:text-white">Configure Backup Job</h2>
<div v-if="createError" class="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 rounded-md">
{{ createError.message }}
</div>
<div v-if="formLoading" class="py-8 text-center text-gray-500 dark:text-gray-400">Loading configuration form...</div>
<!-- Form -->
<div v-else-if="formResult?.backupJobConfigForm" class="mt-6 [&_.vertical-layout]:space-y-6">
<JsonForms
v-if="formResult?.backupJobConfigForm"
:schema="formResult.backupJobConfigForm.dataSchema"
:uischema="formResult.backupJobConfigForm.uiSchema"
:renderers="renderers"
:data="formState"
:config="jsonFormsConfig"
:readonly="isCreating"
@change="onChange"
/>
</div>
<!-- Submit Button (visible only on the last step) -->
<div
v-if="!formLoading && uiSchema && isLastStep"
class="mt-6 flex justify-end space-x-3 border-t border-gray-200 dark:border-gray-700 pt-6"
>
<Button variant="outline" @click="emit('cancel')">
Cancel
</Button>
<Button :loading="isCreating" @click="submitForm">
Create Backup Job
</Button>
</div>
<!-- If there's no stepped layout, show buttons at the bottom -->
<div
v-if="!formLoading && (!uiSchema || (numSteps === 0))"
class="mt-6 flex justify-end space-x-3 border-t border-gray-200 dark:border-gray-700 pt-6"
>
<Button variant="outline" @click="emit('cancel')">
Cancel
</Button>
<Button :loading="isCreating" @click="submitForm">
Create Backup Job
</Button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, provide, ref, watch } from 'vue';
import { useMutation, useQuery } from '@vue/apollo-composable';
import { Button, jsonFormsRenderers } from '@unraid/ui';
import { JsonForms } from '@jsonforms/vue';
import { CREATE_BACKUP_JOB_CONFIG_MUTATION, BACKUP_JOB_CONFIG_FORM_QUERY } from './backup-jobs.query';
// Define emit events
const emit = defineEmits<{
complete: []
cancel: []
}>()
// Define types for form state
interface ConfigStep {
current: number;
total: number;
}
// Form state
const formState = ref({
configStep: 0 as number | ConfigStep,
showAdvanced: false,
name: '',
sourcePath: '',
remoteName: '',
destinationPath: '',
schedule: '0 2 * * *',
enabled: true,
rcloneOptions: {},
});
// Get form schema
const {
result: formResult,
loading: formLoading,
refetch: updateFormSchema,
} = useQuery(BACKUP_JOB_CONFIG_FORM_QUERY, {
input: {
showAdvanced: formState.value.showAdvanced || false,
},
});
// Watch for changes to showAdvanced and refetch schema
let refetchTimeout: NodeJS.Timeout | null = null;
watch(
formState,
async (newValue, oldValue) => {
if (newValue.showAdvanced !== oldValue.showAdvanced) {
console.log('[BackupJobConfigForm] showAdvanced changed:', newValue.showAdvanced);
// Debounce refetch to prevent multiple rapid calls
if (refetchTimeout) {
clearTimeout(refetchTimeout);
}
refetchTimeout = setTimeout(async () => {
await updateFormSchema({
input: {
showAdvanced: newValue.showAdvanced,
},
});
refetchTimeout = null;
}, 100);
}
},
{ deep: true }
);
/**
* Form submission and mutation handling
*/
const {
mutate: createBackupJobConfig,
loading: isCreating,
error: createError,
onDone: onCreateDone,
} = useMutation(CREATE_BACKUP_JOB_CONFIG_MUTATION);
// Handle form submission
const submitForm = async () => {
try {
await createBackupJobConfig({
input: {
name: formState.value.name,
sourcePath: formState.value.sourcePath,
remoteName: formState.value.remoteName,
destinationPath: formState.value.destinationPath,
schedule: formState.value.schedule,
enabled: formState.value.enabled,
rcloneOptions: formState.value.rcloneOptions,
},
});
} catch (error) {
console.error('Error creating backup job config:', error);
}
};
// Handle successful creation
onCreateDone(async ({ data }) => {
// Show success message
if (window.toast) {
window.toast.success('Backup Job Created', {
description: `Successfully created backup job "${formState.value.name}"`,
});
}
console.log('[BackupJobConfigForm] onCreateDone', data);
// Reset form and emit complete event
formState.value = {
configStep: 0,
showAdvanced: false,
name: '',
sourcePath: '',
remoteName: '',
destinationPath: '',
schedule: '0 2 * * *',
enabled: true,
rcloneOptions: {},
};
emit('complete');
});
// Set up JSONForms config
const jsonFormsConfig = {
restrict: false,
trim: false,
};
const renderers = [...jsonFormsRenderers];
// Handle form data changes with debouncing to reduce excessive logging
let changeTimeout: NodeJS.Timeout | null = null;
const onChange = ({ data }: { data: Record<string, unknown> }) => {
// Clear any pending timeout
if (changeTimeout) {
clearTimeout(changeTimeout);
}
// Log changes but debounce to reduce console spam
changeTimeout = setTimeout(() => {
console.log('[BackupJobConfigForm] onChange received data:', JSON.stringify(data));
changeTimeout = null;
}, 300);
// Update formState
formState.value = data as typeof formState.value;
};
// --- Submit Button Logic ---
const uiSchema = computed(() => formResult.value?.backupJobConfigForm?.uiSchema);
// Handle both number and object formats of configStep
const getCurrentStep = computed(() => {
const step = formState.value.configStep;
return typeof step === 'object' ? (step as ConfigStep).current : step as number;
});
// Get total steps from UI schema
const numSteps = computed(() => {
if (uiSchema.value?.type === 'SteppedLayout') {
return uiSchema.value?.options?.steps?.length ?? 0;
} else if (uiSchema.value?.elements?.[0]?.type === 'SteppedLayout') {
return uiSchema.value?.elements[0].options?.steps?.length ?? 0;
}
return 0;
});
const isLastStep = computed(() => {
if (numSteps.value === 0) return false;
return getCurrentStep.value === numSteps.value - 1;
});
// --- Provide submission logic to SteppedLayout ---
provide('submitForm', submitForm);
provide('isSubmitting', isCreating);
</script>
<style lang="postcss">
/* Import unraid-ui globals first */
@import '@unraid/ui/styles';
</style>

View File

@@ -0,0 +1,170 @@
<template>
<div class="backup-overview">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Backup Management
</h1>
<button
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
:disabled="loading"
@click="() => refetch()"
>
{{ loading ? 'Refreshing...' : 'Refresh' }}
</button>
</div>
<!-- Backup Job Configurations -->
<BackupJobConfig />
<!-- Running Backup Jobs Section -->
<div class="mt-8">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6">
Running Backup Jobs
</h2>
<div v-if="loading && !result" class="text-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading backup jobs...</p>
</div>
<div v-else-if="error" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800 dark:text-red-200">
Error loading backup jobs
</h3>
<div class="mt-2 text-sm text-red-700 dark:text-red-300">
{{ error.message }}
</div>
</div>
</div>
</div>
<div v-else-if="!backupJobs?.length" class="text-center py-12">
<div class="text-gray-400 dark:text-gray-600 mb-4">
<svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">
No backup jobs running
</h3>
<p class="text-gray-600 dark:text-gray-400">
There are currently no active backup operations.
</p>
</div>
<div v-else class="space-y-4">
<div
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.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.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.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.formattedSpeed }}/s
</dd>
</div>
<div v-if="job.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.formattedElapsedTime }}
</dd>
</div>
<div v-if="job.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.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>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useQuery } from '@vue/apollo-composable'
import { BACKUP_JOBS_QUERY } from './backup-jobs.query'
import BackupJobConfig from './BackupJobConfig.vue'
const { result, loading, error, refetch } = useQuery(BACKUP_JOBS_QUERY, {}, {
pollInterval: 5000, // Refresh every 5 seconds
})
const backupJobs = computed(() => result.value?.backup?.jobs || [])
</script>
<style scoped>
.backup-overview {
@apply max-w-7xl mx-auto p-6;
}
</style>

View File

@@ -0,0 +1,75 @@
import { graphql } from '~/composables/gql/gql';
export const BACKUP_JOBS_QUERY = graphql(/* GraphQL */ `
query BackupJobs {
backup {
id
jobs {
id
type
stats
formattedBytes
formattedSpeed
formattedElapsedTime
formattedEta
}
}
}
`);
export const BACKUP_JOB_QUERY = graphql(/* GraphQL */ `
query BackupJob($jobId: String!) {
backupJob(jobId: $jobId) {
id
type
stats
}
}
`);
export const BACKUP_JOB_CONFIGS_QUERY = graphql(/* GraphQL */ `
query BackupJobConfigs {
backup {
id
configs {
id
name
sourcePath
remoteName
destinationPath
schedule
enabled
createdAt
updatedAt
lastRunAt
lastRunStatus
}
}
}
`);
export const BACKUP_JOB_CONFIG_FORM_QUERY = graphql(/* GraphQL */ `
query BackupJobConfigForm($input: BackupJobConfigFormInput) {
backupJobConfigForm(input: $input) {
id
dataSchema
uiSchema
}
}
`);
export const CREATE_BACKUP_JOB_CONFIG_MUTATION = graphql(/* GraphQL */ `
mutation CreateBackupJobConfig($input: CreateBackupJobConfigInput!) {
createBackupJobConfig(input: $input) {
id
name
sourcePath
remoteName
destinationPath
schedule
enabled
createdAt
updatedAt
}
}
`);

View File

@@ -20,6 +20,11 @@ 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 {\n backup {\n id\n jobs {\n id\n type\n stats\n formattedBytes\n formattedSpeed\n formattedElapsedTime\n formattedEta\n }\n }\n }\n": typeof types.BackupJobsDocument,
"\n query BackupJob($jobId: String!) {\n backupJob(jobId: $jobId) {\n id\n type\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 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 createBackupJobConfig(input: $input) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n }\n }\n": typeof types.CreateBackupJobConfigDocument,
"\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,
@@ -57,6 +62,11 @@ 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 {\n backup {\n id\n jobs {\n id\n type\n stats\n formattedBytes\n formattedSpeed\n formattedElapsedTime\n formattedEta\n }\n }\n }\n": types.BackupJobsDocument,
"\n query BackupJob($jobId: String!) {\n backupJob(jobId: $jobId) {\n id\n type\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 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 createBackupJobConfig(input: $input) {\n id\n name\n sourcePath\n remoteName\n destinationPath\n schedule\n enabled\n createdAt\n updatedAt\n }\n }\n": types.CreateBackupJobConfigDocument,
"\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,
@@ -126,6 +136,26 @@ export function graphql(source: "\n mutation DeleteApiKey($input: DeleteApiKeyI
* 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 ApiKeyMeta {\n apiKeyPossibleRoles\n apiKeyPossiblePermissions {\n resource\n actions\n }\n }\n"): (typeof documents)["\n query ApiKeyMeta {\n apiKeyPossibleRoles\n apiKeyPossiblePermissions {\n resource\n actions\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 BackupJobs {\n backup {\n id\n jobs {\n id\n type\n stats\n formattedBytes\n formattedSpeed\n formattedElapsedTime\n formattedEta\n }\n }\n }\n"): (typeof documents)["\n query BackupJobs {\n backup {\n id\n jobs {\n id\n type\n stats\n formattedBytes\n formattedSpeed\n formattedElapsedTime\n formattedEta\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 BackupJob($jobId: String!) {\n backupJob(jobId: $jobId) {\n id\n type\n stats\n }\n }\n"): (typeof documents)["\n query BackupJob($jobId: String!) {\n backupJob(jobId: $jobId) {\n id\n type\n stats\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"];
/**
* 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 BackupJobConfigForm($input: BackupJobConfigFormInput) {\n backupJobConfigForm(input: $input) {\n id\n dataSchema\n uiSchema\n }\n }\n"): (typeof documents)["\n query BackupJobConfigForm($input: BackupJobConfigFormInput) {\n backupJobConfigForm(input: $input) {\n id\n dataSchema\n uiSchema\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 CreateBackupJobConfig($input: CreateBackupJobConfigInput!) {\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"): (typeof documents)["\n mutation CreateBackupJobConfig($input: CreateBackupJobConfigInput!) {\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"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

View File

@@ -373,6 +373,79 @@ export enum AuthPossession {
OWN_ANY = 'OWN_ANY'
}
export type Backup = Node & {
__typename?: 'Backup';
configs: Array<BackupJobConfig>;
id: Scalars['PrefixedID']['output'];
jobs: Array<BackupJob>;
/** Get the status for the backup service */
status: BackupStatus;
};
export type BackupJob = {
__typename?: 'BackupJob';
/** 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']>;
/** Job ID */
id: Scalars['String']['output'];
/** Job status and statistics */
stats: Scalars['JSON']['output'];
/** Job type (e.g., sync/copy) */
type: Scalars['String']['output'];
};
export type BackupJobConfig = {
__typename?: 'BackupJobConfig';
/** When this config was created */
createdAt: Scalars['DateTime']['output'];
/** Destination path on the remote */
destinationPath: Scalars['String']['output'];
/** Whether this backup job is enabled */
enabled: Scalars['Boolean']['output'];
id: Scalars['PrefixedID']['output'];
/** Last time this job ran */
lastRunAt?: Maybe<Scalars['DateTime']['output']>;
/** Status of last run */
lastRunStatus?: Maybe<Scalars['String']['output']>;
/** Human-readable name for this backup job */
name: Scalars['String']['output'];
/** RClone options (e.g., --transfers, --checkers) */
rcloneOptions?: Maybe<Scalars['JSON']['output']>;
/** Remote name from rclone config */
remoteName: Scalars['String']['output'];
/** Cron schedule expression (e.g., "0 2 * * *" for daily at 2AM) */
schedule: Scalars['String']['output'];
/** Source path to backup */
sourcePath: Scalars['String']['output'];
/** When this config was last updated */
updatedAt: Scalars['DateTime']['output'];
};
export type BackupJobConfigForm = {
__typename?: 'BackupJobConfigForm';
dataSchema: Scalars['JSON']['output'];
id: Scalars['ID']['output'];
uiSchema: Scalars['JSON']['output'];
};
export type BackupJobConfigFormInput = {
showAdvanced?: Scalars['Boolean']['input'];
};
export type BackupStatus = {
__typename?: 'BackupStatus';
/** Job ID if available, can be used to check job status. */
jobId?: Maybe<Scalars['String']['output']>;
/** Status message indicating the outcome of the backup initiation. */
status: Scalars['String']['output'];
};
export type Baseboard = Node & {
__typename?: 'Baseboard';
assetTag?: Maybe<Scalars['String']['output']>;
@@ -525,6 +598,16 @@ export type CreateApiKeyInput = {
roles?: InputMaybe<Array<Role>>;
};
export type CreateBackupJobConfigInput = {
destinationPath: Scalars['String']['input'];
enabled?: Scalars['Boolean']['input'];
name: Scalars['String']['input'];
rcloneOptions?: InputMaybe<Scalars['JSON']['input']>;
remoteName: Scalars['String']['input'];
schedule: Scalars['String']['input'];
sourcePath: Scalars['String']['input'];
};
export type CreateRCloneRemoteInput = {
name: Scalars['String']['input'];
parameters: Scalars['JSON']['input'];
@@ -760,14 +843,6 @@ export type Flash = Node & {
vendor: Scalars['String']['output'];
};
export type FlashBackupStatus = {
__typename?: 'FlashBackupStatus';
/** Job ID if available, can be used to check job status. */
jobId?: Maybe<Scalars['String']['output']>;
/** Status message indicating the outcome of the backup initiation. */
status: Scalars['String']['output'];
};
export type Gpu = Node & {
__typename?: 'Gpu';
blacklisted: Scalars['Boolean']['output'];
@@ -844,14 +919,14 @@ export type InfoMemory = Node & {
used: Scalars['BigInt']['output'];
};
export type InitiateFlashBackupInput = {
export type InitiateBackupInput = {
/** Destination path on the remote. */
destinationPath: Scalars['String']['input'];
/** Additional options for the backup operation, such as --dry-run or --transfers. */
options?: InputMaybe<Scalars['JSON']['input']>;
/** The name of the remote configuration to use for the backup. */
remoteName: Scalars['String']['input'];
/** Source path to backup (typically the flash drive). */
/** Source path to backup. */
sourcePath: Scalars['String']['input'];
};
@@ -926,15 +1001,19 @@ export type Mutation = {
array: ArrayMutations;
connectSignIn: Scalars['Boolean']['output'];
connectSignOut: Scalars['Boolean']['output'];
/** Create a new backup job configuration */
createBackupJobConfig: BackupJobConfig;
/** Creates a new notification record */
createNotification: Notification;
/** Deletes all archived notifications on server. */
deleteArchivedNotifications: NotificationOverview;
/** Delete a backup job configuration */
deleteBackupJobConfig: Scalars['Boolean']['output'];
deleteNotification: NotificationOverview;
docker: DockerMutations;
enableDynamicRemoteAccess: Scalars['Boolean']['output'];
/** Initiates a flash drive backup using a configured remote. */
initiateFlashBackup: FlashBackupStatus;
/** Initiates a backup using a configured remote. */
initiateBackup: BackupStatus;
parityCheck: ParityCheckMutations;
rclone: RCloneMutations;
/** Reads each notification to recompute & update the overview. */
@@ -947,6 +1026,8 @@ export type Mutation = {
/** Marks a notification as unread. */
unreadNotification: Notification;
updateApiSettings: ConnectSettingsValues;
/** Update a backup job configuration */
updateBackupJobConfig?: Maybe<BackupJobConfig>;
vm: VmMutations;
};
@@ -971,11 +1052,21 @@ export type MutationConnectSignInArgs = {
};
export type MutationCreateBackupJobConfigArgs = {
input: CreateBackupJobConfigInput;
};
export type MutationCreateNotificationArgs = {
input: NotificationData;
};
export type MutationDeleteBackupJobConfigArgs = {
id: Scalars['String']['input'];
};
export type MutationDeleteNotificationArgs = {
id: Scalars['PrefixedID']['input'];
type: NotificationType;
@@ -987,8 +1078,8 @@ export type MutationEnableDynamicRemoteAccessArgs = {
};
export type MutationInitiateFlashBackupArgs = {
input: InitiateFlashBackupInput;
export type MutationInitiateBackupArgs = {
input: InitiateBackupInput;
};
@@ -1021,6 +1112,12 @@ export type MutationUpdateApiSettingsArgs = {
input: ApiSettingsInput;
};
export type MutationUpdateBackupJobConfigArgs = {
id: Scalars['String']['input'];
input: UpdateBackupJobConfigInput;
};
export type Network = Node & {
__typename?: 'Network';
accessUrls?: Maybe<Array<AccessUrl>>;
@@ -1210,6 +1307,14 @@ export type Query = {
apiKeyPossibleRoles: Array<Role>;
apiKeys: Array<ApiKey>;
array: UnraidArray;
/** Get backup service information */
backup: Backup;
/** Get status of a specific backup job */
backupJob?: Maybe<BackupJob>;
/** Get a specific backup job configuration */
backupJobConfig?: Maybe<BackupJobConfig>;
/** Get the JSON schema for backup job configuration form */
backupJobConfigForm: BackupJobConfigForm;
cloud: Cloud;
config: Config;
connect: Connect;
@@ -1252,6 +1357,21 @@ export type QueryApiKeyArgs = {
};
export type QueryBackupJobArgs = {
jobId: Scalars['String']['input'];
};
export type QueryBackupJobConfigArgs = {
id: Scalars['String']['input'];
};
export type QueryBackupJobConfigFormArgs = {
input?: InputMaybe<BackupJobConfigFormInput>;
};
export type QueryDiskArgs = {
id: Scalars['PrefixedID']['input'];
};
@@ -1588,6 +1708,16 @@ export type UnraidArray = Node & {
state: ArrayState;
};
export type UpdateBackupJobConfigInput = {
destinationPath?: InputMaybe<Scalars['String']['input']>;
enabled?: InputMaybe<Scalars['Boolean']['input']>;
name?: InputMaybe<Scalars['String']['input']>;
rcloneOptions?: InputMaybe<Scalars['JSON']['input']>;
remoteName?: InputMaybe<Scalars['String']['input']>;
schedule?: InputMaybe<Scalars['String']['input']>;
sourcePath?: InputMaybe<Scalars['String']['input']>;
};
export type Uptime = {
__typename?: 'Uptime';
timestamp?: Maybe<Scalars['String']['output']>;
@@ -1950,6 +2080,37 @@ 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<{ [key: string]: never; }>;
export type BackupJobsQuery = { __typename?: 'Query', backup: { __typename?: 'Backup', id: string, jobs: Array<{ __typename?: 'BackupJob', id: string, type: string, stats: any, formattedBytes?: string | null, formattedSpeed?: string | null, formattedElapsedTime?: string | null, formattedEta?: string | null }> } };
export type BackupJobQueryVariables = Exact<{
jobId: Scalars['String']['input'];
}>;
export type BackupJobQuery = { __typename?: 'Query', backupJob?: { __typename?: 'BackupJob', id: string, type: string, stats: any } | 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 BackupJobConfigFormQueryVariables = Exact<{
input?: InputMaybe<BackupJobConfigFormInput>;
}>;
export type BackupJobConfigFormQuery = { __typename?: 'Query', backupJobConfigForm: { __typename?: 'BackupJobConfigForm', id: string, dataSchema: any, uiSchema: any } };
export type CreateBackupJobConfigMutationVariables = Exact<{
input: CreateBackupJobConfigInput;
}>;
export type CreateBackupJobConfigMutation = { __typename?: 'Mutation', createBackupJobConfig: { __typename?: 'BackupJobConfig', id: string, name: string, sourcePath: string, remoteName: string, destinationPath: string, schedule: string, enabled: boolean, createdAt: string, updatedAt: string } };
export type GetConnectSettingsFormQueryVariables = Exact<{ [key: string]: never; }>;
@@ -2146,6 +2307,11 @@ 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"},"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":"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<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":"String"}}}}],"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":"type"}},{"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 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":"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 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

@@ -1,5 +1,5 @@
<script setup>
import RCloneConfig from '~/components/RClone/RCloneConfig.vue';
import BackupOverview from '~/components/Backup/BackupOverview.vue';
import RCloneOverview from '~/components/RClone/RCloneOverview.vue';
import { useDummyServerStore } from '~/_data/serverState';
@@ -18,7 +18,7 @@ onMounted(() => {
<template>
<div>
<BackupOverview />
<RCloneOverview />
<RCloneConfig />
</div>
</template>