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:
Pujit Mehrotra
2025-03-17 10:26:07 -04:00
committed by GitHub
parent ce61fee41c
commit acbf46df3f
61 changed files with 2531 additions and 574 deletions

4
api/.env.production Normal file
View File

@@ -0,0 +1,4 @@
ENVIRONMENT="production"
NODE_ENV="production"
PORT="/var/run/unraid-api.sock"
MOTHERSHIP_GRAPHQL_LINK="https://mothership.unraid.net/ws"

View File

@@ -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/**/*'],

View File

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

View 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);
});
});

View File

@@ -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']);
});
});

View File

@@ -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()
})
}

View File

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

View File

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

View File

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

View 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',
},
},
],
};
}
}

View File

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

View File

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

View File

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

View File

@@ -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],
})

View File

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

View 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;

View File

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

View File

@@ -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'):?>
&nbsp;
: _(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'):?>
&nbsp;
: _(Remark: to use the "Dynamic - Manual Port Forward" option for Remote Access please set "Use SSL/TLS" to "Strict" in Management Access.)_
<?endif?>
&nbsp;
: <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']?>&nbsp;&nbsp;&nbsp;</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 ?>
&nbsp;
: <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?>">
&nbsp;
: <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:
&nbsp;
: <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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View 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>

View 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>

View File

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

View File

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

View 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>

View 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';

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View File

@@ -0,0 +1,7 @@
import '@jsonforms/core';
declare module '@jsonforms/core' {
export interface UISchemaElement {
label?: string;
}
}

View File

@@ -0,0 +1,3 @@
# `@unraid/ui/forms`
JSONForms wrappers & renderers.

View 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'))),
};

View 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,
];

View 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'))),
};

View File

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

View File

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

View File

@@ -20,6 +20,7 @@
"files": ["tailwind.config.ts"],
"include": [
"src/**/*.ts",
"src/**/*/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue",

View File

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

View File

@@ -11,7 +11,7 @@ const config: CodegenConfig = {
scalars: {
DateTime: 'string',
Long: 'number',
JSON: 'string',
JSON: 'any',
URL: 'URL',
Port: 'number',
UUID: 'string',

View File

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

View File

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

View 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
}
}
`);

View File

@@ -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.
*/

View File

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

View File

@@ -7,6 +7,7 @@ export default withNuxt(
rules: {
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'off',
'eol-last': ['error', 'always'],
},
},
eslintPrettier,

View File

@@ -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: [
{

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: [