mirror of
https://github.com/unraid/api.git
synced 2025-12-31 05:29:48 -06:00
feat: connect settings web component (#1211)
Replaces the Connect Settings form at Settings > Management Access with a webcomponent containing a generated form. CodeRabbit: - **New Features** - Enhanced connection settings with an updated UI, including a new custom element `<unraid-connect-settings>`. - Introduced several new form components (e.g., `NumberField`, `StringArrayField`, `Select`, `Switch`, `PreconditionsLabel`, `ControlLayout`, and `VerticalLayout`) for a more dynamic experience. - Added a notification system with the `Toaster` component for user feedback. - New GraphQL operations for managing connection settings and API updates. - **Chores** - Upgraded multiple backend and frontend dependencies and refined configuration files. - **Tests** - Expanded test coverage for CSV conversion, form settings merging, and the new `csvStringToArray` function. - **Documentation** - Added introductory documentation for form components and a readme for the forms directory.
This commit is contained in:
4
api/.env.production
Normal file
4
api/.env.production
Normal file
@@ -0,0 +1,4 @@
|
||||
ENVIRONMENT="production"
|
||||
NODE_ENV="production"
|
||||
PORT="/var/run/unraid-api.sock"
|
||||
MOTHERSHIP_GRAPHQL_LINK="https://mothership.unraid.net/ws"
|
||||
@@ -50,6 +50,7 @@ export default tseslint.config(eslint.configs.recommended, ...tseslint.configs.r
|
||||
message: 'Use import.meta.url instead of __filename in ESM',
|
||||
},
|
||||
],
|
||||
'eol-last': ['error', 'always'],
|
||||
},
|
||||
|
||||
ignores: ['src/graphql/generated/client/**/*'],
|
||||
|
||||
@@ -51,19 +51,20 @@
|
||||
"@apollo/client": "^3.11.8",
|
||||
"@apollo/server": "^4.11.2",
|
||||
"@as-integrations/fastify": "^2.1.1",
|
||||
"@fastify/cookie": "^9.4.0",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/helmet": "^13.0.1",
|
||||
"@graphql-codegen/client-preset": "^4.5.0",
|
||||
"@graphql-tools/load-files": "^7.0.0",
|
||||
"@graphql-tools/merge": "^9.0.8",
|
||||
"@graphql-tools/schema": "^10.0.7",
|
||||
"@graphql-tools/utils": "^10.5.5",
|
||||
"@nestjs/apollo": "^12.2.1",
|
||||
"@nestjs/common": "^10.4.7",
|
||||
"@nestjs/core": "^10.4.7",
|
||||
"@nestjs/graphql": "^12.2.1",
|
||||
"@jsonforms/core": "^3.5.1",
|
||||
"@nestjs/apollo": "^13.0.3",
|
||||
"@nestjs/common": "^11.0.11",
|
||||
"@nestjs/core": "^11.0.11",
|
||||
"@nestjs/graphql": "^13.0.3",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-fastify": "^10.4.7",
|
||||
"@nestjs/platform-fastify": "^11.0.11",
|
||||
"@nestjs/schedule": "^5.0.0",
|
||||
"@nestjs/throttler": "^6.2.1",
|
||||
"@reduxjs/toolkit": "^2.3.0",
|
||||
@@ -89,7 +90,7 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"execa": "^9.5.1",
|
||||
"exit-hook": "^4.0.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fastify": "^5.2.1",
|
||||
"filenamify": "^6.0.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"glob": "^11.0.1",
|
||||
@@ -109,7 +110,7 @@
|
||||
"lodash-es": "^4.17.21",
|
||||
"multi-ini": "^2.3.2",
|
||||
"mustache": "^4.2.0",
|
||||
"nest-authz": "^2.11.0",
|
||||
"nest-authz": "^2.14.0",
|
||||
"nest-commander": "^3.15.0",
|
||||
"nestjs-pino": "^4.1.0",
|
||||
"node-cache": "^5.1.2",
|
||||
@@ -145,7 +146,7 @@
|
||||
"@graphql-codegen/typescript-resolvers": "4.4.3",
|
||||
"@graphql-typed-document-node/core": "^3.2.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.4.0",
|
||||
"@nestjs/testing": "^10.4.7",
|
||||
"@nestjs/testing": "^11.0.11",
|
||||
"@originjs/vite-plugin-commonjs": "^1.0.3",
|
||||
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||
"@swc/core": "^1.10.1",
|
||||
|
||||
180
api/src/__test__/json-forms.test.ts
Normal file
180
api/src/__test__/json-forms.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import type { ControlElement } from '@jsonforms/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { SettingSlice } from '@app/unraid-api/types/json-forms.js';
|
||||
import { createEmptySettingSlice, mergeSettingSlices } from '@app/unraid-api/types/json-forms.js';
|
||||
|
||||
describe('mergeSettingSlices', () => {
|
||||
it('returns an empty slice when given an empty array', () => {
|
||||
const result = mergeSettingSlices([]);
|
||||
expect(result).toEqual(createEmptySettingSlice());
|
||||
});
|
||||
|
||||
it('returns the same slice when given a single slice', () => {
|
||||
const slice: SettingSlice = {
|
||||
properties: {
|
||||
test: { type: 'string' },
|
||||
},
|
||||
elements: [{ type: 'Control', scope: '#/properties/test' }],
|
||||
};
|
||||
|
||||
const result = mergeSettingSlices([slice]);
|
||||
expect(result).toEqual(slice);
|
||||
});
|
||||
|
||||
it('merges properties and concatenates elements from multiple slices', () => {
|
||||
const slice1: SettingSlice = {
|
||||
properties: {
|
||||
prop1: { type: 'string' },
|
||||
},
|
||||
elements: [{ type: 'Control', scope: '#/properties/prop1' }],
|
||||
};
|
||||
|
||||
const slice2: SettingSlice = {
|
||||
properties: {
|
||||
prop2: { type: 'number' },
|
||||
},
|
||||
elements: [{ type: 'Control', scope: '#/properties/prop2' }],
|
||||
};
|
||||
|
||||
const expected: SettingSlice = {
|
||||
properties: {
|
||||
prop1: { type: 'string' },
|
||||
prop2: { type: 'number' },
|
||||
},
|
||||
elements: [
|
||||
{ type: 'Control', scope: '#/properties/prop1' },
|
||||
{ type: 'Control', scope: '#/properties/prop2' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = mergeSettingSlices([slice1, slice2]);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('handles more complex schema properties and UI elements', () => {
|
||||
const slice1: SettingSlice = {
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
title: 'Name',
|
||||
minLength: 3,
|
||||
},
|
||||
},
|
||||
elements: [
|
||||
{
|
||||
type: 'Control',
|
||||
scope: '#/properties/name',
|
||||
label: 'Full Name',
|
||||
} as ControlElement,
|
||||
],
|
||||
};
|
||||
|
||||
const slice2: SettingSlice = {
|
||||
properties: {
|
||||
age: {
|
||||
type: 'number',
|
||||
title: 'Age',
|
||||
minimum: 0,
|
||||
maximum: 120,
|
||||
},
|
||||
},
|
||||
elements: [
|
||||
{
|
||||
type: 'Control',
|
||||
scope: '#/properties/age',
|
||||
label: 'Your Age',
|
||||
} as ControlElement,
|
||||
],
|
||||
};
|
||||
|
||||
const slice3: SettingSlice = {
|
||||
properties: {
|
||||
active: {
|
||||
type: 'boolean',
|
||||
title: 'Active Status',
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
elements: [
|
||||
{
|
||||
type: 'Control',
|
||||
scope: '#/properties/active',
|
||||
label: 'Is Active',
|
||||
options: {
|
||||
toggle: true,
|
||||
},
|
||||
} as ControlElement,
|
||||
],
|
||||
};
|
||||
|
||||
const result = mergeSettingSlices([slice1, slice2, slice3]);
|
||||
|
||||
// Check properties were merged correctly
|
||||
expect(result.properties).toHaveProperty('name');
|
||||
expect(result.properties).toHaveProperty('age');
|
||||
expect(result.properties).toHaveProperty('active');
|
||||
expect(result.properties.name.type).toBe('string');
|
||||
expect(result.properties.age.type).toBe('number');
|
||||
expect(result.properties.active.type).toBe('boolean');
|
||||
|
||||
// Check elements were concatenated in order
|
||||
expect(result.elements).toHaveLength(3);
|
||||
expect(result.elements[0]).toEqual(slice1.elements[0]);
|
||||
expect(result.elements[1]).toEqual(slice2.elements[0]);
|
||||
expect(result.elements[2]).toEqual(slice3.elements[0]);
|
||||
});
|
||||
|
||||
it('later properties override earlier ones with the same key', () => {
|
||||
const slice1: SettingSlice = {
|
||||
properties: {
|
||||
prop: { type: 'string', title: 'Original' },
|
||||
},
|
||||
elements: [
|
||||
{ type: 'Control', scope: '#/properties/prop', label: 'First' } as ControlElement,
|
||||
],
|
||||
};
|
||||
|
||||
const slice2: SettingSlice = {
|
||||
properties: {
|
||||
prop: { type: 'number', title: 'Override' },
|
||||
},
|
||||
elements: [
|
||||
{ type: 'Control', scope: '#/properties/prop', label: 'Second' } as ControlElement,
|
||||
],
|
||||
};
|
||||
|
||||
const result = mergeSettingSlices([slice1, slice2]);
|
||||
|
||||
// The property from slice2 should override the one from slice1
|
||||
expect(result.properties.prop.type).toBe('number');
|
||||
expect(result.properties.prop.title).toBe('Override');
|
||||
|
||||
// Both elements should be present
|
||||
expect(result.elements).toHaveLength(2);
|
||||
expect((result.elements[0] as ControlElement).label).toBe('First');
|
||||
expect((result.elements[1] as ControlElement).label).toBe('Second');
|
||||
});
|
||||
|
||||
it('preserves empty properties and elements', () => {
|
||||
const slice1: SettingSlice = {
|
||||
properties: {},
|
||||
elements: [],
|
||||
};
|
||||
|
||||
const slice2: SettingSlice = {
|
||||
properties: {
|
||||
prop: { type: 'string' },
|
||||
},
|
||||
elements: [{ type: 'Control', scope: '#/properties/prop' }],
|
||||
};
|
||||
|
||||
const result = mergeSettingSlices([slice1, slice2]);
|
||||
expect(result.properties).toHaveProperty('prop');
|
||||
expect(result.elements).toHaveLength(1);
|
||||
|
||||
const result2 = mergeSettingSlices([slice2, slice1]);
|
||||
expect(result2.properties).toHaveProperty('prop');
|
||||
expect(result2.elements).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatDatetime } from '@app/utils.js';
|
||||
import { csvStringToArray, formatDatetime } from '@app/utils.js';
|
||||
|
||||
describe('formatDatetime', () => {
|
||||
const testDate = new Date('2024-02-14T12:34:56');
|
||||
@@ -69,3 +69,37 @@ describe('formatDatetime', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('csvStringToArray', () => {
|
||||
it('returns an empty array for null, undefined, or empty strings', () => {
|
||||
expect(csvStringToArray(null)).toEqual([]);
|
||||
expect(csvStringToArray(undefined)).toEqual([]);
|
||||
expect(csvStringToArray('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an array of strings for a CSV string', () => {
|
||||
expect(csvStringToArray('one,two,three')).toEqual(['one', 'two', 'three']);
|
||||
});
|
||||
|
||||
it('returns an array of strings for a CSV string with spaces', () => {
|
||||
expect(csvStringToArray('one, two, three')).toEqual(['one', 'two', 'three']);
|
||||
});
|
||||
|
||||
it('handles single element edge cases', () => {
|
||||
expect(csvStringToArray('one', { noEmpty: false })).toEqual(['one']);
|
||||
expect(csvStringToArray('one,', { noEmpty: false })).toEqual(['one', '']);
|
||||
expect(csvStringToArray(',one', { noEmpty: false })).toEqual(['', 'one']);
|
||||
expect(csvStringToArray(',one,', { noEmpty: false })).toEqual(['', 'one', '']);
|
||||
});
|
||||
|
||||
it('handles non-empty option', () => {
|
||||
expect(csvStringToArray('one', { noEmpty: true })).toEqual(['one']);
|
||||
expect(csvStringToArray('one,', { noEmpty: true })).toEqual(['one']);
|
||||
expect(csvStringToArray(',one', { noEmpty: true })).toEqual(['one']);
|
||||
expect(csvStringToArray(',one,', { noEmpty: true })).toEqual(['one']);
|
||||
});
|
||||
|
||||
it('defaults to noEmpty', () => {
|
||||
expect(csvStringToArray(',one,')).toEqual(['one']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import * as Types from '@app/graphql/generated/api/types.js';
|
||||
|
||||
import { z } from 'zod'
|
||||
import { AccessUrl, AccessUrlInput, AddPermissionInput, AddRoleForApiKeyInput, AddRoleForUserInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ApiKeyWithSecret, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskStatus, ArrayDiskType, ArrayPendingState, ArrayState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, CreateApiKeyInput, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationCounts, NotificationData, NotificationFilter, NotificationOverview, NotificationType, Notifications, NotificationslistArgs, Os, Owner, ParityCheck, Partition, Pci, Permission, ProfileModel, Registration, RegistrationState, RelayResponse, RemoteAccess, RemoveRoleFromApiKeyInput, Resource, Role, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, URL_TYPE, UnassignedDevice, Uptime, Usb, User, UserAccount, Vars, Versions, VmDomain, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addUserInput, arrayDiskInput, deleteUserInput, mdState, registrationType, usersInput } from '@app/graphql/generated/api/types.js'
|
||||
import { AccessUrl, AccessUrlInput, AddPermissionInput, AddRoleForApiKeyInput, AddRoleForUserInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ApiKeyWithSecret, ApiSettingsInput, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskStatus, ArrayDiskType, ArrayPendingState, ArrayState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSettings, ConnectSettingsValues, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, CreateApiKeyInput, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationCounts, NotificationData, NotificationFilter, NotificationOverview, NotificationType, Notifications, NotificationslistArgs, Os, Owner, ParityCheck, Partition, Pci, Permission, ProfileModel, Registration, RegistrationState, RelayResponse, RemoteAccess, RemoveRoleFromApiKeyInput, Resource, Role, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, URL_TYPE, UnassignedDevice, Uptime, Usb, User, UserAccount, Vars, Versions, VmDomain, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addUserInput, arrayDiskInput, deleteUserInput, mdState, registrationType, usersInput } from '@app/graphql/generated/api/types.js'
|
||||
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
||||
|
||||
type Properties<T> = Required<{
|
||||
@@ -152,6 +152,16 @@ export function ApiKeyWithSecretSchema(): z.ZodObject<Properties<ApiKeyWithSecre
|
||||
})
|
||||
}
|
||||
|
||||
export function ApiSettingsInputSchema(): z.ZodObject<Properties<ApiSettingsInput>> {
|
||||
return z.object({
|
||||
accessType: WAN_ACCESS_TYPESchema.nullish(),
|
||||
extraOrigins: z.array(z.string()).nullish(),
|
||||
forwardType: WAN_FORWARD_TYPESchema.nullish(),
|
||||
port: z.number().nullish(),
|
||||
sandbox: z.boolean().nullish()
|
||||
})
|
||||
}
|
||||
|
||||
export function ArrayTypeSchema(): z.ZodObject<Properties<ArrayType>> {
|
||||
return z.object({
|
||||
__typename: z.literal('Array').optional(),
|
||||
@@ -267,7 +277,29 @@ export function ConnectSchema(): z.ZodObject<Properties<Connect>> {
|
||||
return z.object({
|
||||
__typename: z.literal('Connect').optional(),
|
||||
dynamicRemoteAccess: DynamicRemoteAccessStatusSchema(),
|
||||
id: z.string()
|
||||
id: z.string(),
|
||||
settings: ConnectSettingsSchema()
|
||||
})
|
||||
}
|
||||
|
||||
export function ConnectSettingsSchema(): z.ZodObject<Properties<ConnectSettings>> {
|
||||
return z.object({
|
||||
__typename: z.literal('ConnectSettings').optional(),
|
||||
dataSchema: z.record(z.string(), z.any()),
|
||||
id: z.string(),
|
||||
uiSchema: z.record(z.string(), z.any()),
|
||||
values: ConnectSettingsValuesSchema()
|
||||
})
|
||||
}
|
||||
|
||||
export function ConnectSettingsValuesSchema(): z.ZodObject<Properties<ConnectSettingsValues>> {
|
||||
return z.object({
|
||||
__typename: z.literal('ConnectSettingsValues').optional(),
|
||||
accessType: WAN_ACCESS_TYPESchema,
|
||||
extraOrigins: z.array(z.string()),
|
||||
forwardType: WAN_FORWARD_TYPESchema.nullish(),
|
||||
port: z.number().nullish(),
|
||||
sandbox: z.boolean()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,26 @@ export type ApiKeyWithSecret = {
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Input should be a subset of ApiSettings that can be updated.
|
||||
* Some field combinations may be required or disallowed. Please refer to each field for more information.
|
||||
*/
|
||||
export type ApiSettingsInput = {
|
||||
/** The type of WAN access to use for Remote Access. */
|
||||
accessType?: InputMaybe<WAN_ACCESS_TYPE>;
|
||||
/** A list of origins allowed to interact with the API. */
|
||||
extraOrigins?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
/** The type of port forwarding to use for Remote Access. */
|
||||
forwardType?: InputMaybe<WAN_FORWARD_TYPE>;
|
||||
/** The port to use for Remote Access. */
|
||||
port?: InputMaybe<Scalars['Port']['input']>;
|
||||
/**
|
||||
* If true, the GraphQL sandbox will be enabled and available at /graphql.
|
||||
* If false, the GraphQL sandbox will be disabled and only the production API will be available.
|
||||
*/
|
||||
sandbox?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
};
|
||||
|
||||
export type ArrayType = Node & {
|
||||
__typename?: 'Array';
|
||||
/** Current boot disk */
|
||||
@@ -296,6 +316,25 @@ export type Connect = Node & {
|
||||
__typename?: 'Connect';
|
||||
dynamicRemoteAccess: DynamicRemoteAccessStatus;
|
||||
id: Scalars['ID']['output'];
|
||||
settings: ConnectSettings;
|
||||
};
|
||||
|
||||
export type ConnectSettings = Node & {
|
||||
__typename?: 'ConnectSettings';
|
||||
dataSchema: Scalars['JSON']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
uiSchema: Scalars['JSON']['output'];
|
||||
values: ConnectSettingsValues;
|
||||
};
|
||||
|
||||
/** Intersection type of ApiSettings and RemoteAccess */
|
||||
export type ConnectSettingsValues = {
|
||||
__typename?: 'ConnectSettingsValues';
|
||||
accessType: WAN_ACCESS_TYPE;
|
||||
extraOrigins: Array<Scalars['String']['output']>;
|
||||
forwardType?: Maybe<WAN_FORWARD_TYPE>;
|
||||
port?: Maybe<Scalars['Port']['output']>;
|
||||
sandbox: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type ConnectSignInInput = {
|
||||
@@ -705,6 +744,7 @@ export type Mutation = {
|
||||
unmountArrayDisk?: Maybe<Disk>;
|
||||
/** Marks a notification as unread. */
|
||||
unreadNotification: Notification;
|
||||
updateApiSettings: ConnectSettingsValues;
|
||||
};
|
||||
|
||||
|
||||
@@ -839,6 +879,11 @@ export type MutationunreadNotificationArgs = {
|
||||
id: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationupdateApiSettingsArgs = {
|
||||
input: ApiSettingsInput;
|
||||
};
|
||||
|
||||
export type Network = Node & {
|
||||
__typename?: 'Network';
|
||||
accessUrls?: Maybe<Array<AccessUrl>>;
|
||||
@@ -1813,7 +1858,7 @@ export type DirectiveResolverFn<TResult = {}, TParent = {}, TContext = {}, TArgs
|
||||
|
||||
/** Mapping of interface types */
|
||||
export type ResolversInterfaceTypes<_RefType extends Record<string, unknown>> = ResolversObject<{
|
||||
Node: ( ArrayType ) | ( Config ) | ( Connect ) | ( Docker ) | ( Info ) | ( Network ) | ( Notification ) | ( Notifications ) | ( Service ) | ( Vars );
|
||||
Node: ( ArrayType ) | ( Config ) | ( Connect ) | ( ConnectSettings ) | ( Docker ) | ( Info ) | ( Network ) | ( Notification ) | ( Notifications ) | ( Service ) | ( Vars );
|
||||
UserAccount: ( Me ) | ( User );
|
||||
}>;
|
||||
|
||||
@@ -1828,6 +1873,7 @@ export type ResolversTypes = ResolversObject<{
|
||||
ApiKey: ResolverTypeWrapper<ApiKey>;
|
||||
ApiKeyResponse: ResolverTypeWrapper<ApiKeyResponse>;
|
||||
ApiKeyWithSecret: ResolverTypeWrapper<ApiKeyWithSecret>;
|
||||
ApiSettingsInput: ApiSettingsInput;
|
||||
Array: ResolverTypeWrapper<ArrayType>;
|
||||
ArrayCapacity: ResolverTypeWrapper<ArrayCapacity>;
|
||||
ArrayDisk: ResolverTypeWrapper<ArrayDisk>;
|
||||
@@ -1845,6 +1891,8 @@ export type ResolversTypes = ResolversObject<{
|
||||
Config: ResolverTypeWrapper<Config>;
|
||||
ConfigErrorState: ConfigErrorState;
|
||||
Connect: ResolverTypeWrapper<Connect>;
|
||||
ConnectSettings: ResolverTypeWrapper<ConnectSettings>;
|
||||
ConnectSettingsValues: ResolverTypeWrapper<ConnectSettingsValues>;
|
||||
ConnectSignInInput: ConnectSignInInput;
|
||||
ConnectUserInfoInput: ConnectUserInfoInput;
|
||||
ContainerHostConfig: ResolverTypeWrapper<ContainerHostConfig>;
|
||||
@@ -1958,6 +2006,7 @@ export type ResolversParentTypes = ResolversObject<{
|
||||
ApiKey: ApiKey;
|
||||
ApiKeyResponse: ApiKeyResponse;
|
||||
ApiKeyWithSecret: ApiKeyWithSecret;
|
||||
ApiSettingsInput: ApiSettingsInput;
|
||||
Array: ArrayType;
|
||||
ArrayCapacity: ArrayCapacity;
|
||||
ArrayDisk: ArrayDisk;
|
||||
@@ -1969,6 +2018,8 @@ export type ResolversParentTypes = ResolversObject<{
|
||||
CloudResponse: CloudResponse;
|
||||
Config: Config;
|
||||
Connect: Connect;
|
||||
ConnectSettings: ConnectSettings;
|
||||
ConnectSettingsValues: ConnectSettingsValues;
|
||||
ConnectSignInInput: ConnectSignInInput;
|
||||
ConnectUserInfoInput: ConnectUserInfoInput;
|
||||
ContainerHostConfig: ContainerHostConfig;
|
||||
@@ -2179,6 +2230,24 @@ export type ConfigResolvers<ContextType = Context, ParentType extends ResolversP
|
||||
export type ConnectResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Connect'] = ResolversParentTypes['Connect']> = ResolversObject<{
|
||||
dynamicRemoteAccess?: Resolver<ResolversTypes['DynamicRemoteAccessStatus'], ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
settings?: Resolver<ResolversTypes['ConnectSettings'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
}>;
|
||||
|
||||
export type ConnectSettingsResolvers<ContextType = Context, ParentType extends ResolversParentTypes['ConnectSettings'] = ResolversParentTypes['ConnectSettings']> = ResolversObject<{
|
||||
dataSchema?: Resolver<ResolversTypes['JSON'], ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
uiSchema?: Resolver<ResolversTypes['JSON'], ParentType, ContextType>;
|
||||
values?: Resolver<ResolversTypes['ConnectSettingsValues'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
}>;
|
||||
|
||||
export type ConnectSettingsValuesResolvers<ContextType = Context, ParentType extends ResolversParentTypes['ConnectSettingsValues'] = ResolversParentTypes['ConnectSettingsValues']> = ResolversObject<{
|
||||
accessType?: Resolver<ResolversTypes['WAN_ACCESS_TYPE'], ParentType, ContextType>;
|
||||
extraOrigins?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
forwardType?: Resolver<Maybe<ResolversTypes['WAN_FORWARD_TYPE']>, ParentType, ContextType>;
|
||||
port?: Resolver<Maybe<ResolversTypes['Port']>, ParentType, ContextType>;
|
||||
sandbox?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
}>;
|
||||
|
||||
@@ -2492,6 +2561,7 @@ export type MutationResolvers<ContextType = Context, ParentType extends Resolver
|
||||
unarchiveNotifications?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType, Partial<MutationunarchiveNotificationsArgs>>;
|
||||
unmountArrayDisk?: Resolver<Maybe<ResolversTypes['Disk']>, ParentType, ContextType, RequireFields<MutationunmountArrayDiskArgs, 'id'>>;
|
||||
unreadNotification?: Resolver<ResolversTypes['Notification'], ParentType, ContextType, RequireFields<MutationunreadNotificationArgs, 'id'>>;
|
||||
updateApiSettings?: Resolver<ResolversTypes['ConnectSettingsValues'], ParentType, ContextType, RequireFields<MutationupdateApiSettingsArgs, 'input'>>;
|
||||
}>;
|
||||
|
||||
export type NetworkResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Network'] = ResolversParentTypes['Network']> = ResolversObject<{
|
||||
@@ -2513,7 +2583,7 @@ export type NetworkResolvers<ContextType = Context, ParentType extends Resolvers
|
||||
}>;
|
||||
|
||||
export type NodeResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Node'] = ResolversParentTypes['Node']> = ResolversObject<{
|
||||
__resolveType: TypeResolveFn<'Array' | 'Config' | 'Connect' | 'Docker' | 'Info' | 'Network' | 'Notification' | 'Notifications' | 'Service' | 'Vars', ParentType, ContextType>;
|
||||
__resolveType: TypeResolveFn<'Array' | 'Config' | 'Connect' | 'ConnectSettings' | 'Docker' | 'Info' | 'Network' | 'Notification' | 'Notifications' | 'Service' | 'Vars', ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
}>;
|
||||
|
||||
@@ -3120,6 +3190,8 @@ export type Resolvers<ContextType = Context> = ResolversObject<{
|
||||
CloudResponse?: CloudResponseResolvers<ContextType>;
|
||||
Config?: ConfigResolvers<ContextType>;
|
||||
Connect?: ConnectResolvers<ContextType>;
|
||||
ConnectSettings?: ConnectSettingsResolvers<ContextType>;
|
||||
ConnectSettingsValues?: ConnectSettingsValuesResolvers<ContextType>;
|
||||
ContainerHostConfig?: ContainerHostConfigResolvers<ContextType>;
|
||||
ContainerMount?: ContainerMountResolvers<ContextType>;
|
||||
ContainerPort?: ContainerPortResolvers<ContextType>;
|
||||
|
||||
@@ -58,9 +58,73 @@ type DynamicRemoteAccessStatus {
|
||||
error: String
|
||||
}
|
||||
|
||||
"""
|
||||
Intersection type of ApiSettings and RemoteAccess
|
||||
"""
|
||||
type ConnectSettingsValues {
|
||||
"""
|
||||
If true, the GraphQL sandbox is enabled and available at /graphql.
|
||||
If false, the GraphQL sandbox is disabled and only the production API will be available.
|
||||
"""
|
||||
sandbox: Boolean!
|
||||
"""
|
||||
A list of origins allowed to interact with the API.
|
||||
"""
|
||||
extraOrigins: [String!]!
|
||||
"""
|
||||
The type of WAN access used for Remote Access.
|
||||
"""
|
||||
accessType: WAN_ACCESS_TYPE!
|
||||
"""
|
||||
The type of port forwarding used for Remote Access.
|
||||
"""
|
||||
forwardType: WAN_FORWARD_TYPE
|
||||
"""
|
||||
The port used for Remote Access.
|
||||
"""
|
||||
port: Port
|
||||
}
|
||||
|
||||
"""
|
||||
Input should be a subset of ApiSettings that can be updated.
|
||||
Some field combinations may be required or disallowed. Please refer to each field for more information.
|
||||
"""
|
||||
input ApiSettingsInput {
|
||||
"""
|
||||
If true, the GraphQL sandbox will be enabled and available at /graphql.
|
||||
If false, the GraphQL sandbox will be disabled and only the production API will be available.
|
||||
"""
|
||||
sandbox: Boolean
|
||||
"""
|
||||
A list of origins allowed to interact with the API.
|
||||
"""
|
||||
extraOrigins: [String!]
|
||||
"""
|
||||
The type of WAN access to use for Remote Access.
|
||||
"""
|
||||
accessType: WAN_ACCESS_TYPE
|
||||
"""
|
||||
The type of port forwarding to use for Remote Access.
|
||||
"""
|
||||
forwardType: WAN_FORWARD_TYPE
|
||||
"""
|
||||
The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType.
|
||||
Ignored if accessType is DISABLED or forwardType is UPNP.
|
||||
"""
|
||||
port: Port
|
||||
}
|
||||
|
||||
type ConnectSettings implements Node {
|
||||
id: ID!
|
||||
dataSchema: JSON!
|
||||
uiSchema: JSON!
|
||||
values: ConnectSettingsValues!
|
||||
}
|
||||
|
||||
type Connect implements Node {
|
||||
id: ID!
|
||||
dynamicRemoteAccess: DynamicRemoteAccessStatus!
|
||||
settings: ConnectSettings!
|
||||
}
|
||||
|
||||
type Query {
|
||||
@@ -75,4 +139,9 @@ type Mutation {
|
||||
enableDynamicRemoteAccess(input: EnableDynamicRemoteAccessInput!): Boolean!
|
||||
setAdditionalAllowedOrigins(input: AllowedOriginInput!): [String!]!
|
||||
setupRemoteAccess(input: SetupRemoteAccessInput!): Boolean!
|
||||
"""
|
||||
Update the API settings.
|
||||
Some setting combinations may be required or disallowed. Please refer to each setting for more information.
|
||||
"""
|
||||
updateApiSettings(input: ApiSettingsInput!): ConnectSettingsValues!
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
|
||||
import { UserSchema } from '@app/graphql/generated/api/operations.js';
|
||||
import { UserAccount } from '@app/graphql/generated/api/types.js';
|
||||
|
||||
export const GraphqlUser = createParamDecorator<null, any, UserAccount>(
|
||||
export const GraphqlUser = createParamDecorator<null, UserAccount>(
|
||||
(data: null, context: ExecutionContext): UserAccount => {
|
||||
if (context.getType<GqlContextType>() === 'graphql') {
|
||||
const ctx = GqlExecutionContext.create(context);
|
||||
|
||||
331
api/src/unraid-api/graph/connect/connect-settings.service.ts
Normal file
331
api/src/unraid-api/graph/connect/connect-settings.service.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { SchemaBasedCondition } from '@jsonforms/core';
|
||||
import { RuleEffect } from '@jsonforms/core';
|
||||
import { GraphQLError } from 'graphql/error/GraphQLError.js';
|
||||
|
||||
import type {
|
||||
ApiSettingsInput,
|
||||
ConnectSettingsValues,
|
||||
RemoteAccess,
|
||||
SetupRemoteAccessInput,
|
||||
} from '@app/graphql/generated/api/types.js';
|
||||
import type { DataSlice, SettingSlice, UIElement } from '@app/unraid-api/types/json-forms.js';
|
||||
import { fileExistsSync } from '@app/core/utils/files/file-exists.js';
|
||||
import {
|
||||
DynamicRemoteAccessType,
|
||||
WAN_ACCESS_TYPE,
|
||||
WAN_FORWARD_TYPE,
|
||||
} from '@app/graphql/generated/api/types.js';
|
||||
import { setupRemoteAccessThunk } from '@app/store/actions/setup-remote-access.js';
|
||||
import { updateAllowedOrigins, updateUserConfig } from '@app/store/modules/config.js';
|
||||
import { mergeSettingSlices } from '@app/unraid-api/types/json-forms.js';
|
||||
import { csvStringToArray } from '@app/utils.js';
|
||||
|
||||
@Injectable()
|
||||
export class ConnectSettingsService {
|
||||
isConnectPluginInstalled(): boolean {
|
||||
// logic ported from webguid
|
||||
return ['/var/lib/pkgtools/packages/dynamix.unraid.net', '/usr/local/sbin/unraid-api'].every(
|
||||
(path) => fileExistsSync(path)
|
||||
);
|
||||
}
|
||||
|
||||
async isSignedIn(): Promise<boolean> {
|
||||
if (!this.isConnectPluginInstalled()) return false;
|
||||
const { getters } = await import('@app/store/index.js');
|
||||
const { apikey } = getters.config().remote;
|
||||
return Boolean(apikey) && apikey.trim().length > 0;
|
||||
}
|
||||
|
||||
async isSSLCertProvisioned(): Promise<boolean> {
|
||||
const { getters } = await import('@app/store/index.js');
|
||||
const { nginx } = getters.emhttp();
|
||||
return nginx.certificateName.endsWith('.myunraid.net');
|
||||
}
|
||||
|
||||
/**------------------------------------------------------------------------
|
||||
* Settings Form Data
|
||||
*------------------------------------------------------------------------**/
|
||||
|
||||
async getCurrentSettings(): Promise<ConnectSettingsValues> {
|
||||
const { getters } = await import('@app/store/index.js');
|
||||
const { local, api } = getters.config();
|
||||
return {
|
||||
...(await this.dynamicRemoteAccessSettings()),
|
||||
sandbox: local.sandbox === 'yes',
|
||||
extraOrigins: csvStringToArray(api.extraOrigins),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs the settings to the store and writes the config to disk
|
||||
* @param settings - The settings to sync
|
||||
*/
|
||||
async syncSettings(settings: Partial<ApiSettingsInput>) {
|
||||
const { getters } = await import('@app/store/index.js');
|
||||
const { nginx } = getters.emhttp();
|
||||
if (settings.accessType === WAN_ACCESS_TYPE.DISABLED) {
|
||||
settings.port = null;
|
||||
}
|
||||
if (
|
||||
!nginx.sslEnabled &&
|
||||
settings.accessType === WAN_ACCESS_TYPE.DYNAMIC &&
|
||||
settings.forwardType === WAN_FORWARD_TYPE.STATIC
|
||||
) {
|
||||
throw new GraphQLError(
|
||||
'SSL must be provisioned and enabled for dynamic access and static port forwarding.'
|
||||
);
|
||||
}
|
||||
if (settings.accessType) {
|
||||
await this.updateRemoteAccess({
|
||||
accessType: settings.accessType,
|
||||
forwardType: settings.forwardType,
|
||||
port: settings.port,
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.extraOrigins) {
|
||||
await this.updateAllowedOrigins(settings.extraOrigins);
|
||||
}
|
||||
if (typeof settings.sandbox === 'boolean') {
|
||||
await this.setSandboxMode(settings.sandbox);
|
||||
}
|
||||
const { writeConfigSync } = await import('@app/store/sync/config-disk-sync.js');
|
||||
writeConfigSync('flash');
|
||||
}
|
||||
|
||||
private async updateAllowedOrigins(origins: string[]) {
|
||||
const { store } = await import('@app/store/index.js');
|
||||
store.dispatch(updateAllowedOrigins(origins));
|
||||
}
|
||||
|
||||
private async setSandboxMode(sandbox: boolean) {
|
||||
const { store } = await import('@app/store/index.js');
|
||||
store.dispatch(updateUserConfig({ local: { sandbox: sandbox ? 'yes' : 'no' } }));
|
||||
}
|
||||
|
||||
private async updateRemoteAccess(input: SetupRemoteAccessInput): Promise<boolean> {
|
||||
const { store } = await import('@app/store/index.js');
|
||||
await store.dispatch(setupRemoteAccessThunk(input)).unwrap();
|
||||
return true;
|
||||
}
|
||||
|
||||
private async dynamicRemoteAccessSettings(): Promise<Omit<RemoteAccess, '__typename'>> {
|
||||
const { getters } = await import('@app/store/index.js');
|
||||
const hasWanAccess = getters.config().remote.wanaccess === 'yes';
|
||||
return {
|
||||
accessType: hasWanAccess
|
||||
? getters.config().remote.dynamicRemoteAccessType !== DynamicRemoteAccessType.DISABLED
|
||||
? WAN_ACCESS_TYPE.DYNAMIC
|
||||
: WAN_ACCESS_TYPE.ALWAYS
|
||||
: WAN_ACCESS_TYPE.DISABLED,
|
||||
forwardType: getters.config().remote.upnpEnabled
|
||||
? WAN_FORWARD_TYPE.UPNP
|
||||
: WAN_FORWARD_TYPE.STATIC,
|
||||
port: getters.config().remote.wanport ? Number(getters.config().remote.wanport) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**------------------------------------------------------------------------
|
||||
* Settings Form Slices
|
||||
*------------------------------------------------------------------------**/
|
||||
|
||||
/**
|
||||
* Builds the complete settings schema
|
||||
*/
|
||||
async buildSettingsSchema(): Promise<SettingSlice> {
|
||||
const slices = [
|
||||
await this.remoteAccessSlice(),
|
||||
this.sandboxSlice(),
|
||||
this.flashBackupSlice(),
|
||||
// Because CORS is effectively disabled, this setting is no longer necessary
|
||||
// keeping it here for in case it needs to be re-enabled
|
||||
//
|
||||
// this.extraOriginsSlice(),
|
||||
];
|
||||
|
||||
return mergeSettingSlices(slices);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the JSONForms schema definition for remote access settings.
|
||||
*/
|
||||
async remoteAccessSlice(): Promise<SettingSlice> {
|
||||
const precondition = (await this.isSignedIn()) && (await this.isSSLCertProvisioned());
|
||||
|
||||
/** shown when preconditions are not met */
|
||||
const requirements: UIElement[] = [
|
||||
{
|
||||
type: 'Label',
|
||||
text: 'Allow Remote Access',
|
||||
options: {
|
||||
format: 'preconditions',
|
||||
description: 'Remote Access is disabled. To enable, please make sure:',
|
||||
items: [
|
||||
{
|
||||
text: 'You are signed in to Unraid Connect',
|
||||
status: await this.isSignedIn(),
|
||||
},
|
||||
{
|
||||
text: 'You have provisioned a valid SSL certificate',
|
||||
status: await this.isSSLCertProvisioned(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** shown when preconditions are met */
|
||||
const formControls: UIElement[] = [
|
||||
{
|
||||
type: 'Control',
|
||||
scope: '#/properties/accessType',
|
||||
label: 'Allow Remote Access',
|
||||
},
|
||||
{
|
||||
type: 'Control',
|
||||
scope: '#/properties/forwardType',
|
||||
label: 'Remote Access Forward Type',
|
||||
rule: {
|
||||
effect: RuleEffect.DISABLE,
|
||||
condition: {
|
||||
scope: '#/properties/accessType',
|
||||
schema: {
|
||||
enum: [WAN_ACCESS_TYPE.DISABLED],
|
||||
},
|
||||
} as SchemaBasedCondition,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'Control',
|
||||
scope: '#/properties/port',
|
||||
label: 'Remote Access WAN Port',
|
||||
options: {
|
||||
format: 'short',
|
||||
formatOptions: {
|
||||
useGrouping: false,
|
||||
},
|
||||
},
|
||||
rule: {
|
||||
effect: RuleEffect.SHOW,
|
||||
condition: {
|
||||
schema: {
|
||||
properties: {
|
||||
forwardType: {
|
||||
enum: [WAN_FORWARD_TYPE.STATIC],
|
||||
},
|
||||
accessType: {
|
||||
enum: [WAN_ACCESS_TYPE.DYNAMIC, WAN_ACCESS_TYPE.ALWAYS],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Omit<SchemaBasedCondition, 'scope'>,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** shape of the data associated with remote access settings, as json schema properties*/
|
||||
const properties: DataSlice = {
|
||||
accessType: {
|
||||
type: 'string',
|
||||
enum: Object.values(WAN_ACCESS_TYPE),
|
||||
title: 'Allow Remote Access',
|
||||
default: 'DISABLED',
|
||||
},
|
||||
forwardType: {
|
||||
type: 'string',
|
||||
enum: Object.values(WAN_FORWARD_TYPE),
|
||||
title: 'Forward Type',
|
||||
default: 'STATIC',
|
||||
},
|
||||
port: {
|
||||
type: 'number',
|
||||
title: 'WAN Port',
|
||||
minimum: 0,
|
||||
maximum: 65535,
|
||||
default: 0,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
properties,
|
||||
elements: precondition ? formControls : requirements,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Developer sandbox settings slice
|
||||
*/
|
||||
sandboxSlice(): SettingSlice {
|
||||
return {
|
||||
properties: {
|
||||
sandbox: {
|
||||
type: 'boolean',
|
||||
title: 'Enable Developer Sandbox',
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
elements: [
|
||||
{
|
||||
type: 'Control',
|
||||
scope: '#/properties/sandbox',
|
||||
label: 'Enable Developer Sandbox:',
|
||||
options: {
|
||||
toggle: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Flash backup settings slice
|
||||
*/
|
||||
flashBackupSlice(): SettingSlice {
|
||||
return {
|
||||
properties: {
|
||||
flashBackup: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['inactive', 'active', 'updating'],
|
||||
default: 'inactive',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
elements: [], // No UI elements needed for this system-managed setting
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra origins settings slice
|
||||
*/
|
||||
extraOriginsSlice(): SettingSlice {
|
||||
return {
|
||||
properties: {
|
||||
extraOrigins: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
format: 'url',
|
||||
},
|
||||
title: 'Unraid API extra origins',
|
||||
description: `Provide a comma separated list of urls that are allowed to access the unraid-api. \ne.g. https://abc.myreverseproxy.com`,
|
||||
},
|
||||
},
|
||||
elements: [
|
||||
{
|
||||
type: 'Control',
|
||||
scope: '#/properties/extraOrigins',
|
||||
options: {
|
||||
inputType: 'url',
|
||||
placeholder: 'https://example.com',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { ConnectResolver } from '@app/unraid-api/graph/connect/connect.resolver.js';
|
||||
|
||||
describe('ConnectResolver', () => {
|
||||
let resolver: ConnectResolver;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [ConnectResolver],
|
||||
}).compile();
|
||||
|
||||
resolver = module.get<ConnectResolver>(ConnectResolver);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(resolver).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -5,21 +5,22 @@ import { GraphQLError } from 'graphql';
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import type {
|
||||
ApiSettingsInput,
|
||||
ConnectResolvers,
|
||||
ConnectSettings,
|
||||
DynamicRemoteAccessStatus,
|
||||
EnableDynamicRemoteAccessInput,
|
||||
} from '@app/graphql/generated/api/types.js';
|
||||
import {
|
||||
ConnectResolvers,
|
||||
DynamicRemoteAccessType,
|
||||
Resource,
|
||||
} from '@app/graphql/generated/api/types.js';
|
||||
import { DynamicRemoteAccessType, Resource } from '@app/graphql/generated/api/types.js';
|
||||
import { RemoteAccessController } from '@app/remoteAccess/remote-access-controller.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { setAllowedRemoteAccessUrl } from '@app/store/modules/dynamic-remote-access.js';
|
||||
import { ConnectSettingsService } from '@app/unraid-api/graph/connect/connect-settings.service.js';
|
||||
|
||||
@Resolver('Connect')
|
||||
export class ConnectResolver implements ConnectResolvers {
|
||||
protected logger = new Logger(ConnectResolver.name);
|
||||
constructor(private readonly connectSettingsService: ConnectSettingsService) {}
|
||||
|
||||
@Query('connect')
|
||||
@UsePermissions({
|
||||
@@ -36,6 +37,35 @@ export class ConnectResolver implements ConnectResolvers {
|
||||
return 'connect';
|
||||
}
|
||||
|
||||
@ResolveField()
|
||||
public async settings(): Promise<ConnectSettings> {
|
||||
const { properties, elements } = await this.connectSettingsService.buildSettingsSchema();
|
||||
return {
|
||||
id: 'connectSettingsForm',
|
||||
dataSchema: {
|
||||
type: 'object',
|
||||
properties,
|
||||
},
|
||||
uiSchema: {
|
||||
type: 'VerticalLayout',
|
||||
elements,
|
||||
},
|
||||
values: await this.connectSettingsService.getCurrentSettings(),
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation()
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
resource: Resource.CONFIG,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async updateApiSettings(@Args('input') settings: ApiSettingsInput) {
|
||||
this.logger.verbose(`Attempting to update API settings: ${JSON.stringify(settings, null, 2)}`);
|
||||
await this.connectSettingsService.syncSettings(settings);
|
||||
return this.connectSettingsService.getCurrentSettings();
|
||||
}
|
||||
|
||||
@ResolveField()
|
||||
public dynamicRemoteAccess(): DynamicRemoteAccessStatus {
|
||||
return {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { GraphQLLong } from '@app/graphql/resolvers/graphql-type-long.js';
|
||||
import { loadTypeDefs } from '@app/graphql/schema/loadTypesDefs.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { ConnectSettingsService } from '@app/unraid-api/graph/connect/connect-settings.service.js';
|
||||
import { ConnectResolver } from '@app/unraid-api/graph/connect/connect.resolver.js';
|
||||
import { ConnectService } from '@app/unraid-api/graph/connect/connect.service.js';
|
||||
import { idPrefixPlugin } from '@app/unraid-api/graph/id-prefix-plugin.js';
|
||||
@@ -57,6 +58,13 @@ import { SharesResolver } from '@app/unraid-api/graph/shares/shares.resolver.js'
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [NetworkResolver, ServicesResolver, SharesResolver, ConnectResolver, ConnectService],
|
||||
providers: [
|
||||
NetworkResolver,
|
||||
ServicesResolver,
|
||||
SharesResolver,
|
||||
ConnectResolver,
|
||||
ConnectService,
|
||||
ConnectSettingsService,
|
||||
],
|
||||
})
|
||||
export class GraphModule {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '@app/unraid-api/auth/auth.module.js';
|
||||
import { ConnectSettingsService } from '@app/unraid-api/graph/connect/connect-settings.service.js';
|
||||
import { ApiKeyResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.resolver.js';
|
||||
import { ArrayResolver } from '@app/unraid-api/graph/resolvers/array/array.resolver.js';
|
||||
import { CloudResolver } from '@app/unraid-api/graph/resolvers/cloud/cloud.resolver.js';
|
||||
@@ -41,6 +42,7 @@ import { VmsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.resolver.js
|
||||
VmsResolver,
|
||||
NotificationsService,
|
||||
MeResolver,
|
||||
ConnectSettingsService,
|
||||
],
|
||||
exports: [AuthModule, ApiKeyResolver],
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { FastifyAdapter } from '@nestjs/platform-fastify/adapters';
|
||||
import { FastifyAdapter } from '@nestjs/platform-fastify/index.js';
|
||||
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyHelmet from '@fastify/helmet';
|
||||
|
||||
64
api/src/unraid-api/types/json-forms.ts
Normal file
64
api/src/unraid-api/types/json-forms.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type {
|
||||
Categorization,
|
||||
ComposableCondition,
|
||||
ControlElement,
|
||||
JsonSchema,
|
||||
LabelElement,
|
||||
Layout,
|
||||
LeafCondition,
|
||||
SchemaBasedCondition,
|
||||
UISchemaElement,
|
||||
} from '@jsonforms/core';
|
||||
|
||||
/**
|
||||
* JSON schema properties.
|
||||
*/
|
||||
export type DataSlice = Record<string, JsonSchema>;
|
||||
|
||||
/**
|
||||
* A JSONForms UI schema element.
|
||||
*/
|
||||
export type UIElement = UISchemaElement | LabelElement | Layout | ControlElement | Categorization;
|
||||
|
||||
/**
|
||||
* A condition for a JSONForms rule.
|
||||
*/
|
||||
export type RuleCondition =
|
||||
| LeafCondition
|
||||
| ComposableCondition
|
||||
| SchemaBasedCondition
|
||||
| Omit<SchemaBasedCondition, 'scope'>;
|
||||
|
||||
/**
|
||||
* A slice of settings form data.
|
||||
*/
|
||||
export type SettingSlice = {
|
||||
/** One or more JSON schema properties.
|
||||
* Conceptually, this is a subset (slice) of the JSON schema,
|
||||
* specific to a piece or logical group of data.
|
||||
*/
|
||||
properties: DataSlice;
|
||||
/** One or more UI schema elements that describe the form layout of this piece/subset of data. */
|
||||
elements: UIElement[];
|
||||
};
|
||||
|
||||
export function createEmptySettingSlice(): SettingSlice {
|
||||
return { properties: {}, elements: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduces multiple setting slices into a single slice
|
||||
*/
|
||||
function reduceSlices(slices: SettingSlice[]): SettingSlice {
|
||||
const result = createEmptySettingSlice();
|
||||
for (const slice of slices) {
|
||||
Object.assign(result.properties, slice.properties);
|
||||
result.elements.push(...slice.elements);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges multiple setting slices into a single, holistic slice.
|
||||
*/
|
||||
export const mergeSettingSlices = reduceSlices;
|
||||
@@ -1 +1 @@
|
||||
1741881521869
|
||||
1741960696861
|
||||
|
||||
@@ -1 +1 @@
|
||||
1741881520894
|
||||
1741960696132
|
||||
|
||||
@@ -1 +1 @@
|
||||
1741881521463
|
||||
1741960696408
|
||||
|
||||
@@ -1 +1 @@
|
||||
1741881522200
|
||||
1741960697108
|
||||
|
||||
@@ -245,3 +245,30 @@ export function handleAuthError(
|
||||
|
||||
throw new UnauthorizedException(`${operation}: ${errorMessage}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Comma Separated (CSV) string to an array of strings.
|
||||
*
|
||||
* @example
|
||||
* csvStringToArray('one,two,three') // ['one', 'two', 'three']
|
||||
* csvStringToArray('one, two, three') // ['one', 'two', 'three']
|
||||
* csvStringToArray(null) // []
|
||||
* csvStringToArray(undefined) // []
|
||||
* csvStringToArray('') // []
|
||||
*
|
||||
* @param csvString - The Comma Separated string to convert
|
||||
* @param opts - Options
|
||||
* @param opts.noEmpty - Whether to omit empty strings. Default is true.
|
||||
* @returns An array of strings
|
||||
*/
|
||||
export function csvStringToArray(
|
||||
csvString?: string | null,
|
||||
opts: { noEmpty?: boolean } = { noEmpty: true }
|
||||
): string[] {
|
||||
if (!csvString) return [];
|
||||
const result = csvString.split(',').map((item) => item.trim());
|
||||
if (opts.noEmpty) {
|
||||
return result.filter((item) => item !== '');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -486,143 +486,6 @@ $('body').on('click', '.js-setCurrentHostExtraOrigins', function(e) {
|
||||
});
|
||||
</script>
|
||||
|
||||
<form id="UnraidNetSettings" markdown="1" name="UnraidNetSettings" method="POST" action="/update.htm" target="progressFrame">
|
||||
<?
|
||||
/**
|
||||
* Allowed origins warning displayed when the current webGUI URL is NOT included in the known lists of allowed origins.
|
||||
* Include localhost in the test, but only display HTTP(S) URLs that do not include localhost.
|
||||
*/
|
||||
?>
|
||||
<? if ($serverState->combinedKnownOrigins): ?>
|
||||
<div markdown="1" class="<?=$shade?>"><!-- begin allowedOrigins warning -->
|
||||
<dl>
|
||||
<div style="margin-bottom: 2rem;">
|
||||
<span class="orange-text">
|
||||
<i class='fa fa-warning fa-fw'></i> <strong>_(Warning)_</strong></span>
|
||||
<?= sprintf(_('Your current url **%s** is not in the list of allowed origins for this server'), $serverState->host) ?>.
|
||||
<br/>
|
||||
_(For best results, add the current url to the **Unraid API extra origins** field below then apply the change)_.
|
||||
<br>
|
||||
<dd>
|
||||
<p>_(Known Origins)_:</p>
|
||||
<ul>
|
||||
<? foreach($serverState->combinedKnownOrigins as $origin): ?>
|
||||
<li><a href="<?= $origin ?>"><?= $origin ?></a></li>
|
||||
<? endforeach ?>
|
||||
</ul>
|
||||
<dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div><!-- end Account section -->
|
||||
<? endif ?>
|
||||
|
||||
<div markdown="1" class="<?=$shade?>"><!-- begin Remote Access section -->
|
||||
|
||||
_(Allow Remote Access)_:
|
||||
<?if(!$isRegistered): // NOTE: manually added close tags so the next section would not be indented ?>
|
||||
: <span><i class="fa fa-warning icon warning"></i> _(Disabled until you have signed in)_</span></dd></dl>
|
||||
<?elseif(!$isMiniGraphConnected && $myServersFlashCfg['remote']['wanaccess']!="yes"): // NOTE: manually added close tags so the next section would not be indented ?>
|
||||
: <span><i class="fa fa-warning icon warning"></i> _(Disabled until connected to Unraid Connect Cloud - try reloading the page)_</span></dd></dl>
|
||||
<?elseif(!$hasMyUnraidNetCert): // NOTE: manually added close tags so the next section would not be indented ?>
|
||||
: <span><i class="fa fa-warning icon warning"></i> _(Disabled until you Provision a myunraid.net SSL Cert)_</span><input type="hidden" id="wanport" value="0"></dd></dl>
|
||||
<?elseif(!$boolWebUIAuth): // NOTE: manually added close tags so the next section would not be indented ?>
|
||||
: <span><i class="fa fa-warning icon warning"></i> _(Disabled until your root user account is password-protected)_</span><input type="hidden" id="wanport" value="0"></dd></dl>
|
||||
<?else: // begin show remote access form ?>
|
||||
: <select id="remoteAccess" name="remoteAccess" data-orig="<?=$currentRemoteAccessValue?>" onchange="changeRemoteAccess(this)" style="vertical-align: top;">
|
||||
<?=mk_option($currentRemoteAccessValue, "OFF", _("Off"))?>
|
||||
<?=mk_option($currentRemoteAccessValue, "DYNAMIC_UPNP", _("Dynamic - UPnP"), $var['USE_UPNP'] === 'no' ? 'disabled' : '')?>
|
||||
<?=mk_option($currentRemoteAccessValue, "DYNAMIC_MANUAL", _("Dynamic - Manual Port Forward"), $var['USE_SSL'] !== 'auto' ? 'disabled' : '')?>
|
||||
<?=mk_option($currentRemoteAccessValue, "ALWAYS_UPNP", _("Always On - UPnP"), $var['USE_UPNP'] === 'no' ? 'disabled' : '')?>
|
||||
<?=mk_option($currentRemoteAccessValue, "ALWAYS_MANUAL", _("Always On - Manual Port Forward"))?>
|
||||
</select> <span id="remoteAccessMsg"></span>
|
||||
|
||||
<?if($var['USE_UPNP'] === 'no'):?>
|
||||
|
||||
: _(Remark: to use the UPnP options please set "Use UPnP" to "Yes" in Management Access.)_
|
||||
<?endif // end check for ($var['USE_UPNP']) ?>
|
||||
|
||||
<?if($var['USE_SSL'] !== 'auto'):?>
|
||||
|
||||
: _(Remark: to use the "Dynamic - Manual Port Forward" option for Remote Access please set "Use SSL/TLS" to "Strict" in Management Access.)_
|
||||
<?endif?>
|
||||
|
||||
|
||||
: <unraid-i18n-host><unraid-wan-ip-check php-wan-ip="<?=http_get_contents('https://wanip4.unraid.net/')?>"></unraid-wan-ip-check></unraid-i18n-host>
|
||||
|
||||
<div markdown="1" id="wanpanel" style="display:'none'">
|
||||
|
||||
_(WAN Port)_:
|
||||
: <input type="number" id="wanport" onchange="enableDisableCheckButton()" onkeyup="enableDisableCheckButton()" data-orig="<?=$myServersFlashCfg['remote']['wanport']?>" class="trim" min="0" max="65535" value="<?=htmlspecialchars($myServersFlashCfg['remote']['wanport'])?>"> <span id="wanportdisplay" style="display:'none'"><?=$myServersFlashCfg['remote']['wanport']?> </span> <button type="button" id="wancheck" onclick="dnsCheckServer(this)" style="margin-top: 0">_(Check)_</button>
|
||||
<span id="wanportmsg"><?=sprintf(_("Remark: configure your router with port forwarding of port") . " <strong>%u/TCP</strong> " . _("to") . " <strong>%s:%u</strong>", $myServersFlashCfg['remote']['wanport'], htmlspecialchars($eth0['IPADDR:0']??''), $var['PORTSSL']??443)?></span>
|
||||
|
||||
:unraidnet_wanpanel_help:
|
||||
|
||||
</div>
|
||||
<? /** for the time being only display remote T2FA field when enabled manually by Unraid developers */ ?>
|
||||
<?if($showT2Fa):?>
|
||||
<?
|
||||
$remoteT2faRemarks = [
|
||||
'all' => _('Remote T2FA requires Remote Access to be enabled and a *.myunraid.net certificate'),
|
||||
'needRemote' => _('Remote T2FA requires Remote Access to be enabled'),
|
||||
'needRemoteAndCert' => _('Remote T2FA requires Remote Access to be enabled and a *.myunraid.net certificate'),
|
||||
'needCert' => _('Remote T2FA requires a *.myunraid.net certificate'),
|
||||
];
|
||||
|
||||
if ($enableRemoteT2fa)
|
||||
$remoteT2faRemark = '';
|
||||
elseif ($currentRemoteAccessValue === 'OFF' && $hasMyUnraidNetCert)
|
||||
$remoteT2faRemark = $remoteT2faRemarks['needRemote'];
|
||||
elseif ($currentRemoteAccessValue === 'OFF' && !$hasMyUnraidNetCert)
|
||||
$remoteT2faRemark = $remoteT2faRemarks['needRemoteAndCert'];
|
||||
elseif ($currentRemoteAccessValue !== 'OFF' && !$hasMyUnraidNetCert)
|
||||
$remoteT2faRemark = $remoteT2faRemarks['needCert'];
|
||||
else
|
||||
$remoteT2faRemark = $remoteT2faRemarks['all'];
|
||||
|
||||
$localT2faRemarks = [
|
||||
'all' => _('Local T2FA requires Use SSL/TLS to be Strict and a *.myunraid.net certificate'),
|
||||
'needSSLAuto' => _('Local T2FA requires Use SSL/TLS to be Strict'),
|
||||
'needSSLAutoAndCert' => _('Local T2FA requires Use SSL/TLS to be Strict and a *.myunraid.net certificate'),
|
||||
'needCert' => _('Local T2FA requires a *.myunraid.net certificate'),
|
||||
];
|
||||
|
||||
if($enableLocalT2fa)
|
||||
$localT2faRemark = '';
|
||||
elseif ($var['USE_SSL'] !== 'auto' && $hasMyUnraidNetCert)
|
||||
$localT2faRemark = $localT2faRemarks['needSSLAuto'];
|
||||
elseif ($var['USE_SSL'] !== 'auto' && !$hasMyUnraidNetCert)
|
||||
$localT2faRemark = $localT2faRemarks['needSSLAutoAndCert'];
|
||||
elseif ($var['USE_SSL'] === 'auto' && !$hasMyUnraidNetCert)
|
||||
$localT2faRemark = $localT2faRemarks['needCert'];
|
||||
else
|
||||
$localT2faRemark = $localT2faRemarks['all'];
|
||||
?>
|
||||
|
||||
_(Enable Transparent 2FA for Remote Access)_<!-- do not index -->:
|
||||
: <select id="remote2fa" size="1" <?=($enableRemoteT2fa ? '' : 'disabled')?>>
|
||||
<?=mk_option($myServersFlashCfg['remote']['2Fa']??'', "no", _("No"))?>
|
||||
<?=mk_option($myServersFlashCfg['remote']['2Fa']??'', "yes", _("Yes"))?>
|
||||
</select> <span id="remote2fa_remark" style="display:<?=($enableRemoteT2fa ? 'none' : 'inline')?>;"><?=$remoteT2faRemark??''?></span>
|
||||
|
||||
:myservers_remote_t2fa_help:
|
||||
|
||||
_(Enable Transparent 2FA for Local Access)_<!-- do not index -->:
|
||||
: <select id="local2fa" size="1" <?=($enableLocalT2fa ? '' : 'disabled')?>>
|
||||
<?=mk_option($myServersFlashCfg['local']['2Fa']??'', "no", _("No"))?>
|
||||
<?=mk_option($myServersFlashCfg['local']['2Fa']??'', "yes", _("Yes"))?>
|
||||
</select> <span id="local2fa_remark" style="display:<?=($enableLocalT2fa ? 'none' : 'inline')?>;"><?=$localT2faRemark??''?></span>
|
||||
|
||||
:myservers_local_t2fa_help:
|
||||
<?endif // end check for showT2Fa ?>
|
||||
|
||||
|
||||
: <button class="applyBtn" type="button" onclick="registerServer(this)" disabled="disabled">_(Apply)_</button> <span id="useConnectMsg"></span>
|
||||
|
||||
<?endif // end check for (!$boolWebUIAuth) ?>
|
||||
|
||||
</div><!-- end Remote Access section -->
|
||||
</form>
|
||||
|
||||
<div markdown="1" class="<?=$shade?>"><!-- begin Flash Backup section -->
|
||||
_(Flash backup)_:
|
||||
<?if(!$isRegistered):?>
|
||||
@@ -702,36 +565,5 @@ $(function() {
|
||||
</div><!-- end Flash Backup section -->
|
||||
|
||||
<!-- start unraid-api section -->
|
||||
<div markdown="1" class="js-unraidApiLogs <?=$shade?>">
|
||||
|
||||
: <span>_(Questions? See <a href="https://docs.unraid.net/go/connect/" target="_blank">the documentation</a>.)_</span>
|
||||
|
||||
_(Account status)_:
|
||||
: <unraid-i18n-host><unraid-auth></unraid-auth></unraid-i18n-host>
|
||||
|
||||
<!-- start extra origins -->
|
||||
<span id="extraOriginsSettings" class="js-extraOriginsLabel">_(Unraid API extra origins)_:</span>
|
||||
: <input class="js-extraOrigins" name="extraOrigins" type="text" value="<?=$myServersFlashCfg['api']['extraOrigins']??''?>">
|
||||
|
||||
:unraidnet_extraorigins_help:
|
||||
|
||||
|
||||
: <div>
|
||||
<? if ($serverState->combinedKnownOrigins): ?> <button class="js-setCurrentHostExtraOrigins" href="#extraOriginsSettings">_(Add Current Origin)_</button><? endif ?> <button class="js-extraOriginsApply" type="button" onclick="applyExtraOrigins(this)" disabled="disabled">_(Apply)_</button>
|
||||
</div>
|
||||
<!-- // end extra origins -->
|
||||
|
||||
|
||||
<?if($isRegistered):?>
|
||||
_(Connected to Unraid Connect Cloud)_:
|
||||
<?if($isMiniGraphConnected):?>
|
||||
: _(Yes)_
|
||||
<?else:?>
|
||||
: <i class="fa fa-warning icon warning"></i> _(No)_
|
||||
<?endif // end check for ($isMiniGraphConnected) ?>
|
||||
<?endif // end check for ($isRegistered) ?>
|
||||
|
||||
_(Download unraid-api Logs)_:
|
||||
: <unraid-i18n-host><unraid-download-api-logs></unraid-download-api-logs></unraid-i18n-host>
|
||||
</div>
|
||||
<unraid-i18n-host><unraid-connect-settings></unraid-connect-settings></unraid-i18n-host>
|
||||
<!-- end unraid-api section -->
|
||||
|
||||
1094
pnpm-lock.yaml
generated
1094
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -40,12 +40,17 @@
|
||||
"dependencies": {
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@heroicons/vue": "^2.2.0",
|
||||
"@internationalized/number": "^3.6.0",
|
||||
"@jsonforms/core": "^3.5.1",
|
||||
"@jsonforms/vue": "^3.5.1",
|
||||
"@jsonforms/vue-vanilla": "^3.5.1",
|
||||
"@vueuse/core": "^12.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"kebab-case": "^2.0.1",
|
||||
"lucide-vue-next": "^0.475.0",
|
||||
"radix-vue": "^1.9.13",
|
||||
"reka-ui": "^2.0.2",
|
||||
"shadcn-vue": "^0.11.3",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"vue-sonner": "^1.3.0"
|
||||
@@ -81,7 +86,7 @@
|
||||
"tailwind-rem-to-rem": "github:unraid/tailwind-rem-to-rem",
|
||||
"tailwindcss": "^3.0.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-dts": "^3.0.0",
|
||||
"vite-plugin-vue-devtools": "^7.7.1",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { brandButtonVariants } from './brand-button.variants';
|
||||
import { type BrandButtonVariants, brandButtonVariants } from './brand-button.variants';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface BrandButtonProps {
|
||||
variant?: 'fill' | 'black' | 'gray' | 'outline' | 'outline-black' | 'outline-white' | 'underline' | 'underline-hover-red' | 'white' | 'none';
|
||||
size?: '12px' | '14px' | '16px' | '18px' | '20px' | '24px';
|
||||
padding?: 'default' | 'none';
|
||||
variant?: BrandButtonVariants['variant'];
|
||||
size?: BrandButtonVariants['size'];
|
||||
padding?: BrandButtonVariants['padding'];
|
||||
btnType?: 'button' | 'submit' | 'reset';
|
||||
class?: string;
|
||||
click?: () => void;
|
||||
@@ -47,6 +47,9 @@ const classes = computed(() => {
|
||||
icon: `${iconSize} fill-current flex-shrink-0`,
|
||||
};
|
||||
});
|
||||
const needsBrandGradientBackground = computed(() => {
|
||||
return ['outline', 'outline-primary'].includes(props.variant ?? '');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -65,8 +68,9 @@ const classes = computed(() => {
|
||||
v-if="variant === 'fill'"
|
||||
class="absolute -top-[2px] -right-[2px] -bottom-[2px] -left-[2px] -z-10 bg-gradient-to-r from-unraid-red to-orange opacity-100 transition-all rounded-md group-hover:opacity-60 group-focus:opacity-60"
|
||||
/>
|
||||
<!-- gives outline buttons the brand gradient background -->
|
||||
<div
|
||||
v-if="variant === 'outline'"
|
||||
v-if="needsBrandGradientBackground"
|
||||
class="absolute -top-[2px] -right-[2px] -bottom-[2px] -left-[2px] -z-10 bg-gradient-to-r from-unraid-red to-orange opacity-0 transition-all rounded-md group-hover:opacity-100 group-focus:opacity-100"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cva } from "class-variance-authority";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
export const brandButtonVariants = cva(
|
||||
"group text-center font-semibold leading-none relative z-0 flex flex-row items-center justify-center border-2 border-solid shadow-none cursor-pointer rounded-md hover:shadow-md focus:shadow-md disabled:opacity-25 disabled:hover:opacity-25 disabled:focus:opacity-25 disabled:cursor-not-allowed",
|
||||
@@ -9,6 +9,7 @@ export const brandButtonVariants = cva(
|
||||
black: "[&]:text-white bg-black border-black transition hover:text-black focus:text-black hover:bg-grey focus:bg-grey hover:border-grey focus:border-grey",
|
||||
gray: "text-black bg-grey transition hover:text-white focus:text-white hover:bg-grey-mid focus:bg-grey-mid hover:border-grey-mid focus:border-grey-mid",
|
||||
outline: "[&]:text-orange bg-transparent border-orange hover:text-white focus:text-white",
|
||||
"outline-primary": "text-primary [&]:text-primary uppercase tracking-widest bg-transparent border-primary rounded-sm hover:text-white focus:text-white",
|
||||
"outline-black": "text-black bg-transparent border-black hover:text-black focus:text-black hover:bg-grey focus:bg-grey hover:border-grey focus:border-grey",
|
||||
"outline-white": "text-white bg-transparent border-white hover:text-black focus:text-black hover:bg-white focus:bg-white",
|
||||
underline: "opacity-75 underline border-transparent transition hover:text-primary hover:bg-muted hover:border-muted focus:text-primary focus:bg-muted focus:border-muted hover:opacity-100 focus:opacity-100",
|
||||
@@ -27,6 +28,7 @@ export const brandButtonVariants = cva(
|
||||
padding: {
|
||||
default: "",
|
||||
none: "p-0",
|
||||
lean: "px-4 py-2",
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
@@ -67,4 +69,6 @@ export const brandButtonVariants = cva(
|
||||
padding: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
);
|
||||
|
||||
export type BrandButtonVariants = VariantProps<typeof brandButtonVariants>;
|
||||
|
||||
23
unraid-ui/src/components/form/number/NumberField.vue
Normal file
23
unraid-ui/src/components/form/number/NumberField.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { NumberFieldRootEmits, NumberFieldRootProps } from 'reka-ui';
|
||||
import { NumberFieldRoot, useForwardPropsEmits } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
const props = defineProps<NumberFieldRootProps & { class?: HTMLAttributes['class'] }>();
|
||||
const emits = defineEmits<NumberFieldRootEmits>();
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props;
|
||||
|
||||
return delegated;
|
||||
});
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NumberFieldRoot v-bind="forwarded" :class="cn('grid gap-1.5 relative', props.class)">
|
||||
<slot />
|
||||
</NumberFieldRoot>
|
||||
</template>
|
||||
22
unraid-ui/src/components/form/number/NumberFieldContent.vue
Normal file
22
unraid-ui/src/components/form/number/NumberFieldContent.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class'];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Add corresponding padding when increment/decrement buttons are present -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative [&>[data-slot=input]]:has-[[data-slot=increment]]:pr-5 [&>[data-slot=input]]:has-[[data-slot=decrement]]:pl-5',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Minus } from 'lucide-vue-next';
|
||||
import type { NumberFieldDecrementProps } from 'reka-ui';
|
||||
import { NumberFieldDecrement as RekaNumberFieldDecrement, useForwardProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
const props = defineProps<NumberFieldDecrementProps & { class?: HTMLAttributes['class'] }>();
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props;
|
||||
|
||||
return delegated;
|
||||
});
|
||||
|
||||
const forwarded = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RekaNumberFieldDecrement
|
||||
data-slot="decrement"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'absolute top-1/2 -translate-y-1/2 left-0 p-3 disabled:cursor-not-allowed disabled:opacity-20',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot>
|
||||
<Minus class="h-4 w-4" />
|
||||
</slot>
|
||||
</RekaNumberFieldDecrement>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Plus } from 'lucide-vue-next';
|
||||
import type { NumberFieldIncrementProps } from 'reka-ui';
|
||||
import { NumberFieldIncrement as RekaNumberFieldIncrement, useForwardProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
const props = defineProps<NumberFieldIncrementProps & { class?: HTMLAttributes['class'] }>();
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props;
|
||||
|
||||
return delegated;
|
||||
});
|
||||
|
||||
const forwarded = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RekaNumberFieldIncrement
|
||||
data-slot="increment"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'absolute top-1/2 -translate-y-1/2 right-0 disabled:cursor-not-allowed disabled:opacity-20 p-3',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot>
|
||||
<Plus class="h-4 w-4" />
|
||||
</slot>
|
||||
</RekaNumberFieldIncrement>
|
||||
</template>
|
||||
21
unraid-ui/src/components/form/number/NumberFieldInput.vue
Normal file
21
unraid-ui/src/components/form/number/NumberFieldInput.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { NumberFieldInput as RekaNumberFieldInput } from 'reka-ui';
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class'];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RekaNumberFieldInput
|
||||
data-slot="input"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background py-2 text-sm text-center ring-offset-background 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
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
5
unraid-ui/src/components/form/number/index.ts
Normal file
5
unraid-ui/src/components/form/number/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as NumberField } from './NumberField.vue';
|
||||
export { default as NumberFieldContent } from './NumberFieldContent.vue';
|
||||
export { default as NumberFieldDecrement } from './NumberFieldDecrement.vue';
|
||||
export { default as NumberFieldIncrement } from './NumberFieldIncrement.vue';
|
||||
export { default as NumberFieldInput } from './NumberFieldInput.vue';
|
||||
33
unraid-ui/src/forms/ControlLayout.vue
Normal file
33
unraid-ui/src/forms/ControlLayout.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Label } from '@/components/form/label';
|
||||
|
||||
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></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>
|
||||
56
unraid-ui/src/forms/NumberField.vue
Normal file
56
unraid-ui/src/forms/NumberField.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import type { ControlElement } from '@jsonforms/core';
|
||||
import type { RendererProps } from '@jsonforms/vue';
|
||||
import { useJsonFormsControl } from '@jsonforms/vue';
|
||||
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '@/components/form/number';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import ControlLayout from './ControlLayout.vue';
|
||||
|
||||
const props = defineProps<RendererProps<ControlElement>>();
|
||||
const { control, handleChange } = useJsonFormsControl(props);
|
||||
|
||||
// Bind the number field's value to JSONForms data
|
||||
const value = computed({
|
||||
get: () => control.value.data ?? control.value.schema.default,
|
||||
set: (newValue: number) => handleChange(control.value.path, newValue),
|
||||
});
|
||||
// Extract schema-based constraints (optional settings)
|
||||
const min = computed(() => control.value.schema.minimum);
|
||||
const max = computed(() => control.value.schema.maximum);
|
||||
const step = computed(() => control.value.schema.multipleOf ?? 1);
|
||||
const stepperEnabled = computed(() => Boolean(control.value.uischema?.options?.stepper));
|
||||
const formatOptions = computed(() => control.value.uischema?.options?.formatOptions || {});
|
||||
const classOverride = computed(() => {
|
||||
return cn(control.value.uischema?.options?.class, {
|
||||
'max-w-[25ch]': control.value.uischema?.options?.format === 'short',
|
||||
});
|
||||
});
|
||||
</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"
|
||||
>
|
||||
<NumberFieldDecrement v-if="stepperEnabled" />
|
||||
<NumberFieldInput />
|
||||
<NumberFieldIncrement v-if="stepperEnabled" />
|
||||
</NumberField>
|
||||
</ControlLayout>
|
||||
</template>
|
||||
45
unraid-ui/src/forms/PreconditionsLabel.vue
Normal file
45
unraid-ui/src/forms/PreconditionsLabel.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import type { LabelElement } from '@jsonforms/core';
|
||||
import type { RendererProps } from '@jsonforms/vue';
|
||||
|
||||
import ControlLayout from './ControlLayout.vue';
|
||||
|
||||
type PreconditionsLabelElement = LabelElement & {
|
||||
options: {
|
||||
format: 'preconditions';
|
||||
/** A description of the setting this element represents, and context for the preconditions. */
|
||||
description?: string;
|
||||
items: {
|
||||
/** The text to display in the list item, representing each precondition. e.g. "API is enabled" */
|
||||
text: string;
|
||||
/** Whether the precondition is met. */
|
||||
status: boolean;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
// The renderer expects the uischema element to have a `text` property
|
||||
// and `options.items` which is an array of precondition items.
|
||||
// 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>
|
||||
</template>
|
||||
65
unraid-ui/src/forms/Select.vue
Normal file
65
unraid-ui/src/forms/Select.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectItemText,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/form/select';
|
||||
import useTeleport from '@/composables/useTeleport';
|
||||
import { useJsonFormsControl } from '@jsonforms/vue';
|
||||
|
||||
import type { ControlElement } from '@jsonforms/core';
|
||||
import type { RendererProps } from '@jsonforms/vue';
|
||||
|
||||
import ControlLayout from './ControlLayout.vue';
|
||||
|
||||
const props = defineProps<RendererProps<ControlElement>>();
|
||||
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) => ({
|
||||
value,
|
||||
label: value,
|
||||
}));
|
||||
});
|
||||
|
||||
const onChange = (value: string) => {
|
||||
handleChange(control.value.path, value);
|
||||
};
|
||||
|
||||
// Without this, the select dropdown will not be visible, unless it's already in a teleported context.
|
||||
const { teleportTarget, determineTeleportTarget } = useTeleport();
|
||||
const onSelectOpen = () => {
|
||||
determineTeleportTarget();
|
||||
};
|
||||
</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">
|
||||
<SelectItemText>{{ option.label }}</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</ControlLayout>
|
||||
</template>
|
||||
72
unraid-ui/src/forms/StringArrayField.vue
Normal file
72
unraid-ui/src/forms/StringArrayField.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Button } from '@/components/common/button';
|
||||
import { Input } from '@/components/form/input';
|
||||
import { useJsonFormsControl } from '@jsonforms/vue';
|
||||
import type { ControlElement } from '@jsonforms/core';
|
||||
import type { RendererProps } from '@jsonforms/vue';
|
||||
|
||||
import ControlLayout from './ControlLayout.vue';
|
||||
|
||||
const props = defineProps<RendererProps<ControlElement>>();
|
||||
const { control, handleChange } = useJsonFormsControl(props);
|
||||
|
||||
const items = computed({
|
||||
get: () => {
|
||||
const data = control.value.data ?? [];
|
||||
return Array.isArray(data) ? data : [];
|
||||
},
|
||||
set: (newValue: string[]) => {
|
||||
handleChange(control.value.path, newValue);
|
||||
},
|
||||
});
|
||||
|
||||
const addItem = () => {
|
||||
items.value = [...items.value, ''];
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
const newItems = [...items.value];
|
||||
newItems.splice(index, 1);
|
||||
items.value = newItems;
|
||||
};
|
||||
|
||||
const updateItem = (index: number, newValue: string) => {
|
||||
const newItems = [...items.value];
|
||||
newItems[index] = newValue;
|
||||
items.value = newItems;
|
||||
};
|
||||
|
||||
const inputType = computed(() => control.value.uischema?.options?.inputType ?? 'text');
|
||||
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">{{ control.description }}</p>
|
||||
<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"
|
||||
:disabled="!control.enabled"
|
||||
@click="addItem"
|
||||
>
|
||||
Add Item
|
||||
</Button>
|
||||
</div>
|
||||
</ControlLayout>
|
||||
</template>
|
||||
28
unraid-ui/src/forms/Switch.vue
Normal file
28
unraid-ui/src/forms/Switch.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { Switch as UuiSwitch } from '@/components/form/switch';
|
||||
import { useJsonFormsControl } from '@jsonforms/vue';
|
||||
|
||||
import type { ControlElement } from '@jsonforms/core';
|
||||
import type { RendererProps } from '@jsonforms/vue';
|
||||
|
||||
import ControlLayout from './ControlLayout.vue';
|
||||
|
||||
const props = defineProps<RendererProps<ControlElement>>();
|
||||
const { control, handleChange } = useJsonFormsControl(props);
|
||||
const onChange = (checked: boolean) => {
|
||||
handleChange(control.value.path, checked);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ControlLayout v-if="control.visible" :label="control.label" :errors="control.errors">
|
||||
<UuiSwitch
|
||||
:id="control.id + '-input'"
|
||||
:name="control.path"
|
||||
:disabled="!control.enabled"
|
||||
:required="control.required"
|
||||
:checked="Boolean(control.data)"
|
||||
@update:checked="onChange"
|
||||
/>
|
||||
</ControlLayout>
|
||||
</template>
|
||||
44
unraid-ui/src/forms/VerticalLayout.vue
Normal file
44
unraid-ui/src/forms/VerticalLayout.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* VerticalLayout component
|
||||
*
|
||||
* Renders form elements in a vertical 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 { Label } from '@/components/form/label';
|
||||
import { DispatchRenderer, type RendererProps } from '@jsonforms/vue';
|
||||
import { computed } from 'vue';
|
||||
import type { VerticalLayout } from '@jsonforms/core';
|
||||
|
||||
|
||||
const props = defineProps<RendererProps<VerticalLayout>>();
|
||||
|
||||
const elements = computed(() => {
|
||||
return props.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>
|
||||
<DispatchRenderer
|
||||
class="ml-10"
|
||||
:schema="props.schema"
|
||||
:uischema="element"
|
||||
:path="props.path"
|
||||
:enabled="props.enabled"
|
||||
:renderers="props.renderers"
|
||||
:cells="props.cells"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
7
unraid-ui/src/forms/jsonforms.d.ts
vendored
Normal file
7
unraid-ui/src/forms/jsonforms.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import '@jsonforms/core';
|
||||
|
||||
declare module '@jsonforms/core' {
|
||||
export interface UISchemaElement {
|
||||
label?: string;
|
||||
}
|
||||
}
|
||||
3
unraid-ui/src/forms/readme.md
Normal file
3
unraid-ui/src/forms/readme.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# `@unraid/ui/forms`
|
||||
|
||||
JSONForms wrappers & renderers.
|
||||
50
unraid-ui/src/forms/renderer-entries.ts
Normal file
50
unraid-ui/src/forms/renderer-entries.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
and,
|
||||
isBooleanControl,
|
||||
isControl,
|
||||
isEnumControl,
|
||||
isIntegerControl,
|
||||
isNumberControl,
|
||||
optionIs,
|
||||
or,
|
||||
rankWith,
|
||||
schemaMatches,
|
||||
uiTypeIs,
|
||||
} from '@jsonforms/core';
|
||||
import type { JsonFormsRendererRegistryEntry, JsonSchema } from '@jsonforms/core';
|
||||
import numberFieldRenderer from './NumberField.vue';
|
||||
import PreconditionsLabel from './PreconditionsLabel.vue';
|
||||
import selectRenderer from './Select.vue';
|
||||
import StringArrayField from './StringArrayField.vue';
|
||||
import switchRenderer from './Switch.vue';
|
||||
|
||||
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'))),
|
||||
};
|
||||
24
unraid-ui/src/forms/renderers.ts
Normal file
24
unraid-ui/src/forms/renderers.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { vanillaRenderers } from '@jsonforms/vue-vanilla';
|
||||
import {
|
||||
formSelectEntry,
|
||||
formSwitchEntry,
|
||||
numberFieldEntry,
|
||||
preconditionsLabelEntry,
|
||||
stringArrayEntry,
|
||||
} from './renderer-entries';
|
||||
|
||||
/**
|
||||
* 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,
|
||||
];
|
||||
8
unraid-ui/src/forms/vertical-layout.renderer.ts
Normal file
8
unraid-ui/src/forms/vertical-layout.renderer.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { and, isLayout, rankWith, uiTypeIs } from '@jsonforms/core';
|
||||
import type { JsonFormsRendererRegistryEntry } from '@jsonforms/core';
|
||||
import VerticalLayout from './VerticalLayout.vue';
|
||||
|
||||
export const verticalLayoutEntry: JsonFormsRendererRegistryEntry = {
|
||||
renderer: VerticalLayout,
|
||||
tester: rankWith(2, and(isLayout, uiTypeIs('VerticalLayout'))),
|
||||
};
|
||||
@@ -156,3 +156,5 @@ export {
|
||||
};
|
||||
export { Toaster } from '@/components/common/toast';
|
||||
export * from '@/components/common/popover';
|
||||
export * from '@/components/form/number';
|
||||
export * from '@/forms/renderers';
|
||||
|
||||
@@ -20,6 +20,9 @@ export const unraidPreset = {
|
||||
fontFamily: {
|
||||
sans: 'clear-sans,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji',
|
||||
},
|
||||
gridTemplateColumns: {
|
||||
'settings': '35% 1fr',
|
||||
},
|
||||
colors: {
|
||||
inherit: 'inherit',
|
||||
transparent: 'transparent',
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"files": ["tailwind.config.ts"],
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*/*.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
|
||||
@@ -3,4 +3,4 @@ VITE_CONNECT=https://connect.myunraid.net
|
||||
VITE_UNRAID_NET=https://unraid.net
|
||||
VITE_CALLBACK_KEY=Uyv2o8e*FiQe8VeLekTqyX6Z*8XonB
|
||||
# Keep console logs until components are stabilized
|
||||
VITE_ALLOW_CONSOLE_LOGS=true
|
||||
VITE_ALLOW_CONSOLE_LOGS=true
|
||||
|
||||
@@ -11,7 +11,7 @@ const config: CodegenConfig = {
|
||||
scalars: {
|
||||
DateTime: 'string',
|
||||
Long: 'number',
|
||||
JSON: 'string',
|
||||
JSON: 'any',
|
||||
URL: 'URL',
|
||||
Port: 'number',
|
||||
UUID: 'string',
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { BrandButton, Input } from '@unraid/ui';
|
||||
|
||||
import { useUnraidApiSettingsStore } from '~/store/unraidApiSettings';
|
||||
|
||||
const apiSettingsStore = useUnraidApiSettingsStore();
|
||||
@@ -34,12 +36,14 @@ const setAllowedOrigins = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<h2>Setup Allowed Origins</h2>
|
||||
<input v-model="originsText" type="text" placeholder="Input Comma Separated List of URLs">
|
||||
<button type="button" @click="setAllowedOrigins()">
|
||||
Set Allowed Origins
|
||||
</button>
|
||||
<div class="space-y-3 max-w-4xl">
|
||||
<Input
|
||||
v-model="originsText"
|
||||
type="text"
|
||||
placeholder="https://abc.myreverseproxy.com,https://xyz.rvrsprx.com"
|
||||
/>
|
||||
<BrandButton type="button" variant="outline" text="Apply" @click="setAllowedOrigins()">
|
||||
</BrandButton>
|
||||
<div v-for="(error, index) of errors" :key="index">
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
|
||||
@@ -2,13 +2,137 @@
|
||||
// import { useI18n } from 'vue-i18n';
|
||||
|
||||
// const { t } = useI18n();
|
||||
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
|
||||
import { BrandButton, Label, jsonFormsRenderers } from '@unraid/ui';
|
||||
import { JsonForms } from '@jsonforms/vue';
|
||||
|
||||
import type { ConnectSettingsValues } from '~/composables/gql/graphql';
|
||||
|
||||
import { getConnectSettingsForm, updateConnectSettings } from './graphql/settings.query';
|
||||
|
||||
/**--------------------------------------------
|
||||
* Settings State & Form definition
|
||||
*---------------------------------------------**/
|
||||
|
||||
const formState = ref<Partial<ConnectSettingsValues>>({});
|
||||
const { result } = useQuery(getConnectSettingsForm);
|
||||
const settings = computed(() => {
|
||||
if (!result.value) return;
|
||||
return result.value?.connect.settings;
|
||||
});
|
||||
watch(result, () => {
|
||||
if (!result.value) return;
|
||||
const { __typename, ...initialValues } = result.value.connect.settings.values;
|
||||
formState.value = initialValues;
|
||||
});
|
||||
|
||||
/**--------------------------------------------
|
||||
* Update Settings Actions
|
||||
*---------------------------------------------**/
|
||||
|
||||
const {
|
||||
mutate: mutateSettings,
|
||||
loading: mutateSettingsLoading,
|
||||
error: mutateSettingsError,
|
||||
onDone: onMutateSettingsDone,
|
||||
} = useMutation(updateConnectSettings);
|
||||
|
||||
const isUpdating = ref(false);
|
||||
|
||||
// prevent ui flash if loading finishes too fast
|
||||
watchDebounced(
|
||||
mutateSettingsLoading,
|
||||
(loading) => {
|
||||
isUpdating.value = loading;
|
||||
},
|
||||
{
|
||||
debounce: 100,
|
||||
}
|
||||
);
|
||||
|
||||
// show a toast when the update is done
|
||||
onMutateSettingsDone(() => {
|
||||
globalThis.toast.success('Updated API Settings');
|
||||
});
|
||||
|
||||
/**--------------------------------------------
|
||||
* Form Config & Actions
|
||||
*---------------------------------------------**/
|
||||
|
||||
const jsonFormsConfig = {
|
||||
restrict: false,
|
||||
trim: false,
|
||||
};
|
||||
|
||||
const renderers = [
|
||||
...jsonFormsRenderers,
|
||||
];
|
||||
|
||||
/** Called when the user clicks the "Apply" button */
|
||||
const submitSettingsUpdate = async () => {
|
||||
console.log('[ConnectSettings] trying to update settings to', formState.value);
|
||||
await mutateSettings({ input: formState.value });
|
||||
};
|
||||
|
||||
/** Called whenever a JSONForms form control changes */
|
||||
const onChange = ({ data }: { data: Record<string, unknown> }) => {
|
||||
formState.value = data;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthCe />
|
||||
<!-- @todo: flashback up -->
|
||||
<WanIpCheckCe />
|
||||
<ConnectSettingsRemoteAccess />
|
||||
<ConnectSettingsAllowedOrigins />
|
||||
<DownloadApiLogsCe />
|
||||
<!-- common api-related actions -->
|
||||
<div
|
||||
class="grid grid-cols-settings items-baseline pl-3 gap-y-6 [&>*:nth-child(odd)]:text-end [&>*:nth-child(even)]:ml-10"
|
||||
>
|
||||
<Label>Account Status:</Label>
|
||||
<div v-html="'<unraid-i18n-host><unraid-auth></unraid-auth></unraid-i18n-host>'"></div>
|
||||
<Label>Download Unraid API Logs:</Label>
|
||||
<div
|
||||
v-html="
|
||||
'<unraid-i18n-host><unraid-download-api-logs></unraid-download-api-logs></unraid-i18n-host>'
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
<!-- auto-generated settings form -->
|
||||
<div class="mt-6 pl-3 [&_.vertical-layout]:space-y-6">
|
||||
<JsonForms
|
||||
v-if="settings"
|
||||
:schema="settings.dataSchema"
|
||||
:uischema="settings.uiSchema"
|
||||
:renderers="renderers"
|
||||
:data="formState"
|
||||
:config="jsonFormsConfig"
|
||||
:readonly="isUpdating"
|
||||
@change="onChange"
|
||||
/>
|
||||
<!-- form submission & fallback reaction message -->
|
||||
<div class="mt-6 grid grid-cols-settings gap-y-6 items-baseline">
|
||||
<div class="text-sm text-end">
|
||||
<p v-if="isUpdating">Applying Settings...</p>
|
||||
</div>
|
||||
<div class="col-start-2 ml-10 space-y-4">
|
||||
<BrandButton
|
||||
variant="outline-primary"
|
||||
padding="lean"
|
||||
size="12px"
|
||||
class="leading-normal"
|
||||
@click="submitSettingsUpdate"
|
||||
>
|
||||
Apply
|
||||
</BrandButton>
|
||||
|
||||
<p v-if="mutateSettingsError" class="text-sm text-unraid-red-500">
|
||||
✕ Error: {{ mutateSettingsError.message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="postcss">
|
||||
/* Import unraid-ui globals first */
|
||||
@import '@unraid/ui/styles';
|
||||
@import '../../assets/main.css';
|
||||
</style>
|
||||
|
||||
33
web/components/ConnectSettings/graphql/settings.query.ts
Normal file
33
web/components/ConnectSettings/graphql/settings.query.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { graphql } from '~/composables/gql/gql';
|
||||
|
||||
export const getConnectSettingsForm = graphql(/* GraphQL */ `
|
||||
query GetConnectSettingsForm {
|
||||
connect {
|
||||
id
|
||||
settings {
|
||||
id
|
||||
dataSchema
|
||||
uiSchema
|
||||
values {
|
||||
sandbox
|
||||
extraOrigins
|
||||
accessType
|
||||
forwardType
|
||||
port
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const updateConnectSettings = graphql(/* GraphQL */ `
|
||||
mutation UpdateConnectSettings($input: ApiSettingsInput!) {
|
||||
updateApiSettings(input: $input) {
|
||||
sandbox
|
||||
extraOrigins
|
||||
accessType
|
||||
forwardType
|
||||
port
|
||||
}
|
||||
}
|
||||
`);
|
||||
@@ -14,6 +14,8 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-
|
||||
* Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size
|
||||
*/
|
||||
type Documents = {
|
||||
"\n query GetConnectSettingsForm {\n connect {\n id\n settings {\n id\n dataSchema\n uiSchema\n values {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n }\n }\n }\n }\n": typeof types.GetConnectSettingsFormDocument,
|
||||
"\n mutation UpdateConnectSettings($input: ApiSettingsInput!) {\n updateApiSettings(input: $input) {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n }\n }\n": typeof types.UpdateConnectSettingsDocument,
|
||||
"\n fragment NotificationFragment on Notification {\n id\n title\n subject\n description\n importance\n link\n type\n timestamp\n formattedTimestamp\n }\n": typeof types.NotificationFragmentFragmentDoc,
|
||||
"\n fragment NotificationCountFragment on NotificationCounts {\n total\n info\n warning\n alert\n }\n": typeof types.NotificationCountFragmentFragmentDoc,
|
||||
"\n query Notifications($filter: NotificationFilter!) {\n notifications {\n id\n list(filter: $filter) {\n ...NotificationFragment\n }\n }\n }\n": typeof types.NotificationsDocument,
|
||||
@@ -35,6 +37,8 @@ type Documents = {
|
||||
"\n mutation setupRemoteAccess($input: SetupRemoteAccessInput!) {\n setupRemoteAccess(input: $input)\n }\n": typeof types.setupRemoteAccessDocument,
|
||||
};
|
||||
const documents: Documents = {
|
||||
"\n query GetConnectSettingsForm {\n connect {\n id\n settings {\n id\n dataSchema\n uiSchema\n values {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n }\n }\n }\n }\n": types.GetConnectSettingsFormDocument,
|
||||
"\n mutation UpdateConnectSettings($input: ApiSettingsInput!) {\n updateApiSettings(input: $input) {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n }\n }\n": types.UpdateConnectSettingsDocument,
|
||||
"\n fragment NotificationFragment on Notification {\n id\n title\n subject\n description\n importance\n link\n type\n timestamp\n formattedTimestamp\n }\n": types.NotificationFragmentFragmentDoc,
|
||||
"\n fragment NotificationCountFragment on NotificationCounts {\n total\n info\n warning\n alert\n }\n": types.NotificationCountFragmentFragmentDoc,
|
||||
"\n query Notifications($filter: NotificationFilter!) {\n notifications {\n id\n list(filter: $filter) {\n ...NotificationFragment\n }\n }\n }\n": types.NotificationsDocument,
|
||||
@@ -70,6 +74,14 @@ const documents: Documents = {
|
||||
*/
|
||||
export function graphql(source: string): unknown;
|
||||
|
||||
/**
|
||||
* 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 GetConnectSettingsForm {\n connect {\n id\n settings {\n id\n dataSchema\n uiSchema\n values {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n }\n }\n }\n }\n"): (typeof documents)["\n query GetConnectSettingsForm {\n connect {\n id\n settings {\n id\n dataSchema\n uiSchema\n values {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n }\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 UpdateConnectSettings($input: ApiSettingsInput!) {\n updateApiSettings(input: $input) {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n }\n }\n"): (typeof documents)["\n mutation UpdateConnectSettings($input: ApiSettingsInput!) {\n updateApiSettings(input: $input) {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
||||
@@ -17,7 +17,7 @@ export type Scalars = {
|
||||
/** A date-time string at UTC, such as 2007-12-03T10:15:30Z, compliant with the `date-time` format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar. */
|
||||
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: string; output: string; }
|
||||
JSON: { input: any; output: any; }
|
||||
/** The `Long` scalar type represents 52-bit integers */
|
||||
Long: { input: number; output: number; }
|
||||
/** 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 */
|
||||
@@ -89,6 +89,26 @@ export type ApiKeyWithSecret = {
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Input should be a subset of ApiSettings that can be updated.
|
||||
* Some field combinations may be required or disallowed. Please refer to each field for more information.
|
||||
*/
|
||||
export type ApiSettingsInput = {
|
||||
/** The type of WAN access to use for Remote Access. */
|
||||
accessType?: InputMaybe<WAN_ACCESS_TYPE>;
|
||||
/** A list of origins allowed to interact with the API. */
|
||||
extraOrigins?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
/** The type of port forwarding to use for Remote Access. */
|
||||
forwardType?: InputMaybe<WAN_FORWARD_TYPE>;
|
||||
/** The port to use for Remote Access. */
|
||||
port?: InputMaybe<Scalars['Port']['input']>;
|
||||
/**
|
||||
* If true, the GraphQL sandbox will be enabled and available at /graphql.
|
||||
* If false, the GraphQL sandbox will be disabled and only the production API will be available.
|
||||
*/
|
||||
sandbox?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
};
|
||||
|
||||
export type ArrayType = Node & {
|
||||
__typename?: 'Array';
|
||||
/** Current boot disk */
|
||||
@@ -299,6 +319,25 @@ export type Connect = Node & {
|
||||
__typename?: 'Connect';
|
||||
dynamicRemoteAccess: DynamicRemoteAccessStatus;
|
||||
id: Scalars['ID']['output'];
|
||||
settings: ConnectSettings;
|
||||
};
|
||||
|
||||
export type ConnectSettings = Node & {
|
||||
__typename?: 'ConnectSettings';
|
||||
dataSchema: Scalars['JSON']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
uiSchema: Scalars['JSON']['output'];
|
||||
values: ConnectSettingsValues;
|
||||
};
|
||||
|
||||
/** Intersection type of ApiSettings and RemoteAccess */
|
||||
export type ConnectSettingsValues = {
|
||||
__typename?: 'ConnectSettingsValues';
|
||||
accessType: WAN_ACCESS_TYPE;
|
||||
extraOrigins: Array<Scalars['String']['output']>;
|
||||
forwardType?: Maybe<WAN_FORWARD_TYPE>;
|
||||
port?: Maybe<Scalars['Port']['output']>;
|
||||
sandbox: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type ConnectSignInInput = {
|
||||
@@ -708,6 +747,7 @@ export type Mutation = {
|
||||
unmountArrayDisk?: Maybe<Disk>;
|
||||
/** Marks a notification as unread. */
|
||||
unreadNotification: Notification;
|
||||
updateApiSettings: ConnectSettingsValues;
|
||||
};
|
||||
|
||||
|
||||
@@ -842,6 +882,11 @@ export type MutationunreadNotificationArgs = {
|
||||
id: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationupdateApiSettingsArgs = {
|
||||
input: ApiSettingsInput;
|
||||
};
|
||||
|
||||
export type Network = Node & {
|
||||
__typename?: 'Network';
|
||||
accessUrls?: Maybe<Array<AccessUrl>>;
|
||||
@@ -1745,6 +1790,18 @@ export type usersInput = {
|
||||
slim?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
};
|
||||
|
||||
export type GetConnectSettingsFormQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GetConnectSettingsFormQuery = { __typename?: 'Query', connect: { __typename?: 'Connect', id: string, settings: { __typename?: 'ConnectSettings', id: string, dataSchema: any, uiSchema: any, values: { __typename?: 'ConnectSettingsValues', sandbox: boolean, extraOrigins: Array<string>, accessType: WAN_ACCESS_TYPE, forwardType?: WAN_FORWARD_TYPE | null, port?: number | null } } } };
|
||||
|
||||
export type UpdateConnectSettingsMutationVariables = Exact<{
|
||||
input: ApiSettingsInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type UpdateConnectSettingsMutation = { __typename?: 'Mutation', updateApiSettings: { __typename?: 'ConnectSettingsValues', sandbox: boolean, extraOrigins: Array<string>, accessType: WAN_ACCESS_TYPE, forwardType?: WAN_FORWARD_TYPE | null, port?: number | null } };
|
||||
|
||||
export type NotificationFragmentFragment = { __typename?: 'Notification', id: string, title: string, subject: string, description: string, importance: Importance, link?: string | null, type: NotificationType, timestamp?: string | null, formattedTimestamp?: string | null } & { ' $fragmentName'?: 'NotificationFragmentFragment' };
|
||||
|
||||
export type NotificationCountFragmentFragment = { __typename?: 'NotificationCounts', total: number, info: number, warning: number, alert: number } & { ' $fragmentName'?: 'NotificationCountFragmentFragment' };
|
||||
@@ -1871,6 +1928,8 @@ export type setupRemoteAccessMutation = { __typename?: 'Mutation', setupRemoteAc
|
||||
export const NotificationFragmentFragmentDoc = {"kind":"Document","definitions":[{"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<NotificationFragmentFragment, unknown>;
|
||||
export const NotificationCountFragmentFragmentDoc = {"kind":"Document","definitions":[{"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<NotificationCountFragmentFragment, unknown>;
|
||||
export const PartialCloudFragmentDoc = {"kind":"Document","definitions":[{"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<PartialCloudFragment, unknown>;
|
||||
export const GetConnectSettingsFormDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetConnectSettingsForm"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connect"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"settings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dataSchema"}},{"kind":"Field","name":{"kind":"Name","value":"uiSchema"}},{"kind":"Field","name":{"kind":"Name","value":"values"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sandbox"}},{"kind":"Field","name":{"kind":"Name","value":"extraOrigins"}},{"kind":"Field","name":{"kind":"Name","value":"accessType"}},{"kind":"Field","name":{"kind":"Name","value":"forwardType"}},{"kind":"Field","name":{"kind":"Name","value":"port"}}]}}]}}]}}]}}]} as unknown as DocumentNode<GetConnectSettingsFormQuery, GetConnectSettingsFormQueryVariables>;
|
||||
export const UpdateConnectSettingsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateConnectSettings"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ApiSettingsInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateApiSettings"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sandbox"}},{"kind":"Field","name":{"kind":"Name","value":"extraOrigins"}},{"kind":"Field","name":{"kind":"Name","value":"accessType"}},{"kind":"Field","name":{"kind":"Name","value":"forwardType"}},{"kind":"Field","name":{"kind":"Name","value":"port"}}]}}]}}]} as unknown as DocumentNode<UpdateConnectSettingsMutation, UpdateConnectSettingsMutationVariables>;
|
||||
export const NotificationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Notifications"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"NotificationFilter"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"notifications"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"list"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"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<NotificationsQuery, NotificationsQueryVariables>;
|
||||
export const ArchiveNotificationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ArchiveNotification"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"archiveNotification"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"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<ArchiveNotificationMutation, ArchiveNotificationMutationVariables>;
|
||||
export const ArchiveAllNotificationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ArchiveAllNotifications"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"archiveAll"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unread"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"total"}}]}},{"kind":"Field","name":{"kind":"Name","value":"archive"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"info"}},{"kind":"Field","name":{"kind":"Name","value":"warning"}},{"kind":"Field","name":{"kind":"Name","value":"alert"}},{"kind":"Field","name":{"kind":"Name","value":"total"}}]}}]}}]}}]} as unknown as DocumentNode<ArchiveAllNotificationsMutation, ArchiveAllNotificationsMutationVariables>;
|
||||
|
||||
@@ -7,6 +7,7 @@ export default withNuxt(
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-v-html': 'off',
|
||||
'eol-last': ['error', 'always'],
|
||||
},
|
||||
},
|
||||
eslintPrettier,
|
||||
|
||||
@@ -36,14 +36,7 @@ export default defineNuxtConfig({
|
||||
enabled: process.env.NODE_ENV === 'development',
|
||||
},
|
||||
|
||||
modules: [
|
||||
'@vueuse/nuxt',
|
||||
'@pinia/nuxt',
|
||||
'@nuxtjs/tailwindcss',
|
||||
'nuxt-custom-elements',
|
||||
'@nuxt/eslint',
|
||||
'shadcn-nuxt',
|
||||
],
|
||||
modules: ['@vueuse/nuxt', '@pinia/nuxt', '@nuxtjs/tailwindcss', 'nuxt-custom-elements', '@nuxt/eslint'],
|
||||
|
||||
ignore: ['/webGui/images'],
|
||||
|
||||
@@ -55,9 +48,6 @@ export default defineNuxtConfig({
|
||||
'~/components',
|
||||
],
|
||||
|
||||
// typescript: {
|
||||
// typeCheck: true
|
||||
// },
|
||||
vite: {
|
||||
plugins: [
|
||||
// Only remove non-critical console methods when VITE_ALLOW_CONSOLE_LOGS is false
|
||||
@@ -81,10 +71,6 @@ export default defineNuxtConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
shadcn: {
|
||||
prefix: '',
|
||||
componentDir: './components/shadcn',
|
||||
},
|
||||
customElements: {
|
||||
entries: [
|
||||
{
|
||||
|
||||
@@ -68,7 +68,8 @@
|
||||
"typescript": "^5.7.3",
|
||||
"vite-plugin-remove-console": "^2.2.0",
|
||||
"vitest": "^3.0.0",
|
||||
"vue-tsc": "^2.1.10"
|
||||
"vue-tsc": "^2.1.10",
|
||||
"vuetify-nuxt-module": "0.18.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.12.3",
|
||||
@@ -77,6 +78,10 @@
|
||||
"@floating-ui/vue": "^1.1.5",
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@heroicons/vue": "^2.2.0",
|
||||
"@jsonforms/core": "^3.5.1",
|
||||
"@jsonforms/vue": "^3.5.1",
|
||||
"@jsonforms/vue-vanilla": "^3.5.1",
|
||||
"@jsonforms/vue-vuetify": "^3.5.1",
|
||||
"@nuxtjs/color-mode": "^3.5.2",
|
||||
"@pinia/nuxt": "^0.10.0",
|
||||
"@unraid/ui": "link:../unraid-ui",
|
||||
@@ -101,6 +106,7 @@
|
||||
"semver": "^7.6.3",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"vue-i18n": "^10.0.5",
|
||||
"vuetify": "^3.7.14",
|
||||
"wretch": "^2.11.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { ExclamationTriangleIcon } from '@heroicons/vue/24/solid';
|
||||
import { BrandButton, BrandLogo } from '@unraid/ui';
|
||||
import { BrandButton, BrandLogo, Toaster } from '@unraid/ui';
|
||||
import { useDummyServerStore } from '~/_data/serverState';
|
||||
import AES from 'crypto-js/aes';
|
||||
|
||||
@@ -186,6 +186,7 @@ const bannerImage = watch(theme, () => {
|
||||
</div>
|
||||
</client-only>
|
||||
</div>
|
||||
<Toaster rich-colors close-button />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { useDummyServerStore } from '~/_data/serverState';
|
||||
import { Toaster } from '@unraid/ui';
|
||||
|
||||
const serverStore = useDummyServerStore();
|
||||
const { serverState } = storeToRefs(serverStore);
|
||||
@@ -57,5 +58,6 @@ onBeforeMount(() => {
|
||||
<h3 class="text-lg font-semibold font-mono">SSOSignInButtonCe</h3>
|
||||
<unraid-sso-button :ssoenabled="serverState.ssoEnabled" />
|
||||
</unraid-i18n-host>
|
||||
<Toaster rich-colors close-button />
|
||||
</client-only>
|
||||
</template>
|
||||
|
||||
@@ -11,7 +11,7 @@ fi
|
||||
server_name="$1"
|
||||
|
||||
# Source directory path
|
||||
source_directory=".nuxt/nuxt-custom-elements/dist/unraid-components"
|
||||
source_directory=".nuxt/nuxt-custom-elements/dist/unraid-components/"
|
||||
|
||||
if [ ! -d "$source_directory" ]; then
|
||||
echo "The web components directory does not exist."
|
||||
@@ -19,7 +19,7 @@ if [ ! -d "$source_directory" ]; then
|
||||
fi
|
||||
|
||||
# Replace the value inside the rsync command with the user's input
|
||||
rsync_command="rsync -avz -e ssh $source_directory root@${server_name}:/usr/local/emhttp/plugins/dynamix.my.servers"
|
||||
rsync_command="rsync -avz -e ssh $source_directory root@${server_name}:/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/nuxt"
|
||||
|
||||
echo "Executing the following command:"
|
||||
echo "$rsync_command"
|
||||
@@ -34,7 +34,7 @@ update_auth_request() {
|
||||
# SSH into server and update auth-request.php
|
||||
ssh "root@${server_name}" "
|
||||
AUTH_REQUEST_FILE='/usr/local/emhttp/auth-request.php'
|
||||
WEB_COMPS_DIR='/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/_nuxt/'
|
||||
WEB_COMPS_DIR='/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/nuxt/_nuxt/'
|
||||
|
||||
# Find JS files and modify paths
|
||||
mapfile -t JS_FILES < <(find \"\$WEB_COMPS_DIR\" -type f -name \"*.js\" | sed 's|/usr/local/emhttp||' | sort -u)
|
||||
@@ -54,7 +54,7 @@ update_auth_request() {
|
||||
print \$0
|
||||
next
|
||||
}
|
||||
!in_array || !/\/plugins\/dynamix\.my\.servers\/unraid-components\/_nuxt\/unraid-components\.client-/ {
|
||||
!in_array || !/\/plugins\/dynamix\.my\.servers\/unraid-components\/nuxt\/_nuxt\/unraid-components\.client-/ {
|
||||
print \$0
|
||||
}
|
||||
' \"\$AUTH_REQUEST_FILE\" > \"\${AUTH_REQUEST_FILE}.tmp\"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'dotenv/config';
|
||||
import tailwindRemToRem from '@unraid/tailwind-rem-to-rem';
|
||||
import tailwindConfig from '@unraid/ui/tailwind.config.ts';
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
|
||||
|
||||
export default {
|
||||
presets: [tailwindConfig],
|
||||
content: [
|
||||
|
||||
Reference in New Issue
Block a user