Files
api/web/store/server.ts
Michael Datelle d74d9f1246 test: create tests for stores batch 2 (#1351)
This is batch 2 of the web store tests. I started implementing the
recommended approach in the Pinia docs for the store tests and was able
to eliminate most of the mocking files. The server.test.ts file still
uses `pinia/testing` for now since I was having trouble with some of the
dependencies in the store due to it's complexity.

I also updated the `web-testing-rules`for Cursor in an effort to
streamline the AI's approach when helping with tests. There's some
things it still struggles with and seems like it doesn't always take the
rules into consideration until after it hits a snag. It likes to try and
create a mock for the store it's actually testing even though there's a
rule in place and has to be reminded.


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

## Summary by CodeRabbit

- **Tests**
- Added comprehensive test suites for account management and activation
flows to ensure smoother user interactions.
- Introduced new test coverage for callback actions, validating state
management and action handling.
- Added a new test file for the account store, covering various actions
and their interactions.
- Introduced a new test file for the activation code store, verifying
state management and modal visibility.
- Established a new test file for dropdown functionality, ensuring
accurate state management and action methods.
- Added a new test suite for the Errors store, covering error handling
and modal interactions.
- Enhanced test guidelines for Vue components and Pinia stores,
providing clearer documentation and best practices.
- Introduced a new test file for the InstallKey store, validating key
installation processes and error handling.
- Added a new test file for the dropdown store, ensuring accurate
visibility state management and action methods.

- **Refactor**
- Streamlined the structure of account action payloads for consistent
behavior.
- Improved code formatting and reactivity setups to enhance overall
operational stability.
- Enhanced reactivity capabilities in various stores by introducing new
Vue composition API functions.
- Improved code readability and organization in the installKey store and
other related files.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: mdatelle <mike@datelle.net>
2025-04-14 12:30:27 -04:00

1400 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @todo Check OS and Connect Plugin versions against latest via API every session
*/
import { computed, ref, toRefs, watch, watchEffect } from 'vue';
import { createPinia, defineStore, setActivePinia } from 'pinia';
import { useLazyQuery } from '@vue/apollo-composable';
import {
ArrowPathIcon,
ArrowRightOnRectangleIcon,
CogIcon,
GlobeAltIcon,
InformationCircleIcon,
KeyIcon,
QuestionMarkCircleIcon,
} from '@heroicons/vue/24/solid';
import {
WEBGUI_SETTINGS_MANAGMENT_ACCESS,
WEBGUI_TOOLS_REGISTRATION,
WEBGUI_TOOLS_UPDATE,
} from '~/helpers/urls';
import dayjs from 'dayjs';
import prerelease from 'semver/functions/prerelease';
import type { ApolloQueryResult } from '@apollo/client/core/index.js';
import type { Config, PartialCloudFragment, ServerStateQuery } from '~/composables/gql/graphql';
import type { Error } from '~/store/errors';
import type { Theme } from '~/themes/types';
import type {
Server,
ServerAccountCallbackSendPayload,
ServerconnectPluginInstalled,
ServerDateTimeFormat,
ServerKeyTypeForPurchase,
ServerOsVersionBranch,
ServerPurchaseCallbackSendPayload,
ServerState,
ServerStateArray,
ServerStateData,
ServerStateDataAction,
ServerStateDataKeyActions,
ServerUpdateOsResponse,
} from '~/types/server';
import { useFragment } from '~/composables/gql/fragment-masking';
import { WebguiState, WebguiUpdateIgnore } from '~/composables/services/webgui';
import { useAccountStore } from '~/store/account';
import { useActivationCodeStore } from '~/store/activationCode';
import { useErrorsStore } from '~/store/errors';
import { usePurchaseStore } from '~/store/purchase';
import { useThemeStore } from '~/store/theme';
import { useUnraidApiStore } from '~/store/unraidApi';
import { SERVER_CLOUD_FRAGMENT, SERVER_STATE_QUERY } from './server.fragment';
/**
* @see https://stackoverflow.com/questions/73476371/using-pinia-with-vue-js-web-components
* @see https://github.com/vuejs/pinia/discussions/1085
*/
setActivePinia(createPinia());
export const useServerStore = defineStore('server', () => {
const accountStore = useAccountStore();
const errorsStore = useErrorsStore();
const purchaseStore = usePurchaseStore();
const themeStore = useThemeStore();
const unraidApiStore = useUnraidApiStore();
/**
* State
*/
const apiVersion = ref<string>('');
const array = ref<ServerStateArray | undefined>();
// helps to display warning next to array status
const arrayWarning = computed(() => !!(stateDataError.value || serverConfigError.value));
const computedArray = computed(() => {
if (arrayWarning.value) {
if (array.value?.state === 'Stopped') {
return 'Stopped. The Array will not start until the above issue is resolved.';
}
return 'Started. If stopped, the Array will not restart until the above issue is resolved.';
}
return array.value?.state;
});
const avatar = ref<string>(''); // @todo potentially move to a user store
const caseModel = ref<string>('');
const cloud = ref<PartialCloudFragment | undefined>();
const config = ref<Config | undefined>();
const connectPluginInstalled = ref<ServerconnectPluginInstalled>('');
const connectPluginVersion = ref<string>('');
const csrf = ref<string>(''); // required to make requests to Unraid webgui
const dateTimeFormat = ref<ServerDateTimeFormat | undefined>();
const description = ref<string>('');
const deviceCount = ref<number>(0);
const email = ref<string>('');
const expireTime = ref<number>(0);
const flashBackupActivated = ref<boolean>(false);
const flashProduct = ref<string>('');
const flashVendor = ref<string>('');
const guid = ref<string>('');
const guidBlacklisted = ref<boolean>();
const guidRegistered = ref<boolean>();
const guidReplaceable = ref<boolean | undefined>();
const inIframe = ref<boolean>(window.self !== window.top);
const keyfile = ref<string>('');
const lanIp = ref<string>('');
const license = ref<string>('');
const locale = ref<string>('');
const name = ref<string>('');
const osVersion = ref<string>('');
const osVersionBranch = ref<ServerOsVersionBranch>('stable');
const rebootType = ref<'thirdPartyDriversDownloading' | 'downgrade' | 'update' | ''>('');
const rebootVersion = ref<string | undefined>();
const registered = ref<boolean>();
const regDevs = ref<number>(0); // use computedRegDevs to ensure it includes Basic, Plus, and Pro
const computedRegDevs = computed(() => {
if (regDevs.value > 0) {
return regDevs.value;
}
switch (regTy.value) {
case 'Starter':
case 'Basic':
return 6;
case 'Plus':
return 12;
case 'Unleashed':
case 'Lifetime':
case 'Pro':
case 'Trial':
return -1; // unlimited
default:
return 0;
}
});
const regGen = ref<number>(0);
const regGuid = ref<string>('');
const regTm = ref<number>(0);
const regTo = ref<string>('');
const regTy = ref<string>('');
const regExp = ref<number>(0);
const parsedRegExp = computed(() => (regExp.value ? dayjs(regExp.value).format('YYYY-MM-DD') : null));
const regUpdatesExpired = computed(() => {
if (!regExp.value) {
return false;
}
const today = dayjs();
const parsedUpdateExpirationDate = dayjs(regExp.value);
return today.isAfter(parsedUpdateExpirationDate, 'day');
});
const site = ref<string>('');
const ssoEnabled = ref<boolean>(false);
const state = ref<ServerState>();
const theme = ref<Theme>();
watch(theme, (newVal) => {
if (newVal) {
themeStore.setTheme(newVal);
}
});
const updateOsResponse = ref<ServerUpdateOsResponse>();
const updateOsIgnoredReleases = ref<string[]>([]);
const updateOsNotificationsEnabled = ref<boolean>(false);
const uptime = ref<number>(0);
const username = ref<string>(''); // @todo potentially move to a user store
const wanFQDN = ref<string>('');
const combinedKnownOrigins = ref<string[]>([]);
const apiServerStateRefresh =
ref<
(
variables?: Record<string, never> | undefined
) => Promise<ApolloQueryResult<ServerStateQuery>> | undefined
>();
/**
* Getters
*/
const isRemoteAccess = computed(
() =>
wanFQDN.value || (site.value && site.value.includes('www.') && site.value.includes('unraid.net'))
);
/**
* @todo configure
*/
const pluginOutdated = computed((): boolean => {
return false;
});
const isOsVersionStable = computed(() => {
const hasPrerelease = prerelease(osVersion.value);
return !hasPrerelease;
}); // used to determine if we should look for stable or next releases
const server = computed((): Server => {
return {
apiVersion: apiVersion.value,
array: array.value,
avatar: avatar.value,
connectPluginVersion: connectPluginVersion.value,
connectPluginInstalled: connectPluginInstalled.value,
description: description.value,
deviceCount: deviceCount.value,
email: email.value,
expireTime: expireTime.value,
flashProduct: flashProduct.value,
flashVendor: flashVendor.value,
guid: guid.value,
inIframe: inIframe.value,
keyfile: keyfile.value,
lanIp: lanIp.value,
license: license.value,
locale: locale.value,
name: name.value,
osVersion: osVersion.value,
osVersionBranch: osVersionBranch.value,
rebootType: rebootType.value,
rebootVersion: rebootVersion.value,
registered: registered.value,
regDevs: computedRegDevs.value,
regGen: regGen.value,
regGuid: regGuid.value,
regExp: regExp.value,
regUpdatesExpired: regUpdatesExpired.value,
site: site.value,
state: state.value,
theme: theme.value,
uptime: uptime.value,
username: username.value,
wanFQDN: wanFQDN.value,
};
});
const serverPurchasePayload = computed((): ServerPurchaseCallbackSendPayload => {
/** @todo refactor out. Just parse state on craft site to determine */
let keyTypeForPurchase: ServerKeyTypeForPurchase = 'Trial';
switch (state.value) {
case 'BASIC':
keyTypeForPurchase = 'Basic';
break;
case 'PLUS':
keyTypeForPurchase = 'Plus';
break;
case 'PRO':
keyTypeForPurchase = 'Pro';
break;
case 'STARTER':
keyTypeForPurchase = 'Starter';
break;
case 'UNLEASHED':
keyTypeForPurchase = 'Unleashed';
break;
}
const server: ServerPurchaseCallbackSendPayload = {
apiVersion: apiVersion.value,
connectPluginVersion: connectPluginVersion.value,
deviceCount: deviceCount.value,
email: email.value,
guid: guid.value,
inIframe: inIframe.value,
keyTypeForPurchase,
locale: locale.value,
osVersion: osVersion.value,
osVersionBranch: osVersionBranch.value,
registered: registered.value ?? false,
regExp: regExp.value,
regTy: regTy.value,
regUpdatesExpired: regUpdatesExpired.value,
state: state.value,
site: site.value,
};
const { code, partnerName } = storeToRefs(useActivationCodeStore());
if (code.value) {
server['activationCodeData'] = {
code: code.value,
};
if (partnerName.value) {
server['activationCodeData']['partnerName'] = partnerName.value;
}
}
return server;
});
const serverAccountPayload = computed((): ServerAccountCallbackSendPayload => {
return {
apiVersion: apiVersion.value,
caseModel: caseModel.value,
connectPluginVersion: connectPluginVersion.value,
deviceCount: deviceCount.value,
description: description.value,
expireTime: expireTime.value,
flashBackupActivated: flashBackupActivated.value,
flashProduct: flashProduct.value,
flashVendor: flashVendor.value,
guid: guid.value,
inIframe: inIframe.value,
keyfile: keyfile.value,
lanIp: lanIp.value,
name: name.value,
osVersion: osVersion.value,
osVersionBranch: osVersionBranch.value,
rebootType: rebootType.value,
rebootVersion: rebootVersion.value,
registered: registered.value ?? false,
regGuid: regGuid.value,
regExp: regExp.value,
regTy: regTy.value,
regUpdatesExpired: regUpdatesExpired.value,
site: site.value,
state: state.value,
wanFQDN: wanFQDN.value,
};
});
const serverDebugPayload = computed((): Server => {
const payload = {
apiVersion: apiVersion.value,
avatar: avatar.value,
connectPluginInstalled: connectPluginInstalled.value,
connectPluginVersion: connectPluginVersion.value,
description: description.value,
deviceCount: deviceCount.value,
email: email.value,
expireTime: expireTime.value,
flashProduct: flashProduct.value,
flashVendor: flashVendor.value,
guid: guid.value,
inIframe: inIframe.value,
lanIp: lanIp.value,
locale: locale.value,
name: name.value,
osVersion: osVersion.value,
osVersionBranch: osVersionBranch.value,
rebootType: rebootType.value,
rebootVersion: rebootVersion.value,
registered: registered.value,
regGen: regGen.value,
regGuid: regGuid.value,
regTy: regTy.value,
site: site.value,
state: state.value,
uptime: uptime.value,
username: username.value,
wanFQDN: wanFQDN.value,
};
// remove any empty values from object
return Object.fromEntries(
Object.entries(payload).filter(([_, v]) => v !== null && v !== undefined && v !== '')
);
});
const serverActionsDisable = computed(() => {
const disable = !!(
connectPluginInstalled.value &&
(unraidApiStore.unraidApiStatus !== 'online' || unraidApiStore.prioritizeCorsError)
);
return {
disable,
title: disable ? 'Requires the local unraid-api to be running successfully' : '',
};
});
const purchaseAction = computed((): ServerStateDataAction => {
return {
click: () => {
purchaseStore.purchase();
},
disabled: serverActionsDisable.value.disable,
external: true,
icon: KeyIcon,
name: 'purchase',
text: 'Purchase Key',
title: serverActionsDisable.value.title,
};
});
const upgradeAction = computed((): ServerStateDataAction => {
return {
click: () => {
purchaseStore.upgrade();
},
disabled: serverActionsDisable.value.disable,
external: true,
icon: KeyIcon,
name: 'upgrade',
text: 'Upgrade Key',
title: serverActionsDisable.value.title,
};
});
const recoverAction = computed((): ServerStateDataAction => {
return {
click: () => {
accountStore.recover();
},
disabled: serverActionsDisable.value.disable,
external: true,
icon: KeyIcon,
name: 'recover',
text: 'Recover Key',
title: serverActionsDisable.value.title,
};
});
const redeemAction = computed((): ServerStateDataAction => {
const { code } = storeToRefs(useActivationCodeStore());
return {
click: () => {
if (code.value) {
purchaseStore.activate();
} else {
purchaseStore.redeem();
}
},
disabled: serverActionsDisable.value.disable,
external: true,
icon: KeyIcon,
name: code.value ? 'activate' : 'redeem',
text: code.value ? 'Activate Now' : 'Redeem Activation Code',
title: serverActionsDisable.value.title,
};
});
const renewAction = computed((): ServerStateDataAction => {
return {
click: () => {
purchaseStore.renew();
},
disabled: serverActionsDisable.value.disable,
external: true,
icon: KeyIcon,
name: 'renew',
text: 'Extend License to Enable OS Updates',
title: serverActionsDisable.value.title,
};
});
const replaceAction = computed((): ServerStateDataAction => {
return {
click: () => {
accountStore.replace();
},
external: true,
icon: KeyIcon,
name: 'replace',
text: 'Replace Key',
};
});
const signInAction = computed((): ServerStateDataAction => {
return {
click: () => {
accountStore.signIn();
},
disabled: serverActionsDisable.value.disable,
external: true,
icon: GlobeAltIcon,
name: 'signIn',
text: 'Sign In with Unraid.net Account',
title: serverActionsDisable.value.title,
};
});
/**
* The Sign Out action is a computed property because it depends on the state of the keyfile & unraid-api being online
*/
const signOutAction = computed((): ServerStateDataAction => {
const disabled: boolean = !keyfile.value || serverActionsDisable.value.disable;
let title = '';
if (!keyfile.value) {
title = 'Sign Out requires a keyfile';
}
if (serverActionsDisable.value.disable) {
title = serverActionsDisable.value.title;
}
return {
click: () => {
accountStore.signOut();
},
disabled,
external: true,
icon: ArrowRightOnRectangleIcon,
name: 'signOut',
text: 'Sign Out of Unraid.net',
title,
};
});
const trialExtendAction = computed((): ServerStateDataAction => {
return {
click: () => {
accountStore.trialExtend();
},
disabled: serverActionsDisable.value.disable,
external: true,
icon: KeyIcon,
name: 'trialExtend',
text: 'Extend Trial',
title: serverActionsDisable.value.title,
};
});
const trialStartAction = computed((): ServerStateDataAction => {
return {
click: () => {
accountStore.trialStart();
},
disabled: serverActionsDisable.value.disable,
external: true,
icon: KeyIcon,
name: 'trialStart',
text: 'Start Free 30 Day Trial',
title: serverActionsDisable.value.title,
};
});
let messageEGUID = '';
const stateData = computed((): ServerStateData => {
switch (state.value) {
case 'ENOKEYFILE':
return {
actions: [
...(!registered.value && connectPluginInstalled.value ? [signInAction.value] : []),
...[trialStartAction.value, purchaseAction.value, redeemAction.value, recoverAction.value],
...(registered.value && connectPluginInstalled.value ? [signOutAction.value] : []),
],
humanReadable: 'No Keyfile',
heading: "Let's Unleash Your Hardware",
message:
'<p>Choose an option below, then use our <a href="https://unraid.net/getting-started" target="_blank" rel="noreffer noopener">Getting Started Guide</a> to configure your array in less than 15 minutes.</p>',
};
case 'TRIAL':
return {
actions: [
...(!registered.value && connectPluginInstalled.value ? [signInAction.value] : []),
...[purchaseAction.value, redeemAction.value],
...(registered.value && connectPluginInstalled.value ? [signOutAction.value] : []),
],
humanReadable: 'Trial',
heading: 'Thank you for choosing Unraid OS!',
message:
'<p>Your <em>Trial</em> key includes all the functionality and device support of an <em>Unleashed</em> key.</p><p>After your <em>Trial</em> has reached expiration, your server <strong>still functions normally</strong> until the next time you Stop the array or reboot your server.</p><p>At that point you may either purchase a license key or request a <em>Trial</em> extension.</p>',
};
case 'EEXPIRED':
return {
actions: [
...(!registered.value && connectPluginInstalled.value ? [signInAction.value] : []),
...[purchaseAction.value, redeemAction.value],
...(trialExtensionEligible.value ? [trialExtendAction.value] : []),
...(registered.value && connectPluginInstalled.value ? [signOutAction.value] : []),
],
error: true,
humanReadable: 'Trial Expired',
heading: 'Your Trial has expired',
message: trialExtensionEligible.value
? '<p>To continue using Unraid OS you may purchase a license key. Alternately, you may request a Trial extension.</p>'
: '<p>You have used all your Trial extensions. To continue using Unraid OS you may purchase a license key.</p>',
};
case 'BASIC':
case 'STARTER':
return {
actions: [
...(!registered.value && connectPluginInstalled.value ? [signInAction.value] : []),
...(regUpdatesExpired.value ? [renewAction.value] : []),
...[upgradeAction.value],
...(registered.value && connectPluginInstalled.value ? [signOutAction.value] : []),
],
humanReadable: state.value === 'BASIC' ? 'Basic' : 'Starter',
heading: 'Thank you for choosing Unraid OS!',
message:
!registered.value && connectPluginInstalled.value
? '<p>Register for Connect by signing in to your Unraid.net account</p>'
: guidRegistered.value
? '<p>To support more storage devices as your server grows, click Upgrade Key.</p>'
: '',
};
case 'PLUS':
return {
actions: [
...(!registered.value && connectPluginInstalled.value ? [signInAction.value] : []),
...[upgradeAction.value],
...(registered.value && connectPluginInstalled.value ? [signOutAction.value] : []),
],
humanReadable: 'Plus',
heading: 'Thank you for choosing Unraid OS!',
message:
!registered.value && connectPluginInstalled.value
? '<p>Register for Connect by signing in to your Unraid.net account</p>'
: guidRegistered.value
? '<p>To support more storage devices as your server grows, click Upgrade Key.</p>'
: '',
};
case 'PRO':
case 'LIFETIME':
case 'UNLEASHED':
return {
actions: [
...(!registered.value && connectPluginInstalled.value ? [signInAction.value] : []),
...(regUpdatesExpired.value ? [renewAction.value] : []),
...(state.value === 'UNLEASHED' ? [upgradeAction.value] : []),
...(registered.value && connectPluginInstalled.value ? [signOutAction.value] : []),
],
humanReadable:
state.value === 'PRO' ? 'Pro' : state.value === 'LIFETIME' ? 'Lifetime' : 'Unleashed',
heading: 'Thank you for choosing Unraid OS!',
message:
!registered.value && connectPluginInstalled.value
? '<p>Register for Connect by signing in to your Unraid.net account</p>'
: '',
};
case 'EGUID':
if (guidReplaceable.value) {
messageEGUID =
'<p>Your Unraid registration key is ineligible for replacement as it has been replaced within the last 12 months.</p>';
} else if (guidReplaceable.value === false && guidBlacklisted.value) {
messageEGUID =
'<p>The license key file does not correspond to the USB Flash boot device. Please copy the correct key file to the /config directory on your USB Flash boot device or choose Purchase Key.</p><p>Your Unraid registration key is ineligible for replacement as it is blacklisted.</p>';
} else if (guidReplaceable.value === false && !guidBlacklisted.value) {
messageEGUID =
'<p>The license key file does not correspond to the USB Flash boot device. Please copy the correct key file to the /config directory on your USB Flash boot device or choose Purchase Key.</p><p>Your Unraid registration key is ineligible for replacement as it has been replaced within the last 12 months.</p>';
} else {
// basically guidReplaceable.value === null
messageEGUID =
'<p>The license key file does not correspond to the USB Flash boot device. Please copy the correct key file to the /config directory on your USB Flash boot device.</p><p>You may also attempt to Purchase or Replace your key.</p>';
}
return {
actions: [
...(!registered.value && connectPluginInstalled.value ? [signInAction.value] : []),
...[replaceAction.value, purchaseAction.value, redeemAction.value],
...(registered.value && connectPluginInstalled.value ? [signOutAction.value] : []),
],
error: true,
humanReadable: 'Flash GUID Error',
heading: 'Registration key / USB Flash GUID mismatch',
message: messageEGUID,
};
case 'EGUID1':
return {
actions: [
...(!registered.value && connectPluginInstalled.value ? [signInAction.value] : []),
...[purchaseAction.value, redeemAction.value],
...(registered.value && connectPluginInstalled.value ? [signOutAction.value] : []),
],
error: true,
humanReadable: 'Multiple License Keys Present',
heading: 'Multiple License Keys Present',
message:
'<p>There are multiple license key files present on your USB flash device and none of them correspond to the USB Flash boot device. Please remove all key files, except the one you want to replace, from the /config directory on your USB Flash boot device.</p><p>Alternately you may purchase a license key for this USB flash device.</p><p>If you want to replace one of your license keys with a new key bound to this USB Flash device, please first remove all other key files first.</p>',
// signInToFix: true, // @todo is this needed?
};
case 'ENOKEYFILE2':
return {
actions: [
...(!registered.value && connectPluginInstalled.value ? [signInAction.value] : []),
...[recoverAction.value, purchaseAction.value, redeemAction.value],
...(registered.value ? [signOutAction.value] : []),
],
error: true,
humanReadable: 'Missing key file',
heading: 'Missing key file',
message: connectPluginInstalled.value
? '<p>Your license key file is corrupted or missing. The key file should be located in the /config directory on your USB Flash boot device.</p><p>You may attempt to recover your key with your Unraid.net account.</p><p>If this was an expired Trial installation, you may purchase a license key.</p>'
: '<p>Your license key file is corrupted or missing. The key file should be located in the /config directory on your USB Flash boot device.</p><p>If you do not have a backup copy of your license key file you may attempt to recover your key.</p><p>If this was an expired Trial installation, you may purchase a license key.</p>',
};
case 'ETRIAL':
return {
actions: [
...(!registered.value && connectPluginInstalled.value ? [signInAction.value] : []),
...[purchaseAction.value, redeemAction.value],
...(registered.value && connectPluginInstalled.value ? [signOutAction.value] : []),
],
error: true,
humanReadable: 'Invalid installation',
heading: 'Invalid installation',
message:
'<p>It is not possible to use a Trial key with an existing Unraid OS installation.</p><p>You may purchase a license key corresponding to this USB Flash device to continue using this installation.</p>',
};
case 'ENOKEYFILE1':
return {
actions: [
...(!registered.value && connectPluginInstalled.value ? [signInAction.value] : []),
...[purchaseAction.value, redeemAction.value],
...(registered.value && connectPluginInstalled.value ? [signOutAction.value] : []),
],
error: true,
humanReadable: 'No Keyfile',
heading: 'No USB flash configuration data',
message: '<p>There is a problem with your USB Flash device</p>',
};
case 'ENOFLASH':
case 'ENOFLASH1':
case 'ENOFLASH2':
case 'ENOFLASH3':
case 'ENOFLASH4':
case 'ENOFLASH5':
case 'ENOFLASH6':
case 'ENOFLASH7':
return {
error: true,
humanReadable: 'No Flash',
heading: 'Cannot access your USB Flash boot device',
message: '<p>There is a physical problem accessing your USB Flash boot device</p>',
};
case 'EBLACKLISTED':
return {
error: true,
humanReadable: 'BLACKLISTED',
heading: 'Blacklisted USB Flash GUID',
message:
'<p>This USB Flash boot device has been blacklisted. This can occur as a result of transferring your license key to a replacement USB Flash device, and you are currently booted from your old USB Flash device.</p><p>A USB Flash device may also be blacklisted if we discover the serial number is not unique this is common with USB card readers.</p>',
};
case 'EBLACKLISTED1':
return {
error: true,
humanReadable: 'BLACKLISTED',
heading: 'USB Flash device error',
message:
'<p>This USB Flash device has an invalid GUID. Please try a different USB Flash device</p>',
};
case 'EBLACKLISTED2':
return {
error: true,
humanReadable: 'BLACKLISTED',
heading: 'USB Flash has no serial number',
message:
'<p>This USB Flash boot device has been blacklisted. This can occur as a result of transferring your license key to a replacement USB Flash device, and you are currently booted from your old USB Flash device.</p><p>A USB Flash device may also be blacklisted if we discover the serial number is not unique this is common with USB card readers.</p>',
};
case 'ENOCONN':
return {
error: true,
humanReadable: 'Trial Requires Internet Connection',
heading: 'Cannot validate Unraid Trial key',
message:
'<p>Your Trial key requires an internet connection.</p><p><a href="/Settings/NetworkSettings" class="underline">Please check Settings > Network</a></p>',
};
default:
return {
error: true,
humanReadable: 'Stale',
heading: 'Stale Server',
message: '<p>Please refresh the page to ensure you load your latest configuration</p>',
};
}
});
const stateDataError = computed((): Error | undefined => {
if (!stateData.value?.error) {
return undefined;
}
return {
actions: [
{
click: () => {
errorsStore.openTroubleshoot({
email: email.value,
includeUnraidApiLogs: !!connectPluginInstalled.value,
});
},
icon: QuestionMarkCircleIcon,
text: 'Contact Support',
},
],
debugServer: serverDebugPayload.value,
heading: stateData.value?.heading ?? '',
level: 'error',
message: stateData.value?.message ?? '',
ref: `stateDataError__${state.value}`,
type: 'serverState',
};
});
watch(stateDataError, (newVal, oldVal) => {
if (oldVal && oldVal.ref) {
errorsStore.removeErrorByRef(oldVal.ref);
}
if (newVal) {
errorsStore.setError(newVal);
}
});
const authActionsNames = ['signIn', 'signOut'];
// Extract sign in / out from actions so we can display seperately as needed
const authAction = computed((): ServerStateDataAction | undefined => {
if (!stateData.value.actions) {
return;
}
return stateData.value.actions.find((action) => authActionsNames.includes(action.name));
});
// Remove sign in / out from actions so we can display them separately
const keyActions = computed((): ServerStateDataAction[] | undefined => {
if (!stateData.value.actions) {
return;
}
return stateData.value.actions.filter((action) => !authActionsNames.includes(action.name));
});
const trialExtensionEligible = computed(() => !regGen.value || regGen.value < 2);
const serverConfigError = computed((): Error | undefined => {
if (!config.value?.valid && config.value?.error) {
switch (config.value?.error) {
// case 'UNKNOWN_ERROR':
// return {
// heading: 'Unknown Error',
// level: 'error',
// message: 'An unknown internal error occurred.',
// ref: 'configError',
// type: 'server',
// };
case 'INELIGIBLE':
return {
heading: 'Ineligible for OS Version',
level: 'error',
message:
'Your License Key does not support this OS Version. OS build date greater than key expiration. Please consider extending your registration key.',
actions: [
{
href: WEBGUI_TOOLS_REGISTRATION.toString(),
icon: CogIcon,
text: 'Learn More at Tools > Registration',
},
],
ref: 'configError',
type: 'server',
};
case 'INVALID':
return {
heading: 'Too Many Devices',
level: 'error',
message:
'You have exceeded the number of devices allowed for your license. Please remove a device to start the array, or upgrade your key to support more devices.',
ref: 'configError',
type: 'server',
};
case 'NO_KEY_SERVER':
return {
heading: 'Check Network Connection',
level: 'error',
message: 'Unable to validate your trial key. Please check your network connection.',
ref: 'configError',
type: 'server',
};
case 'WITHDRAWN':
return {
heading: 'OS Version Withdrawn',
level: 'error',
message: 'This OS release should not be run. OS Update Required.',
actions: [
{
href: WEBGUI_TOOLS_UPDATE.toString(),
icon: ArrowPathIcon,
text: 'Check for Update',
},
],
ref: 'configError',
type: 'server',
};
}
return undefined;
}
});
watch(serverConfigError, (newVal, oldVal) => {
if (oldVal && oldVal.ref) {
errorsStore.removeErrorByRef(oldVal.ref);
}
if (newVal) {
errorsStore.setError(newVal);
}
});
const tooManyDevices = computed((): boolean => {
return (
(deviceCount.value !== 0 &&
computedRegDevs.value > 0 &&
deviceCount.value > computedRegDevs.value) ||
(!config.value?.valid && config.value?.error === 'INVALID')
);
});
const pluginInstallFailed = computed((): Error | undefined => {
if (connectPluginInstalled.value && connectPluginInstalled.value.includes('_installFailed')) {
return {
actions: [
{
external: true,
href: 'https://forums.unraid.net/topic/112073-my-servers-releases/#comment-1154449',
icon: InformationCircleIcon,
text: 'Learn More',
},
],
heading: 'Unraid Connect Install Failed',
level: 'error',
message: 'Rebooting will likely solve this.',
ref: 'pluginInstallFailed',
type: 'server',
};
}
return undefined;
});
watch(pluginInstallFailed, (newVal, oldVal) => {
if (oldVal && oldVal.ref) {
errorsStore.removeErrorByRef(oldVal.ref);
}
if (newVal) {
errorsStore.setError(newVal);
}
});
/**
* Deprecation warning for [hash].unraid.net SSL certs. Deprecation started 2023-01-01
*/
const deprecatedUnraidSSL = ref<Error | undefined>(
window.location.hostname.includes('localhost') && window.location.port !== '4321'
? {
actions: [
{
href: WEBGUI_SETTINGS_MANAGMENT_ACCESS.toString(),
icon: CogIcon,
text: 'Go to Management Access Now',
},
{
external: true,
href: 'https://unraid.net/blog/ssl-certificate-update',
icon: InformationCircleIcon,
text: 'Learn More',
},
],
forumLink: true,
heading: 'SSL certificates for unraid.net deprecated',
level: 'error',
message:
'On January 1st, 2023 SSL certificates for unraid.net were deprecated. You MUST provision a new SSL certificate to use our new myunraid.net domain. You can do this on the Settings > Management Access page.',
ref: 'deprecatedUnraidSSL',
type: 'server',
}
: undefined
);
watch(deprecatedUnraidSSL, (newVal, oldVal) => {
if (oldVal && oldVal.ref) {
errorsStore.removeErrorByRef(oldVal.ref);
}
if (newVal) {
errorsStore.setError(newVal);
}
});
const cloudError = computed((): Error | undefined => {
// if we're not registered or we're in the process of signing out then the cloud error should be ignored
if (
!registered.value ||
!cloud.value?.error ||
accountStore.accountActionType === 'signOut' ||
accountStore.accountActionType === 'oemSignOut'
) {
return;
}
// otherwise if we are we should display any cloud errors
return {
actions: [
{
click: () => {
errorsStore.openTroubleshoot({
email: email.value,
includeUnraidApiLogs: !!connectPluginInstalled.value,
});
},
icon: QuestionMarkCircleIcon,
text: 'Contact Support',
},
],
debugServer: serverDebugPayload.value,
heading: 'Unraid Connect Error',
level: 'error',
message: cloud.value?.error ?? '',
ref: 'cloudError',
type: 'unraidApiState',
};
});
watch(cloudError, (newVal, oldVal) => {
if (oldVal && oldVal.ref) {
errorsStore.removeErrorByRef(oldVal.ref);
}
if (newVal) {
errorsStore.setError(newVal);
}
});
const serverErrors = computed(() => {
return [
stateDataError.value,
serverConfigError.value,
pluginInstallFailed.value,
deprecatedUnraidSSL.value,
cloudError.value,
].filter(Boolean);
});
/**
* Actions
*/
const setServer = (data: Server) => {
console.debug('[setServer]', data);
if (typeof data?.array !== 'undefined') {
array.value = data.array;
}
if (typeof data?.apiVersion !== 'undefined') {
apiVersion.value = data.apiVersion;
}
if (typeof data?.avatar !== 'undefined') {
avatar.value = data.avatar;
}
if (typeof data?.caseModel !== 'undefined') {
caseModel.value = data.caseModel;
}
if (typeof data?.cloud !== 'undefined') {
cloud.value = data.cloud;
}
if (typeof data?.combinedKnownOrigins !== 'undefined') {
combinedKnownOrigins.value = data.combinedKnownOrigins;
}
if (typeof data?.config !== 'undefined') {
config.value = data.config;
}
if (typeof data?.connectPluginInstalled !== 'undefined') {
connectPluginInstalled.value = data.connectPluginInstalled;
}
if (typeof data?.connectPluginVersion !== 'undefined') {
connectPluginVersion.value = data.connectPluginVersion;
}
if (typeof data?.csrf !== 'undefined') {
csrf.value = data.csrf;
}
if (typeof data?.dateTimeFormat !== 'undefined') {
dateTimeFormat.value = data.dateTimeFormat;
}
if (typeof data?.description !== 'undefined') {
description.value = data.description;
}
if (typeof data?.deviceCount !== 'undefined') {
deviceCount.value = data.deviceCount;
}
if (typeof data?.email !== 'undefined') {
email.value = data.email;
}
if (typeof data?.expireTime !== 'undefined') {
expireTime.value = data.expireTime;
}
if (typeof data?.flashBackupActivated !== 'undefined') {
flashBackupActivated.value = data.flashBackupActivated;
}
if (typeof data?.flashProduct !== 'undefined') {
flashProduct.value = data.flashProduct;
}
if (typeof data?.flashVendor !== 'undefined') {
flashVendor.value = data.flashVendor;
}
if (typeof data?.guid !== 'undefined') {
guid.value = data.guid;
}
if (typeof data?.keyfile !== 'undefined') {
keyfile.value = data.keyfile;
}
if (typeof data?.lanIp !== 'undefined') {
lanIp.value = data.lanIp;
}
if (typeof data?.license !== 'undefined') {
license.value = data.license;
}
if (typeof data?.locale !== 'undefined') {
locale.value = data.locale;
}
if (typeof data?.name !== 'undefined') {
name.value = data.name;
}
if (typeof data?.osVersion !== 'undefined') {
osVersion.value = data.osVersion;
}
if (typeof data?.osVersionBranch !== 'undefined') {
osVersionBranch.value = data.osVersionBranch;
}
if (typeof data?.rebootType !== 'undefined') {
rebootType.value = data.rebootType;
}
if (typeof data?.rebootVersion !== 'undefined') {
rebootVersion.value = data.rebootVersion;
}
if (typeof data?.registered !== 'undefined') {
registered.value = data.registered;
}
if (typeof data?.regGen !== 'undefined') {
regGen.value = data.regGen;
}
if (typeof data?.regGuid !== 'undefined') {
regGuid.value = data.regGuid;
}
if (typeof data?.regTy !== 'undefined') {
regTy.value = data.regTy;
}
if (typeof data?.regExp !== 'undefined') {
regExp.value = data.regExp;
}
if (typeof data?.site !== 'undefined') {
site.value = data.site;
}
if (typeof data?.state !== 'undefined') {
state.value = data.state;
}
if (typeof data?.theme !== 'undefined') {
theme.value = data.theme;
}
if (typeof data?.updateOsIgnoredReleases !== 'undefined') {
updateOsIgnoredReleases.value = data.updateOsIgnoredReleases;
}
if (typeof data?.updateOsNotificationsEnabled !== 'undefined') {
updateOsNotificationsEnabled.value = data.updateOsNotificationsEnabled;
}
if (typeof data?.updateOsResponse !== 'undefined') {
updateOsResponse.value = data.updateOsResponse;
}
if (typeof data?.uptime !== 'undefined') {
uptime.value = data.uptime;
}
if (typeof data?.username !== 'undefined') {
username.value = data.username;
}
if (typeof data?.wanFQDN !== 'undefined') {
wanFQDN.value = data.wanFQDN;
}
if (typeof data?.regTm !== 'undefined') {
regTm.value = data.regTm;
}
if (typeof data?.regTo !== 'undefined') {
regTo.value = data.regTo;
}
if (typeof data?.ssoEnabled !== 'undefined') {
ssoEnabled.value = Boolean(data.ssoEnabled);
}
if (typeof data.activationCodeData !== 'undefined') {
const activationCodeStore = useActivationCodeStore();
activationCodeStore.setData(data.activationCodeData);
}
};
const setUpdateOsResponse = (response: ServerUpdateOsResponse) => {
updateOsResponse.value = response;
};
const mutateServerStateFromApi = (data: ServerStateQuery): Server => {
console.debug('mutateServerStateFromApi', data);
const mutatedData: Server = {
// if we get an owners obj back and the username is root we don't want to overwrite the values
...(data.owner && data.owner.username !== 'root'
? {
// avatar: data.owner.avatar,
username: data.owner.username ?? '',
registered: true,
}
: {
// handles sign outs
// avatar: data.owner.avatar,
username: '',
registered: false,
}),
name: data.info && data.info.os && data.info.os.hostname ? data.info.os.hostname : undefined,
keyfile:
data.registration && data.registration.keyFile && data.registration.keyFile.contents
? data.registration.keyFile.contents
: undefined,
regGen: data.vars && data.vars.regGen ? parseInt(data.vars.regGen) : undefined,
state: data.vars && data.vars.regState ? data.vars.regState : undefined,
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,
},
expireTime:
data.registration && data.registration.expiration ? parseInt(data.registration.expiration) : 0,
cloud: data.cloud ? useFragment(SERVER_CLOUD_FRAGMENT, data.cloud) : undefined,
regExp:
data.registration && data.registration.updateExpiration
? Number(data.registration.updateExpiration)
: undefined,
};
console.debug('mutatedData', mutatedData);
return mutatedData;
};
const { load, refetch: refetchServerState, onResult, onError } = useLazyQuery(SERVER_STATE_QUERY);
setTimeout(() => {
load();
}, 500);
onResult((result) => {
if (result.data) {
const { unraidApiStatus } = toRefs(useUnraidApiStore());
unraidApiStatus.value = 'online';
apiServerStateRefresh.value = refetchServerState;
const mutatedServerStateResult = mutateServerStateFromApi(result.data);
setServer(mutatedServerStateResult);
}
});
onError((error) => {
console.error('[serverStateQuery] error', error);
const { unraidApiStatus } = toRefs(useUnraidApiStore());
unraidApiStatus.value = 'offline';
});
const phpServerStateRefresh = async () => {
try {
const stateResponse: Server = await WebguiState.get().json();
setServer(stateResponse);
return stateResponse;
} catch (error) {
console.error('[phpServerStateRefresh] error', error);
}
};
let refreshCount = 0;
const refreshLimit = 20;
const refreshTimeout = 250;
const refreshServerStateStatus = ref<'done' | 'ready' | 'refreshing' | 'timeout'>('ready');
const refreshServerState = async () => {
// If we've reached the refresh limit, stop refreshing
if (refreshCount >= refreshLimit) {
refreshServerStateStatus.value = 'timeout';
return false;
}
refreshCount++;
refreshServerStateStatus.value = 'refreshing';
// Values to compare to response values should be set before the response is set
const oldRegistered = registered.value;
const oldState = state.value;
const oldRegExp = regExp.value;
const fromApi = Boolean(apiServerStateRefresh.value);
// Fetch the server state from the API or PHP
const response = fromApi ? await refetchServerState() : await phpServerStateRefresh();
if (!response) {
return setTimeout(() => {
refreshServerState();
}, refreshTimeout);
}
// Extract the new values from the response
const output: {
newRegistered: boolean;
newState: ServerState | ServerState | null;
newRegExp: number | null;
} = {
newRegistered: false,
newState: null,
newRegExp: null,
};
if ('data' in response) {
output.newRegistered = Boolean(response.data.owner && response.data.owner.username !== 'root');
output.newState = response.data.vars?.regState ?? null;
output.newRegExp = Number(response.data.registration?.updateExpiration ?? 0);
} else {
output.newRegistered = Boolean(response.registered);
output.newState = response.state;
output.newRegExp = Number(response.regExp ?? 0);
}
// Compare the new values to the old values
const registrationStatusChanged = output.newRegistered !== oldRegistered;
const stateChanged = output.newState !== oldState;
const regExpChanged = output.newRegExp ?? 0 > oldRegExp;
// If the registration status or state changed, stop refreshing
if (registrationStatusChanged || stateChanged || regExpChanged) {
refreshServerStateStatus.value = 'done';
return true;
}
// If we haven't reached the refresh limit, try again
setTimeout(() => {
return refreshServerState();
}, refreshTimeout);
};
const filteredKeyActions = (
filterType: 'by' | 'out',
filters: string | ServerStateDataKeyActions[]
): ServerStateDataAction[] | undefined => {
if (!stateData.value.actions) {
return;
}
return stateData.value.actions.filter((action) => {
return filterType === 'out'
? !filters.includes(action.name as ServerStateDataKeyActions)
: filters.includes(action.name as ServerStateDataKeyActions);
});
};
const setRebootVersion = (version: string) => {
rebootVersion.value = version;
};
watchEffect(() => {
if (rebootVersion.value) {
console.debug('[server.rebootVersion]', rebootVersion.value);
}
});
const updateOsIgnoreRelease = (release: string) => {
updateOsIgnoredReleases.value.push(release);
const response = WebguiUpdateIgnore({
action: 'ignoreVersion',
version: release,
});
console.debug('[updateOsIgnoreRelease] response', response);
/** @todo when update check modal is displayed and there's no available updates, allow users to remove ignored releases from the list */
};
const updateOsRemoveIgnoredRelease = (release: string) => {
updateOsIgnoredReleases.value = updateOsIgnoredReleases.value.filter((r) => r !== release);
const response = WebguiUpdateIgnore({
action: 'removeIgnoredVersion',
version: release,
});
console.debug('[updateOsRemoveIgnoredRelease] response', response);
};
const updateOsRemoveAllIgnoredReleases = () => {
updateOsIgnoredReleases.value = [];
const response = WebguiUpdateIgnore({
action: 'removeAllIgnored',
});
console.debug('[updateOsRemoveAllIgnoredReleases] response', response);
};
return {
// state
array,
avatar,
cloud,
config,
connectPluginInstalled,
csrf,
dateTimeFormat,
description,
deviceCount,
expireTime,
flashBackupActivated,
flashProduct,
flashVendor,
guid,
keyfile,
inIframe,
locale,
lanIp,
name,
osVersion,
osVersionBranch,
rebootType,
rebootVersion,
registered,
computedRegDevs,
regGen,
regGuid,
regTm,
regTo,
regTy,
regExp,
parsedRegExp,
regUpdatesExpired,
site,
ssoEnabled,
state,
theme,
updateOsIgnoredReleases,
updateOsNotificationsEnabled,
updateOsResponse,
uptime,
username,
refreshServerStateStatus,
isOsVersionStable,
renewAction,
// getters
authAction,
deprecatedUnraidSSL,
isRemoteAccess,
keyActions,
pluginInstallFailed,
pluginOutdated,
server,
serverAccountPayload,
serverPurchasePayload,
stateData,
stateDataError,
serverErrors,
tooManyDevices,
serverConfigError,
arrayWarning,
computedArray,
// actions
setServer,
setUpdateOsResponse,
refreshServerState,
filteredKeyActions,
setRebootVersion,
updateOsIgnoreRelease,
updateOsRemoveIgnoredRelease,
updateOsRemoveAllIgnoredReleases,
};
});