From 6cac078e1568fcdaa43a55ea4dfc4e28361e1d95 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Wed, 16 Oct 2024 09:44:07 -0400 Subject: [PATCH] refactor(web): instantiate apollo client statically instead of dynamically --- web/components/Notifications/Item.vue | 5 +- web/components/Notifications/Sidebar.vue | 82 +++---- .../graphql/notification.query.ts | 26 ++ web/components/UserProfile.ce.vue | 2 +- web/composables/gql/gql.ts | 10 + web/composables/gql/graphql.ts | 226 +++++++++++++++--- web/helpers/create-apollo-client.ts | 112 +++++++++ web/store/server.ts | 11 +- web/store/theme.ts | 1 + web/store/unraidApi.ts | 196 ++------------- web/types/ui/notification.ts | 10 - 11 files changed, 406 insertions(+), 275 deletions(-) create mode 100644 web/components/Notifications/graphql/notification.query.ts create mode 100644 web/helpers/create-apollo-client.ts delete mode 100644 web/types/ui/notification.ts diff --git a/web/components/Notifications/Item.vue b/web/components/Notifications/Item.vue index 8aa4c7d4a..d41d79e98 100644 --- a/web/components/Notifications/Item.vue +++ b/web/components/Notifications/Item.vue @@ -6,10 +6,9 @@ import { ExclamationTriangleIcon, LinkIcon, } from '@heroicons/vue/24/solid'; +import type { NotificationFragmentFragment } from '~/composables/gql/graphql'; -import type { NotificationItemProps } from '~/types/ui/notification'; - -const props = defineProps(); +const props = defineProps(); const icon = computed<{ component: Component, color: string } | null>(() => { switch (props.importance) { diff --git a/web/components/Notifications/Sidebar.vue b/web/components/Notifications/Sidebar.vue index cfaf7883a..7e69dd1bd 100644 --- a/web/components/Notifications/Sidebar.vue +++ b/web/components/Notifications/Sidebar.vue @@ -9,52 +9,39 @@ import { SheetTrigger, } from "@/components/shadcn/sheet"; -import type { NotificationItemProps } from "~/types/ui/notification"; -import { useUnraidApiStore } from "~/store/unraidApi"; -import gql from "graphql-tag"; +import { + getNotifications, + NOTIFICATION_FRAGMENT, +} from "./graphql/notification.query"; +import { + NotificationType, +} from "~/composables/gql/graphql"; +import { useFragment } from "~/composables/gql/fragment-masking"; +import { useQuery } from "@vue/apollo-composable"; -const getNotifications = gql` - query Notifications($filter: NotificationFilter!) { - notifications { - list(filter: $filter) { - id - title - subject - description - importance - link - type - timestamp - } - } - } -`; +// const notifications = ref([]); +// watch(notifications, (newVal) => { +// console.log("[notifications]", newVal); +// }); -const notifications = ref([]); -watch(notifications, (newVal) => { - console.log("[notifications]", newVal); +const fetchType = ref(NotificationType.Unread); +const setFetchType = (type: NotificationType) => (fetchType.value = type); + +const { result, error } = useQuery(getNotifications, { + filter: { + offset: 0, + limit: 10, + type: fetchType.value, + }, }); -const fetchType = ref<"UNREAD" | "ARCHIVED">("UNREAD"); -const setFetchType = (type: "UNREAD" | "ARCHIVED") => (fetchType.value = type); +const notifications = computed(() => { + if (!result.value?.notifications.list) return []; + return useFragment(NOTIFICATION_FRAGMENT, result.value?.notifications.list); +}); -const { unraidApiClient: maybeApi } = storeToRefs(useUnraidApiStore()); - - -watch(maybeApi, async (apiClient) => { - if (apiClient) { - const apiResponse = await apiClient.query({ - query: getNotifications, - variables: { - filter: { - offset: 0, - limit: 10, - type: fetchType.value, - }, - }, - }); - notifications.value = apiResponse.data.notifications.list; - } +watch(error, (newVal) => { + console.log("[sidebar error]", newVal); }); const { teleportTarget, determineTeleportTarget } = useTeleport(); @@ -81,14 +68,14 @@ const { teleportTarget, determineTeleportTarget } = useTeleport(); Unread Archived @@ -97,7 +84,7 @@ const { teleportTarget, determineTeleportTarget } = useTeleport(); @@ -119,7 +106,10 @@ const { teleportTarget, determineTeleportTarget } = useTeleport(); -
+
- + \ No newline at end of file diff --git a/web/components/Notifications/graphql/notification.query.ts b/web/components/Notifications/graphql/notification.query.ts new file mode 100644 index 000000000..44eaac61f --- /dev/null +++ b/web/components/Notifications/graphql/notification.query.ts @@ -0,0 +1,26 @@ +import { graphql } from "~/composables/gql/gql"; + +export const NOTIFICATION_FRAGMENT = graphql(/* GraphQL */ ` + fragment NotificationFragment on Notification { + id + title + subject + description + importance + link + type + timestamp + } +`); + +export const getNotifications = graphql(/* GraphQL */ ` + query Notifications($filter: NotificationFilter!) { + notifications { + id + list(filter: $filter) { + ...NotificationFragment + } + } + } +`); + diff --git a/web/components/UserProfile.ce.vue b/web/components/UserProfile.ce.vue index 3276926e5..9af4c9867 100644 --- a/web/components/UserProfile.ce.vue +++ b/web/components/UserProfile.ce.vue @@ -128,7 +128,7 @@ onBeforeMount(() => {
- + diff --git a/web/composables/gql/gql.ts b/web/composables/gql/gql.ts index 9f9bc07b6..aece72454 100644 --- a/web/composables/gql/gql.ts +++ b/web/composables/gql/gql.ts @@ -13,6 +13,8 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document- * Therefore it is highly recommended to use the babel or swc plugin for production. */ const documents = { + "\n fragment NotificationFragment on Notification {\n id\n title\n subject\n description\n importance\n link\n type\n timestamp\n }\n": types.NotificationFragmentFragmentDoc, + "\n query Notifications($filter: NotificationFilter!) {\n notifications {\n id\n list(filter: $filter) {\n ...NotificationFragment\n }\n }\n }\n": types.NotificationsDocument, "\n mutation ConnectSignIn($input: ConnectSignInInput!) {\n connectSignIn(input: $input)\n }\n": types.ConnectSignInDocument, "\n mutation SignOut {\n connectSignOut\n }\n": types.SignOutDocument, "\n fragment PartialCloud on Cloud {\n error\n apiKey {\n valid\n error\n }\n cloud {\n status\n error\n }\n minigraphql {\n status\n error\n }\n relay {\n status\n error\n }\n }\n": types.PartialCloudFragmentDoc, @@ -37,6 +39,14 @@ const 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 fragment NotificationFragment on Notification {\n id\n title\n subject\n description\n importance\n link\n type\n timestamp\n }\n"): (typeof documents)["\n fragment NotificationFragment on Notification {\n id\n title\n subject\n description\n importance\n link\n type\n timestamp\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query Notifications($filter: NotificationFilter!) {\n notifications {\n id\n list(filter: $filter) {\n ...NotificationFragment\n }\n }\n }\n"): (typeof documents)["\n query Notifications($filter: NotificationFilter!) {\n notifications {\n id\n list(filter: $filter) {\n ...NotificationFragment\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/web/composables/gql/graphql.ts b/web/composables/gql/graphql.ts index 7da8ec1cc..52f60a7e2 100644 --- a/web/composables/gql/graphql.ts +++ b/web/composables/gql/graphql.ts @@ -1,4 +1,4 @@ - +/* eslint-disable */ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; export type Maybe = T | null; export type InputMaybe = Maybe; @@ -22,10 +22,27 @@ export type Scalars = { 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 */ Port: { input: number; output: number; } + /** A field whose value conforms to the standard URL format as specified in RFC3986: https://www.ietf.org/rfc/rfc3986.txt. */ + URL: { input: URL; output: URL; } /** A field whose value is a generic Universally Unique Identifier: https://en.wikipedia.org/wiki/Universally_unique_identifier. */ UUID: { input: string; output: string; } }; +export type AccessUrl = { + __typename?: 'AccessUrl'; + ipv4?: Maybe; + ipv6?: Maybe; + name?: Maybe; + type: URL_TYPE; +}; + +export type AccessUrlInput = { + ipv4?: InputMaybe; + ipv6?: InputMaybe; + name?: InputMaybe; + type: URL_TYPE; +}; + export type AllowedOriginInput = { origins: Array; }; @@ -45,7 +62,7 @@ export type ApiKeyResponse = { valid: Scalars['Boolean']['output']; }; -export type ArrayType = { +export type ArrayType = Node & { __typename?: 'Array'; /** Current boot disk */ boot?: Maybe; @@ -55,6 +72,7 @@ export type ArrayType = { capacity: ArrayCapacity; /** Data disks in the current array */ disks: Array; + id: Scalars['ID']['output']; /** Parity disks in the current array */ parities: Array; /** Array state after this query/mutation */ @@ -235,20 +253,26 @@ export type CloudResponse = { status: Scalars['String']['output']; }; -export type Config = { +export type Config = Node & { __typename?: 'Config'; error?: Maybe; + id: Scalars['ID']['output']; valid?: Maybe; }; export enum ConfigErrorState { - Ineligible = 'INELIGIBLE', Invalid = 'INVALID', NoKeyServer = 'NO_KEY_SERVER', UnknownError = 'UNKNOWN_ERROR', Withdrawn = 'WITHDRAWN' } +export type Connect = Node & { + __typename?: 'Connect'; + dynamicRemoteAccess: DynamicRemoteAccessStatus; + id: Scalars['ID']['output']; +}; + export type ConnectSignInInput = { accessToken?: InputMaybe; apiKey: Scalars['String']['input']; @@ -363,6 +387,7 @@ export type Display = { dashapps?: Maybe; date?: Maybe; hot?: Maybe; + id: Scalars['ID']['output']; locale?: Maybe; max?: Maybe; number?: Maybe; @@ -379,6 +404,13 @@ export type Display = { wwn?: Maybe; }; +export type Docker = Node & { + __typename?: 'Docker'; + containers?: Maybe>; + id: Scalars['ID']['output']; + networks?: Maybe>; +}; + export type DockerContainer = { __typename?: 'DockerContainer'; autoStart: Scalars['Boolean']['output']; @@ -418,6 +450,24 @@ export type DockerNetwork = { scope?: Maybe; }; +export type DynamicRemoteAccessStatus = { + __typename?: 'DynamicRemoteAccessStatus'; + enabledType: DynamicRemoteAccessType; + error?: Maybe; + runningType: DynamicRemoteAccessType; +}; + +export enum DynamicRemoteAccessType { + Disabled = 'DISABLED', + Static = 'STATIC', + Upnp = 'UPNP' +} + +export type EnableDynamicRemoteAccessInput = { + enabled: Scalars['Boolean']['input']; + url: AccessUrlInput; +}; + export type Flash = { __typename?: 'Flash'; guid?: Maybe; @@ -442,7 +492,7 @@ export enum Importance { Warning = 'WARNING' } -export type Info = { +export type Info = Node & { __typename?: 'Info'; /** Count of docker containers */ apps?: Maybe; @@ -450,11 +500,13 @@ export type Info = { cpu?: Maybe; devices?: Maybe; display?: Maybe; + id: Scalars['ID']['output']; /** Machine ID */ machineId?: Maybe; memory?: Maybe; os?: Maybe; system?: Maybe; + time: Scalars['DateTime']['output']; versions?: Maybe; }; @@ -574,13 +626,20 @@ export type Mutation = { addDiskToArray?: Maybe; /** Add a new user */ addUser?: Maybe; + archiveAll: NotificationOverview; + /** Marks a notification as archived. */ + archiveNotification: NotificationOverview; + archiveNotifications: NotificationOverview; /** Cancel parity check */ cancelParityCheck?: Maybe; clearArrayDiskStatistics?: Maybe; connectSignIn: Scalars['Boolean']['output']; connectSignOut: Scalars['Boolean']['output']; + createNotification: Notification; + deleteNotification: NotificationOverview; /** Delete a user */ deleteUser?: Maybe; + enableDynamicRemoteAccess: Scalars['Boolean']['output']; /** Get an existing API key */ getApiKey?: Maybe; login?: Maybe; @@ -588,11 +647,12 @@ export type Mutation = { /** Pause parity check */ pauseParityCheck?: Maybe; reboot?: Maybe; + /** Reads each notification to recompute & update the overview. */ + recalculateOverview: NotificationOverview; /** Remove existing disk from array. NOTE: The array must be stopped before running this otherwise it'll throw an error. */ removeDiskFromArray?: Maybe; /** Resume parity check */ resumeParityCheck?: Maybe; - sendNotification?: Maybe; setAdditionalAllowedOrigins: Array; setupRemoteAccess: Scalars['Boolean']['output']; shutdown?: Maybe; @@ -602,7 +662,11 @@ export type Mutation = { startParityCheck?: Maybe; /** Stop array */ stopArray?: Maybe; + unarchiveAll: NotificationOverview; + unarchiveNotifications: NotificationOverview; unmountArrayDisk?: Maybe; + /** Marks a notification as unread. */ + unreadNotification: NotificationOverview; /** Update an existing API key */ updateApikey?: Maybe; }; @@ -624,6 +688,21 @@ export type MutationaddUserArgs = { }; +export type MutationarchiveAllArgs = { + importance?: InputMaybe; +}; + + +export type MutationarchiveNotificationArgs = { + id: Scalars['String']['input']; +}; + + +export type MutationarchiveNotificationsArgs = { + ids?: InputMaybe>; +}; + + export type MutationclearArrayDiskStatisticsArgs = { id: Scalars['ID']['input']; }; @@ -634,11 +713,27 @@ export type MutationconnectSignInArgs = { }; +export type MutationcreateNotificationArgs = { + input: NotificationData; +}; + + +export type MutationdeleteNotificationArgs = { + id: Scalars['String']['input']; + type: NotificationType; +}; + + export type MutationdeleteUserArgs = { input: deleteUserInput; }; +export type MutationenableDynamicRemoteAccessArgs = { + input: EnableDynamicRemoteAccessInput; +}; + + export type MutationgetApiKeyArgs = { input?: InputMaybe; name: Scalars['String']['input']; @@ -661,11 +756,6 @@ export type MutationremoveDiskFromArrayArgs = { }; -export type MutationsendNotificationArgs = { - notification: NotificationInput; -}; - - export type MutationsetAdditionalAllowedOriginsArgs = { input: AllowedOriginInput; }; @@ -681,20 +771,37 @@ export type MutationstartParityCheckArgs = { }; +export type MutationunarchiveAllArgs = { + importance?: InputMaybe; +}; + + +export type MutationunarchiveNotificationsArgs = { + ids?: InputMaybe>; +}; + + export type MutationunmountArrayDiskArgs = { id: Scalars['ID']['input']; }; +export type MutationunreadNotificationArgs = { + id: Scalars['String']['input']; +}; + + export type MutationupdateApikeyArgs = { input?: InputMaybe; name: Scalars['String']['input']; }; -export type Network = { +export type Network = Node & { __typename?: 'Network'; + accessUrls?: Maybe>; carrierChanges?: Maybe; duplex?: Maybe; + id: Scalars['ID']['output']; iface?: Maybe; ifaceName?: Maybe; internal?: Maybe; @@ -707,19 +814,40 @@ export type Network = { type?: Maybe; }; -export type Notification = { +export type Node = { + id: Scalars['ID']['output']; +}; + +export type Notification = Node & { __typename?: 'Notification'; description: Scalars['String']['output']; id: Scalars['ID']['output']; importance: Importance; link?: Maybe; subject: Scalars['String']['output']; - /** ISO Timestamp for when the notification occurred */ + /** ISO Timestamp for when the notification occurred */ timestamp?: Maybe; + /** Also known as 'event' */ title: Scalars['String']['output']; type: NotificationType; }; +export type NotificationCounts = { + __typename?: 'NotificationCounts'; + alert: Scalars['Int']['output']; + info: Scalars['Int']['output']; + total: Scalars['Int']['output']; + warning: Scalars['Int']['output']; +}; + +export type NotificationData = { + description: Scalars['String']['input']; + importance: Importance; + link?: InputMaybe; + subject: Scalars['String']['input']; + title: Scalars['String']['input']; +}; + export type NotificationFilter = { importance?: InputMaybe; limit: Scalars['Int']['input']; @@ -727,23 +855,30 @@ export type NotificationFilter = { type?: InputMaybe; }; -export type NotificationInput = { - description?: InputMaybe; - id: Scalars['ID']['input']; - importance: Importance; - link?: InputMaybe; - subject: Scalars['String']['input']; - timestamp?: InputMaybe; - title: Scalars['String']['input']; - type: NotificationType; +export type NotificationOverview = { + __typename?: 'NotificationOverview'; + archive: NotificationCounts; + unread: NotificationCounts; }; export enum NotificationType { - Archived = 'ARCHIVED', - Restored = 'RESTORED', + Archive = 'ARCHIVE', Unread = 'UNREAD' } +export type Notifications = Node & { + __typename?: 'Notifications'; + id: Scalars['ID']['output']; + list: Array; + /** A cached overview of the notifications in the system & their severity. */ + overview: NotificationOverview; +}; + + +export type NotificationslistArgs = { + filter: NotificationFilter; +}; + export type Os = { __typename?: 'Os'; arch?: Maybe; @@ -867,11 +1002,13 @@ export type Query = { array: ArrayType; cloud?: Maybe; config: Config; + connect: Connect; /** Single disk */ disk?: Maybe; /** Mulitiple disks */ disks: Array>; display?: Maybe; + docker: Docker; /** All Docker containers */ dockerContainers: Array; /** Docker network */ @@ -883,7 +1020,8 @@ export type Query = { info?: Maybe; /** Current user account */ me?: Maybe; - notifications: Array; + network?: Maybe; + notifications: Notifications; online?: Maybe; owner?: Maybe; parityHistory?: Maybe>>; @@ -891,6 +1029,7 @@ export type Query = { remoteAccess: RemoteAccess; server?: Maybe; servers: Array; + services: Array; /** Network Shares */ shares?: Maybe>>; unassignedDevices?: Maybe>>; @@ -924,11 +1063,6 @@ export type QuerydockerNetworksArgs = { }; -export type QuerynotificationsArgs = { - filter: NotificationFilter; -}; - - export type QueryuserArgs = { id: Scalars['ID']['input']; }; @@ -1022,8 +1156,9 @@ export enum ServerStatus { Online = 'online' } -export type Service = { +export type Service = Node & { __typename?: 'Service'; + id: Scalars['ID']['output']; name?: Maybe; online?: Maybe; uptime?: Maybe; @@ -1077,6 +1212,7 @@ export type Subscription = { info: Info; me?: Maybe; notificationAdded: Notification; + notificationsOverview: NotificationOverview; online: Scalars['Boolean']['output']; owner: Owner; parityHistory: ParityCheck; @@ -1137,6 +1273,15 @@ export enum Theme { White = 'white' } +export enum URL_TYPE { + Default = 'DEFAULT', + Lan = 'LAN', + Mdns = 'MDNS', + Other = 'OTHER', + Wan = 'WAN', + Wireguard = 'WIREGUARD' +} + export type UnassignedDevice = { __typename?: 'UnassignedDevice'; devlinks?: Maybe; @@ -1223,7 +1368,7 @@ export type UserAccount = { roles: Scalars['String']['output']; }; -export type Vars = { +export type Vars = Node & { __typename?: 'Vars'; bindMgt?: Maybe; cacheNumDevices?: Maybe; @@ -1257,6 +1402,7 @@ export type Vars = { fuseRememberDefault?: Maybe; fuseRememberStatus?: Maybe; hideDotFiles?: Maybe; + id: Scalars['ID']['output']; joinStatus?: Maybe; localMaster?: Maybe; localTld?: Maybe; @@ -1510,6 +1656,18 @@ export type usersInput = { slim?: InputMaybe; }; +export type NotificationFragmentFragment = { __typename?: 'Notification', id: string, title: string, subject: string, description: string, importance: Importance, link?: string | null, type: NotificationType, timestamp?: string | null } & { ' $fragmentName'?: 'NotificationFragmentFragment' }; + +export type NotificationsQueryVariables = Exact<{ + filter: NotificationFilter; +}>; + + +export type NotificationsQuery = { __typename?: 'Query', notifications: { __typename?: 'Notifications', id: string, list: Array<( + { __typename?: 'Notification' } + & { ' $fragmentRefs'?: { 'NotificationFragmentFragment': NotificationFragmentFragment } } + )> } }; + export type ConnectSignInMutationVariables = Exact<{ input: ConnectSignInInput; }>; @@ -1556,7 +1714,9 @@ export type setupRemoteAccessMutationVariables = Exact<{ export type setupRemoteAccessMutation = { __typename?: 'Mutation', setupRemoteAccess: boolean }; +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"}}]}}]} as unknown as DocumentNode; 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; +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"}}]}}]} as unknown as DocumentNode; export const ConnectSignInDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ConnectSignIn"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ConnectSignInInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectSignIn"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const SignOutDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SignOut"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectSignOut"}}]}}]} as unknown as DocumentNode; export const serverStateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"serverState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cloud"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PartialCloud"}}]}},{"kind":"Field","name":{"kind":"Name","value":"config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}}]}},{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"os"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hostname"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"owner"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}},{"kind":"Field","name":{"kind":"Name","value":"registration"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"expiration"}},{"kind":"Field","name":{"kind":"Name","value":"keyFile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contents"}}]}},{"kind":"Field","name":{"kind":"Name","value":"updateExpiration"}}]}},{"kind":"Field","name":{"kind":"Name","value":"vars"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"regGen"}},{"kind":"Field","name":{"kind":"Name","value":"regState"}},{"kind":"Field","name":{"kind":"Name","value":"configError"}},{"kind":"Field","name":{"kind":"Name","value":"configValid"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PartialCloud"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Cloud"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cloud"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"minigraphql"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"relay"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]} as unknown as DocumentNode; diff --git a/web/helpers/create-apollo-client.ts b/web/helpers/create-apollo-client.ts new file mode 100644 index 000000000..b43ec13c0 --- /dev/null +++ b/web/helpers/create-apollo-client.ts @@ -0,0 +1,112 @@ +import type { + split as SplitType, + ApolloClient as ApolloClientType, + InMemoryCache as InMemoryCacheType, +} from "@apollo/client"; + +import { + from, + ApolloClient, + createHttpLink, + InMemoryCache, + split, + // @ts-expect-error - CommonJS doesn't have type definitions +} from "@apollo/client/core/core.cjs"; + +import { onError } from "@apollo/client/link/error"; +import { RetryLink } from "@apollo/client/link/retry"; +import { GraphQLWsLink } from "@apollo/client/link/subscriptions"; +import { getMainDefinition } from "@apollo/client/utilities"; +import { provideApolloClient } from "@vue/apollo-composable"; +import { createClient } from "graphql-ws"; +import { WEBGUI_GRAPHQL } from "./urls"; + +const httpEndpoint = WEBGUI_GRAPHQL; +const wsEndpoint = new URL(WEBGUI_GRAPHQL.toString().replace("http", "ws")); + +// const headers = { 'x-api-key': serverStore.apiKey }; +const headers = {}; + +const httpLink = createHttpLink({ + uri: httpEndpoint.toString(), + headers, + credentials: "include", +}); + +const wsLink = new GraphQLWsLink( + createClient({ + url: wsEndpoint.toString(), + connectionParams: () => ({ + headers, + }), + }) +); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const errorLink = onError(({ graphQLErrors, networkError }: any) => { + if (graphQLErrors) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + graphQLErrors.map((error: any) => { + console.error("[GraphQL error]", error); + const errorMsg = + error.error && error.error.message + ? error.error.message + : error.message; + if (errorMsg && errorMsg.includes("offline")) { + // @todo restart the api + } + return error.message; + }); + } + + if (networkError) { + console.error(`[Network error]: ${networkError}`); + const msg = networkError.message ? networkError.message : networkError; + if ( + typeof msg === "string" && + msg.includes("Unexpected token < in JSON at position 0") + ) { + return "Unraid API • CORS Error"; + } + return msg; + } +}); + +const retryLink = new RetryLink({ + attempts: { + max: 20, + retryIf: (error, _operation) => { + return Boolean(error); + }, + }, + delay: { + initial: 300, + max: 10000, + jitter: true, + }, +}); + +const splitLinks = (split as typeof SplitType)( + ({ query }) => { + const definition = getMainDefinition(query); + return ( + definition.kind === "OperationDefinition" && + definition.operation === "subscription" + ); + }, + wsLink, + httpLink +); +/** + * @todo as we add retries, determine which we'll need + * https://www.apollographql.com/docs/react/api/link/introduction/#additive-composition + * https://www.apollographql.com/docs/react/api/link/introduction/#directional-composition + */ +const additiveLink = from([errorLink, retryLink, splitLinks]); + +const client: ApolloClientType = new ApolloClient({ + link: additiveLink, + cache: new InMemoryCache(), +}); + +provideApolloClient(client); diff --git a/web/store/server.ts b/web/store/server.ts index ca4a9a0fb..82484750e 100644 --- a/web/store/server.ts +++ b/web/store/server.ts @@ -63,14 +63,6 @@ export const useServerStore = defineStore('server', () => { * State */ const apiKey = ref(''); - watch(apiKey, (newVal, oldVal) => { - if (newVal) { - return unraidApiStore.createApolloClient(); - } - if (oldVal) { - return unraidApiStore.closeUnraidApiClient(); - } - }); const apiVersion = ref(''); const array = ref(); // helps to display warning next to array status @@ -958,8 +950,9 @@ export const useServerStore = defineStore('server', () => { regGen: data.vars && data.vars.regGen ? parseInt(data.vars.regGen) : undefined, state: data.vars && data.vars.regState ? data.vars.regState : undefined, config: data.config - ? data.config + ? { id: 'config', ...data.config } : { + id: 'config', error: data.vars && data.vars.configError ? data.vars.configError : undefined, valid: data.vars && data.vars.configValid ? data.vars.configValid : true, }, diff --git a/web/store/theme.ts b/web/store/theme.ts index ae64b7dcd..f87decbd2 100644 --- a/web/store/theme.ts +++ b/web/store/theme.ts @@ -64,6 +64,7 @@ export const useThemeStore = defineStore('theme', () => { body.style.setProperty('--shadow-beta', `0 25px 50px -12px ${hexToRgba(beta, 0.15)}`); body.style.setProperty('--ring-offset-shadow', `0 0 ${beta}`); body.style.setProperty('--ring-shadow', `0 0 ${beta}`); + body.style.setProperty('--dev-test', `0 0 ${beta}`); }; watch(theme, () => { diff --git a/web/store/unraidApi.ts b/web/store/unraidApi.ts index 69542a811..2b5c516dc 100644 --- a/web/store/unraidApi.ts +++ b/web/store/unraidApi.ts @@ -1,33 +1,17 @@ -import { - from, - ApolloClient, - createHttpLink, - InMemoryCache, - split, - // @ts-expect-error - CommonJS doesn't have type definitions -} from "@apollo/client/core/core.cjs"; import { type ApolloClient as ApolloClientType, type InMemoryCache as InMemoryCacheType, -} from '@apollo/client'; -import { onError } from '@apollo/client/link/error'; -import { RetryLink } from '@apollo/client/link/retry'; -import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; -import { getMainDefinition } from '@apollo/client/utilities'; -import { ArrowPathIcon, CogIcon } from '@heroicons/vue/24/solid'; -import { provideApolloClient } from '@vue/apollo-composable'; +} from "@apollo/client"; +import { ArrowPathIcon } from "@heroicons/vue/24/solid"; // import { logErrorMessages } from '@vue/apollo-util'; -import { createClient } from 'graphql-ws'; -import { defineStore, createPinia, setActivePinia } from 'pinia'; -import type { UserProfileLink } from '~/types/userProfile'; +import { defineStore, createPinia, setActivePinia } from "pinia"; +import type { UserProfileLink } from "~/types/userProfile"; -import { WebguiUnraidApiCommand } from '~/composables/services/webgui'; -import { - WEBGUI_GRAPHQL, - WEBGUI_SETTINGS_MANAGMENT_ACCESS, -} from '~/helpers/urls'; -import { useErrorsStore } from '~/store/errors'; -import { useServerStore } from '~/store/server'; +import { WebguiUnraidApiCommand } from "~/composables/services/webgui"; +import { useErrorsStore } from "~/store/errors"; +import { useServerStore } from "~/store/server"; + +import "~/helpers/create-apollo-client"; /** * @see https://stackoverflow.com/questions/73476371/using-pinia-with-vue-js-web-components @@ -35,13 +19,7 @@ import { useServerStore } from '~/store/server'; */ setActivePinia(createPinia()); -const ERROR_CORS_403 = - 'The CORS policy for this site does not allow access from the specified Origin'; - -const httpEndpoint = WEBGUI_GRAPHQL; -const wsEndpoint = new URL(WEBGUI_GRAPHQL.toString().replace('http', 'ws')); - -export const useUnraidApiStore = defineStore('unraidApi', () => { +export const useUnraidApiStore = defineStore("unraidApi", () => { const errorsStore = useErrorsStore(); const serverStore = useServerStore(); const unraidApiClient = ref | null>(null); @@ -50,21 +28,21 @@ export const useUnraidApiStore = defineStore('unraidApi', () => { const apiResponse = serverStore.fetchServerFromApi(); if (apiResponse) { // we have a response, so we're online - unraidApiStatus.value = 'online'; + unraidApiStatus.value = "online"; } } }); // const unraidApiErrors = ref([]); const unraidApiStatus = ref< - 'connecting' | 'offline' | 'online' | 'restarting' - >('offline'); + "connecting" | "offline" | "online" | "restarting" + >("offline"); const prioritizeCorsError = ref(false); // Ensures we don't overwrite this specific error message with a non-descriptive network error message const unraidApiRestartAction = computed((): UserProfileLink | undefined => { const { connectPluginInstalled, stateDataError } = serverStore; if ( - unraidApiStatus.value !== 'offline' || + unraidApiStatus.value !== "offline" || !connectPluginInstalled || stateDataError ) { @@ -74,132 +52,10 @@ export const useUnraidApiStore = defineStore('unraidApi', () => { click: () => restartUnraidApiClient(), emphasize: true, icon: ArrowPathIcon, - text: 'Restart unraid-api', + text: "Restart unraid-api", }; }); - /** - * Automatically called when an apiKey is set in the serverStore - */ - const createApolloClient = () => { - // return; // @todo remove - unraidApiStatus.value = 'connecting'; - - // const headers = { 'x-api-key': serverStore.apiKey }; - const headers = {}; - - const httpLink = createHttpLink({ - uri: httpEndpoint.toString(), - headers, - credentials: "include", - }); - - const wsLink = new GraphQLWsLink( - createClient({ - url: wsEndpoint.toString(), - connectionParams: () => ({ - headers, - }), - }) - ); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const errorLink = onError(({ graphQLErrors, networkError }: any) => { - if (graphQLErrors) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - graphQLErrors.map((error: any) => { - console.error('[GraphQL error]', error); - const errorMsg = - error.error && error.error.message - ? error.error.message - : error.message; - if (errorMsg && errorMsg.includes('offline')) { - unraidApiStatus.value = 'offline'; - // attempt to automatically restart the unraid-api - if (unraidApiRestartAction) { - restartUnraidApiClient(); - } - } - if (errorMsg && errorMsg.includes(ERROR_CORS_403)) { - prioritizeCorsError.value = true; - const msg = `

The CORS policy for the unraid-api does not allow access from the specified origin.

If you are using a reverse proxy, you need to copy your origin ${window.location.origin} and paste it into the "Extra Origins" list in the Connect settings.

`; - errorsStore.setError({ - heading: 'Unraid API • CORS Error', - message: msg, - level: 'error', - ref: 'unraidApiCorsError', - type: 'unraidApiGQL', - actions: [ - { - href: `${WEBGUI_SETTINGS_MANAGMENT_ACCESS.toString()}#extraOriginsSettings`, - icon: CogIcon, - text: 'Go to Connect Settings', - }, - ], - }); - } - return error.message; - }); - } - - if (networkError && !prioritizeCorsError) { - console.error(`[Network error]: ${networkError}`); - const msg = networkError.message ? networkError.message : networkError; - if ( - typeof msg === 'string' && - msg.includes('Unexpected token < in JSON at position 0') - ) { - return 'Unraid API • CORS Error'; - } - return msg; - } - }); - - const retryLink = new RetryLink({ - attempts: { - max: 20, - retryIf: (error, _operation) => { - return !!error && !prioritizeCorsError; // don't retry when ERROR_CORS_403 - }, - }, - delay: { - initial: prioritizeCorsError ? 3000 : 300, - max: 10000, - jitter: true, - }, - }); - - interface Definintion { - kind: string; - operation?: string; - } - const splitLinks = split( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ({ query }: any) => { - const definition: Definintion = getMainDefinition(query); - return ( - definition.kind === 'OperationDefinition' && - definition.operation === 'subscription' - ); - }, - wsLink, - httpLink - ); - /** - * @todo as we add retries, determine which we'll need - * https://www.apollographql.com/docs/react/api/link/introduction/#additive-composition - * https://www.apollographql.com/docs/react/api/link/introduction/#directional-composition - */ - const additiveLink = from([errorLink, retryLink, splitLinks]); - - const client: ApolloClientType = new ApolloClient({ - link: additiveLink, - cache: new InMemoryCache(), - }); - unraidApiClient.value = client; - - provideApolloClient(client); - }; /** * Automatically called when an apiKey is unset in the serverStore */ @@ -219,31 +75,26 @@ export const useUnraidApiStore = defineStore('unraidApi', () => { * Can both start and restart the unraid-api depending on it's current status */ const restartUnraidApiClient = async () => { - const command = unraidApiStatus.value === 'offline' ? 'start' : 'restart'; - unraidApiStatus.value = 'restarting'; + const command = unraidApiStatus.value === "offline" ? "start" : "restart"; + unraidApiStatus.value = "restarting"; try { await WebguiUnraidApiCommand({ csrf_token: serverStore.csrf, command, }); - return setTimeout(() => { - if (unraidApiClient.value) { - createApolloClient(); - } - }, 5000); } catch (error) { - let errorMessage = 'Unknown error'; - if (typeof error === 'string') { + let errorMessage = "Unknown error"; + if (typeof error === "string") { errorMessage = error.toUpperCase(); } else if (error instanceof Error) { errorMessage = error.message; } errorsStore.setError({ - heading: 'Error: unraid-api restart', + heading: "Error: unraid-api restart", message: errorMessage, - level: 'error', - ref: 'restartUnraidApiClient', - type: 'request', + level: "error", + ref: "restartUnraidApiClient", + type: "request", }); } }; @@ -253,7 +104,6 @@ export const useUnraidApiStore = defineStore('unraidApi', () => { unraidApiStatus, prioritizeCorsError, unraidApiRestartAction, - createApolloClient, closeUnraidApiClient, restartUnraidApiClient, }; diff --git a/web/types/ui/notification.ts b/web/types/ui/notification.ts deleted file mode 100644 index 1f163a402..000000000 --- a/web/types/ui/notification.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface NotificationItemProps { - id: string; - title: string; - subject: string; - description: string; - importance: string; - link: string; - type: 'success' | 'warning' | 'alert'; - timestamp: string; -}