feat: add rclone (#1362)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced full RClone remote management with creation, deletion,
listing, and detailed remote info via a multi-step, schema-driven UI.
- Added guided configuration forms supporting advanced and
provider-specific options for RClone remotes.
  - Enabled flash backup initiation through API mutations.
- Added new Vue components for RClone configuration, overview, remote
item cards, and flash backup page.
- Integrated new combobox, stepped layout, control wrapper, label
renderer, and improved form renderers with enhanced validation and error
display.
- Added JSON Forms visibility composable and Unraid settings layout for
consistent UI rendering.

- **Bug Fixes**
- Standardized JSON scalar usage in Docker-related types, replacing
`JSONObject` with `JSON`.

- **Chores**
- Added utility scripts and helpers to manage rclone binary installation
and versioning.
- Updated build scripts and Storybook configuration for CSS handling and
improved developer workflow.
- Refactored ESLint config for modularity and enhanced code quality
enforcement.
- Improved component registration with runtime type checks and error
handling.

- **Documentation**
- Added extensive test coverage for RClone API service, JSON Forms
schema merging, and provider config slice generation.

- **Style**
- Improved UI consistency with new layouts, tooltips on select options,
password visibility toggles, and error handling components.
- Removed deprecated components and consolidated renderer registrations
for JSON Forms.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Eli Bosley
2025-05-27 04:52:25 -07:00
committed by GitHub
parent d37dc3bce2
commit 5517e7506b
101 changed files with 9402 additions and 862 deletions

View File

@@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: true
---
Never add comments for obvious things, and avoid commenting when starting and ending code blocks

1
.rclone-version Normal file
View File

@@ -0,0 +1 @@
1.69.1

View File

@@ -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"

View File

@@ -946,6 +946,25 @@ type ParityCheckMutations {
cancel: JSON!
}
"""RClone related mutations"""
type RCloneMutations {
"""Create a new RClone remote"""
createRCloneRemote(input: CreateRCloneRemoteInput!): RCloneRemote!
"""Delete an existing RClone remote"""
deleteRCloneRemote(input: DeleteRCloneRemoteInput!): Boolean!
}
input CreateRCloneRemoteInput {
name: String!
type: String!
parameters: JSON!
}
input DeleteRCloneRemoteInput {
name: String!
}
type ParityCheck {
"""Date of the parity check"""
date: DateTime
@@ -1299,20 +1318,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 +1339,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 +1356,49 @@ type Docker implements Node {
networks(skipCache: Boolean! = false): [DockerNetwork!]!
}
type FlashBackupStatus {
"""Status message indicating the outcome of the backup initiation."""
status: String!
"""Job ID if available, can be used to check job status."""
jobId: String
}
type RCloneDrive {
"""Provider name"""
name: String!
"""Provider options and configuration schema"""
options: JSON!
}
type RCloneBackupConfigForm {
id: ID!
dataSchema: JSON!
uiSchema: JSON!
}
type RCloneBackupSettings {
configForm(formOptions: RCloneConfigFormInput): RCloneBackupConfigForm!
drives: [RCloneDrive!]!
remotes: [RCloneRemote!]!
}
input RCloneConfigFormInput {
providerType: String
showAdvanced: Boolean = false
parameters: JSON
}
type RCloneRemote {
name: String!
type: String!
parameters: JSON!
"""Complete remote configuration"""
config: JSON!
}
type Flash implements Node {
id: PrefixedID!
guid: String!
@@ -1543,6 +1600,7 @@ type Query {
docker: Docker!
disks: [Disk!]!
disk(id: PrefixedID!): Disk!
rclone: RCloneBackupSettings!
health: String!
getDemo: String!
}
@@ -1572,12 +1630,16 @@ type Mutation {
vm: VmMutations!
parityCheck: ParityCheckMutations!
apiKey: ApiKeyMutations!
rclone: RCloneMutations!
updateApiSettings(input: ApiSettingsInput!): ConnectSettingsValues!
connectSignIn(input: ConnectSignInInput!): Boolean!
connectSignOut: Boolean!
setupRemoteAccess(input: SetupRemoteAccessInput!): Boolean!
setAdditionalAllowedOrigins(input: AllowedOriginInput!): [String!]!
enableDynamicRemoteAccess(input: EnableDynamicRemoteAccessInput!): Boolean!
"""Initiates a flash drive backup using a configured remote."""
initiateFlashBackup(input: InitiateFlashBackupInput!): FlashBackupStatus!
setDemo: String!
}
@@ -1674,6 +1736,22 @@ input AccessUrlInput {
ipv6: URL
}
input InitiateFlashBackupInput {
"""The name of the remote configuration to use for the backup."""
remoteName: String!
"""Source path to backup (typically the flash drive)."""
sourcePath: String!
"""Destination path on the remote."""
destinationPath: String!
"""
Additional options for the backup operation, such as --dry-run or --transfers.
"""
options: JSON
}
type Subscription {
displaySubscription: Display!
infoSubscription: Info!

View File

@@ -109,8 +109,6 @@
"graphql-scalars": "^1.23.0",
"graphql-subscriptions": "^3.0.0",
"graphql-tag": "^2.12.6",
"graphql-type-json": "^0.3.2",
"graphql-type-uuid": "^0.2.0",
"graphql-ws": "^6.0.0",
"ini": "^5.0.0",
"ip": "^2.0.1",

View File

@@ -0,0 +1,374 @@
import { HTTPError } from 'got';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
import {
CreateRCloneRemoteDto,
DeleteRCloneRemoteDto,
GetRCloneJobStatusDto,
GetRCloneRemoteConfigDto,
GetRCloneRemoteDetailsDto,
RCloneStartBackupInput,
UpdateRCloneRemoteDto,
} from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
vi.mock('got');
vi.mock('execa');
vi.mock('p-retry');
vi.mock('node:fs', () => ({
existsSync: vi.fn(),
}));
vi.mock('node:fs/promises', () => ({
mkdir: vi.fn(),
rm: vi.fn(),
writeFile: vi.fn(),
}));
vi.mock('@app/core/log.js', () => ({
sanitizeParams: vi.fn((params) => params),
}));
vi.mock('@app/store/index.js', () => ({
getters: {
paths: () => ({
'rclone-socket': '/tmp/rclone.sock',
'log-base': '/var/log',
}),
},
}));
// Mock NestJS Logger to suppress logs during tests
vi.mock('@nestjs/common', async (importOriginal) => {
const original = await importOriginal<typeof import('@nestjs/common')>();
return {
...original,
Logger: vi.fn(() => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
})),
};
});
describe('RCloneApiService', () => {
let service: RCloneApiService;
let mockGot: any;
let mockExeca: any;
let mockPRetry: any;
let mockExistsSync: any;
beforeEach(async () => {
vi.clearAllMocks();
const { default: got } = await import('got');
const { execa } = await import('execa');
const pRetry = await import('p-retry');
const { existsSync } = await import('node:fs');
mockGot = vi.mocked(got);
mockExeca = vi.mocked(execa);
mockPRetry = vi.mocked(pRetry.default);
mockExistsSync = vi.mocked(existsSync);
mockGot.post = vi.fn().mockResolvedValue({ body: {} });
mockExeca.mockReturnValue({
on: vi.fn(),
kill: vi.fn(),
killed: false,
pid: 12345,
} as any);
mockPRetry.mockResolvedValue(undefined);
mockExistsSync.mockReturnValue(false);
service = new RCloneApiService();
await service.onModuleInit();
});
describe('getProviders', () => {
it('should return list of providers', async () => {
const mockProviders = [
{ name: 'aws', prefix: 's3', description: 'Amazon S3' },
{ name: 'google', prefix: 'drive', description: 'Google Drive' },
];
mockGot.post.mockResolvedValue({
body: { providers: mockProviders },
});
const result = await service.getProviders();
expect(result).toEqual(mockProviders);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/config/providers',
expect.objectContaining({
json: {},
responseType: 'json',
enableUnixSockets: true,
})
);
});
it('should return empty array when no providers', async () => {
mockGot.post.mockResolvedValue({ body: {} });
const result = await service.getProviders();
expect(result).toEqual([]);
});
});
describe('listRemotes', () => {
it('should return list of remotes', async () => {
const mockRemotes = ['backup-s3', 'drive-storage'];
mockGot.post.mockResolvedValue({
body: { remotes: mockRemotes },
});
const result = await service.listRemotes();
expect(result).toEqual(mockRemotes);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/config/listremotes',
expect.objectContaining({
json: {},
})
);
});
it('should return empty array when no remotes', async () => {
mockGot.post.mockResolvedValue({ body: {} });
const result = await service.listRemotes();
expect(result).toEqual([]);
});
});
describe('getRemoteDetails', () => {
it('should return remote details', async () => {
const input: GetRCloneRemoteDetailsDto = { name: 'test-remote' };
const mockConfig = { type: 's3', provider: 'AWS' };
mockGot.post.mockResolvedValue({ body: mockConfig });
const result = await service.getRemoteDetails(input);
expect(result).toEqual(mockConfig);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/config/get',
expect.objectContaining({
json: { name: 'test-remote' },
})
);
});
});
describe('getRemoteConfig', () => {
it('should return remote configuration', async () => {
const input: GetRCloneRemoteConfigDto = { name: 'test-remote' };
const mockConfig = { type: 's3', access_key_id: 'AKIA...' };
mockGot.post.mockResolvedValue({ body: mockConfig });
const result = await service.getRemoteConfig(input);
expect(result).toEqual(mockConfig);
});
});
describe('createRemote', () => {
it('should create a new remote', async () => {
const input: CreateRCloneRemoteDto = {
name: 'new-remote',
type: 's3',
parameters: { access_key_id: 'AKIA...', secret_access_key: 'secret' },
};
const mockResponse = { success: true };
mockGot.post.mockResolvedValue({ body: mockResponse });
const result = await service.createRemote(input);
expect(result).toEqual(mockResponse);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/config/create',
expect.objectContaining({
json: {
name: 'new-remote',
type: 's3',
parameters: { access_key_id: 'AKIA...', secret_access_key: 'secret' },
},
})
);
});
});
describe('updateRemote', () => {
it('should update an existing remote', async () => {
const input: UpdateRCloneRemoteDto = {
name: 'existing-remote',
parameters: { access_key_id: 'NEW_AKIA...' },
};
const mockResponse = { success: true };
mockGot.post.mockResolvedValue({ body: mockResponse });
const result = await service.updateRemote(input);
expect(result).toEqual(mockResponse);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/config/update',
expect.objectContaining({
json: {
name: 'existing-remote',
access_key_id: 'NEW_AKIA...',
},
})
);
});
});
describe('deleteRemote', () => {
it('should delete a remote', async () => {
const input: DeleteRCloneRemoteDto = { name: 'remote-to-delete' };
const mockResponse = { success: true };
mockGot.post.mockResolvedValue({ body: mockResponse });
const result = await service.deleteRemote(input);
expect(result).toEqual(mockResponse);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/config/delete',
expect.objectContaining({
json: { name: 'remote-to-delete' },
})
);
});
});
describe('startBackup', () => {
it('should start a backup operation', async () => {
const input: RCloneStartBackupInput = {
srcPath: '/source/path',
dstPath: 'remote:backup/path',
options: { delete_on: 'dst' },
};
const mockResponse = { jobid: 'job-123' };
mockGot.post.mockResolvedValue({ body: mockResponse });
const result = await service.startBackup(input);
expect(result).toEqual(mockResponse);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/sync/copy',
expect.objectContaining({
json: {
srcFs: '/source/path',
dstFs: 'remote:backup/path',
delete_on: 'dst',
},
})
);
});
});
describe('getJobStatus', () => {
it('should return job status', async () => {
const input: GetRCloneJobStatusDto = { jobId: 'job-123' };
const mockStatus = { status: 'running', progress: 0.5 };
mockGot.post.mockResolvedValue({ body: mockStatus });
const result = await service.getJobStatus(input);
expect(result).toEqual(mockStatus);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/job/status',
expect.objectContaining({
json: { jobid: 'job-123' },
})
);
});
});
describe('listRunningJobs', () => {
it('should return list of running jobs', async () => {
const mockJobs = [
{ id: 'job-1', status: 'running' },
{ id: 'job-2', status: 'finished' },
];
mockGot.post.mockResolvedValue({ body: mockJobs });
const result = await service.listRunningJobs();
expect(result).toEqual(mockJobs);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/job/list',
expect.objectContaining({
json: {},
})
);
});
});
describe('error handling', () => {
it('should handle HTTP errors with detailed messages', async () => {
const httpError = {
name: 'HTTPError',
message: 'Request failed',
response: {
statusCode: 500,
body: JSON.stringify({ error: 'Internal server error' }),
},
};
Object.setPrototypeOf(httpError, HTTPError.prototype);
mockGot.post.mockRejectedValue(httpError);
await expect(service.getProviders()).rejects.toThrow(
'Rclone API Error (config/providers, HTTP 500): Rclone Error: Internal server error'
);
});
it('should handle HTTP errors with empty response body', async () => {
const httpError = {
name: 'HTTPError',
message: 'Request failed',
response: {
statusCode: 404,
body: '',
},
};
Object.setPrototypeOf(httpError, HTTPError.prototype);
mockGot.post.mockRejectedValue(httpError);
await expect(service.getProviders()).rejects.toThrow(
'Rclone API Error (config/providers, HTTP 404): Failed to process error response body. Raw body:'
);
});
it('should handle HTTP errors with malformed JSON', async () => {
const httpError = {
name: 'HTTPError',
message: 'Request failed',
response: {
statusCode: 400,
body: 'invalid json',
},
};
Object.setPrototypeOf(httpError, HTTPError.prototype);
mockGot.post.mockRejectedValue(httpError);
await expect(service.getProviders()).rejects.toThrow(
'Rclone API Error (config/providers, HTTP 400): Failed to process error response body. Raw body: invalid json'
);
});
it('should handle non-HTTP errors', async () => {
const networkError = new Error('Network connection failed');
mockGot.post.mockRejectedValue(networkError);
await expect(service.getProviders()).rejects.toThrow('Network connection failed');
});
it('should handle unknown errors', async () => {
mockGot.post.mockRejectedValue('unknown error');
await expect(service.getProviders()).rejects.toThrow(
'Unknown error calling RClone API (config/providers) with params {}: unknown error'
);
});
});
});

View File

@@ -7,6 +7,7 @@ exports[`Returns paths 1`] = `
"unraid-data",
"docker-autostart",
"docker-socket",
"rclone-socket",
"parity-checks",
"htpasswd",
"emhttpd-socket",

View File

@@ -1,7 +1,7 @@
import { pino } from 'pino';
import pretty from 'pino-pretty';
import { LOG_TYPE } from '@app/environment.js';
import { API_VERSION, LOG_TYPE } from '@app/environment.js';
export const levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const;
@@ -10,9 +10,7 @@ export type LogLevel = (typeof levels)[number];
const level =
levels[levels.indexOf(process.env.LOG_LEVEL?.toLowerCase() as (typeof levels)[number])] ?? 'info';
export const logDestination = pino.destination({
sync: true,
});
export const logDestination = pino.destination();
const stream =
LOG_TYPE === 'pretty'
@@ -28,9 +26,25 @@ const stream =
export const logger = pino(
{
level,
timestamp: () => `,"time":"${new Date().toISOString()}"`,
timestamp: pino.stdTimeFunctions.isoTime,
formatters: {
level: (label: string) => ({ level: label }),
bindings: (bindings) => ({ ...bindings, apiVersion: API_VERSION }),
},
redact: {
paths: [
'*.password',
'*.pass',
'*.secret',
'*.token',
'*.key',
'*.Password',
'*.Pass',
'*.Secret',
'*.Token',
'*.Key',
],
censor: '***REDACTED***',
},
},
stream
@@ -71,3 +85,19 @@ export const loggers = [
remoteQueryLogger,
apiLogger,
];
export function sanitizeParams(params: Record<string, any>): Record<string, any> {
const SENSITIVE_KEYS = ['password', 'secret', 'token', 'key', 'client_secret'];
const mask = (value: any) => (typeof value === 'string' && value.length > 0 ? '***' : value);
const sanitized: Record<string, any> = {};
for (const k in params) {
if (SENSITIVE_KEYS.some((s) => k.toLowerCase().includes(s))) {
sanitized[k] = mask(params[k]);
} else if (typeof params[k] === 'object' && params[k] !== null && !Array.isArray(params[k])) {
sanitized[k] = sanitizeParams(params[k]);
} else {
sanitized[k] = params[k];
}
}
return sanitized;
}

View File

@@ -21,6 +21,7 @@ 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 +55,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',

View File

@@ -52,7 +52,11 @@ 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');
}

View File

@@ -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 {
@@ -54,6 +54,7 @@ export class ConnectSettingsResolver {
elements,
};
}
@ResolveField(() => ConnectSettingsValues)
public async values(): Promise<ConnectSettingsValues> {
return await this.connectSettingsService.getCurrentSettings();

View File

@@ -33,6 +33,7 @@ import {
WAN_ACCESS_TYPE,
WAN_FORWARD_TYPE,
} from '@app/unraid-api/graph/resolvers/connect/connect.model.js';
import { createLabeledControl } from '@app/unraid-api/graph/utils/form-utils.js';
import { mergeSettingSlices } from '@app/unraid-api/types/json-forms.js';
import { csvStringToArray } from '@app/utils.js';
@@ -346,15 +347,15 @@ export class ConnectSettingsService {
/** shown when preconditions are met */
const formControls: UIElement[] = [
{
type: 'Control',
createLabeledControl({
scope: '#/properties/accessType',
label: 'Allow Remote Access',
},
{
type: 'Control',
controlOptions: {},
}),
createLabeledControl({
scope: '#/properties/forwardType',
label: 'Remote Access Forward Type',
controlOptions: {},
rule: {
effect: RuleEffect.DISABLE,
condition: {
@@ -364,12 +365,11 @@ export class ConnectSettingsService {
},
} as SchemaBasedCondition,
},
},
{
type: 'Control',
}),
createLabeledControl({
scope: '#/properties/port',
label: 'Remote Access WAN Port',
options: {
controlOptions: {
format: 'short',
formatOptions: {
useGrouping: false,
@@ -390,7 +390,7 @@ export class ConnectSettingsService {
},
} as Omit<SchemaBasedCondition, 'scope'>,
},
},
}),
];
/** shape of the data associated with remote access settings, as json schema properties*/
@@ -438,15 +438,14 @@ export class ConnectSettingsService {
},
},
elements: [
{
type: 'Control',
createLabeledControl({
scope: '#/properties/sandbox',
label: 'Enable Developer Sandbox:',
options: {
description: sandbox ? description : undefined,
controlOptions: {
toggle: true,
description: sandbox ? description : undefined,
},
},
}),
],
};
}
@@ -489,14 +488,17 @@ export class ConnectSettingsService {
},
},
elements: [
{
type: 'Control',
createLabeledControl({
scope: '#/properties/extraOrigins',
options: {
label: 'Allowed Origins (CORS)',
description:
'Provide a comma-separated list of URLs allowed to access the API (e.g., https://myapp.example.com).',
controlOptions: {
inputType: 'url',
placeholder: 'https://example.com',
format: 'array',
},
},
}),
],
};
}
@@ -513,18 +515,20 @@ export class ConnectSettingsService {
type: 'string',
},
title: 'Unraid API SSO Users',
description: `Provide a list of Unique Unraid Account ID's. Find yours at <a href="https://account.unraid.net/settings" target="_blank">account.unraid.net/settings</a>`,
description: `Provide a list of Unique Unraid Account ID's. Find yours at <a href="https://account.unraid.net/settings" target="_blank" rel="noopener noreferrer">account.unraid.net/settings</a>. Requires restart if adding first user.`,
},
},
elements: [
{
type: 'Control',
createLabeledControl({
scope: '#/properties/ssoUserIds',
options: {
label: 'Unraid Connect SSO Users',
description: `Provide a list of Unique Unraid Account IDs. Find yours at <a href="https://account.unraid.net/settings" target="_blank" rel="noopener noreferrer">account.unraid.net/settings</a>. Requires restart if adding first user.`,
controlOptions: {
inputType: 'text',
placeholder: 'UUID',
format: 'array',
},
},
}),
],
};
}

View File

@@ -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>;
}

View File

@@ -59,7 +59,6 @@ export class DockerService implements OnModuleInit {
public async onModuleInit() {
try {
this.logger.debug('Warming Docker cache on startup...');
await this.getContainers({ skipCache: true });
await this.getNetworks({ skipCache: true });
this.logger.debug('Docker cache warming complete.');

View File

@@ -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;
}

View File

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

View File

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

View File

@@ -1,5 +1,13 @@
import { Field, ObjectType } from '@nestjs/graphql';
/**
* Important:
*
* When adding a new mutation, you must also add it to the RootMutations resolver
*
* @file src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts
*/
@ObjectType()
export class ArrayMutations {}
@@ -19,6 +27,11 @@ export class ApiKeyMutations {}
})
export class ParityCheckMutations {}
@ObjectType({
description: 'RClone related mutations',
})
export class RCloneMutations {}
@ObjectType()
export class RootMutations {
@Field(() => ArrayMutations, { description: 'Array related mutations' })
@@ -35,4 +48,7 @@ export class RootMutations {
@Field(() => ParityCheckMutations, { description: 'Parity check related mutations' })
parityCheck: ParityCheckMutations = new ParityCheckMutations();
@Field(() => RCloneMutations, { description: 'RClone related mutations' })
rclone: RCloneMutations = new RCloneMutations();
}

View File

@@ -5,6 +5,7 @@ import {
ArrayMutations,
DockerMutations,
ParityCheckMutations,
RCloneMutations,
RootMutations,
VmMutations,
} from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js';
@@ -35,4 +36,9 @@ export class RootMutationsResolver {
apiKey(): ApiKeyMutations {
return new ApiKeyMutations();
}
@Mutation(() => RCloneMutations, { name: 'rclone' })
rclone(): RCloneMutations {
return new RCloneMutations();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,339 @@
import type { JsonSchema7, Layout, SchemaBasedCondition } from '@jsonforms/core';
import { beforeEach, describe, expect, it } from 'vitest';
// Adjusted path assuming rclone.model.ts is sibling to jsonforms dir
import type { RCloneProviderOptionResponse } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
import { config as rawProviderConfig } from '@app/unraid-api/graph/resolvers/rclone/jsonforms/config.js'; // Added .js extension
import { getProviderConfigSlice } from '@app/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.js'; // Added .js extension
// --- Data Processing ---
// Type assertion for the imported config
interface RawProviderConfigEntry {
Name: string;
Options: RCloneProviderOptionResponse[];
// Add other properties from config.ts if needed, though Name and Options are primary
}
// Process the raw config into the format expected by the functions under test
const providerOptionsMap: Record<string, RCloneProviderOptionResponse[]> = (
rawProviderConfig as RawProviderConfigEntry[]
).reduce(
(acc, provider) => {
if (provider.Name && Array.isArray(provider.Options)) {
// Ensure options conform to the expected type structure if necessary
// For now, we assume the structure matches RCloneProviderOptionResponse
acc[provider.Name] = provider.Options;
}
return acc;
},
{} as Record<string, RCloneProviderOptionResponse[]>
);
const providerNames = Object.keys(providerOptionsMap);
// --- Test Suite ---
describe('getProviderConfigSlice', () => {
// Example provider to use in tests - choose one with both standard and advanced options
const testProvider = 's3'; // S3 usually has a good mix
let s3Options: RCloneProviderOptionResponse[];
beforeEach(() => {
// Ensure we have the options for the test provider
s3Options = providerOptionsMap[testProvider];
expect(s3Options).toBeDefined();
expect(s3Options.length).toBeGreaterThan(0);
});
it('should return an empty slice if the provider name is invalid', () => {
const result = getProviderConfigSlice({
selectedProvider: 'invalid-provider-name',
providerOptions: [], // Doesn't matter for this case
isAdvancedStep: false,
stepIndex: 1,
});
expect(result.properties).toEqual({});
expect(result.elements).toEqual([]);
});
it('should return an empty slice if providerOptions are empty', () => {
const result = getProviderConfigSlice({
selectedProvider: testProvider, // Valid provider
providerOptions: [], // Empty options
isAdvancedStep: false,
stepIndex: 1,
});
expect(result.properties).toEqual({});
expect(result.elements).toEqual([]);
});
it('should return only standard options when isAdvancedStep is false', () => {
const result = getProviderConfigSlice({
selectedProvider: testProvider,
providerOptions: s3Options,
isAdvancedStep: false,
stepIndex: 1,
});
// Check properties schema
expect(result.properties).toBeDefined();
expect(result.properties.parameters).toBeDefined();
const paramProps = result.properties.parameters?.properties || {};
expect(Object.keys(paramProps).length).toBeGreaterThan(0);
// Check that all properties included are standard (Advanced !== true)
const standardOptions = s3Options.filter((opt) => opt.Advanced !== true);
const uniqueStandardOptionNames = [...new Set(standardOptions.map((opt) => opt.Name))];
// Assert against the count of UNIQUE standard option names
expect(Object.keys(paramProps).length).toEqual(uniqueStandardOptionNames.length);
// Check that each unique standard option name exists in the generated props
uniqueStandardOptionNames.forEach((name) => {
expect(paramProps[name]).toBeDefined();
// Find the first option with this name to check title (or implement more complex logic if needed)
const correspondingOption = standardOptions.find((opt) => opt.Name === name);
expect(paramProps[name]?.title).toEqual(correspondingOption?.Name);
});
// Check UI elements - compare count against unique names
expect(result.elements).toBeDefined();
// Expect a single VerticalLayout containing the actual elements
expect(result.elements).toHaveLength(1);
const verticalLayoutStd = result.elements[0];
expect(verticalLayoutStd.type).toBe('VerticalLayout');
expect(Array.isArray(verticalLayoutStd.elements)).toBe(true);
expect(verticalLayoutStd.elements?.length).toEqual(uniqueStandardOptionNames.length);
// Check elements based on unique names
uniqueStandardOptionNames.forEach((name) => {
// Use `as any` for type assertion on the result elements array
// Adjust to check within the VerticalLayout's elements
const elementsArray = verticalLayoutStd.elements as any[];
// Find element by scope instead of label
const expectedScope = `#/properties/parameters/properties/${name}`;
const element = elementsArray.find((el) => el.scope === expectedScope);
expect(element).toBeDefined(); // Check if element was found
if (element) {
// Check the type of the wrapper layout
expect(element.type).toEqual('UnraidSettingsLayout');
}
});
});
it('should return only advanced options when isAdvancedStep is true', () => {
const result = getProviderConfigSlice({
selectedProvider: testProvider,
providerOptions: s3Options,
isAdvancedStep: true,
stepIndex: 2,
});
// Check properties schema
expect(result.properties).toBeDefined();
expect(result.properties.parameters).toBeDefined();
const paramProps = result.properties.parameters?.properties || {};
expect(Object.keys(paramProps).length).toBeGreaterThan(0);
// Check that all properties included are advanced (Advanced === true)
const advancedOptions = s3Options.filter((opt) => opt.Advanced === true);
const uniqueAdvancedOptionNames = [...new Set(advancedOptions.map((opt) => opt.Name))];
// Assert against the count of UNIQUE advanced option names
expect(Object.keys(paramProps).length).toEqual(uniqueAdvancedOptionNames.length);
// Check that each unique advanced option name exists in the generated props
uniqueAdvancedOptionNames.forEach((name) => {
expect(paramProps[name]).toBeDefined();
const correspondingOption = advancedOptions.find((opt) => opt.Name === name);
expect(paramProps[name]?.title).toEqual(correspondingOption?.Name);
});
// Check UI elements - compare count against unique names
expect(result.elements).toBeDefined();
// Expect a single VerticalLayout containing the actual elements
expect(result.elements).toHaveLength(1);
const verticalLayoutAdv = result.elements[0];
expect(verticalLayoutAdv.type).toBe('VerticalLayout');
expect(Array.isArray(verticalLayoutAdv.elements)).toBe(true);
expect(verticalLayoutAdv.elements?.length).toEqual(uniqueAdvancedOptionNames.length);
// Check elements based on unique names
uniqueAdvancedOptionNames.forEach((name) => {
// Use `as any` for type assertion on the result elements array
// Adjust to check within the VerticalLayout's elements
const elementsArray = verticalLayoutAdv.elements as any[];
// Find element by scope instead of label
const expectedScope = `#/properties/parameters/properties/${name}`;
const element = elementsArray.find((el) => el.scope === expectedScope);
expect(element).toBeDefined(); // Check if element was found
if (element) {
// Check the type of the wrapper layout
expect(element.type).toEqual('UnraidSettingsLayout');
}
});
});
it('should return an empty slice for advanced options if none exist for the provider', () => {
const testProviderNoAdvanced = 'alias'; // 'alias' provider typically has no advanced options
const aliasOptions = providerOptionsMap[testProviderNoAdvanced];
// Pre-check: Verify that the chosen provider actually has no advanced options in our data
const hasAdvanced = aliasOptions?.some((opt) => opt.Advanced === true);
expect(hasAdvanced).toBe(false); // Ensure our assumption about 'alias' holds
const result = getProviderConfigSlice({
selectedProvider: testProviderNoAdvanced,
providerOptions: aliasOptions || [],
isAdvancedStep: true,
stepIndex: 2,
});
// Expect empty results because no advanced options should be found
expect(result.properties).toEqual({}); // Should not even have parameters object
expect(result.elements).toEqual([]);
});
it('should handle duplicate option names within the same type (standard/advanced)', () => {
const duplicateOptions: RCloneProviderOptionResponse[] = [
{ Name: 'test_opt', Help: 'First', Advanced: false, Provider: '' },
{ Name: 'duplicate_opt', Help: 'Keep this one', Advanced: false, Provider: '' },
{ Name: 'duplicate_opt', Help: 'Skip this one', Advanced: false, Provider: '' },
{ Name: 'another_opt', Help: 'Another', Advanced: false, Provider: '' },
];
const result = getProviderConfigSlice({
selectedProvider: 'test',
providerOptions: duplicateOptions,
isAdvancedStep: false,
stepIndex: 1,
});
// Check properties - should only contain unique names
const paramProps = result.properties.parameters?.properties || {};
expect(Object.keys(paramProps)).toEqual(['test_opt', 'duplicate_opt', 'another_opt']);
expect(paramProps['duplicate_opt']?.description).toBe('Keep this one'); // Check it kept the first one
// Check elements - should only contain unique names
// Expect a single VerticalLayout containing the actual elements
expect(result.elements).toHaveLength(1);
const verticalLayoutDup = result.elements[0];
expect(verticalLayoutDup.type).toBe('VerticalLayout');
expect(Array.isArray(verticalLayoutDup.elements)).toBe(true);
expect(verticalLayoutDup.elements?.length).toBe(3);
const foundDuplicateElement = verticalLayoutDup.elements?.find((el: any) =>
el.scope?.includes('duplicate_opt')
);
expect(foundDuplicateElement).toBeDefined();
const duplicateLabelElement = foundDuplicateElement?.elements?.find(
(innerEl: any) => innerEl.type === 'Label'
);
expect(duplicateLabelElement?.options?.description).toBe('Keep this one');
const containsSkipped = verticalLayoutDup.elements?.some((el: any) =>
el.elements?.some(
(innerEl: any) =>
innerEl.type === 'Label' && innerEl.options?.description === 'Skip this one'
)
);
expect(containsSkipped).toBe(false);
});
it('should add a SHOW rule for positive Provider filters', () => {
const providerSpecificOptions: RCloneProviderOptionResponse[] = [
{ Name: 'always_show', Help: 'Always Visible', Provider: '' },
{ Name: 's3_only', Help: 'S3 Specific', Provider: 's3' },
{ Name: 'gdrive_only', Help: 'GDrive Specific', Provider: 'google drive' }, // Check space handling
];
const result = getProviderConfigSlice({
selectedProvider: 'anyProvider',
providerOptions: providerSpecificOptions,
isAdvancedStep: false,
stepIndex: 1,
});
// Expect a single VerticalLayout containing the actual elements
expect(result.elements).toHaveLength(1);
const verticalLayoutPos = result.elements[0];
expect(verticalLayoutPos.type).toBe('VerticalLayout');
expect(verticalLayoutPos.elements?.length).toBe(3);
const alwaysShowEl = verticalLayoutPos.elements?.find((el: any) =>
el.scope.includes('always_show')
);
const s3OnlyEl = verticalLayoutPos.elements?.find((el: any) => el.scope.includes('s3_only'));
const gdriveOnlyEl = verticalLayoutPos.elements?.find((el: any) =>
el.scope.includes('gdrive_only')
);
expect(alwaysShowEl).toBeDefined();
expect(s3OnlyEl).toBeDefined();
expect(gdriveOnlyEl).toBeDefined();
expect(alwaysShowEl!.rule).toBeUndefined();
expect(s3OnlyEl!.rule).toBeDefined();
expect(s3OnlyEl!.rule!.effect).toBe('SHOW');
// Explicitly cast condition to SchemaBasedCondition
const s3Condition = s3OnlyEl!.rule!.condition as SchemaBasedCondition;
expect(s3Condition.scope).toBe('#/properties/type');
expect(s3Condition.schema).toEqual({ enum: ['s3'] });
expect(gdriveOnlyEl!.rule).toBeDefined();
expect(gdriveOnlyEl!.rule!.effect).toBe('SHOW');
// Explicitly cast condition to SchemaBasedCondition
const gdriveCondition = gdriveOnlyEl!.rule!.condition as SchemaBasedCondition;
expect(gdriveCondition.scope).toBe('#/properties/type');
expect(gdriveCondition.schema).toEqual({ enum: ['google drive'] });
});
it('should add a SHOW rule with negated condition for negative Provider filters', () => {
const providerSpecificOptions: RCloneProviderOptionResponse[] = [
{ Name: 'not_s3', Help: 'Not S3', Provider: '!s3' },
{
Name: 'not_s3_or_gdrive',
Help: 'Not S3 or GDrive',
Provider: '!s3, google drive ',
}, // Check trimming
];
const result = getProviderConfigSlice({
selectedProvider: 'anyProvider',
providerOptions: providerSpecificOptions,
isAdvancedStep: false,
stepIndex: 1,
});
// Expect a single VerticalLayout containing the actual elements
expect(result.elements).toHaveLength(1);
const verticalLayoutNeg = result.elements[0];
expect(verticalLayoutNeg.type).toBe('VerticalLayout');
expect(verticalLayoutNeg.elements?.length).toBe(2);
const notS3El = verticalLayoutNeg.elements?.find((el: any) => el.scope.includes('not_s3'));
const notS3OrGDriveEl = verticalLayoutNeg.elements?.find((el: any) =>
el.scope.includes('not_s3_or_gdrive')
);
expect(notS3El).toBeDefined();
expect(notS3OrGDriveEl).toBeDefined();
expect(notS3El!.rule).toBeDefined();
expect(notS3El!.rule!.effect).toBe('SHOW');
// Explicitly cast condition to SchemaBasedCondition
const notS3Condition = notS3El!.rule!.condition as SchemaBasedCondition;
expect(notS3Condition.scope).toBe('#/properties/type');
expect(notS3Condition.schema).toEqual({ not: { enum: ['s3'] } });
expect(notS3OrGDriveEl!.rule).toBeDefined();
expect(notS3OrGDriveEl!.rule!.effect).toBe('SHOW');
// Explicitly cast condition to SchemaBasedCondition
const notS3OrGDriveCondition = notS3OrGDriveEl!.rule!.condition as SchemaBasedCondition;
expect(notS3OrGDriveCondition.scope).toBe('#/properties/type');
expect(notS3OrGDriveCondition.schema).toEqual({ not: { enum: ['s3', 'google drive'] } });
});
// More tests will be added here...
});

View File

@@ -0,0 +1,446 @@
import type { LabelElement, Layout, Rule, SchemaBasedCondition } from '@jsonforms/core';
import { JsonSchema7, RuleEffect } from '@jsonforms/core';
import type { DataSlice, SettingSlice, UIElement } from '@app/unraid-api/types/json-forms.js';
import { RCloneProviderOptionResponse } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
import { createLabeledControl } from '@app/unraid-api/graph/utils/form-utils.js';
import { mergeSettingSlices } from '@app/unraid-api/types/json-forms.js';
function translateRCloneOptionToJsonSchema({
option,
}: {
option: RCloneProviderOptionResponse;
}): JsonSchema7 {
const schema: JsonSchema7 = {
type: getJsonSchemaType(option.Type || 'string'),
title: option.Name,
description: option.Help || '',
};
if (option.Default !== undefined && option.Default !== '') {
if ((option.Type === 'SizeSuffix' || option.Type === 'Duration') && option.Default === 'off') {
schema.default = 'off';
} else if (schema.type === 'number' && typeof option.Default === 'number') {
schema.default = option.Default;
} else if (schema.type === 'integer' && Number.isInteger(option.Default)) {
schema.default = option.Default;
} else if (schema.type === 'boolean' && typeof option.Default === 'boolean') {
schema.default = option.Default;
} else if (schema.type === 'string') {
schema.default = String(option.Default);
}
}
const format = getJsonFormElementForType({
rcloneType: option.Type,
examples: option.Examples?.map((example) => example.Value),
isPassword: option.IsPassword,
});
if (format && format !== schema.type && format !== 'combobox') {
schema.format = format;
}
if (option.Required) {
if (schema.type === 'string') {
schema.minLength = 1;
}
}
switch (option.Type?.toLowerCase()) {
case 'int':
break;
case 'sizesuffix':
schema.pattern = '^(off|(\\d+([KMGTPE]i?B?)?)+)$';
schema.errorMessage = 'Invalid size format. Examples: "10G", "100M", "1.5GiB", "off".';
break;
case 'duration':
schema.pattern = '^(off|(d+(.d+)?(ns|us|\u00b5s|ms|s|m|h))+)$';
schema.errorMessage =
'Invalid duration format. Examples: "10s", "1.5m", "100ms", "1h15m", "off".';
break;
}
return schema;
}
function getBasicConfigSlice({ providerTypes }: { providerTypes: string[] }): SettingSlice {
const basicConfigElements: UIElement[] = [
createLabeledControl({
scope: '#/properties/name',
label: 'Remote Name',
description:
'Name to identify this remote configuration (e.g., my_google_drive). Use only letters, numbers, hyphens, and underscores.',
controlOptions: {
placeholder: 'Enter a name',
format: 'string',
},
}),
createLabeledControl({
scope: '#/properties/type',
label: 'Storage Provider Type',
description: 'Select the cloud storage provider to use for this remote.',
controlOptions: {},
}),
{
type: 'Label',
text: 'Documentation Link',
options: {
description:
'For more information, refer to the [RClone Config Documentation](https://rclone.org/commands/rclone_config/).',
},
} as LabelElement,
createLabeledControl({
scope: '#/properties/showAdvanced',
label: 'Show Advanced Options',
description: 'Display additional configuration options for experts.',
controlOptions: {
toggle: true,
},
layoutOptions: {
style: 'margin-top: 1em;',
},
}),
];
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] : '',
enum: providerTypes,
},
showAdvanced: {
type: 'boolean',
title: 'Show Advanced Options',
description: 'Whether to show advanced configuration options.',
default: false,
},
};
const verticalLayoutElement: UIElement = {
type: 'VerticalLayout',
elements: basicConfigElements,
options: { step: 0 },
};
return {
properties: basicConfigProperties as unknown as DataSlice,
elements: [verticalLayoutElement],
};
}
export function getProviderConfigSlice({
selectedProvider,
providerOptions,
isAdvancedStep,
stepIndex,
}: {
selectedProvider: string;
providerOptions: RCloneProviderOptionResponse[];
isAdvancedStep: boolean;
stepIndex: number;
}): SettingSlice {
const configProperties: DataSlice = {};
if (!selectedProvider || !providerOptions || providerOptions.length === 0) {
return {
properties: configProperties,
elements: [],
};
}
const filteredOptions = providerOptions.filter((option) => {
if (isAdvancedStep) {
return option.Advanced === true;
} else {
return option.Advanced !== true;
}
});
const uniqueOptionsByName = filteredOptions.reduce((acc, current) => {
if (!acc.find((item) => item.Name === current.Name)) {
acc.push(current);
} else {
console.warn(
`Duplicate RClone option name skipped in ${isAdvancedStep ? 'advanced' : 'standard'} slice: ${current.Name}`
);
}
return acc;
}, [] as RCloneProviderOptionResponse[]);
if (uniqueOptionsByName.length === 0) {
return {
properties: configProperties,
elements: [],
};
}
const controlElements = uniqueOptionsByName
.filter((option) => {
const providerFilter = option.Provider?.trim();
return !(option.Hide === 1 && !providerFilter);
})
.map((option): UIElement => {
const format = getJsonFormElementForType({
rcloneType: option.Type,
examples: option.Examples?.map((example) => example.Value),
isPassword: option.IsPassword,
});
const controlOptions: Record<string, any> = {
placeholder: option.Default?.toString() || '',
required: option.Required || false,
format,
};
if (option.Examples && option.Examples.length > 0) {
const exampleValues = option.Examples.map((example) => example.Value).join(', ');
controlOptions.placeholder = `e.g., ${exampleValues}`;
}
if (format === 'checkbox' && (!option.Examples || option.Examples.length === 0)) {
controlOptions.toggle = true;
}
if (format === 'combobox' && option.Examples && option.Examples.length > 0) {
const isBooleanType = getJsonSchemaType(option.Type ?? '') === 'boolean';
controlOptions.suggestions = option.Examples.map((example) => ({
value: isBooleanType
? String(example.Value ?? '').toLowerCase() === 'true'
: example.Value,
label: String(example.Value ?? ''),
tooltip: example.Help || '',
}));
}
let providerRule: Rule | undefined = undefined;
const providerFilter = option.Provider?.trim();
if (providerFilter) {
const isNegated = providerFilter.startsWith('!');
const providers = (isNegated ? providerFilter.substring(1) : providerFilter)
.split(',')
.map((p) => p.trim())
.filter((p) => p);
if (providers.length > 0) {
const conditionSchema = isNegated
? { not: { enum: providers } }
: { enum: providers };
const effect = option.Hide === 1 ? RuleEffect.HIDE : RuleEffect.SHOW;
providerRule = {
effect: effect,
condition: {
scope: '#/properties/type',
schema: conditionSchema,
} as SchemaBasedCondition,
};
}
}
const labeledControl = createLabeledControl({
scope: `#/properties/parameters/properties/${option.Name}`,
label: option.Name,
description: option.Help || undefined,
controlOptions: controlOptions,
rule: providerRule,
});
return labeledControl;
});
const paramProperties: Record<string, JsonSchema7> = {};
uniqueOptionsByName.forEach((option) => {
if (option) {
paramProperties[option.Name] = translateRCloneOptionToJsonSchema({ option });
}
});
if (Object.keys(paramProperties).length > 0) {
if (!configProperties.parameters) {
configProperties.parameters = { type: 'object', properties: {} } as any;
} else if (!(configProperties.parameters as any).properties) {
(configProperties.parameters as any).properties = {};
}
(configProperties.parameters as any).properties = {
...(configProperties.parameters as any).properties,
...paramProperties,
};
}
const verticalLayoutElement: UIElement = {
type: 'VerticalLayout',
elements: controlElements,
options: { step: stepIndex, showDividers: true },
};
return {
properties: configProperties,
elements: [verticalLayoutElement],
};
}
function getJsonSchemaType(rcloneType: string): string {
switch (rcloneType?.toLowerCase()) {
case 'int':
return 'integer';
case 'size':
case 'number':
return 'number';
case 'sizesuffix':
case 'duration':
return 'string';
case 'bool':
return 'boolean';
case 'string':
case 'text':
case 'password':
default:
return 'string';
}
}
function getJsonFormElementForType({
rcloneType = '',
examples = null,
isPassword = false,
}: {
rcloneType?: string;
examples?: string[] | null;
isPassword?: boolean;
}): string | undefined {
if (isPassword) {
return 'password';
}
switch (rcloneType?.toLowerCase()) {
case 'int':
case 'size':
return undefined;
case 'sizesuffix':
return undefined;
case 'duration':
return undefined;
case 'bool':
return 'toggle';
case 'text':
return undefined;
case 'password':
return 'password';
case 'string':
default:
if (examples && examples.length > 0) {
return 'combobox';
}
return undefined;
}
}
export function buildRcloneConfigSchema({
providerTypes = [],
selectedProvider = '',
providerOptions = {},
showAdvanced = false,
}: {
providerTypes?: string[];
selectedProvider?: string;
providerOptions?: Record<string, RCloneProviderOptionResponse[]>;
showAdvanced?: boolean;
}): {
dataSchema: { properties: DataSlice; type: 'object' };
uiSchema: Layout;
} {
const optionsForProvider = providerOptions[selectedProvider] || [];
const slicesToMerge: SettingSlice[] = [];
const basicSlice = getBasicConfigSlice({ providerTypes });
slicesToMerge.push(basicSlice);
if (selectedProvider && optionsForProvider.length > 0) {
const standardConfigSlice = getProviderConfigSlice({
selectedProvider,
providerOptions: optionsForProvider,
isAdvancedStep: false,
stepIndex: 1,
});
if (
standardConfigSlice.elements.length > 0 ||
Object.keys(standardConfigSlice.properties).length > 0
) {
slicesToMerge.push(standardConfigSlice);
}
}
let advancedConfigSlice: SettingSlice | null = null;
if (showAdvanced && selectedProvider && optionsForProvider.length > 0) {
advancedConfigSlice = getProviderConfigSlice({
selectedProvider,
providerOptions: optionsForProvider,
isAdvancedStep: true,
stepIndex: 2,
});
if (
advancedConfigSlice.elements.length > 0 ||
Object.keys(advancedConfigSlice.properties).length > 0
) {
slicesToMerge.push(advancedConfigSlice);
}
}
const mergedSlices = mergeSettingSlices(slicesToMerge);
const dataSchema: { properties: DataSlice; type: 'object' } = {
type: 'object',
properties: mergedSlices.properties,
};
const steps = [{ label: 'Set up Remote Config', description: 'Name and provider selection' }];
if (selectedProvider) {
steps.push({ label: 'Set up Drive', description: 'Provider-specific configuration' });
}
if (
showAdvanced &&
advancedConfigSlice &&
(advancedConfigSlice.elements.length > 0 ||
Object.keys(advancedConfigSlice.properties).length > 0)
) {
steps.push({ label: 'Advanced Config', description: 'Optional advanced settings' });
}
const steppedLayoutElement: UIElement = {
type: 'SteppedLayout',
options: {
steps: steps,
},
elements: mergedSlices.elements,
};
const titleLabel: UIElement = {
type: 'Label',
text: 'Configure RClone Remote',
options: {
format: 'title',
description:
'This process will guide you through setting up your RClone remote configuration.',
},
};
const uiSchema: Layout = {
type: 'VerticalLayout',
elements: [titleLabel, steppedLayoutElement],
};
return { dataSchema, uiSchema };
}

View File

@@ -0,0 +1,394 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import crypto from 'crypto';
import { ChildProcess } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { execa } from 'execa';
import got, { HTTPError } from 'got';
import pRetry from 'p-retry';
import { sanitizeParams } from '@app/core/log.js';
import {
CreateRCloneRemoteDto,
DeleteRCloneRemoteDto,
GetRCloneJobStatusDto,
GetRCloneRemoteConfigDto,
GetRCloneRemoteDetailsDto,
RCloneProviderOptionResponse,
RCloneProviderResponse,
RCloneRemoteConfig,
RCloneStartBackupInput,
UpdateRCloneRemoteDto,
} from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.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 rcloneProcess: ChildProcess | null = null;
private readonly rcloneUsername: string =
process.env.RCLONE_USERNAME || crypto.randomBytes(12).toString('base64');
private readonly rclonePassword: string =
process.env.RCLONE_PASSWORD || crypto.randomBytes(24).toString('base64');
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}:`;
// Check if the RClone socket exists, if not, create it.
const socketExists = await this.checkRcloneSocketExists(this.rcloneSocketPath);
if (socketExists) {
const isRunning = await this.checkRcloneSocketRunning();
if (isRunning) {
this.isInitialized = true;
return;
} else {
this.logger.warn(
'RClone socket is not running but socket exists, removing socket before starting...'
);
await rm(this.rcloneSocketPath, { force: true });
}
this.logger.warn('RClone socket is not running, starting it...');
this.isInitialized = await this.startRcloneSocket(this.rcloneSocketPath, logFilePath);
return;
} else {
this.logger.warn('RClone socket does not exist, creating it...');
this.isInitialized = await this.startRcloneSocket(this.rcloneSocketPath, logFilePath);
return;
}
} catch (error: unknown) {
this.logger.error(`Error initializing RCloneApiService: ${error}`);
this.isInitialized = false;
}
}
async onModuleDestroy(): Promise<void> {
await this.stopRcloneSocket();
this.logger.log('RCloneApiService module destroyed');
}
/**
* Starts the RClone RC daemon on the specified socket path
*/
private async startRcloneSocket(socketPath: string, logFilePath: string): Promise<boolean> {
try {
// 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}`);
// Start the process but don't wait for it to finish
this.rcloneProcess = execa(
'rclone',
[
'rcd',
'--rc-addr',
socketPath,
'--log-level',
'INFO',
'--log-file',
logFilePath,
...(this.rcloneUsername ? ['--rc-user', this.rcloneUsername] : []),
...(this.rclonePassword ? ['--rc-pass', this.rclonePassword] : []),
],
{ detached: false } // Keep attached to manage lifecycle
);
// Handle potential errors during process spawning (e.g., command not found)
this.rcloneProcess.on('error', (error: Error) => {
this.logger.error(`RClone process failed to start: ${error.message}`);
this.rcloneProcess = null; // Clear the handle on error
this.isInitialized = false;
});
// Handle unexpected exit
this.rcloneProcess.on('exit', (code, signal) => {
this.logger.warn(
`RClone process exited unexpectedly with code: ${code}, signal: ${signal}`
);
this.rcloneProcess = null;
this.isInitialized = false;
});
// Wait for socket to be ready using p-retry with exponential backoff
await pRetry(
async () => {
const isRunning = await this.checkRcloneSocketRunning();
if (!isRunning) throw new Error('Rclone socket not ready');
},
{
retries: 6, // 7 attempts total
minTimeout: 100,
maxTimeout: 5000,
factor: 2,
maxRetryTime: 30000,
}
);
return true;
} catch (error: unknown) {
this.logger.error(`Error starting RClone RC daemon: ${error}`);
this.rcloneProcess?.kill(); // Attempt to kill if started but failed later
this.rcloneProcess = null;
return false;
}
}
private async stopRcloneSocket(): Promise<void> {
if (this.rcloneProcess && !this.rcloneProcess.killed) {
this.logger.log(`Stopping RClone RC daemon process (PID: ${this.rcloneProcess.pid})...`);
try {
const killed = this.rcloneProcess.kill('SIGTERM'); // Send SIGTERM first
if (!killed) {
this.logger.warn('Failed to kill RClone process with SIGTERM, trying SIGKILL.');
this.rcloneProcess.kill('SIGKILL'); // Force kill if SIGTERM failed
}
this.logger.log('RClone process stopped.');
} catch (error: unknown) {
this.logger.error(`Error stopping RClone process: ${error}`);
} finally {
this.rcloneProcess = null; // Clear the handle
}
} else {
this.logger.log('RClone process not running or already stopped.');
}
// Clean up the socket file if it exists
if (this.rcloneSocketPath && existsSync(this.rcloneSocketPath)) {
this.logger.log(`Removing RClone socket file: ${this.rcloneSocketPath}`);
try {
await rm(this.rcloneSocketPath, { force: true });
} catch (error: unknown) {
this.logger.error(`Error removing RClone socket file: ${error}`);
}
}
}
/**
* Checks if the RClone socket exists
*/
private async checkRcloneSocketExists(socketPath: string): Promise<boolean> {
const socketExists = existsSync(socketPath);
if (!socketExists) {
this.logger.warn(`RClone socket does not exist at: ${socketPath}`);
return false;
}
return true;
}
/**
* Checks if the RClone socket is running
*/
private async checkRcloneSocketRunning(): Promise<boolean> {
// Use the API check instead of execa('rclone', ['about']) as rclone might not be in PATH
// or configured correctly for the execa environment vs the rcd environment.
try {
// A simple API call to check if the daemon is responsive
await this.callRcloneApi('core/pid');
this.logger.debug('RClone socket is running and responsive.');
return true;
} catch (error: unknown) {
// Log less verbosely during checks
// this.logger.error(`Error checking RClone socket: ${error}`);
return false;
}
}
/**
* Get providers supported by RClone
*/
async getProviders(): Promise<RCloneProviderResponse[]> {
const response = (await this.callRcloneApi('config/providers')) as {
providers: RCloneProviderResponse[];
};
return response?.providers || [];
}
/**
* List all remotes configured in rclone
*/
async listRemotes(): Promise<string[]> {
const response = (await this.callRcloneApi('config/listremotes')) as { remotes: string[] };
return response?.remotes || [];
}
/**
* Get complete remote details
*/
async getRemoteDetails(input: GetRCloneRemoteDetailsDto): Promise<RCloneRemoteConfig> {
await validateObject(GetRCloneRemoteDetailsDto, input);
const config = (await this.getRemoteConfig({ name: input.name })) || {};
return config as RCloneRemoteConfig;
}
/**
* Get configuration of a remote
*/
async getRemoteConfig(input: GetRCloneRemoteConfigDto): Promise<RCloneRemoteConfig> {
await validateObject(GetRCloneRemoteConfigDto, input);
return this.callRcloneApi('config/get', { name: input.name });
}
/**
* Create a new remote configuration
*/
async createRemote(input: CreateRCloneRemoteDto): Promise<any> {
await validateObject(CreateRCloneRemoteDto, input);
this.logger.log(`Creating new remote: ${input.name} of type: ${input.type}`);
const params = {
name: input.name,
type: input.type,
parameters: input.parameters,
};
const result = await this.callRcloneApi('config/create', params);
this.logger.log(`Successfully created remote: ${input.name}`);
return result;
}
/**
* Update an existing remote configuration
*/
async updateRemote(input: UpdateRCloneRemoteDto): Promise<any> {
await validateObject(UpdateRCloneRemoteDto, input);
this.logger.log(`Updating remote: ${input.name}`);
const params = {
name: input.name,
...input.parameters,
};
return this.callRcloneApi('config/update', params);
}
/**
* Delete a remote configuration
*/
async deleteRemote(input: DeleteRCloneRemoteDto): Promise<any> {
await validateObject(DeleteRCloneRemoteDto, input);
this.logger.log(`Deleting remote: ${input.name}`);
return this.callRcloneApi('config/delete', { name: input.name });
}
/**
* Start a backup operation using sync/copy
* This copies a directory from source to destination
*/
async startBackup(input: RCloneStartBackupInput): Promise<any> {
await validateObject(RCloneStartBackupInput, input);
this.logger.log(`Starting backup from ${input.srcPath} to ${input.dstPath}`);
const params = {
srcFs: input.srcPath,
dstFs: input.dstPath,
...(input.options || {}),
};
return this.callRcloneApi('sync/copy', params);
}
/**
* Get the status of a running job
*/
async getJobStatus(input: GetRCloneJobStatusDto): Promise<any> {
await validateObject(GetRCloneJobStatusDto, input);
return this.callRcloneApi('job/status', { jobid: input.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> {
const url = `${this.rcloneBaseUrl}/${endpoint}`;
try {
this.logger.debug(
`Calling RClone API: ${url} with params: ${JSON.stringify(sanitizeParams(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) {
this.handleApiError(error, endpoint, params);
}
}
private handleApiError(error: unknown, endpoint: string, params: Record<string, unknown>): never {
if (error instanceof HTTPError) {
const statusCode = error.response.statusCode;
const rcloneError = this.extractRcloneError(error.response.body, params);
const detailedErrorMessage = `Rclone API Error (${endpoint}, HTTP ${statusCode}): ${rcloneError}`;
const sanitizedParams = sanitizeParams(params);
this.logger.error(
`Original ${detailedErrorMessage} | Params: ${JSON.stringify(sanitizedParams)}`,
error.stack
);
throw new Error(detailedErrorMessage);
} else if (error instanceof Error) {
const detailedErrorMessage = `Error calling RClone API (${endpoint}) with params ${JSON.stringify(sanitizeParams(params))}: ${error.message}`;
this.logger.error(detailedErrorMessage, error.stack);
throw error;
} else {
const detailedErrorMessage = `Unknown error calling RClone API (${endpoint}) with params ${JSON.stringify(sanitizeParams(params))}: ${String(error)}`;
this.logger.error(detailedErrorMessage);
throw new Error(detailedErrorMessage);
}
}
private extractRcloneError(responseBody: unknown, fallbackParams: Record<string, unknown>): string {
try {
let errorBody: unknown;
if (typeof responseBody === 'string') {
errorBody = JSON.parse(responseBody);
} else if (typeof responseBody === 'object' && responseBody !== null) {
errorBody = responseBody;
}
if (errorBody && typeof errorBody === 'object' && 'error' in errorBody) {
const typedErrorBody = errorBody as { error: unknown; input?: unknown };
let rcloneError = `Rclone Error: ${String(typedErrorBody.error)}`;
if (typedErrorBody.input) {
rcloneError += ` | Input: ${JSON.stringify(typedErrorBody.input)}`;
} else if (fallbackParams) {
rcloneError += ` | Original Params: ${JSON.stringify(fallbackParams)}`;
}
return rcloneError;
} else if (responseBody) {
return `Non-standard error response body: ${typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)}`;
} else {
return 'Empty error response body received.';
}
} catch (parseOrAccessError) {
return `Failed to process error response body. Raw body: ${typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)}`;
}
}
}

View File

@@ -0,0 +1,66 @@
import { Injectable, Logger } from '@nestjs/common';
import { type Layout } from '@jsonforms/core';
import { buildRcloneConfigSchema } from '@app/unraid-api/graph/resolvers/rclone/jsonforms/rclone-jsonforms-config.js';
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
import {
RCloneConfigFormInput,
RCloneProviderOptionResponse,
} from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
import { DataSlice } from '@app/unraid-api/types/json-forms.js';
/**
* Service responsible for generating form UI schemas and form logic
*/
@Injectable()
export class RCloneFormService {
private readonly logger = new Logger(RCloneFormService.name);
private providerNames: string[] = [];
private providerOptions: Record<string, RCloneProviderOptionResponse[]> = {};
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.providerNames = providersResponse.map((provider) => provider.Name);
this.providerOptions = providersResponse.reduce((acc, provider) => {
acc[provider.Name] = provider.Options;
return acc;
}, {});
this.logger.debug(`Loaded ${this.providerNames.length} provider types`);
}
} catch (error) {
this.logger.error(`Error loading provider information: ${error}`);
throw error;
}
}
/**
* Returns both data schema and UI schema for the form
*/
async getFormSchemas(options: RCloneConfigFormInput): Promise<{
dataSchema: { properties: DataSlice; type: 'object' };
uiSchema: Layout;
}> {
const { providerType: selectedProvider = '', showAdvanced = false } = options;
// Ensure provider info is loaded
if (Object.keys(this.providerOptions).length === 0) {
await this.loadProviderInfo();
}
return buildRcloneConfigSchema({
providerTypes: this.providerNames,
selectedProvider,
providerOptions: this.providerOptions,
showAdvanced,
});
}
}

View File

@@ -0,0 +1,208 @@
import { Field, ID, InputType, ObjectType } from '@nestjs/graphql';
import { type Layout } from '@jsonforms/core';
import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator';
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?: number;
Required?: boolean;
IsPassword?: boolean;
NoPrefix?: boolean;
Advanced?: boolean;
DefaultStr?: string;
ValueStr?: string;
Type?: string;
Examples?: Array<{ Value: string; Help: string; Provider: string }>;
}
/**
* Complete remote configuration as returned by rclone
*/
export interface RCloneRemoteConfig {
type: string;
[key: string]: unknown;
}
@InputType()
export class RCloneConfigFormInput {
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
providerType?: string;
@Field(() => Boolean, { defaultValue: false, nullable: true })
@IsOptional()
@IsBoolean()
showAdvanced?: boolean;
@Field(() => GraphQLJSON, { nullable: true })
@IsOptional()
@IsObject()
parameters?: Record<string, unknown>;
}
@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(() => [RCloneRemote])
remotes!: RCloneRemote[];
}
@ObjectType()
export class RCloneRemote {
@Field(() => String)
name!: string;
@Field(() => String)
type!: string;
@Field(() => GraphQLJSON)
parameters!: Record<string, unknown>;
@Field(() => GraphQLJSON, { description: 'Complete remote configuration' })
config!: RCloneRemoteConfig;
}
@InputType()
export class CreateRCloneRemoteInput {
@Field(() => String)
@IsString()
name!: string;
@Field(() => String)
@IsString()
type!: string;
@Field(() => GraphQLJSON)
@IsObject()
parameters!: Record<string, unknown>;
}
@InputType()
export class DeleteRCloneRemoteInput {
@Field(() => String)
@IsString()
name!: string;
}
@InputType()
export class RCloneStartBackupInput {
@Field(() => String)
@IsString()
srcPath!: string;
@Field(() => String)
@IsString()
dstPath!: string;
@Field(() => GraphQLJSON, { nullable: true })
@IsOptional()
@IsObject()
options?: Record<string, any>;
}
@InputType()
export class CreateRCloneRemoteDto {
@Field(() => String)
@IsString()
name!: string;
@Field(() => String)
@IsString()
type!: string;
@Field(() => GraphQLJSON)
@IsObject()
parameters!: Record<string, any>;
}
@InputType()
export class UpdateRCloneRemoteDto {
@Field(() => String)
@IsString()
name!: string;
@Field(() => GraphQLJSON)
@IsObject()
parameters!: Record<string, any>;
}
@InputType()
export class DeleteRCloneRemoteDto {
@Field(() => String)
@IsString()
name!: string;
}
@InputType()
export class GetRCloneRemoteConfigDto {
@Field(() => String)
@IsString()
name!: string;
}
@InputType()
export class GetRCloneRemoteDetailsDto {
@Field(() => String)
@IsString()
name!: string;
}
@InputType()
export class GetRCloneJobStatusDto {
@Field(() => String)
@IsString()
jobId!: string;
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
import { RCloneFormService } from '@app/unraid-api/graph/resolvers/rclone/rclone-form.service.js';
import { RCloneMutationsResolver } from '@app/unraid-api/graph/resolvers/rclone/rclone.mutation.resolver.js';
import { RCloneBackupSettingsResolver } from '@app/unraid-api/graph/resolvers/rclone/rclone.resolver.js';
import { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.service.js';
@Module({
imports: [],
providers: [
RCloneService,
RCloneApiService,
RCloneFormService,
RCloneBackupSettingsResolver,
RCloneMutationsResolver,
],
exports: [RCloneService, RCloneApiService],
})
export class RCloneModule {}

View File

@@ -0,0 +1,63 @@
import { Logger } from '@nestjs/common';
import { Args, 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 { RCloneMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js';
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
import {
CreateRCloneRemoteInput,
DeleteRCloneRemoteInput,
RCloneRemote,
} from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
/**
* Nested Resolvers for Mutations MUST use @ResolveField() instead of @Mutation()
*/
@Resolver(() => RCloneMutations)
export class RCloneMutationsResolver {
private readonly logger = new Logger(RCloneMutationsResolver.name);
constructor(private readonly rcloneApiService: RCloneApiService) {}
@ResolveField(() => RCloneRemote, { description: 'Create a new RClone remote' })
@UsePermissions({
action: AuthActionVerb.CREATE,
resource: Resource.FLASH,
possession: AuthPossession.ANY,
})
async createRCloneRemote(@Args('input') input: CreateRCloneRemoteInput): Promise<RCloneRemote> {
try {
const config = await this.rcloneApiService.createRemote(input);
return {
name: input.name,
type: input.type,
parameters: {},
config,
};
} catch (error) {
this.logger.error(`Error creating remote: ${error}`);
throw new Error(`Failed to create remote: ${error}`);
}
}
@ResolveField(() => Boolean, { description: 'Delete an existing RClone remote' })
@UsePermissions({
action: AuthActionVerb.DELETE,
resource: Resource.FLASH,
possession: AuthPossession.ANY,
})
async deleteRCloneRemote(@Args('input') input: DeleteRCloneRemoteInput): Promise<boolean> {
try {
await this.rcloneApiService.deleteRemote(input);
return true;
} catch (error) {
this.logger.error(`Error deleting remote: ${error}`);
throw new Error(`Failed to delete remote: ${error}`);
}
}
}

View File

@@ -0,0 +1,64 @@
import { Logger } from '@nestjs/common';
import { Args, 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 { RCloneFormService } from '@app/unraid-api/graph/resolvers/rclone/rclone-form.service.js';
import {
RCloneBackupConfigForm,
RCloneBackupSettings,
RCloneConfigFormInput,
RCloneRemote,
} from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
import { RCloneService } from '@app/unraid-api/graph/resolvers/rclone/rclone.service.js';
import { DataSlice } from '@app/unraid-api/types/json-forms.js';
@Resolver(() => RCloneBackupSettings)
export class RCloneBackupSettingsResolver {
private readonly logger = new Logger(RCloneBackupSettingsResolver.name);
constructor(
private readonly rcloneService: RCloneService,
private readonly rcloneApiService: RCloneApiService,
private readonly rcloneFormService: RCloneFormService
) {}
@Query(() => RCloneBackupSettings)
@UsePermissions({
action: AuthActionVerb.READ,
resource: Resource.FLASH,
possession: AuthPossession.ANY,
})
async rclone(): Promise<RCloneBackupSettings> {
return {} as RCloneBackupSettings;
}
@ResolveField(() => RCloneBackupConfigForm)
async configForm(
@Parent() _parent: RCloneBackupSettings,
@Args('formOptions', { type: () => RCloneConfigFormInput, nullable: true })
formOptions?: RCloneConfigFormInput
): Promise<RCloneBackupConfigForm> {
const form = await this.rcloneFormService.getFormSchemas(formOptions ?? {});
return {
id: 'rcloneBackupConfigForm',
dataSchema: form.dataSchema as { properties: DataSlice; type: 'object' },
uiSchema: form.uiSchema,
};
}
@ResolveField(() => [RCloneRemote])
async remotes(@Parent() _parent: RCloneBackupSettings): Promise<RCloneRemote[]> {
try {
return await this.rcloneService.getRemoteDetails();
} catch (error) {
this.logger.error(`Error listing remotes: ${error}`);
return [];
}
}
}

View File

@@ -0,0 +1,122 @@
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';
import { RCloneRemote } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.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 = providersResponse.map((provider) => provider.Name);
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();
}
/**
* Gets detailed information about all configured remotes
*/
async getRemoteDetails(): Promise<RCloneRemote[]> {
try {
const remoteNames = await this.rcloneApiService.listRemotes();
const remoteDetails: RCloneRemote[] = [];
for (const name of remoteNames) {
try {
const config = await this.rcloneApiService.getRemoteDetails({ name });
const { type, ...parameters } = config;
remoteDetails.push({
name,
type: type || '',
parameters,
config,
});
} catch (error) {
this.logger.error(`Error getting details for remote ${name}: ${error}`);
}
}
return remoteDetails;
} catch (error) {
this.logger.error(`Error listing remotes: ${error}`);
return [];
}
}
}

View File

@@ -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,

View File

@@ -0,0 +1,46 @@
import type { ControlElement, LabelElement, Layout, Rule } from '@jsonforms/core';
/**
* Creates a Layout (typically UnraidSettingsLayout) containing a Label and a Control element.
*/
export function createLabeledControl({
scope,
label,
description,
controlOptions,
labelOptions,
layoutOptions,
rule,
}: {
scope: string;
label: string;
description?: string;
controlOptions: ControlElement['options'];
labelOptions?: LabelElement['options'];
layoutOptions?: Layout['options'];
rule?: Rule;
}): Layout {
const layout: Layout & { scope?: string } = {
type: 'UnraidSettingsLayout', // Use the specific Unraid layout type
scope: scope, // Apply scope to the layout for potential rules/visibility
options: layoutOptions,
elements: [
{
type: 'Label',
text: label,
scope: scope, // Scope might be needed for specific label behaviors
options: { ...labelOptions, description },
} as LabelElement,
{
type: 'Control',
scope: scope,
options: controlOptions,
} as ControlElement,
],
};
// Conditionally add the rule to the layout if provided
if (rule) {
layout.rule = rule;
}
return layout;
}

View File

@@ -1,8 +1,9 @@
import { Controller, Get, Logger, Param, Res } from '@nestjs/common';
import { All, Controller, Get, Logger, Param, Req, Res } from '@nestjs/common';
import got from 'got';
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';

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js';
import { RestController } from '@app/unraid-api/rest/rest.controller.js';
import { RestService } from '@app/unraid-api/rest/rest.service.js';
@Module({
imports: [],
imports: [RCloneModule],
controllers: [RestController],
providers: [RestService],
})

View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import type { DataSlice, SettingSlice, UIElement } from '@app/unraid-api/types/json-forms.js';
import { createEmptySettingSlice, mergeSettingSlices } from '@app/unraid-api/types/json-forms.js';
describe('mergeSettingSlices', () => {
it('should return an empty slice when merging an empty array', () => {
const slices: SettingSlice[] = [];
const expected = createEmptySettingSlice();
expect(mergeSettingSlices(slices)).toEqual(expected);
});
it('should return the same slice when merging a single slice', () => {
const slice: SettingSlice = {
properties: { prop1: { type: 'string' } },
elements: [{ type: 'Control', scope: '#/properties/prop1' }],
};
expect(mergeSettingSlices([slice])).toEqual(slice);
});
it('should merge properties deeply and concatenate elements for multiple slices', () => {
const slice1: SettingSlice = {
properties: {
prop1: { type: 'string' },
nested: { type: 'object', properties: { nestedProp1: { type: 'boolean' } } },
},
elements: [{ type: 'Control', scope: '#/properties/prop1' } as UIElement],
};
const slice2: SettingSlice = {
properties: {
prop2: { type: 'number' },
nested: { type: 'object', properties: { nestedProp2: { type: 'string' } } }, // Overlapping nested property
},
elements: [
{ type: 'Control', scope: '#/properties/prop2' } as UIElement,
{ type: 'Label', text: 'Nested' } as UIElement,
],
};
const expectedProperties: DataSlice = {
prop1: { type: 'string' },
prop2: { type: 'number' },
nested: {
type: 'object',
properties: {
nestedProp1: { type: 'boolean' },
nestedProp2: { type: 'string' },
},
},
};
const expectedElements: UIElement[] = [
{ type: 'Control', scope: '#/properties/prop1' },
{ type: 'Control', scope: '#/properties/prop2' },
{ type: 'Label', text: 'Nested' },
];
const mergedSlice = mergeSettingSlices([slice1, slice2]);
expect(mergedSlice.properties).toEqual(expectedProperties);
expect(mergedSlice.elements).toEqual(expectedElements);
});
it('should handle slices with only properties or only elements', () => {
const slice1: SettingSlice = {
properties: { prop1: { type: 'string' } },
elements: [],
};
const slice2: SettingSlice = {
properties: {},
elements: [{ type: 'Control', scope: '#/properties/prop1' } as UIElement],
};
const slice3: SettingSlice = {
properties: { prop2: { type: 'number' } },
elements: [{ type: 'Label', text: 'Label' } as UIElement],
};
const expectedProperties: DataSlice = {
prop1: { type: 'string' },
prop2: { type: 'number' },
};
const expectedElements: UIElement[] = [
{ type: 'Control', scope: '#/properties/prop1' },
{ type: 'Label', text: 'Label' },
];
const mergedSlice = mergeSettingSlices([slice1, slice2, slice3]);
expect(mergedSlice.properties).toEqual(expectedProperties);
expect(mergedSlice.elements).toEqual(expectedElements);
});
});

View File

@@ -9,6 +9,7 @@ import type {
SchemaBasedCondition,
UISchemaElement,
} from '@jsonforms/core';
import { merge } from 'lodash-es';
/**
* JSON schema properties.
@@ -18,7 +19,9 @@ export type DataSlice = Record<string, JsonSchema>;
/**
* A JSONForms UI schema element.
*/
export type UIElement = UISchemaElement | LabelElement | Layout | ControlElement | Categorization;
export type UIElement = (UISchemaElement | LabelElement | Layout | ControlElement | Categorization) & {
elements?: UIElement[];
};
/**
* A condition for a JSONForms rule.
@@ -52,7 +55,9 @@ export function createEmptySettingSlice(): SettingSlice {
function reduceSlices(slices: SettingSlice[]): SettingSlice {
const result = createEmptySettingSlice();
for (const slice of slices) {
Object.assign(result.properties, slice.properties);
// Deep merge properties using lodash.merge
merge(result.properties, slice.properties);
// Append elements
result.elements.push(...slice.elements);
}
return result;

View File

@@ -10,6 +10,7 @@ import { cleanupTxzFiles } from "./utils/cleanup";
import { apiDir } from "./utils/paths";
import { getVendorBundleName, getVendorFullPath } from "./build-vendor-store";
import { getAssetUrl } from "./utils/bucket-urls";
import { ensureRclone } from "./utils/rclone-helper";
// Recursively search for manifest files
@@ -173,7 +174,10 @@ const buildTxz = async (validatedEnv: TxzEnv) => {
console.log(`Storing vendor archive information: ${vendorUrl} -> ${vendorFilename}`);
await storeVendorArchiveInfo(version, vendorUrl, vendorFilename);
await ensureNodeJs();
await Promise.all([
ensureNodeJs(),
ensureRclone()
]);
// Create package - must be run from within the pre-pack directory
// Use cd option to run command from prePackDir

View File

@@ -0,0 +1,64 @@
import { join } from "path";
import { existsSync, mkdirSync, createWriteStream, readFileSync } from "fs";
import { writeFile, readFile, unlink } from "fs/promises";
import { get } from "https";
import { $ } from "zx";
import { startingDir } from "./consts";
const RCLONE_VERSION_PATHS = [
join(startingDir, "..", ".rclone-version"),
join(startingDir, ".rclone-version"),
];
const findRcloneVersion = () => {
for (const path of RCLONE_VERSION_PATHS) {
if (existsSync(path)) {
return readFileSync(path, "utf8").trim();
}
}
throw new Error(".rclone-version file not found");
};
const RCLONE_VERSION = findRcloneVersion();
const RCLONE_FILENAME = `rclone-v${RCLONE_VERSION}-linux-amd64.zip`;
const RCLONE_URL = `https://downloads.rclone.org/v${RCLONE_VERSION}/${RCLONE_FILENAME}`;
const RCLONE_DEST = join(startingDir, "source", "dynamix.unraid.net", "usr", "local", "rclone");
const RCLONE_VERSION_FILE = join(RCLONE_DEST, ".rclone-version");
const RCLONE_BIN = join(RCLONE_DEST, "rclone");
async function fetchFile(url: string, dest: string) {
return new Promise((resolve, reject) => {
const file = createWriteStream(dest);
get(url, (response) => {
if (response.statusCode !== 200) {
reject(new Error(`Failed to get '${url}' (${response.statusCode})`));
return;
}
response.pipe(file);
file.on("finish", () => file.close(resolve));
file.on("error", reject);
}).on("error", reject);
});
}
export async function ensureRclone() {
let currentVersion: string | null = null;
if (existsSync(RCLONE_VERSION_FILE)) {
currentVersion = (await readFile(RCLONE_VERSION_FILE, "utf8")).trim();
}
if (currentVersion !== RCLONE_VERSION) {
mkdirSync(RCLONE_DEST, { recursive: true });
if (!existsSync(RCLONE_FILENAME)) {
await fetchFile(RCLONE_URL, RCLONE_FILENAME);
}
await $`unzip -oj ${RCLONE_FILENAME} rclone-v${RCLONE_VERSION}-linux-amd64/rclone -d ${RCLONE_DEST}`;
await $`chmod +x ${RCLONE_BIN}`;
await writeFile(RCLONE_VERSION_FILE, RCLONE_VERSION, "utf8");
// Clean up old rclone archives
const glob = await import("glob");
const files = glob.sync("rclone-v*-linux-amd64.zip", { cwd: startingDir });
for (const file of files) {
if (file !== RCLONE_FILENAME) await unlink(join(startingDir, file));
}
}
}

403
pnpm-lock.yaml generated
View File

@@ -204,12 +204,6 @@ importers:
graphql-tag:
specifier: ^2.12.6
version: 2.12.6(graphql@16.10.0)
graphql-type-json:
specifier: ^0.3.2
version: 0.3.2(graphql@16.10.0)
graphql-type-uuid:
specifier: ^0.2.0
version: 0.2.0(graphql@16.10.0)
graphql-ws:
specifier: ^6.0.0
version: 6.0.4(graphql@16.10.0)(ws@8.18.1)
@@ -687,12 +681,18 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
dompurify:
specifier: ^3.2.5
version: 3.2.5
kebab-case:
specifier: ^2.0.1
version: 2.0.1
lucide-vue-next:
specifier: ^0.511.0
version: 0.511.0(vue@3.5.13(typescript@5.8.3))
marked:
specifier: ^15.0.0
version: 15.0.7
reka-ui:
specifier: ^2.1.1
version: 2.1.1(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3))
@@ -710,20 +710,23 @@ importers:
specifier: ^4.4.1
version: 4.4.1(@vue/compiler-sfc@3.5.13)(prettier@3.5.3)
'@storybook/addon-essentials':
specifier: ^8.5.8
version: 8.6.9(@types/react@19.0.8)(storybook@8.6.9(prettier@3.5.3))
specifier: ^8.6.12
version: 8.6.12(@types/react@19.0.8)(storybook@8.6.12(prettier@3.5.3))
'@storybook/addon-interactions':
specifier: ^8.5.8
version: 8.6.9(storybook@8.6.9(prettier@3.5.3))
specifier: ^8.6.12
version: 8.6.12(storybook@8.6.12(prettier@3.5.3))
'@storybook/addon-links':
specifier: ^8.5.8
version: 8.6.9(react@19.0.0)(storybook@8.6.9(prettier@3.5.3))
specifier: ^8.6.12
version: 8.6.12(react@19.0.0)(storybook@8.6.12(prettier@3.5.3))
'@storybook/builder-vite':
specifier: ^8.5.8
version: 8.6.9(storybook@8.6.9(prettier@3.5.3))(vite@6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))
specifier: ^8.6.12
version: 8.6.12(storybook@8.6.12(prettier@3.5.3))(vite@6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))
'@storybook/vue3':
specifier: ^8.6.12
version: 8.6.12(storybook@8.6.12(prettier@3.5.3))(vue@3.5.13(typescript@5.8.3))
'@storybook/vue3-vite':
specifier: ^8.5.8
version: 8.6.9(storybook@8.6.9(prettier@3.5.3))(vite@6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))(vue@3.5.13(typescript@5.8.3))
specifier: ^8.6.12
version: 8.6.12(storybook@8.6.12(prettier@3.5.3))(vite@6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))(vue@3.5.13(typescript@5.8.3))
'@tailwindcss/typography':
specifier: ^0.5.15
version: 0.5.16(tailwindcss@3.4.17)
@@ -766,6 +769,9 @@ importers:
autoprefixer:
specifier: ^10.4.20
version: 10.4.21(postcss@8.5.3)
concurrently:
specifier: ^9.1.2
version: 9.1.2
eslint:
specifier: ^9.17.0
version: 9.23.0(jiti@2.4.2)
@@ -790,6 +796,9 @@ importers:
postcss:
specifier: ^8.4.49
version: 8.5.3
postcss-import:
specifier: ^16.1.0
version: 16.1.0(postcss@8.5.3)
prettier:
specifier: 3.5.3
version: 3.5.3
@@ -800,8 +809,8 @@ importers:
specifier: ^6.0.1
version: 6.0.1
storybook:
specifier: ^8.5.8
version: 8.6.9(prettier@3.5.3)
specifier: ^8.6.12
version: 8.6.12(prettier@3.5.3)
tailwind-rem-to-rem:
specifier: github:unraid/tailwind-rem-to-rem
version: '@unraid/tailwind-rem-to-rem@https://codeload.github.com/unraid/tailwind-rem-to-rem/tar.gz/4b907d0cdb3abda88de9813e33c13c3e7b1300c4(tailwindcss@3.4.17)'
@@ -3596,105 +3605,105 @@ packages:
'@speed-highlight/core@1.2.7':
resolution: {integrity: sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==}
'@storybook/addon-actions@8.6.9':
resolution: {integrity: sha512-H2v17sMbSl8jhSulPxcOyChsFbzik9E7mgCWIf4P114KcIUokWLVuALnSOeqHME6lY0pPBZs3DgvVVMVMm7zNw==}
'@storybook/addon-actions@8.6.12':
resolution: {integrity: sha512-B5kfiRvi35oJ0NIo53CGH66H471A3XTzrfaa6SxXEJsgxxSeKScG5YeXcCvLiZfvANRQ7QDsmzPUgg0o3hdMXw==}
peerDependencies:
storybook: ^8.6.9
storybook: ^8.6.12
'@storybook/addon-backgrounds@8.6.9':
resolution: {integrity: sha512-DiNpKJq4sEqTCGwwGs8fwi1hxBniCQMxsJFfrYlIx0HTyfA7AMROqP9fyv1aCV1JWDiwlL+cwCurkoyhpuZioQ==}
'@storybook/addon-backgrounds@8.6.12':
resolution: {integrity: sha512-lmIAma9BiiCTbJ8YfdZkXjpnAIrOUcgboLkt1f6XJ78vNEMnLNzD9gnh7Tssz1qrqvm34v9daDjIb+ggdiKp3Q==}
peerDependencies:
storybook: ^8.6.9
storybook: ^8.6.12
'@storybook/addon-controls@8.6.9':
resolution: {integrity: sha512-YXBYsbHqdYhmrbGI+wv9LAr/LlKnPt9f9GL+9rw82lnYadWObYxzUxs+PPLNO5tc14fd2g+FMVHOfovaRdFvrQ==}
'@storybook/addon-controls@8.6.12':
resolution: {integrity: sha512-9VSRPJWQVb9wLp21uvpxDGNctYptyUX0gbvxIWOHMH3R2DslSoq41lsC/oQ4l4zSHVdL+nq8sCTkhBxIsjKqdQ==}
peerDependencies:
storybook: ^8.6.9
storybook: ^8.6.12
'@storybook/addon-docs@8.6.9':
resolution: {integrity: sha512-yAP59G5Vd+E6O9KLfBR5ALdOFA5yEZ0n1f8Ne9jwF+NGu1U8KNIfWnZmBYaBGe+bpYn0CWV5AfdFvw83bzHYpw==}
'@storybook/addon-docs@8.6.12':
resolution: {integrity: sha512-kEezQjAf/p3SpDzLABgg4fbT48B6dkT2LiZCKTRmCrJVtuReaAr4R9MMM6Jsph6XjbIj/SvOWf3CMeOPXOs9sg==}
peerDependencies:
storybook: ^8.6.9
storybook: ^8.6.12
'@storybook/addon-essentials@8.6.9':
resolution: {integrity: sha512-n3DSSIjDsVDw7uOatP2remC5SVSIfjwHcLGor85xLd1SQUh98wednM1Iby19qc/QR69UuOL0nB/d5yG1ifh0sA==}
'@storybook/addon-essentials@8.6.12':
resolution: {integrity: sha512-Y/7e8KFlttaNfv7q2zoHMPdX6hPXHdsuQMAjYl5NG9HOAJREu4XBy4KZpbcozRe4ApZ78rYsN/MO1EuA+bNMIA==}
peerDependencies:
storybook: ^8.6.9
storybook: ^8.6.12
'@storybook/addon-highlight@8.6.9':
resolution: {integrity: sha512-I0gBHgaH74wX6yf5S7zUmdfr25hwPONpSAqPPGBSNYu0Jj9Je+ANr1y4T1I3cOaEvf73QntDhCgHC6/iqY90Fw==}
'@storybook/addon-highlight@8.6.12':
resolution: {integrity: sha512-9FITVxdoycZ+eXuAZL9ElWyML/0fPPn9UgnnAkrU7zkMi+Segq/Tx7y+WWanC5zfWZrXAuG6WTOYEXeWQdm//w==}
peerDependencies:
storybook: ^8.6.9
storybook: ^8.6.12
'@storybook/addon-interactions@8.6.9':
resolution: {integrity: sha512-KpSVjcDD+5vmGA78MM2blsfy8J/PfuIMb74nJufgjci2xlzUxB8dGEFJACZPfqM5kUuUv/AhHHsAzP1r/wr83Q==}
'@storybook/addon-interactions@8.6.12':
resolution: {integrity: sha512-cTAJlTq6uVZBEbtwdXkXoPQ4jHOAGKQnYSezBT4pfNkdjn/FnEeaQhMBDzf14h2wr5OgBnJa6Lmd8LD9ficz4A==}
peerDependencies:
storybook: ^8.6.9
storybook: ^8.6.12
'@storybook/addon-links@8.6.9':
resolution: {integrity: sha512-cYYlsaMHvEJzGqJ3BO5BpXaa00AxYtEKjJEFP7q/LDZBxMnrChzDygFUTAAbUTHF4U3mNCrl1KuyoUL3nMQquA==}
'@storybook/addon-links@8.6.12':
resolution: {integrity: sha512-AfKujFHoAxhxq4yu+6NwylltS9lf5MPs1eLLXvOlwo3l7Y/c68OdxJ7j68vLQhs9H173WVYjKyjbjFxJWf/YYg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
storybook: ^8.6.9
storybook: ^8.6.12
peerDependenciesMeta:
react:
optional: true
'@storybook/addon-measure@8.6.9':
resolution: {integrity: sha512-2GrHtaYZgM7qeil5/XfNJrdnan7hoLLUyU7w7fph0EVl7tiwmhtp4He0PX9hrT/Abk2HxeCP4WU2fAGwIuTkYg==}
'@storybook/addon-measure@8.6.12':
resolution: {integrity: sha512-tACmwqqOvutaQSduw8SMb62wICaT1rWaHtMN3vtWXuxgDPSdJQxLP+wdVyRYMAgpxhLyIO7YRf++Hfha9RHgFg==}
peerDependencies:
storybook: ^8.6.9
storybook: ^8.6.12
'@storybook/addon-outline@8.6.9':
resolution: {integrity: sha512-YXfiSmjdpXGNYns9NZfdiEbwRfOW/Naym0dIH7s1LAlZZPJvtEYe2hNUOjBfAEm8ZhC1fA1+pZFnspOQHPENlA==}
'@storybook/addon-outline@8.6.12':
resolution: {integrity: sha512-1ylwm+n1s40S91No0v9T4tCjZORu3GbnjINlyjYTDLLhQHyBQd3nWR1Y1eewU4xH4cW9SnSLcMQFS/82xHqU6A==}
peerDependencies:
storybook: ^8.6.9
storybook: ^8.6.12
'@storybook/addon-toolbars@8.6.9':
resolution: {integrity: sha512-WOO3CHyzqEql9xnNzi7BUkPRPGHGMCtAR+szGeWqmuj3GZLqXwDOb8HDa3aVMIhVEKhk5jN2zGQmxH53vReBNQ==}
'@storybook/addon-toolbars@8.6.12':
resolution: {integrity: sha512-HEcSzo1DyFtIu5/ikVOmh5h85C1IvK9iFKSzBR6ice33zBOaehVJK+Z5f487MOXxPsZ63uvWUytwPyViGInj+g==}
peerDependencies:
storybook: ^8.6.9
storybook: ^8.6.12
'@storybook/addon-viewport@8.6.9':
resolution: {integrity: sha512-1xkozyB1zs3eSNTc8ePAMcajUfbKvNMTjs5LYdts2N1Ss0xeZ+K/gphfRg0GaYsNvRYi5piufag/niHCGkT3hA==}
'@storybook/addon-viewport@8.6.12':
resolution: {integrity: sha512-EXK2LArAnABsPP0leJKy78L/lbMWow+EIJfytEP5fHaW4EhMR6h7Hzaqzre6U0IMMr/jVFa1ci+m0PJ0eQc2bw==}
peerDependencies:
storybook: ^8.6.9
storybook: ^8.6.12
'@storybook/blocks@8.6.9':
resolution: {integrity: sha512-+vSRkHLD7ho3Wd1WVA1KrYAnv7BnGHOhHWHAgTR5IdeMdgzQxm6+HHeqGB5sncilA0AjVC6udBIgHbCSuD61dA==}
'@storybook/blocks@8.6.12':
resolution: {integrity: sha512-DohlTq6HM1jDbHYiXL4ZvZ00VkhpUp5uftzj/CZDLY1fYHRjqtaTwWm2/OpceivMA8zDitLcq5atEZN+f+siTg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
storybook: ^8.6.9
storybook: ^8.6.12
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
'@storybook/builder-vite@8.6.9':
resolution: {integrity: sha512-8U11A7sLPvvcnJQ3pXyoX1LdJDpa4+JOYcASL9A+DL591jkfYKxhim7R4BOHO55aetmqQAoA/LEAD5runu7zoQ==}
'@storybook/builder-vite@8.6.12':
resolution: {integrity: sha512-Gju21ud/3Qw4v2vLNaa5SuJECsI9ICNRr2G0UyCCzRvCHg8jpA9lDReu2NqhLDyFIuDG+ZYT38gcaHEUoNQ8KQ==}
peerDependencies:
storybook: ^8.6.9
storybook: ^8.6.12
vite: ^4.0.0 || ^5.0.0 || ^6.0.0
'@storybook/components@8.6.9':
resolution: {integrity: sha512-CqWUAYK/RgV++sXfiDG63DM2JF2FeidvnMO5/bki2hFbEqgs0/yy7BKUjhsGmuri5y+r9B2FJhW0WnE6PI8NWw==}
'@storybook/components@8.6.12':
resolution: {integrity: sha512-FiaE8xvCdvKC2arYusgtlDNZ77b8ysr8njAYQZwwaIHjy27TbR2tEpLDCmUwSbANNmivtc/xGEiDDwcNppMWlQ==}
peerDependencies:
storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0
'@storybook/core@8.6.9':
resolution: {integrity: sha512-psYxJAlj34ZaDAk+OvT/He6ZuUh0eGiHVtZNe0xWbNp5pQvOBjf+dg48swdI6KEbVs3aeU+Wnyra/ViU2RtA+Q==}
'@storybook/core@8.6.12':
resolution: {integrity: sha512-t+ZuDzAlsXKa6tLxNZT81gEAt4GNwsKP/Id2wluhmUWD/lwYW0uum1JiPUuanw8xD6TdakCW/7ULZc7aQUBLCQ==}
peerDependencies:
prettier: ^2 || ^3
peerDependenciesMeta:
prettier:
optional: true
'@storybook/csf-plugin@8.6.9':
resolution: {integrity: sha512-IQnhyaVUkcRR9e4xiHN83xMQtTMH+lJp472iMifUIqxx/Yw137BTef2DEEp6EnRct4yKrch24+Nl65LWg0mRpQ==}
'@storybook/csf-plugin@8.6.12':
resolution: {integrity: sha512-6s8CnP1aoKPb3XtC0jRLUp8M5vTA8RhGAwQDKUsFpCC7g89JR9CaKs9FY2ZSzsNbjR15uASi7b3K8BzeYumYQg==}
peerDependencies:
storybook: ^8.6.9
storybook: ^8.6.12
'@storybook/global@5.0.0':
resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==}
@@ -3706,50 +3715,50 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
'@storybook/instrumenter@8.6.9':
resolution: {integrity: sha512-Gp6OSiu9KA/p1HWd7VW9TtpWX32ZBfqRVrOm4wW1AM6B4XACbQWFE/aQ25HwU834yfdJkr2BW+uUH8DBAQ6kTw==}
'@storybook/instrumenter@8.6.12':
resolution: {integrity: sha512-VK5fYAF8jMwWP/u3YsmSwKGh+FeSY8WZn78flzRUwirp2Eg1WWjsqPRubAk7yTpcqcC/km9YMF3KbqfzRv2s/A==}
peerDependencies:
storybook: ^8.6.9
storybook: ^8.6.12
'@storybook/manager-api@8.6.9':
resolution: {integrity: sha512-mxq9B9rxAraOCBapGKsUDfI+8yNtFhTgKMZCxmHoUCxvAHaIt4S9JcdX0qQQKUsBTr/b2hHm0O7A8DYrbgBRfw==}
'@storybook/manager-api@8.6.12':
resolution: {integrity: sha512-O0SpISeJLNTQvhSBOsWzzkCgs8vCjOq1578rwqHlC6jWWm4QmtfdyXqnv7rR1Hk08kQ+Dzqh0uhwHx0nfwy4nQ==}
peerDependencies:
storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0
'@storybook/preview-api@8.6.9':
resolution: {integrity: sha512-hW3Z8NBrGs2bNunaHgrLjpfrOcWsxH0ejAqaba8MolPXjzNs0lTFF/Ela7pUsh2m1R4/kiD+WfddQzyipUo4Mg==}
'@storybook/preview-api@8.6.12':
resolution: {integrity: sha512-84FE3Hrs0AYKHqpDZOwx1S/ffOfxBdL65lhCoeI8GoWwCkzwa9zEP3kvXBo/BnEDO7nAfxvMhjASTZXbKRJh5Q==}
peerDependencies:
storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0
'@storybook/react-dom-shim@8.6.9':
resolution: {integrity: sha512-SjqP6r5yy87OJRAiq1JzFazn6VWfptOA2HaxOiP8zRhJgG41K0Vseh8tbZdycj1AzJYSCcnKaIcfd/GEo/41+g==}
'@storybook/react-dom-shim@8.6.12':
resolution: {integrity: sha512-51QvoimkBzYs8s3rCYnY5h0cFqLz/Mh0vRcughwYaXckWzDBV8l67WBO5Xf5nBsukCbWyqBVPpEQLww8s7mrLA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta
storybook: ^8.6.9
storybook: ^8.6.12
'@storybook/test@8.6.9':
resolution: {integrity: sha512-lIJA6jup3ZZNkKFyUiy1q2tHWZv5q5bTaLxTnI85XIWr+sFCZG5oo3pOQESBkX4V95rv8sq9gEmEWySZvW7MBw==}
'@storybook/test@8.6.12':
resolution: {integrity: sha512-0BK1Eg+VD0lNMB1BtxqHE3tP9FdkUmohtvWG7cq6lWvMrbCmAmh3VWai3RMCCDOukPFpjabOr8BBRLVvhNpv2w==}
peerDependencies:
storybook: ^8.6.9
storybook: ^8.6.12
'@storybook/theming@8.6.9':
resolution: {integrity: sha512-FQafe66itGnIh0V42R65tgFKyz0RshpIs0pTrxrdByuB2yKsep+f8ZgKLJE3fCKw/Egw4bUuICo2m8d7uOOumA==}
'@storybook/theming@8.6.12':
resolution: {integrity: sha512-6VjZg8HJ2Op7+KV7ihJpYrDnFtd9D1jrQnUS8LckcpuBXrIEbaut5+34ObY8ssQnSqkk2GwIZBBBQYQBCVvkOw==}
peerDependencies:
storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0
'@storybook/vue3-vite@8.6.9':
resolution: {integrity: sha512-TTPzFwK7Yb1diC0o4uUrdmoDYVHRqRlH/xD8sZzud+N7MK+OJ7HPyCN8bw3e6HdsqLdJmfos45hRVTBgTYLOYA==}
'@storybook/vue3-vite@8.6.12':
resolution: {integrity: sha512-ihYH2TiV14B8V1mrCVVrbjuf+F6+V/78oWofVkvnUQnpwH4CnAySGf6bz6c6/Y6qEr9r30ECUe6/sS0TMt1ZAQ==}
engines: {node: '>=18.0.0'}
peerDependencies:
storybook: ^8.6.9
storybook: ^8.6.12
vite: ^4.0.0 || ^5.0.0 || ^6.0.0
'@storybook/vue3@8.6.9':
resolution: {integrity: sha512-asGlWyITyMGytNO+yXrWKcU+Ygk9G9zlmb0mVoTFxZGLiq/Wk3OmHmOlf5g0LyU8bkps43ZdkovEXfvMwQVm6A==}
'@storybook/vue3@8.6.12':
resolution: {integrity: sha512-mgGRMrFghDW5nHCDbdbhC4YUrOs7mCzwEuLZtdcvpB8TUPP62lTSnv3Gvcz8r12HjyIK6Jow9WgjTtdownGzkA==}
engines: {node: '>=18.0.0'}
peerDependencies:
storybook: ^8.6.9
storybook: ^8.6.12
vue: ^3.0.0
'@stylistic/eslint-plugin@4.2.0':
@@ -5592,6 +5601,11 @@ packages:
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
engines: {'0': node >= 6.0}
concurrently@9.1.2:
resolution: {integrity: sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==}
engines: {node: '>=18'}
hasBin: true
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
@@ -6292,6 +6306,9 @@ packages:
dompurify@3.2.4:
resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==}
dompurify@3.2.5:
resolution: {integrity: sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==}
domutils@2.8.0:
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
@@ -7476,16 +7493,6 @@ packages:
peerDependencies:
graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
graphql-type-json@0.3.2:
resolution: {integrity: sha512-J+vjof74oMlCWXSvt0DOf2APEdZOCdubEvGDUAlqH//VBYcOYsGgRW7Xzorr44LvkjiuvecWc8fChxuZZbChtg==}
peerDependencies:
graphql: '>=0.8.0'
graphql-type-uuid@0.2.0:
resolution: {integrity: sha512-AHTJn95nj5y/M4d2nqWYzWJNd/C/9XA7aYIdVxnySeDFXv/lq5ZlL9RtH/iNvD1pbEZIkZmnPHqJKPYQDelEUg==}
peerDependencies:
graphql: '>=0.8.0'
graphql-ws@6.0.4:
resolution: {integrity: sha512-8b4OZtNOvv8+NZva8HXamrc0y1jluYC0+13gdh7198FKjVzXyTvVc95DCwGzaKEfn3YuWZxUqjJlHe3qKM/F2g==}
engines: {node: '>=20'}
@@ -9712,6 +9719,12 @@ packages:
peerDependencies:
postcss: ^8.0.0
postcss-import@16.1.0:
resolution: {integrity: sha512-7hsAZ4xGXl4MW+OKEWCnF6T5jqBw80/EE9aXg1r2yyn1RsVEU8EtKXbijEODa+rg7iih4bKf7vlvTGYR4CnPNg==}
engines: {node: '>=18.0.0'}
peerDependencies:
postcss: ^8.0.0
postcss-js@4.0.1:
resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==}
engines: {node: ^12 || ^14 || >= 16}
@@ -10082,7 +10095,6 @@ packages:
engines: {node: '>=0.6.0', teleport: '>=0.2.0'}
deprecated: |-
You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.
(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)
qs@6.13.0:
@@ -10841,8 +10853,8 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
storybook@8.6.9:
resolution: {integrity: sha512-Iw4+R4V3yX7MhXJaLBAT4oLtZ+SaTzX8KvUNZiQzvdD+TrFKVA3QKV8gvWjstGyU2dd+afE1Ph6EG5Xa2Az2CA==}
storybook@8.6.12:
resolution: {integrity: sha512-Z/nWYEHBTLK1ZBtAWdhxC0l5zf7ioJ7G4+zYqtTdYeb67gTnxNj80gehf8o8QY9L2zA2+eyMRGLC2V5fI7Z3Tw==}
hasBin: true
peerDependencies:
prettier: ^2 || ^3
@@ -11265,6 +11277,10 @@ packages:
resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==}
engines: {node: '>=18'}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
trim-newlines@3.0.1:
resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==}
engines: {node: '>=8'}
@@ -15448,125 +15464,125 @@ snapshots:
'@speed-highlight/core@1.2.7': {}
'@storybook/addon-actions@8.6.9(storybook@8.6.9(prettier@3.5.3))':
'@storybook/addon-actions@8.6.12(storybook@8.6.12(prettier@3.5.3))':
dependencies:
'@storybook/global': 5.0.0
'@types/uuid': 9.0.8
dequal: 2.0.3
polished: 4.3.1
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
uuid: 9.0.1
'@storybook/addon-backgrounds@8.6.9(storybook@8.6.9(prettier@3.5.3))':
'@storybook/addon-backgrounds@8.6.12(storybook@8.6.12(prettier@3.5.3))':
dependencies:
'@storybook/global': 5.0.0
memoizerific: 1.11.3
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
ts-dedent: 2.2.0
'@storybook/addon-controls@8.6.9(storybook@8.6.9(prettier@3.5.3))':
'@storybook/addon-controls@8.6.12(storybook@8.6.12(prettier@3.5.3))':
dependencies:
'@storybook/global': 5.0.0
dequal: 2.0.3
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
ts-dedent: 2.2.0
'@storybook/addon-docs@8.6.9(@types/react@19.0.8)(storybook@8.6.9(prettier@3.5.3))':
'@storybook/addon-docs@8.6.12(@types/react@19.0.8)(storybook@8.6.12(prettier@3.5.3))':
dependencies:
'@mdx-js/react': 3.1.0(@types/react@19.0.8)(react@19.0.0)
'@storybook/blocks': 8.6.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.6.9(prettier@3.5.3))
'@storybook/csf-plugin': 8.6.9(storybook@8.6.9(prettier@3.5.3))
'@storybook/react-dom-shim': 8.6.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.6.9(prettier@3.5.3))
'@storybook/blocks': 8.6.12(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.6.12(prettier@3.5.3))
'@storybook/csf-plugin': 8.6.12(storybook@8.6.12(prettier@3.5.3))
'@storybook/react-dom-shim': 8.6.12(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.6.12(prettier@3.5.3))
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
ts-dedent: 2.2.0
transitivePeerDependencies:
- '@types/react'
'@storybook/addon-essentials@8.6.9(@types/react@19.0.8)(storybook@8.6.9(prettier@3.5.3))':
'@storybook/addon-essentials@8.6.12(@types/react@19.0.8)(storybook@8.6.12(prettier@3.5.3))':
dependencies:
'@storybook/addon-actions': 8.6.9(storybook@8.6.9(prettier@3.5.3))
'@storybook/addon-backgrounds': 8.6.9(storybook@8.6.9(prettier@3.5.3))
'@storybook/addon-controls': 8.6.9(storybook@8.6.9(prettier@3.5.3))
'@storybook/addon-docs': 8.6.9(@types/react@19.0.8)(storybook@8.6.9(prettier@3.5.3))
'@storybook/addon-highlight': 8.6.9(storybook@8.6.9(prettier@3.5.3))
'@storybook/addon-measure': 8.6.9(storybook@8.6.9(prettier@3.5.3))
'@storybook/addon-outline': 8.6.9(storybook@8.6.9(prettier@3.5.3))
'@storybook/addon-toolbars': 8.6.9(storybook@8.6.9(prettier@3.5.3))
'@storybook/addon-viewport': 8.6.9(storybook@8.6.9(prettier@3.5.3))
storybook: 8.6.9(prettier@3.5.3)
'@storybook/addon-actions': 8.6.12(storybook@8.6.12(prettier@3.5.3))
'@storybook/addon-backgrounds': 8.6.12(storybook@8.6.12(prettier@3.5.3))
'@storybook/addon-controls': 8.6.12(storybook@8.6.12(prettier@3.5.3))
'@storybook/addon-docs': 8.6.12(@types/react@19.0.8)(storybook@8.6.12(prettier@3.5.3))
'@storybook/addon-highlight': 8.6.12(storybook@8.6.12(prettier@3.5.3))
'@storybook/addon-measure': 8.6.12(storybook@8.6.12(prettier@3.5.3))
'@storybook/addon-outline': 8.6.12(storybook@8.6.12(prettier@3.5.3))
'@storybook/addon-toolbars': 8.6.12(storybook@8.6.12(prettier@3.5.3))
'@storybook/addon-viewport': 8.6.12(storybook@8.6.12(prettier@3.5.3))
storybook: 8.6.12(prettier@3.5.3)
ts-dedent: 2.2.0
transitivePeerDependencies:
- '@types/react'
'@storybook/addon-highlight@8.6.9(storybook@8.6.9(prettier@3.5.3))':
'@storybook/addon-highlight@8.6.12(storybook@8.6.12(prettier@3.5.3))':
dependencies:
'@storybook/global': 5.0.0
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
'@storybook/addon-interactions@8.6.9(storybook@8.6.9(prettier@3.5.3))':
'@storybook/addon-interactions@8.6.12(storybook@8.6.12(prettier@3.5.3))':
dependencies:
'@storybook/global': 5.0.0
'@storybook/instrumenter': 8.6.9(storybook@8.6.9(prettier@3.5.3))
'@storybook/test': 8.6.9(storybook@8.6.9(prettier@3.5.3))
'@storybook/instrumenter': 8.6.12(storybook@8.6.12(prettier@3.5.3))
'@storybook/test': 8.6.12(storybook@8.6.12(prettier@3.5.3))
polished: 4.3.1
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
ts-dedent: 2.2.0
'@storybook/addon-links@8.6.9(react@19.0.0)(storybook@8.6.9(prettier@3.5.3))':
'@storybook/addon-links@8.6.12(react@19.0.0)(storybook@8.6.12(prettier@3.5.3))':
dependencies:
'@storybook/global': 5.0.0
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
ts-dedent: 2.2.0
optionalDependencies:
react: 19.0.0
'@storybook/addon-measure@8.6.9(storybook@8.6.9(prettier@3.5.3))':
'@storybook/addon-measure@8.6.12(storybook@8.6.12(prettier@3.5.3))':
dependencies:
'@storybook/global': 5.0.0
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
tiny-invariant: 1.3.3
'@storybook/addon-outline@8.6.9(storybook@8.6.9(prettier@3.5.3))':
'@storybook/addon-outline@8.6.12(storybook@8.6.12(prettier@3.5.3))':
dependencies:
'@storybook/global': 5.0.0
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
ts-dedent: 2.2.0
'@storybook/addon-toolbars@8.6.9(storybook@8.6.9(prettier@3.5.3))':
'@storybook/addon-toolbars@8.6.12(storybook@8.6.12(prettier@3.5.3))':
dependencies:
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
'@storybook/addon-viewport@8.6.9(storybook@8.6.9(prettier@3.5.3))':
'@storybook/addon-viewport@8.6.12(storybook@8.6.12(prettier@3.5.3))':
dependencies:
memoizerific: 1.11.3
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
'@storybook/blocks@8.6.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.6.9(prettier@3.5.3))':
'@storybook/blocks@8.6.12(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.6.12(prettier@3.5.3))':
dependencies:
'@storybook/icons': 1.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
ts-dedent: 2.2.0
optionalDependencies:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
'@storybook/builder-vite@8.6.9(storybook@8.6.9(prettier@3.5.3))(vite@6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))':
'@storybook/builder-vite@8.6.12(storybook@8.6.12(prettier@3.5.3))(vite@6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))':
dependencies:
'@storybook/csf-plugin': 8.6.9(storybook@8.6.9(prettier@3.5.3))
'@storybook/csf-plugin': 8.6.12(storybook@8.6.12(prettier@3.5.3))
browser-assert: 1.2.1
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
ts-dedent: 2.2.0
vite: 6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1)
'@storybook/components@8.6.9(storybook@8.6.9(prettier@3.5.3))':
'@storybook/components@8.6.12(storybook@8.6.12(prettier@3.5.3))':
dependencies:
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
'@storybook/core@8.6.9(prettier@3.5.3)(storybook@8.6.9(prettier@3.5.3))':
'@storybook/core@8.6.12(prettier@3.5.3)(storybook@8.6.12(prettier@3.5.3))':
dependencies:
'@storybook/theming': 8.6.9(storybook@8.6.9(prettier@3.5.3))
'@storybook/theming': 8.6.12(storybook@8.6.12(prettier@3.5.3))
better-opn: 3.0.2
browser-assert: 1.2.1
esbuild: 0.25.1
@@ -15585,9 +15601,9 @@ snapshots:
- supports-color
- utf-8-validate
'@storybook/csf-plugin@8.6.9(storybook@8.6.9(prettier@3.5.3))':
'@storybook/csf-plugin@8.6.12(storybook@8.6.12(prettier@3.5.3))':
dependencies:
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
unplugin: 1.16.1
'@storybook/global@5.0.0': {}
@@ -15597,48 +15613,48 @@ snapshots:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
'@storybook/instrumenter@8.6.9(storybook@8.6.9(prettier@3.5.3))':
'@storybook/instrumenter@8.6.12(storybook@8.6.12(prettier@3.5.3))':
dependencies:
'@storybook/global': 5.0.0
'@vitest/utils': 2.1.9
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
'@storybook/manager-api@8.6.9(storybook@8.6.9(prettier@3.5.3))':
'@storybook/manager-api@8.6.12(storybook@8.6.12(prettier@3.5.3))':
dependencies:
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
'@storybook/preview-api@8.6.9(storybook@8.6.9(prettier@3.5.3))':
'@storybook/preview-api@8.6.12(storybook@8.6.12(prettier@3.5.3))':
dependencies:
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
'@storybook/react-dom-shim@8.6.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.6.9(prettier@3.5.3))':
'@storybook/react-dom-shim@8.6.12(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@8.6.12(prettier@3.5.3))':
dependencies:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
'@storybook/test@8.6.9(storybook@8.6.9(prettier@3.5.3))':
'@storybook/test@8.6.12(storybook@8.6.12(prettier@3.5.3))':
dependencies:
'@storybook/global': 5.0.0
'@storybook/instrumenter': 8.6.9(storybook@8.6.9(prettier@3.5.3))
'@storybook/instrumenter': 8.6.12(storybook@8.6.12(prettier@3.5.3))
'@testing-library/dom': 10.4.0
'@testing-library/jest-dom': 6.5.0
'@testing-library/user-event': 14.5.2(@testing-library/dom@10.4.0)
'@vitest/expect': 2.0.5
'@vitest/spy': 2.0.5
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
'@storybook/theming@8.6.9(storybook@8.6.9(prettier@3.5.3))':
'@storybook/theming@8.6.12(storybook@8.6.12(prettier@3.5.3))':
dependencies:
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
'@storybook/vue3-vite@8.6.9(storybook@8.6.9(prettier@3.5.3))(vite@6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))(vue@3.5.13(typescript@5.8.3))':
'@storybook/vue3-vite@8.6.12(storybook@8.6.12(prettier@3.5.3))(vite@6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))(vue@3.5.13(typescript@5.8.3))':
dependencies:
'@storybook/builder-vite': 8.6.9(storybook@8.6.9(prettier@3.5.3))(vite@6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))
'@storybook/vue3': 8.6.9(storybook@8.6.9(prettier@3.5.3))(vue@3.5.13(typescript@5.8.3))
'@storybook/builder-vite': 8.6.12(storybook@8.6.12(prettier@3.5.3))(vite@6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))
'@storybook/vue3': 8.6.12(storybook@8.6.12(prettier@3.5.3))(vue@3.5.13(typescript@5.8.3))
find-package-json: 1.2.0
magic-string: 0.30.17
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
typescript: 5.8.3
vite: 6.2.3(@types/node@22.15.3)(jiti@2.4.2)(stylus@0.57.0)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1)
vue-component-meta: 2.2.8(typescript@5.8.3)
@@ -15646,15 +15662,15 @@ snapshots:
transitivePeerDependencies:
- vue
'@storybook/vue3@8.6.9(storybook@8.6.9(prettier@3.5.3))(vue@3.5.13(typescript@5.8.3))':
'@storybook/vue3@8.6.12(storybook@8.6.12(prettier@3.5.3))(vue@3.5.13(typescript@5.8.3))':
dependencies:
'@storybook/components': 8.6.9(storybook@8.6.9(prettier@3.5.3))
'@storybook/components': 8.6.12(storybook@8.6.12(prettier@3.5.3))
'@storybook/global': 5.0.0
'@storybook/manager-api': 8.6.9(storybook@8.6.9(prettier@3.5.3))
'@storybook/preview-api': 8.6.9(storybook@8.6.9(prettier@3.5.3))
'@storybook/theming': 8.6.9(storybook@8.6.9(prettier@3.5.3))
'@storybook/manager-api': 8.6.12(storybook@8.6.12(prettier@3.5.3))
'@storybook/preview-api': 8.6.12(storybook@8.6.12(prettier@3.5.3))
'@storybook/theming': 8.6.12(storybook@8.6.12(prettier@3.5.3))
'@vue/compiler-core': 3.5.13
storybook: 8.6.9(prettier@3.5.3)
storybook: 8.6.12(prettier@3.5.3)
ts-dedent: 2.2.0
type-fest: 2.19.0
vue: 3.5.13(typescript@5.8.3)
@@ -15662,7 +15678,7 @@ snapshots:
'@stylistic/eslint-plugin@4.2.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.3)':
dependencies:
'@typescript-eslint/utils': 8.28.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.3)
'@typescript-eslint/utils': 8.29.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.3)
eslint: 9.23.0(jiti@2.4.2)
eslint-visitor-keys: 4.2.0
espree: 10.3.0
@@ -17780,6 +17796,16 @@ snapshots:
readable-stream: 3.6.2
typedarray: 0.0.6
concurrently@9.1.2:
dependencies:
chalk: 4.1.2
lodash: 4.17.21
rxjs: 7.8.2
shell-quote: 1.8.2
supports-color: 8.1.1
tree-kill: 1.2.2
yargs: 17.7.2
confbox@0.1.8: {}
confbox@0.2.1: {}
@@ -18524,6 +18550,10 @@ snapshots:
optionalDependencies:
'@types/trusted-types': 2.0.7
dompurify@3.2.5:
optionalDependencies:
'@types/trusted-types': 2.0.7
domutils@2.8.0:
dependencies:
dom-serializer: 1.4.1
@@ -18982,7 +19012,7 @@ snapshots:
eslint-plugin-import-x@4.8.1(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.3):
dependencies:
'@types/doctrine': 0.0.9
'@typescript-eslint/utils': 8.28.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.3)
'@typescript-eslint/utils': 8.29.0(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.3)
debug: 4.4.0(supports-color@9.4.0)
doctrine: 3.0.0
eslint: 9.23.0(jiti@2.4.2)
@@ -20026,14 +20056,6 @@ snapshots:
graphql: 16.10.0
tslib: 2.8.1
graphql-type-json@0.3.2(graphql@16.10.0):
dependencies:
graphql: 16.10.0
graphql-type-uuid@0.2.0(graphql@16.10.0):
dependencies:
graphql: 16.10.0
graphql-ws@6.0.4(graphql@16.10.0)(ws@8.18.1):
dependencies:
graphql: 16.10.0
@@ -22577,6 +22599,13 @@ snapshots:
read-cache: 1.0.0
resolve: 1.22.10
postcss-import@16.1.0(postcss@8.5.3):
dependencies:
postcss: 8.5.3
postcss-value-parser: 4.2.0
read-cache: 1.0.0
resolve: 1.22.10
postcss-js@4.0.1(postcss@8.5.3):
dependencies:
camelcase-css: 2.0.1
@@ -23847,9 +23876,9 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
storybook@8.6.9(prettier@3.5.3):
storybook@8.6.12(prettier@3.5.3):
dependencies:
'@storybook/core': 8.6.9(prettier@3.5.3)(storybook@8.6.9(prettier@3.5.3))
'@storybook/core': 8.6.12(prettier@3.5.3)(storybook@8.6.12(prettier@3.5.3))
optionalDependencies:
prettier: 3.5.3
transitivePeerDependencies:
@@ -24311,6 +24340,8 @@ snapshots:
dependencies:
punycode: 2.3.1
tree-kill@1.2.2: {}
trim-newlines@3.0.1: {}
ts-api-utils@2.1.0(typescript@5.8.3):
@@ -25270,7 +25301,7 @@ snapshots:
isarray: 2.0.5
which-boxed-primitive: 1.1.1
which-collection: 1.0.2
which-typed-array: 1.1.18
which-typed-array: 1.1.19
which-collection@1.0.2:
dependencies:

View File

@@ -1,2 +1,3 @@
!.env.development
dist-wc/
dist-wc/
.storybook/static/*

View File

@@ -1,30 +1,26 @@
import { dirname, join } from "path";
import type { StorybookConfig } from "@storybook/vue3-vite";
import { dirname, join } from 'path';
import type { StorybookConfig } from '@storybook/vue3-vite';
const config: StorybookConfig = {
stories: ["../stories/**/*.stories.@(js|jsx|ts|tsx)"],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-interactions"
],
stories: ['../stories/**/*.stories.@(js|jsx|ts|tsx)'],
addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'],
framework: {
name: "@storybook/vue3-vite",
name: '@storybook/vue3-vite',
options: {
docgen: "vue-component-meta",
docgen: 'vue-component-meta',
},
},
core: {
builder: "@storybook/builder-vite",
builder: '@storybook/builder-vite',
},
docs: {
autodocs: "tag",
autodocs: 'tag',
},
staticDirs: ['./static'],
async viteFinal(config) {
config.root = dirname(require.resolve('@storybook/builder-vite'));
return {
...config,
root: dirname(require.resolve('@storybook/builder-vite')),
resolve: {
alias: {
'@': join(dirname(new URL(import.meta.url).pathname), '../src'),
@@ -32,6 +28,9 @@ const config: StorybookConfig = {
'@/lib': join(dirname(new URL(import.meta.url).pathname), '../src/lib'),
},
},
optimizeDeps: {
include: [...(config.optimizeDeps?.include ?? []), '@unraid/tailwind-rem-to-rem'],
},
css: {
postcss: {
plugins: [
@@ -46,4 +45,4 @@ const config: StorybookConfig = {
},
};
export default config;
export default config;

View File

@@ -1,8 +1,10 @@
import type { Preview } from '@storybook/vue3';
import '../src/styles/globals.css';
import { registerAllComponents } from '../src/register';
import '@/styles/index.css';
registerAllComponents({});
registerAllComponents({
pathToSharedCss: '/index.css',
});
const preview: Preview = {
parameters: {
@@ -18,9 +20,11 @@ const preview: Preview = {
decorators: [
(story) => ({
components: { story },
template: `
<div>
<div id="modals"></div>
<uui-modals></uui-modals>
<story />
</div>
`,
@@ -28,4 +32,4 @@ const preview: Preview = {
],
};
export default preview;
export default preview;

View File

@@ -1,9 +0,0 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"paths": {
"@/*": ["../src/*"]
}
},
"include": ["../stories/**/*", "../src/**/*"]
}

View File

@@ -6,89 +6,146 @@ import prettier from 'eslint-plugin-prettier';
import vuePlugin from 'eslint-plugin-vue';
import tseslint from 'typescript-eslint';
export default tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended, {
files: ['**/*.ts', '**/*.tsx', '**/*.vue'],
languageOptions: {
parser: require('vue-eslint-parser'),
parserOptions: {
// Common rules shared across file types
const commonRules = {
'@typescript-eslint/no-unused-vars': ['off'],
'no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 1 }],
'no-relative-import-paths/no-relative-import-paths': [
'error',
{ allowSameFolder: false, rootDir: 'src', prefix: '@' },
],
'prettier/prettier': 'error',
'no-restricted-globals': [
'error',
{
name: '__dirname',
message: 'Use import.meta.url instead of __dirname in ESM',
},
{
name: '__filename',
message: 'Use import.meta.url instead of __filename in ESM',
},
],
'eol-last': ['error', 'always'],
'@typescript-eslint/no-explicit-any': [
'error',
{
ignoreRestArgs: true,
fixToUnknown: false,
},
],
};
// Vue-specific rules
const vueRules = {
'vue/multi-word-component-names': 'off',
'vue/html-self-closing': [
'error',
{
html: {
void: 'always',
normal: 'always',
component: 'always',
},
},
],
'vue/component-name-in-template-casing': ['error', 'PascalCase'],
'vue/component-definition-name-casing': ['error', 'PascalCase'],
'vue/no-unsupported-features': [
'error',
{
version: '^3.3.0',
},
],
'vue/no-undef-components': ['error'],
'vue/no-unused-properties': [
'error',
{
groups: ['props'],
deepData: false,
},
],
};
// Common language options
const commonLanguageOptions = {
ecmaVersion: 'latest',
sourceType: 'module',
};
// Define globals separately
const commonGlobals = {
browser: true,
window: true,
document: true,
console: true,
Event: true,
HTMLElement: true,
HTMLInputElement: true,
CustomEvent: true,
es2022: true,
};
export default [
// Base config from recommended configs
eslint.configs.recommended,
...tseslint.configs.recommended,
// TypeScript Files (.ts)
{
files: ['**/*.ts'],
languageOptions: {
parser: tseslint.parser,
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
globals: {
browser: true,
window: true,
document: true,
es2022: true,
HTMLElement: true,
},
},
plugins: {
'no-relative-import-paths': noRelativeImportPaths,
prettier: prettier,
import: importPlugin,
vue: vuePlugin,
},
rules: {
'@typescript-eslint/no-unused-vars': ['off'],
'no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 1 }],
'no-relative-import-paths/no-relative-import-paths': [
'error',
{ allowSameFolder: false, rootDir: 'src', prefix: '@' },
],
'prettier/prettier': 'error',
'no-restricted-globals': [
'error',
{
name: '__dirname',
message: 'Use import.meta.url instead of __dirname in ESM',
},
{
name: '__filename',
message: 'Use import.meta.url instead of __filename in ESM',
},
],
'eol-last': ['error', 'always'],
// Vue specific rules
'vue/multi-word-component-names': 'off',
'vue/html-self-closing': [
'error',
{
html: {
void: 'always',
normal: 'always',
component: 'always',
parserOptions: {
...commonLanguageOptions,
ecmaFeatures: {
jsx: true,
},
},
],
'vue/component-name-in-template-casing': ['error', 'PascalCase'],
'vue/component-definition-name-casing': ['error', 'PascalCase'],
'vue/no-unsupported-features': [
'error',
{
version: '^3.3.0',
globals: {
...commonGlobals
},
],
'vue/no-undef-components': ['error'],
'vue/no-unused-properties': [
'error',
{
groups: ['props'],
deepData: false,
},
],
// Allow empty object types and any types in Vue component definitions
'@typescript-eslint/no-explicit-any': [
'error',
{
ignoreRestArgs: true,
fixToUnknown: false,
},
],
},
plugins: {
'no-relative-import-paths': noRelativeImportPaths,
prettier: prettier,
import: importPlugin,
},
rules: {
...commonRules,
},
},
ignores: ['src/graphql/generated/client/**/*'],
});
// Vue Files (.vue)
{
files: ['**/*.vue'],
languageOptions: {
parser: require('vue-eslint-parser'),
parserOptions: {
...commonLanguageOptions,
parser: tseslint.parser,
ecmaFeatures: {
jsx: true,
},
},
globals: {
...commonGlobals
},
},
plugins: {
'no-relative-import-paths': noRelativeImportPaths,
prettier: prettier,
import: importPlugin,
vue: vuePlugin,
},
rules: {
...commonRules,
...vueRules,
},
},
// Ignores
{
ignores: ['src/graphql/generated/client/**/*'],
},
];

View File

@@ -21,9 +21,9 @@
"test:ui": "vitest --ui",
"coverage": "vitest run --coverage",
"// Build": "",
"prebuild": "npm run clean",
"build": "vite build",
"build:watch": "vite build -c vite.web-component.ts --mode production --watch",
"build:watch": "concurrently \"pnpm build:wc --watch\" \"pnpm build --watch\"",
"build:watch:main": "vite build --watch",
"build:wc": "REM_PLUGIN=true vite build -c vite.web-component.ts --mode production",
"build:all": "vite build && vite build -c vite.web-component.ts --mode production",
"clean": "rimraf dist",
@@ -34,7 +34,10 @@
"preunraid:deploy": "pnpm build:wc",
"unraid:deploy": "just deploy",
"// Storybook": "",
"prestorybook": "pnpm storybook:css",
"storybook": "storybook dev -p 6006",
"storybook:css": "node scripts/build-style.mjs",
"prebuild-storybook": "pnpm storybook:css",
"build-storybook": "storybook build"
},
"peerDependencies": {
@@ -51,20 +54,23 @@
"@vueuse/core": "^13.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.2.5",
"kebab-case": "^2.0.1",
"lucide-vue-next": "^0.511.0",
"reka-ui": "^2.1.1",
"shadcn-vue": "^2.0.0",
"marked": "^15.0.0",
"reka-ui": "^2.1.1",
"tailwind-merge": "^2.6.0",
"vue-sonner": "^1.3.0"
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
"@storybook/addon-essentials": "^8.5.8",
"@storybook/addon-interactions": "^8.5.8",
"@storybook/addon-links": "^8.5.8",
"@storybook/builder-vite": "^8.5.8",
"@storybook/vue3-vite": "^8.5.8",
"@storybook/addon-essentials": "^8.6.12",
"@storybook/addon-interactions": "^8.6.12",
"@storybook/addon-links": "^8.6.12",
"@storybook/builder-vite": "^8.6.12",
"@storybook/vue3": "^8.6.12",
"@storybook/vue3-vite": "^8.6.12",
"@tailwindcss/typography": "^0.5.15",
"@testing-library/vue": "^8.0.0",
"@types/jsdom": "^21.1.7",
@@ -79,6 +85,7 @@
"@vue/test-utils": "^2.4.0",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.20",
"concurrently": "^9.1.2",
"eslint": "^9.17.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-import": "^2.31.0",
@@ -87,10 +94,11 @@
"eslint-plugin-vue": "^10.0.0",
"happy-dom": "^17.0.0",
"postcss": "^8.4.49",
"postcss-import": "^16.1.0",
"prettier": "3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"rimraf": "^6.0.1",
"storybook": "^8.5.8",
"storybook": "^8.6.12",
"tailwind-rem-to-rem": "github:unraid/tailwind-rem-to-rem",
"tailwindcss": "^3.0.0",
"tailwindcss-animate": "^1.0.7",

View File

@@ -0,0 +1,30 @@
import fs from 'fs/promises';
import autoprefixer from 'autoprefixer';
import postcss from 'postcss';
import postcssImport from 'postcss-import';
import tailwindcss from 'tailwindcss';
/**
* Helper script for storybook to build the CSS file for the components. This is used to ensure that modals render using the shadow styles.
*/
process.env.VITE_TAILWIND_BASE_FONT_SIZE = 16;
const inputPath = './src/styles/index.css';
const outputPath = './.storybook/static/index.css'; // served from root: /index.css
const css = await fs.readFile(inputPath, 'utf8');
const result = await postcss([
postcssImport(),
tailwindcss({ config: './tailwind.config.ts' }),
autoprefixer(),
]).process(css, {
from: inputPath,
to: outputPath,
});
await fs.mkdir('./.storybook/static', { recursive: true });
await fs.writeFile(outputPath, result.css);
console.log('✅ CSS built for Storybook:', outputPath);

View File

@@ -0,0 +1,21 @@
export * from '@/components/common/badge';
export * from '@/components/brand';
export * from '@/components/common/button';
export * from '@/components/layout';
export * from '@/components/common/dropdown-menu';
export * from '@/components/common/loading';
export * from '@/components/form/input';
export * from '@/components/form/label';
export * from '@/components/form/number';
export * from '@/components/form/lightswitch';
export * from '@/components/form/select';
export * from '@/components/form/switch';
export * from '@/components/common/scroll-area';
export * from '@/components/common/stepper';
export * from '@/components/common/sheet';
export * from '@/components/common/tabs';
export * from '@/components/common/tooltip';
export * from '@/components/common/toast';
export * from '@/components/common/popover';
export * from '@/components/modals';
export * from '@/components/common/accordion';

View File

@@ -1,5 +1,6 @@
export { default as DropdownMenu } from './DropdownMenu.vue';
import DropdownMenu from '@/components/common/dropdown-menu/DropdownMenu.vue';
export { DropdownMenu };
export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue';
export { default as DropdownMenuContent } from './DropdownMenuContent.vue';
export { default as DropdownMenuGroup } from './DropdownMenuGroup.vue';

View File

@@ -6,7 +6,7 @@ const props = defineProps<{ class?: HTMLAttributes['class'] }>();
</script>
<template>
<div :class="cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2', props.class)">
<div :class="cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2', props.class)">
<slot />
</div>
</template>

View File

@@ -33,7 +33,7 @@ const { teleportTarget } = useTeleport();
</script>
<template>
<TooltipPortal :to="teleportTarget as HTMLElement" defer>
<TooltipPortal :to="teleportTarget" defer>
<TooltipContent
v-bind="{ ...forwarded, ...$attrs }"
:class="

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import {
ComboboxRoot,
useForwardPropsEmits,
type ComboboxRootEmits,
type ComboboxRootProps,
} from 'reka-ui';
const props = defineProps<ComboboxRootProps>();
const emits = defineEmits<ComboboxRootEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<ComboboxRoot v-bind="forwarded">
<slot />
</ComboboxRoot>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import type { ComboboxAnchorProps } from 'reka-ui';
import { ComboboxAnchor, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
const props = defineProps<ComboboxAnchorProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<ComboboxAnchor v-bind="forwarded" :class="cn('w-[200px]', props.class)">
<slot />
</ComboboxAnchor>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import type { ComboboxEmptyProps } from 'reka-ui';
import { ComboboxEmpty } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
const props = defineProps<ComboboxEmptyProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<ComboboxEmpty v-bind="delegatedProps" :class="cn('py-6 text-center text-sm', props.class)">
<slot />
</ComboboxEmpty>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import type { ComboboxGroupProps } from 'reka-ui';
import { ComboboxGroup, ComboboxLabel } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
const props = defineProps<
ComboboxGroupProps & {
class?: HTMLAttributes['class'];
heading?: string;
}
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<ComboboxGroup
v-bind="delegatedProps"
:class="
cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
props.class
)
"
>
<ComboboxLabel v-if="heading" class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ heading }}
</ComboboxLabel>
<slot />
</ComboboxGroup>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import {
ComboboxInput,
useForwardPropsEmits,
type ComboboxInputEmits,
type ComboboxInputProps,
} from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
const props = defineProps<
ComboboxInputProps & {
class?: HTMLAttributes['class'];
}
>();
const emits = defineEmits<ComboboxInputEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<ComboboxInput
v-bind="forwarded"
:class="
cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
props.class
)
"
>
<slot />
</ComboboxInput>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import type { ComboboxItemEmits, ComboboxItemProps } from 'reka-ui';
import { ComboboxItem, useForwardPropsEmits } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
const props = defineProps<ComboboxItemProps & { class?: HTMLAttributes['class'] }>();
const emits = defineEmits<ComboboxItemEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<ComboboxItem
v-bind="forwarded"
:class="
cn(
'relative flex cursor-default gap-2 select-none justify-between items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
props.class
)
"
>
<slot />
</ComboboxItem>
</template>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import useTeleport from '@/composables/useTeleport';
import { cn } from '@/lib/utils';
import type { ComboboxContentEmits, ComboboxContentProps } from 'reka-ui';
import { ComboboxContent, ComboboxPortal, ComboboxViewport, useForwardPropsEmits } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
defineOptions({
inheritAttrs: false,
});
const { teleportTarget } = useTeleport();
const props = withDefaults(defineProps<ComboboxContentProps & { class?: HTMLAttributes['class'] }>(), {
forceMount: false,
position: 'popper',
to: undefined,
});
const emits = defineEmits<ComboboxContentEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<ComboboxPortal :to="teleportTarget" :force-mount="forceMount">
<ComboboxContent
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'z-50 w-[200px] rounded-md border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class
)
"
>
<ComboboxViewport
:class="
cn(
'p-1',
position === 'popper' &&
'h-[--reka-combobox-trigger-height] w-full min-w-[--reka-combobox-trigger-width]'
)
"
>
<slot />
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import type { ComboboxSeparatorProps } from 'reka-ui';
import { ComboboxSeparator } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
const props = defineProps<ComboboxSeparatorProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<ComboboxSeparator v-bind="delegatedProps" :class="cn('-mx-1 h-px bg-border', props.class)">
<slot />
</ComboboxSeparator>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import type { ComboboxTriggerProps } from 'reka-ui';
import { ComboboxTrigger, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
const props = defineProps<ComboboxTriggerProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<ComboboxTrigger v-bind="forwarded" :class="cn('', props.class)" tabindex="0">
<slot />
</ComboboxTrigger>
</template>

View File

@@ -0,0 +1,10 @@
export { default as Combobox } from './Combobox.vue';
export { default as ComboboxAnchor } from './ComboboxAnchor.vue';
export { default as ComboboxEmpty } from './ComboboxEmpty.vue';
export { default as ComboboxGroup } from './ComboboxGroup.vue';
export { default as ComboboxInput } from './ComboboxInput.vue';
export { default as ComboboxItem } from './ComboboxItem.vue';
export { default as ComboboxList } from './ComboboxList.vue';
export { default as ComboboxSeparator } from './ComboboxSeparator.vue';
export { ComboboxCancel, ComboboxItemIndicator, ComboboxTrigger } from 'reka-ui';

View File

@@ -1,52 +0,0 @@
export { Badge } from '@/components/common/badge';
export { BrandButton, BrandLoading, BrandLogo, BrandLogoConnect } from '@/components/brand';
export { Button } from '@/components/common/button';
export { CardWrapper, PageContainer } from '@/components/layout';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuRadioGroup,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuShortcut,
DropdownMenuSeparator,
DropdownMenuLabel,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from '@/components/common/dropdown-menu';
export { Bar, Error, Spinner } from '@/components/common/loading';
export { Input } from '@/components/form/input';
export { Label } from '@/components/form/label';
export { Lightswitch } from '@/components/form/lightswitch';
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectItemText,
SelectLabel,
SelectScrollUpButton,
SelectScrollDownButton,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/form/select';
export { Switch } from '@/components/form/switch';
export { ScrollArea, ScrollBar } from '@/components/common/scroll-area';
export {
Sheet,
SheetTrigger,
SheetContent,
SheetClose,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
} from '@/components/common/sheet';
export { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/common/tabs';
export { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/common/tooltip';
export { Toaster } from '@/components/common/toast';

View File

@@ -0,0 +1,4 @@
<script setup lang="ts"></script>
<template>
<div id="modals" />
</template>

View File

@@ -0,0 +1 @@
export { default as Modals } from './ModalTarget.vue';

View File

@@ -4,14 +4,14 @@ const useTeleport = () => {
const teleportTarget = ref<string | HTMLElement>('#modals');
const determineTeleportTarget = () => {
const myModalsComponent = document.querySelector('unraid-modals');
const myModalsComponent =
document.querySelector('unraid-modals') || document.querySelector('uui-modals');
if (!myModalsComponent?.shadowRoot) return;
const potentialTarget = myModalsComponent.shadowRoot.querySelector('#modals');
if (!potentialTarget) return;
teleportTarget.value = potentialTarget as HTMLElement;
console.log('[determineTeleportTarget] teleportTarget', teleportTarget.value);
};
onMounted(() => {

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/common/tooltip';
import {
Combobox,
ComboboxAnchor,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
} from '@/components/form/combobox';
import useTeleport from '@/composables/useTeleport';
import type { ControlElement } from '@jsonforms/core';
import { useJsonFormsControl } from '@jsonforms/vue';
import type { RendererProps } from '@jsonforms/vue';
import type { AcceptableValue } from 'reka-ui';
import { computed, ref, watch } from 'vue';
interface Suggestion {
value: string;
label?: string;
tooltip?: string;
}
const { teleportTarget } = useTeleport();
const props = defineProps<RendererProps<ControlElement>>();
const { control, handleChange } = useJsonFormsControl(props);
const inputValue = ref(control.value.data ?? '');
const isOpen = ref(false);
const suggestions = computed<Suggestion[]>(() => {
return control.value.uischema.options?.suggestions || [];
});
const handleInput = (event: Event) => {
inputValue.value = (event.target as HTMLInputElement).value;
};
const handleSelect = (event: CustomEvent<{ value?: AcceptableValue }>) => {
if (event.detail.value === undefined || event.detail.value === null) return;
const stringValue = String(event.detail.value);
inputValue.value = stringValue;
handleChange(control.value.path, stringValue);
isOpen.value = false;
};
const handleOpenChange = (open: boolean) => {
isOpen.value = open;
if (!open) {
if (control.value.uischema.options?.strictSuggestions && suggestions.value.length > 0) {
const isValid = suggestions.value.some(
(suggestion) => suggestion.value === inputValue.value || suggestion.label === inputValue.value
);
if (!isValid) {
inputValue.value = control.value.data || '';
return;
}
}
handleChange(control.value.path, inputValue.value);
}
};
watch(
() => control.value.data,
(newValue) => {
const currentVal = newValue ?? '';
if (currentVal !== inputValue.value) {
inputValue.value = currentVal;
}
}
);
if (control.value.data !== undefined && control.value.data !== null) {
inputValue.value = String(control.value.data);
}
</script>
<template>
<Combobox :open="isOpen" @update:open="handleOpenChange">
<ComboboxAnchor>
<ComboboxTrigger>
<ComboboxInput
:id="control.id"
:value="inputValue"
@input="handleInput"
:placeholder="control.uischema.options?.placeholder"
:disabled="!control.enabled"
/>
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxList>
<ComboboxEmpty class="p-2 text-sm text-muted-foreground"> No suggestions found </ComboboxEmpty>
<template v-for="suggestion in suggestions" :key="suggestion.value">
<TooltipProvider v-if="suggestion.tooltip">
<Tooltip :delay-duration="50">
<TooltipTrigger as-child>
<ComboboxItem
:value="suggestion.value"
@select="handleSelect"
class="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
>
<span>{{ suggestion.label || suggestion.value }}</span>
</ComboboxItem>
</TooltipTrigger>
<TooltipContent :to="teleportTarget" side="right" :side-offset="5">
<p class="max-w-xs">{{ suggestion.tooltip }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<ComboboxItem
v-else
:value="suggestion.value"
@select="handleSelect"
class="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
>
<span>{{ suggestion.label || suggestion.value }}</span>
</ComboboxItem>
</template>
</ComboboxList>
</Combobox>
</template>

View File

@@ -1,32 +0,0 @@
<script setup lang="ts">
import { Label } from '@/components/form/label';
import { computed } from 'vue';
const props = defineProps<{
label: string;
errors?: string | string[];
}>();
const normalizedErrors = computed(() => {
if (!props.errors) return [];
return Array.isArray(props.errors) ? props.errors : [props.errors];
});
// ensures the label ends with a colon
// todo: in RTL locales, this probably isn't desirable
const formattedLabel = computed(() => {
return props.label.endsWith(':') ? props.label : `${props.label}:`;
});
</script>
<template>
<div class="grid grid-cols-settings items-baseline">
<Label class="text-end">{{ formattedLabel }}</Label>
<div class="ml-10 max-w-3xl">
<slot />
<div v-if="normalizedErrors.length > 0" class="mt-2 text-red-500 text-sm">
<p v-for="error in normalizedErrors" :key="error">{{ error }}</p>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import FormErrors from '@/forms/FormErrors.vue';
import type { ControlElement } from '@jsonforms/core';
import { useJsonFormsControl } from '@jsonforms/vue';
import type { RendererProps } from '@jsonforms/vue';
// Define props consistent with JsonForms renderers
const props = defineProps<RendererProps<ControlElement>>();
// Use the standard composable to get control state
const { control } = useJsonFormsControl(props);
</script>
<template>
<!-- Only render the wrapper if the control is visible -->
<div v-if="control.visible" class="flex-grow">
<!-- Render the actual control passed via the default slot -->
<slot />
<!-- Automatically display errors below the control -->
<FormErrors :errors="control.errors" />
</div>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
errors?: string | string[];
}>();
const normalizedErrors = computed(() => {
if (!props.errors) return [];
return Array.isArray(props.errors) ? props.errors : [props.errors];
});
</script>
<template>
<div v-if="normalizedErrors.length > 0" class="mt-2 text-red-500 text-sm">
<p v-for="error in normalizedErrors" :key="error">{{ error }}</p>
</div>
</template>

View File

@@ -0,0 +1,46 @@
<script lang="ts" setup>
/**
* HorizontalLayout component
*
* Renders form elements in a horizontal layout with labels aligned to the right
* and fields to the left. Consumes JSON Schema uischema to determine what elements
* to render.
*
* @prop schema - The JSON Schema
* @prop uischema - The UI Schema containing the layout elements
* @prop path - The current path
* @prop enabled - Whether the form is enabled
* @prop renderers - Available renderers
* @prop cells - Available cells
*/
import { useJsonFormsVisibility } from '@/forms/composables/useJsonFormsVisibility';
import type { HorizontalLayout } from '@jsonforms/core';
import { DispatchRenderer, type RendererProps } from '@jsonforms/vue';
import { computed } from 'vue';
const props = defineProps<RendererProps<HorizontalLayout>>();
// Use the new composable
const { layout, isVisible } = useJsonFormsVisibility({ rendererProps: props });
const elements = computed(() => {
// Access elements from the layout object returned by the composable
return layout.layout.value.uischema.elements || [];
});
</script>
<template>
<div v-if="isVisible" class="flex flex-row gap-2 items-baseline">
<template v-for="(element, _i) in elements" :key="_i">
<DispatchRenderer
:schema="layout.layout.value.schema"
:uischema="element"
:path="layout.layout.value.path"
:enabled="layout.layout.value.enabled"
:renderers="layout.layout.value.renderers"
:cells="layout.layout.value.cells"
/>
</template>
</div>
</template>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { Input } from '@/components/form/input';
import { cn } from '@/lib/utils';
import type { ControlElement } from '@jsonforms/core';
import type { RendererProps } from '@jsonforms/vue';
import { useJsonFormsControl } from '@jsonforms/vue';
import { Eye, EyeOff } from 'lucide-vue-next';
import { computed, ref } from 'vue';
const props = defineProps<RendererProps<ControlElement>>();
const { control, handleChange } = useJsonFormsControl(props);
// Bind the input field's value to JSONForms data
const value = computed({
get: () => control.value.data ?? control.value.schema.default ?? '',
set: (newValue: string) => handleChange(control.value.path, newValue || undefined),
});
// Track password visibility
const showPassword = ref(false);
// Determine the input type based on schema format and visibility state
const inputType = computed(() => {
if (control.value.schema.format === 'password') {
return showPassword.value ? 'text' : 'password';
}
return 'text';
});
const isPassword = computed(() => control.value.schema.format === 'password');
const classOverride = computed(() => {
return cn(control.value.uischema?.options?.class, {
'max-w-[25ch]': control.value.uischema?.options?.format === 'short',
});
});
// Toggle password visibility
const togglePasswordVisibility = () => {
showPassword.value = !showPassword.value;
};
</script>
<template>
<div class="relative">
<Input
v-model="value"
:type="inputType"
:class="cn('flex-grow', classOverride, { 'pr-10': isPassword })"
:disabled="!control.enabled"
:required="control.required"
:placeholder="control.schema.description"
/>
<button
v-if="isPassword"
type="button"
@click="togglePasswordVisibility"
class="absolute inset-y-0 right-2 flex items-center text-gray-400 hover:text-gray-500"
tabindex="-1"
>
<Eye v-if="!showPassword" class="h-4 w-4" />
<EyeOff v-else class="h-4 w-4" />
</button>
</div>
</template>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import Label from '@/components/form/label/Label.vue';
import { Markdown } from '@/lib/utils';
import { type UISchemaElement } from '@jsonforms/core';
import { rendererProps, useJsonFormsRenderer } from '@jsonforms/vue';
import { computed, ref, watchEffect } from 'vue';
// Define a type for our specific Label UI Schema
interface LabelUISchema extends UISchemaElement {
text?: string;
options?: {
description?: string;
format?: 'title' | 'heading' | 'documentation' | string; // Add other formats as needed
};
}
const props = defineProps(rendererProps<UISchemaElement>());
// Destructure the renderer ref from the hook's return value
const { renderer } = useJsonFormsRenderer(props);
// Cast the uischema inside the computed ref to our specific type
const typedUISchema = computed(() => renderer.value.uischema as LabelUISchema);
// Access properties via renderer.value
const labelText = computed(() => typedUISchema.value.text);
const descriptionText = computed(() => typedUISchema.value.options?.description);
const labelFormat = computed(() => typedUISchema.value.options?.format);
// --- Parsed Description ---
const parsedDescription = ref<string | null>(null);
watchEffect(async () => {
// console.log('descriptionText', descriptionText.value); // Removed
const desc = descriptionText.value;
if (desc) {
try {
parsedDescription.value = await Markdown.parse(desc);
// console.log('parsedDescription after parse:', parsedDescription.value); // Removed
} catch (error) {
console.error('Error parsing markdown in LabelRenderer:', error);
// Fallback to plain text if parsing fails
parsedDescription.value = desc;
}
} else {
parsedDescription.value = null;
}
});
// Conditional classes or elements based on format
const labelClass = computed(() => {
switch (labelFormat.value) {
case 'title':
return 'text-xl font-semibold mb-2'; // Example styling for title
case 'heading':
return 'text-lg font-semibold mt-4 mb-1'; // Example styling for heading
default:
return 'font-semibold'; // Default label styling
}
});
const descriptionClass = computed(() => {
switch (labelFormat.value) {
case 'documentation':
return 'text-sm text-gray-500 italic p-2 border-l-4 border-gray-300 bg-gray-50 my-2 font-bold'; // Example styling for documentation
default:
return 'text-sm text-gray-600 mt-1'; // Default description styling
}
});
</script>
<template>
<!-- Use the computed isVisible based on renderer.value.visible -->
<div class="flex flex-col gap-2 max-w-lg flex-shrink-0">
<!-- Replace native label with the Label component -->
<Label v-if="labelText" :class="labelClass">{{ labelText }}</Label>
<!-- Use v-html with the parsedDescription ref -->
<p v-if="parsedDescription" :class="descriptionClass" v-html="parsedDescription" />
</div>
</template>

View File

@@ -0,0 +1,29 @@
<template>
<div>
<p>Missing renderer for:</p>
<pre>{{ props }}</pre>
</div>
</template>
<script setup lang="ts">
import { defineProps } from 'vue';
const props = defineProps<{
schema?: object;
uischema?: object;
path?: string;
data?: unknown;
errors?: string;
}>();
console.warn('Missing renderer used for:', props);
</script>
<style scoped>
/* Add styles if needed */
div {
border: 1px dashed red;
padding: 1em;
margin: 1em 0;
}
</style>

View File

@@ -5,7 +5,6 @@ import {
NumberFieldIncrement,
NumberFieldInput,
} from '@/components/form/number';
import ControlLayout from '@/forms/ControlLayout.vue';
import { cn } from '@/lib/utils';
import type { ControlElement } from '@jsonforms/core';
import type { RendererProps } from '@jsonforms/vue';
@@ -34,22 +33,20 @@ const classOverride = computed(() => {
</script>
<template>
<ControlLayout v-if="control.visible" :label="control.label" :errors="control.errors">
<NumberField
v-model="value"
:min="min"
:max="max"
:step="step"
:format-options="formatOptions"
:class="classOverride"
:disabled="!control.enabled"
:required="control.required"
blah="true"
:blah-2="true"
>
<NumberFieldDecrement v-if="stepperEnabled" />
<NumberFieldInput />
<NumberFieldIncrement v-if="stepperEnabled" />
</NumberField>
</ControlLayout>
<NumberField
v-model="value"
:min="min"
:max="max"
:step="step"
:format-options="formatOptions"
:class="classOverride"
:disabled="!control.enabled"
:required="control.required"
blah="true"
:blah-2="true"
>
<NumberFieldDecrement v-if="stepperEnabled" />
<NumberFieldInput />
<NumberFieldIncrement v-if="stepperEnabled" />
</NumberField>
</template>

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import ControlLayout from '@/forms/ControlLayout.vue';
import type { LabelElement } from '@jsonforms/core';
import type { RendererProps } from '@jsonforms/vue';
import { computed } from 'vue';
@@ -23,21 +22,18 @@ type PreconditionsLabelElement = LabelElement & {
// Each item should have a `text` and a `status` (boolean) property.
const props = defineProps<RendererProps<PreconditionsLabelElement>>();
const labelText = computed(() => props.uischema.text);
const items = computed(() => props.uischema.options?.items || []);
const description = computed(() => props.uischema.options?.description);
</script>
<template>
<ControlLayout :label="labelText">
<!-- Render each precondition as a list item with an icon bullet -->
<p v-if="description" class="mb-2">{{ description }}</p>
<ul class="list-none space-y-1">
<li v-for="(item, index) in items" :key="index" class="flex items-center">
<span v-if="item.status" class="text-green-500 mr-2 font-bold"></span>
<span v-else class="text-red-500 mr-2 font-extrabold"></span>
<span>{{ item.text }}</span>
</li>
</ul>
</ControlLayout>
<!-- Render each precondition as a list item with an icon bullet -->
<p v-if="description" class="mb-2">{{ description }}</p>
<ul class="list-none space-y-1">
<li v-for="(item, index) in items" :key="index" class="flex items-center">
<span v-if="item.status" class="text-green-500 mr-2 font-bold"></span>
<span v-else class="text-red-500 mr-2 font-extrabold"></span>
<span>{{ item.text }}</span>
</li>
</ul>
</template>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/common/tooltip';
import {
Select,
SelectContent,
@@ -8,7 +9,6 @@ import {
SelectValue,
} from '@/components/form/select';
import useTeleport from '@/composables/useTeleport';
import ControlLayout from '@/forms/ControlLayout.vue';
import type { ControlElement } from '@jsonforms/core';
import { useJsonFormsControl } from '@jsonforms/vue';
import type { RendererProps } from '@jsonforms/vue';
@@ -20,9 +20,12 @@ const { control, handleChange } = useJsonFormsControl(props);
const selected = computed(() => control.value.data);
const options = computed(() => {
const enumValues: string[] = control.value.schema.enum || [];
return enumValues.map((value) => ({
const tooltips: string[] | undefined = control.value.uischema.options?.tooltips;
return enumValues.map((value, index) => ({
value,
label: value,
tooltip: tooltips && tooltips[index] ? tooltips[index] : undefined,
}));
});
@@ -38,25 +41,38 @@ const onSelectOpen = () => {
</script>
<template>
<ControlLayout v-if="control.visible" :label="control.label" :errors="control.errors">
<Select
v-model="selected"
:disabled="!control.enabled"
:required="control.required"
@update:model-value="onChange"
@update:open="onSelectOpen"
>
<!-- The trigger shows the currently selected value (if any) -->
<SelectTrigger>
<SelectValue v-if="selected">{{ selected }}</SelectValue>
<span v-else>{{ control.schema.default ?? 'Select an option' }}</span>
</SelectTrigger>
<!-- The content includes the selectable options -->
<SelectContent :to="teleportTarget">
<SelectItem v-for="option in options" :key="option.value" :value="option.value">
<!-- The ControlWrapper now handles the v-if based on control.visible -->
<Select
v-model="selected"
:disabled="!control.enabled"
:required="control.required"
@update:model-value="onChange"
@update:open="onSelectOpen"
>
<!-- The trigger shows the currently selected value (if any) -->
<SelectTrigger>
<SelectValue v-if="selected">{{ selected }}</SelectValue>
<span v-else>{{ control.schema.default ?? 'Select an option' }}</span>
</SelectTrigger>
<!-- The content includes the selectable options -->
<SelectContent :to="teleportTarget">
<template v-for="option in options" :key="option.value">
<TooltipProvider v-if="option.tooltip" :delay-duration="50">
<Tooltip>
<TooltipTrigger as-child>
<SelectItem :value="option.value">
<SelectItemText>{{ option.label }}</SelectItemText>
</SelectItem>
</TooltipTrigger>
<TooltipContent :to="teleportTarget" side="right" :side-offset="5">
<p class="max-w-xs">{{ option.tooltip }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<SelectItem v-else :value="option.value">
<SelectItemText>{{ option.label }}</SelectItemText>
</SelectItem>
</SelectContent>
</Select>
</ControlLayout>
</template>
</SelectContent>
</Select>
</template>

View File

@@ -0,0 +1,184 @@
<script setup lang="ts">
import Button from '@/components/common/button/Button.vue';
import Stepper from '@/components/common/stepper/Stepper.vue';
import StepperDescription from '@/components/common/stepper/StepperDescription.vue';
import StepperItem from '@/components/common/stepper/StepperItem.vue';
import StepperSeparator from '@/components/common/stepper/StepperSeparator.vue';
import StepperTitle from '@/components/common/stepper/StepperTitle.vue';
import StepperTrigger from '@/components/common/stepper/StepperTrigger.vue';
import { CheckIcon } from '@heroicons/vue/24/solid'; // Example icon
import {
Actions,
type CoreActions,
type JsonFormsSubStates,
type JsonSchema,
type Layout,
type UISchemaElement,
} from '@jsonforms/core';
import { DispatchRenderer, useJsonFormsLayout, type RendererProps } from '@jsonforms/vue';
import { computed, inject, ref, type Ref } from 'vue';
// Define props based on RendererProps<Layout>
const props = defineProps<RendererProps<Layout>>();
// --- JSON Forms Context Injection ---
const jsonforms = inject<JsonFormsSubStates>('jsonforms');
const dispatch = inject<(action: CoreActions) => void>('dispatch'); // Inject dispatch separately
// --- START: Inject submission logic from parent ---
const submitForm = inject<() => Promise<void>>('submitForm', () => {
console.warn('SteppedLayout: submitForm function not provided');
return Promise.resolve(); // Provide a default no-op function
});
const isSubmitting = inject<Ref<boolean>>('isSubmitting', ref(false)); // Provide a default non-reactive ref
// --- END: Inject submission logic from parent ---
if (!jsonforms || !dispatch) {
throw new Error("'jsonforms' or 'dispatch' context wasn't provided. Are you within JsonForms?");
}
const { core } = jsonforms; // Extract core state
// --- Layout Specific Composables ---
const { layout } = useJsonFormsLayout(props);
// --- Step Configuration --- Use props.uischema
const stepsConfig = computed(() => props.uischema.options?.steps || []);
const numSteps = computed(() => stepsConfig.value.length);
// --- Current Step Logic --- Use injected core.data
const currentStep = computed(() => {
const stepData = core!.data?.configStep;
// Handle both the new object format and the old number format
if (typeof stepData === 'object' && stepData !== null && typeof stepData.current === 'number') {
// Ensure step is within bounds
return Math.max(0, Math.min(stepData.current, numSteps.value - 1));
}
// Fallback for initial state or old number format
const numericStep = typeof stepData === 'number' ? stepData : 0;
return Math.max(0, Math.min(numericStep, numSteps.value - 1));
});
const isLastStep = computed(() => numSteps.value > 0 && currentStep.value === numSteps.value - 1);
// --- Step Update Logic ---
const updateStep = (newStep: number) => {
// Validate step index bounds
if (newStep < 0 || newStep >= numSteps.value) {
return;
}
// Make total zero-indexed
const total = numSteps.value > 0 ? numSteps.value - 1 : 0;
// Update the 'configStep' property in the JSON Forms data with the new object structure
dispatch(Actions.update('configStep', () => ({ current: newStep, total })));
};
// --- Filtered Elements for Current Step ---
const currentStepElements = computed(() => {
const filtered = (props.uischema.elements || []).filter((element: UISchemaElement) => {
// Check if the element has an 'options' object and an 'step' property
return (
typeof element.options === 'object' &&
element.options !== null &&
element.options.step === currentStep.value
);
});
return filtered;
});
// --- Stepper State Logic ---
type StepState = 'inactive' | 'active' | 'completed';
const getStepState = (stepIndex: number): StepState => {
if (stepIndex < currentStep.value) return 'completed';
if (stepIndex === currentStep.value) return 'active';
return 'inactive';
};
</script>
<template>
<div v-if="layout.visible" class="stepped-layout space-y-6">
<!-- Stepper Indicators -->
<Stepper
v-if="numSteps > 0"
:modelValue="currentStep + 1"
class="text-foreground flex w-full items-start gap-2 text-sm"
>
<StepperItem
v-for="(step, index) in stepsConfig"
:key="index"
class="relative flex w-full flex-col items-center justify-center"
:step="index + 1"
:disabled="getStepState(index) === 'inactive'"
>
<StepperTrigger @click="updateStep(index)" class="cursor-pointer">
<!-- Use state calculated by getStepState for styling -->
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-full border-2',
getStepState(index) === 'completed'
? 'border-primary bg-primary text-primary-foreground'
: getStepState(index) === 'active'
? 'border-primary text-primary'
: 'border-muted text-muted-foreground',
]"
>
<CheckIcon v-if="getStepState(index) === 'completed'" class="size-4" />
<span v-else>{{ index + 1 }}</span>
</div>
</StepperTrigger>
<div class="mt-2 flex flex-col items-center text-center">
<StepperTitle
:class="[getStepState(index) === 'active' && 'text-primary']"
class="text-xs font-semibold transition"
>
{{ step.label }}
</StepperTitle>
<StepperDescription v-if="step.description" class="text-xs font-normal text-muted-foreground">
{{ step.description }}
</StepperDescription>
</div>
<StepperSeparator
v-if="index < stepsConfig.length - 1"
class="absolute left-1/2 top-4 -z-10 ml-[calc(var(--separator-offset,0rem)+10px)] h-0.5 w-[calc(100%-20px)] bg-border data-[state=active]:bg-primary data-[state=completed]:bg-primary"
/>
</StepperItem>
</Stepper>
<!-- Render elements for the current step -->
<!-- Added key to force re-render on step change, ensuring correct elements display -->
<div class="current-step-content rounded-md border p-4 shadow" :key="`step-content-${currentStep}`">
<DispatchRenderer
v-for="(element, index) in currentStepElements"
:key="`${layout.path}-${index}-step-${currentStep}`"
:schema="props.schema as JsonSchema"
:uischema="element as UISchemaElement"
:path="layout.path || ''"
:renderers="layout.renderers"
:cells="layout.cells"
:enabled="layout.enabled && !isSubmitting"
/>
</div>
<!-- Navigation Buttons -->
<div class="mt-4 flex justify-end space-x-2">
<Button
variant="outline"
@click="updateStep(currentStep - 1)"
:disabled="currentStep === 0 || isSubmitting"
>
Previous
</Button>
<!-- Show Next button only if not the last step -->
<Button v-if="!isLastStep" @click="updateStep(currentStep + 1)" :disabled="isSubmitting">
Next
</Button>
<!-- Show Submit button only on the last step -->
<Button v-if="isLastStep" @click="submitForm" :loading="isSubmitting" :disabled="isSubmitting">
Submit Configuration
</Button>
</div>
</div>
</template>
<style scoped>
/* Add any specific styling needed */
</style>

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { Button } from '@/components/common/button';
import { Input } from '@/components/form/input';
import ControlLayout from '@/forms/ControlLayout.vue';
import type { ControlElement } from '@jsonforms/core';
import { useJsonFormsControl } from '@jsonforms/vue';
import type { RendererProps } from '@jsonforms/vue';
@@ -41,36 +40,34 @@ const placeholder = computed(() => control.value.uischema?.options?.placeholder
</script>
<template>
<ControlLayout v-if="control.visible" :label="control.label" :errors="control.errors">
<div class="space-y-4">
<p v-if="control.description" v-html="control.description" />
<div v-for="(item, index) in items" :key="index" class="flex gap-2">
<Input
:type="inputType"
:model-value="item"
:placeholder="placeholder"
:disabled="!control.enabled"
class="flex-1"
@update:model-value="(value) => updateItem(index, String(value))"
/>
<Button
variant="ghost"
class="rounded underline underline-offset-4"
:disabled="!control.enabled"
@click="() => removeItem(index)"
>
Remove
</Button>
</div>
<Button
variant="outline"
size="md"
class="text-sm rounded-sm"
<div class="space-y-4">
<p v-if="control.description" v-html="control.description" />
<div v-for="(item, index) in items" :key="index" class="flex gap-2">
<Input
:type="inputType"
:model-value="item"
:placeholder="placeholder"
:disabled="!control.enabled"
@click="addItem"
class="flex-1"
@update:model-value="(value) => updateItem(index, String(value))"
/>
<Button
variant="ghost"
class="rounded underline underline-offset-4"
:disabled="!control.enabled"
@click="() => removeItem(index)"
>
Add Item
Remove
</Button>
</div>
</ControlLayout>
<Button
variant="outline"
size="md"
class="text-sm rounded-sm"
:disabled="!control.enabled"
@click="addItem"
>
Add Item
</Button>
</div>
</template>

View File

@@ -1,29 +1,23 @@
<script setup lang="ts">
import { Switch as UuiSwitch } from '@/components/form/switch';
import ControlLayout from '@/forms/ControlLayout.vue';
import type { ControlElement } from '@jsonforms/core';
import { useJsonFormsControl } from '@jsonforms/vue';
import type { RendererProps } from '@jsonforms/vue';
import { computed } from 'vue';
const props = defineProps<RendererProps<ControlElement>>();
const { control, handleChange } = useJsonFormsControl(props);
const onChange = (checked: boolean) => {
handleChange(control.value.path, checked);
};
const description = computed(() => props.uischema.options?.description);
</script>
<template>
<ControlLayout v-if="control.visible" :label="control.label" :errors="control.errors">
<p v-if="description" v-html="description" class="mb-2" />
<UuiSwitch
:id="control.id + '-input'"
:name="control.path"
:disabled="!control.enabled"
:required="control.required"
:modelValue="Boolean(control.data)"
@update:modelValue="onChange"
/>
</ControlLayout>
<UuiSwitch
:id="control.id + '-input'"
:name="control.path"
:disabled="!control.enabled"
:required="control.required"
:modelValue="Boolean(control.data)"
@update:modelValue="onChange"
/>
</template>

View File

@@ -0,0 +1,47 @@
<script lang="ts" setup>
/**
* UnraidSettingsLayout component
*
* Renders form elements defined in a UI schema within a two-column grid layout.
* Typically used for settings pages where each row has a label on the left
* and the corresponding form control on the right.
* Consumes JSON Schema and UI Schema to determine what elements to render.
*
* @prop schema - The JSON Schema
* @prop uischema - The UI Schema containing the layout elements
* @prop path - The current path
* @prop enabled - Whether the form is enabled
* @prop renderers - Available renderers
* @prop cells - Available cells
*/
import { useJsonFormsVisibility } from '@/forms/composables/useJsonFormsVisibility';
import type { HorizontalLayout } from '@jsonforms/core';
import { DispatchRenderer, type RendererProps } from '@jsonforms/vue';
import { computed } from 'vue';
const props = defineProps<RendererProps<HorizontalLayout>>();
// Use the new composable
const { layout, isVisible } = useJsonFormsVisibility({ rendererProps: props });
const elements = computed(() => {
// Access elements from the layout object returned by the composable
return layout.layout.value.uischema.elements || [];
});
</script>
<template>
<div v-if="isVisible" class="grid grid-cols-settings items-baseline">
<template v-for="(element, _i) in elements" :key="_i">
<DispatchRenderer
:schema="layout.layout.value.schema"
:uischema="element"
:path="layout.layout.value.path"
:enabled="layout.layout.value.enabled"
:renderers="layout.layout.value.renderers"
:cells="layout.layout.value.cells"
/>
</template>
</div>
</template>

View File

@@ -13,30 +13,41 @@
* @prop renderers - Available renderers
* @prop cells - Available cells
*/
import { Label } from '@/components/form/label';
import { useJsonFormsVisibility } from '@/forms/composables/useJsonFormsVisibility';
import type { VerticalLayout } from '@jsonforms/core';
import { DispatchRenderer, type RendererProps } from '@jsonforms/vue';
import { computed } from 'vue';
const props = defineProps<RendererProps<VerticalLayout>>();
const { layout, isVisible } = useJsonFormsVisibility({ rendererProps: props });
const showDividers = computed(() => {
return !!layout.layout.value.uischema.options?.showDividers;
});
const elements = computed(() => {
return props.uischema?.elements || [];
return layout.layout.value.uischema.elements || [];
});
</script>
<template>
<div class="grid grid-cols-settings items-baseline gap-y-6">
<template v-for="(element, index) in elements" :key="index">
<Label v-if="element.label" class="text-end">{{ element.label ?? index }}</Label>
<div
v-if="isVisible"
class="flex flex-col items-stretch gap-4"
:class="{
'divide-y divide-gray-200 dark:divide-gray-700': showDividers,
}"
>
<template v-for="(element, _i) in elements" :key="_i">
<DispatchRenderer
class="ml-10"
:schema="props.schema"
:schema="layout.layout.value.schema"
:uischema="element"
:path="props.path"
:enabled="props.enabled"
:renderers="props.renderers"
:cells="props.cells"
:path="layout.layout.value.path"
:enabled="layout.layout.value.enabled"
:renderers="layout.layout.value.renderers"
:cells="layout.layout.value.cells"
/>
</template>
</div>

View File

@@ -0,0 +1,29 @@
import type { Layout } from '@jsonforms/core';
import { useJsonFormsLayout, type RendererProps } from '@jsonforms/vue';
import { computed, type ComputedRef } from 'vue';
interface UseJsonFormsVisibilityProps<T extends Layout> {
rendererProps: RendererProps<T>;
}
interface UseJsonFormsVisibilityReturn {
layout: ReturnType<typeof useJsonFormsLayout>;
isVisible: ComputedRef<boolean>;
}
export function useJsonFormsVisibility<T extends Layout>(
props: UseJsonFormsVisibilityProps<T>
): UseJsonFormsVisibilityReturn {
const layout = useJsonFormsLayout(props.rendererProps);
const isVisible = computed(() => {
// The composable handles rule evaluation and provides the visibility status
// console.log('[useJsonFormsVisibility] isVisible computed. layout.layout.value.visible:', layout.layout.value.visible);
return !!layout.layout.value.visible;
});
return {
layout,
isVisible,
};
}

View File

@@ -1,50 +0,0 @@
import numberFieldRenderer from '@/forms/NumberField.vue';
import PreconditionsLabel from '@/forms/PreconditionsLabel.vue';
import selectRenderer from '@/forms/Select.vue';
import StringArrayField from '@/forms/StringArrayField.vue';
import switchRenderer from '@/forms/Switch.vue';
import {
and,
isBooleanControl,
isControl,
isEnumControl,
isIntegerControl,
isNumberControl,
optionIs,
or,
rankWith,
schemaMatches,
uiTypeIs,
} from '@jsonforms/core';
import type { JsonFormsRendererRegistryEntry, JsonSchema } from '@jsonforms/core';
const isStringArray = (schema: JsonSchema): boolean => {
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return false;
const items = schema.items as JsonSchema;
return schema.type === 'array' && items?.type === 'string';
};
export const formSwitchEntry: JsonFormsRendererRegistryEntry = {
renderer: switchRenderer,
tester: rankWith(4, and(isBooleanControl, optionIs('toggle', true))),
};
export const formSelectEntry: JsonFormsRendererRegistryEntry = {
renderer: selectRenderer,
tester: rankWith(4, and(isEnumControl)),
};
export const numberFieldEntry: JsonFormsRendererRegistryEntry = {
renderer: numberFieldRenderer,
tester: rankWith(4, or(isNumberControl, isIntegerControl)),
};
export const stringArrayEntry: JsonFormsRendererRegistryEntry = {
renderer: StringArrayField,
tester: rankWith(4, and(isControl, schemaMatches(isStringArray))),
};
export const preconditionsLabelEntry: JsonFormsRendererRegistryEntry = {
renderer: PreconditionsLabel,
tester: rankWith(3, and(uiTypeIs('Label'), optionIs('format', 'preconditions'))),
};

View File

@@ -1,24 +1,111 @@
import comboBoxRenderer from '@/forms/ComboBoxField.vue';
import ControlWrapper from '@/forms/ControlWrapper.vue';
import HorizontalLayout from '@/forms/HorizontalLayout.vue';
import inputFieldRenderer from '@/forms/InputField.vue';
import LabelRenderer from '@/forms/LabelRenderer.vue';
import MissingRenderer from '@/forms/MissingRenderer.vue';
import numberFieldRenderer from '@/forms/NumberField.vue';
import PreconditionsLabel from '@/forms/PreconditionsLabel.vue';
import selectRenderer from '@/forms/Select.vue';
import SteppedLayout from '@/forms/SteppedLayout.vue';
import StringArrayField from '@/forms/StringArrayField.vue';
import switchRenderer from '@/forms/Switch.vue';
import UnraidSettingsLayout from '@/forms/UnraidSettingsLayout.vue';
import VerticalLayout from '@/forms/VerticalLayout.vue';
import {
formSelectEntry,
formSwitchEntry,
numberFieldEntry,
preconditionsLabelEntry,
stringArrayEntry,
} from '@/forms/renderer-entries';
import { vanillaRenderers } from '@jsonforms/vue-vanilla';
and,
isBooleanControl,
isControl,
isEnumControl,
isIntegerControl,
isLayout,
isNumberControl,
isStringControl,
optionIs,
or,
rankWith,
schemaMatches,
uiTypeIs,
} from '@jsonforms/core';
import type { ControlElement, JsonFormsRendererRegistryEntry, JsonSchema } from '@jsonforms/core';
import type { RendererProps } from '@jsonforms/vue';
import { h, markRaw, type Component } from 'vue';
/**
* JSONForms renderers for Unraid UI
*
* This file exports a list of JSONForms renderers that are used in the Unraid UI.
* It combines the vanilla renderers with the custom renderers defined in
* `@unraid/ui/src/forms/renderer-entries.ts`.
*/
export const jsonFormsRenderers = [
...vanillaRenderers,
formSwitchEntry,
formSelectEntry,
numberFieldEntry,
preconditionsLabelEntry,
stringArrayEntry,
// Helper function to wrap control renderers with error display
// Returns a functional component
const withErrorWrapper = (RendererComponent: Component) => {
return (props: RendererProps<ControlElement>) => {
return h(ControlWrapper, props, {
default: () => h(RendererComponent, props),
});
};
};
const isStringArray = (schema: JsonSchema): boolean => {
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return false;
const items = schema.items as JsonSchema;
return schema.type === 'array' && items?.type === 'string';
};
export const jsonFormsRenderers: JsonFormsRendererRegistryEntry[] = [
// Layouts
{
renderer: markRaw(VerticalLayout),
tester: rankWith(2, and(isLayout, uiTypeIs('VerticalLayout'))),
},
{
renderer: markRaw(HorizontalLayout),
tester: rankWith(3, and(isLayout, uiTypeIs('HorizontalLayout'))),
},
{
renderer: markRaw(SteppedLayout),
tester: rankWith(3, and(isLayout, uiTypeIs('SteppedLayout'))),
},
{
renderer: markRaw(UnraidSettingsLayout),
tester: rankWith(3, and(isLayout, uiTypeIs('UnraidSettingsLayout'))),
},
// Controls
{
renderer: markRaw(withErrorWrapper(switchRenderer)),
tester: rankWith(4, and(isBooleanControl, optionIs('toggle', true))),
},
{
renderer: markRaw(withErrorWrapper(switchRenderer)),
tester: rankWith(4, and(isBooleanControl, optionIs('format', 'toggle'))),
},
{
renderer: markRaw(withErrorWrapper(selectRenderer)),
tester: rankWith(4, and(isEnumControl)),
},
{
renderer: markRaw(withErrorWrapper(comboBoxRenderer)),
tester: rankWith(4, and(isControl, optionIs('format', 'combobox'))),
},
{
renderer: markRaw(withErrorWrapper(numberFieldRenderer)),
tester: rankWith(4, or(isNumberControl, isIntegerControl)),
},
{
renderer: markRaw(withErrorWrapper(inputFieldRenderer)),
tester: rankWith(3, isStringControl),
},
{
renderer: markRaw(withErrorWrapper(StringArrayField)),
tester: rankWith(4, and(isControl, schemaMatches(isStringArray))),
},
// Labels
{
renderer: markRaw(PreconditionsLabel),
tester: rankWith(3, and(uiTypeIs('Label'), optionIs('format', 'preconditions'))),
},
{
renderer: markRaw(LabelRenderer),
tester: rankWith(3, and(uiTypeIs('Label'))),
},
// Fallback / Meta
{
renderer: markRaw(withErrorWrapper(MissingRenderer)),
tester: rankWith(0, isControl),
},
];

View File

@@ -1,8 +0,0 @@
import VerticalLayout from '@/forms/VerticalLayout.vue';
import { and, isLayout, rankWith, uiTypeIs } from '@jsonforms/core';
import type { JsonFormsRendererRegistryEntry } from '@jsonforms/core';
export const verticalLayoutEntry: JsonFormsRendererRegistryEntry = {
renderer: VerticalLayout,
tester: rankWith(2, and(isLayout, uiTypeIs('VerticalLayout'))),
};

View File

@@ -1,177 +1,17 @@
// Styles
import '@/styles/index.css';
import {
BrandButton,
brandButtonVariants,
BrandLoading,
brandLoadingVariants,
BrandLogo,
BrandLogoConnect,
type BrandButtonProps,
} from '@/components/brand';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/common/accordion';
// Components
import { Badge, type BadgeProps } from '@/components/common/badge';
import { Button, buttonVariants, type ButtonProps } from '@/components/common/button';
import {
DropdownMenu,
DropdownMenuArrow,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/common/dropdown-menu';
import { Bar, Error, Spinner } from '@/components/common/loading';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/common/popover';
import { ScrollArea, ScrollBar } from '@/components/common/scroll-area';
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/common/sheet';
import {
Stepper,
StepperDescription,
StepperItem,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from '@/components/common/stepper';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/common/tabs';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/common/tooltip';
import { Input } from '@/components/form/input';
import { Label } from '@/components/form/label';
import { Lightswitch } from '@/components/form/lightswitch';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectItemText,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/form/select';
import { Switch } from '@/components/form/switch';
import { CardWrapper, PageContainer } from '@/components/layout';
// Composables
import useTeleport from '@/composables/useTeleport';
// Lib
import { cn } from '@/lib/utils';
// Config
import tailwindConfig from '../tailwind.config';
export * from '@/components';
// Export
export {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Bar,
Badge,
BrandButton,
brandButtonVariants,
BrandLoading,
brandLoadingVariants,
BrandLogo,
BrandLogoConnect,
Button,
buttonVariants,
CardWrapper,
cn,
DropdownMenu,
DropdownMenuArrow,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuItem,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
DropdownMenuPortal,
Error,
Input,
Label,
PageContainer,
Popover,
PopoverContent,
PopoverTrigger,
ScrollBar,
ScrollArea,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectItemText,
SelectLabel,
SelectScrollUpButton,
SelectScrollDownButton,
SelectSeparator,
SelectTrigger,
SelectValue,
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
Spinner,
Stepper,
StepperDescription,
StepperItem,
StepperSeparator,
StepperTitle,
StepperTrigger,
Switch,
tailwindConfig,
Lightswitch,
Tabs,
TabsList,
TabsTrigger,
TabsContent,
Tooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider,
useTeleport,
// Type exports
type BrandButtonProps,
type BadgeProps,
type ButtonProps,
};
export { Toaster } from '@/components/common/toast';
export * from '@/components/common/popover';
export * from '@/components/form/number';
// JsonForms
export * from '@/forms/renderers';
// Lib
export * from '@/lib/utils';
// Config
export { default as tailwindConfig } from '../tailwind.config';
// Composables
export { default as useTeleport } from '@/composables/useTeleport';

View File

@@ -1,6 +1,54 @@
import { clsx, type ClassValue } from 'clsx';
import DOMPurify from 'dompurify';
import { Marked, type MarkedExtension } from 'marked';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
const defaultMarkedExtension: MarkedExtension = {
hooks: {
// must define as a function (instead of a lambda) to preserve/reflect bindings downstream
postprocess(html) {
return DOMPurify.sanitize(html, {
FORBID_TAGS: ['style'],
FORBID_ATTR: ['style'],
});
},
},
};
/**
* Helper class to build or conveniently use a markdown parser.
*
* - Use `Markdown.create` to extend or customize parsing functionality.
* - Use `Markdown.parse` to conveniently parse markdown to safe html.
*/
export class Markdown {
private static instance = Markdown.create();
/**
* Creates a `Marked` instance with default MarkedExtension's already added.
*
* Default behaviors:
* - Sanitizes html after parsing
*
* @param args any number of Marked Extensions
* @returns Marked parser instance
*/
static create(...args: Parameters<Marked['use']>) {
return new Marked(defaultMarkedExtension, ...args);
}
/**
* Parses arbitrary markdown content as sanitized html. May throw if parsing fails.
*
* @param markdownContent string of markdown content
* @returns safe, sanitized html content
*/
static async parse(markdownContent: string): Promise<string> {
return Markdown.instance.parse(markdownContent);
}
}

View File

@@ -10,25 +10,58 @@ type RegisterParams = {
pathToSharedCss?: string;
};
type CustomElementComponent = {
styles?: string[];
render?: () => unknown;
setup?: () => unknown;
[key: string]: unknown;
};
export function registerAllComponents(params: RegisterParams = {}) {
const { namePrefix = 'uui', pathToSharedCss = './src/styles/index.css' } = params;
Object.entries(Components).forEach(([name, component]) => {
// add our shared css to each web component
component.styles ??= [];
component.styles.unshift(`@import "${pathToSharedCss}"`);
// translate ui component names from PascalCase to kebab-case
let elementName = kebabCase(name);
if (!elementName) {
console.log('[register components] Could not translate component name to kebab-case:', name);
elementName = name;
}
elementName = namePrefix + elementName;
Object.entries(Components).forEach(([name, originalComponent]) => {
try {
if (typeof originalComponent !== 'object' || originalComponent === null) {
if (debugImports) {
console.log(`[register components] Skipping non-object: ${name}`);
}
return;
}
// register custom web components
if (debugImports) {
console.log(name, elementName, component.styles);
if (typeof originalComponent === 'function') {
if (debugImports) {
console.log(`[register components] Skipping function: ${name}`);
}
return;
}
if (!('render' in originalComponent || 'setup' in originalComponent)) {
if (debugImports) {
console.log(`[register components] Skipping non-component object: ${name}`);
}
return;
}
const component = originalComponent as CustomElementComponent;
component.styles ??= [];
component.styles.unshift(`@import "${pathToSharedCss}"`);
let elementName = kebabCase(name);
if (!elementName) {
console.log('[register components] Could not translate component name to kebab-case:', name);
elementName = name;
}
elementName = namePrefix + elementName;
if (debugImports) {
console.log(name, elementName, component.styles);
}
customElements.define(elementName, defineCustomElement(component as object));
} catch (error) {
console.error(`[register components] Error registering component ${name}:`, error);
}
customElements.define(elementName, defineCustomElement(component));
});
}

6
unraid-ui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.css?raw' {
const content: string;
export default content;
}

View File

@@ -1,13 +1,15 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import { MoreVertical } from 'lucide-vue-next';
import Button from '../../../src/components/common/button/Button.vue';
import DropdownMenu from '../../../src/components/common/dropdown-menu/DropdownMenu.vue';
import DropdownMenuArrow from '../../../src/components/common/dropdown-menu/DropdownMenuArrow.vue';
import DropdownMenuContent from '../../../src/components/common/dropdown-menu/DropdownMenuContent.vue';
import DropdownMenuItem from '../../../src/components/common/dropdown-menu/DropdownMenuItem.vue';
import DropdownMenuLabel from '../../../src/components/common/dropdown-menu/DropdownMenuLabel.vue';
import DropdownMenuSeparator from '../../../src/components/common/dropdown-menu/DropdownMenuSeparator.vue';
import DropdownMenuTrigger from '../../../src/components/common/dropdown-menu/DropdownMenuTrigger.vue';
import {
DropdownMenu,
DropdownMenuArrow,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/common/dropdown-menu';
import { Button } from '@/components/common/button';
const meta = {
title: 'Components/Common/DropdownMenu',

View File

@@ -0,0 +1,93 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import {
Combobox as ComboboxComponent,
ComboboxAnchor,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
} from '../../../src/components/form/combobox';
const meta = {
title: 'Components/Form/Combobox',
component: ComboboxComponent,
} satisfies Meta<typeof ComboboxComponent>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Combobox: Story = {
render: (args) => ({
components: {
ComboboxComponent,
ComboboxAnchor,
ComboboxTrigger,
ComboboxInput,
ComboboxList,
ComboboxGroup,
ComboboxItem,
},
setup() {
return { args };
},
template: `
<ComboboxComponent>
<ComboboxAnchor class="w-[180px]">
<ComboboxTrigger>
<ComboboxInput placeholder="Select a fruit" />
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxList>
<ComboboxGroup>
<ComboboxItem value="apple">Apple</ComboboxItem>
<ComboboxItem value="banana">Banana</ComboboxItem>
<ComboboxItem value="orange">Orange</ComboboxItem>
<ComboboxItem value="grape">Grape</ComboboxItem>
</ComboboxGroup>
</ComboboxList>
</ComboboxComponent>
`,
}),
};
export const Grouped: Story = {
render: (args) => ({
components: {
ComboboxComponent,
ComboboxTrigger,
ComboboxInput,
ComboboxList,
ComboboxGroup,
ComboboxItem,
ComboboxEmpty,
},
setup() {
return { args };
},
template: `
<div>
<ComboboxComponent>
<ComboboxTrigger class="w-[180px]">
<ComboboxInput placeholder="Select a food" />
</ComboboxTrigger>
<ComboboxList>
<ComboboxEmpty>No results found</ComboboxEmpty>
<ComboboxGroup>
<ComboboxItem value="apple">Apple</ComboboxItem>
<ComboboxItem value="banana">Banana</ComboboxItem>
<ComboboxItem value="grape">Grape</ComboboxItem>
</ComboboxGroup>
<ComboboxGroup>
<ComboboxItem value="carrot">Carrot</ComboboxItem>
<ComboboxItem value="potato">Potato</ComboboxItem>
<ComboboxItem value="celery">Celery</ComboboxItem>
</ComboboxGroup>
</ComboboxList>
</ComboboxComponent>
</div>
`,
}),
};

View File

@@ -7,7 +7,7 @@ import {
SelectLabel,
SelectTrigger,
SelectValue,
} from '../../../src/components/form/select';
} from '@/components/form/select';
const meta = {
title: 'Components/Form/Select',
@@ -67,7 +67,6 @@ export const Grouped: Story = {
},
template: `
<div>
<unraid-modals></unraid-modals>
<SelectComponent>
<SelectTrigger class="w-[180px]">
<SelectValue placeholder="Select a food" />

View File

@@ -31,7 +31,7 @@
"node",
"happy-dom",
"@vue/test-utils",
"@testing-library/vue"
"@testing-library/vue",
],
"forceConsistentCasingInFileNames": true
},
@@ -44,7 +44,10 @@
"tailwind.config.ts",
"src/theme/**/*.ts",
"**/*.config.ts",
"eslint.config.ts"
"eslint.config.ts",
"stories/**/*.ts",
"stories/**/*.tsx",
"stories/**/*.vue"
],
"exclude": [
"node_modules",

View File

@@ -0,0 +1,245 @@
<script lang="ts" setup>
import { computed, provide, ref, watch } from 'vue';
import { useMutation, useQuery } from '@vue/apollo-composable';
import { Button, jsonFormsRenderers } from '@unraid/ui';
import { JsonForms } from '@jsonforms/vue';
import { CREATE_REMOTE } from '~/components/RClone/graphql/rclone.mutations';
import { GET_RCLONE_CONFIG_FORM } from '~/components/RClone/graphql/rclone.query';
import { useUnraidApiStore } from '~/store/unraidApi';
const { offlineError: _offlineError, unraidApiStatus: _unraidApiStatus } = useUnraidApiStore();
// Define props
const props = defineProps({
initialState: {
type: Object,
default: null
}
});
// Define emit events
const emit = defineEmits(['complete']);
// Define types for form state
interface ConfigStep {
current: number;
total: number;
}
// Form state
const formState = ref(props.initialState || {
configStep: 0 as number | ConfigStep,
showAdvanced: false,
name: '',
type: '',
parameters: {},
});
// Use static variables to prevent unnecessary refetches
const {
result: formResult,
loading: formLoading,
refetch: updateFormSchema,
} = useQuery(GET_RCLONE_CONFIG_FORM, {
formOptions: {
providerType: formState.value.type || '',
parameters: formState.value.parameters || {},
showAdvanced: formState.value.showAdvanced || false,
},
});
// Consolidate both watchers into a single watcher with throttling
let refetchTimeout: NodeJS.Timeout | null = null;
watch(
formState,
async (newValue, oldValue) => {
// Get current step as number for comparison
const newStep = typeof newValue.configStep === 'object'
? (newValue.configStep as ConfigStep).current
: newValue.configStep as number;
const oldStep = typeof oldValue.configStep === 'object'
? (oldValue.configStep as ConfigStep).current
: oldValue.configStep as number;
// Check if we need to refetch
const shouldRefetch =
newValue.type !== oldValue.type ||
newStep !== oldStep ||
newValue.showAdvanced !== oldValue.showAdvanced;
if (shouldRefetch) {
// Log only meaningful changes
if (newValue.type !== oldValue.type) {
console.log('[RCloneConfig] providerType changed:', newValue.type);
}
if (newStep !== oldStep || newValue.showAdvanced !== oldValue.showAdvanced) {
console.log('[RCloneConfig] Refetching form schema');
}
// Debounce refetch to prevent multiple rapid calls
if (refetchTimeout) {
clearTimeout(refetchTimeout);
}
refetchTimeout = setTimeout(async () => {
await updateFormSchema({
formOptions: {
providerType: newValue.type,
parameters: newValue.parameters,
showAdvanced: newValue.showAdvanced,
},
});
refetchTimeout = null;
}, 100);
}
},
{ deep: true }
);
/**
* 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 ({ data }) => {
// Show success message
if (window.toast) {
window.toast.success('Remote Configuration Created', {
description: `Successfully created remote "${formState.value.name}"`,
});
}
console.log('[RCloneConfig] onCreateDone', data);
// Reset form and emit complete event
formState.value = {
configStep: 0,
showAdvanced: false,
name: '',
type: '',
parameters: {},
};
emit('complete');
});
// Set up JSONForms config
const jsonFormsConfig = {
restrict: false,
trim: false,
};
const renderers = [...jsonFormsRenderers];
// Handle form data changes with debouncing to reduce excessive logging
let changeTimeout: NodeJS.Timeout | null = null;
const onChange = ({ data }: { data: Record<string, unknown> }) => {
// Clear any pending timeout
if (changeTimeout) {
clearTimeout(changeTimeout);
}
// Log changes but debounce to reduce console spam
changeTimeout = setTimeout(() => {
console.log('[RCloneConfig] onChange received data:', JSON.stringify(data));
changeTimeout = null;
}, 300);
// Update formState
formState.value = data as typeof formState.value;
};
// --- Submit Button Logic ---
const uiSchema = computed(() => formResult.value?.rclone?.configForm?.uiSchema);
// Handle both number and object formats of configStep
const getCurrentStep = computed(() => {
const step = formState.value.configStep;
return typeof step === 'object' ? (step as ConfigStep).current : step as number;
});
// Get total steps from UI schema
const numSteps = computed(() => {
if (uiSchema.value?.type === 'SteppedLayout') {
return uiSchema.value?.options?.steps?.length ?? 0;
} else if (uiSchema.value?.elements?.[0]?.type === 'SteppedLayout') {
return uiSchema.value?.elements[0].options?.steps?.length ?? 0;
}
return 0;
});
const isLastStep = computed(() => {
if (numSteps.value === 0) return false;
return getCurrentStep.value === numSteps.value - 1;
});
// --- Provide submission logic to SteppedLayout ---
provide('submitForm', submitForm);
provide('isSubmitting', isCreating);
</script>
<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?.rclone?.configForm" class="mt-6 [&_.vertical-layout]:space-y-6">
<JsonForms
v-if="formResult?.rclone?.configForm"
:schema="formResult.rclone.configForm.dataSchema"
:uischema="formResult.rclone.configForm.uiSchema"
:renderers="renderers"
:data="formState"
:config="jsonFormsConfig"
:readonly="isCreating"
@change="onChange"
/>
</div>
<!-- Submit Button (visible only on the last step) -->
<div
v-if="!formLoading && uiSchema && isLastStep"
class="mt-6 flex justify-end border-t border-gray-200 pt-6"
>
<Button :loading="isCreating" @click="submitForm"> Submit Configuration </Button>
</div>
</div>
</div>
</template>
<style lang="postcss">
/* Import unraid-ui globals first */
@import '@unraid/ui/styles';
</style>

View File

@@ -0,0 +1,145 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useMutation, useQuery } from '@vue/apollo-composable';
import { Button } from '@unraid/ui';
import { DELETE_REMOTE } from '~/components/RClone/graphql/rclone.mutations';
import { GET_RCLONE_REMOTES } from '~/components/RClone/graphql/rclone.query';
import RCloneConfig from './RCloneConfig.vue';
import RemoteItem from './RemoteItem.vue';
interface FormState {
configStep: number;
showAdvanced: boolean;
name: string;
type: string;
parameters: Record<string, unknown>;
}
const showConfigModal = ref(false);
const selectedRemote = ref<{ name: string, type: string } | null>(null);
const initialFormState = ref<FormState | null>(null);
const {
result: remotes,
loading: loadingRemotes,
refetch: refetchRemotes,
error,
} = useQuery(GET_RCLONE_REMOTES);
const {
mutate: deleteRemote,
loading: isDeleting,
onDone: onDeleteDone,
} = useMutation(DELETE_REMOTE, {
refetchQueries: [{ query: GET_RCLONE_REMOTES }],
});
onDeleteDone((result) => {
const data = result?.data;
if (data?.rclone?.deleteRCloneRemote) {
if (window.toast) {
window.toast.success('Remote Deleted', {
description: 'Remote deleted successfully',
});
}
} else {
if (window.toast) {
window.toast.error('Deletion Failed', {
description: 'Failed to delete remote. Please try again.',
});
}
}
});
const confirmDelete = (remote: string) => {
if (confirm(`Are you sure you want to delete "${remote}"?`)) {
deleteRemote({ input: { name: remote } });
}
};
const openCryptModal = (remote: { name: string, type: string }) => {
const entropy = Math.random().toString(36).substring(2, 8);
selectedRemote.value = remote;
initialFormState.value = {
configStep: 0,
showAdvanced: false,
name: `${remote.name}-crypt-${entropy}`,
type: 'crypt',
parameters: {
remote: `${remote.name}:`,
filename_encryption: 'standard',
directory_name_encryption: true,
password: '',
password2: '',
},
};
showConfigModal.value = true;
};
const onConfigComplete = () => {
showConfigModal.value = false;
initialFormState.value = null;
refetchRemotes();
};
declare global {
interface Window {
toast?: {
success: (title: string, options: { description?: string }) => void;
error?: (title: string, options: { description?: string }) => void;
};
}
}
</script>
<template>
<div class="space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">RClone Remotes</h1>
<Button @click="showConfigModal = true; initialFormState = null">Add New Remote</Button>
</div>
<div v-if="loadingRemotes" class="py-8 text-center text-gray-500">Loading remotes...</div>
<div v-else-if="remotes?.rclone?.remotes?.length" class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<RemoteItem
v-for="remote in remotes?.rclone?.remotes"
:key="remote.name"
:remote="remote"
:is-deleting="isDeleting"
@open-crypt-modal="openCryptModal"
@delete="confirmDelete"
/>
</div>
<div v-else-if="error" class="py-8 text-center text-red-500">
<p class="mb-4">Failed to load remotes</p>
<Button @click="refetchRemotes">Retry</Button>
</div>
<div v-else class="py-8 text-center">
<p class="text-gray-500 mb-4">No remotes configured yet</p>
<Button @click="showConfigModal = true">Create Your First Remote</Button>
</div>
<div
v-if="showConfigModal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div class="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-auto">
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h2 id="modal-title" class="text-xl font-semibold">{{ initialFormState ? `Add Crypt to ${selectedRemote?.name}` : 'Add New Remote' }}</h2>
<Button variant="ghost" size="sm" aria-label="Close dialog" @click="showConfigModal = false">×</Button>
</div>
<div class="p-6">
<RCloneConfig :initial-state="initialFormState || undefined" @complete="onConfigComplete" />
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,77 @@
<script lang="ts" setup>
import { Button } from '@unraid/ui';
interface RemoteProps {
remote: {
name: string;
type: string;
parameters: Record<string, unknown>;
};
isDeleting: boolean;
}
defineProps<RemoteProps>();
const emit = defineEmits<{
'open-crypt-modal': [remote: { name: string; type: string }];
'delete': [name: string];
}>();
const confirmDelete = (name: string) => {
if (confirm(`Are you sure you want to delete "${name}"?`)) {
emit('delete', name);
}
};
// Helper function to safely handle the string replacement
const getLinkedRemote = (remote: string | unknown): string => {
if (typeof remote === 'string') {
return remote.replace(':', '');
}
return String(remote || '');
};
</script>
<template>
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-4">
<div class="flex justify-between items-start">
<div class="space-y-1">
<h3 class="text-lg font-medium">{{ remote.name }}</h3>
<p class="text-sm text-gray-600">Type: {{ remote.type }}</p>
<!-- Show additional details based on remote type -->
<div v-if="remote.parameters" class="space-y-1 mt-2 text-sm text-gray-600">
<!-- For crypt remotes, show which remote they're linked to -->
<p v-if="remote.type === 'crypt' && remote.parameters.remote">
Linked to: {{ getLinkedRemote(remote.parameters.remote) }}
</p>
<!-- For other remote types with important parameters -->
<template v-if="remote.type === 's3' || remote.type === 'b2' || remote.type === 'drive'">
<p v-if="remote.parameters.provider">Provider: {{ remote.parameters.provider }}</p>
<p v-if="remote.parameters.region">Region: {{ remote.parameters.region }}</p>
<p v-if="remote.parameters.bucket_name">Bucket: {{ remote.parameters.bucket_name }}</p>
</template>
</div>
</div>
<div class="flex gap-2">
<Button
v-if="remote.type !== 'crypt'"
size="sm"
variant="secondary"
@click="emit('open-crypt-modal', remote)"
>
Add Crypt
</Button>
<Button
size="sm"
variant="destructive"
:loading="isDeleting"
@click="confirmDelete(remote.name)"
>
Delete
</Button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,23 @@
import { graphql } from "~/composables/gql/gql";
// Create a new remote
export const CREATE_REMOTE = graphql(/* GraphQL */ `
mutation CreateRCloneRemote($input: CreateRCloneRemoteInput!) {
rclone {
createRCloneRemote(input: $input) {
name
type
parameters
}
}
}
`);
// Delete a remote
export const DELETE_REMOTE = graphql(/* GraphQL */ `
mutation DeleteRCloneRemote($input: DeleteRCloneRemoteInput!) {
rclone {
deleteRCloneRemote(input: $input)
}
}
`);

View File

@@ -0,0 +1,29 @@
import { graphql } from '~/composables/gql/gql';
// Get RClone configuration form
export const GET_RCLONE_CONFIG_FORM = graphql(/* GraphQL */ `
query GetRCloneConfigForm($formOptions: RCloneConfigFormInput) {
rclone {
configForm(formOptions: $formOptions) {
id
dataSchema
uiSchema
}
}
}
`);
// Get all remotes
export const GET_RCLONE_REMOTES = graphql(/* GraphQL */ `
query ListRCloneRemotes {
rclone {
remotes {
name
type
parameters
config
}
}
}
`);

View File

@@ -36,6 +36,10 @@ 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 mutation CreateRCloneRemote($input: CreateRCloneRemoteInput!) {\n rclone {\n createRCloneRemote(input: $input) {\n name\n type\n parameters\n }\n }\n }\n": typeof types.CreateRCloneRemoteDocument,
"\n mutation DeleteRCloneRemote($input: DeleteRCloneRemoteInput!) {\n rclone {\n deleteRCloneRemote(input: $input)\n }\n }\n": typeof types.DeleteRCloneRemoteDocument,
"\n query GetRCloneConfigForm($formOptions: RCloneConfigFormInput) {\n rclone {\n configForm(formOptions: $formOptions) {\n id\n dataSchema\n uiSchema\n }\n }\n }\n": typeof types.GetRCloneConfigFormDocument,
"\n query ListRCloneRemotes {\n rclone {\n remotes {\n name\n type\n parameters\n config\n }\n }\n }\n": typeof types.ListRCloneRemotesDocument,
"\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 +73,10 @@ 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 mutation CreateRCloneRemote($input: CreateRCloneRemoteInput!) {\n rclone {\n createRCloneRemote(input: $input) {\n name\n type\n parameters\n }\n }\n }\n": types.CreateRCloneRemoteDocument,
"\n mutation DeleteRCloneRemote($input: DeleteRCloneRemoteInput!) {\n rclone {\n deleteRCloneRemote(input: $input)\n }\n }\n": types.DeleteRCloneRemoteDocument,
"\n query GetRCloneConfigForm($formOptions: RCloneConfigFormInput) {\n rclone {\n configForm(formOptions: $formOptions) {\n id\n dataSchema\n uiSchema\n }\n }\n }\n": types.GetRCloneConfigFormDocument,
"\n query ListRCloneRemotes {\n rclone {\n remotes {\n name\n type\n parameters\n config\n }\n }\n }\n": types.ListRCloneRemotesDocument,
"\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 +190,22 @@ 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 mutation CreateRCloneRemote($input: CreateRCloneRemoteInput!) {\n rclone {\n createRCloneRemote(input: $input) {\n name\n type\n parameters\n }\n }\n }\n"): (typeof documents)["\n mutation CreateRCloneRemote($input: CreateRCloneRemoteInput!) {\n rclone {\n createRCloneRemote(input: $input) {\n name\n type\n parameters\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation DeleteRCloneRemote($input: DeleteRCloneRemoteInput!) {\n rclone {\n deleteRCloneRemote(input: $input)\n }\n }\n"): (typeof documents)["\n mutation DeleteRCloneRemote($input: DeleteRCloneRemoteInput!) {\n rclone {\n deleteRCloneRemote(input: $input)\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($formOptions: RCloneConfigFormInput) {\n rclone {\n configForm(formOptions: $formOptions) {\n id\n dataSchema\n uiSchema\n }\n }\n }\n"): (typeof documents)["\n query GetRCloneConfigForm($formOptions: RCloneConfigFormInput) {\n rclone {\n configForm(formOptions: $formOptions) {\n id\n dataSchema\n uiSchema\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 rclone {\n remotes {\n name\n type\n parameters\n config\n }\n }\n }\n"): (typeof documents)["\n query ListRCloneRemotes {\n rclone {\n remotes {\n name\n type\n parameters\n config\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

View File

@@ -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 = {
name: Scalars['String']['input'];
parameters: Scalars['JSON']['input'];
type: Scalars['String']['input'];
};
export type Customization = {
__typename?: 'Customization';
activationCode?: Maybe<ActivationCode>;
@@ -538,6 +542,10 @@ export type DeleteApiKeyInput = {
ids: Array<Scalars['PrefixedID']['input']>;
};
export type DeleteRCloneRemoteInput = {
name: Scalars['String']['input'];
};
export type Devices = Node & {
__typename?: 'Devices';
gpu: Array<Gpu>;
@@ -673,10 +681,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 +713,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 +760,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 +844,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']>;
@@ -906,7 +933,10 @@ export type Mutation = {
deleteNotification: NotificationOverview;
docker: DockerMutations;
enableDynamicRemoteAccess: Scalars['Boolean']['output'];
/** Initiates a flash drive backup using a configured remote. */
initiateFlashBackup: FlashBackupStatus;
parityCheck: ParityCheckMutations;
rclone: RCloneMutations;
/** Reads each notification to recompute & update the overview. */
recalculateOverview: NotificationOverview;
setAdditionalAllowedOrigins: Array<Scalars['String']['output']>;
@@ -957,6 +987,11 @@ export type MutationEnableDynamicRemoteAccessArgs = {
};
export type MutationInitiateFlashBackupArgs = {
input: InitiateFlashBackupInput;
};
export type MutationSetAdditionalAllowedOriginsArgs = {
input: AllowedOriginInput;
};
@@ -1199,6 +1234,7 @@ export type Query = {
parityHistory: Array<ParityCheck>;
publicPartnerInfo?: Maybe<PublicPartnerInfo>;
publicTheme: Theme;
rclone: RCloneBackupSettings;
registration?: Maybe<Registration>;
remoteAccess: RemoteAccess;
server?: Maybe<Server>;
@@ -1227,6 +1263,69 @@ 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<RCloneRemote>;
};
export type RCloneBackupSettingsConfigFormArgs = {
formOptions?: InputMaybe<RCloneConfigFormInput>;
};
export type RCloneConfigFormInput = {
parameters?: InputMaybe<Scalars['JSON']['input']>;
providerType?: InputMaybe<Scalars['String']['input']>;
showAdvanced?: InputMaybe<Scalars['Boolean']['input']>;
};
export type RCloneDrive = {
__typename?: 'RCloneDrive';
/** Provider name */
name: Scalars['String']['output'];
/** Provider options and configuration schema */
options: Scalars['JSON']['output'];
};
/** RClone related mutations */
export type RCloneMutations = {
__typename?: 'RCloneMutations';
/** Create a new RClone remote */
createRCloneRemote: RCloneRemote;
/** Delete an existing RClone remote */
deleteRCloneRemote: Scalars['Boolean']['output'];
};
/** RClone related mutations */
export type RCloneMutationsCreateRCloneRemoteArgs = {
input: CreateRCloneRemoteInput;
};
/** RClone related mutations */
export type RCloneMutationsDeleteRCloneRemoteArgs = {
input: DeleteRCloneRemoteInput;
};
export type RCloneRemote = {
__typename?: 'RCloneRemote';
/** Complete remote configuration */
config: Scalars['JSON']['output'];
name: Scalars['String']['output'];
parameters: Scalars['JSON']['output'];
type: Scalars['String']['output'];
};
export type Registration = Node & {
__typename?: 'Registration';
expiration?: Maybe<Scalars['String']['output']>;
@@ -1961,6 +2060,32 @@ export type NotificationOverviewSubSubscription = { __typename?: 'Subscription',
& { ' $fragmentRefs'?: { 'NotificationCountFragmentFragment': NotificationCountFragmentFragment } }
) } };
export type CreateRCloneRemoteMutationVariables = Exact<{
input: CreateRCloneRemoteInput;
}>;
export type CreateRCloneRemoteMutation = { __typename?: 'Mutation', rclone: { __typename?: 'RCloneMutations', createRCloneRemote: { __typename?: 'RCloneRemote', name: string, type: string, parameters: any } } };
export type DeleteRCloneRemoteMutationVariables = Exact<{
input: DeleteRCloneRemoteInput;
}>;
export type DeleteRCloneRemoteMutation = { __typename?: 'Mutation', rclone: { __typename?: 'RCloneMutations', deleteRCloneRemote: boolean } };
export type GetRCloneConfigFormQueryVariables = Exact<{
formOptions?: InputMaybe<RCloneConfigFormInput>;
}>;
export type GetRCloneConfigFormQuery = { __typename?: 'Query', rclone: { __typename?: 'RCloneBackupSettings', configForm: { __typename?: 'RCloneBackupConfigForm', id: string, dataSchema: any, uiSchema: any } } };
export type ListRCloneRemotesQueryVariables = Exact<{ [key: string]: never; }>;
export type ListRCloneRemotesQuery = { __typename?: 'Query', rclone: { __typename?: 'RCloneBackupSettings', remotes: Array<{ __typename?: 'RCloneRemote', name: string, type: string, parameters: any, config: any }> } };
export type ConnectSignInMutationVariables = Exact<{
input: ConnectSignInInput;
}>;
@@ -2035,6 +2160,10 @@ 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 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":"rclone"},"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":"parameters"}}]}}]}}]}}]} as unknown as DocumentNode<CreateRCloneRemoteMutation, CreateRCloneRemoteMutationVariables>;
export const DeleteRCloneRemoteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteRCloneRemote"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteRCloneRemoteInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rclone"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteRCloneRemote"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]} as unknown as DocumentNode<DeleteRCloneRemoteMutation, DeleteRCloneRemoteMutationVariables>;
export const GetRCloneConfigFormDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetRCloneConfigForm"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"formOptions"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"RCloneConfigFormInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rclone"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configForm"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"formOptions"},"value":{"kind":"Variable","name":{"kind":"Name","value":"formOptions"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dataSchema"}},{"kind":"Field","name":{"kind":"Name","value":"uiSchema"}}]}}]}}]}}]} as unknown as DocumentNode<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":"rclone"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"remotes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"parameters"}},{"kind":"Field","name":{"kind":"Name","value":"config"}}]}}]}}]}}]} as unknown as DocumentNode<ListRCloneRemotesQuery, ListRCloneRemotesQueryVariables>;
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>;

View File

@@ -11,7 +11,6 @@ const useTeleport = () => {
if (!potentialTarget) return;
teleportTarget.value = potentialTarget;
console.log("[determineTeleportTarget] teleportTarget", teleportTarget.value);
};
onMounted(() => {

View File

@@ -1,4 +1,4 @@
import { ApolloClient, createHttpLink, from, split } from '@apollo/client/core/index.js';
import { ApolloClient, ApolloLink, createHttpLink, from, split } from '@apollo/client/core/index.js';
import { onError } from '@apollo/client/link/error/index.js';
import { RetryLink } from '@apollo/client/link/retry/index.js';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions/index.js';
@@ -10,6 +10,7 @@ import { WEBGUI_GRAPHQL } from './urls';
const httpEndpoint = WEBGUI_GRAPHQL;
const wsEndpoint = new URL(WEBGUI_GRAPHQL.toString().replace('http', 'ws'));
const DEV_MODE = (globalThis as unknown as { __DEV__: boolean }).__DEV__ ?? false;
const headers = {
'x-csrf-token': globalThis.csrf_token ?? '0000000000000000',
@@ -66,6 +67,14 @@ const retryLink = new RetryLink({
},
});
// Disable Apollo Client if not in DEV Mode and server state says unraid-api is not running
const disableQueryLink = new ApolloLink((operation, forward) => {
if (!DEV_MODE && operation.getContext().serverState?.unraidApi?.status === 'offline') {
return null;
}
return forward(operation);
});
const splitLinks = split(
({ query }) => {
const definition = getMainDefinition(query);
@@ -74,12 +83,13 @@ 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
* https://www.apollographql.com/docs/react/api/link/introduction/#directional-composition
*/
const additiveLink = from([errorLink, retryLink, splitLinks]);
const additiveLink = from([errorLink, retryLink, disableQueryLink, splitLinks]);
export const client = new ApolloClient({
link: additiveLink,

Some files were not shown because too many files have changed in this diff Show More