mirror of
https://github.com/unraid/api.git
synced 2026-02-20 06:58:29 -06:00
chore: begin setting up new views and mutations for flash backup
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
[api]
|
||||
version="4.4.1"
|
||||
version="4.8.0"
|
||||
extraOrigins="https://google.com,https://test.com"
|
||||
[local]
|
||||
sandbox="yes"
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
242
api/src/unraid-api/graph/resolvers/backup/backup.model.ts
Normal file
242
api/src/unraid-api/graph/resolvers/backup/backup.model.ts
Normal 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;
|
||||
}
|
||||
14
api/src/unraid-api/graph/resolvers/backup/backup.module.ts
Normal file
14
api/src/unraid-api/graph/resolvers/backup/backup.module.ts
Normal 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 {}
|
||||
201
api/src/unraid-api/graph/resolvers/backup/backup.resolver.ts
Normal file
201
api/src/unraid-api/graph/resolvers/backup/backup.resolver.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
20
api/src/unraid-api/graph/resolvers/backup/format.service.ts
Normal file
20
api/src/unraid-api/graph/resolvers/backup/format.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
183
web/components/Backup/BackupJobConfig.vue
Normal file
183
web/components/Backup/BackupJobConfig.vue
Normal 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>
|
||||
240
web/components/Backup/BackupJobConfigForm.vue
Normal file
240
web/components/Backup/BackupJobConfigForm.vue
Normal 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>
|
||||
170
web/components/Backup/BackupOverview.vue
Normal file
170
web/components/Backup/BackupOverview.vue
Normal 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>
|
||||
75
web/components/Backup/backup-jobs.query.ts
Normal file
75
web/components/Backup/backup-jobs.query.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`);
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user