restart when developer sandbox is toggled (#1232)

When the sandbox is toggled via api, the api now restarts after a 3 second delay. The Connect settings UI also informs users, when applicable, that the api will restart before and after they apply their settings.

## Summary by CodeRabbit

- **New Features**
- Improved deployment commands now allow specifying a target server,
streamlining the deployment process.
- Enhanced settings synchronization provides clear feedback on when a
system restart is required after updates.
- Automatic service restart is now triggered after applying connection
settings changes.
- User interface enhancements include added contextual descriptions for
toggle controls.
- New functionality to refetch connection settings after updates,
providing users with the latest information.

- **Bug Fixes**
- Improved user feedback regarding API restart status after settings
updates.
This commit is contained in:
Pujit Mehrotra
2025-03-18 10:33:09 -04:00
committed by GitHub
parent dd9983c8b7
commit a4f69dc539
6 changed files with 69 additions and 22 deletions

View File

@@ -9,8 +9,8 @@ default:
pnpm run build
# deploys to an unraid server
@deploy:
./scripts/deploy-dev.sh
@deploy remote:
./scripts/deploy-dev.sh {{remote}}
# build & deploy
bd: build deploy
alias b := build
alias d := deploy

View File

@@ -61,8 +61,10 @@ export class ConnectSettingsService {
/**
* Syncs the settings to the store and writes the config to disk
* @param settings - The settings to sync
* @returns true if a restart is required, false otherwise
*/
async syncSettings(settings: Partial<ApiSettingsInput>) {
let restartRequired = false;
const { getters } = await import('@app/store/index.js');
const { nginx } = getters.emhttp();
if (settings.accessType === WAN_ACCESS_TYPE.DISABLED) {
@@ -89,10 +91,11 @@ export class ConnectSettingsService {
await this.updateAllowedOrigins(settings.extraOrigins);
}
if (typeof settings.sandbox === 'boolean') {
await this.setSandboxMode(settings.sandbox);
restartRequired ||= await this.setSandboxMode(settings.sandbox);
}
const { writeConfigSync } = await import('@app/store/sync/config-disk-sync.js');
writeConfigSync('flash');
return restartRequired;
}
private async updateAllowedOrigins(origins: string[]) {
@@ -100,9 +103,18 @@ export class ConnectSettingsService {
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' } }));
/**
* Sets the sandbox mode and returns true if the mode was changed
* @param sandboxEnabled - Whether to enable sandbox mode
* @returns true if the mode was changed, false otherwise
*/
private async setSandboxMode(sandboxEnabled: boolean): Promise<boolean> {
const { store, getters } = await import('@app/store/index.js');
const currentSandbox = getters.config().local.sandbox;
const sandbox = sandboxEnabled ? 'yes' : 'no';
if (currentSandbox === sandbox) return false;
store.dispatch(updateUserConfig({ local: { sandbox } }));
return true;
}
private async updateRemoteAccess(input: SetupRemoteAccessInput): Promise<boolean> {
@@ -137,7 +149,7 @@ export class ConnectSettingsService {
async buildSettingsSchema(): Promise<SettingSlice> {
const slices = [
await this.remoteAccessSlice(),
this.sandboxSlice(),
await 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
@@ -257,7 +269,10 @@ export class ConnectSettingsService {
/**
* Developer sandbox settings slice
*/
sandboxSlice(): SettingSlice {
async sandboxSlice(): Promise<SettingSlice> {
const { sandbox } = await this.getCurrentSettings();
const description =
'The developer sandbox is available at <code><a class="underline" href="/graphql" target="_blank">/graphql</a></code>.';
return {
properties: {
sandbox: {
@@ -273,6 +288,7 @@ export class ConnectSettingsService {
label: 'Enable Developer Sandbox:',
options: {
toggle: true,
description: sandbox ? description : undefined,
},
},
],

View File

@@ -16,11 +16,15 @@ import { RemoteAccessController } from '@app/remoteAccess/remote-access-controll
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';
import { ConnectService } from '@app/unraid-api/graph/connect/connect.service.js';
@Resolver('Connect')
export class ConnectResolver implements ConnectResolvers {
protected logger = new Logger(ConnectResolver.name);
constructor(private readonly connectSettingsService: ConnectSettingsService) {}
constructor(
private readonly connectSettingsService: ConnectSettingsService,
private readonly connectService: ConnectService
) {}
@Query('connect')
@UsePermissions({
@@ -62,8 +66,16 @@ export class ConnectResolver implements ConnectResolvers {
})
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();
const restartRequired = await this.connectSettingsService.syncSettings(settings);
const currentSettings = await this.connectSettingsService.getCurrentSettings();
if (restartRequired) {
const restartDelayMs = 3_000;
setTimeout(async () => {
this.logger.log('Restarting API');
await this.connectService.restartApi();
}, restartDelayMs);
}
return currentSettings;
}
@ResolveField()

View File

@@ -1,4 +1,15 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { execa } from 'execa';
@Injectable()
export class ConnectService {}
export class ConnectService {
private logger = new Logger(ConnectService.name);
async restartApi() {
try {
await execa('unraid-api', ['restart'], { shell: 'bash', stdio: 'ignore' });
} catch (error) {
this.logger.error(error);
}
}
}

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Switch as UuiSwitch } from '@/components/form/switch';
import { useJsonFormsControl } from '@jsonforms/vue';
@@ -12,10 +14,12 @@ const { control, handleChange } = useJsonFormsControl(props);
const onChange = (checked: boolean) => {
handleChange(control.value.path, checked);
};
const description = computed(() => props.uischema.options?.description);
</script>
<template>
<ControlLayout v-if="control.visible" :label="control.label" :errors="control.errors">
<p v-if="description" v-html="description" class="mb-2"></p>
<UuiSwitch
:id="control.id + '-input'"
:name="control.path"

View File

@@ -5,7 +5,7 @@
import { useMutation, useQuery } from '@vue/apollo-composable';
import { BrandButton, Label, jsonFormsRenderers } from '@unraid/ui';
import { BrandButton, jsonFormsRenderers, Label } from '@unraid/ui';
import { JsonForms } from '@jsonforms/vue';
import type { ConnectSettingsValues } from '~/composables/gql/graphql';
@@ -17,7 +17,7 @@ import { getConnectSettingsForm, updateConnectSettings } from './graphql/setting
*---------------------------------------------**/
const formState = ref<Partial<ConnectSettingsValues>>({});
const { result } = useQuery(getConnectSettingsForm);
const { result, refetch } = useQuery(getConnectSettingsForm);
const settings = computed(() => {
if (!result.value) return;
return result.value?.connect.settings;
@@ -27,6 +27,9 @@ watch(result, () => {
const { __typename, ...initialValues } = result.value.connect.settings.values;
formState.value = initialValues;
});
const restartRequired = computed(() => {
return settings.value?.values.sandbox !== formState.value?.sandbox;
});
/**--------------------------------------------
* Update Settings Actions
@@ -54,7 +57,9 @@ watchDebounced(
// show a toast when the update is done
onMutateSettingsDone(() => {
globalThis.toast.success('Updated API Settings');
globalThis.toast.success('Updated API Settings', {
description: restartRequired.value ? 'The API is restarting...' : undefined,
});
});
/**--------------------------------------------
@@ -66,14 +71,13 @@ const jsonFormsConfig = {
trim: false,
};
const renderers = [
...jsonFormsRenderers,
];
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 });
await refetch();
};
/** Called whenever a JSONForms form control changes */
@@ -112,6 +116,7 @@ const onChange = ({ data }: { data: Record<string, unknown> }) => {
<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>
<p v-else-if="restartRequired">The API will restart after settings are applied.</p>
</div>
<div class="col-start-2 ml-10 space-y-4">
<BrandButton
@@ -123,7 +128,6 @@ const onChange = ({ data }: { data: Record<string, unknown> }) => {
>
Apply
</BrandButton>
<p v-if="mutateSettingsError" class="text-sm text-unraid-red-500">
✕ Error: {{ mutateSettingsError.message }}
</p>