mirror of
https://github.com/unraid/api.git
synced 2026-02-16 13:08:28 -06:00
chore: progress on rclone
This commit is contained in:
@@ -13,6 +13,8 @@ PATHS_PARITY_CHECKS=./dev/states/parity-checks.log
|
||||
PATHS_CONFIG_MODULES=./dev/configs
|
||||
PATHS_ACTIVATION_BASE=./dev/activation
|
||||
PATHS_PASSWD=./dev/passwd
|
||||
PATHS_RCLONE_SOCKET=./dev/rclone-socket
|
||||
PATHS_LOG_BASE=./dev/log # Where we store logs
|
||||
ENVIRONMENT="development"
|
||||
NODE_ENV="development"
|
||||
PORT="3001"
|
||||
|
||||
@@ -1299,20 +1299,15 @@ type DockerContainer implements Node {
|
||||
|
||||
"""Total size of all the files in the container"""
|
||||
sizeRootFs: Int
|
||||
labels: JSONObject
|
||||
labels: JSON
|
||||
state: ContainerState!
|
||||
status: String!
|
||||
hostConfig: ContainerHostConfig
|
||||
networkSettings: JSONObject
|
||||
mounts: [JSONObject!]
|
||||
networkSettings: JSON
|
||||
mounts: [JSON!]
|
||||
autoStart: Boolean!
|
||||
}
|
||||
|
||||
"""
|
||||
The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
|
||||
"""
|
||||
scalar JSONObject @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf")
|
||||
|
||||
enum ContainerState {
|
||||
RUNNING
|
||||
EXITED
|
||||
@@ -1325,15 +1320,15 @@ type DockerNetwork implements Node {
|
||||
scope: String!
|
||||
driver: String!
|
||||
enableIPv6: Boolean!
|
||||
ipam: JSONObject!
|
||||
ipam: JSON!
|
||||
internal: Boolean!
|
||||
attachable: Boolean!
|
||||
ingress: Boolean!
|
||||
configFrom: JSONObject!
|
||||
configFrom: JSON!
|
||||
configOnly: Boolean!
|
||||
containers: JSONObject!
|
||||
options: JSONObject!
|
||||
labels: JSONObject!
|
||||
containers: JSON!
|
||||
options: JSON!
|
||||
labels: JSON!
|
||||
}
|
||||
|
||||
type Docker implements Node {
|
||||
@@ -1342,6 +1337,64 @@ 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 RCloneProviderOption {
|
||||
name: String!
|
||||
help: String!
|
||||
provider: String!
|
||||
default: JSON
|
||||
value: JSON
|
||||
shortOpt: String
|
||||
hide: Boolean
|
||||
required: Boolean
|
||||
isPassword: Boolean
|
||||
noPrefix: Boolean
|
||||
advanced: Boolean
|
||||
defaultStr: String
|
||||
valueStr: String
|
||||
type: String
|
||||
examples: [RCloneProviderOptionExample!]
|
||||
}
|
||||
|
||||
type RCloneProviderOptionExample {
|
||||
value: String!
|
||||
help: String!
|
||||
provider: String!
|
||||
}
|
||||
|
||||
type RCloneBackupConfigForm {
|
||||
id: ID!
|
||||
dataSchema: JSON!
|
||||
uiSchema: JSON!
|
||||
}
|
||||
|
||||
type RCloneBackupSettings {
|
||||
configForm: RCloneBackupConfigForm!
|
||||
drives: [RCloneDrive!]!
|
||||
remotes: [String!]!
|
||||
}
|
||||
|
||||
type RCloneRemote {
|
||||
name: String!
|
||||
type: String!
|
||||
config: JSON!
|
||||
}
|
||||
|
||||
type Flash implements Node {
|
||||
id: PrefixedID!
|
||||
guid: String!
|
||||
@@ -1543,6 +1596,7 @@ type Query {
|
||||
docker: Docker!
|
||||
disks: [Disk!]!
|
||||
disk(id: PrefixedID!): Disk!
|
||||
rcloneBackup: RCloneBackupSettings!
|
||||
health: String!
|
||||
getDemo: String!
|
||||
}
|
||||
@@ -1578,6 +1632,10 @@ type Mutation {
|
||||
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!
|
||||
createRCloneRemote(input: CreateRCloneRemoteInput!): RCloneRemote!
|
||||
setDemo: String!
|
||||
}
|
||||
|
||||
@@ -1674,6 +1732,28 @@ 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
|
||||
}
|
||||
|
||||
input CreateRCloneRemoteInput {
|
||||
name: String!
|
||||
type: String!
|
||||
config: JSON!
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
displaySubscription: Display!
|
||||
infoSubscription: Info!
|
||||
|
||||
@@ -21,6 +21,9 @@ const initialState = {
|
||||
),
|
||||
'docker-autostart': '/var/lib/docker/unraid-autostart' as const,
|
||||
'docker-socket': '/var/run/docker.sock' as const,
|
||||
'rclone-socket': resolvePath(
|
||||
process.env.PATHS_RCLONE_SOCKET ?? ('/var/run/rclone.socket' as const)
|
||||
),
|
||||
'parity-checks': resolvePath(
|
||||
process.env.PATHS_PARITY_CHECKS ?? ('/boot/config/parity-checks.log' as const)
|
||||
),
|
||||
@@ -54,8 +57,8 @@ const initialState = {
|
||||
('/boot/config/plugins/dynamix.my.servers/fb_keepalive' as const),
|
||||
'keyfile-base': resolvePath(process.env.PATHS_KEYFILE_BASE ?? ('/boot/config' as const)),
|
||||
'machine-id': resolvePath(process.env.PATHS_MACHINE_ID ?? ('/var/lib/dbus/machine-id' as const)),
|
||||
'log-base': resolvePath('/var/log/unraid-api/' as const),
|
||||
'unraid-log-base': resolvePath('/var/log/' as const),
|
||||
'log-base': process.env.PATHS_LOG_BASE ?? resolvePath('/var/log/unraid-api/' as const),
|
||||
'unraid-log-base': process.env.PATHS_UNRAID_LOG_BASE ?? resolvePath('/var/log/' as const),
|
||||
'var-run': '/var/run' as const,
|
||||
// contains sess_ files that correspond to authenticated user sessions
|
||||
'auth-sessions': process.env.PATHS_AUTH_SESSIONS ?? '/var/lib/php',
|
||||
|
||||
@@ -52,7 +52,7 @@ export class AuthService {
|
||||
|
||||
async validateCookiesWithCsrfToken(request: FastifyRequest): Promise<UserAccount> {
|
||||
try {
|
||||
if (!this.validateCsrfToken(request.headers['x-csrf-token'] || request.query.csrf_token)) {
|
||||
if (request.method !== 'GET' && !request.url.startsWith('/graphql/api/rclone-webgui/') && (!this.validateCsrfToken(request.headers['x-csrf-token'] || request.query.csrf_token))) {
|
||||
throw new UnauthorizedException('Invalid CSRF token');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Layout } from '@jsonforms/core';
|
||||
import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars';
|
||||
import { type Layout } from '@jsonforms/core';
|
||||
import { GraphQLJSON } from 'graphql-scalars';
|
||||
|
||||
import { getAllowedOrigins } from '@app/common/allowed-origins.js';
|
||||
import { setupRemoteAccessThunk } from '@app/store/actions/setup-remote-access.js';
|
||||
@@ -46,7 +46,7 @@ export class ConnectSettingsResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@ResolveField(() => GraphQLJSONObject)
|
||||
@ResolveField(() => GraphQLJSON)
|
||||
public async uiSchema(): Promise<Layout> {
|
||||
const { elements } = await this.connectSettingsService.buildSettingsSchema();
|
||||
return {
|
||||
|
||||
@@ -95,8 +95,8 @@ export class ConnectSettingsService {
|
||||
getState: store.getState,
|
||||
dispatch: store.dispatch,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async isSignedIn(): Promise<boolean> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Field, ID, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { GraphQLJSONObject, GraphQLPort } from 'graphql-scalars';
|
||||
import { GraphQLJSON, GraphQLPort } from 'graphql-scalars';
|
||||
|
||||
import { Node } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
|
||||
@@ -93,7 +93,7 @@ export class DockerContainer extends Node {
|
||||
@Field(() => Int, { nullable: true, description: 'Total size of all the files in the container' })
|
||||
sizeRootFs?: number;
|
||||
|
||||
@Field(() => GraphQLJSONObject, { nullable: true })
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
labels?: Record<string, any>;
|
||||
|
||||
@Field(() => ContainerState)
|
||||
@@ -105,10 +105,10 @@ export class DockerContainer extends Node {
|
||||
@Field(() => ContainerHostConfig, { nullable: true })
|
||||
hostConfig?: ContainerHostConfig;
|
||||
|
||||
@Field(() => GraphQLJSONObject, { nullable: true })
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
networkSettings?: Record<string, any>;
|
||||
|
||||
@Field(() => [GraphQLJSONObject], { nullable: true })
|
||||
@Field(() => [GraphQLJSON], { nullable: true })
|
||||
mounts?: Record<string, any>[];
|
||||
|
||||
@Field(() => Boolean)
|
||||
@@ -132,7 +132,7 @@ export class DockerNetwork extends Node {
|
||||
@Field(() => Boolean)
|
||||
enableIPv6!: boolean;
|
||||
|
||||
@Field(() => GraphQLJSONObject)
|
||||
@Field(() => GraphQLJSON)
|
||||
ipam!: Record<string, any>;
|
||||
|
||||
@Field(() => Boolean)
|
||||
@@ -144,19 +144,19 @@ export class DockerNetwork extends Node {
|
||||
@Field(() => Boolean)
|
||||
ingress!: boolean;
|
||||
|
||||
@Field(() => GraphQLJSONObject)
|
||||
@Field(() => GraphQLJSON)
|
||||
configFrom!: Record<string, any>;
|
||||
|
||||
@Field(() => Boolean)
|
||||
configOnly!: boolean;
|
||||
|
||||
@Field(() => GraphQLJSONObject)
|
||||
@Field(() => GraphQLJSON)
|
||||
containers!: Record<string, any>;
|
||||
|
||||
@Field(() => GraphQLJSONObject)
|
||||
@Field(() => GraphQLJSON)
|
||||
options!: Record<string, any>;
|
||||
|
||||
@Field(() => GraphQLJSONObject)
|
||||
@Field(() => GraphQLJSON)
|
||||
labels!: Record<string, any>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { FlashBackupResolver } from './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 {}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||
|
||||
|
||||
import {
|
||||
FlashBackupStatus,
|
||||
InitiateFlashBackupInput,
|
||||
} from './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');
|
||||
}
|
||||
}
|
||||
4237
api/src/unraid-api/graph/resolvers/rclone/jsonforms/config.ts
Normal file
4237
api/src/unraid-api/graph/resolvers/rclone/jsonforms/config.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,427 @@
|
||||
import { RuleEffect, type SchemaBasedCondition, type JsonSchema, JsonSchema7 } from '@jsonforms/core';
|
||||
import type { DataSlice, SettingSlice, UIElement } from '@app/unraid-api/types/json-forms.js';
|
||||
import { mergeSettingSlices } from '@app/unraid-api/types/json-forms.js';
|
||||
import { config as rcloneConfig } from './config.js';
|
||||
|
||||
/**
|
||||
* Type definitions for RClone config options
|
||||
*/
|
||||
export interface RCloneOptionDef {
|
||||
Name: string;
|
||||
Help: string;
|
||||
Provider: string;
|
||||
Default: any;
|
||||
Value: any | null;
|
||||
ShortOpt: string;
|
||||
Hide: number;
|
||||
Required: boolean;
|
||||
IsPassword: boolean;
|
||||
NoPrefix: boolean;
|
||||
Advanced: boolean;
|
||||
Type?: string;
|
||||
Options?: string[];
|
||||
}
|
||||
|
||||
export interface RCloneProviderDef {
|
||||
Name: string;
|
||||
Description: string;
|
||||
Prefix: string;
|
||||
Options: RCloneOptionDef[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates RClone config option to JsonSchema properties
|
||||
*/
|
||||
function translateRCloneOptionToJsonSchema(option: RCloneOptionDef): JsonSchema7 {
|
||||
const schema: JsonSchema7 = {
|
||||
type: getJsonSchemaType(option.Type || 'string'),
|
||||
title: option.Name,
|
||||
description: option.Help || '',
|
||||
};
|
||||
|
||||
// Add default value if available
|
||||
if (option.Default !== undefined && option.Default !== '') {
|
||||
schema.default = option.Default;
|
||||
}
|
||||
|
||||
// Add enum values if available
|
||||
if (option.Options && option.Options.length > 0) {
|
||||
schema.enum = option.Options;
|
||||
}
|
||||
|
||||
// Add validation constraints
|
||||
if (option.Required) {
|
||||
if (schema.type === 'string') {
|
||||
schema.minLength = 1;
|
||||
} else if (schema.type === 'number') {
|
||||
schema.minimum = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available provider types from RClone config
|
||||
*/
|
||||
export function getAvailableProviderTypes(): string[] {
|
||||
return rcloneConfig.map(provider => provider.Name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider options for a specific provider
|
||||
*/
|
||||
export function getProviderOptions(providerName: string): Record<string, RCloneOptionDef> {
|
||||
const provider = rcloneConfig.find(p => p.Name === providerName);
|
||||
if (!provider) return {};
|
||||
|
||||
return provider.Options.reduce((acc, option) => {
|
||||
acc[option.Name] = option;
|
||||
return acc;
|
||||
}, {} as Record<string, RCloneOptionDef>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the UI schema for RClone remote configuration
|
||||
*/
|
||||
export function getRcloneConfigFormSchema(
|
||||
providerTypes: string[] = [],
|
||||
selectedProvider: string = '',
|
||||
providerOptions: Record<string, RCloneOptionDef> = {}
|
||||
): SettingSlice {
|
||||
// If provider types not provided, get from config
|
||||
if (providerTypes.length === 0) {
|
||||
providerTypes = getAvailableProviderTypes();
|
||||
}
|
||||
|
||||
// Combine all form slices for the complete schema
|
||||
const slices = [
|
||||
getBasicConfigSlice(providerTypes),
|
||||
getProviderConfigSlice(selectedProvider, providerOptions),
|
||||
getAdvancedConfigSlice(selectedProvider, providerOptions),
|
||||
];
|
||||
|
||||
return mergeSettingSlices(slices);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1: Basic configuration - name and type selection
|
||||
*/
|
||||
function getBasicConfigSlice(providerTypes: string[]): SettingSlice {
|
||||
// Create UI elements for basic configuration (Step 1)
|
||||
const basicConfigElements: UIElement[] = [
|
||||
{
|
||||
type: 'Control',
|
||||
scope: '#/properties/name',
|
||||
label: 'Name of this remote (For your reference)',
|
||||
options: {
|
||||
placeholder: 'Enter a name',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'Control',
|
||||
scope: '#/properties/type',
|
||||
label: 'Storage Provider Type',
|
||||
options: {
|
||||
format: 'dropdown',
|
||||
description: 'Select the cloud storage provider to use for this remote.',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'Label',
|
||||
text: 'Documentation',
|
||||
options: {
|
||||
format: 'documentation',
|
||||
description: 'For more information, refer to the <a href="https://rclone.org/commands/rclone_config/" target="_blank">RClone Config Documentation</a>.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Define the data schema for basic configuration
|
||||
const basicConfigProperties: Record<string, JsonSchema7> = {
|
||||
name: {
|
||||
type: 'string',
|
||||
title: 'Remote Name',
|
||||
description: 'Name to identify this remote configuration',
|
||||
pattern: '^[a-zA-Z0-9_-]+$',
|
||||
minLength: 1,
|
||||
maxLength: 50,
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
title: 'Provider Type',
|
||||
default: providerTypes.length > 0 ? providerTypes[0] : '',
|
||||
oneOf: providerTypes.map(type => ({ const: type, title: type }))
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
properties: basicConfigProperties as unknown as DataSlice,
|
||||
elements: basicConfigElements,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2: Provider-specific configuration based on the selected provider
|
||||
*/
|
||||
function getProviderConfigSlice(
|
||||
selectedProvider: string,
|
||||
providerOptions: Record<string, RCloneOptionDef>
|
||||
): SettingSlice {
|
||||
// Default elements for when a provider isn't selected or options aren't loaded
|
||||
let providerConfigElements: UIElement[] = [
|
||||
{
|
||||
type: 'Label',
|
||||
text: 'Provider Configuration',
|
||||
options: {
|
||||
format: 'loading',
|
||||
description: 'Select a provider type first to see provider-specific options.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Default properties when no provider is selected
|
||||
let providerConfigProperties: Record<string, JsonSchema7> = {};
|
||||
|
||||
// If we have a selected provider and options for it
|
||||
if (selectedProvider && Object.keys(providerOptions).length > 0) {
|
||||
// Create dynamic UI elements based on provider options
|
||||
providerConfigElements = Object.entries(providerOptions).map(([key, option]) => {
|
||||
if (option.Advanced === true) {
|
||||
return null; // Skip advanced options for this step
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'Control',
|
||||
scope: `#/properties/parameters/properties/${key}`,
|
||||
label: option.Help || key,
|
||||
options: {
|
||||
placeholder: option.Default?.toString() || '',
|
||||
description: option.Help || '',
|
||||
required: option.Required || false,
|
||||
format: getFormatForType(option.Type, option.Options),
|
||||
hide: option.Hide === 1,
|
||||
},
|
||||
};
|
||||
}).filter(Boolean) as UIElement[];
|
||||
|
||||
// No options available case
|
||||
if (providerConfigElements.length === 0) {
|
||||
providerConfigElements = [
|
||||
{
|
||||
type: 'Label',
|
||||
text: 'No Configuration Required',
|
||||
options: {
|
||||
description: 'This provider does not require additional configuration, or all options are advanced.',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Create dynamic properties schema based on provider options
|
||||
const paramProperties: Record<string, JsonSchema7> = {};
|
||||
|
||||
Object.entries(providerOptions).forEach(([key, option]) => {
|
||||
if (option.Advanced === true) {
|
||||
return; // Skip advanced options for this step
|
||||
}
|
||||
|
||||
paramProperties[key] = translateRCloneOptionToJsonSchema(option);
|
||||
});
|
||||
|
||||
providerConfigProperties = {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: paramProperties,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
properties: providerConfigProperties as unknown as DataSlice,
|
||||
elements: providerConfigElements,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3: Advanced configuration options for the selected provider
|
||||
*/
|
||||
function getAdvancedConfigSlice(
|
||||
selectedProvider: string,
|
||||
providerOptions: Record<string, RCloneOptionDef>
|
||||
): SettingSlice {
|
||||
// Default elements when no advanced options are available
|
||||
let advancedConfigElements: UIElement[] = [
|
||||
{
|
||||
type: 'Label',
|
||||
text: 'Advanced Configuration',
|
||||
options: {
|
||||
format: 'note',
|
||||
description: 'No advanced options available for this provider.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Default properties
|
||||
let advancedConfigProperties: Record<string, JsonSchema7> = {};
|
||||
|
||||
// If we have a selected provider and options
|
||||
if (selectedProvider && Object.keys(providerOptions).length > 0) {
|
||||
// Create dynamic UI elements for advanced options
|
||||
const advancedElements = Object.entries(providerOptions).map(([key, option]) => {
|
||||
if (!option.Advanced) {
|
||||
return null; // Skip non-advanced options
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'Control',
|
||||
scope: `#/properties/parameters/properties/${key}`,
|
||||
label: option.Help || key,
|
||||
options: {
|
||||
placeholder: option.Default?.toString() || '',
|
||||
description: option.Help || '',
|
||||
required: option.Required || false,
|
||||
format: getFormatForType(option.Type, option.Options),
|
||||
hide: option.Hide === 1,
|
||||
},
|
||||
};
|
||||
}).filter(Boolean) as UIElement[];
|
||||
|
||||
// Use default message if no advanced options
|
||||
if (advancedElements.length > 0) {
|
||||
advancedConfigElements = advancedElements;
|
||||
}
|
||||
|
||||
// Create dynamic properties schema for advanced options
|
||||
const advancedProperties: Record<string, JsonSchema7> = {};
|
||||
|
||||
Object.entries(providerOptions).forEach(([key, option]) => {
|
||||
if (!option.Advanced) {
|
||||
return; // Skip non-advanced options
|
||||
}
|
||||
|
||||
advancedProperties[key] = translateRCloneOptionToJsonSchema(option);
|
||||
});
|
||||
|
||||
// Only add if we have advanced options
|
||||
if (Object.keys(advancedProperties).length > 0) {
|
||||
advancedConfigProperties = {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: advancedProperties,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
properties: advancedConfigProperties as unknown as DataSlice,
|
||||
elements: advancedConfigElements,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to convert RClone option types to JSON Schema types
|
||||
*/
|
||||
function getJsonSchemaType(rcloneType: string): string {
|
||||
switch (rcloneType?.toLowerCase()) {
|
||||
case 'int':
|
||||
case 'size':
|
||||
case 'duration':
|
||||
return 'number';
|
||||
case 'bool':
|
||||
return 'boolean';
|
||||
case 'string':
|
||||
case 'text':
|
||||
default:
|
||||
return 'string';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get the appropriate UI format based on RClone option type
|
||||
*/
|
||||
function getFormatForType(rcloneType: string = '', options: string[] | null = null): string {
|
||||
if (options && options.length > 0) {
|
||||
return 'dropdown';
|
||||
}
|
||||
|
||||
switch (rcloneType?.toLowerCase()) {
|
||||
case 'int':
|
||||
case 'size':
|
||||
return 'number';
|
||||
case 'duration':
|
||||
return 'duration';
|
||||
case 'bool':
|
||||
return 'checkbox';
|
||||
case 'password':
|
||||
return 'password';
|
||||
case 'text':
|
||||
return 'textarea';
|
||||
default:
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a combined form schema for the rclone backup configuration UI
|
||||
*/
|
||||
export function getRcloneConfigSlice(): SettingSlice {
|
||||
const elements: UIElement[] = [
|
||||
{
|
||||
type: 'Label',
|
||||
text: 'Configure RClone Backup',
|
||||
options: {
|
||||
format: 'title',
|
||||
description: 'This 3-step process will guide you through setting up your RClone backup configuration.',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'Control',
|
||||
scope: '#/properties/configStep',
|
||||
label: 'Configuration Step',
|
||||
options: {
|
||||
format: 'stepper',
|
||||
steps: [
|
||||
{ label: 'Set up Remote Config', description: 'Name and provider selection' },
|
||||
{ label: 'Set up Drive', description: 'Provider-specific configuration' },
|
||||
{ label: 'Advanced Config', description: 'Optional advanced settings' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'Control',
|
||||
scope: '#/properties/showAdvanced',
|
||||
label: 'Edit Advanced Options',
|
||||
options: {
|
||||
format: 'checkbox',
|
||||
},
|
||||
rule: {
|
||||
effect: RuleEffect.SHOW,
|
||||
condition: {
|
||||
scope: '#/properties/configStep',
|
||||
schema: { enum: [1] } // Only show on step 2
|
||||
} as SchemaBasedCondition,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Basic properties for the rclone backup configuration
|
||||
const properties: Record<string, JsonSchema7> = {
|
||||
configStep: {
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
maximum: 2,
|
||||
default: 0,
|
||||
},
|
||||
showAdvanced: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
properties: properties as unknown as DataSlice,
|
||||
elements,
|
||||
};
|
||||
}
|
||||
272
api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts
Normal file
272
api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import crypto from 'crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import { execa } from 'execa';
|
||||
import got from 'got';
|
||||
import { RCloneProvider, RCloneProviderOption, RCloneProviderOptionExample, RCloneProviderResponse, RCloneProviderOptionResponse, RCloneProviderTypes } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
|
||||
|
||||
@Injectable()
|
||||
export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
|
||||
private isInitialized: boolean = false;
|
||||
private readonly logger = new Logger(RCloneApiService.name);
|
||||
private rcloneSocketPath: string = '';
|
||||
private rcloneBaseUrl: string = '';
|
||||
private rcloneUsername: string = 'unraid-rclone';
|
||||
private rclonePassword: string = crypto.randomBytes(32).toString('hex');
|
||||
constructor() {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
const { getters } = await import('@app/store/index.js');
|
||||
// Check if Rclone Socket is running, if not, start it.
|
||||
this.rcloneSocketPath = getters.paths()['rclone-socket'];
|
||||
const logFilePath = join(getters.paths()['log-base'], 'rclone-unraid-api.log');
|
||||
this.logger.log(`RClone socket path: ${this.rcloneSocketPath}`);
|
||||
this.logger.log(`RClone log file path: ${logFilePath}`);
|
||||
|
||||
// Format the base URL for Unix socket
|
||||
this.rcloneBaseUrl = `http://unix:${this.rcloneSocketPath}:/`;
|
||||
|
||||
const socketRunning = await this.checkRcloneSocket(this.rcloneSocketPath);
|
||||
if (!socketRunning) {
|
||||
this.startRcloneSocket(this.rcloneSocketPath, logFilePath);
|
||||
}
|
||||
this.isInitialized = true;
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(`Error initializing FlashBackupService: ${error}`);
|
||||
this.isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
await this.stopRcloneSocket(this.rcloneSocketPath);
|
||||
}
|
||||
this.logger.log('FlashBackupService module destroyed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the RClone RC daemon on the specified socket path
|
||||
*/
|
||||
private async startRcloneSocket(socketPath: string, logFilePath: string): Promise<void> {
|
||||
// Make log file exists
|
||||
if (!existsSync(logFilePath)) {
|
||||
this.logger.debug(`Creating log file: ${logFilePath}`);
|
||||
await mkdir(dirname(logFilePath), { recursive: true });
|
||||
await writeFile(logFilePath, '', 'utf-8');
|
||||
}
|
||||
this.logger.log(`Starting RClone RC daemon on socket: ${socketPath}`);
|
||||
await execa('rclone', [
|
||||
'rcd',
|
||||
'--rc-addr',
|
||||
socketPath,
|
||||
'--log-level',
|
||||
'INFO',
|
||||
'--rc-user',
|
||||
this.rcloneUsername,
|
||||
'--rc-pass',
|
||||
this.rclonePassword,
|
||||
'--log-file',
|
||||
logFilePath,
|
||||
'--rc-web-gui',
|
||||
'--rc-web-gui-no-open-browser',
|
||||
]);
|
||||
}
|
||||
|
||||
private async stopRcloneSocket(socketPath: string): Promise<void> {
|
||||
this.logger.log(`Stopping RClone RC daemon on socket: ${socketPath}`);
|
||||
execa('rclone', ['rcd', '--rc-addr', socketPath, '--stop']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the RClone socket exists and is running
|
||||
*/
|
||||
private async checkRcloneSocket(socketPath: string): Promise<boolean> {
|
||||
const socketExists = existsSync(socketPath);
|
||||
if (!socketExists) {
|
||||
this.logger.warn(`RClone socket does not exist at: ${socketPath}`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const socketIsRunning = await execa('rclone', ['about']);
|
||||
return socketIsRunning.exitCode === 0;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(`Error checking RClone socket: ${errorMessage}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get providers supported by RClone
|
||||
*/
|
||||
async getProviders(): Promise<RCloneProvider[]> {
|
||||
const response = await this.callRcloneApi('config/providers') as { providers: RCloneProviderResponse[] };
|
||||
return response?.providers?.map(provider => ({
|
||||
name: provider.Name,
|
||||
description: provider.Description,
|
||||
prefix: provider.Prefix,
|
||||
options: this.mapProviderOptions(provider.Options),
|
||||
})) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider types as a simple array
|
||||
*/
|
||||
async getProviderTypes(): Promise<RCloneProviderTypes> {
|
||||
const providers = await this.getProviders();
|
||||
const types: string[] = providers.map(provider => provider.prefix);
|
||||
return { types };
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps RClone provider options from API format to our model format
|
||||
*/
|
||||
private mapProviderOptions(options: RCloneProviderOptionResponse[]): RCloneProviderOption[] {
|
||||
return options.map(option => ({
|
||||
name: option.Name,
|
||||
help: option.Help,
|
||||
provider: option.Provider,
|
||||
default: option.Default,
|
||||
value: option.Value,
|
||||
shortOpt: option.ShortOpt,
|
||||
hide: option.Hide,
|
||||
required: option.Required,
|
||||
isPassword: option.IsPassword,
|
||||
noPrefix: option.NoPrefix,
|
||||
advanced: option.Advanced,
|
||||
defaultStr: option.DefaultStr,
|
||||
valueStr: option.ValueStr,
|
||||
type: option.Type,
|
||||
examples: option.Examples?.map(example => ({
|
||||
value: example.Value,
|
||||
help: example.Help,
|
||||
provider: example.Provider,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* List all configured remotes
|
||||
*/
|
||||
async listRemotes(): Promise<string[]> {
|
||||
const response = await this.callRcloneApi('config/listremotes');
|
||||
return response.remotes || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed config for a specific remote
|
||||
*/
|
||||
async getRemoteConfig(name: string): Promise<any> {
|
||||
return this.callRcloneApi('config/get', { name });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new remote configuration
|
||||
*/
|
||||
async createRemote(name: string, type: string, parameters: Record<string, any> = {}): Promise<any> {
|
||||
// Combine the required parameters for the create request
|
||||
const params = {
|
||||
name,
|
||||
type,
|
||||
...parameters,
|
||||
};
|
||||
|
||||
this.logger.log(`Creating new remote: ${name} of type: ${type}`);
|
||||
return this.callRcloneApi('config/create', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing remote configuration
|
||||
*/
|
||||
async updateRemote(name: string, parameters: Record<string, any> = {}): Promise<any> {
|
||||
const params = {
|
||||
name,
|
||||
...parameters,
|
||||
};
|
||||
|
||||
this.logger.log(`Updating remote: ${name}`);
|
||||
return this.callRcloneApi('config/update', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a remote configuration
|
||||
*/
|
||||
async deleteRemote(name: string): Promise<any> {
|
||||
this.logger.log(`Deleting remote: ${name}`);
|
||||
return this.callRcloneApi('config/delete', { name });
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a backup operation using sync/copy
|
||||
* This copies a directory from source to destination
|
||||
*/
|
||||
async startBackup(
|
||||
srcPath: string,
|
||||
dstPath: string,
|
||||
options: Record<string, any> = {}
|
||||
): Promise<any> {
|
||||
this.logger.log(`Starting backup from ${srcPath} to ${dstPath}`);
|
||||
const params = {
|
||||
srcFs: srcPath,
|
||||
dstFs: dstPath,
|
||||
...options,
|
||||
};
|
||||
|
||||
return this.callRcloneApi('sync/copy', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status of a running job
|
||||
*/
|
||||
async getJobStatus(jobId: string): Promise<any> {
|
||||
return this.callRcloneApi('job/status', { jobid: jobId });
|
||||
}
|
||||
|
||||
/**
|
||||
* List all running jobs
|
||||
*/
|
||||
async listRunningJobs(): Promise<any> {
|
||||
return this.callRcloneApi('job/list');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic method to call the RClone RC API
|
||||
*/
|
||||
private async callRcloneApi(endpoint: string, params: Record<string, any> = {}): Promise<any> {
|
||||
try {
|
||||
const url = `${this.rcloneBaseUrl}/${endpoint}`;
|
||||
this.logger.debug(`Calling RClone API: ${url} with params: ${JSON.stringify(params)}`);
|
||||
|
||||
const response = await got.post(url, {
|
||||
json: params,
|
||||
responseType: 'json',
|
||||
enableUnixSockets: true,
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${this.rcloneUsername}:${this.rclonePassword}`).toString('base64')}`,
|
||||
},
|
||||
});
|
||||
|
||||
return response.body;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(`Error calling RClone API (${endpoint}): ${errorMessage} ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async serveWebGui(): Promise<{ url: string; username: string; password: string }> {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error('RClone service is not initialized');
|
||||
}
|
||||
|
||||
return {
|
||||
url: this.rcloneBaseUrl,
|
||||
username: this.rcloneUsername,
|
||||
password: this.rclonePassword,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Args, Mutation, Parent, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { Layout } from '@jsonforms/core';
|
||||
import { GraphQLJSON } from 'graphql-scalars';
|
||||
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
|
||||
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
import { RCloneBackupConfigForm, RCloneRemote, CreateRCloneRemoteInput } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
|
||||
import { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.service.js';
|
||||
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
|
||||
import { DataSlice } from '@app/unraid-api/types/json-forms.js';
|
||||
import { RCloneFormService } from '@app/unraid-api/graph/resolvers/rclone/rclone-form.service.js';
|
||||
|
||||
@Resolver(() => RCloneBackupConfigForm)
|
||||
export class RCloneConfigResolver {
|
||||
private readonly logger = new Logger(RCloneConfigResolver.name);
|
||||
|
||||
constructor(
|
||||
private readonly rcloneService: RCloneService,
|
||||
private readonly rcloneApiService: RCloneApiService,
|
||||
private readonly rcloneFormService: RCloneFormService
|
||||
) {}
|
||||
|
||||
@ResolveField(() => GraphQLJSON)
|
||||
async dataSchema(
|
||||
@Parent() _parent: RCloneBackupConfigForm
|
||||
): Promise<{ properties: DataSlice; type: 'object' }> {
|
||||
const schema = await this.rcloneFormService.dataSchema();
|
||||
return {
|
||||
properties: schema.properties as DataSlice,
|
||||
type: 'object',
|
||||
};
|
||||
}
|
||||
|
||||
@ResolveField(() => GraphQLJSON)
|
||||
async uiSchema(@Parent() _parent: RCloneBackupConfigForm): Promise<Layout> {
|
||||
return this.rcloneFormService.uiSchema();
|
||||
}
|
||||
|
||||
@Mutation(() => RCloneRemote)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.CREATE,
|
||||
resource: Resource.FLASH,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async createRCloneRemote(@Args('input') input: CreateRCloneRemoteInput): Promise<RCloneRemote> {
|
||||
try {
|
||||
await this.rcloneApiService.createRemote(input.name, input.type, input.config);
|
||||
return {
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
config: input.config
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Error creating remote: ${error}`);
|
||||
throw new Error(`Failed to create remote: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
118
api/src/unraid-api/graph/resolvers/rclone/rclone-form.service.ts
Normal file
118
api/src/unraid-api/graph/resolvers/rclone/rclone-form.service.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { type Layout } from '@jsonforms/core';
|
||||
|
||||
import type { SettingSlice } from '@app/unraid-api/types/json-forms.js';
|
||||
import { mergeSettingSlices } from '@app/unraid-api/types/json-forms.js';
|
||||
|
||||
import {
|
||||
getRcloneConfigFormSchema,
|
||||
getRcloneConfigSlice,
|
||||
getAvailableProviderTypes,
|
||||
} from './jsonforms/rclone-jsonforms-config.js';
|
||||
import { RCloneApiService } from './rclone-api.service.js';
|
||||
|
||||
/**
|
||||
* Service responsible for generating form UI schemas and form logic
|
||||
*/
|
||||
@Injectable()
|
||||
export class RCloneFormService {
|
||||
private readonly logger = new Logger(RCloneFormService.name);
|
||||
private _providerTypes: string[] = [];
|
||||
private _providerOptions: Record<string, any> = {};
|
||||
|
||||
constructor(
|
||||
private readonly rcloneApiService: RCloneApiService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Loads RClone provider types and options
|
||||
*/
|
||||
private async loadProviderInfo(): Promise<void> {
|
||||
try {
|
||||
const providersResponse = await this.rcloneApiService.getProviders();
|
||||
if (providersResponse) {
|
||||
// Extract provider types
|
||||
this._providerTypes = providersResponse.map(provider => provider.name);
|
||||
this._providerOptions = providersResponse.reduce((acc, provider) => {
|
||||
acc[provider.name] = provider.options;
|
||||
return acc;
|
||||
}, {});
|
||||
this.logger.debug(`Loaded ${this._providerTypes.length} provider types`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error loading provider information: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the complete settings schema
|
||||
*/
|
||||
async buildSettingsSchema(
|
||||
providerTypes: string[] = [],
|
||||
selectedProvider: string = '',
|
||||
providerOptions: Record<string, any> = {}
|
||||
): Promise<SettingSlice> {
|
||||
// Get the stepper UI and the form based on the selected provider
|
||||
const baseSlice = getRcloneConfigSlice();
|
||||
const formSlice = getRcloneConfigFormSchema(
|
||||
providerTypes,
|
||||
selectedProvider,
|
||||
providerOptions
|
||||
);
|
||||
|
||||
return mergeSettingSlices([baseSlice, formSlice]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data schema for the form
|
||||
*/
|
||||
async dataSchema(
|
||||
providerTypes: string[] = [],
|
||||
selectedProvider: string = '',
|
||||
providerOptions: Record<string, any> = {}
|
||||
): Promise<Record<string, any>> {
|
||||
// If provider types weren't provided and we haven't loaded them yet, try to load them
|
||||
if (providerTypes.length === 0 && this._providerTypes.length === 0) {
|
||||
await this.loadProviderInfo();
|
||||
providerTypes = this._providerTypes;
|
||||
providerOptions = this._providerOptions;
|
||||
}
|
||||
|
||||
const { properties } = await this.buildSettingsSchema(
|
||||
providerTypes,
|
||||
selectedProvider,
|
||||
providerOptions
|
||||
);
|
||||
return {
|
||||
type: 'object',
|
||||
properties,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the UI schema for the form
|
||||
*/
|
||||
async uiSchema(
|
||||
providerTypes: string[] = [],
|
||||
selectedProvider: string = '',
|
||||
providerOptions: Record<string, any> = {}
|
||||
): Promise<Layout> {
|
||||
// If provider types weren't provided and we haven't loaded them yet, try to load them
|
||||
if (providerTypes.length === 0 && this._providerTypes.length === 0) {
|
||||
await this.loadProviderInfo();
|
||||
providerTypes = this._providerTypes;
|
||||
providerOptions = this._providerOptions;
|
||||
}
|
||||
|
||||
const { elements } = await this.buildSettingsSchema(
|
||||
providerTypes,
|
||||
selectedProvider,
|
||||
providerOptions
|
||||
);
|
||||
return {
|
||||
type: 'VerticalLayout',
|
||||
elements,
|
||||
};
|
||||
}
|
||||
}
|
||||
199
api/src/unraid-api/graph/resolvers/rclone/rclone.model.ts
Normal file
199
api/src/unraid-api/graph/resolvers/rclone/rclone.model.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { Field, ID, InputType, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
|
||||
|
||||
import { type Layout } from '@jsonforms/core';
|
||||
import { GraphQLJSON } from 'graphql-scalars';
|
||||
|
||||
|
||||
|
||||
import { DataSlice } from '@app/unraid-api/types/json-forms.js';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ObjectType()
|
||||
export class RCloneDrive {
|
||||
@Field(() => String, { description: 'Provider name' })
|
||||
name!: string;
|
||||
|
||||
@Field(() => GraphQLJSON, { description: 'Provider options and configuration schema' })
|
||||
options!: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw response format from rclone API
|
||||
*/
|
||||
export interface RCloneProviderResponse {
|
||||
Name: string;
|
||||
Description: string;
|
||||
Prefix: string;
|
||||
Options: RCloneProviderOptionResponse[];
|
||||
CommandHelp?: string | null;
|
||||
Aliases?: string[] | null;
|
||||
Hide?: boolean;
|
||||
MetadataInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw option format from rclone API
|
||||
*/
|
||||
export interface RCloneProviderOptionResponse {
|
||||
Name: string;
|
||||
Help: string;
|
||||
Provider: string;
|
||||
Default?: unknown;
|
||||
Value?: unknown;
|
||||
ShortOpt?: string;
|
||||
Hide?: boolean;
|
||||
Required?: boolean;
|
||||
IsPassword?: boolean;
|
||||
NoPrefix?: boolean;
|
||||
Advanced?: boolean;
|
||||
DefaultStr?: string;
|
||||
ValueStr?: string;
|
||||
Type?: string;
|
||||
Examples?: Array<{ Value: string; Help: string; Provider: string }>;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class RCloneProviderOption {
|
||||
@Field(() => String)
|
||||
name!: string;
|
||||
|
||||
@Field(() => String)
|
||||
help!: string;
|
||||
|
||||
@Field(() => String)
|
||||
provider!: string;
|
||||
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
default?: unknown;
|
||||
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
value?: unknown;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
shortOpt?: string;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
hide?: boolean;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
required?: boolean;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
isPassword?: boolean;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
noPrefix?: boolean;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
advanced?: boolean;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
defaultStr?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
valueStr?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
type?: string;
|
||||
|
||||
@Field(() => [RCloneProviderOptionExample], { nullable: true })
|
||||
examples?: RCloneProviderOptionExample[];
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class RCloneProviderOptionExample {
|
||||
@Field(() => String)
|
||||
value!: string;
|
||||
|
||||
@Field(() => String)
|
||||
help!: string;
|
||||
|
||||
@Field(() => String)
|
||||
provider!: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class RCloneProviderTypes {
|
||||
@Field(() => [String], { description: 'List of all provider types' })
|
||||
types!: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* {
|
||||
Name: 'jottacloud',
|
||||
Description: 'Jottacloud',
|
||||
Prefix: 'jottacloud',
|
||||
Options: [Array],
|
||||
CommandHelp: null,
|
||||
Aliases: null,
|
||||
Hide: false,
|
||||
MetadataInfo: [Object]
|
||||
},
|
||||
*/
|
||||
@ObjectType()
|
||||
export class RCloneProvider {
|
||||
@Field(() => String)
|
||||
name!: string;
|
||||
|
||||
@Field(() => String)
|
||||
description!: string;
|
||||
|
||||
@Field(() => String)
|
||||
prefix!: string;
|
||||
|
||||
@Field(() => [RCloneProviderOption])
|
||||
options!: RCloneProviderOption[];
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class RCloneBackupConfigForm {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field(() => GraphQLJSON)
|
||||
dataSchema!: { properties: DataSlice; type: 'object' };
|
||||
|
||||
@Field(() => GraphQLJSON)
|
||||
uiSchema!: Layout;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class RCloneBackupSettings {
|
||||
@Field(() => RCloneBackupConfigForm)
|
||||
configForm!: RCloneBackupConfigForm;
|
||||
|
||||
@Field(() => [RCloneDrive])
|
||||
drives!: RCloneDrive[];
|
||||
|
||||
@Field(() => [String])
|
||||
remotes!: string[];
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class RCloneRemote {
|
||||
@Field(() => String)
|
||||
name!: string;
|
||||
|
||||
@Field(() => String)
|
||||
type!: string;
|
||||
|
||||
@Field(() => GraphQLJSON)
|
||||
config!: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class CreateRCloneRemoteInput {
|
||||
@Field(() => String)
|
||||
name!: string;
|
||||
|
||||
@Field(() => String)
|
||||
type!: string;
|
||||
|
||||
@Field(() => GraphQLJSON)
|
||||
config!: Record<string, unknown>;
|
||||
}
|
||||
20
api/src/unraid-api/graph/resolvers/rclone/rclone.module.ts
Normal file
20
api/src/unraid-api/graph/resolvers/rclone/rclone.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.service.js';
|
||||
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
|
||||
import { RCloneFormService } from '@app/unraid-api/graph/resolvers/rclone/rclone-form.service.js';
|
||||
import { RCloneBackupSettingsResolver } from '@app/unraid-api/graph/resolvers/rclone/rclone.resolver.js';
|
||||
import { RCloneConfigResolver } from '@app/unraid-api/graph/resolvers/rclone/rclone-config.resolver.js';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: [
|
||||
RCloneService,
|
||||
RCloneApiService,
|
||||
RCloneFormService,
|
||||
RCloneBackupSettingsResolver,
|
||||
RCloneConfigResolver
|
||||
],
|
||||
exports: [RCloneService, RCloneApiService]
|
||||
})
|
||||
export class RCloneModule {}
|
||||
69
api/src/unraid-api/graph/resolvers/rclone/rclone.resolver.ts
Normal file
69
api/src/unraid-api/graph/resolvers/rclone/rclone.resolver.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@app/unraid-api/graph/directives/use-permissions.directive.js';
|
||||
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
|
||||
import {
|
||||
RCloneBackupSettings,
|
||||
RCloneBackupConfigForm,
|
||||
RCloneDrive,
|
||||
} from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
|
||||
|
||||
import { RCloneService } from './rclone.service.js';
|
||||
|
||||
@Resolver(() => RCloneBackupSettings)
|
||||
export class RCloneBackupSettingsResolver {
|
||||
private readonly logger = new Logger(RCloneBackupSettingsResolver.name);
|
||||
|
||||
constructor(
|
||||
private readonly rcloneService: RCloneService,
|
||||
private readonly rcloneApiService: RCloneApiService
|
||||
) {}
|
||||
|
||||
@Query(() => RCloneBackupSettings)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.FLASH,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async rcloneBackup(): Promise<RCloneBackupSettings> {
|
||||
return {} as RCloneBackupSettings;
|
||||
}
|
||||
|
||||
@ResolveField(() => RCloneBackupConfigForm)
|
||||
async configForm(@Parent() _parent: RCloneBackupSettings): Promise<RCloneBackupConfigForm> {
|
||||
return {
|
||||
id: 'rcloneBackupConfigForm',
|
||||
} as RCloneBackupConfigForm;
|
||||
}
|
||||
|
||||
@ResolveField(() => [RCloneDrive])
|
||||
async drives(@Parent() _parent: RCloneBackupSettings): Promise<RCloneDrive[]> {
|
||||
try {
|
||||
const providers = await this.rcloneApiService.getProviders();
|
||||
|
||||
return Object.entries(providers).map(([name, options]) => ({
|
||||
name,
|
||||
options: options as Record<string, unknown>,
|
||||
}));
|
||||
} catch (error) {
|
||||
this.logger.error(`Error getting providers: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@ResolveField(() => [String])
|
||||
async remotes(@Parent() _parent: RCloneBackupSettings): Promise<string[]> {
|
||||
try {
|
||||
return await this.rcloneApiService.listRemotes();
|
||||
} catch (error) {
|
||||
this.logger.error(`Error listing remotes: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
89
api/src/unraid-api/graph/resolvers/rclone/rclone.service.ts
Normal file
89
api/src/unraid-api/graph/resolvers/rclone/rclone.service.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { type Layout } from '@jsonforms/core';
|
||||
|
||||
import type { SettingSlice } from '@app/unraid-api/types/json-forms.js';
|
||||
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
|
||||
import { RCloneFormService } from '@app/unraid-api/graph/resolvers/rclone/rclone-form.service.js';
|
||||
|
||||
/**
|
||||
* Types for rclone backup configuration UI
|
||||
*/
|
||||
export interface RcloneBackupConfigValues {
|
||||
configStep: number;
|
||||
showAdvanced: boolean;
|
||||
name?: string;
|
||||
type?: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RCloneService {
|
||||
private readonly logger = new Logger(RCloneService.name);
|
||||
private _providerTypes: string[] = [];
|
||||
private _providerOptions: Record<string, any> = {};
|
||||
|
||||
constructor(
|
||||
private readonly rcloneApiService: RCloneApiService,
|
||||
private readonly rcloneFormService: RCloneFormService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get provider types
|
||||
*/
|
||||
get providerTypes(): string[] {
|
||||
return this._providerTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider options
|
||||
*/
|
||||
get providerOptions(): Record<string, any> {
|
||||
return this._providerOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the service by loading provider information
|
||||
*/
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
await this.loadProviderInfo();
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to initialize RcloneBackupSettingsService: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads RClone provider types and options
|
||||
*/
|
||||
private async loadProviderInfo(): Promise<void> {
|
||||
try {
|
||||
const providersResponse = await this.rcloneApiService.getProviders();
|
||||
if (providersResponse) {
|
||||
// Extract provider types
|
||||
this._providerTypes = Object.keys(providersResponse);
|
||||
this._providerOptions = providersResponse;
|
||||
this.logger.debug(`Loaded ${this._providerTypes.length} provider types`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error loading provider information: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets current configuration values
|
||||
*/
|
||||
async getCurrentSettings(): Promise<RcloneBackupConfigValues> {
|
||||
return {
|
||||
configStep: 0,
|
||||
showAdvanced: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of configured remotes
|
||||
*/
|
||||
async getConfiguredRemotes(): Promise<string[]> {
|
||||
return this.rcloneApiService.listRemotes();
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,6 @@ 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 { ArrayMutationsResolver } from '@app/unraid-api/graph/resolvers/array/array.mutations.resolver.js';
|
||||
import { ArrayResolver } from '@app/unraid-api/graph/resolvers/array/array.resolver.js';
|
||||
import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.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';
|
||||
@@ -14,6 +11,7 @@ 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';
|
||||
@@ -24,6 +22,7 @@ import { NotificationsResolver } from '@app/unraid-api/graph/resolvers/notificat
|
||||
import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js';
|
||||
import { OnlineResolver } from '@app/unraid-api/graph/resolvers/online/online.resolver.js';
|
||||
import { OwnerResolver } from '@app/unraid-api/graph/resolvers/owner/owner.resolver.js';
|
||||
import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js';
|
||||
import { RegistrationResolver } from '@app/unraid-api/graph/resolvers/registration/registration.resolver.js';
|
||||
import { ServerResolver } from '@app/unraid-api/graph/resolvers/servers/server.resolver.js';
|
||||
import { VarsResolver } from '@app/unraid-api/graph/resolvers/vars/vars.resolver.js';
|
||||
@@ -35,7 +34,17 @@ import { SharesResolver } from '@app/unraid-api/graph/shares/shares.resolver.js'
|
||||
import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js';
|
||||
|
||||
@Module({
|
||||
imports: [ArrayModule, ApiKeyModule, ConnectModule, CustomizationModule, DockerModule, DisksModule],
|
||||
imports: [
|
||||
ArrayModule,
|
||||
ApiKeyModule,
|
||||
AuthModule,
|
||||
ConnectModule,
|
||||
CustomizationModule,
|
||||
DockerModule,
|
||||
DisksModule,
|
||||
FlashBackupModule,
|
||||
RCloneModule,
|
||||
],
|
||||
providers: [
|
||||
CloudResolver,
|
||||
ConfigResolver,
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { Controller, Get, Logger, Param, Res } from '@nestjs/common';
|
||||
import { Controller, Get, Logger, Param, Res, Req, All } from '@nestjs/common';
|
||||
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import type { FastifyReply } from '@app/unraid-api/types/fastify.js';
|
||||
import type { FastifyReply, FastifyRequest } from '@app/unraid-api/types/fastify.js';
|
||||
import { Public } from '@app/unraid-api/auth/public.decorator.js';
|
||||
import { Resource } from '@app/unraid-api/graph/resolvers/base.model.js';
|
||||
import { RestService } from '@app/unraid-api/rest/rest.service.js';
|
||||
import got from 'got';
|
||||
|
||||
@Controller()
|
||||
export class RestController {
|
||||
protected logger = new Logger(RestController.name);
|
||||
constructor(private readonly restService: RestService) {}
|
||||
constructor(
|
||||
private readonly restService: RestService,
|
||||
) {}
|
||||
|
||||
@Get('/')
|
||||
@Public()
|
||||
@@ -53,4 +56,45 @@ export class RestController {
|
||||
return res.status(500).send(`Error: Failed to get customizations`);
|
||||
}
|
||||
}
|
||||
/*
|
||||
@All('/graphql/api/rclone-webgui/*')
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.FLASH,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async proxyRcloneWebGui(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
|
||||
try {
|
||||
const rcloneDetails = await this.flashBackupService.serveWebGui();
|
||||
const path = req.url.replace('/graphql/api/rclone-webgui/', '');
|
||||
const targetUrl = `${rcloneDetails.url}${path}`;
|
||||
|
||||
this.logger.debug(`Proxying request to: ${targetUrl}`);
|
||||
|
||||
// Forward the request to the RClone service
|
||||
const method = req.method.toLowerCase();
|
||||
const options = {
|
||||
headers: {
|
||||
...req.headers,
|
||||
Authorization: `Basic ${Buffer.from(`${rcloneDetails.username}:${rcloneDetails.password}`).toString('base64')}`,
|
||||
},
|
||||
body: req.body,
|
||||
responseType: 'buffer',
|
||||
enableUnixSockets: true,
|
||||
};
|
||||
|
||||
const response = await got[method](targetUrl, options);
|
||||
|
||||
// Forward the response back to the client
|
||||
return res
|
||||
.status(response.statusCode)
|
||||
.headers(response.headers)
|
||||
.send(response.body);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(`Error proxying to RClone WebGUI: ${errorMessage}`);
|
||||
return res.status(500).send(`Error: Failed to proxy to RClone WebGUI`);
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { RestController } from '@app/unraid-api/rest/rest.controller.js';
|
||||
import { RestService } from '@app/unraid-api/rest/rest.service.js';
|
||||
|
||||
import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js';
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [RCloneModule],
|
||||
controllers: [RestController],
|
||||
providers: [RestService],
|
||||
})
|
||||
|
||||
216
web/components/RClone/RCloneConfig.vue
Normal file
216
web/components/RClone/RCloneConfig.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
|
||||
import { BrandButton, jsonFormsRenderers } from '@unraid/ui';
|
||||
import { JsonForms } from '@jsonforms/vue';
|
||||
|
||||
import {
|
||||
CREATE_REMOTE,
|
||||
GET_RCLONE_CONFIG_FORM,
|
||||
LIST_REMOTES,
|
||||
} from '~/components/RClone/graphql/settings.query';
|
||||
import { useUnraidApiStore } from '~/store/unraidApi';
|
||||
import type { RCloneRemote } from '~/composables/gql/graphql';
|
||||
const { offlineError, unraidApiStatus } = useUnraidApiStore();
|
||||
|
||||
// Form state
|
||||
const formState = ref({
|
||||
configStep: 0,
|
||||
showAdvanced: false,
|
||||
name: '',
|
||||
type: '',
|
||||
parameters: {},
|
||||
});
|
||||
|
||||
|
||||
// Load form schema from API
|
||||
const { result: formResult, loading: formLoading } = useQuery(GET_RCLONE_CONFIG_FORM);
|
||||
|
||||
// Query existing remotes
|
||||
const { result, refetch: refetchRemotes } = useQuery(LIST_REMOTES);
|
||||
/**
|
||||
* Form submission and mutation handling
|
||||
*/
|
||||
const {
|
||||
mutate: createRemote,
|
||||
loading: isCreating,
|
||||
error: createError,
|
||||
onDone: onCreateDone,
|
||||
} = useMutation(CREATE_REMOTE);
|
||||
|
||||
// Handle form submission
|
||||
const submitForm = async () => {
|
||||
try {
|
||||
await createRemote({
|
||||
input: {
|
||||
name: formState.value.name,
|
||||
type: formState.value.type,
|
||||
parameters: formState.value.parameters,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating remote:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle successful creation
|
||||
onCreateDone(async () => {
|
||||
// Show success message
|
||||
if (window.toast) {
|
||||
window.toast.success('Remote Configuration Created', {
|
||||
description: `Successfully created remote "${formState.value.name}"`,
|
||||
});
|
||||
}
|
||||
|
||||
// Reset form and refetch remotes
|
||||
formState.value = {
|
||||
configStep: 0,
|
||||
showAdvanced: false,
|
||||
name: '',
|
||||
type: '',
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
await refetchRemotes();
|
||||
});
|
||||
|
||||
// Set up JSONForms config
|
||||
const jsonFormsConfig = {
|
||||
restrict: false,
|
||||
trim: false,
|
||||
};
|
||||
|
||||
const renderers = [...jsonFormsRenderers];
|
||||
|
||||
// Handle form data changes
|
||||
const onChange = ({ data }: { data: Record<string, unknown> }) => {
|
||||
formState.value = data as typeof formState.value;
|
||||
};
|
||||
|
||||
// Navigate between form steps
|
||||
const goToNextStep = () => {
|
||||
if (formState.value.configStep < 2) {
|
||||
formState.value.configStep++;
|
||||
}
|
||||
};
|
||||
|
||||
const goToPreviousStep = () => {
|
||||
if (formState.value.configStep > 0) {
|
||||
formState.value.configStep--;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if form can proceed to next step
|
||||
const canProceedToNextStep = computed(() => {
|
||||
if (formState.value.configStep === 0) {
|
||||
// Step 1: Need name and type
|
||||
return !!formState.value.name && !!formState.value.type;
|
||||
}
|
||||
|
||||
if (formState.value.configStep === 1) {
|
||||
// Step 2: Provider-specific validation could go here
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Check if form can be submitted (on last step)
|
||||
const canSubmitForm = computed(() => {
|
||||
return formState.value.configStep === 2 && !!formState.value.name && !!formState.value.type;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white rounded-lg border border-gray-200 shadow-sm">
|
||||
<div class="p-6">
|
||||
<h2 class="text-xl font-medium mb-4">Configure RClone Remote</h2>
|
||||
|
||||
<div v-if="createError" class="mb-4 p-4 bg-red-50 border border-red-200 text-red-700 rounded-md">
|
||||
{{ createError.message }}
|
||||
</div>
|
||||
|
||||
<div v-if="formLoading" class="py-8 text-center text-gray-500">Loading configuration form...</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div v-else-if="formResult?.rcloneBackup?.configForm" class="mt-6 [&_.vertical-layout]:space-y-6">
|
||||
<JsonForms
|
||||
v-if="formResult?.rcloneBackup?.configForm"
|
||||
:schema="formResult.rcloneBackup.configForm.dataSchema"
|
||||
:uischema="formResult.rcloneBackup.configForm.uiSchema"
|
||||
:renderers="renderers"
|
||||
:data="formState"
|
||||
:config="jsonFormsConfig"
|
||||
:readonly="isCreating"
|
||||
@change="onChange"
|
||||
/>
|
||||
|
||||
<!-- Form navigation buttons -->
|
||||
<div class="mt-6 flex justify-between">
|
||||
<BrandButton
|
||||
v-if="formState.configStep > 0"
|
||||
variant="outline"
|
||||
padding="lean"
|
||||
size="12px"
|
||||
class="leading-normal"
|
||||
:disabled="isCreating"
|
||||
@click="goToPreviousStep"
|
||||
>
|
||||
Previous
|
||||
</BrandButton>
|
||||
|
||||
<div class="flex space-x-4 ml-auto">
|
||||
<BrandButton
|
||||
v-if="formState.configStep < 2"
|
||||
variant="outline-primary"
|
||||
padding="lean"
|
||||
size="12px"
|
||||
class="leading-normal"
|
||||
:disabled="!canProceedToNextStep || isCreating"
|
||||
@click="goToNextStep"
|
||||
>
|
||||
Next
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
v-if="formState.configStep === 2"
|
||||
variant="fill"
|
||||
padding="lean"
|
||||
size="12px"
|
||||
class="leading-normal"
|
||||
:disabled="!canSubmitForm || isCreating"
|
||||
@click="submitForm"
|
||||
>
|
||||
Create Remote
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing remotes list -->
|
||||
<div v-if="existingRemotes?.length > 0" class="mt-10">
|
||||
<h3 class="text-lg font-medium mb-4">Configured Remotes</h3>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="remote in existingRemotes"
|
||||
:key="remote.name"
|
||||
class="p-4 border border-gray-200 rounded-md"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h4 class="font-medium">{{ remote.name }}</h4>
|
||||
<div class="text-sm text-gray-500">Type: {{ remote.type }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="postcss">
|
||||
/* Import unraid-ui globals first */
|
||||
@import '@unraid/ui/styles';
|
||||
</style>
|
||||
35
web/components/RClone/graphql/settings.query.ts
Normal file
35
web/components/RClone/graphql/settings.query.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { graphql } from "~/composables/gql/gql";
|
||||
|
||||
|
||||
export const GET_RCLONE_CONFIG_FORM = graphql(/* GraphQL */ `
|
||||
query GetRCloneConfigForm {
|
||||
rcloneBackup {
|
||||
configForm {
|
||||
dataSchema
|
||||
uiSchema
|
||||
}
|
||||
drives {
|
||||
name
|
||||
options
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const LIST_REMOTES = graphql(/* GraphQL */ `
|
||||
query ListRCloneRemotes {
|
||||
rcloneBackup {
|
||||
remotes
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const CREATE_REMOTE = graphql(/* GraphQL */ `
|
||||
mutation CreateRCloneRemote($input: CreateRCloneRemoteInput!) {
|
||||
createRCloneRemote(input: $input) {
|
||||
name
|
||||
type
|
||||
config
|
||||
}
|
||||
}
|
||||
`);
|
||||
@@ -36,6 +36,9 @@ type Documents = {
|
||||
"\n mutation RecomputeOverview {\n recalculateOverview {\n archive {\n ...NotificationCountFragment\n }\n unread {\n ...NotificationCountFragment\n }\n }\n }\n": typeof types.RecomputeOverviewDocument,
|
||||
"\n subscription NotificationAddedSub {\n notificationAdded {\n ...NotificationFragment\n }\n }\n": typeof types.NotificationAddedSubDocument,
|
||||
"\n subscription NotificationOverviewSub {\n notificationsOverview {\n archive {\n ...NotificationCountFragment\n }\n unread {\n ...NotificationCountFragment\n }\n }\n }\n": typeof types.NotificationOverviewSubDocument,
|
||||
"\n query GetRCloneConfigForm {\n rcloneBackup {\n configForm { \n dataSchema\n uiSchema\n }\n drives {\n name\n options\n }\n }\n }\n": typeof types.GetRCloneConfigFormDocument,
|
||||
"\n query ListRCloneRemotes {\n rcloneBackup {\n remotes\n }\n }\n": typeof types.ListRCloneRemotesDocument,
|
||||
"\n mutation CreateRCloneRemote($input: CreateRCloneRemoteInput!) {\n createRCloneRemote(input: $input) {\n name\n type\n config\n }\n }\n": typeof types.CreateRCloneRemoteDocument,
|
||||
"\n mutation ConnectSignIn($input: ConnectSignInInput!) {\n connectSignIn(input: $input)\n }\n": typeof types.ConnectSignInDocument,
|
||||
"\n mutation SignOut {\n connectSignOut\n }\n": typeof types.SignOutDocument,
|
||||
"\n fragment PartialCloud on Cloud {\n error\n apiKey {\n valid\n error\n }\n cloud {\n status\n error\n }\n minigraphql {\n status\n error\n }\n relay {\n status\n error\n }\n }\n": typeof types.PartialCloudFragmentDoc,
|
||||
@@ -69,6 +72,9 @@ const documents: Documents = {
|
||||
"\n mutation RecomputeOverview {\n recalculateOverview {\n archive {\n ...NotificationCountFragment\n }\n unread {\n ...NotificationCountFragment\n }\n }\n }\n": types.RecomputeOverviewDocument,
|
||||
"\n subscription NotificationAddedSub {\n notificationAdded {\n ...NotificationFragment\n }\n }\n": types.NotificationAddedSubDocument,
|
||||
"\n subscription NotificationOverviewSub {\n notificationsOverview {\n archive {\n ...NotificationCountFragment\n }\n unread {\n ...NotificationCountFragment\n }\n }\n }\n": types.NotificationOverviewSubDocument,
|
||||
"\n query GetRCloneConfigForm {\n rcloneBackup {\n configForm { \n dataSchema\n uiSchema\n }\n drives {\n name\n options\n }\n }\n }\n": types.GetRCloneConfigFormDocument,
|
||||
"\n query ListRCloneRemotes {\n rcloneBackup {\n remotes\n }\n }\n": types.ListRCloneRemotesDocument,
|
||||
"\n mutation CreateRCloneRemote($input: CreateRCloneRemoteInput!) {\n createRCloneRemote(input: $input) {\n name\n type\n config\n }\n }\n": types.CreateRCloneRemoteDocument,
|
||||
"\n mutation ConnectSignIn($input: ConnectSignInInput!) {\n connectSignIn(input: $input)\n }\n": types.ConnectSignInDocument,
|
||||
"\n mutation SignOut {\n connectSignOut\n }\n": types.SignOutDocument,
|
||||
"\n fragment PartialCloud on Cloud {\n error\n apiKey {\n valid\n error\n }\n cloud {\n status\n error\n }\n minigraphql {\n status\n error\n }\n relay {\n status\n error\n }\n }\n": types.PartialCloudFragmentDoc,
|
||||
@@ -182,6 +188,18 @@ export function graphql(source: "\n subscription NotificationAddedSub {\n no
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n subscription NotificationOverviewSub {\n notificationsOverview {\n archive {\n ...NotificationCountFragment\n }\n unread {\n ...NotificationCountFragment\n }\n }\n }\n"): (typeof documents)["\n subscription NotificationOverviewSub {\n notificationsOverview {\n archive {\n ...NotificationCountFragment\n }\n unread {\n ...NotificationCountFragment\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 GetRCloneConfigForm {\n rcloneBackup {\n configForm { \n dataSchema\n uiSchema\n }\n drives {\n name\n options\n }\n }\n }\n"): (typeof documents)["\n query GetRCloneConfigForm {\n rcloneBackup {\n configForm { \n dataSchema\n uiSchema\n }\n drives {\n name\n options\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 ListRCloneRemotes {\n rcloneBackup {\n remotes\n }\n }\n"): (typeof documents)["\n query ListRCloneRemotes {\n rcloneBackup {\n remotes\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 CreateRCloneRemote($input: CreateRCloneRemoteInput!) {\n createRCloneRemote(input: $input) {\n name\n type\n config\n }\n }\n"): (typeof documents)["\n mutation CreateRCloneRemote($input: CreateRCloneRemoteInput!) {\n createRCloneRemote(input: $input) {\n name\n type\n config\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
||||
@@ -20,8 +20,6 @@ export type Scalars = {
|
||||
DateTime: { input: string; output: string; }
|
||||
/** The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */
|
||||
JSON: { input: any; output: any; }
|
||||
/** The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */
|
||||
JSONObject: { input: any; output: any; }
|
||||
/** A field whose value is a valid TCP port within the range of 0 to 65535: https://en.wikipedia.org/wiki/Transmission_Control_Protocol#TCP_ports */
|
||||
Port: { input: number; output: number; }
|
||||
/**
|
||||
@@ -527,6 +525,12 @@ export type CreateApiKeyInput = {
|
||||
roles?: InputMaybe<Array<Role>>;
|
||||
};
|
||||
|
||||
export type CreateRCloneRemoteInput = {
|
||||
config: Scalars['JSON']['input'];
|
||||
name: Scalars['String']['input'];
|
||||
type: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type Customization = {
|
||||
__typename?: 'Customization';
|
||||
activationCode?: Maybe<ActivationCode>;
|
||||
@@ -673,10 +677,10 @@ export type DockerContainer = Node & {
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
image: Scalars['String']['output'];
|
||||
imageId: Scalars['String']['output'];
|
||||
labels?: Maybe<Scalars['JSONObject']['output']>;
|
||||
mounts?: Maybe<Array<Scalars['JSONObject']['output']>>;
|
||||
labels?: Maybe<Scalars['JSON']['output']>;
|
||||
mounts?: Maybe<Array<Scalars['JSON']['output']>>;
|
||||
names: Array<Scalars['String']['output']>;
|
||||
networkSettings?: Maybe<Scalars['JSONObject']['output']>;
|
||||
networkSettings?: Maybe<Scalars['JSON']['output']>;
|
||||
ports: Array<ContainerPort>;
|
||||
/** Total size of all the files in the container */
|
||||
sizeRootFs?: Maybe<Scalars['Int']['output']>;
|
||||
@@ -705,19 +709,19 @@ export type DockerMutationsStopArgs = {
|
||||
export type DockerNetwork = Node & {
|
||||
__typename?: 'DockerNetwork';
|
||||
attachable: Scalars['Boolean']['output'];
|
||||
configFrom: Scalars['JSONObject']['output'];
|
||||
configFrom: Scalars['JSON']['output'];
|
||||
configOnly: Scalars['Boolean']['output'];
|
||||
containers: Scalars['JSONObject']['output'];
|
||||
containers: Scalars['JSON']['output'];
|
||||
created: Scalars['String']['output'];
|
||||
driver: Scalars['String']['output'];
|
||||
enableIPv6: Scalars['Boolean']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
ingress: Scalars['Boolean']['output'];
|
||||
internal: Scalars['Boolean']['output'];
|
||||
ipam: Scalars['JSONObject']['output'];
|
||||
labels: Scalars['JSONObject']['output'];
|
||||
ipam: Scalars['JSON']['output'];
|
||||
labels: Scalars['JSON']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
options: Scalars['JSONObject']['output'];
|
||||
options: Scalars['JSON']['output'];
|
||||
scope: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
@@ -752,6 +756,14 @@ 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'];
|
||||
@@ -828,6 +840,17 @@ export type InfoMemory = Node & {
|
||||
used: Scalars['BigInt']['output'];
|
||||
};
|
||||
|
||||
export type InitiateFlashBackupInput = {
|
||||
/** 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). */
|
||||
sourcePath: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type KeyFile = {
|
||||
__typename?: 'KeyFile';
|
||||
contents?: Maybe<Scalars['String']['output']>;
|
||||
@@ -901,11 +924,14 @@ export type Mutation = {
|
||||
connectSignOut: Scalars['Boolean']['output'];
|
||||
/** Creates a new notification record */
|
||||
createNotification: Notification;
|
||||
createRCloneRemote: RCloneRemote;
|
||||
/** Deletes all archived notifications on server. */
|
||||
deleteArchivedNotifications: NotificationOverview;
|
||||
deleteNotification: NotificationOverview;
|
||||
docker: DockerMutations;
|
||||
enableDynamicRemoteAccess: Scalars['Boolean']['output'];
|
||||
/** Initiates a flash drive backup using a configured remote. */
|
||||
initiateFlashBackup: FlashBackupStatus;
|
||||
parityCheck: ParityCheckMutations;
|
||||
/** Reads each notification to recompute & update the overview. */
|
||||
recalculateOverview: NotificationOverview;
|
||||
@@ -946,6 +972,11 @@ export type MutationCreateNotificationArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationCreateRCloneRemoteArgs = {
|
||||
input: CreateRCloneRemoteInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteNotificationArgs = {
|
||||
id: Scalars['PrefixedID']['input'];
|
||||
type: NotificationType;
|
||||
@@ -957,6 +988,16 @@ export type MutationEnableDynamicRemoteAccessArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationInitiateFlashBackupArgs = {
|
||||
input: InitiateFlashBackupInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationRemoveRoleFromApiKeyArgs = {
|
||||
input: RemoveRoleFromApiKeyInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationSetAdditionalAllowedOriginsArgs = {
|
||||
input: AllowedOriginInput;
|
||||
};
|
||||
@@ -1199,6 +1240,7 @@ export type Query = {
|
||||
parityHistory: Array<ParityCheck>;
|
||||
publicPartnerInfo?: Maybe<PublicPartnerInfo>;
|
||||
publicTheme: Theme;
|
||||
rcloneBackup: RCloneBackupSettings;
|
||||
registration?: Maybe<Registration>;
|
||||
remoteAccess: RemoteAccess;
|
||||
server?: Maybe<Server>;
|
||||
@@ -1227,6 +1269,61 @@ export type QueryLogFileArgs = {
|
||||
startLine?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
export type RCloneBackupConfigForm = {
|
||||
__typename?: 'RCloneBackupConfigForm';
|
||||
dataSchema: Scalars['JSON']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
uiSchema: Scalars['JSON']['output'];
|
||||
};
|
||||
|
||||
export type RCloneBackupSettings = {
|
||||
__typename?: 'RCloneBackupSettings';
|
||||
configForm: RCloneBackupConfigForm;
|
||||
drives: Array<RCloneDrive>;
|
||||
remotes: Array<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type RCloneDrive = {
|
||||
__typename?: 'RCloneDrive';
|
||||
/** Provider name */
|
||||
name: Scalars['String']['output'];
|
||||
/** Provider options and configuration schema */
|
||||
options: Scalars['JSON']['output'];
|
||||
};
|
||||
|
||||
export type RCloneProviderOption = {
|
||||
__typename?: 'RCloneProviderOption';
|
||||
advanced?: Maybe<Scalars['Boolean']['output']>;
|
||||
default?: Maybe<Scalars['JSON']['output']>;
|
||||
defaultStr?: Maybe<Scalars['String']['output']>;
|
||||
examples?: Maybe<Array<RCloneProviderOptionExample>>;
|
||||
help: Scalars['String']['output'];
|
||||
hide?: Maybe<Scalars['Boolean']['output']>;
|
||||
isPassword?: Maybe<Scalars['Boolean']['output']>;
|
||||
name: Scalars['String']['output'];
|
||||
noPrefix?: Maybe<Scalars['Boolean']['output']>;
|
||||
provider: Scalars['String']['output'];
|
||||
required?: Maybe<Scalars['Boolean']['output']>;
|
||||
shortOpt?: Maybe<Scalars['String']['output']>;
|
||||
type?: Maybe<Scalars['String']['output']>;
|
||||
value?: Maybe<Scalars['JSON']['output']>;
|
||||
valueStr?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type RCloneProviderOptionExample = {
|
||||
__typename?: 'RCloneProviderOptionExample';
|
||||
help: Scalars['String']['output'];
|
||||
provider: Scalars['String']['output'];
|
||||
value: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type RCloneRemote = {
|
||||
__typename?: 'RCloneRemote';
|
||||
config: Scalars['JSON']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
type: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type Registration = Node & {
|
||||
__typename?: 'Registration';
|
||||
expiration?: Maybe<Scalars['String']['output']>;
|
||||
@@ -1961,6 +2058,23 @@ export type NotificationOverviewSubSubscription = { __typename?: 'Subscription',
|
||||
& { ' $fragmentRefs'?: { 'NotificationCountFragmentFragment': NotificationCountFragmentFragment } }
|
||||
) } };
|
||||
|
||||
export type GetRCloneConfigFormQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GetRCloneConfigFormQuery = { __typename?: 'Query', rcloneBackup: { __typename?: 'RCloneBackupSettings', configForm: { __typename?: 'RCloneBackupConfigForm', dataSchema: any, uiSchema: any }, drives: Array<{ __typename?: 'RCloneDrive', name: string, options: any }> } };
|
||||
|
||||
export type ListRCloneRemotesQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type ListRCloneRemotesQuery = { __typename?: 'Query', rcloneBackup: { __typename?: 'RCloneBackupSettings', remotes: Array<string> } };
|
||||
|
||||
export type CreateRCloneRemoteMutationVariables = Exact<{
|
||||
input: CreateRCloneRemoteInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type CreateRCloneRemoteMutation = { __typename?: 'Mutation', createRCloneRemote: { __typename?: 'RCloneRemote', name: string, type: string, config: any } };
|
||||
|
||||
export type ConnectSignInMutationVariables = Exact<{
|
||||
input: ConnectSignInInput;
|
||||
}>;
|
||||
@@ -2035,6 +2149,9 @@ export const OverviewDocument = {"kind":"Document","definitions":[{"kind":"Opera
|
||||
export const RecomputeOverviewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RecomputeOverview"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"recalculateOverview"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"archive"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NotificationCountFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"unread"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NotificationCountFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NotificationCountFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NotificationCounts"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"total"}},{"kind":"Field","name":{"kind":"Name","value":"info"}},{"kind":"Field","name":{"kind":"Name","value":"warning"}},{"kind":"Field","name":{"kind":"Name","value":"alert"}}]}}]} as unknown as DocumentNode<RecomputeOverviewMutation, RecomputeOverviewMutationVariables>;
|
||||
export const NotificationAddedSubDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"NotificationAddedSub"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"notificationAdded"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NotificationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NotificationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Notification"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"importance"}},{"kind":"Field","name":{"kind":"Name","value":"link"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"formattedTimestamp"}}]}}]} as unknown as DocumentNode<NotificationAddedSubSubscription, NotificationAddedSubSubscriptionVariables>;
|
||||
export const NotificationOverviewSubDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"NotificationOverviewSub"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"notificationsOverview"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"archive"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NotificationCountFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"unread"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NotificationCountFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NotificationCountFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NotificationCounts"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"total"}},{"kind":"Field","name":{"kind":"Name","value":"info"}},{"kind":"Field","name":{"kind":"Name","value":"warning"}},{"kind":"Field","name":{"kind":"Name","value":"alert"}}]}}]} as unknown as DocumentNode<NotificationOverviewSubSubscription, NotificationOverviewSubSubscriptionVariables>;
|
||||
export const GetRCloneConfigFormDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetRCloneConfigForm"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rcloneBackup"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configForm"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dataSchema"}},{"kind":"Field","name":{"kind":"Name","value":"uiSchema"}}]}},{"kind":"Field","name":{"kind":"Name","value":"drives"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"options"}}]}}]}}]}}]} as unknown as DocumentNode<GetRCloneConfigFormQuery, GetRCloneConfigFormQueryVariables>;
|
||||
export const ListRCloneRemotesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ListRCloneRemotes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rcloneBackup"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"remotes"}}]}}]}}]} as unknown as DocumentNode<ListRCloneRemotesQuery, ListRCloneRemotesQueryVariables>;
|
||||
export const CreateRCloneRemoteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateRCloneRemote"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateRCloneRemoteInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createRCloneRemote"},"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":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"config"}}]}}]}}]} as unknown as DocumentNode<CreateRCloneRemoteMutation, CreateRCloneRemoteMutationVariables>;
|
||||
export const ConnectSignInDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ConnectSignIn"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ConnectSignInInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectSignIn"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode<ConnectSignInMutation, ConnectSignInMutationVariables>;
|
||||
export const SignOutDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SignOut"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectSignOut"}}]}}]} as unknown as DocumentNode<SignOutMutation, SignOutMutationVariables>;
|
||||
export const ServerStateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"serverState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cloud"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PartialCloud"}}]}},{"kind":"Field","name":{"kind":"Name","value":"config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}}]}},{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"os"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hostname"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"owner"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}},{"kind":"Field","name":{"kind":"Name","value":"registration"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"expiration"}},{"kind":"Field","name":{"kind":"Name","value":"keyFile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contents"}}]}},{"kind":"Field","name":{"kind":"Name","value":"updateExpiration"}}]}},{"kind":"Field","name":{"kind":"Name","value":"vars"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"regGen"}},{"kind":"Field","name":{"kind":"Name","value":"regState"}},{"kind":"Field","name":{"kind":"Name","value":"configError"}},{"kind":"Field","name":{"kind":"Name","value":"configValid"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PartialCloud"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Cloud"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cloud"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"minigraphql"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"relay"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]} as unknown as DocumentNode<ServerStateQuery, ServerStateQueryVariables>;
|
||||
|
||||
@@ -74,6 +74,7 @@ const splitLinks = split(
|
||||
wsLink,
|
||||
httpLink
|
||||
);
|
||||
|
||||
/**
|
||||
* @todo as we add retries, determine which we'll need
|
||||
* https://www.apollographql.com/docs/react/api/link/introduction/#additive-composition
|
||||
|
||||
24
web/pages/flashbackup.vue
Normal file
24
web/pages/flashbackup.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
import RCloneConfig from '~/components/RClone/RCloneConfig.vue';
|
||||
import { useDummyServerStore } from '~/_data/serverState';
|
||||
|
||||
const { registerEntry } = useCustomElements();
|
||||
|
||||
const serverStore = useDummyServerStore();
|
||||
const { serverState } = storeToRefs(serverStore);
|
||||
|
||||
onBeforeMount(() => {
|
||||
registerEntry('UnraidComponents');
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.cookie = 'unraid_session_cookie=mockusersession';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
{{ serverState.connectPluginInstalled }}
|
||||
<RCloneConfig />
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user