mirror of
https://github.com/unraid/api.git
synced 2026-02-05 07:29:21 -06:00
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:
6
.cursor/rules/no-comments.mdc
Normal file
6
.cursor/rules/no-comments.mdc
Normal 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
1
.rclone-version
Normal file
@@ -0,0 +1 @@
|
||||
1.69.1
|
||||
@@ -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"
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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",
|
||||
|
||||
374
api/src/__test__/graphql/resolvers/rclone-api.service.test.ts
Normal file
374
api/src/__test__/graphql/resolvers/rclone-api.service.test.ts
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ exports[`Returns paths 1`] = `
|
||||
"unraid-data",
|
||||
"docker-autostart",
|
||||
"docker-socket",
|
||||
"rclone-socket",
|
||||
"parity-checks",
|
||||
"htpasswd",
|
||||
"emhttpd-socket",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Field, InputType, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { GraphQLJSON } from 'graphql-scalars';
|
||||
|
||||
@InputType()
|
||||
export class InitiateFlashBackupInput {
|
||||
@Field(() => String, { description: 'The name of the remote configuration to use for the backup.' })
|
||||
remoteName!: string;
|
||||
|
||||
@Field(() => String, { description: 'Source path to backup (typically the flash drive).' })
|
||||
sourcePath!: string;
|
||||
|
||||
@Field(() => String, { description: 'Destination path on the remote.' })
|
||||
destinationPath!: string;
|
||||
|
||||
@Field(() => GraphQLJSON, {
|
||||
description: 'Additional options for the backup operation, such as --dry-run or --transfers.',
|
||||
nullable: true,
|
||||
})
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class FlashBackupStatus {
|
||||
@Field(() => String, {
|
||||
description: 'Status message indicating the outcome of the backup initiation.',
|
||||
})
|
||||
status!: string;
|
||||
|
||||
@Field(() => String, {
|
||||
description: 'Job ID if available, can be used to check job status.',
|
||||
nullable: true,
|
||||
})
|
||||
jobId?: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class FlashBackupJob {
|
||||
@Field(() => String, { description: 'Job ID' })
|
||||
id!: string;
|
||||
|
||||
@Field(() => String, { description: 'Job type (e.g., sync/copy)' })
|
||||
type!: string;
|
||||
|
||||
@Field(() => GraphQLJSON, { description: 'Job status and statistics' })
|
||||
stats!: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class RCloneWebGuiInfo {
|
||||
@Field()
|
||||
url!: string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { FlashBackupResolver } from '@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 {}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
4237
api/src/unraid-api/graph/resolvers/rclone/jsonforms/config.ts
Normal file
4237
api/src/unraid-api/graph/resolvers/rclone/jsonforms/config.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,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...
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
394
api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts
Normal file
394
api/src/unraid-api/graph/resolvers/rclone/rclone-api.service.ts
Normal 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)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
208
api/src/unraid-api/graph/resolvers/rclone/rclone.model.ts
Normal file
208
api/src/unraid-api/graph/resolvers/rclone/rclone.model.ts
Normal 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;
|
||||
}
|
||||
20
api/src/unraid-api/graph/resolvers/rclone/rclone.module.ts
Normal file
20
api/src/unraid-api/graph/resolvers/rclone/rclone.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { 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 {}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
64
api/src/unraid-api/graph/resolvers/rclone/rclone.resolver.ts
Normal file
64
api/src/unraid-api/graph/resolvers/rclone/rclone.resolver.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
122
api/src/unraid-api/graph/resolvers/rclone/rclone.service.ts
Normal file
122
api/src/unraid-api/graph/resolvers/rclone/rclone.service.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
46
api/src/unraid-api/graph/utils/form-utils.ts
Normal file
46
api/src/unraid-api/graph/utils/form-utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
91
api/src/unraid-api/types/json-forms.test.ts
Normal file
91
api/src/unraid-api/types/json-forms.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
64
plugin/builder/utils/rclone-helper.ts
Normal file
64
plugin/builder/utils/rclone-helper.ts
Normal 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
403
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
3
unraid-ui/.gitignore
vendored
3
unraid-ui/.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
!.env.development
|
||||
dist-wc/
|
||||
dist-wc/
|
||||
.storybook/static/*
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["../src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["../stories/**/*", "../src/**/*"]
|
||||
}
|
||||
@@ -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/**/*'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
30
unraid-ui/scripts/build-style.mjs
Normal file
30
unraid-ui/scripts/build-style.mjs
Normal 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);
|
||||
21
unraid-ui/src/components.ts
Normal file
21
unraid-ui/src/components.ts
Normal 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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="
|
||||
|
||||
19
unraid-ui/src/components/form/combobox/Combobox.vue
Normal file
19
unraid-ui/src/components/form/combobox/Combobox.vue
Normal 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>
|
||||
22
unraid-ui/src/components/form/combobox/ComboboxAnchor.vue
Normal file
22
unraid-ui/src/components/form/combobox/ComboboxAnchor.vue
Normal 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>
|
||||
20
unraid-ui/src/components/form/combobox/ComboboxEmpty.vue
Normal file
20
unraid-ui/src/components/form/combobox/ComboboxEmpty.vue
Normal 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>
|
||||
36
unraid-ui/src/components/form/combobox/ComboboxGroup.vue
Normal file
36
unraid-ui/src/components/form/combobox/ComboboxGroup.vue
Normal 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>
|
||||
40
unraid-ui/src/components/form/combobox/ComboboxInput.vue
Normal file
40
unraid-ui/src/components/form/combobox/ComboboxInput.vue
Normal 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>
|
||||
31
unraid-ui/src/components/form/combobox/ComboboxItem.vue
Normal file
31
unraid-ui/src/components/form/combobox/ComboboxItem.vue
Normal 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>
|
||||
56
unraid-ui/src/components/form/combobox/ComboboxList.vue
Normal file
56
unraid-ui/src/components/form/combobox/ComboboxList.vue
Normal 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>
|
||||
20
unraid-ui/src/components/form/combobox/ComboboxSeparator.vue
Normal file
20
unraid-ui/src/components/form/combobox/ComboboxSeparator.vue
Normal 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>
|
||||
22
unraid-ui/src/components/form/combobox/ComboboxTrigger.vue
Normal file
22
unraid-ui/src/components/form/combobox/ComboboxTrigger.vue
Normal 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>
|
||||
10
unraid-ui/src/components/form/combobox/index.ts
Normal file
10
unraid-ui/src/components/form/combobox/index.ts
Normal 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';
|
||||
@@ -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';
|
||||
4
unraid-ui/src/components/modals/ModalTarget.vue
Normal file
4
unraid-ui/src/components/modals/ModalTarget.vue
Normal file
@@ -0,0 +1,4 @@
|
||||
<script setup lang="ts"></script>
|
||||
<template>
|
||||
<div id="modals" />
|
||||
</template>
|
||||
1
unraid-ui/src/components/modals/index.ts
Normal file
1
unraid-ui/src/components/modals/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Modals } from './ModalTarget.vue';
|
||||
@@ -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(() => {
|
||||
|
||||
123
unraid-ui/src/forms/ComboBoxField.vue
Normal file
123
unraid-ui/src/forms/ComboBoxField.vue
Normal 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>
|
||||
@@ -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>
|
||||
22
unraid-ui/src/forms/ControlWrapper.vue
Normal file
22
unraid-ui/src/forms/ControlWrapper.vue
Normal 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>
|
||||
18
unraid-ui/src/forms/FormErrors.vue
Normal file
18
unraid-ui/src/forms/FormErrors.vue
Normal 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>
|
||||
46
unraid-ui/src/forms/HorizontalLayout.vue
Normal file
46
unraid-ui/src/forms/HorizontalLayout.vue
Normal 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>
|
||||
65
unraid-ui/src/forms/InputField.vue
Normal file
65
unraid-ui/src/forms/InputField.vue
Normal 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>
|
||||
79
unraid-ui/src/forms/LabelRenderer.vue
Normal file
79
unraid-ui/src/forms/LabelRenderer.vue
Normal 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>
|
||||
29
unraid-ui/src/forms/MissingRenderer.vue
Normal file
29
unraid-ui/src/forms/MissingRenderer.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
184
unraid-ui/src/forms/SteppedLayout.vue
Normal file
184
unraid-ui/src/forms/SteppedLayout.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
47
unraid-ui/src/forms/UnraidSettingsLayout.vue
Normal file
47
unraid-ui/src/forms/UnraidSettingsLayout.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
29
unraid-ui/src/forms/composables/useJsonFormsVisibility.ts
Normal file
29
unraid-ui/src/forms/composables/useJsonFormsVisibility.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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'))),
|
||||
};
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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'))),
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
6
unraid-ui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.css?raw' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
93
unraid-ui/stories/components/form/Combobox.stories.ts
Normal file
93
unraid-ui/stories/components/form/Combobox.stories.ts
Normal 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>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
@@ -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" />
|
||||
|
||||
@@ -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",
|
||||
|
||||
245
web/components/RClone/RCloneConfig.vue
Normal file
245
web/components/RClone/RCloneConfig.vue
Normal 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>
|
||||
145
web/components/RClone/RCloneOverview.vue
Normal file
145
web/components/RClone/RCloneOverview.vue
Normal 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>
|
||||
77
web/components/RClone/RemoteItem.vue
Normal file
77
web/components/RClone/RemoteItem.vue
Normal 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>
|
||||
23
web/components/RClone/graphql/rclone.mutations.ts
Normal file
23
web/components/RClone/graphql/rclone.mutations.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
`);
|
||||
29
web/components/RClone/graphql/rclone.query.ts
Normal file
29
web/components/RClone/graphql/rclone.query.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -11,7 +11,6 @@ const useTeleport = () => {
|
||||
if (!potentialTarget) return;
|
||||
|
||||
teleportTarget.value = potentialTarget;
|
||||
console.log("[determineTeleportTarget] teleportTarget", teleportTarget.value);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user