Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{".":"4.25.2"}
{".":"4.25.3"}
7 changes: 7 additions & 0 deletions api/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## [4.25.3](https://github.com/unraid/unraid-api/compare/v4.25.2...v4.25.3) (2025-10-22)


### Bug Fixes

* flaky watch on boot drive's dynamix config ([ec7aa06](https://github.com/unraid/unraid-api/commit/ec7aa06d4a5fb1f0e84420266b0b0d7ee09a3663))
Comment on lines +3 to +8
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix the compare URL repository path

The new 4.25.3 entry points to https://github.com/unraid/unraid-api/compare/..., but the rest of this changelog—and the actual repo—use https://github.com/unraid/api/compare/.... That typo breaks the compare link for this release. Please change the URL to https://github.com/unraid/api/compare/v4.25.2...v4.25.3 to keep the changelog consistent and the link working.

🤖 Prompt for AI Agents
In api/CHANGELOG.md around lines 3 to 8, the compare URL for version 4.25.3 uses
the wrong repository path "unraid/unraid-api"; update the URL to use
"unraid/api/compare/v4.25.2...v4.25.3" so the compare link matches the
repository and the rest of the changelog; ensure only the repo segment is
changed and keep the rest of the line intact.


## [4.25.2](https://github.com/unraid/api/compare/v4.25.1...v4.25.2) (2025-09-30)


Expand Down
2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@unraid/api",
"version": "4.25.2",
"version": "4.25.3",
"main": "src/cli/index.ts",
"type": "module",
"corepack": {
Expand Down
11 changes: 3 additions & 8 deletions api/src/__test__/core/utils/images/image-file-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,18 @@ import {
getBannerPathIfPresent,
getCasePathIfPresent,
} from '@app/core/utils/images/image-file-helpers.js';
import { loadDynamixConfigFile } from '@app/store/actions/load-dynamix-config-file.js';
import { store } from '@app/store/index.js';
import { loadDynamixConfig } from '@app/store/index.js';

test('get case path returns expected result', async () => {
await expect(getCasePathIfPresent()).resolves.toContain('/dev/dynamix/case-model.png');
});

test('get banner path returns null (state unloaded)', async () => {
await expect(getBannerPathIfPresent()).resolves.toMatchInlineSnapshot('null');
});

test('get banner path returns the banner (state loaded)', async () => {
await store.dispatch(loadDynamixConfigFile()).unwrap();
loadDynamixConfig();
await expect(getBannerPathIfPresent()).resolves.toContain('/dev/dynamix/banner.png');
});

test('get banner path returns null when no banner (state loaded)', async () => {
await store.dispatch(loadDynamixConfigFile()).unwrap();
loadDynamixConfig();
await expect(getBannerPathIfPresent('notabanner.png')).resolves.toMatchInlineSnapshot('null');
});
9 changes: 2 additions & 7 deletions api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,11 @@ import { fileExistsSync } from '@app/core/utils/files/file-exists.js';
import { getServerIdentifier } from '@app/core/utils/server-identifier.js';
import { environment, PATHS_CONFIG_MODULES, PORT } from '@app/environment.js';
import * as envVars from '@app/environment.js';
import { loadDynamixConfigFile } from '@app/store/actions/load-dynamix-config-file.js';
import { shutdownApiEvent } from '@app/store/actions/shutdown-api-event.js';
import { store } from '@app/store/index.js';
import { loadDynamixConfig, store } from '@app/store/index.js';
import { startMiddlewareListeners } from '@app/store/listeners/listener-middleware.js';
import { loadStateFiles } from '@app/store/modules/emhttp.js';
import { loadRegistrationKey } from '@app/store/modules/registration.js';
import { setupDynamixConfigWatch } from '@app/store/watch/dynamix-config-watch.js';
import { setupRegistrationKeyWatch } from '@app/store/watch/registration-watch.js';
import { StateManager } from '@app/store/watch/state-watch.js';

Expand Down Expand Up @@ -76,17 +74,14 @@ export const viteNodeApp = async () => {
await store.dispatch(loadRegistrationKey());

// Load my dynamix config file into store
await store.dispatch(loadDynamixConfigFile());
loadDynamixConfig();

// Start listening to file updates
StateManager.getInstance();

// Start listening to key file changes
setupRegistrationKeyWatch();

// Start listening to dynamix config file changes
setupDynamixConfigWatch();

// If port is unix socket, delete old socket before starting http server
unlinkUnixPort();

Expand Down
65 changes: 39 additions & 26 deletions api/src/store/actions/load-dynamix-config-file.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { F_OK } from 'constants';
import { access } from 'fs/promises';

import { createAsyncThunk } from '@reduxjs/toolkit';
import { createTtlMemoizedLoader } from '@unraid/shared';

import type { RecursivePartial } from '@app/types/index.js';
import { type DynamixConfig } from '@app/core/types/ini.js';
import { fileExistsSync } from '@app/core/utils/files/file-exists.js';
import { parseConfig } from '@app/core/utils/misc/parse-config.js';
import { type RecursiveNullable, type RecursivePartial } from '@app/types/index.js';
import { batchProcess } from '@app/utils.js';

/**
* Loads a configuration file from disk, parses it to a RecursivePartial of the provided type, and returns it.
Expand All @@ -16,33 +13,49 @@ import { batchProcess } from '@app/utils.js';
* @param path The path to the configuration file on disk.
* @returns A parsed RecursivePartial of the provided type.
*/
async function loadConfigFile<ConfigType>(path: string): Promise<RecursivePartial<ConfigType>> {
const fileIsAccessible = await access(path, F_OK)
.then(() => true)
.catch(() => false);
return fileIsAccessible
function loadConfigFileSync<ConfigType>(path: string): RecursivePartial<ConfigType> {
return fileExistsSync(path)
? parseConfig<RecursivePartial<ConfigType>>({
filePath: path,
type: 'ini',
})
: {};
}

type ConfigPaths = readonly (string | undefined | null)[];
const CACHE_WINDOW_MS = 250;

const memoizedConfigLoader = createTtlMemoizedLoader<
RecursivePartial<DynamixConfig>,
ConfigPaths,
string
>({
ttlMs: CACHE_WINDOW_MS,
getCacheKey: (configPaths: ConfigPaths): string => JSON.stringify(configPaths),
load: (configPaths: ConfigPaths) => {
const validPaths = configPaths.filter((path): path is string => Boolean(path));
if (validPaths.length === 0) {
return {};
}
const configFiles = validPaths.map((path) => loadConfigFileSync<DynamixConfig>(path));
return configFiles.reduce<RecursivePartial<DynamixConfig>>(
(accumulator, configFile) => ({
...accumulator,
...configFile,
}),
{}
);
},
});

/**
* Load the dynamix.cfg into the store.
* Loads dynamix config from disk with TTL caching.
*
* Note: If the file doesn't exist this will fallback to default values.
* @param configPaths - Array of config file paths to load and merge
* @returns Merged config object from all valid paths
*/
export const loadDynamixConfigFile = createAsyncThunk<
RecursiveNullable<RecursivePartial<DynamixConfig>>,
string | undefined
>('config/load-dynamix-config-file', async (filePath) => {
if (filePath) {
return loadConfigFile<DynamixConfig>(filePath);
}
const store = await import('@app/store/index.js');
const paths = store.getters.paths()['dynamix-config'];
const { data: configs } = await batchProcess(paths, (path) => loadConfigFile<DynamixConfig>(path));
const [defaultConfig = {}, customConfig = {}] = configs;
return { ...defaultConfig, ...customConfig };
});
export const loadDynamixConfigFromDiskSync = (
configPaths: readonly (string | undefined | null)[]
): RecursivePartial<DynamixConfig> => {
return memoizedConfigLoader.get(configPaths);
Comment on lines +25 to +60

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bust TTL cache after writes to dynamix.cfg

The new memoized loader keeps a 250 ms snapshot of the config (memoizedConfigLoader), but there is no way to invalidate that cache after the config file is modified. Callers such as CustomizationService.applyDisplaySettings read the current config through getters.dynamix() before writing updates and then immediately call getters.dynamix() again to return the updated theme. Because the cache is still valid, the second read reuses the pre‑update snapshot and the mutation responds with stale values until the TTL expires. Consider exposing a clear() or bypassing the cache when a write occurs so that reads performed in the same request reflect the new file contents.

Useful? React with 👍 / 👎.

};
34 changes: 33 additions & 1 deletion api/src/store/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { configureStore } from '@reduxjs/toolkit';

import { logger } from '@app/core/log.js';
import { loadDynamixConfigFromDiskSync } from '@app/store/actions/load-dynamix-config-file.js';
import { listenerMiddleware } from '@app/store/listeners/listener-middleware.js';
import { updateDynamixConfig } from '@app/store/modules/dynamix.js';
import { rootReducer } from '@app/store/root-reducer.js';
import { FileLoadStatus } from '@app/store/types.js';

export const store = configureStore({
reducer: rootReducer,
Expand All @@ -15,8 +19,36 @@ export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export type ApiStore = typeof store;

// loadDynamixConfig is located here and not in the actions/load-dynamix-config-file.js file because it needs to access the store,
// and injecting it seemed circular and convoluted for this use case.
/**
* Loads the dynamix config into the store.
* Can be called multiple times - uses TTL caching internally.
* @returns The loaded dynamix config.
*/
export const loadDynamixConfig = () => {
const configPaths = store.getState().paths['dynamix-config'] ?? [];
try {
const config = loadDynamixConfigFromDiskSync(configPaths);
store.dispatch(
updateDynamixConfig({
...config,
status: FileLoadStatus.LOADED,
})
);
} catch (error) {
logger.error(error, 'Failed to load dynamix config from disk');
store.dispatch(
updateDynamixConfig({
status: FileLoadStatus.FAILED_LOADING,
})
);
}
return store.getState().dynamix;
};

export const getters = {
dynamix: () => store.getState().dynamix,
dynamix: () => loadDynamixConfig(),
emhttp: () => store.getState().emhttp,
paths: () => store.getState().paths,
registration: () => store.getState().registration,
Expand Down
19 changes: 0 additions & 19 deletions api/src/store/modules/dynamix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';

import { type DynamixConfig } from '@app/core/types/ini.js';
import { loadDynamixConfigFile } from '@app/store/actions/load-dynamix-config-file.js';
import { FileLoadStatus } from '@app/store/types.js';
import { RecursivePartial } from '@app/types/index.js';

Expand All @@ -22,24 +21,6 @@ export const dynamix = createSlice({
return Object.assign(state, action.payload);
},
},
extraReducers(builder) {
builder.addCase(loadDynamixConfigFile.pending, (state) => {
state.status = FileLoadStatus.LOADING;
});

builder.addCase(loadDynamixConfigFile.fulfilled, (state, action) => {
return {
...(action.payload as DynamixConfig),
status: FileLoadStatus.LOADED,
};
});

builder.addCase(loadDynamixConfigFile.rejected, (state, action) => {
Object.assign(state, action.payload, {
status: FileLoadStatus.FAILED_LOADING,
});
});
},
});

export const { updateDynamixConfig } = dynamix.actions;
17 changes: 0 additions & 17 deletions api/src/store/watch/dynamix-config-watch.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import { AuthZGuard } from 'nest-authz';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';

import { loadDynamixConfigFile } from '@app/store/actions/load-dynamix-config-file.js';
import { store } from '@app/store/index.js';
import { loadDynamixConfig, store } from '@app/store/index.js';
import { loadStateFiles } from '@app/store/modules/emhttp.js';
import { AppModule } from '@app/unraid-api/app/app.module.js';
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
Expand Down Expand Up @@ -111,8 +110,8 @@ describe('AppModule Integration Tests', () => {

beforeAll(async () => {
// Initialize the dynamix config and state files before creating the module
await store.dispatch(loadDynamixConfigFile());
await store.dispatch(loadStateFiles());
loadDynamixConfig();

// Debug: Log the CSRF token from the store
const { getters } = await import('@app/store/index.js');
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "unraid-monorepo",
"private": true,
"version": "4.25.2",
"version": "4.25.3",
"scripts": {
"build": "pnpm -r build",
"build:watch": "pnpm -r --parallel --filter '!@unraid/ui' build:watch",
Expand Down
2 changes: 1 addition & 1 deletion packages/unraid-api-plugin-connect/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "unraid-api-plugin-connect",
"version": "1.0.0",
"version": "4.25.3",
"main": "dist/index.js",
"type": "module",
"files": [
Expand Down
2 changes: 1 addition & 1 deletion packages/unraid-api-plugin-generator/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@unraid/create-api-plugin",
"version": "1.0.0",
"version": "4.25.3",
"type": "module",
"bin": {
"create-api-plugin": "./dist/index.js"
Expand Down
2 changes: 1 addition & 1 deletion packages/unraid-api-plugin-health/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "unraid-api-plugin-health",
"version": "1.0.0",
"version": "4.25.3",
"main": "dist/index.js",
"type": "module",
"files": [
Expand Down
4 changes: 2 additions & 2 deletions packages/unraid-shared/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@unraid/shared",
"version": "1.0.0",
"version": "4.25.3",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
Expand Down Expand Up @@ -69,4 +69,4 @@
"undici": "7.15.0",
"ws": "8.18.3"
}
}
}
5 changes: 5 additions & 0 deletions packages/unraid-shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,10 @@ export * from './graphql.model.js';
export * from './tokens.js';
export * from './use-permissions.directive.js';
export * from './util/permissions.js';
export { createTtlMemoizedLoader } from './util/create-ttl-memoized-loader.js';
export type {
CreateTtlMemoizedLoaderOptions,
TtlMemoizedLoader,
} from './util/create-ttl-memoized-loader.js';
export type { InternalGraphQLClientFactory } from './types/internal-graphql-client.factory.js';
export type { CanonicalInternalClientService } from './types/canonical-internal-client.interface.js';
Loading
Loading