Compare commits

...

87 Commits

Author SHA1 Message Date
Pujit Mehrotra
0d7bee2490 add reboot test 2025-12-12 13:34:19 -05:00
Pujit Mehrotra
e5abbcbf90 test: create vitest based integration suite 2025-12-12 13:34:19 -05:00
Pujit Mehrotra
22bb548833 test: add bats & equivalent vitest system tests setup 2025-12-12 13:34:19 -05:00
Pujit Mehrotra
7c0d42a5cb test: unraid-api daemonization 2025-12-12 13:34:19 -05:00
Pujit Mehrotra
f0cfdfc0b5 fix: ignore errors from /var/log/.pm2 removal 2025-12-11 12:23:32 -05:00
Pujit Mehrotra
6d9796a981 fix: process lock 2025-12-10 12:21:02 -05:00
Pujit Mehrotra
ca5e84f916 fix: narrow api exec command 2025-12-10 11:07:24 -05:00
Pujit Mehrotra
719795647c feat: improve determinism 2025-12-09 15:40:34 -05:00
Pujit Mehrotra
4c2e212a03 tmp: add boot diagnistics 2025-12-09 12:04:47 -05:00
Eli Bosley
9ae3f3cec3 feat: Enhance UserProfile component to conditionally render banner gradient based on CSS variable
- Updated UserProfile component to load banner gradient from a CSS variable, allowing for dynamic styling.
- Added tests to verify banner rendering behavior based on the presence of the CSS variable, ensuring correct functionality regardless of theme store settings.
- Removed outdated test cases that relied solely on theme store flags for banner gradient rendering.
2025-11-26 15:36:13 -05:00
Eli Bosley
071efeac45 feat: make casbin not always log 2025-11-25 14:29:54 -05:00
Eli Bosley
9ff64629cf chore: Update API version and refactor pubsub channel references
- Updated API version in api.json from 4.25.3 to 4.27.2.
- Refactored pubsub channel references across multiple files to use GRAPHQL_PUBSUB_CHANNEL instead of PUBSUB_CHANNEL, enhancing consistency and clarity in the codebase.
- Adjusted related tests to ensure they align with the new pubsub channel structure.
2025-11-25 13:59:09 -05:00
Eli Bosley
fa837db09f feat: Add nodemon log file configuration and enhance logging in NodemonService
- Introduced PATHS_NODEMON_LOG_FILE to configure the log file for nodemon, allowing for better log management.
- Updated log stream handling in NodemonService to write to the specified nodemon log file.
- Enhanced integration tests to validate logging behavior and ensure proper file creation for both application and nodemon logs.
2025-11-25 12:28:28 -05:00
Eli Bosley
3462e7688d feat: Add integration tests for NodemonService with real nodemon execution
- Introduced a new integration test file for NodemonService to validate the start and stop functionality of the real nodemon process.
- Implemented setup and teardown logic to create temporary files and directories for testing.
- Enhanced logging and error handling in the tests to ensure proper verification of nodemon's behavior during execution.
2025-11-25 12:03:01 -05:00
Eli Bosley
dc7a449f3f fix: Update log stream handling in NodemonService tests
- Modified the log stream mock to use a file descriptor instead of pipe methods, aligning with the actual implementation in NodemonService.
- Removed unnecessary stdout and stderr pipe mocks from unit tests, simplifying the test setup while maintaining functionality.
- Ensured consistency between the test and implementation for improved clarity and maintainability.
2025-11-25 10:40:52 -05:00
Eli Bosley
bec54e4feb feat: Enhance NodemonService to improve process management during restarts
- Updated the start method to restart nodemon if a recorded pid is already running, ensuring proper cleanup and logging.
- Modified the restart method to delegate to start, streamlining the process management logic.
- Enhanced unit tests to validate the new behavior, including scenarios for cleaning up stray processes and ensuring fresh starts.
2025-11-23 10:11:42 -05:00
Eli Bosley
a5e9b83374 feat: Implement waitForNodemonExit method and enhance restart logic in NodemonService
- Added waitForNodemonExit method to ensure nodemon processes are fully terminated before restarting.
- Updated restart method to call waitForNodemonExit, improving process management during restarts.
- Introduced a new unit test to validate the behavior of the restart method, ensuring proper sequence of stop, wait, and start operations.
2025-11-21 22:16:32 -05:00
Eli Bosley
9253250dc5 feat: Add process termination and management in NodemonService
- Implemented findDirectMainPids and terminatePids methods to identify and terminate existing unraid-api processes before starting nodemon.
- Enhanced the start method to include checks for running processes, ensuring proper cleanup and logging.
- Updated unit tests to validate the new process management functionality, improving overall robustness.
2025-11-21 21:55:59 -05:00
Eli Bosley
1d9c76f410 feat: Enhance NodemonService with process management and cleanup
- Implemented stopPm2IfRunning method to stop any running pm2-managed instances of unraid-api before starting nodemon.
- Added findMatchingNodemonPids method to identify existing nodemon processes, improving resource management.
- Updated start method to handle scenarios where a stored pid is running or dead, ensuring proper cleanup and logging.
- Introduced new unit tests to validate the new functionality and ensure robustness in process handling.
2025-11-21 18:53:21 -05:00
Eli Bosley
33e88bc5f5 fix: Simplify return type for killSpy in NodemonService tests
- Updated the return type of killSpy in nodemon.service.spec.ts to directly return a boolean instead of casting it, improving code clarity and maintainability.
2025-11-21 18:06:40 -05:00
Eli Bosley
d4f90d6d64 test: Add unit tests for nodemon path configuration and enhance error handling
- Introduced a new test file to validate nodemon path configurations, ensuring they anchor to the package root by default.
- Enhanced the NodemonService to throw an error when nodemon exits immediately after starting, improving robustness.
- Added tests to cover scenarios where nodemon fails to start, ensuring proper logging and resource cleanup.
2025-11-21 17:23:16 -05:00
Eli Bosley
b35da13234 refactor: Update environment configuration for nodemon paths
- Introduced UNRAID_API_ROOT to streamline path definitions for nodemon.
- Replaced direct usage of import.meta.dirname with UNRAID_API_ROOT in NODEMON_PATH and UNRAID_API_CWD for improved clarity and maintainability.
- Added dirname import to facilitate the new path structure.
2025-11-21 16:53:31 -05:00
Eli Bosley
6d3d623b66 feat: Refactor nodemon configuration and improve error handling in NodemonService
- Updated nodemon.json to remove unnecessary watch entry.
- Adjusted NODEMON_CONFIG_PATH and UNRAID_API_CWD paths for better structure.
- Enhanced error handling in isUnraidApiRunning and start methods of NodemonService to ensure proper logging and resource management.
- Added tests for error scenarios in NodemonService to ensure robustness.
2025-11-21 16:53:31 -05:00
Eli Bosley
f6521d8c1c Add SUPPRESS_LOGS to environment mocks 2025-11-21 16:53:31 -05:00
Eli Bosley
e5e77321da Include nodemon config in build artifact 2025-11-21 16:53:31 -05:00
Pujit Mehrotra
31af99e52f chore: for releases, use tag as source of truth for API_VERSION (#1804) 2025-11-21 10:16:00 -05:00
Eli Bosley
933cefa020 New Crowdin updates (#1803)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Localization**
* Updated translations across 24 languages including Arabic, Bengali,
German, Spanish, French, Japanese, Korean, Portuguese, and Russian for
OS update eligibility messages, driver update status notifications, and
license/trial key expiration messaging to improve international user
experience.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-21 10:09:30 -05:00
github-actions[bot]
375dcd0598 chore(main): release 4.27.2 (#1802)
🤖 I have created a release *beep* *boop*
---


## [4.27.2](https://github.com/unraid/api/compare/v4.27.1...v4.27.2)
(2025-11-21)


### Bug Fixes

* issue with header flashing + issue with trial date
([64875ed](64875edbba))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-20 21:16:14 -05:00
Eli Bosley
64875edbba fix: issue with header flashing + issue with trial date
Removed an empty line in the web testing rules.
2025-11-20 21:08:07 -05:00
github-actions[bot]
330e81a484 chore(main): release 4.27.1 (#1797)
🤖 I have created a release *beep* *boop*
---


## [4.27.1](https://github.com/unraid/api/compare/v4.27.0...v4.27.1)
(2025-11-21)


### Bug Fixes

* missing translations for expiring trials
([#1800](https://github.com/unraid/api/issues/1800))
([36c1049](36c104915e))
* resolve header flash when background color is set
([#1796](https://github.com/unraid/api/issues/1796))
([dc9a036](dc9a036c73))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-20 19:44:39 -05:00
Eli Bosley
b8f0fdf8d2 New Crowdin updates (#1801) 2025-11-20 19:39:45 -05:00
Eli Bosley
36c104915e fix: missing translations for expiring trials (#1800)
- Removed translation function calls from the UI components for reboot
type text, replacing them with direct references to the computed
properties.
- Enhanced ineligible update messages by integrating localization for
various conditions, ensuring clearer user feedback regarding update
eligibility.
- Added new localization strings for ineligible update scenarios in the
English locale file.

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

* **New Features**
* Added new localization keys for OS update eligibility, reboot labels,
changelog link, and expanded uptime/trial expiry messages.

* **Bug Fixes**
* Restored translated strings and added locale-aware release date
formatting for update/ineligible messaging and badges.

* **Theme & UI**
* Streamlined theme initialization and server-driven theme application;
removed legacy CSS-variable persistence and adjusted dark/banner
behavior.

* **Tests**
* Added i18n and date/locale formatting tests and improved
local-storage-like test mocks.

* **Chores**
* Removed an auto-registered global component and strengthened
script/theme initialization and CSS-variable validation.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-20 19:30:39 -05:00
Eli Bosley
dc9a036c73 fix: resolve header flash when background color is set (#1796)
## Summary
- rely on the existing Pinia persisted state instead of manual
localStorage hydration
- reapply CSS variables after persisted hydration so custom header
colors show immediately

## Testing
- Not run (not requested)


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_691e5a1d052c8323973847eb5833fbb9)
2025-11-19 19:43:45 -05:00
github-actions[bot]
c71b0487ad chore(main): release 4.27.0 (#1795)
🤖 I have created a release *beep* *boop*
---


## [4.27.0](https://github.com/unraid/api/compare/v4.26.2...v4.27.0)
(2025-11-19)


### Features

* remove Unraid API log download functionality
([#1793](https://github.com/unraid/api/issues/1793))
([e4a9b82](e4a9b8291b))


### Bug Fixes

* auto-uninstallation of connect api plugin
([#1791](https://github.com/unraid/api/issues/1791))
([e734043](e7340431a5))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-19 14:35:57 -05:00
Pujit Mehrotra
e7340431a5 fix: auto-uninstallation of connect api plugin (#1791)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Refactor**
* Plugin configuration now lives in a single API configuration object
for consistent handling.
* Connection plugin wiring simplified so the connect plugin is always
provided without runtime fallbacks.

* **Chores**
* Startup now automatically removes stale connect-plugin entries from
saved config when the plugin is absent, improving startup reliability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-19 14:22:24 -05:00
Eli Bosley
e4a9b8291b feat: remove Unraid API log download functionality (#1793)
## Summary
- remove the REST API log download helper and associated service wiring
- drop the Download API Logs UI component and related registrations and
test references
- update tests and type declarations to reflect the removal

## Testing
- Not run (not requested)


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_691ce360f8f88323888ad6ef49f32b45)

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

* **Removed Features**
* Removed the API logs download feature — the UI download component and
the corresponding public API endpoint are no longer available.

* **Chores**
* Cleaned up related tests, component registrations, and unused
integration/dependency wiring tied to the removed logs feature.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-19 09:16:59 -05:00
github-actions[bot]
6b6b78fa2e chore(main): release 4.26.2 (#1794) 2025-11-19 06:38:32 -05:00
Eli Bosley
e2fdf6cadb fix(theme): Missing header background color 2025-11-19 06:25:03 -05:00
github-actions[bot]
3d4f193fa4 chore(main): release 4.26.1 (#1790)
🤖 I have created a release *beep* *boop*
---


## [4.26.1](https://github.com/unraid/api/compare/v4.26.0...v4.26.1)
(2025-11-18)


### Bug Fixes

* **theme:** update theme class naming and scoping logic
([b28ef1e](b28ef1ea33))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-18 13:37:18 -05:00
Eli Bosley
b28ef1ea33 fix(theme): update theme class naming and scoping logic
- Changed theme class names from `.theme-*` to `.Theme--*` for consistency.
- Updated scoping logic to prevent scoping of `.Theme--` classes, ensuring they remain global.
- Enhanced theme store logic to check for existing `.Theme--` classes before applying new theme classes, preventing conflicts.
- Adjusted class cleaning logic to retain `.Theme--` classes when necessary.
2025-11-18 13:29:39 -05:00
github-actions[bot]
ee0f240233 chore(main): release 4.26.0 (#1744)
🤖 I have created a release *beep* *boop*
---


## [4.26.0](https://github.com/unraid/api/compare/v4.25.3...v4.26.0)
(2025-11-17)


### Features

* add cpu power query & subscription
([#1745](https://github.com/unraid/api/issues/1745))
([d7aca81](d7aca81c60))
* add schema publishing to apollo studio
([#1772](https://github.com/unraid/api/issues/1772))
([7e13202](7e13202aa1))
* add workflow_dispatch trigger to schema publishing workflow
([818e7ce](818e7ce997))
* apollo studio readme link
([c4cd0c6](c4cd0c6352))
* **cli:** make `unraid-api plugins remove` scriptable
([#1774](https://github.com/unraid/api/issues/1774))
([64eb9ce](64eb9ce9b5))
* use persisted theme css to fix flashes on header
([#1784](https://github.com/unraid/api/issues/1784))
([854b403](854b403fbd))


### Bug Fixes

* **api:** decode html entities before parsing notifications
([#1768](https://github.com/unraid/api/issues/1768))
([42406e7](42406e795d))
* **connect:** disable api plugin if unraid plugin is absent
([#1773](https://github.com/unraid/api/issues/1773))
([c264a18](c264a1843c))
* detection of flash backup activation state
([#1769](https://github.com/unraid/api/issues/1769))
([d18eaf2](d18eaf2364))
* re-add missing header gradient styles
([#1787](https://github.com/unraid/api/issues/1787))
([f8a6785](f8a6785e9c))
* respect OS safe mode in plugin loader
([#1775](https://github.com/unraid/api/issues/1775))
([92af3b6](92af3b6115))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-17 13:03:50 -05:00
Eli Bosley
3aacaa1fb5 chore: trigger release please 2025-11-17 12:54:59 -05:00
Pujit Mehrotra
0cd4c0ae16 chore: remove fetch-depth limit in release-please git checkout (#1789)
Hopefully fixes release please over-scoping its changelog generation

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

## Summary by CodeRabbit

* **Chores**
* Updated CI/CD workflow configuration to enable full repository history
retrieval during build processes, improving the reliability of version
control operations in automated deployments.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-17 12:33:40 -05:00
Eli Bosley
66625ded6a New Crowdin updates (#1786)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **New Features**
* Expanded localization support with comprehensive validation error
messages across 22 languages including Arabic, Chinese, French, German,
Hindi, Japanese, Portuguese, Russian, Spanish, and others.
* Enhanced form validation messaging for API key management, OIDC
provider configuration, SSO setup, and related settings to provide
localized guidance for users fixing errors.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-17 11:04:10 -05:00
Eli Bosley
f8a6785e9c fix: re-add missing header gradient styles (#1787)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Style**
* Enhanced header banner styling: centered, non-repeating cover images
with layered gradient overlays and adjusted user-profile banner
positioning for improved layout.
* **Bug Fixes**
* Banner display logic updated so "image" is treated like "yes" for
showing banner images.
* **Tests**
  * Added unit tests covering banner/theme display behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-17 11:03:37 -05:00
Pujit Mehrotra
d7aca81c60 feat: add cpu power query & subscription (#1745)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Per-package CPU power and temperature displayed in hardware info
(total and per-package values).
* CPU package topology (cores/threads per package) included in CPU info.
* Real-time per-package CPU telemetry exposed via a new system metrics
subscription.

* **Chores**
* Added an automated deployment script and npm deploy script for the
shared package.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Signed-off-by: Simon Fairweather <simon.n.fairweather@gmail.com>
Co-authored-by: Simon Fairweather <simon.n.fairweather@gmail.com>
Co-authored-by: SimonFair <39065407+SimonFair@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-11-14 14:27:49 -05:00
Eli Bosley
854b403fbd feat: use persisted theme css to fix flashes on header (#1784)
## Summary
- install the pinia-plugin-persistedstate integration directly inside
the theme store and hydrate cached themes before applying CSS variables
- fall back to the active/global Pinia instance while ensuring persisted
state is only wired once per store instance
- update the theme store tests to reset the shared Pinia state between
runs and rely on the plugin-backed persistence

## Testing
- pnpm --filter web test __test__/store/theme.test.ts

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_69156c5e8de48323841f7dbfdadec51d)

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

* **New Features**
* Theme preferences now persist across sessions and are restored on
return.
* **Behavior Change**
* Theme switching may now update the URL/address bar to reflect the
selected theme.
* **Chores**
* Added a persistence integration to enable storing/restoring theme
data.
* **Tests**
* Updated/added tests covering hydration from storage and persistence of
server-provided themes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-13 16:24:30 -05:00
Pujit Mehrotra
c264a1843c fix(connect): disable api plugin if unraid plugin is absent (#1773)
Mitigates an edge case where the connect api plugin does not uninstall
itself when Unraid version < 7.2.0, resulting in retention of undesired
connect functionality on stock unraid after upgrading to 7.2.0+.

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

* **New Features**
* App now detects Connect plugin availability at startup and falls back
gracefully with a no-op mode and a logged warning if the plugin is
absent.
* Added an environment option to skip the plugin availability check when
needed.
* Export behavior adjusted so the application uses the appropriate
module based on plugin presence.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-13 12:26:35 -05:00
Eli Bosley
45cda4af80 chore: nunjucks template engine for test pages (#1783) 2025-11-13 11:35:43 -05:00
Pujit Mehrotra
64eb9ce9b5 feat(cli): make unraid-api plugins remove scriptable (#1774)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added --bypass-npm and --npm flags, support for passing plugin names
as command args, and a restart option; CLI params now merge with
interactive prompts.

* **Bug Fixes**
* Vendor archive rebuild is performed only when actual uninstalls occur.
  * Restart behavior uses resolved options for consistent restarts.
  * Removal can run as "config-only" without running package operations.

* **Tests**
* Expanded tests for bypass scenarios, prompt flows, config-only
removals, and removal control flow.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-13 10:49:50 -05:00
Eli Bosley
d56797c59f chore: add dev mode language selection (#1782)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added a language switcher widget to all test pages for convenient
locale selection.
* Displays supported language options in a dropdown menu with extensive
multi-language support.
  * Persists user's locale preference across page reloads.

* **Tests**
* Enhanced test utilities with improved multi-locale support and locale
switching capabilities.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-13 10:49:36 -05:00
Pujit Mehrotra
92af3b6115 fix: respect OS safe mode in plugin loader (#1775)
- also adds util for reading ini configs synchronously

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

## Summary by CodeRabbit

## Release Notes

* **New Features**
* Added safe mode support to prevent plugin loading when enabled,
returning an empty plugin list in safe mode.

* **Tests**
* Added comprehensive test coverage for safe mode functionality and
state file loading mechanisms.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-13 10:15:12 -05:00
Eli Bosley
35f8bc2258 refactor: remove unnecessary network stack reload in ConnectSettingsService (#1751)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **Chores**
* Streamlined the remote access update workflow by removing an
unnecessary step previously executed during remote access configuration
changes, resulting in a more efficient update process.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-13 09:58:50 -05:00
Eli Bosley
c4cd0c6352 feat: apollo studio readme link 2025-11-10 12:18:32 -05:00
Eli Bosley
818e7ce997 feat: add workflow_dispatch trigger to schema publishing workflow 2025-11-10 12:05:46 -05:00
Eli Bosley
7e13202aa1 feat: add schema publishing to apollo studio (#1772) 2025-11-10 12:03:47 -05:00
Pujit Mehrotra
d18eaf2364 fix: detection of flash backup activation state (#1769)
Resolves #1767 


plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/UpdateFlashBackup.php:415
still reads the API version from _var($mystatus,'version'), but commit
441e1805c removed the code that populates $mystatus (the parse of
/var/local/emhttp/myservers.cfg). As a result $mystatus is undefined, so
we now send api_version= to the flash activation endpoint. The PHP
runtime also emits “Undefined variable: mystatus” / “Trying to access
array offset on value of type null” notices before headers are written.
Those notices corrupt the JSON response, the keyserver rejects the
request because the api_version is missing, and the flash backup state
file is never updated—so the web GUI stays stuck at “Loading”.

Because the status request always invokes UpdateFlashBackup.php, every
page load trips the same failure path, leaving
/var/local/emhttp/flashbackup.ini with loading=Loading. The frontend
only listens for /sub/flashbackup events, so until that INI file is
rewritten the spinner never clears and the enable button never becomes
active.

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

* **New Features**
* UI now initializes flash backup status on page load so backup controls
reflect current server state immediately.
* Backup state saves now publish remote updates, improving
synchronization of backup status.

* **Bug Fixes**
* Improved API version handling for flash backup operations: sends
version when available and falls back gracefully when unknown.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-07 14:52:18 -05:00
Pujit Mehrotra
42406e795d fix(api): decode html entities before parsing notifications (#1768)
so the parser does not treat them as comments.

This surfaces a new bug: `#`'s in notification subject or descriptions
are treated as comments, and content following a `#` will not be
displayed in queries from the api, unless the values are explicitly
quoted as strings:
```
subject=Warning #1 OS      #  Truncates after "Warning"
subject=\#1 OS             #  Backslash escape doesn't work
subject="Warning #1 OS"    #  Double quotes work!
subject='Warning #1 OS'    #  Single quotes work!
```

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

## Summary by CodeRabbit

## Version 4.25.3

* **Improvements**
* Enhanced notification system with improved handling of special
characters and HTML-formatted content in messages.
  * Better text rendering accuracy across all notification types.

* **Chores**
  * Updated application dependencies.
  * Version bumped to 4.25.3.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-07 14:49:22 -05:00
Pujit Mehrotra
11d2de5d08 chore(web): reduce lint duration by 90% (#1766)
Replace the eslint prettier plugin (which profiling revealed to be the
bottleneck) with separate `prettier` invocations. This yielded a 73 second reduction (89%) in the environment described below.

Before:
```
pnpm --filter web lint:fix  81.79s user 1.85s system 110% cpu 1:15.81 total
```
After: 
```
pnpm --filter web lint:fix  8.83s user 0.93s system 170% cpu 5.737 total
```
System specs (Nov 5 2025):
```
OS: macOS Sequoia 15.6.1 (24G90) arm64
Host: MacBook Air (15-inch, M2, 2023)
Kernel: Darwin 24.6.0
Uptime: 44 days, 5 hours, 22 mins
Packages: 52 (nix-default), 195 (brew), 4 (brew-cask)
Shell: zsh 5.9
CPU: Apple M2 (8) @ 3.50 GHz
GPU: Apple M2 (10) @ 1.40 GHz [Integrated]
Memory: 19.51 GiB / 24.00 GiB (81%)
Swap: 4.83 GiB / 6.00 GiB (80%)
```

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

## Summary by CodeRabbit

* **Chores**
* Separated code formatting and linting tools into independent workflows
for improved developer efficiency
* Updated development tool configuration to streamline the linting and
formatting process

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-06 09:22:45 -05:00
Eli Bosley
031c1ab5dc New Crowdin updates (#1760)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Localization**
  * Added proper Arabic translation for server action status messages
  * Added proper French translation for server action status messages
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-11-01 07:34:39 -04:00
Pujit Mehrotra
34075e44c5 fix: flaky watch on boot drive's dynamix config (#1753)
On FAT32, `fs.stat()` updates accesstime, which means file reads are
also writes, which means we can't use `usePoll` without degrading users'
flash drives.

To keep file reads lazy without a larger refactor, I override
`getters.dynamix()` as the entrypoint to re-read the boot drive's
dynamix config.

Consecutive calls to `getters.dynamix()` are a common access pattern,
which means we have to memoize to avoid many redundant file reads, so I
used a TTL cache with a 250ms lifetime, hoping to scope config files to
each request.

`getters.dynamix()` is also used synchonously, so bit the bullet and
switched away from async reads for simplicity, considering that most
reads will be occurring from memory, even during cache misses.

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

* **New Features**
  * Added a TTL memoized loader utility with exported types.
  * Added a public function to load Dynamix configuration at startup.

* **Refactor**
* Startup now uses the deterministic, cached config loader; runtime
file-watch for Dynamix config removed.
* Simplified config state handling and load-status reporting for more
predictable startup behavior.

* **Tests**
* Added tests for TTL caching, eviction, keying, and conditional
caching.

* **Chores**
  * Bumped package versions and updated changelog.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-27 11:05:13 -04:00
Pujit Mehrotra
ff2906e52a chore: fix header capture from changelog in generate-release-notes.yml (#1759)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Chores**
* Updated release notes extraction workflow to improve handling of
version headers in generated release notes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-22 16:19:38 -04:00
Pujit Mehrotra
a0d6cc92c8 chore: decouple manual-release from release-production (#1758) 2025-10-22 15:57:41 -04:00
Pujit Mehrotra
57acfaacf0 chore: avoid altering formatting via jq in manual-release workflow 2025-10-22 15:36:20 -04:00
Pujit Mehrotra
ea816c7a5c chore: use jq to update package versions in manual-release (#1757) 2025-10-22 15:19:28 -04:00
Pujit Mehrotra
cafde72d38 chore: add version check & changelog generation to manual-release (#1756)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Automated release-notes generation with layered fallbacks (use
provided notes, extract from changelog, generate from previous release,
call provider APIs, or default message).
* New version-validation step to ensure package versions are consistent
before publishing.

* **Chores**
* Moved release-notes logic into a reusable workflow and rewired the
manual release process to consume its outputs for more consistent
releases.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-22 15:13:17 -04:00
Pujit Mehrotra
2b481c397c chore: add manual-release workflow and extract build-artifacts workflow (#1755)
Adds a workflow to create or override (github) releases with a release
produced from a specific git ref. Refactors the main build process into
a workflow call for reusability.

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

* **Chores**
* Consolidated multi-target build pipeline for API, UI library, and web
app with unified artifact publishing, improved caching, and simplified
downstream wiring.
* **New Features**
* Added a manual, parameterized release workflow to create/update draft
releases with optional prerelease tagging and generated release notes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-22 13:49:15 -04:00
Eli Bosley
8c4e9dd7ae New Crowdin updates (#1750)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* New Features
* Expanded and refined translations across the UI for Arabic, Bengali,
Catalan, Czech, Danish, German, Spanish, French, Hindi, Croatian,
Hungarian, Italian, Japanese, Korean, Latvian, Dutch, Norwegian, Polish,
Portuguese, Romanian, Russian, Swedish, Ukrainian, and Chinese.
* Updated labels, titles, and descriptions for API key management,
OIDC/SSO configuration, buttons, and restrictions to native-language
equivalents.
* Improves readability and consistency in localized interfaces; no
functional changes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-21 13:15:11 -04:00
Eli Bosley
f212dce88b fix: use relative URLs in the web links and fix color in PM2 startup (#1752)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **Refactor**
* Consolidated URL resolution and handling logic for improved
consistency across the application
* Enhanced GraphQL endpoint configuration with better fallback
mechanisms for more reliable connections
* Optimized platform command execution through improved default
parameter handling

* **Chores**
  * Infrastructure configuration updates and maintenance

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-21 10:10:50 -04:00
Eli Bosley
8cd2a4c124 chore: add translations.php to backup and restore lists 2025-10-15 15:23:39 -04:00
Eli Bosley
10f048ee1f chore: re-add translations.php to prevent breaking uninstalls 2025-10-15 12:05:02 -04:00
Eli Bosley
e9e271ade5 fix(#1729): api key authorize component not mounted when on Unraid OS 2025-10-13 21:37:28 -04:00
Eli Bosley
31c41027fc feat: translations now use crowdin (translate.unraid.net) (#1739)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- App-wide internationalization: dynamic locale detection/loading, many
new locale bundles, and CLI helpers to extract/sort translation keys.

- **Accessibility**
  - Brand button supports keyboard activation (Enter/Space).

- **Documentation**
  - Internationalization guidance added to API and Web READMEs.

- **Refactor**
- UI updated to use centralized i18n keys and a unified locale loading
approach.

- **Tests**
  - Test utilities updated to support i18n and localized assertions.

- **Chores**
- Crowdin config and i18n scripts added; runtime locale exposed for
selection.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-13 16:56:08 -04:00
Eli Bosley
fabe6a2c4b chore: Delete .github/workflows/claude-code-review.yml 2025-10-13 09:27:40 -04:00
Pujit Mehrotra
754966d5d3 fix: api auth from web during local dev (#1743) 2025-10-07 19:45:50 -04:00
Pujit Mehrotra
ed594e9147 chore(readme): add link to Deep Wiki for automated code documentation (#1735)
Deep Wiki from Cognition Labs already has some useful documentation that
they generated for us: https://deepwiki.com/unraid/api. This PR adds
their badge to our readme so contributors can access their documentation
directly.

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

## Summary by CodeRabbit

- Documentation
- Added an “Ask DeepWiki” shield badge to the top of the README’s
project shields, giving users quick access to a dedicated Q&A/help
resource.
- Improves discoverability of support and learning materials directly
from the project homepage.
- No functional or behavioral changes to the application; this is an
informational enhancement aimed at easing onboarding and providing
faster guidance for users exploring the project.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-02 11:01:12 -04:00
github-actions[bot]
50d83313a1 chore(main): release 4.25.2 (#1734)
🤖 I have created a release *beep* *boop*
---


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


### Bug Fixes

* enhance activation code modal visibility logic
([#1733](https://github.com/unraid/api/issues/1733))
([e57ec00](e57ec00627))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-30 17:16:08 -04:00
Eli Bosley
e57ec00627 fix: enhance activation code modal visibility logic (#1733)
Updated the visibility logic in the activation code modal store to
include a check for the presence of an activation code. The modal will
now be shown if it is not explicitly hidden, the installation is fresh,
there is no callback data, and an activation code is present.

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

## Summary by CodeRabbit

* **Bug Fixes**
* Activation modal visibility refined: it now appears only when an
activation code is available, it’s a fresh install, the modal isn’t
hidden, and there’s no callback data. Prevents unnecessary prompts.

* **Tests**
* Expanded coverage to include activation code presence/absence and
combined scenarios with fresh install and hidden states, ensuring
accurate visibility logic.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-30 17:08:16 -04:00
github-actions[bot]
84f4a7221d chore(main): release 4.25.1 (#1732)
🤖 I have created a release *beep* *boop*
---


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


### Bug Fixes

* add cache busting to web component extractor
([#1731](https://github.com/unraid/api/issues/1731))
([0d165a6](0d165a6087))
* Connect won't appear within Apps - Previous Apps
([#1727](https://github.com/unraid/api/issues/1727))
([d73953f](d73953f8ff))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-30 13:01:57 -04:00
Squidly271
d73953f8ff fix: Connect won't appear within Apps - Previous Apps (#1727)
Manual removal of the .plg is never necessary. plugin script will
automatically move the .plg to /config/plugins-removed

Manual removal results in PHP errors and possible indeterminate state

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

- Bug Fixes
- Updated plugin removal on Unraid 7.2+ to no longer delete the plugin
file during boot. You’ll now be clearly prompted to reboot to complete
uninstallation, reducing unexpected removals and improving guidance.
Behavior on earlier Unraid versions remains unchanged.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-30 12:32:16 -04:00
Eli Bosley
0d165a6087 fix: add cache busting to web component extractor (#1731)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- Bug Fixes
- Ensures UI assets use content-hashed filenames so browsers load the
latest scripts and styles after updates, reducing stale-cache issues.
- Keeps scripts and their related styles in sync for consistent
rendering and fewer cache-related glitches.
- Ignores non-asset manifest entries to prevent accidental inclusion of
invalid items and ensure correct asset loading.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-30 12:27:48 -04:00
github-actions[bot]
f4f3e3c44b chore(main): release 4.25.0 (#1725)
🤖 I have created a release *beep* *boop*
---


## [4.25.0](https://github.com/unraid/api/compare/v4.24.1...v4.25.0)
(2025-09-26)


### Features

* add Tailwind scoping plugin and integrate into Vite config
([#1722](https://github.com/unraid/api/issues/1722))
([b7afaf4](b7afaf4632))
* notification filter controls pill buttons
([#1718](https://github.com/unraid/api/issues/1718))
([661865f](661865f976))


### Bug Fixes

* enable auth guard for nested fields - thanks
[@ingel81](https://github.com/ingel81)
([7bdeca8](7bdeca8338))
* enhance user context validation in auth module
([#1726](https://github.com/unraid/api/issues/1726))
([cd5eff1](cd5eff11bc))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-26 12:58:08 -04:00
Eli Bosley
cd5eff11bc fix: enhance user context validation in auth module (#1726)
Fixes #1723

- Improved error handling in the auth module to ensure user context is
present and valid.
- Added checks for user roles and identifiers, throwing appropriate
exceptions for missing or invalid data.
- Introduced a new integration test suite for AuthZGuard, validating
role-based access control for various actions in the application.
- Tests cover scenarios for viewer and admin roles, ensuring correct
permissions are enforced.

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

## Summary by CodeRabbit

* **Bug Fixes**
* Hardened authorization: properly rejects requests with missing users
or invalid roles and ensures a valid subject is derived for permission
checks, improving reliability and security of access control responses.

* **Tests**
* Added comprehensive integration tests for authorization, covering
admin/viewer role behaviors, API key permissions, and various resource
actions to verify expected allow/deny outcomes across scenarios.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-26 12:50:31 -04:00
Eli Bosley
7bdeca8338 fix: enable auth guard for nested fields - thanks @ingel81 2025-09-26 11:12:17 -04:00
Eli Bosley
661865f976 feat: notification filter controls pill buttons (#1718)
## Summary
- replace the notification type dropdown with inline pill buttons for
quick filtering
- expose accessible role and pressed state on the new filter buttons

## Testing
- pnpm --filter @unraid/web lint

------
https://chatgpt.com/codex/tasks/task_e_68d184ad60348323b60c9b8e19146025

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

* **New Features**
* Notifications sidebar now uses a pill-style button group instead of a
dropdown for filtering by importance/type.
  * One-tap switching applies filters instantly for faster navigation.
* Active filters are more visible, improving clarity and accessibility.
* No changes to existing workflows or public behavior; settings and
filtering semantics remain unchanged.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-26 10:15:27 -04:00
Eli Bosley
b7afaf4632 feat: add Tailwind scoping plugin and integrate into Vite config (#1722)
- Introduced a new PostCSS plugin, `scopeTailwindToUnapi`, to scope
Tailwind CSS classes to specific elements.
- Updated Vite configuration to include the new PostCSS plugin for CSS
processing.
- Enhanced theme management in the theme store to apply scoped classes
and dynamic CSS variables to multiple targets, including the document
root and elements with the `.unapi` class.

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

* **New Features**
* Scoped styling for embedded (.unapi) contexts and a PostCSS plugin to
automate it.
* Theme refresh after mount to propagate CSS variables to embedded
roots.
  * Exposed idempotent restart action for the Unraid API when offline.

* **Bug Fixes**
* Consistent dark mode and theme variable application across main and
embedded views.
  * Interactive element and SSO styles now apply in embedded contexts.
* Simplified changelog iframe with a reliable fallback renderer;
improved logs styling scope.

* **Tests**
* New unit tests for the scoping plugin, changelog iframe, and related
components.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-26 09:56:27 -04:00
github-actions[bot]
b3ca40c639 chore(main): release 4.24.1 (#1721)
🤖 I have created a release *beep* *boop*
---


## [4.24.1](https://github.com/unraid/api/compare/v4.24.0...v4.24.1)
(2025-09-23)


### Bug Fixes

* cleanup leftover removed packages on upgrade
([#1719](https://github.com/unraid/api/issues/1719))
([9972a5f](9972a5f178))
* enhance version comparison logic in installation script
([d9c561b](d9c561bfeb))
* issue with incorrect permissions on viewer / other roles
([378cdb7](378cdb7f10))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-23 16:06:18 -04:00
371 changed files with 28258 additions and 7520 deletions

View File

@@ -241,4 +241,3 @@ const pinia = createTestingPinia({
- Set initial state for focused testing
- Test computed properties by accessing them directly
- Verify state changes by updating the store

201
.github/workflows/build-artifacts.yml vendored Normal file
View File

@@ -0,0 +1,201 @@
name: Build Artifacts
on:
workflow_call:
inputs:
ref:
type: string
required: false
description: "Git ref to checkout (commit SHA, branch, or tag)"
version_override:
type: string
required: false
description: "Override version (for manual releases)"
outputs:
build_number:
description: "Build number for the artifacts"
value: ${{ jobs.build-api.outputs.build_number }}
secrets:
VITE_ACCOUNT:
required: true
VITE_CONNECT:
required: true
VITE_UNRAID_NET:
required: true
VITE_CALLBACK_KEY:
required: true
UNRAID_BOT_GITHUB_ADMIN_TOKEN:
required: false
jobs:
build-api:
name: Build API
runs-on: ubuntu-latest
outputs:
build_number: ${{ steps.buildnumber.outputs.build_number }}
defaults:
run:
working-directory: api
steps:
- name: Checkout repo
uses: actions/checkout@v5
with:
ref: ${{ inputs.ref || github.ref }}
fetch-depth: 0
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v5
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
with:
packages: bash procps python3 libvirt-dev jq zstd git build-essential
version: 1.0
- name: PNPM Install
run: |
cd ${{ github.workspace }}
pnpm install --frozen-lockfile
- name: Get Git Short Sha and API version
id: vars
run: |
GIT_SHA=$(git rev-parse --short HEAD)
IS_TAGGED=$(git describe --tags --abbrev=0 --exact-match || echo '')
PACKAGE_LOCK_VERSION=$(jq -r '.version' package.json)
API_VERSION=${{ inputs.version_override && format('"{0}"', inputs.version_override) || '${PACKAGE_LOCK_VERSION}' }}
if [ -z "${{ inputs.version_override }}" ] && [ -z "$IS_TAGGED" ]; then
API_VERSION="${PACKAGE_LOCK_VERSION}+${GIT_SHA}"
fi
export API_VERSION
echo "API_VERSION=${API_VERSION}" >> $GITHUB_ENV
echo "PACKAGE_LOCK_VERSION=${PACKAGE_LOCK_VERSION}" >> $GITHUB_OUTPUT
- name: Generate build number
id: buildnumber
uses: onyxmueller/build-tag-number@v1
with:
token: ${{ secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN || github.token }}
prefix: ${{ inputs.version_override || steps.vars.outputs.PACKAGE_LOCK_VERSION }}
- name: Build
run: |
pnpm run build:release
tar -czf deploy/unraid-api.tgz -C deploy/pack/ .
- name: Upload tgz to Github artifacts
uses: actions/upload-artifact@v4
with:
name: unraid-api
path: ${{ github.workspace }}/api/deploy/unraid-api.tgz
build-unraid-ui-webcomponents:
name: Build Unraid UI Library (Webcomponent Version)
defaults:
run:
working-directory: unraid-ui
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v5
with:
ref: ${{ inputs.ref || github.ref }}
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v5
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
with:
packages: bash procps python3 libvirt-dev jq zstd git build-essential
version: 1.0
- name: Install dependencies
run: |
cd ${{ github.workspace }}
pnpm install --frozen-lockfile --filter @unraid/ui
- name: Lint
run: pnpm run lint
- name: Build
run: pnpm run build:wc
- name: Upload Artifact to Github
uses: actions/upload-artifact@v4
with:
name: unraid-wc-ui
path: unraid-ui/dist-wc/
build-web:
name: Build Web App
defaults:
run:
working-directory: web
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v5
with:
ref: ${{ inputs.ref || github.ref }}
- name: Create env file
run: |
touch .env
echo VITE_ACCOUNT=${{ secrets.VITE_ACCOUNT }} >> .env
echo VITE_CONNECT=${{ secrets.VITE_CONNECT }} >> .env
echo VITE_UNRAID_NET=${{ secrets.VITE_UNRAID_NET }} >> .env
echo VITE_CALLBACK_KEY=${{ secrets.VITE_CALLBACK_KEY }} >> .env
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v5
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: PNPM Install
run: |
cd ${{ github.workspace }}
pnpm install --frozen-lockfile --filter @unraid/web --filter @unraid/ui
- name: Build Unraid UI
run: |
cd ${{ github.workspace }}/unraid-ui
pnpm run build
- name: Lint files
run: pnpm run lint
- name: Type Check
run: pnpm run type-check
- name: Build
run: pnpm run build
- name: Upload build to Github artifacts
uses: actions/upload-artifact@v4
with:
name: unraid-wc-rich
path: web/dist

View File

@@ -27,6 +27,15 @@ on:
type: string
required: true
description: "Build number for the plugin builds"
ref:
type: string
required: false
description: "Git ref (commit SHA, branch, or tag) to checkout"
TRIGGER_PRODUCTION_RELEASE:
type: boolean
required: false
default: false
description: "Whether to automatically trigger the release-production workflow (default: false)"
secrets:
CF_ACCESS_KEY_ID:
required: true
@@ -49,6 +58,7 @@ jobs:
- name: Checkout repo
uses: actions/checkout@v5
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
- uses: pnpm/action-setup@v4
@@ -68,7 +78,21 @@ jobs:
GIT_SHA=$(git rev-parse --short HEAD)
IS_TAGGED=$(git describe --tags --abbrev=0 --exact-match || echo '')
PACKAGE_LOCK_VERSION=$(jq -r '.version' package.json)
API_VERSION=$([[ -n "$IS_TAGGED" ]] && echo "$PACKAGE_LOCK_VERSION" || echo "${PACKAGE_LOCK_VERSION}+${GIT_SHA}")
# For release builds, trust the release tag version to avoid stale checkouts
if [ "${{ inputs.RELEASE_CREATED }}" = "true" ] && [ -n "${{ inputs.RELEASE_TAG }}" ]; then
TAG_VERSION="${{ inputs.RELEASE_TAG }}"
TAG_VERSION="${TAG_VERSION#v}" # trim leading v if present
if [ "$TAG_VERSION" != "$PACKAGE_LOCK_VERSION" ]; then
echo "::warning::Release tag version ($TAG_VERSION) does not match package.json version ($PACKAGE_LOCK_VERSION). Using tag version for TXZ naming."
fi
API_VERSION="$TAG_VERSION"
else
API_VERSION=$([[ -n "$IS_TAGGED" ]] && echo "$PACKAGE_LOCK_VERSION" || echo "${PACKAGE_LOCK_VERSION}+${GIT_SHA}")
fi
echo "API_VERSION=${API_VERSION}" >> $GITHUB_OUTPUT
- name: Install dependencies
@@ -136,7 +160,7 @@ jobs:
done
- name: Workflow Dispatch and wait
if: inputs.RELEASE_CREATED == 'true'
if: inputs.RELEASE_CREATED == 'true' && inputs.TRIGGER_PRODUCTION_RELEASE == true
uses: the-actions-org/workflow-dispatch@v4.0.0
with:
workflow: release-production.yml

View File

@@ -1,103 +0,0 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
# Skip reviews for non-code changes
paths-ignore:
- "**/*.md"
- "**/package-lock.json"
- "**/pnpm-lock.yaml"
- "**/.gitignore"
- "**/LICENSE"
- "**/*.config.js"
- "**/*.config.ts"
- "**/tsconfig.json"
- "**/.github/workflows/*.yml"
- "**/docs/**"
jobs:
claude-review:
# Skip review for bot PRs and WIP/skip-review PRs
# Only run if changes are significant (>10 lines)
if: |
(github.event.pull_request.additions > 10 || github.event.pull_request.deletions > 10) &&
!contains(github.event.pull_request.title, '[skip-review]') &&
!contains(github.event.pull_request.title, '[WIP]') &&
!endsWith(github.event.pull_request.user.login, '[bot]') &&
github.event.pull_request.user.login != 'dependabot' &&
github.event.pull_request.user.login != 'renovate'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@beta
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
# model: "claude-opus-4-20250514"
# Direct prompt for automated review (no @claude mention needed)
direct_prompt: |
IMPORTANT: Review ONLY the DIFF/CHANGESET - the actual lines that were added or modified in this PR.
DO NOT review the entire file context, only analyze the specific changes being made.
Look for HIGH-PRIORITY issues in the CHANGED LINES ONLY:
1. CRITICAL BUGS: Logic errors, null pointer issues, infinite loops, race conditions
2. SECURITY: SQL injection, XSS, authentication bypass, exposed secrets, unsafe operations
3. BREAKING CHANGES: API contract violations, removed exports, changed function signatures
4. DATA LOSS RISKS: Destructive operations without safeguards, missing data validation
DO NOT comment on:
- Code that wasn't changed in this PR
- Style, formatting, or documentation
- Test coverage (unless tests are broken by the changes)
- Minor optimizations or best practices
- Existing code issues that weren't introduced by this PR
If you find no critical issues in the DIFF, respond with: "✅ No critical issues found in changes"
Keep response under 10 lines. Reference specific line numbers from the diff when reporting issues.
# Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR
use_sticky_comment: true
# Context-aware review based on PR characteristics
# Uncomment to enable different review strategies based on context
# direct_prompt: |
# ${{
# (github.event.pull_request.additions > 500) &&
# 'Large PR detected. Focus only on architectural issues and breaking changes. Skip minor issues.' ||
# contains(github.event.pull_request.title, 'fix') &&
# 'Bug fix PR: Verify the fix addresses the root cause and check for regression risks.' ||
# contains(github.event.pull_request.title, 'deps') &&
# 'Dependency update: Check for breaking changes and security advisories only.' ||
# contains(github.event.pull_request.title, 'refactor') &&
# 'Refactor PR: Verify no behavior changes and check for performance regressions.' ||
# contains(github.event.pull_request.title, 'feat') &&
# 'New feature: Check for security issues, edge cases, and integration problems only.' ||
# 'Standard review: Check for critical bugs, security issues, and breaking changes only.'
# }}
# Optional: Add specific tools for running tests or linting
# allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"
# Optional: Skip review for certain conditions
# if: |
# !contains(github.event.pull_request.title, '[skip-review]') &&
# !contains(github.event.pull_request.title, '[WIP]')

View File

@@ -1,64 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@beta
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
# model: "claude-opus-4-20250514"
# Optional: Customize the trigger phrase (default: @claude)
# trigger_phrase: "/claude"
# Optional: Trigger when specific user is assigned to an issue
# assignee_trigger: "claude-bot"
# Optional: Allow Claude to run specific commands
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
# Optional: Add custom instructions for Claude to customize its behavior for your project
# custom_instructions: |
# Follow our coding standards
# Ensure all new code has tests
# Use TypeScript for new files
# Optional: Custom environment variables for Claude
# claude_env: |
# NODE_ENV: test

View File

@@ -0,0 +1,210 @@
name: Generate Release Notes
on:
workflow_call:
inputs:
version:
description: 'Version number (e.g., 4.25.3)'
required: true
type: string
target_commitish:
description: 'Commit SHA or branch (leave empty for current HEAD)'
required: false
type: string
release_notes:
description: 'Custom release notes (leave empty to auto-generate)'
required: false
type: string
outputs:
release_notes:
description: 'Generated or provided release notes'
value: ${{ jobs.generate.outputs.release_notes }}
secrets:
UNRAID_BOT_GITHUB_ADMIN_TOKEN:
required: true
jobs:
generate:
name: Generate Release Notes
runs-on: ubuntu-latest
outputs:
release_notes: ${{ steps.generate_notes.outputs.release_notes }}
steps:
- name: Checkout repo
uses: actions/checkout@v5
with:
ref: ${{ inputs.target_commitish || github.ref }}
fetch-depth: 0
token: ${{ secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Generate Release Notes
id: generate_notes
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG_NAME="v${{ inputs.version }}"
VERSION="${{ inputs.version }}"
if [ -n "${{ inputs.release_notes }}" ]; then
NOTES="${{ inputs.release_notes }}"
else
CHANGELOG_PATH="api/CHANGELOG.md"
if [ -f "$CHANGELOG_PATH" ]; then
echo "Extracting release notes from CHANGELOG.md for version ${VERSION}"
NOTES=$(awk -v ver="$VERSION" '
BEGIN {
found=0; capture=0; output="";
gsub(/\./, "\\.", ver);
}
/^## \[/ {
if (capture) exit;
if ($0 ~ "\\[" ver "\\]") {
found=1;
capture=1;
}
}
capture {
if (output != "") output = output "\n";
output = output $0;
}
END {
if (found) print output;
else exit 1;
}
' "$CHANGELOG_PATH") || EXTRACTION_STATUS=$?
if [ ${EXTRACTION_STATUS:-0} -eq 0 ] && [ -n "$NOTES" ]; then
echo "✓ Found release notes in CHANGELOG.md"
else
echo "⚠ Version ${VERSION} not found in CHANGELOG.md, generating with conventional-changelog"
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
CHANGELOG_GENERATED=false
if [ -n "$PREV_TAG" ]; then
echo "Generating changelog from ${PREV_TAG}..HEAD using conventional-changelog"
npm install -g conventional-changelog-cli
TEMP_NOTES=$(mktemp)
conventional-changelog -p conventionalcommits \
--release-count 1 \
--output-unreleased \
> "$TEMP_NOTES" 2>/dev/null || true
if [ -s "$TEMP_NOTES" ]; then
NOTES=$(cat "$TEMP_NOTES")
if [ -n "$NOTES" ]; then
echo "✓ Generated changelog with conventional-changelog"
CHANGELOG_GENERATED=true
TEMP_CHANGELOG=$(mktemp)
{
if [ -f "$CHANGELOG_PATH" ]; then
head -n 1 "$CHANGELOG_PATH"
echo ""
echo "$NOTES"
echo ""
tail -n +2 "$CHANGELOG_PATH"
else
echo "# Changelog"
echo ""
echo "$NOTES"
fi
} > "$TEMP_CHANGELOG"
mv "$TEMP_CHANGELOG" "$CHANGELOG_PATH"
echo "✓ Updated CHANGELOG.md with generated notes"
else
echo "⚠ conventional-changelog produced empty output, using GitHub auto-generation"
NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \
-f tag_name="${TAG_NAME}" \
-f target_commitish="${{ inputs.target_commitish || github.sha }}" \
-f previous_tag_name="${PREV_TAG}" \
--jq '.body')
fi
else
echo "⚠ conventional-changelog failed, using GitHub auto-generation"
NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \
-f tag_name="${TAG_NAME}" \
-f target_commitish="${{ inputs.target_commitish || github.sha }}" \
-f previous_tag_name="${PREV_TAG}" \
--jq '.body')
fi
rm -f "$TEMP_NOTES"
else
echo "⚠ No previous tag found, using GitHub auto-generation"
NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \
-f tag_name="${TAG_NAME}" \
-f target_commitish="${{ inputs.target_commitish || github.sha }}" \
--jq '.body' || echo "Release ${VERSION}")
fi
if [ "$CHANGELOG_GENERATED" = true ]; then
BRANCH_OR_SHA="${{ inputs.target_commitish || github.ref }}"
if git show-ref --verify --quiet "refs/heads/${BRANCH_OR_SHA}"; then
echo ""
echo "=========================================="
echo "CHANGELOG GENERATED AND COMMITTED"
echo "=========================================="
echo ""
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
BEFORE_SHA=$(git rev-parse HEAD)
git add "$CHANGELOG_PATH"
git commit -m "chore: add changelog for version ${VERSION}"
git push origin "HEAD:${BRANCH_OR_SHA}"
AFTER_SHA=$(git rev-parse HEAD)
echo "✓ Changelog committed and pushed successfully"
echo ""
echo "Previous SHA: ${BEFORE_SHA}"
echo "New SHA: ${AFTER_SHA}"
echo ""
echo "⚠️ CRITICAL: A new commit was created, but github.sha is immutable."
echo "⚠️ github.sha = ${BEFORE_SHA} (original workflow trigger)"
echo "⚠️ The release tag must point to ${AFTER_SHA} (with changelog)"
echo ""
echo "Re-run this workflow to create the release with the correct commit."
echo ""
exit 1
else
echo "⚠ Target is a commit SHA, not a branch. Cannot push changelog updates."
echo "Changelog was generated but not committed."
fi
fi
fi
else
echo "⚠ CHANGELOG.md not found, using GitHub auto-generation"
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -n "$PREV_TAG" ]; then
NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \
-f tag_name="${TAG_NAME}" \
-f target_commitish="${{ inputs.target_commitish || github.sha }}" \
-f previous_tag_name="${PREV_TAG}" \
--jq '.body')
else
NOTES="Release ${VERSION}"
fi
fi
fi
echo "release_notes<<EOF" >> $GITHUB_OUTPUT
echo "$NOTES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT

View File

@@ -154,173 +154,15 @@ jobs:
files: ./coverage/coverage-final.json,../web/coverage/coverage-final.json,../unraid-ui/coverage/coverage-final.json,../packages/unraid-api-plugin-connect/coverage/coverage-final.json,../packages/unraid-shared/coverage/coverage-final.json
fail_ci_if_error: false
build-api:
name: Build API
runs-on: ubuntu-latest
outputs:
build_number: ${{ steps.buildnumber.outputs.build_number }}
defaults:
run:
working-directory: api
steps:
- name: Checkout repo
uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v5
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
with:
packages: bash procps python3 libvirt-dev jq zstd git build-essential
version: 1.0
- name: PNPM Install
run: |
cd ${{ github.workspace }}
pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
- name: Get Git Short Sha and API version
id: vars
run: |
GIT_SHA=$(git rev-parse --short HEAD)
IS_TAGGED=$(git describe --tags --abbrev=0 --exact-match || echo '')
PACKAGE_LOCK_VERSION=$(jq -r '.version' package.json)
API_VERSION=$([[ -n "$IS_TAGGED" ]] && echo "$PACKAGE_LOCK_VERSION" || echo "${PACKAGE_LOCK_VERSION}+${GIT_SHA}")
export API_VERSION
echo "API_VERSION=${API_VERSION}" >> $GITHUB_ENV
echo "PACKAGE_LOCK_VERSION=${PACKAGE_LOCK_VERSION}" >> $GITHUB_OUTPUT
- name: Generate build number
id: buildnumber
uses: onyxmueller/build-tag-number@v1
with:
token: ${{secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN}}
prefix: ${{steps.vars.outputs.PACKAGE_LOCK_VERSION}}
- name: Build
run: |
pnpm run build:release
tar -czf deploy/unraid-api.tgz -C deploy/pack/ .
- name: Upload tgz to Github artifacts
uses: actions/upload-artifact@v4
with:
name: unraid-api
path: ${{ github.workspace }}/api/deploy/unraid-api.tgz
build-unraid-ui-webcomponents:
name: Build Unraid UI Library (Webcomponent Version)
defaults:
run:
working-directory: unraid-ui
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v5
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
with:
packages: bash procps python3 libvirt-dev jq zstd git build-essential
version: 1.0
- name: Install dependencies
run: |
cd ${{ github.workspace }}
pnpm install --frozen-lockfile --filter @unraid/ui
- name: Lint
run: pnpm run lint
- name: Build
run: pnpm run build:wc
- name: Upload Artifact to Github
uses: actions/upload-artifact@v4
with:
name: unraid-wc-ui
path: unraid-ui/dist-wc/
build-web:
# needs: [build-unraid-ui]
name: Build Web App
defaults:
run:
working-directory: web
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v5
- name: Create env file
run: |
touch .env
echo VITE_ACCOUNT=${{ secrets.VITE_ACCOUNT }} >> .env
echo VITE_CONNECT=${{ secrets.VITE_CONNECT }} >> .env
echo VITE_UNRAID_NET=${{ secrets.VITE_UNRAID_NET }} >> .env
echo VITE_CALLBACK_KEY=${{ secrets.VITE_CALLBACK_KEY }} >> .env
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v5
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: PNPM Install
run: |
cd ${{ github.workspace }}
pnpm install --frozen-lockfile --filter @unraid/web --filter @unraid/ui
- name: Build Unraid UI
run: |
cd ${{ github.workspace }}/unraid-ui
pnpm run build
- name: Lint files
run: pnpm run lint
- name: Type Check
run: pnpm run type-check
- name: Test
run: pnpm run test:ci
- name: Build
run: pnpm run build
- name: Upload build to Github artifacts
uses: actions/upload-artifact@v4
with:
name: unraid-wc-rich
path: web/dist
build-artifacts:
name: Build All Artifacts
uses: ./.github/workflows/build-artifacts.yml
secrets:
VITE_ACCOUNT: ${{ secrets.VITE_ACCOUNT }}
VITE_CONNECT: ${{ secrets.VITE_CONNECT }}
VITE_UNRAID_NET: ${{ secrets.VITE_UNRAID_NET }}
VITE_CALLBACK_KEY: ${{ secrets.VITE_CALLBACK_KEY }}
UNRAID_BOT_GITHUB_ADMIN_TOKEN: ${{ secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN }}
release-please:
name: Release Please
@@ -329,15 +171,15 @@ jobs:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- test-api
- build-api
- build-web
- build-unraid-ui-webcomponents
- build-artifacts
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- id: release
uses: googleapis/release-please-action@v4
@@ -348,17 +190,15 @@ jobs:
build-plugin-staging-pr:
name: Build and Deploy Plugin
needs:
- build-api
- build-web
- build-unraid-ui-webcomponents
- build-artifacts
- test-api
uses: ./.github/workflows/build-plugin.yml
with:
RELEASE_CREATED: false
RELEASE_CREATED: 'false'
TAG: ${{ github.event.pull_request.number && format('PR{0}', github.event.pull_request.number) || '' }}
BUCKET_PATH: ${{ github.event.pull_request.number && format('unraid-api/tag/PR{0}', github.event.pull_request.number) || 'unraid-api' }}
BASE_URL: "https://preview.dl.unraid.net/unraid-api"
BUILD_NUMBER: ${{ needs.build-api.outputs.build_number }}
BUILD_NUMBER: ${{ needs.build-artifacts.outputs.build_number }}
secrets:
CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
CF_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}
@@ -370,15 +210,16 @@ jobs:
name: Build and Deploy Production Plugin
needs:
- release-please
- build-api
- build-artifacts
uses: ./.github/workflows/build-plugin.yml
with:
RELEASE_CREATED: true
RELEASE_CREATED: 'true'
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
TAG: ""
BUCKET_PATH: unraid-api
BASE_URL: "https://stable.dl.unraid.net/unraid-api"
BUILD_NUMBER: ${{ needs.build-api.outputs.build_number }}
BUILD_NUMBER: ${{ needs.build-artifacts.outputs.build_number }}
TRIGGER_PRODUCTION_RELEASE: true
secrets:
CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
CF_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}

239
.github/workflows/manual-release.yml vendored Normal file
View File

@@ -0,0 +1,239 @@
name: Manual Release
on:
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g., 4.25.3)'
required: true
type: string
target_commitish:
description: 'Commit SHA or branch (leave empty for current HEAD)'
required: false
type: string
release_notes:
description: 'Release notes/changelog (leave empty to auto-generate from commits)'
required: false
type: string
prerelease:
description: 'Mark as prerelease'
required: false
type: boolean
default: false
permissions:
contents: write
pull-requests: write
jobs:
validate-version:
name: Validate and Update Package Versions
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v5
with:
ref: ${{ inputs.target_commitish || github.ref }}
fetch-depth: 0
token: ${{ secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Check and Update Package Versions
run: |
EXPECTED_VERSION="${{ inputs.version }}"
MISMATCHES_FOUND=false
PACKAGE_JSONS=(
"package.json"
"api/package.json"
"web/package.json"
"unraid-ui/package.json"
"plugin/package.json"
"packages/unraid-shared/package.json"
"packages/unraid-api-plugin-health/package.json"
"packages/unraid-api-plugin-generator/package.json"
"packages/unraid-api-plugin-connect/package.json"
)
echo "Checking package.json versions against expected version: ${EXPECTED_VERSION}"
for pkg in "${PACKAGE_JSONS[@]}"; do
if [ -f "$pkg" ]; then
CURRENT_VERSION=$(node -p "require('./$pkg').version")
if [ "$CURRENT_VERSION" != "$EXPECTED_VERSION" ]; then
echo "❌ Version mismatch in $pkg: $CURRENT_VERSION != $EXPECTED_VERSION"
MISMATCHES_FOUND=true
# Detect indentation by checking the first property line
INDENT_SPACES=$(head -10 "$pkg" | grep '^ *"' | head -1 | sed 's/".*//g' | wc -c)
INDENT_SPACES=$((INDENT_SPACES - 1))
jq --indent "$INDENT_SPACES" --arg version "$EXPECTED_VERSION" '.version = $version' "$pkg" > "$pkg.tmp" && mv "$pkg.tmp" "$pkg"
echo "✓ Updated $pkg to version $EXPECTED_VERSION"
else
echo "✓ $pkg version matches: $CURRENT_VERSION"
fi
fi
done
if [ "$MISMATCHES_FOUND" = true ]; then
echo ""
echo "=========================================="
echo "Version mismatches found!"
echo "=========================================="
echo ""
BRANCH_OR_SHA="${{ inputs.target_commitish || github.ref }}"
if git show-ref --verify --quiet "refs/heads/${BRANCH_OR_SHA}"; then
echo "Creating commit with version updates and pushing to branch: ${BRANCH_OR_SHA}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
BEFORE_SHA=$(git rev-parse HEAD)
git add ${PACKAGE_JSONS[@]}
git commit -m "chore: update package versions to ${{ inputs.version }}"
git push origin "HEAD:${BRANCH_OR_SHA}"
AFTER_SHA=$(git rev-parse HEAD)
echo ""
echo "=========================================="
echo "WORKFLOW MUST BE RE-RUN"
echo "=========================================="
echo ""
echo "✓ Version updates committed and pushed successfully"
echo ""
echo "Previous SHA: ${BEFORE_SHA}"
echo "New SHA: ${AFTER_SHA}"
echo ""
echo "⚠️ CRITICAL: A new commit was created, but github.sha is immutable."
echo "⚠️ github.sha = ${BEFORE_SHA} (original workflow trigger)"
echo "⚠️ The release tag must point to ${AFTER_SHA} (with version updates)"
echo ""
echo "Re-run this workflow to create the release with the correct commit."
echo ""
exit 1
else
echo "Target is a commit SHA, not a branch. Cannot push version updates."
echo "Please update the package.json versions manually and re-run the workflow."
exit 1
fi
fi
echo ""
echo "✓ All package.json versions match the expected version: ${EXPECTED_VERSION}"
build-artifacts:
name: Build All Artifacts
needs:
- validate-version
uses: ./.github/workflows/build-artifacts.yml
with:
ref: ${{ inputs.target_commitish || github.ref }}
version_override: ${{ inputs.version }}
secrets:
VITE_ACCOUNT: ${{ secrets.VITE_ACCOUNT }}
VITE_CONNECT: ${{ secrets.VITE_CONNECT }}
VITE_UNRAID_NET: ${{ secrets.VITE_UNRAID_NET }}
VITE_CALLBACK_KEY: ${{ secrets.VITE_CALLBACK_KEY }}
UNRAID_BOT_GITHUB_ADMIN_TOKEN: ${{ secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN }}
generate-release-notes:
name: Generate Release Notes
needs:
- build-artifacts
uses: ./.github/workflows/generate-release-notes.yml
with:
version: ${{ inputs.version }}
target_commitish: ${{ inputs.target_commitish || github.ref }}
release_notes: ${{ inputs.release_notes }}
secrets:
UNRAID_BOT_GITHUB_ADMIN_TOKEN: ${{ secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN }}
create-release:
name: Create GitHub Release (Draft)
runs-on: ubuntu-latest
needs:
- generate-release-notes
outputs:
tag_name: ${{ steps.create_release.outputs.tag_name }}
release_notes: ${{ needs.generate-release-notes.outputs.release_notes }}
steps:
- name: Checkout repo
uses: actions/checkout@v5
with:
ref: ${{ inputs.target_commitish || github.ref }}
fetch-depth: 0
- name: Create or Update Release as Draft
id: create_release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG_NAME="v${{ inputs.version }}"
TARGET="${{ inputs.target_commitish || github.sha }}"
echo "tag_name=${TAG_NAME}" >> $GITHUB_OUTPUT
if gh release view "${TAG_NAME}" > /dev/null 2>&1; then
echo "Release ${TAG_NAME} already exists, updating as draft..."
gh release edit "${TAG_NAME}" \
--draft \
--notes "${{ needs.generate-release-notes.outputs.release_notes }}" \
${{ inputs.prerelease && '--prerelease' || '' }}
else
echo "Creating new draft release ${TAG_NAME}..."
git tag "${TAG_NAME}" "${TARGET}" || true
git push origin "${TAG_NAME}" || true
gh release create "${TAG_NAME}" \
--draft \
--title "${{ inputs.version }}" \
--notes "${{ needs.generate-release-notes.outputs.release_notes }}" \
--target "${TARGET}" \
${{ inputs.prerelease && '--prerelease' || '' }}
fi
build-plugin-production:
name: Build and Deploy Production Plugin
needs:
- create-release
- build-artifacts
uses: ./.github/workflows/build-plugin.yml
with:
RELEASE_CREATED: 'true'
RELEASE_TAG: ${{ needs.create-release.outputs.tag_name }}
TAG: ""
BUCKET_PATH: unraid-api
BASE_URL: "https://stable.dl.unraid.net/unraid-api"
BUILD_NUMBER: ${{ needs.build-artifacts.outputs.build_number }}
ref: ${{ inputs.target_commitish || github.ref }}
secrets:
CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
CF_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}
CF_BUCKET_PREVIEW: ${{ secrets.CF_BUCKET_PREVIEW }}
CF_ENDPOINT: ${{ secrets.CF_ENDPOINT }}
UNRAID_BOT_GITHUB_ADMIN_TOKEN: ${{ secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN }}
publish-release:
name: Publish Release
runs-on: ubuntu-latest
needs:
- create-release
- build-plugin-production
steps:
- name: Publish Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG_NAME="${{ needs.create-release.outputs.tag_name }}"
echo "Publishing release ${TAG_NAME}..."
gh release edit "${TAG_NAME}" --draft=false --repo ${{ github.repository }}

30
.github/workflows/publish-schema.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Publish GraphQL Schema
on:
push:
branches:
- main
paths:
- 'api/generated-schema.graphql'
workflow_dispatch:
jobs:
publish-schema:
name: Publish Schema to Apollo Studio
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v5
- name: Install Apollo Rover CLI
run: |
curl -sSL https://rover.apollo.dev/nix/latest | sh
echo "$HOME/.rover/bin" >> $GITHUB_PATH
- name: Publish schema to Apollo Studio
env:
APOLLO_KEY: ${{ secrets.APOLLO_KEY }}
run: |
rover graph publish Unraid-API@current \
--schema api/generated-schema.graphql

3
.gitignore vendored
View File

@@ -123,3 +123,6 @@ api/dev/Unraid.net/myservers.cfg
# local Mise settings
.mise.toml
# Compiled test pages (generated from Nunjucks templates)
web/public/test-pages/*.html

View File

@@ -1 +1 @@
{".":"4.24.0"}
{".":"4.27.2"}

View File

@@ -1,7 +1,8 @@
@custom-variant dark (&:where(.dark, .dark *));
/* Utility defaults for web components (when we were using shadow DOM) */
:host {
:host,
.unapi {
--tw-divide-y-reverse: 0;
--tw-border-style: solid;
--tw-font-weight: initial;
@@ -61,7 +62,7 @@
}
*/
body {
.unapi {
--color-alpha: #1c1b1b;
--color-beta: #f2f2f2;
--color-gamma: #999999;
@@ -73,13 +74,14 @@ body {
--ring-shadow: 0 0 var(--color-beta);
}
button:not(:disabled),
[role='button']:not(:disabled) {
.unapi button:not(:disabled),
.unapi [role='button']:not(:disabled) {
cursor: pointer;
}
/* Font size overrides for SSO button component */
unraid-sso-button {
.unapi unraid-sso-button,
unraid-sso-button.unapi {
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
@@ -93,4 +95,4 @@ unraid-sso-button {
--text-7xl: 4.5rem;
--text-8xl: 6rem;
--text-9xl: 8rem;
}
}

View File

@@ -5,13 +5,7 @@
*/
/* Default/White Theme */
:root,
.theme-white {
--header-text-primary: #ffffff;
--header-text-secondary: #999999;
--header-background-color: #1c1b1b;
--header-gradient-start: rgba(28, 27, 27, 0);
--header-gradient-end: rgba(28, 27, 27, 0.7);
.Theme--white {
--color-border: #383735;
--color-alpha: #ff8c2f;
--color-beta: #1c1b1b;
@@ -20,13 +14,8 @@
}
/* Black Theme */
.theme-black,
.theme-black.dark {
--header-text-primary: #1c1b1b;
--header-text-secondary: #999999;
--header-background-color: #f2f2f2;
--header-gradient-start: rgba(242, 242, 242, 0);
--header-gradient-end: rgba(242, 242, 242, 0.7);
.Theme--black,
.Theme--black.dark {
--color-border: #e0e0e0;
--color-alpha: #ff8c2f;
--color-beta: #f2f2f2;
@@ -35,12 +24,7 @@
}
/* Gray Theme */
.theme-gray {
--header-text-primary: #ffffff;
--header-text-secondary: #999999;
--header-background-color: #1c1b1b;
--header-gradient-start: rgba(28, 27, 27, 0);
--header-gradient-end: rgba(28, 27, 27, 0.7);
.Theme--gray {
--color-border: #383735;
--color-alpha: #ff8c2f;
--color-beta: #383735;
@@ -49,12 +33,7 @@
}
/* Azure Theme */
.theme-azure {
--header-text-primary: #1c1b1b;
--header-text-secondary: #999999;
--header-background-color: #f2f2f2;
--header-gradient-start: rgba(242, 242, 242, 0);
--header-gradient-end: rgba(242, 242, 242, 0.7);
.Theme--azure {
--color-border: #5a8bb8;
--color-alpha: #ff8c2f;
--color-beta: #e7f2f8;
@@ -66,27 +45,3 @@
.dark {
--color-border: #383735;
}
/*
* Dynamic color variables for user overrides from GraphQL
* These are set via JavaScript and override the theme defaults
* Using :root with class for higher specificity to override theme classes
*/
:root.has-custom-header-text {
--header-text-primary: var(--custom-header-text-primary);
--color-header-text-primary: var(--custom-header-text-primary);
}
:root.has-custom-header-meta {
--header-text-secondary: var(--custom-header-text-secondary);
--color-header-text-secondary: var(--custom-header-text-secondary);
}
:root.has-custom-header-bg {
--header-background-color: var(--custom-header-background-color);
--color-header-background: var(--custom-header-background-color);
--header-gradient-start: var(--custom-header-gradient-start);
--header-gradient-end: var(--custom-header-gradient-end);
--color-header-gradient-start: var(--custom-header-gradient-start);
--color-header-gradient-end: var(--custom-header-gradient-end);
}

View File

@@ -14,6 +14,9 @@ This is the Unraid API monorepo containing multiple packages that provide API fu
## Essential Commands
pnpm does not use `--` to pass additional arguments.
For example, to target a specific test file, `pnpm test <file>` is sufficient.
### Development
```bash

View File

@@ -32,3 +32,4 @@ CHOKIDAR_USEPOLLING=true
LOG_TRANSPORT=console
LOG_LEVEL=trace
ENABLE_NEXT_DOCKER_RELEASE=true
SKIP_CONNECT_PLUGIN_CHECK=true

View File

@@ -42,7 +42,10 @@ export default tseslint.config(
'ignorePackages',
{
js: 'always',
ts: 'always',
mjs: 'always',
cjs: 'always',
ts: 'never',
tsx: 'never',
},
],
'no-restricted-globals': [

View File

@@ -1,5 +1,112 @@
# Changelog
## [4.27.2](https://github.com/unraid/api/compare/v4.27.1...v4.27.2) (2025-11-21)
### Bug Fixes
* issue with header flashing + issue with trial date ([64875ed](https://github.com/unraid/api/commit/64875edbba786a0d1ba0113c9e9a3d38594eafcc))
## [4.27.1](https://github.com/unraid/api/compare/v4.27.0...v4.27.1) (2025-11-21)
### Bug Fixes
* missing translations for expiring trials ([#1800](https://github.com/unraid/api/issues/1800)) ([36c1049](https://github.com/unraid/api/commit/36c104915ece203a3cac9e1a13e0c325e536a839))
* resolve header flash when background color is set ([#1796](https://github.com/unraid/api/issues/1796)) ([dc9a036](https://github.com/unraid/api/commit/dc9a036c73d8ba110029364e0d044dc24c7d0dfa))
## [4.27.0](https://github.com/unraid/api/compare/v4.26.2...v4.27.0) (2025-11-19)
### Features
* remove Unraid API log download functionality ([#1793](https://github.com/unraid/api/issues/1793)) ([e4a9b82](https://github.com/unraid/api/commit/e4a9b8291b049752a9ff59b17ff50cf464fe0535))
### Bug Fixes
* auto-uninstallation of connect api plugin ([#1791](https://github.com/unraid/api/issues/1791)) ([e734043](https://github.com/unraid/api/commit/e7340431a58821ec1b4f5d1b452fba6613b01fa5))
## [4.26.2](https://github.com/unraid/api/compare/v4.26.1...v4.26.2) (2025-11-19)
### Bug Fixes
* **theme:** Missing header background color ([e2fdf6c](https://github.com/unraid/api/commit/e2fdf6cadbd816559b8c82546c2bc771a81ffa9e))
## [4.26.1](https://github.com/unraid/api/compare/v4.26.0...v4.26.1) (2025-11-18)
### Bug Fixes
* **theme:** update theme class naming and scoping logic ([b28ef1e](https://github.com/unraid/api/commit/b28ef1ea334cb4842f01fa992effa7024185c6c9))
## [4.26.0](https://github.com/unraid/api/compare/v4.25.3...v4.26.0) (2025-11-17)
### Features
* add cpu power query & subscription ([#1745](https://github.com/unraid/api/issues/1745)) ([d7aca81](https://github.com/unraid/api/commit/d7aca81c60281bfa47fb9113929c1ead6ed3361b))
* add schema publishing to apollo studio ([#1772](https://github.com/unraid/api/issues/1772)) ([7e13202](https://github.com/unraid/api/commit/7e13202aa1c02803095bb72bb1bcb2472716f53a))
* add workflow_dispatch trigger to schema publishing workflow ([818e7ce](https://github.com/unraid/api/commit/818e7ce997059663e07efcf1dab706bf0d7fc9da))
* apollo studio readme link ([c4cd0c6](https://github.com/unraid/api/commit/c4cd0c63520deec15d735255f38811f0360fe3a1))
* **cli:** make `unraid-api plugins remove` scriptable ([#1774](https://github.com/unraid/api/issues/1774)) ([64eb9ce](https://github.com/unraid/api/commit/64eb9ce9b5d1ff4fb1f08d9963522c5d32221ba7))
* use persisted theme css to fix flashes on header ([#1784](https://github.com/unraid/api/issues/1784)) ([854b403](https://github.com/unraid/api/commit/854b403fbd85220a3012af58ce033cf0b8418516))
### Bug Fixes
* **api:** decode html entities before parsing notifications ([#1768](https://github.com/unraid/api/issues/1768)) ([42406e7](https://github.com/unraid/api/commit/42406e795da1e5b95622951a467722dde72d51a8))
* **connect:** disable api plugin if unraid plugin is absent ([#1773](https://github.com/unraid/api/issues/1773)) ([c264a18](https://github.com/unraid/api/commit/c264a1843cf115e8cc1add1ab4f12fdcc932405a))
* detection of flash backup activation state ([#1769](https://github.com/unraid/api/issues/1769)) ([d18eaf2](https://github.com/unraid/api/commit/d18eaf2364e0c04992c52af38679ff0a0c570440))
* re-add missing header gradient styles ([#1787](https://github.com/unraid/api/issues/1787)) ([f8a6785](https://github.com/unraid/api/commit/f8a6785e9c92f81acaef76ac5eb78a4a769e69da))
* respect OS safe mode in plugin loader ([#1775](https://github.com/unraid/api/issues/1775)) ([92af3b6](https://github.com/unraid/api/commit/92af3b61156cabae70368cf5222a2f7ac5b4d083))
## [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))
## [4.25.2](https://github.com/unraid/api/compare/v4.25.1...v4.25.2) (2025-09-30)
### Bug Fixes
* enhance activation code modal visibility logic ([#1733](https://github.com/unraid/api/issues/1733)) ([e57ec00](https://github.com/unraid/api/commit/e57ec00627e54ce76d903fd0fa8686ad02b393f3))
## [4.25.1](https://github.com/unraid/api/compare/v4.25.0...v4.25.1) (2025-09-30)
### Bug Fixes
* add cache busting to web component extractor ([#1731](https://github.com/unraid/api/issues/1731)) ([0d165a6](https://github.com/unraid/api/commit/0d165a608740505bdc505dcf69fb615225969741))
* Connect won't appear within Apps - Previous Apps ([#1727](https://github.com/unraid/api/issues/1727)) ([d73953f](https://github.com/unraid/api/commit/d73953f8ff3d7425c0aed32d16236ededfd948e1))
## [4.25.0](https://github.com/unraid/api/compare/v4.24.1...v4.25.0) (2025-09-26)
### Features
* add Tailwind scoping plugin and integrate into Vite config ([#1722](https://github.com/unraid/api/issues/1722)) ([b7afaf4](https://github.com/unraid/api/commit/b7afaf463243b073e1ab1083961a16a12ac6c4a3))
* notification filter controls pill buttons ([#1718](https://github.com/unraid/api/issues/1718)) ([661865f](https://github.com/unraid/api/commit/661865f97611cf802f239fde8232f3109281dde6))
### Bug Fixes
* enable auth guard for nested fields - thanks [@ingel81](https://github.com/ingel81) ([7bdeca8](https://github.com/unraid/api/commit/7bdeca8338a3901f15fde06fd7aede3b0c16e087))
* enhance user context validation in auth module ([#1726](https://github.com/unraid/api/issues/1726)) ([cd5eff1](https://github.com/unraid/api/commit/cd5eff11bcb4398581472966cb7ec124eac7ad0a))
## [4.24.1](https://github.com/unraid/api/compare/v4.24.0...v4.24.1) (2025-09-23)
### Bug Fixes
* cleanup leftover removed packages on upgrade ([#1719](https://github.com/unraid/api/issues/1719)) ([9972a5f](https://github.com/unraid/api/commit/9972a5f178f9a251e6c129d85c5f11cfd25e6281))
* enhance version comparison logic in installation script ([d9c561b](https://github.com/unraid/api/commit/d9c561bfebed0c553fe4bfa26b088ae71ca59755))
* issue with incorrect permissions on viewer / other roles ([378cdb7](https://github.com/unraid/api/commit/378cdb7f102f63128dd236c13f1a3745902d5a2c))
## [4.24.0](https://github.com/unraid/api/compare/v4.23.1...v4.24.0) (2025-09-18)

View File

@@ -71,6 +71,10 @@ unraid-api report -vv
If you found this file you're likely a developer. If you'd like to know more about the API and when it's available please join [our discord](https://discord.unraid.net/).
## Internationalization
- Run `pnpm --filter @unraid/api i18n:extract` to scan the Nest.js source for translation helper usages and update `src/i18n/en.json` with any new keys. The extractor keeps existing translations intact and appends new keys with their English source text.
## License
Copyright Lime Technology Inc. All rights reserved.

View File

@@ -1,5 +1,5 @@
{
"version": "4.22.2",
"version": "4.27.2",
"extraOrigins": [],
"sandbox": true,
"ssoSubIds": [],

View File

@@ -0,0 +1,6 @@
timestamp=1730937600
event=Hashtag Test
subject=Warning [UNRAID] - #1 OS is cooking
description=Disk 1 temperature has reached #epic # levels of proportion
importance=warning

View File

@@ -0,0 +1,6 @@
timestamp=1730937600
event=Temperature Test
subject=Warning [UNRAID] - High disk temperature detected: 45&#8201;&#176;C
description=Disk 1 temperature has reached 45&#8201;&#176;C (threshold: 40&#8201;&#176;C)<br><br>Current temperatures:<br>Parity - 32&#8201;&#176;C [OK]<br>Disk 1 - 45&#8201;&#176;C [WARNING]<br>Disk 2 - 38&#8201;&#176;C [OK]<br>Cache - 28&#8201;&#176;C [OK]<br><br>Please check cooling system.
importance=warning

View File

@@ -1,22 +0,0 @@
{
"$schema": "https://json.schemastore.org/pm2-ecosystem",
"apps": [
{
"name": "unraid-api",
"script": "./dist/main.js",
"cwd": "/usr/local/unraid-api",
"exec_mode": "fork",
"wait_ready": true,
"listen_timeout": 15000,
"max_restarts": 10,
"min_uptime": 10000,
"watch": false,
"interpreter": "/usr/local/bin/node",
"ignore_watch": ["node_modules", "src", ".env.*", "myservers.cfg"],
"out_file": "/var/log/graphql-api.log",
"error_file": "/var/log/graphql-api.log",
"merge_logs": true,
"kill_timeout": 10000
}
]
}

View File

@@ -1391,6 +1391,19 @@ type CpuLoad {
percentSteal: Float!
}
type CpuPackages implements Node {
id: PrefixedID!
"""Total CPU package power draw (W)"""
totalPower: Float!
"""Power draw per package (W)"""
power: [Float!]!
"""Temperature per package (°C)"""
temp: [Float!]!
}
type CpuUtilization implements Node {
id: PrefixedID!
@@ -1454,6 +1467,12 @@ type InfoCpu implements Node {
"""CPU feature flags"""
flags: [String!]
"""
Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]]
"""
topology: [[[Int!]!]!]!
packages: CpuPackages!
}
type MemoryLayout implements Node {
@@ -1654,8 +1673,8 @@ type PackageVersions {
"""npm version"""
npm: String
"""pm2 version"""
pm2: String
"""nodemon version"""
nodemon: String
"""Git version"""
git: String
@@ -2642,6 +2661,7 @@ type Subscription {
arraySubscription: UnraidArray!
logFile(path: String!): LogFileContent!
systemMetricsCpu: CpuUtilization!
systemMetricsCpuTelemetry: CpuPackages!
systemMetricsMemory: MemoryUtilization!
upsUpdates: UPSDevice!
}

View File

@@ -1257,7 +1257,7 @@ type Versions {
openssl: String
perl: String
php: String
pm2: String
nodemon: String
postfix: String
postgresql: String
python: String

17
api/nodemon.json Normal file
View File

@@ -0,0 +1,17 @@
{
"watch": [
"dist/main.js"
],
"ignore": [
"node_modules",
"src",
".env.*"
],
"exec": "node $UNRAID_API_SERVER_ENTRYPOINT",
"signal": "SIGTERM",
"ext": "js,json",
"restartable": "rs",
"env": {
"NODE_ENV": "production"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/api",
"version": "4.24.0",
"version": "4.27.2",
"main": "src/cli/index.ts",
"type": "module",
"corepack": {
@@ -30,6 +30,8 @@
"// GraphQL Codegen": "",
"codegen": "graphql-codegen --config codegen.ts",
"codegen:watch": "graphql-codegen --config codegen.ts --watch",
"// Internationalization": "",
"i18n:extract": "node ./scripts/extract-translations.mjs",
"// Code Quality": "",
"lint": "eslint --config .eslintrc.ts src/",
"lint:fix": "eslint --fix --config .eslintrc.ts src/",
@@ -114,6 +116,7 @@
"graphql-subscriptions": "3.0.0",
"graphql-tag": "2.12.6",
"graphql-ws": "6.0.6",
"html-entities": "^2.6.0",
"ini": "5.0.0",
"ip": "2.0.1",
"jose": "6.0.13",
@@ -126,6 +129,7 @@
"nestjs-pino": "4.4.0",
"node-cache": "5.1.2",
"node-window-polyfill": "1.0.4",
"nodemon": "3.1.10",
"openid-client": "6.6.4",
"p-retry": "7.0.0",
"passport-custom": "1.1.1",
@@ -134,7 +138,7 @@
"pino": "9.9.0",
"pino-http": "10.5.0",
"pino-pretty": "13.1.1",
"pm2": "6.0.8",
"proper-lockfile": "^4.1.2",
"reflect-metadata": "^0.1.14",
"rxjs": "7.8.2",
"semver": "7.7.2",
@@ -185,6 +189,7 @@
"@types/mustache": "4.2.6",
"@types/node": "22.18.0",
"@types/pify": "6.1.0",
"@types/proper-lockfile": "^4.1.4",
"@types/semver": "7.7.0",
"@types/sendmail": "1.4.7",
"@types/stoppable": "1.1.3",
@@ -200,7 +205,6 @@
"eslint-plugin-no-relative-import-paths": "1.6.1",
"eslint-plugin-prettier": "5.5.4",
"jiti": "2.5.1",
"nodemon": "3.1.10",
"prettier": "3.6.2",
"rollup-plugin-node-externals": "8.1.0",
"supertest": "7.1.4",

View File

@@ -7,7 +7,7 @@ import { exit } from 'process';
import type { PackageJson } from 'type-fest';
import { $, cd } from 'zx';
import { getDeploymentVersion } from './get-deployment-version.js';
import { getDeploymentVersion } from '@app/../scripts/get-deployment-version.js';
type ApiPackageJson = PackageJson & {
version: string;
@@ -94,7 +94,7 @@ try {
await writeFile('./deploy/pack/package.json', JSON.stringify(parsedPackageJson, null, 4));
// Copy necessary files to the pack directory
await $`cp -r dist README.md .env.* ecosystem.config.json ./deploy/pack/`;
await $`cp -r dist README.md .env.* nodemon.json ./deploy/pack/`;
// Change to the pack directory and install dependencies
cd('./deploy/pack');

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env node
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { glob } from 'glob';
import ts from 'typescript';
const projectRoot = process.cwd();
const sourcePatterns = 'src/**/*.{ts,js}';
const ignorePatterns = [
'**/__tests__/**',
'**/__test__/**',
'**/*.spec.ts',
'**/*.spec.js',
'**/*.test.ts',
'**/*.test.js',
];
const englishLocaleFile = path.resolve(projectRoot, 'src/i18n/en.json');
const identifierTargets = new Set(['t', 'translate']);
const propertyTargets = new Set([
'i18n.t',
'i18n.translate',
'ctx.t',
'this.translate',
'this.i18n.translate',
'this.i18n.t',
]);
function getPropertyChain(node) {
if (ts.isIdentifier(node)) {
return node.text;
}
if (ts.isPropertyAccessExpression(node)) {
const left = getPropertyChain(node.expression);
if (!left) return undefined;
return `${left}.${node.name.text}`;
}
return undefined;
}
function extractLiteral(node) {
if (ts.isStringLiteralLike(node)) {
return node.text;
}
if (ts.isNoSubstitutionTemplateLiteral(node)) {
return node.text;
}
return undefined;
}
function collectKeysFromSource(sourceFile) {
const keys = new Set();
function visit(node) {
if (ts.isCallExpression(node)) {
const expr = node.expression;
let matches = false;
if (ts.isIdentifier(expr) && identifierTargets.has(expr.text)) {
matches = true;
} else if (ts.isPropertyAccessExpression(expr)) {
const chain = getPropertyChain(expr);
if (chain && propertyTargets.has(chain)) {
matches = true;
}
}
if (matches) {
const [firstArg] = node.arguments;
if (firstArg) {
const literal = extractLiteral(firstArg);
if (literal) {
keys.add(literal);
}
}
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return keys;
}
async function loadEnglishCatalog() {
try {
const raw = await readFile(englishLocaleFile, 'utf8');
const parsed = raw.trim() ? JSON.parse(raw) : {};
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('English locale file must contain a JSON object.');
}
return parsed;
} catch (error) {
if (error && error.code === 'ENOENT') {
return {};
}
throw error;
}
}
async function ensureEnglishCatalog(keys) {
const existingCatalog = await loadEnglishCatalog();
const existingKeys = new Set(Object.keys(existingCatalog));
let added = 0;
const combinedKeys = new Set([...existingKeys, ...keys]);
const sortedKeys = Array.from(combinedKeys).sort((a, b) => a.localeCompare(b));
const nextCatalog = {};
for (const key of sortedKeys) {
if (Object.prototype.hasOwnProperty.call(existingCatalog, key)) {
nextCatalog[key] = existingCatalog[key];
} else {
nextCatalog[key] = key;
added += 1;
}
}
const nextJson = `${JSON.stringify(nextCatalog, null, 2)}\n`;
const existingJson = JSON.stringify(existingCatalog, null, 2) + '\n';
if (nextJson !== existingJson) {
await writeFile(englishLocaleFile, nextJson, 'utf8');
}
return added;
}
async function main() {
const files = await glob(sourcePatterns, {
cwd: projectRoot,
ignore: ignorePatterns,
absolute: true,
});
const collectedKeys = new Set();
await Promise.all(
files.map(async (file) => {
const content = await readFile(file, 'utf8');
const sourceFile = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true);
const keys = collectKeysFromSource(sourceFile);
keys.forEach((key) => collectedKeys.add(key));
}),
);
const added = await ensureEnglishCatalog(collectedKeys);
if (added === 0) {
console.log('[i18n] No new backend translation keys detected.');
} else {
console.log(`[i18n] Added ${added} key(s) to src/i18n/en.json.`);
}
}
main().catch((error) => {
console.error('[i18n] Failed to extract backend translations.', error);
process.exitCode = 1;
});

View File

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

View File

@@ -1,5 +0,0 @@
/* eslint-disable no-undef */
// Dummy process for PM2 testing
setInterval(() => {
// Keep process alive
}, 1000);

View File

@@ -1,222 +0,0 @@
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execa } from 'execa';
import pm2 from 'pm2';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { isUnraidApiRunning } from '@app/core/utils/pm2/unraid-api-running.js';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const PROJECT_ROOT = join(__dirname, '../../../../..');
const DUMMY_PROCESS_PATH = join(__dirname, 'dummy-process.js');
const CLI_PATH = join(PROJECT_ROOT, 'dist/cli.js');
const TEST_PROCESS_NAME = 'test-unraid-api';
// Shared PM2 connection state
let pm2Connected = false;
// Helper to ensure PM2 connection is established
async function ensurePM2Connection() {
if (pm2Connected) return;
return new Promise<void>((resolve, reject) => {
pm2.connect((err) => {
if (err) {
reject(err);
return;
}
pm2Connected = true;
resolve();
});
});
}
// Helper to delete specific test processes (lightweight, reuses connection)
async function deleteTestProcesses() {
if (!pm2Connected) {
// No connection, nothing to clean up
return;
}
const deletePromise = new Promise<void>((resolve) => {
// Delete specific processes we might have created
const processNames = ['unraid-api', TEST_PROCESS_NAME];
let deletedCount = 0;
const deleteNext = () => {
if (deletedCount >= processNames.length) {
resolve();
return;
}
const processName = processNames[deletedCount];
pm2.delete(processName, () => {
// Ignore errors, process might not exist
deletedCount++;
deleteNext();
});
};
deleteNext();
});
const timeoutPromise = new Promise<void>((resolve) => {
setTimeout(() => resolve(), 3000); // 3 second timeout
});
return Promise.race([deletePromise, timeoutPromise]);
}
// Helper to ensure PM2 is completely clean (heavy cleanup with daemon kill)
async function cleanupAllPM2Processes() {
// First delete test processes if we have a connection
if (pm2Connected) {
await deleteTestProcesses();
}
return new Promise<void>((resolve) => {
// Always connect fresh for daemon kill (in case we weren't connected)
pm2.connect((err) => {
if (err) {
// If we can't connect, assume PM2 is not running
pm2Connected = false;
resolve();
return;
}
// Kill the daemon to ensure fresh state
pm2.killDaemon(() => {
pm2.disconnect();
pm2Connected = false;
// Small delay to let PM2 fully shutdown
setTimeout(resolve, 500);
});
});
});
}
describe.skipIf(!!process.env.CI)('PM2 integration tests', () => {
beforeAll(async () => {
// Set PM2_HOME to use home directory for testing (not /var/log)
process.env.PM2_HOME = join(homedir(), '.pm2');
// Build the CLI if it doesn't exist (only for CLI tests)
if (!existsSync(CLI_PATH)) {
console.log('Building CLI for integration tests...');
try {
await execa('pnpm', ['build'], {
cwd: PROJECT_ROOT,
stdio: 'inherit',
timeout: 120000, // 2 minute timeout for build
});
} catch (error) {
console.error('Failed to build CLI:', error);
throw new Error(
'Cannot run CLI integration tests without built CLI. Run `pnpm build` first.'
);
}
}
// Only do a full cleanup once at the beginning
await cleanupAllPM2Processes();
}, 150000); // 2.5 minute timeout for setup
afterAll(async () => {
// Only do a full cleanup once at the end
await cleanupAllPM2Processes();
});
afterEach(async () => {
// Lightweight cleanup after each test - just delete our test processes
await deleteTestProcesses();
}, 5000); // 5 second timeout for cleanup
describe('isUnraidApiRunning function', () => {
it('should return false when PM2 is not running the unraid-api process', async () => {
const result = await isUnraidApiRunning();
expect(result).toBe(false);
});
it('should return true when PM2 has unraid-api process running', async () => {
// Ensure PM2 connection
await ensurePM2Connection();
// Start a dummy process with the name 'unraid-api'
await new Promise<void>((resolve, reject) => {
pm2.start(
{
script: DUMMY_PROCESS_PATH,
name: 'unraid-api',
},
(startErr) => {
if (startErr) return reject(startErr);
resolve();
}
);
});
// Give PM2 time to start the process
await new Promise((resolve) => setTimeout(resolve, 2000));
const result = await isUnraidApiRunning();
expect(result).toBe(true);
}, 30000);
it('should return false when unraid-api process is stopped', async () => {
// Ensure PM2 connection
await ensurePM2Connection();
// Start and then stop the process
await new Promise<void>((resolve, reject) => {
pm2.start(
{
script: DUMMY_PROCESS_PATH,
name: 'unraid-api',
},
(startErr) => {
if (startErr) return reject(startErr);
// Stop the process after starting
setTimeout(() => {
pm2.stop('unraid-api', (stopErr) => {
if (stopErr) return reject(stopErr);
resolve();
});
}, 1000);
}
);
});
await new Promise((resolve) => setTimeout(resolve, 1000));
const result = await isUnraidApiRunning();
expect(result).toBe(false);
}, 30000);
it('should handle PM2 connection errors gracefully', async () => {
// Disconnect PM2 first to ensure we're testing fresh connection
await new Promise<void>((resolve) => {
pm2.disconnect();
pm2Connected = false;
setTimeout(resolve, 100);
});
// Set an invalid PM2_HOME to force connection failure
const originalPM2Home = process.env.PM2_HOME;
process.env.PM2_HOME = '/invalid/path/that/does/not/exist';
const result = await isUnraidApiRunning();
expect(result).toBe(false);
// Restore original PM2_HOME
if (originalPM2Home) {
process.env.PM2_HOME = originalPM2Home;
} else {
delete process.env.PM2_HOME;
}
}, 15000); // 15 second timeout to allow for the Promise.race timeout
});
});

View File

@@ -0,0 +1,54 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
describe('isUnraidApiRunning (nodemon pid detection)', () => {
let tempDir: string;
let pidPath: string;
beforeAll(() => {
tempDir = mkdtempSync(join(tmpdir(), 'unraid-api-'));
pidPath = join(tempDir, 'nodemon.pid');
});
afterAll(() => {
rmSync(tempDir, { recursive: true, force: true });
});
afterEach(() => {
vi.resetModules();
});
async function loadIsRunning() {
vi.doMock('@app/environment.js', async () => {
const actual =
await vi.importActual<typeof import('@app/environment.js')>('@app/environment.js');
return { ...actual, NODEMON_PID_PATH: pidPath };
});
const module = await import('@app/core/utils/process/unraid-api-running.js');
return module.isUnraidApiRunning;
}
it('returns false when pid file is missing', async () => {
const isUnraidApiRunning = await loadIsRunning();
expect(await isUnraidApiRunning()).toBe(false);
});
it('returns true when a live pid is recorded', async () => {
writeFileSync(pidPath, `${process.pid}`);
const isUnraidApiRunning = await loadIsRunning();
expect(await isUnraidApiRunning()).toBe(true);
});
it('returns false when pid file is invalid', async () => {
writeFileSync(pidPath, 'not-a-number');
const isUnraidApiRunning = await loadIsRunning();
expect(await isUnraidApiRunning()).toBe(false);
});
});

View File

@@ -0,0 +1,29 @@
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
describe('nodemon path configuration', () => {
const originalUnraidApiCwd = process.env.UNRAID_API_CWD;
beforeEach(() => {
vi.resetModules();
delete process.env.UNRAID_API_CWD;
});
afterEach(() => {
if (originalUnraidApiCwd === undefined) {
delete process.env.UNRAID_API_CWD;
} else {
process.env.UNRAID_API_CWD = originalUnraidApiCwd;
}
});
it('anchors nodemon paths to the package root by default', async () => {
const environment = await import('@app/environment.js');
const { UNRAID_API_ROOT, NODEMON_CONFIG_PATH, NODEMON_PATH, UNRAID_API_CWD } = environment;
expect(UNRAID_API_CWD).toBe(UNRAID_API_ROOT);
expect(NODEMON_CONFIG_PATH).toBe(join(UNRAID_API_ROOT, 'nodemon.json'));
expect(NODEMON_PATH).toBe(join(UNRAID_API_ROOT, 'node_modules', 'nodemon', 'bin', 'nodemon.js'));
});
});

View File

@@ -51,6 +51,8 @@ vi.mock('@app/store/index.js', () => ({
}));
vi.mock('@app/environment.js', () => ({
ENVIRONMENT: 'development',
SUPPRESS_LOGS: false,
LOG_LEVEL: 'INFO',
environment: {
IS_MAIN_PROCESS: true,
},

View File

@@ -1,12 +1,25 @@
import '@app/dotenv.js';
import { Logger } from '@nestjs/common';
import { appendFileSync } from 'node:fs';
import { CommandFactory } from 'nest-commander';
import { LOG_LEVEL, SUPPRESS_LOGS } from '@app/environment.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
const BOOT_LOG_PATH = '/var/log/unraid-api/boot.log';
const logToBootFile = (message: string): void => {
const timestamp = new Date().toISOString();
const line = `[${timestamp}] [cli] ${message}\n`;
try {
appendFileSync(BOOT_LOG_PATH, line);
} catch {
// Silently fail if we can't write to boot log
}
};
const getUnraidApiLocation = async () => {
const { execa } = await import('execa');
try {
@@ -26,6 +39,8 @@ const getLogger = () => {
const logger = getLogger();
try {
logToBootFile(`CLI started with args: ${process.argv.slice(2).join(' ')}`);
await import('json-bigint-patch');
const { CliModule } = await import('@app/unraid-api/cli/cli.module.js');
@@ -38,10 +53,17 @@ try {
nativeShell: { executablePath: await getUnraidApiLocation() },
},
});
logToBootFile('CLI completed successfully');
process.exit(0);
} catch (error) {
// Always log errors to boot file for boot-time debugging
const errorMessage = error instanceof Error ? error.stack || error.message : String(error);
logToBootFile(`CLI ERROR: ${errorMessage}`);
if (logger) {
logger.error('ERROR:', error);
} else {
console.error('ERROR:', error);
}
process.exit(1);
}

View File

@@ -0,0 +1,12 @@
import { existsSync } from 'node:fs';
/**
* Local filesystem and env checks stay synchronous so we can branch at module load.
* @returns True if the Connect Unraid plugin is installed, false otherwise.
*/
export const isConnectPluginInstalled = () => {
if (process.env.SKIP_CONNECT_PLUGIN_CHECK === 'true') {
return true;
}
return existsSync('/boot/config/plugins/dynamix.unraid.net.plg');
};

View File

@@ -1,7 +1,7 @@
import pino from 'pino';
import pretty from 'pino-pretty';
import { API_VERSION, LOG_LEVEL, LOG_TYPE, SUPPRESS_LOGS } from '@app/environment.js';
import { API_VERSION, LOG_LEVEL, LOG_TYPE, PATHS_LOGS_FILE, SUPPRESS_LOGS } from '@app/environment.js';
export const levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const;
@@ -16,8 +16,10 @@ const nullDestination = pino.destination({
});
export const logDestination =
process.env.SUPPRESS_LOGS === 'true' ? nullDestination : pino.destination();
// Since PM2 captures stdout and writes to the log file, we should not colorize stdout
process.env.SUPPRESS_LOGS === 'true'
? nullDestination
: pino.destination({ dest: PATHS_LOGS_FILE, mkdir: true });
// Since process output is piped directly to the log file, we should not colorize stdout
// to avoid ANSI escape codes in the log file
const stream = SUPPRESS_LOGS
? nullDestination
@@ -25,7 +27,7 @@ const stream = SUPPRESS_LOGS
? pretty({
singleLine: true,
hideObject: false,
colorize: false, // No colors since PM2 writes stdout to file
colorize: false, // No colors since logs are written directly to file
colorizeObjects: false,
levelFirst: false,
ignore: 'hostname,pid',

View File

@@ -7,8 +7,6 @@ import { PubSub } from 'graphql-subscriptions';
const eventEmitter = new EventEmitter();
eventEmitter.setMaxListeners(30);
export { GRAPHQL_PUBSUB_CHANNEL as PUBSUB_CHANNEL };
export const pubsub = new PubSub({ eventEmitter });
/**

View File

@@ -0,0 +1,66 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { isSafeModeEnabled } from '@app/core/utils/safe-mode.js';
import { store } from '@app/store/index.js';
import * as stateFileLoader from '@app/store/services/state-file-loader.js';
describe('isSafeModeEnabled', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('returns the safe mode flag already present in the store', () => {
const baseState = store.getState();
vi.spyOn(store, 'getState').mockReturnValue({
...baseState,
emhttp: {
...baseState.emhttp,
var: {
...(baseState.emhttp?.var ?? {}),
safeMode: true,
},
},
});
const loaderSpy = vi.spyOn(stateFileLoader, 'loadStateFileSync');
expect(isSafeModeEnabled()).toBe(true);
expect(loaderSpy).not.toHaveBeenCalled();
});
it('falls back to the synchronous loader when store state is missing', () => {
const baseState = store.getState();
vi.spyOn(store, 'getState').mockReturnValue({
...baseState,
emhttp: {
...baseState.emhttp,
var: {
...(baseState.emhttp?.var ?? {}),
safeMode: undefined as unknown as boolean,
} as typeof baseState.emhttp.var,
} as typeof baseState.emhttp,
} as typeof baseState);
vi.spyOn(stateFileLoader, 'loadStateFileSync').mockReturnValue({
...(baseState.emhttp?.var ?? {}),
safeMode: true,
} as any);
expect(isSafeModeEnabled()).toBe(true);
});
it('defaults to false when loader cannot provide state', () => {
const baseState = store.getState();
vi.spyOn(store, 'getState').mockReturnValue({
...baseState,
emhttp: {
...baseState.emhttp,
var: {
...(baseState.emhttp?.var ?? {}),
safeMode: undefined as unknown as boolean,
} as typeof baseState.emhttp.var,
} as typeof baseState.emhttp,
} as typeof baseState);
vi.spyOn(stateFileLoader, 'loadStateFileSync').mockReturnValue(null);
expect(isSafeModeEnabled()).toBe(false);
});
});

View File

@@ -1,40 +0,0 @@
export const isUnraidApiRunning = async (): Promise<boolean | undefined> => {
const { PM2_HOME } = await import('@app/environment.js');
// Set PM2_HOME if not already set
if (!process.env.PM2_HOME) {
process.env.PM2_HOME = PM2_HOME;
}
const pm2Module = await import('pm2');
const pm2 = pm2Module.default || pm2Module;
const pm2Promise = new Promise<boolean>((resolve) => {
pm2.connect(function (err) {
if (err) {
// Don't reject here, resolve with false since we can't connect to PM2
resolve(false);
return;
}
// Now try to describe unraid-api specifically
pm2.describe('unraid-api', function (err, processDescription) {
if (err || processDescription.length === 0) {
// Service not found or error occurred
resolve(false);
} else {
const isOnline = processDescription?.[0]?.pm2_env?.status === 'online';
resolve(isOnline);
}
pm2.disconnect();
});
});
});
const timeoutPromise = new Promise<boolean>((resolve) => {
setTimeout(() => resolve(false), 10000); // 10 second timeout
});
return Promise.race([pm2Promise, timeoutPromise]);
};

View File

@@ -0,0 +1,23 @@
import { readFile } from 'node:fs/promises';
import { fileExists } from '@app/core/utils/files/file-exists.js';
import { NODEMON_PID_PATH } from '@app/environment.js';
export const isUnraidApiRunning = async (): Promise<boolean> => {
try {
if (!(await fileExists(NODEMON_PID_PATH))) {
return false;
}
const pidText = (await readFile(NODEMON_PID_PATH, 'utf-8')).trim();
const pid = Number.parseInt(pidText, 10);
if (Number.isNaN(pid)) {
return false;
}
process.kill(pid, 0);
return true;
} catch {
return false;
}
};

View File

@@ -0,0 +1,17 @@
import { store } from '@app/store/index.js';
import { loadStateFileSync } from '@app/store/services/state-file-loader.js';
import { StateFileKey } from '@app/store/types.js';
export const isSafeModeEnabled = (): boolean => {
const safeModeFromStore = store.getState().emhttp?.var?.safeMode;
if (typeof safeModeFromStore === 'boolean') {
return safeModeFromStore;
}
const varState = loadStateFileSync(StateFileKey.var);
if (varState) {
return Boolean(varState.safeMode);
}
return false;
};

View File

@@ -2,7 +2,7 @@
// Non-function exports from this module are loaded into the NestJS Config at runtime.
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { PackageJson, SetRequired } from 'type-fest';
@@ -65,6 +65,7 @@ export const getPackageJsonDependencies = (): string[] | undefined => {
};
export const API_VERSION = process.env.npm_package_version ?? getPackageJson().version;
export const UNRAID_API_ROOT = dirname(getPackageJsonPath());
/** Controls how the app is built/run (i.e. in terms of optimization) */
export const NODE_ENV =
@@ -91,6 +92,7 @@ export const LOG_LEVEL = process.env.LOG_LEVEL
: process.env.ENVIRONMENT === 'production'
? 'INFO'
: 'DEBUG';
export const LOG_CASBIN = process.env.LOG_CASBIN === 'true';
export const SUPPRESS_LOGS = process.env.SUPPRESS_LOGS === 'true';
export const MOTHERSHIP_GRAPHQL_LINK = process.env.MOTHERSHIP_GRAPHQL_LINK
? process.env.MOTHERSHIP_GRAPHQL_LINK
@@ -98,12 +100,18 @@ export const MOTHERSHIP_GRAPHQL_LINK = process.env.MOTHERSHIP_GRAPHQL_LINK
? 'https://staging.mothership.unraid.net/ws'
: 'https://mothership.unraid.net/ws';
export const PM2_HOME = process.env.PM2_HOME ?? '/var/log/.pm2';
export const PM2_PATH = join(import.meta.dirname, '../../', 'node_modules', 'pm2', 'bin', 'pm2');
export const ECOSYSTEM_PATH = join(import.meta.dirname, '../../', 'ecosystem.config.json');
export const PATHS_LOGS_DIR =
process.env.PATHS_LOGS_DIR ?? process.env.LOGS_DIR ?? '/var/log/unraid-api';
export const PATHS_LOGS_FILE = process.env.PATHS_LOGS_FILE ?? '/var/log/graphql-api.log';
export const PATHS_NODEMON_LOG_FILE =
process.env.PATHS_NODEMON_LOG_FILE ?? join(PATHS_LOGS_DIR, 'nodemon.log');
export const NODEMON_PATH = join(UNRAID_API_ROOT, 'node_modules', 'nodemon', 'bin', 'nodemon.js');
export const NODEMON_CONFIG_PATH = join(UNRAID_API_ROOT, 'nodemon.json');
export const NODEMON_PID_PATH = process.env.NODEMON_PID_PATH ?? '/var/run/unraid-api/nodemon.pid';
export const NODEMON_LOCK_PATH = process.env.NODEMON_LOCK_PATH ?? '/var/run/unraid-api/nodemon.lock';
export const UNRAID_API_CWD = process.env.UNRAID_API_CWD ?? UNRAID_API_ROOT;
export const UNRAID_API_SERVER_ENTRYPOINT = join(UNRAID_API_CWD, 'dist', 'main.js');
export const PATHS_CONFIG_MODULES =
process.env.PATHS_CONFIG_MODULES ?? '/boot/config/plugins/dynamix.my.servers/configs';

1
api/src/i18n/ar.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/bn.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/ca.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/cs.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/da.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/de.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/en.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/es.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/fr.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/hi.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/hr.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/hu.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/it.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/ja.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/ko.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/lv.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/nl.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/no.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/pl.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/pt.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/ro.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/ru.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/sv.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/uk.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/zh.json Normal file
View File

@@ -0,0 +1 @@
{}

View File

@@ -4,7 +4,7 @@ import '@app/dotenv.js';
import { type NestFastifyApplication } from '@nestjs/platform-fastify';
import { unlinkSync } from 'fs';
import { mkdir } from 'fs/promises';
import { mkdir, readFile } from 'fs/promises';
import http from 'http';
import https from 'https';
@@ -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';
@@ -76,7 +74,7 @@ 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();
@@ -84,9 +82,6 @@ export const viteNodeApp = async () => {
// 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();

View File

@@ -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.
@@ -16,11 +13,8 @@ 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',
@@ -28,21 +22,40 @@ async function loadConfigFile<ConfigType>(path: string): Promise<RecursivePartia
: {};
}
/**
* Load the dynamix.cfg into the store.
*
* Note: If the file doesn't exist this will fallback to default values.
*/
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 };
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,
}),
{}
);
},
});
/**
* Loads dynamix config from disk with TTL caching.
*
* @param configPaths - Array of config file paths to load and merge
* @returns Merged config object from all valid paths
*/
export const loadDynamixConfigFromDiskSync = (
configPaths: readonly (string | undefined | null)[]
): RecursivePartial<DynamixConfig> => {
return memoizedConfigLoader.get(configPaths);
};

View File

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

View File

@@ -1,9 +1,10 @@
import { isAnyOf } from '@reduxjs/toolkit';
import { GRAPHQL_PUBSUB_CHANNEL } from '@unraid/shared/pubsub/graphql.pubsub.js';
import { isEqual } from 'lodash-es';
import { logger } from '@app/core/log.js';
import { getArrayData } from '@app/core/modules/array/get-array-data.js';
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { pubsub } from '@app/core/pubsub.js';
import { startAppListening } from '@app/store/listeners/listener-middleware.js';
import { loadSingleStateFile } from '@app/store/modules/emhttp.js';
import { StateFileKey } from '@app/store/types.js';
@@ -20,14 +21,14 @@ export const enableArrayEventListener = () =>
await delay(5_000);
const array = getArrayData(getState);
if (!isEqual(oldArrayData, array)) {
pubsub.publish(PUBSUB_CHANNEL.ARRAY, { array });
pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.ARRAY, { array });
logger.debug({ event: array }, 'Array was updated, publishing event');
}
subscribe();
} else if (action.meta.arg === StateFileKey.var) {
if (!isEqual(getOriginalState().emhttp.var?.name, getState().emhttp.var?.name)) {
await pubsub.publish(PUBSUB_CHANNEL.INFO, {
await pubsub.publish(GRAPHQL_PUBSUB_CHANNEL.INFO, {
info: {
os: {
hostname: getState().emhttp.var?.name,

View File

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

View File

@@ -163,6 +163,18 @@ export const loadStateFiles = createAsyncThunk<
return state;
});
const stateFieldKeyMap: Record<StateFileKey, keyof SliceState> = {
[StateFileKey.var]: 'var',
[StateFileKey.devs]: 'devices',
[StateFileKey.network]: 'networks',
[StateFileKey.nginx]: 'nginx',
[StateFileKey.shares]: 'shares',
[StateFileKey.disks]: 'disks',
[StateFileKey.users]: 'users',
[StateFileKey.sec]: 'smbShares',
[StateFileKey.sec_nfs]: 'nfsShares',
};
export const emhttp = createSlice({
name: 'emhttp',
initialState,
@@ -175,7 +187,8 @@ export const emhttp = createSlice({
}>
) {
const { field } = action.payload;
return Object.assign(state, { [field]: action.payload.state });
const targetField = stateFieldKeyMap[field] ?? (field as keyof SliceState);
return Object.assign(state, { [targetField]: action.payload.state });
},
},
extraReducers(builder) {

View File

@@ -0,0 +1,81 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { store } from '@app/store/index.js';
import { loadStateFileSync } from '@app/store/services/state-file-loader.js';
import { StateFileKey } from '@app/store/types.js';
const VAR_FIXTURE = readFileSync(new URL('../../../../dev/states/var.ini', import.meta.url), 'utf-8');
const writeVarFixture = (dir: string, safeMode: 'yes' | 'no') => {
const content = VAR_FIXTURE.replace(/safeMode="(yes|no)"/, `safeMode="${safeMode}"`);
writeFileSync(join(dir, `${StateFileKey.var}.ini`), content);
};
describe('loadStateFileSync', () => {
let tempDir: string;
let baseState: ReturnType<typeof store.getState>;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'state-file-'));
baseState = store.getState();
});
afterEach(() => {
vi.restoreAllMocks();
rmSync(tempDir, { recursive: true, force: true });
});
it('loads var.ini, updates the store, and returns the parsed state', () => {
writeVarFixture(tempDir, 'yes');
vi.spyOn(store, 'getState').mockReturnValue({
...baseState,
paths: {
...baseState.paths,
states: tempDir,
},
});
const dispatchSpy = vi.spyOn(store, 'dispatch').mockImplementation((action) => action as any);
const result = loadStateFileSync(StateFileKey.var);
expect(result?.safeMode).toBe(true);
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'emhttp/updateEmhttpState',
payload: {
field: StateFileKey.var,
state: expect.objectContaining({ safeMode: true }),
},
})
);
});
it('returns null when the states path is missing', () => {
vi.spyOn(store, 'getState').mockReturnValue({
...baseState,
paths: undefined,
} as any);
const dispatchSpy = vi.spyOn(store, 'dispatch');
expect(loadStateFileSync(StateFileKey.var)).toBeNull();
expect(dispatchSpy).not.toHaveBeenCalled();
});
it('returns null when the requested state file cannot be found', () => {
vi.spyOn(store, 'getState').mockReturnValue({
...baseState,
paths: {
...baseState.paths,
states: tempDir,
},
});
const dispatchSpy = vi.spyOn(store, 'dispatch');
expect(loadStateFileSync(StateFileKey.var)).toBeNull();
expect(dispatchSpy).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,81 @@
import { join } from 'node:path';
import type { SliceState } from '@app/store/modules/emhttp.js';
import type { StateFileToIniParserMap } from '@app/store/types.js';
import { parseConfig } from '@app/core/utils/misc/parse-config.js';
import { store } from '@app/store/index.js';
import { updateEmhttpState } from '@app/store/modules/emhttp.js';
import { parse as parseDevices } from '@app/store/state-parsers/devices.js';
import { parse as parseNetwork } from '@app/store/state-parsers/network.js';
import { parse as parseNfs } from '@app/store/state-parsers/nfs.js';
import { parse as parseNginx } from '@app/store/state-parsers/nginx.js';
import { parse as parseShares } from '@app/store/state-parsers/shares.js';
import { parse as parseSlots } from '@app/store/state-parsers/slots.js';
import { parse as parseSmb } from '@app/store/state-parsers/smb.js';
import { parse as parseUsers } from '@app/store/state-parsers/users.js';
import { parse as parseVar } from '@app/store/state-parsers/var.js';
import { StateFileKey } from '@app/store/types.js';
type ParserReturnMap = {
[StateFileKey.var]: ReturnType<typeof parseVar>;
[StateFileKey.devs]: ReturnType<typeof parseDevices>;
[StateFileKey.network]: ReturnType<typeof parseNetwork>;
[StateFileKey.nginx]: ReturnType<typeof parseNginx>;
[StateFileKey.shares]: ReturnType<typeof parseShares>;
[StateFileKey.disks]: ReturnType<typeof parseSlots>;
[StateFileKey.users]: ReturnType<typeof parseUsers>;
[StateFileKey.sec]: ReturnType<typeof parseSmb>;
[StateFileKey.sec_nfs]: ReturnType<typeof parseNfs>;
};
const PARSER_MAP: { [K in StateFileKey]: StateFileToIniParserMap[K] } = {
[StateFileKey.var]: parseVar,
[StateFileKey.devs]: parseDevices,
[StateFileKey.network]: parseNetwork,
[StateFileKey.nginx]: parseNginx,
[StateFileKey.shares]: parseShares,
[StateFileKey.disks]: parseSlots,
[StateFileKey.users]: parseUsers,
[StateFileKey.sec]: parseSmb,
[StateFileKey.sec_nfs]: parseNfs,
};
/**
* Synchronously loads an emhttp state file, updates the Redux store slice, and returns the parsed state.
*
* Designed for bootstrap contexts (CLI, plugin loading, etc.) where dispatching the async thunks is
* impractical but we still need authoritative emhttp state from disk.
*/
export const loadStateFileSync = <K extends StateFileKey>(
stateFileKey: K
): ParserReturnMap[K] | null => {
const state = store.getState();
const statesDirectory = state.paths?.states;
if (!statesDirectory) {
return null;
}
const filePath = join(statesDirectory, `${stateFileKey}.ini`);
try {
const parser = PARSER_MAP[stateFileKey] as StateFileToIniParserMap[K];
const rawConfig = parseConfig<Record<string, unknown>>({
filePath,
type: 'ini',
});
const config = rawConfig as Parameters<StateFileToIniParserMap[K]>[0];
const parsed = (parser as (input: any) => ParserReturnMap[K])(config);
store.dispatch(
updateEmhttpState({
field: stateFileKey,
state: parsed as Partial<SliceState[keyof SliceState]>,
})
);
return parsed;
} catch (error) {
return null;
}
};

View File

@@ -1,17 +0,0 @@
import { watch } from 'chokidar';
import { loadDynamixConfigFile } from '@app/store/actions/load-dynamix-config-file.js';
import { getters, store } from '@app/store/index.js';
export const setupDynamixConfigWatch = () => {
const configPath = getters.paths()?.['dynamix-config'];
// Update store when cfg changes
watch(configPath, {
persistent: true,
ignoreInitial: true,
}).on('change', async () => {
// Load updated dynamix config file into store
await store.dispatch(loadDynamixConfigFile());
});
};

40
api/src/types/jsonforms-i18n.d.ts vendored Normal file
View File

@@ -0,0 +1,40 @@
import '@jsonforms/core/lib/models/jsonSchema4';
import '@jsonforms/core/lib/models/jsonSchema7';
import '@jsonforms/core/src/models/jsonSchema4';
import '@jsonforms/core/src/models/jsonSchema7';
declare module '@jsonforms/core/lib/models/jsonSchema4' {
interface JsonSchema4 {
i18n?: string;
}
}
declare module '@jsonforms/core/lib/models/jsonSchema7' {
interface JsonSchema7 {
i18n?: string;
}
}
declare module '@jsonforms/core/src/models/jsonSchema4' {
interface JsonSchema4 {
i18n?: string;
}
}
declare module '@jsonforms/core/src/models/jsonSchema7' {
interface JsonSchema7 {
i18n?: string;
}
}
declare module '@jsonforms/core/lib/models/jsonSchema4.js' {
interface JsonSchema4 {
i18n?: string;
}
}
declare module '@jsonforms/core/lib/models/jsonSchema7.js' {
interface JsonSchema7 {
i18n?: string;
}
}

View File

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

View File

@@ -1,4 +1,5 @@
import { CacheModule } from '@nestjs/cache-manager';
import { ConfigModule } from '@nestjs/config';
import { Test } from '@nestjs/testing';
import { describe, expect, it } from 'vitest';
@@ -10,7 +11,11 @@ describe('Module Dependencies Integration', () => {
let module;
try {
module = await Test.createTestingModule({
imports: [CacheModule.register({ isGlobal: true }), RestModule],
imports: [
ConfigModule.forRoot({ ignoreEnvFile: true, isGlobal: true }),
CacheModule.register({ isGlobal: true }),
RestModule,
],
}).compile();
expect(module).toBeDefined();

View File

@@ -8,6 +8,7 @@ import { AuthService } from '@app/unraid-api/auth/auth.service.js';
import { CasbinModule } from '@app/unraid-api/auth/casbin/casbin.module.js';
import { CasbinService } from '@app/unraid-api/auth/casbin/casbin.service.js';
import { BASE_POLICY, CASBIN_MODEL } from '@app/unraid-api/auth/casbin/index.js';
import { resolveSubjectFromUser } from '@app/unraid-api/auth/casbin/resolve-subject.util.js';
import { CookieService, SESSION_COOKIE_CONFIG } from '@app/unraid-api/auth/cookie.service.js';
import { UserCookieStrategy } from '@app/unraid-api/auth/cookie.strategy.js';
import { ServerHeaderStrategy } from '@app/unraid-api/auth/header.strategy.js';
@@ -41,13 +42,7 @@ import { getRequest } from '@app/utils.js';
try {
const request = getRequest(ctx);
const roles = request?.user?.roles || [];
if (!Array.isArray(roles)) {
throw new UnauthorizedException('User roles must be an array');
}
return roles.join(',');
return resolveSubjectFromUser(request?.user);
} catch (error) {
logger.error('Failed to extract user context', error);
throw new UnauthorizedException('Failed to authenticate user');

View File

@@ -0,0 +1,133 @@
import { ExecutionContext, Type } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host.js';
import type { Enforcer } from 'casbin';
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
import { AuthZGuard, BatchApproval } from 'nest-authz';
import { beforeAll, describe, expect, it } from 'vitest';
import { CasbinService } from '@app/unraid-api/auth/casbin/casbin.service.js';
import { CASBIN_MODEL } from '@app/unraid-api/auth/casbin/model.js';
import { BASE_POLICY } from '@app/unraid-api/auth/casbin/policy.js';
import { resolveSubjectFromUser } from '@app/unraid-api/auth/casbin/resolve-subject.util.js';
import { DockerMutationsResolver } from '@app/unraid-api/graph/resolvers/docker/docker.mutations.resolver.js';
import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver.js';
import { VmMutationsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.mutations.resolver.js';
import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js';
import { getRequest } from '@app/utils.js';
type Handler = (...args: any[]) => unknown;
type TestUser = {
id?: string;
roles?: Role[];
};
type TestRequest = {
user?: TestUser;
};
function createExecutionContext(
handler: Handler,
classRef: Type<unknown> | null,
roles: Role[],
userId = 'api-key-viewer'
): ExecutionContext {
const request: TestRequest = {
user: {
id: userId,
roles: [...roles],
},
};
const graphqlContextHost = new ExecutionContextHost(
[undefined, undefined, { req: request }, undefined],
classRef,
handler
);
graphqlContextHost.setType('graphql');
return graphqlContextHost as unknown as ExecutionContext;
}
describe('AuthZGuard + Casbin policies', () => {
let guard: AuthZGuard;
let enforcer: Enforcer;
beforeAll(async () => {
const casbinService = new CasbinService();
enforcer = await casbinService.initializeEnforcer(CASBIN_MODEL, BASE_POLICY);
await enforcer.addGroupingPolicy('api-key-viewer', Role.VIEWER);
await enforcer.addGroupingPolicy('api-key-admin', Role.ADMIN);
guard = new AuthZGuard(new Reflector(), enforcer, {
enablePossession: false,
batchApproval: BatchApproval.ALL,
userFromContext: (ctx: ExecutionContext) => {
const request = getRequest(ctx) as TestRequest | undefined;
return resolveSubjectFromUser(request?.user);
},
});
});
it('denies viewer role from stopping docker containers', async () => {
const context = createExecutionContext(
DockerMutationsResolver.prototype.stop,
DockerMutationsResolver,
[Role.VIEWER],
'api-key-viewer'
);
await expect(guard.canActivate(context)).resolves.toBe(false);
});
it('allows admin role to stop docker containers', async () => {
const context = createExecutionContext(
DockerMutationsResolver.prototype.stop,
DockerMutationsResolver,
[Role.ADMIN],
'api-key-admin'
);
await expect(guard.canActivate(context)).resolves.toBe(true);
});
it('denies viewer role from stopping virtual machines', async () => {
const context = createExecutionContext(
VmMutationsResolver.prototype.stop,
VmMutationsResolver,
[Role.VIEWER],
'api-key-viewer'
);
await expect(guard.canActivate(context)).resolves.toBe(false);
});
it('allows viewer role to read docker data', async () => {
const context = createExecutionContext(
DockerResolver.prototype.containers,
DockerResolver,
[Role.VIEWER],
'api-key-viewer'
);
await expect(guard.canActivate(context)).resolves.toBe(true);
});
it('allows API key with explicit permission to access ME resource', async () => {
await enforcer.addPolicy('api-key-custom', Resource.ME, AuthAction.READ_ANY);
const context = createExecutionContext(
MeResolver.prototype.me,
MeResolver,
[],
'api-key-custom'
);
await expect(guard.canActivate(context)).resolves.toBe(true);
});
});

View File

@@ -2,7 +2,7 @@ import { Injectable, InternalServerErrorException, Logger, OnModuleInit } from '
import { Model as CasbinModel, Enforcer, newEnforcer, StringAdapter } from 'casbin';
import { LOG_LEVEL } from '@app/environment.js';
import { LOG_CASBIN, LOG_LEVEL } from '@app/environment.js';
@Injectable()
export class CasbinService {
@@ -20,9 +20,8 @@ export class CasbinService {
const casbinPolicy = new StringAdapter(policy);
try {
const enforcer = await newEnforcer(casbinModel, casbinPolicy);
if (LOG_LEVEL === 'TRACE') {
enforcer.enableLog(true);
}
// Casbin request logging is extremely verbose; keep it off unless explicitly enabled.
enforcer.enableLog(LOG_CASBIN && LOG_LEVEL === 'TRACE');
return enforcer;
} catch (error: unknown) {

View File

@@ -0,0 +1,43 @@
import { UnauthorizedException } from '@nestjs/common';
import { describe, expect, it } from 'vitest';
import { resolveSubjectFromUser } from '@app/unraid-api/auth/casbin/resolve-subject.util.js';
describe('resolveSubjectFromUser', () => {
it('returns trimmed user id when available', () => {
const subject = resolveSubjectFromUser({ id: ' user-123 ', roles: ['viewer'] });
expect(subject).toBe('user-123');
});
it('falls back to a single non-empty role', () => {
const subject = resolveSubjectFromUser({ roles: [' viewer '] });
expect(subject).toBe('viewer');
});
it('throws when role list is empty', () => {
expect(() => resolveSubjectFromUser({ roles: [] })).toThrow(UnauthorizedException);
});
it('throws when multiple roles are present', () => {
expect(() => resolveSubjectFromUser({ roles: ['viewer', 'admin'] })).toThrow(
UnauthorizedException
);
});
it('throws when roles is not an array', () => {
expect(() => resolveSubjectFromUser({ roles: 'viewer' as unknown })).toThrow(
UnauthorizedException
);
});
it('throws when role subject is blank', () => {
expect(() => resolveSubjectFromUser({ roles: [' '] })).toThrow(UnauthorizedException);
});
it('throws when user is missing', () => {
expect(() => resolveSubjectFromUser(undefined)).toThrow(UnauthorizedException);
});
});

View File

@@ -0,0 +1,46 @@
import { UnauthorizedException } from '@nestjs/common';
type CasbinUser = {
id?: unknown;
roles?: unknown;
};
/**
* Determine the Casbin subject for a request user.
*
* Prefers a non-empty `user.id`, otherwise falls back to a single non-empty role.
* Throws when the subject cannot be resolved.
*/
export function resolveSubjectFromUser(user: CasbinUser | undefined): string {
if (!user) {
throw new UnauthorizedException('Request user context missing');
}
const roles = user.roles ?? [];
if (!Array.isArray(roles)) {
throw new UnauthorizedException('User roles must be an array');
}
const userId = typeof user.id === 'string' ? user.id.trim() : '';
if (userId.length > 0) {
return userId;
}
if (roles.length === 1) {
const [role] = roles;
if (typeof role === 'string') {
const trimmedRole = role.trim();
if (trimmedRole.length > 0) {
return trimmedRole;
}
}
throw new UnauthorizedException('Role subject must be a non-empty string');
}
throw new UnauthorizedException('Unable to determine subject from user context');
}

View File

@@ -36,6 +36,7 @@ const mockPluginManagementService = {
addPlugin: vi.fn(),
addBundledPlugin: vi.fn(),
removePlugin: vi.fn(),
removePluginConfigOnly: vi.fn(),
removeBundledPlugin: vi.fn(),
plugins: [] as string[],
};
@@ -147,6 +148,7 @@ describe('Plugin Commands', () => {
'@unraid/plugin-example',
'@unraid/plugin-test'
);
expect(mockPluginManagementService.removePluginConfigOnly).not.toHaveBeenCalled();
expect(mockLogger.log).toHaveBeenCalledWith('Removed plugin @unraid/plugin-example');
expect(mockLogger.log).toHaveBeenCalledWith('Removed plugin @unraid/plugin-test');
expect(mockApiConfigPersistence.persist).toHaveBeenCalled();
@@ -178,9 +180,72 @@ describe('Plugin Commands', () => {
expect(mockPluginManagementService.removePlugin).toHaveBeenCalledWith(
'@unraid/plugin-example'
);
expect(mockPluginManagementService.removePluginConfigOnly).not.toHaveBeenCalled();
expect(mockApiConfigPersistence.persist).toHaveBeenCalled();
expect(mockRestartCommand.run).not.toHaveBeenCalled();
});
it('should bypass npm uninstall when bypass flag is provided', async () => {
mockInquirerService.prompt.mockResolvedValue({
plugins: ['@unraid/plugin-example'],
restart: true,
bypassNpm: true,
});
await command.run([], { restart: true, bypassNpm: true });
expect(mockPluginManagementService.removePluginConfigOnly).toHaveBeenCalledWith(
'@unraid/plugin-example'
);
expect(mockPluginManagementService.removePlugin).not.toHaveBeenCalled();
});
it('should preserve cli flags when prompt supplies plugins', async () => {
mockInquirerService.prompt.mockResolvedValue({
plugins: ['@unraid/plugin-example'],
});
await command.run([], { restart: false, bypassNpm: true });
expect(mockPluginManagementService.removePluginConfigOnly).toHaveBeenCalledWith(
'@unraid/plugin-example'
);
expect(mockPluginManagementService.removePlugin).not.toHaveBeenCalled();
expect(mockRestartCommand.run).not.toHaveBeenCalled();
});
it('should honor prompt restart value when cli flag not provided', async () => {
mockInquirerService.prompt.mockResolvedValue({
plugins: ['@unraid/plugin-example'],
restart: false,
});
await command.run([], {});
expect(mockPluginManagementService.removePlugin).toHaveBeenCalledWith(
'@unraid/plugin-example'
);
expect(mockRestartCommand.run).not.toHaveBeenCalled();
});
it('should respect passed params and skip inquirer', async () => {
await command.run(['@unraid/plugin-example'], { restart: true, bypassNpm: false });
expect(mockInquirerService.prompt).not.toHaveBeenCalled();
expect(mockPluginManagementService.removePlugin).toHaveBeenCalledWith(
'@unraid/plugin-example'
);
});
it('should bypass npm when flag provided with passed params', async () => {
await command.run(['@unraid/plugin-example'], { restart: true, bypassNpm: true });
expect(mockInquirerService.prompt).not.toHaveBeenCalled();
expect(mockPluginManagementService.removePluginConfigOnly).toHaveBeenCalledWith(
'@unraid/plugin-example'
);
expect(mockPluginManagementService.removePlugin).not.toHaveBeenCalled();
});
});
describe('ListPluginCommand', () => {

View File

@@ -26,10 +26,10 @@ const mockApiReportService = {
generateReport: vi.fn(),
};
// Mock PM2 check
// Mock process manager check
const mockIsUnraidApiRunning = vi.fn().mockResolvedValue(true);
vi.mock('@app/core/utils/pm2/unraid-api-running.js', () => ({
vi.mock('@app/core/utils/process/unraid-api-running.js', () => ({
isUnraidApiRunning: () => mockIsUnraidApiRunning(),
}));
@@ -50,7 +50,7 @@ describe('ReportCommand', () => {
// Clear mocks
vi.clearAllMocks();
// Reset PM2 mock to default
// Reset nodemon mock to default
mockIsUnraidApiRunning.mockResolvedValue(true);
});
@@ -150,7 +150,7 @@ describe('ReportCommand', () => {
// Reset mocks
vi.clearAllMocks();
// Test with API running but PM2 check returns true
// Test with API running but status check returns true
mockIsUnraidApiRunning.mockResolvedValue(true);
await reportCommand.report();
expect(mockApiReportService.generateReport).toHaveBeenCalledWith(true);

View File

@@ -4,7 +4,7 @@ import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js';
import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js';
import { LegacyConfigModule } from '@app/unraid-api/config/legacy-config.module.js';
import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js';
@@ -21,7 +21,7 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u
PluginCliModule.register(),
UnraidFileModifierModule,
],
providers: [LogService, PM2Service, ApiKeyService, DependencyService, ApiReportService],
providers: [LogService, NodemonService, ApiKeyService, DependencyService, ApiReportService],
exports: [ApiReportService, LogService, ApiKeyService],
})
export class CliServicesModule {}

View File

@@ -13,6 +13,7 @@ import { DeveloperCommand } from '@app/unraid-api/cli/developer/developer.comman
import { DeveloperQuestions } from '@app/unraid-api/cli/developer/developer.questions.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
import { LogsCommand } from '@app/unraid-api/cli/logs.command.js';
import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js';
import {
InstallPluginCommand,
ListPluginCommand,
@@ -20,7 +21,6 @@ import {
RemovePluginCommand,
} from '@app/unraid-api/cli/plugins/plugin.command.js';
import { RemovePluginQuestionSet } from '@app/unraid-api/cli/plugins/remove-plugin.questions.js';
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
import { ReportCommand } from '@app/unraid-api/cli/report.command.js';
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
import { SSOCommand } from '@app/unraid-api/cli/sso/sso.command.js';
@@ -64,7 +64,7 @@ const DEFAULT_PROVIDERS = [
DeveloperQuestions,
DeveloperToolsService,
LogService,
PM2Service,
NodemonService,
ApiKeyService,
DependencyService,
ApiReportService,

View File

@@ -559,6 +559,17 @@ export type CpuLoad = {
percentUser: Scalars['Float']['output'];
};
export type CpuPackages = Node & {
__typename?: 'CpuPackages';
id: Scalars['PrefixedID']['output'];
/** Power draw per package (W) */
power: Array<Scalars['Float']['output']>;
/** Temperature per package (°C) */
temp: Array<Scalars['Float']['output']>;
/** Total CPU package power draw (W) */
totalPower: Scalars['Float']['output'];
};
export type CpuUtilization = Node & {
__typename?: 'CpuUtilization';
/** CPU load for each core */
@@ -869,6 +880,7 @@ export type InfoCpu = Node & {
manufacturer?: Maybe<Scalars['String']['output']>;
/** CPU model */
model?: Maybe<Scalars['String']['output']>;
packages: CpuPackages;
/** Number of physical processors */
processors?: Maybe<Scalars['Int']['output']>;
/** CPU revision */
@@ -885,6 +897,8 @@ export type InfoCpu = Node & {
stepping?: Maybe<Scalars['Int']['output']>;
/** Number of CPU threads */
threads?: Maybe<Scalars['Int']['output']>;
/** Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]] */
topology: Array<Array<Array<Scalars['Int']['output']>>>;
/** CPU vendor */
vendor?: Maybe<Scalars['String']['output']>;
/** CPU voltage */
@@ -1531,14 +1545,14 @@ export type PackageVersions = {
nginx?: Maybe<Scalars['String']['output']>;
/** Node.js version */
node?: Maybe<Scalars['String']['output']>;
/** nodemon version */
nodemon?: Maybe<Scalars['String']['output']>;
/** npm version */
npm?: Maybe<Scalars['String']['output']>;
/** OpenSSL version */
openssl?: Maybe<Scalars['String']['output']>;
/** PHP version */
php?: Maybe<Scalars['String']['output']>;
/** pm2 version */
pm2?: Maybe<Scalars['String']['output']>;
};
export type ParityCheck = {
@@ -2053,6 +2067,7 @@ export type Subscription = {
parityHistorySubscription: ParityCheck;
serversSubscription: Server;
systemMetricsCpu: CpuUtilization;
systemMetricsCpuTelemetry: CpuPackages;
systemMetricsMemory: MemoryUtilization;
upsUpdates: UpsDevice;
};

View File

@@ -1,6 +1,6 @@
import { Command, CommandRunner, Option } from 'nest-commander';
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js';
interface LogsOptions {
lines: number;
@@ -8,7 +8,7 @@ interface LogsOptions {
@Command({ name: 'logs', description: 'View logs' })
export class LogsCommand extends CommandRunner {
constructor(private readonly pm2: PM2Service) {
constructor(private readonly nodemon: NodemonService) {
super();
}
@@ -20,13 +20,6 @@ export class LogsCommand extends CommandRunner {
async run(_: string[], options?: LogsOptions): Promise<void> {
const lines = options?.lines ?? 100;
await this.pm2.run(
{ tag: 'PM2 Logs', stdio: 'inherit' },
'logs',
'unraid-api',
'--lines',
lines.toString(),
'--raw'
);
await this.nodemon.logs(lines);
}
}

View File

@@ -0,0 +1,142 @@
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { LogService } from '@app/unraid-api/cli/log.service.js';
const logger = {
clear: vi.fn(),
shouldLog: vi.fn(() => true),
table: vi.fn(),
trace: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
log: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
always: vi.fn(),
} as unknown as LogService;
describe('NodemonService (real nodemon)', () => {
const tmpRoot = join(tmpdir(), 'nodemon-service-');
let workdir: string;
let scriptPath: string;
let configPath: string;
let appLogPath: string;
let nodemonLogPath: string;
let pidPath: string;
const nodemonPath = join(process.cwd(), 'node_modules', 'nodemon', 'bin', 'nodemon.js');
beforeAll(async () => {
workdir = await mkdtemp(tmpRoot);
scriptPath = join(workdir, 'app.js');
configPath = join(workdir, 'nodemon.json');
appLogPath = join(workdir, 'app.log');
nodemonLogPath = join(workdir, 'nodemon.log');
pidPath = join(workdir, 'nodemon.pid');
await writeFile(
scriptPath,
[
"const { appendFileSync } = require('node:fs');",
"const appLog = process.env.PATHS_LOGS_FILE || './app.log';",
"const nodemonLog = process.env.PATHS_NODEMON_LOG_FILE || './nodemon.log';",
"appendFileSync(appLog, 'app-log-entry\\n');",
"appendFileSync(nodemonLog, 'nodemon-log-entry\\n');",
"console.log('nodemon-integration-start');",
'setInterval(() => {}, 1000);',
].join('\n')
);
await writeFile(
configPath,
JSON.stringify(
{
watch: ['app.js'],
exec: 'node ./app.js',
signal: 'SIGTERM',
ext: 'js',
},
null,
2
)
);
});
afterAll(async () => {
await rm(workdir, { recursive: true, force: true });
});
it('starts and stops real nodemon and writes logs', async () => {
vi.resetModules();
vi.doMock('@app/environment.js', () => ({
LOG_LEVEL: 'INFO',
LOG_TYPE: 'pretty',
SUPPRESS_LOGS: false,
API_VERSION: 'test-version',
NODEMON_CONFIG_PATH: configPath,
NODEMON_LOCK_PATH: join(workdir, 'nodemon.lock'),
NODEMON_PATH: nodemonPath,
NODEMON_PID_PATH: pidPath,
PATHS_LOGS_DIR: workdir,
PATHS_LOGS_FILE: appLogPath,
PATHS_NODEMON_LOG_FILE: nodemonLogPath,
UNRAID_API_CWD: workdir,
UNRAID_API_SERVER_ENTRYPOINT: join(workdir, 'app.js'),
}));
const { NodemonService } = await import('./nodemon.service.js');
const service = new NodemonService(logger);
await service.start();
const pidText = (await readFile(pidPath, 'utf-8')).trim();
const pid = Number.parseInt(pidText, 10);
expect(Number.isInteger(pid) && pid > 0).toBe(true);
const nodemonLogStats = await stat(nodemonLogPath);
expect(nodemonLogStats.isFile()).toBe(true);
await waitForLogEntry(nodemonLogPath, 'Starting nodemon');
await waitForLogEntry(appLogPath, 'app-log-entry');
await service.stop();
await waitForExit(pid);
await expect(stat(pidPath)).rejects.toThrow();
}, 20_000);
});
async function waitForLogEntry(path: string, needle: string, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
while (true) {
try {
const contents = await readFile(path, 'utf-8');
if (contents.includes(needle)) return contents;
} catch {
// ignore until timeout
}
if (Date.now() > deadline) {
throw new Error(`Log entry "${needle}" not found in ${path} within ${timeoutMs}ms`);
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
async function waitForExit(pid: number, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
while (true) {
try {
process.kill(pid, 0);
} catch {
return;
}
if (Date.now() > deadline) {
throw new Error(`Process ${pid} did not exit within ${timeoutMs}ms`);
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}

View File

@@ -0,0 +1,569 @@
import { spawn } from 'node:child_process';
import { createWriteStream, openSync } from 'node:fs';
import * as fs from 'node:fs/promises';
import { execa } from 'execa';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fileExists, fileExistsSync } from '@app/core/utils/files/file-exists.js';
import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js';
const createLogStreamMock = (fd = 42, autoOpen = true) => {
const listeners: Record<string, Array<(...args: any[]) => void>> = {};
const stream: any = {
fd,
close: vi.fn(),
destroy: vi.fn(),
write: vi.fn(),
once: vi.fn(),
off: vi.fn(),
};
stream.once.mockImplementation((event: string, cb: (...args: any[]) => void) => {
listeners[event] = listeners[event] ?? [];
listeners[event].push(cb);
if (event === 'open' && autoOpen) cb();
return stream;
});
stream.off.mockImplementation((event: string, cb: (...args: any[]) => void) => {
listeners[event] = (listeners[event] ?? []).filter((fn) => fn !== cb);
return stream;
});
stream.emit = (event: string, ...args: any[]) => {
(listeners[event] ?? []).forEach((fn) => fn(...args));
};
return stream as ReturnType<typeof createWriteStream> & {
emit: (event: string, ...args: any[]) => void;
};
};
const createSpawnMock = (pid?: number) => {
const unref = vi.fn();
return {
pid,
unref,
} as unknown as ReturnType<typeof spawn>;
};
vi.mock('node:child_process', () => ({
spawn: vi.fn(),
}));
vi.mock('node:fs', () => ({
createWriteStream: vi.fn(),
openSync: vi.fn().mockReturnValue(42),
writeSync: vi.fn(),
}));
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof fs>();
return {
...actual,
mkdir: vi.fn(),
writeFile: vi.fn(),
rm: vi.fn(),
readFile: vi.fn(),
appendFile: vi.fn(),
};
});
vi.mock('execa', () => ({ execa: vi.fn() }));
vi.mock('proper-lockfile', () => ({
lock: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(undefined)),
}));
vi.mock('@app/core/utils/files/file-exists.js', () => ({
fileExists: vi.fn().mockResolvedValue(false),
fileExistsSync: vi.fn().mockReturnValue(true),
}));
vi.mock('@app/environment.js', () => ({
LOG_LEVEL: 'INFO',
SUPPRESS_LOGS: false,
NODEMON_CONFIG_PATH: '/etc/unraid-api/nodemon.json',
NODEMON_LOCK_PATH: '/var/run/unraid-api/nodemon.lock',
NODEMON_PATH: '/usr/bin/nodemon',
NODEMON_PID_PATH: '/var/run/unraid-api/nodemon.pid',
PATHS_LOGS_DIR: '/var/log/unraid-api',
PATHS_LOGS_FILE: '/var/log/graphql-api.log',
PATHS_NODEMON_LOG_FILE: '/var/log/unraid-api/nodemon.log',
UNRAID_API_CWD: '/usr/local/unraid-api',
UNRAID_API_SERVER_ENTRYPOINT: '/usr/local/unraid-api/dist/main.js',
}));
describe('NodemonService', () => {
const logger = {
trace: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
log: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
} as unknown as NodemonService['logger'];
const mockMkdir = vi.mocked(fs.mkdir);
const mockWriteFile = vi.mocked(fs.writeFile);
const mockRm = vi.mocked(fs.rm);
const killSpy = vi.spyOn(process, 'kill');
const stopPm2Spy = vi.spyOn(
NodemonService.prototype as unknown as { stopPm2IfRunning: () => Promise<void> },
'stopPm2IfRunning'
);
const findMatchingSpy = vi.spyOn(
NodemonService.prototype as unknown as { findMatchingNodemonPids: () => Promise<number[]> },
'findMatchingNodemonPids'
);
const findDirectMainSpy = vi.spyOn(
NodemonService.prototype as unknown as { findDirectMainPids: () => Promise<number[]> },
'findDirectMainPids'
);
const terminateSpy = vi.spyOn(
NodemonService.prototype as unknown as { terminatePids: (pids: number[]) => Promise<void> },
'terminatePids'
);
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(createWriteStream).mockImplementation(() => createLogStreamMock());
vi.mocked(openSync).mockReturnValue(42);
vi.mocked(spawn).mockReturnValue(createSpawnMock(123));
mockMkdir.mockResolvedValue(undefined);
mockWriteFile.mockResolvedValue(undefined as unknown as void);
mockRm.mockResolvedValue(undefined as unknown as void);
vi.mocked(fileExists).mockResolvedValue(false);
vi.mocked(fileExistsSync).mockReturnValue(true);
killSpy.mockReturnValue(true);
findMatchingSpy.mockResolvedValue([]);
findDirectMainSpy.mockResolvedValue([]);
terminateSpy.mockResolvedValue();
stopPm2Spy.mockResolvedValue();
});
it('ensures directories needed by nodemon exist', async () => {
const service = new NodemonService(logger);
await service.ensureNodemonDependencies();
expect(mockMkdir).toHaveBeenCalledWith('/var/log/unraid-api', { recursive: true });
expect(mockMkdir).toHaveBeenCalledWith('/var/log', { recursive: true });
expect(mockMkdir).toHaveBeenCalledWith('/var/run/unraid-api', { recursive: true });
});
it('throws error when directory creation fails', async () => {
const service = new NodemonService(logger);
const error = new Error('Permission denied');
mockMkdir.mockRejectedValue(error);
await expect(service.ensureNodemonDependencies()).rejects.toThrow('Permission denied');
expect(mockMkdir).toHaveBeenCalledWith('/var/log/unraid-api', { recursive: true });
});
it('starts nodemon and writes pid file', async () => {
const service = new NodemonService(logger);
const spawnMock = createSpawnMock(123);
vi.mocked(spawn).mockReturnValue(spawnMock);
killSpy.mockReturnValue(true);
findMatchingSpy.mockResolvedValue([]);
await service.start({ env: { LOG_LEVEL: 'DEBUG' } });
expect(stopPm2Spy).toHaveBeenCalled();
expect(spawn).toHaveBeenCalledWith(
process.execPath,
['/usr/bin/nodemon', '--config', '/etc/unraid-api/nodemon.json', '--quiet'],
{
cwd: '/usr/local/unraid-api',
env: expect.objectContaining({ LOG_LEVEL: 'DEBUG' }),
detached: true,
stdio: ['ignore', 42, 42],
}
);
expect(openSync).toHaveBeenCalledWith('/var/log/unraid-api/nodemon.log', 'a');
expect(spawnMock.unref).toHaveBeenCalled();
expect(mockWriteFile).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', '123');
expect(logger.info).toHaveBeenCalledWith('Started nodemon (pid 123)');
});
it('throws error and aborts start when directory creation fails', async () => {
const service = new NodemonService(logger);
const error = new Error('Permission denied');
mockMkdir.mockRejectedValue(error);
await expect(service.start()).rejects.toThrow('Permission denied');
expect(logger.error).toHaveBeenCalledWith(
'Failed to ensure nodemon dependencies: Permission denied'
);
expect(spawn).not.toHaveBeenCalled();
});
it('throws error when spawn fails', async () => {
const service = new NodemonService(logger);
const error = new Error('Command not found');
vi.mocked(spawn).mockImplementation(() => {
throw error;
});
await expect(service.start()).rejects.toThrow('Failed to start nodemon: Command not found');
expect(mockWriteFile).not.toHaveBeenCalledWith(
'/var/run/unraid-api/nodemon.pid',
expect.anything()
);
expect(logger.info).not.toHaveBeenCalled();
});
it('throws a clear error when the log file cannot be opened', async () => {
const service = new NodemonService(logger);
const openError = new Error('EACCES: permission denied');
vi.mocked(openSync).mockImplementation(() => {
throw openError;
});
await expect(service.start()).rejects.toThrow(
'Failed to start nodemon: EACCES: permission denied'
);
expect(spawn).not.toHaveBeenCalled();
});
it('throws error when pid is missing', async () => {
const service = new NodemonService(logger);
const spawnMock = createSpawnMock(undefined);
vi.mocked(spawn).mockReturnValue(spawnMock);
await expect(service.start()).rejects.toThrow(
'Failed to start nodemon: process spawned but no PID was assigned'
);
expect(mockWriteFile).not.toHaveBeenCalledWith(
'/var/run/unraid-api/nodemon.pid',
expect.anything()
);
expect(logger.info).not.toHaveBeenCalled();
});
it('throws when nodemon exits immediately after start', async () => {
const service = new NodemonService(logger);
const spawnMock = createSpawnMock(456);
vi.mocked(spawn).mockReturnValue(spawnMock);
killSpy.mockImplementation(() => {
throw new Error('not running');
});
const logsSpy = vi.spyOn(service, 'logs').mockResolvedValue('recent log lines');
await expect(service.start()).rejects.toThrow(/Nodemon exited immediately/);
expect(mockRm).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', { force: true });
expect(logsSpy).toHaveBeenCalledWith(50);
});
it('restarts when a recorded nodemon pid is already running', async () => {
const service = new NodemonService(logger);
const stopSpy = vi.spyOn(service, 'stop').mockResolvedValue();
vi.spyOn(
service as unknown as { waitForNodemonExit: () => Promise<void> },
'waitForNodemonExit'
).mockResolvedValue();
vi.spyOn(
service as unknown as { getStoredPid: () => Promise<number | null> },
'getStoredPid'
).mockResolvedValue(999);
vi.spyOn(
service as unknown as { isPidRunning: (pid: number) => Promise<boolean> },
'isPidRunning'
).mockResolvedValue(true);
const spawnMock = createSpawnMock(456);
vi.mocked(spawn).mockReturnValue(spawnMock);
await service.start();
expect(stopSpy).toHaveBeenCalledWith({ quiet: true });
expect(mockRm).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', { force: true });
expect(spawn).toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
'unraid-api already running under nodemon (pid 999); restarting for a fresh start.'
);
});
it('removes stale pid file and starts when recorded pid is dead', async () => {
const service = new NodemonService(logger);
const spawnMock = createSpawnMock(111);
vi.mocked(spawn).mockReturnValue(spawnMock);
vi.spyOn(
service as unknown as { getStoredPid: () => Promise<number | null> },
'getStoredPid'
).mockResolvedValue(555);
vi.spyOn(
service as unknown as { isPidRunning: (pid: number) => Promise<boolean> },
'isPidRunning'
)
.mockResolvedValueOnce(false)
.mockResolvedValue(true);
vi.spyOn(service, 'logs').mockResolvedValue('recent log lines');
findMatchingSpy.mockResolvedValue([]);
await service.start();
expect(mockRm).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', { force: true });
expect(spawn).toHaveBeenCalled();
expect(mockWriteFile).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', '111');
expect(logger.warn).toHaveBeenCalledWith(
'Found nodemon pid file (555) but the process is not running. Cleaning up.'
);
});
it('cleans up stray nodemon when no pid file exists', async () => {
const service = new NodemonService(logger);
findMatchingSpy.mockResolvedValue([888]);
vi.spyOn(
service as unknown as { isPidRunning: (pid: number) => Promise<boolean> },
'isPidRunning'
).mockResolvedValue(true);
vi.spyOn(
service as unknown as { waitForNodemonExit: () => Promise<void> },
'waitForNodemonExit'
).mockResolvedValue();
const spawnMock = createSpawnMock(222);
vi.mocked(spawn).mockReturnValue(spawnMock);
await service.start();
expect(terminateSpy).toHaveBeenCalledWith([888]);
expect(spawn).toHaveBeenCalled();
});
it('terminates direct main.js processes before starting nodemon', async () => {
const service = new NodemonService(logger);
findMatchingSpy.mockResolvedValue([]);
findDirectMainSpy.mockResolvedValue([321, 654]);
const spawnMock = createSpawnMock(777);
vi.mocked(spawn).mockReturnValue(spawnMock);
await service.start();
expect(terminateSpy).toHaveBeenCalledWith([321, 654]);
expect(spawn).toHaveBeenCalledWith(
process.execPath,
['/usr/bin/nodemon', '--config', '/etc/unraid-api/nodemon.json', '--quiet'],
expect.objectContaining({ cwd: '/usr/local/unraid-api' })
);
});
it('returns not running when pid file is missing and no orphans', async () => {
const service = new NodemonService(logger);
vi.mocked(fileExists).mockResolvedValue(false);
findMatchingSpy.mockResolvedValue([]);
findDirectMainSpy.mockResolvedValue([]);
const result = await service.status();
expect(result).toBe(false);
expect(logger.info).toHaveBeenCalledWith('unraid-api is not running (no pid file).');
});
it('returns running and warns when orphan processes found without pid file', async () => {
const service = new NodemonService(logger);
vi.mocked(fileExists).mockResolvedValue(false);
findMatchingSpy.mockResolvedValue([]);
findDirectMainSpy.mockResolvedValue([123, 456]);
const result = await service.status();
expect(result).toBe(true);
expect(logger.warn).toHaveBeenCalledWith(
'No PID file, but found orphaned processes: nodemon=none, main.js=123,456'
);
});
it('returns running and warns when orphan nodemon found without pid file', async () => {
const service = new NodemonService(logger);
vi.mocked(fileExists).mockResolvedValue(false);
findMatchingSpy.mockResolvedValue([789]);
findDirectMainSpy.mockResolvedValue([]);
const result = await service.status();
expect(result).toBe(true);
expect(logger.warn).toHaveBeenCalledWith(
'No PID file, but found orphaned processes: nodemon=789, main.js=none'
);
});
it('stop: sends SIGTERM to nodemon and waits for exit', async () => {
const service = new NodemonService(logger);
vi.mocked(fileExists).mockResolvedValue(true);
vi.mocked(fs.readFile).mockResolvedValue('100');
findDirectMainSpy.mockResolvedValue([200]);
const waitForPidsToExitSpy = vi
.spyOn(
service as unknown as {
waitForPidsToExit: (pids: number[], timeoutMs?: number) => Promise<number[]>;
},
'waitForPidsToExit'
)
.mockResolvedValue([]);
await service.stop();
expect(killSpy).toHaveBeenCalledWith(100, 'SIGTERM');
expect(waitForPidsToExitSpy).toHaveBeenCalledWith([100, 200], 5000);
expect(mockRm).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', { force: true });
});
it('stop: force kills remaining processes after timeout', async () => {
const service = new NodemonService(logger);
vi.mocked(fileExists).mockResolvedValue(true);
vi.mocked(fs.readFile).mockResolvedValue('100');
findDirectMainSpy.mockResolvedValue([200]);
vi.spyOn(
service as unknown as {
waitForPidsToExit: (pids: number[], timeoutMs?: number) => Promise<number[]>;
},
'waitForPidsToExit'
).mockResolvedValue([100, 200]);
const terminatePidsWithForceSpy = vi
.spyOn(
service as unknown as {
terminatePidsWithForce: (pids: number[], gracePeriodMs?: number) => Promise<void>;
},
'terminatePidsWithForce'
)
.mockResolvedValue();
await service.stop();
expect(logger.warn).toHaveBeenCalledWith('Force killing remaining processes: 100, 200');
expect(terminatePidsWithForceSpy).toHaveBeenCalledWith([100, 200]);
});
it('stop: cleans up orphaned main.js when no pid file exists', async () => {
const service = new NodemonService(logger);
vi.mocked(fileExists).mockResolvedValue(false);
findDirectMainSpy.mockResolvedValue([300, 400]);
const terminatePidsWithForceSpy = vi
.spyOn(
service as unknown as {
terminatePidsWithForce: (pids: number[], gracePeriodMs?: number) => Promise<void>;
},
'terminatePidsWithForce'
)
.mockResolvedValue();
await service.stop();
expect(logger.warn).toHaveBeenCalledWith('No nodemon pid file found.');
expect(logger.warn).toHaveBeenCalledWith(
'Found orphaned main.js processes: 300, 400. Terminating.'
);
expect(terminatePidsWithForceSpy).toHaveBeenCalledWith([300, 400]);
});
it('stop --force: skips graceful wait', async () => {
const service = new NodemonService(logger);
vi.mocked(fileExists).mockResolvedValue(true);
vi.mocked(fs.readFile).mockResolvedValue('100');
findDirectMainSpy.mockResolvedValue([]);
const waitForPidsToExitSpy = vi
.spyOn(
service as unknown as {
waitForPidsToExit: (pids: number[], timeoutMs?: number) => Promise<number[]>;
},
'waitForPidsToExit'
)
.mockResolvedValue([100]);
vi.spyOn(
service as unknown as {
terminatePidsWithForce: (pids: number[], gracePeriodMs?: number) => Promise<void>;
},
'terminatePidsWithForce'
).mockResolvedValue();
await service.stop({ force: true });
expect(waitForPidsToExitSpy).toHaveBeenCalledWith([100], 0);
});
it('logs stdout when tail succeeds', async () => {
const service = new NodemonService(logger);
vi.mocked(execa).mockResolvedValue({
stdout: 'log line 1\nlog line 2',
} as unknown as Awaited<ReturnType<typeof execa>>);
const result = await service.logs(50);
expect(execa).toHaveBeenCalledWith('tail', ['-n', '50', '/var/log/graphql-api.log']);
expect(logger.log).toHaveBeenCalledWith('log line 1\nlog line 2');
expect(result).toBe('log line 1\nlog line 2');
});
it('handles ENOENT error when log file is missing', async () => {
const service = new NodemonService(logger);
const error = new Error('ENOENT: no such file or directory');
(error as Error & { code?: string }).code = 'ENOENT';
vi.mocked(execa).mockRejectedValue(error);
const result = await service.logs();
expect(logger.error).toHaveBeenCalledWith(
'Log file not found: /var/log/graphql-api.log (ENOENT: no such file or directory)'
);
expect(result).toBe('');
});
it('handles non-zero exit error from tail', async () => {
const service = new NodemonService(logger);
const error = new Error('Command failed with exit code 1');
vi.mocked(execa).mockRejectedValue(error);
const result = await service.logs(100);
expect(logger.error).toHaveBeenCalledWith(
'Failed to read logs from /var/log/graphql-api.log: Command failed with exit code 1'
);
expect(result).toBe('');
});
it('waits for nodemon to exit during restart before starting again', async () => {
const service = new NodemonService(logger);
const stopSpy = vi.spyOn(service, 'stop').mockResolvedValue();
const waitSpy = vi
.spyOn(
service as unknown as { waitForNodemonExit: () => Promise<void> },
'waitForNodemonExit'
)
.mockResolvedValue();
vi.spyOn(
service as unknown as { getStoredPid: () => Promise<number | null> },
'getStoredPid'
).mockResolvedValue(123);
vi.spyOn(
service as unknown as { isPidRunning: (pid: number) => Promise<boolean> },
'isPidRunning'
).mockResolvedValue(true);
const spawnMock = createSpawnMock(456);
vi.mocked(spawn).mockReturnValue(spawnMock);
await service.restart({ env: { LOG_LEVEL: 'DEBUG' } });
expect(stopSpy).toHaveBeenCalledWith({ quiet: true });
expect(waitSpy).toHaveBeenCalled();
expect(spawn).toHaveBeenCalled();
});
it('performs clean start on restart when nodemon is not running', async () => {
const service = new NodemonService(logger);
const stopSpy = vi.spyOn(service, 'stop').mockResolvedValue();
const startSpy = vi.spyOn(service, 'start').mockResolvedValue();
const waitSpy = vi
.spyOn(
service as unknown as { waitForNodemonExit: () => Promise<void> },
'waitForNodemonExit'
)
.mockResolvedValue();
vi.spyOn(
service as unknown as { getStoredPid: () => Promise<number | null> },
'getStoredPid'
).mockResolvedValue(null);
await service.restart();
expect(stopSpy).not.toHaveBeenCalled();
expect(waitSpy).not.toHaveBeenCalled();
expect(startSpy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,534 @@
import { Injectable } from '@nestjs/common';
import { spawn } from 'node:child_process';
import { openSync, writeSync } from 'node:fs';
import { appendFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { execa } from 'execa';
import { lock } from 'proper-lockfile';
import { fileExists, fileExistsSync } from '@app/core/utils/files/file-exists.js';
import {
NODEMON_CONFIG_PATH,
NODEMON_LOCK_PATH,
NODEMON_PATH,
NODEMON_PID_PATH,
PATHS_LOGS_DIR,
PATHS_LOGS_FILE,
PATHS_NODEMON_LOG_FILE,
UNRAID_API_CWD,
UNRAID_API_SERVER_ENTRYPOINT,
} from '@app/environment.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
const LOCK_TIMEOUT_SECONDS = 30;
type StartOptions = {
env?: Record<string, string | undefined>;
};
type StopOptions = {
/** When true, uses SIGKILL instead of SIGTERM */
force?: boolean;
/** Suppress warnings when there is no pid file */
quiet?: boolean;
};
const BOOT_LOG_PATH = '/var/log/unraid-api/boot.log';
@Injectable()
export class NodemonService {
constructor(private readonly logger: LogService) {}
private async logToBootFile(message: string): Promise<void> {
const timestamp = new Date().toISOString();
const line = `[${timestamp}] [nodemon-service] ${message}\n`;
try {
await appendFile(BOOT_LOG_PATH, line);
} catch {
// Fallback to console if file write fails (e.g., directory doesn't exist yet)
}
}
private validatePaths(): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (!fileExistsSync(NODEMON_PATH)) {
errors.push(`NODEMON_PATH does not exist: ${NODEMON_PATH}`);
}
if (!fileExistsSync(NODEMON_CONFIG_PATH)) {
errors.push(`NODEMON_CONFIG_PATH does not exist: ${NODEMON_CONFIG_PATH}`);
}
if (!fileExistsSync(UNRAID_API_CWD)) {
errors.push(`UNRAID_API_CWD does not exist: ${UNRAID_API_CWD}`);
}
return { valid: errors.length === 0, errors };
}
async ensureNodemonDependencies() {
await mkdir(PATHS_LOGS_DIR, { recursive: true });
await mkdir(dirname(PATHS_LOGS_FILE), { recursive: true });
await mkdir(dirname(PATHS_NODEMON_LOG_FILE), { recursive: true });
await mkdir(dirname(NODEMON_PID_PATH), { recursive: true });
await mkdir(dirname(NODEMON_LOCK_PATH), { recursive: true });
await writeFile(NODEMON_LOCK_PATH, '', { flag: 'a' });
}
private async withLock<T>(fn: () => Promise<T>): Promise<T> {
let release: (() => Promise<void>) | null = null;
try {
release = await lock(NODEMON_LOCK_PATH, {
stale: LOCK_TIMEOUT_SECONDS * 1000,
retries: {
retries: Math.floor(LOCK_TIMEOUT_SECONDS * 10),
factor: 1,
minTimeout: 100,
maxTimeout: 100,
},
});
return await fn();
} finally {
if (release) {
await release().catch(() => {});
}
}
}
private async stopPm2IfRunning() {
const pm2PidPath = '/var/log/.pm2/pm2.pid';
if (!(await fileExists(pm2PidPath))) return;
const pm2Candidates = ['/usr/bin/pm2', '/usr/local/bin/pm2'];
const pm2Path =
(
await Promise.all(
pm2Candidates.map(async (candidate) =>
(await fileExists(candidate)) ? candidate : null
)
)
).find(Boolean) ?? null;
if (pm2Path) {
try {
const { stdout } = await execa(pm2Path, ['jlist']);
const processes = JSON.parse(stdout);
const hasUnraid =
Array.isArray(processes) && processes.some((proc) => proc?.name === 'unraid-api');
if (hasUnraid) {
await execa(pm2Path, ['delete', 'unraid-api']);
this.logger.info('Stopped pm2-managed unraid-api before starting nodemon.');
}
} catch (error) {
// PM2 may not be installed or responding; keep this quiet to avoid noisy startup.
this.logger.debug?.('Skipping pm2 cleanup (not installed or not running).');
}
}
// Fallback: directly kill the pm2 daemon and remove its state, even if pm2 binary is missing.
try {
const pidText = (await readFile(pm2PidPath, 'utf-8')).trim();
const pid = Number.parseInt(pidText, 10);
if (!Number.isNaN(pid)) {
process.kill(pid, 'SIGTERM');
this.logger.debug?.(`Sent SIGTERM to pm2 daemon (pid ${pid}).`);
}
} catch {
// ignore
}
try {
await rm('/var/log/.pm2', { recursive: true, force: true });
} catch {
// Ignore errors when removing pm2 state - shouldn't block API startup
}
}
private async getStoredPid(): Promise<number | null> {
if (!(await fileExists(NODEMON_PID_PATH))) return null;
const contents = (await readFile(NODEMON_PID_PATH, 'utf-8')).trim();
const pid = Number.parseInt(contents, 10);
return Number.isNaN(pid) ? null : pid;
}
private async isPidRunning(pid: number): Promise<boolean> {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
private async findMatchingNodemonPids(): Promise<number[]> {
try {
const { stdout } = await execa('ps', ['-eo', 'pid,args']);
return stdout
.split('\n')
.map((line) => line.trim())
.map((line) => line.match(/^(\d+)\s+(.*)$/))
.filter((match): match is RegExpMatchArray => Boolean(match))
.map(([, pid, cmd]) => ({ pid: Number.parseInt(pid, 10), cmd }))
.filter(({ cmd }) => cmd.includes('nodemon') && cmd.includes(NODEMON_CONFIG_PATH))
.map(({ pid }) => pid)
.filter((pid) => Number.isInteger(pid));
} catch {
return [];
}
}
private async findDirectMainPids(): Promise<number[]> {
try {
const { stdout } = await execa('ps', ['-eo', 'pid,args']);
return stdout
.split('\n')
.map((line) => line.trim())
.map((line) => line.match(/^(\d+)\s+(.*)$/))
.filter((match): match is RegExpMatchArray => Boolean(match))
.map(([, pid, cmd]) => ({ pid: Number.parseInt(pid, 10), cmd }))
.filter(({ cmd }) => cmd.includes(UNRAID_API_SERVER_ENTRYPOINT))
.map(({ pid }) => pid)
.filter((pid) => Number.isInteger(pid));
} catch {
return [];
}
}
private async terminatePids(pids: number[]) {
for (const pid of pids) {
try {
process.kill(pid, 'SIGTERM');
this.logger.debug?.(`Sent SIGTERM to existing unraid-api process (pid ${pid}).`);
} catch (error) {
this.logger.debug?.(
`Failed to send SIGTERM to pid ${pid}: ${error instanceof Error ? error.message : error}`
);
}
}
}
private async waitForNodemonExit(timeoutMs = 5000, pollIntervalMs = 100) {
const deadline = Date.now() + timeoutMs;
// Poll for any remaining nodemon processes that match our config file
while (Date.now() < deadline) {
const pids = await this.findMatchingNodemonPids();
if (pids.length === 0) return;
const runningFlags = await Promise.all(pids.map((pid) => this.isPidRunning(pid)));
if (!runningFlags.some(Boolean)) return;
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
this.logger.debug?.('Timed out waiting for nodemon to exit; continuing restart anyway.');
}
/**
* Wait for processes to exit, returns array of PIDs that didn't exit in time
*/
private async waitForPidsToExit(pids: number[], timeoutMs = 5000): Promise<number[]> {
if (timeoutMs <= 0) return pids.filter((pid) => pid > 0);
const deadline = Date.now() + timeoutMs;
const remaining = new Set(pids.filter((pid) => pid > 0));
while (remaining.size > 0 && Date.now() < deadline) {
for (const pid of remaining) {
if (!(await this.isPidRunning(pid))) {
remaining.delete(pid);
}
}
if (remaining.size > 0) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
return [...remaining];
}
/**
* Terminate PIDs with SIGTERM, then SIGKILL after timeout
*/
private async terminatePidsWithForce(pids: number[], gracePeriodMs = 2000): Promise<void> {
// Send SIGTERM to all
for (const pid of pids) {
try {
process.kill(pid, 'SIGTERM');
} catch {
// Process may have already exited
}
}
// Wait for graceful exit
const remaining = await this.waitForPidsToExit(pids, gracePeriodMs);
// Force kill any that didn't exit
for (const pid of remaining) {
try {
process.kill(pid, 'SIGKILL');
this.logger.debug?.(`Sent SIGKILL to pid ${pid}`);
} catch {
// Process may have already exited
}
}
// Brief wait for SIGKILL to take effect
if (remaining.length > 0) {
await this.waitForPidsToExit(remaining, 1000);
}
}
async start(options: StartOptions = {}) {
// Log boot attempt with diagnostic info
await this.logToBootFile('=== Starting unraid-api via nodemon ===');
await this.logToBootFile(`NODEMON_PATH: ${NODEMON_PATH}`);
await this.logToBootFile(`NODEMON_CONFIG_PATH: ${NODEMON_CONFIG_PATH}`);
await this.logToBootFile(`UNRAID_API_CWD: ${UNRAID_API_CWD}`);
await this.logToBootFile(`NODEMON_PID_PATH: ${NODEMON_PID_PATH}`);
await this.logToBootFile(`process.cwd(): ${process.cwd()}`);
await this.logToBootFile(`process.execPath: ${process.execPath}`);
await this.logToBootFile(`PATH: ${process.env.PATH}`);
// Validate paths before proceeding
const { valid, errors } = this.validatePaths();
if (!valid) {
for (const error of errors) {
await this.logToBootFile(`ERROR: ${error}`);
this.logger.error(error);
}
throw new Error(`Path validation failed: ${errors.join('; ')}`);
}
await this.logToBootFile('Path validation passed');
try {
await this.ensureNodemonDependencies();
await this.logToBootFile('Dependencies ensured');
} catch (error) {
const msg = `Failed to ensure nodemon dependencies: ${error instanceof Error ? error.message : error}`;
await this.logToBootFile(`ERROR: ${msg}`);
this.logger.error(msg);
throw error;
}
await this.withLock(() => this.startInternal(options));
}
private async startInternal(options: StartOptions = {}) {
await this.stopPm2IfRunning();
await this.logToBootFile('PM2 cleanup complete');
const existingPid = await this.getStoredPid();
if (existingPid) {
const running = await this.isPidRunning(existingPid);
if (running) {
await this.logToBootFile(`Found running nodemon (pid ${existingPid}), restarting`);
this.logger.info(
`unraid-api already running under nodemon (pid ${existingPid}); restarting for a fresh start.`
);
await this.stop({ quiet: true });
await this.waitForNodemonExit();
await rm(NODEMON_PID_PATH, { force: true });
} else {
await this.logToBootFile(`Found stale pid file (${existingPid}), cleaning up`);
this.logger.warn(
`Found nodemon pid file (${existingPid}) but the process is not running. Cleaning up.`
);
await rm(NODEMON_PID_PATH, { force: true });
}
}
const discoveredPids = await this.findMatchingNodemonPids();
const liveDiscoveredPids = await Promise.all(
discoveredPids.map(async (pid) => ((await this.isPidRunning(pid)) ? pid : null))
).then((pids) => pids.filter((pid): pid is number => pid !== null));
if (liveDiscoveredPids.length > 0) {
await this.logToBootFile(`Found orphan nodemon processes: ${liveDiscoveredPids.join(', ')}`);
this.logger.info(
`Found nodemon process(es) (${liveDiscoveredPids.join(', ')}) without a pid file; restarting for a fresh start.`
);
await this.terminatePids(liveDiscoveredPids);
await this.waitForNodemonExit();
}
const directMainPids = await this.findDirectMainPids();
if (directMainPids.length > 0) {
await this.logToBootFile(`Found direct main.js processes: ${directMainPids.join(', ')}`);
this.logger.warn(
`Found existing unraid-api process(es) running directly: ${directMainPids.join(', ')}. Stopping them before starting nodemon.`
);
await this.terminatePids(directMainPids);
}
const overrides = Object.fromEntries(
Object.entries(options.env ?? {}).filter(([, value]) => value !== undefined)
);
const env = {
...process.env,
// Ensure PATH includes standard locations for boot-time reliability
PATH: `/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}`,
NODE_ENV: 'production',
PATHS_LOGS_FILE,
PATHS_NODEMON_LOG_FILE,
NODEMON_CONFIG_PATH,
NODEMON_PID_PATH,
UNRAID_API_CWD,
UNRAID_API_SERVER_ENTRYPOINT,
...overrides,
} as Record<string, string>;
await this.logToBootFile(
`Spawning: ${process.execPath} ${NODEMON_PATH} --config ${NODEMON_CONFIG_PATH}`
);
let logFd: number | null = null;
try {
// Use file descriptor for stdio - more reliable for detached processes at boot
logFd = openSync(PATHS_NODEMON_LOG_FILE, 'a');
// Write initial message to nodemon log
writeSync(logFd, 'Starting nodemon...\n');
// Use native spawn instead of execa for more reliable detached process handling
const nodemonProcess = spawn(
process.execPath, // Use current node executable path
[NODEMON_PATH, '--config', NODEMON_CONFIG_PATH, '--quiet'],
{
cwd: UNRAID_API_CWD,
env,
detached: true,
stdio: ['ignore', logFd, logFd],
}
);
nodemonProcess.unref();
if (!nodemonProcess.pid) {
await this.logToBootFile('ERROR: Failed to spawn nodemon - no PID assigned');
throw new Error('Failed to start nodemon: process spawned but no PID was assigned');
}
await writeFile(NODEMON_PID_PATH, `${nodemonProcess.pid}`);
await this.logToBootFile(`Spawned nodemon with PID: ${nodemonProcess.pid}`);
// Multiple verification checks with increasing delays for boot-time reliability
const verificationDelays = [200, 500, 1000];
for (const delay of verificationDelays) {
await new Promise((resolve) => setTimeout(resolve, delay));
const stillRunning = await this.isPidRunning(nodemonProcess.pid);
if (!stillRunning) {
const recentLogs = await this.logs(50);
await rm(NODEMON_PID_PATH, { force: true });
const logMessage = recentLogs ? ` Recent logs:\n${recentLogs}` : '';
await this.logToBootFile(`ERROR: Nodemon exited after ${delay}ms`);
await this.logToBootFile(`Recent logs: ${recentLogs}`);
throw new Error(`Nodemon exited immediately after start.${logMessage}`);
}
await this.logToBootFile(`Verification passed after ${delay}ms`);
}
await this.logToBootFile(`Successfully started nodemon (pid ${nodemonProcess.pid})`);
this.logger.info(`Started nodemon (pid ${nodemonProcess.pid})`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
await this.logToBootFile(`ERROR: ${errorMessage}`);
throw new Error(`Failed to start nodemon: ${errorMessage}`);
}
}
async stop(options: StopOptions = {}) {
const nodemonPid = await this.getStoredPid();
// Find child processes BEFORE sending any signals
const childPids = await this.findDirectMainPids();
if (!nodemonPid) {
if (!options.quiet) {
this.logger.warn('No nodemon pid file found.');
}
// Clean up orphaned children if any exist
if (childPids.length > 0) {
this.logger.warn(
`Found orphaned main.js processes: ${childPids.join(', ')}. Terminating.`
);
await this.terminatePidsWithForce(childPids);
}
return;
}
// Step 1: SIGTERM to nodemon (will forward to child)
try {
process.kill(nodemonPid, 'SIGTERM');
this.logger.trace(`Sent SIGTERM to nodemon (pid ${nodemonPid})`);
} catch (error) {
// Process may have already exited
this.logger.debug?.(`nodemon (pid ${nodemonPid}) already gone: ${error}`);
}
// Step 2: Wait for both nodemon and children to exit
const allPids = [nodemonPid, ...childPids];
const gracefulTimeout = options.force ? 0 : 5000;
const remainingPids = await this.waitForPidsToExit(allPids, gracefulTimeout);
// Step 3: Force kill any remaining processes
if (remainingPids.length > 0) {
this.logger.warn(`Force killing remaining processes: ${remainingPids.join(', ')}`);
await this.terminatePidsWithForce(remainingPids);
}
// Step 4: Clean up PID file
await rm(NODEMON_PID_PATH, { force: true });
}
async restart(options: StartOptions = {}) {
// Delegate to start so both commands share identical logic
await this.start(options);
}
async status(): Promise<boolean> {
const pid = await this.getStoredPid();
// Check for orphaned processes even without PID file
const orphanNodemonPids = await this.findMatchingNodemonPids();
const orphanMainPids = await this.findDirectMainPids();
if (!pid) {
if (orphanNodemonPids.length > 0 || orphanMainPids.length > 0) {
this.logger.warn(
`No PID file, but found orphaned processes: nodemon=${orphanNodemonPids.join(',') || 'none'}, main.js=${orphanMainPids.join(',') || 'none'}`
);
return true; // Processes ARE running, just not tracked
}
this.logger.info('unraid-api is not running (no pid file).');
return false;
}
const running = await this.isPidRunning(pid);
if (running) {
this.logger.info(`unraid-api is running under nodemon (pid ${pid}).`);
} else {
this.logger.warn(`Found nodemon pid file (${pid}) but the process is not running.`);
await rm(NODEMON_PID_PATH, { force: true });
}
return running;
}
async logs(lines = 100): Promise<string> {
try {
const { stdout } = await execa('tail', ['-n', `${lines}`, PATHS_LOGS_FILE]);
this.logger.log(stdout);
return stdout;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const isFileNotFound =
errorMessage.includes('ENOENT') ||
(error instanceof Error && 'code' in error && error.code === 'ENOENT');
if (isFileNotFound) {
this.logger.error(`Log file not found: ${PATHS_LOGS_FILE} (${errorMessage})`);
} else {
this.logger.error(`Failed to read logs from ${PATHS_LOGS_FILE}: ${errorMessage}`);
}
return '';
}
}
}

View File

@@ -74,13 +74,15 @@ export class InstallPluginCommand extends CommandRunner {
interface RemovePluginCommandOptions {
plugins?: string[];
restart: boolean;
restart?: boolean;
bypassNpm?: boolean;
}
@SubCommand({
name: 'remove',
aliases: ['rm'],
description: 'Remove plugin peer dependencies.',
arguments: '[plugins...]',
})
export class RemovePluginCommand extends CommandRunner {
constructor(
@@ -93,9 +95,83 @@ export class RemovePluginCommand extends CommandRunner {
super();
}
async run(_passedParams: string[], options?: RemovePluginCommandOptions): Promise<void> {
async run(passedParams: string[], options?: RemovePluginCommandOptions): Promise<void> {
const cliBypass = options?.bypassNpm;
const cliRestart = options?.restart;
const mergedOptions: RemovePluginCommandOptions = {
bypassNpm: cliBypass ?? false,
restart: cliRestart ?? true,
plugins: passedParams.length > 0 ? passedParams : options?.plugins,
};
let resolvedOptions = mergedOptions;
if (!mergedOptions.plugins?.length) {
const promptOptions = await this.promptForPlugins(mergedOptions);
if (!promptOptions) {
return;
}
resolvedOptions = {
// precedence: cli > prompt > default (fallback)
bypassNpm: cliBypass ?? promptOptions.bypassNpm ?? mergedOptions.bypassNpm,
restart: cliRestart ?? promptOptions.restart ?? mergedOptions.restart,
// precedence: prompt > default (fallback)
plugins: promptOptions.plugins ?? mergedOptions.plugins,
};
}
if (!resolvedOptions.plugins?.length) {
this.logService.warn('No plugins selected for removal.');
return;
}
if (resolvedOptions.bypassNpm) {
await this.pluginManagementService.removePluginConfigOnly(...resolvedOptions.plugins);
} else {
await this.pluginManagementService.removePlugin(...resolvedOptions.plugins);
}
for (const plugin of resolvedOptions.plugins) {
this.logService.log(`Removed plugin ${plugin}`);
}
await this.apiConfigPersistence.persist();
if (resolvedOptions.restart) {
await this.restartCommand.run();
}
}
@Option({
flags: '--no-restart',
description: 'do NOT restart the service after deploy',
defaultValue: true,
})
parseRestart(): boolean {
return false;
}
@Option({
flags: '-b, --bypass-npm',
description: 'Bypass npm uninstall and only update the config',
defaultValue: false,
name: 'bypassNpm',
})
parseBypass(): boolean {
return true;
}
@Option({
flags: '--npm',
description: 'Run npm uninstall for unbundled plugins (default behavior)',
name: 'bypassNpm',
})
parseRunNpm(): boolean {
return false;
}
private async promptForPlugins(
initialOptions: RemovePluginCommandOptions
): Promise<RemovePluginCommandOptions | undefined> {
try {
options = await this.inquirerService.prompt(RemovePluginQuestionSet.name, options);
return await this.inquirerService.prompt(RemovePluginQuestionSet.name, initialOptions);
} catch (error) {
if (error instanceof NoPluginsFoundError) {
this.logService.error(error.message);
@@ -108,30 +184,6 @@ export class RemovePluginCommand extends CommandRunner {
process.exit(1);
}
}
if (!options.plugins || options.plugins.length === 0) {
this.logService.warn('No plugins selected for removal.');
return;
}
await this.pluginManagementService.removePlugin(...options.plugins);
for (const plugin of options.plugins) {
this.logService.log(`Removed plugin ${plugin}`);
}
await this.apiConfigPersistence.persist();
if (options.restart) {
await this.restartCommand.run();
}
}
@Option({
flags: '--no-restart',
description: 'do NOT restart the service after deploy',
defaultValue: true,
})
parseRestart(): boolean {
return false;
}
}

View File

@@ -1,76 +0,0 @@
import * as fs from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LogService } from '@app/unraid-api/cli/log.service.js';
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
vi.mock('node:fs/promises');
vi.mock('execa');
vi.mock('@app/core/utils/files/file-exists.js', () => ({
fileExists: vi.fn().mockResolvedValue(false),
}));
vi.mock('@app/environment.js', () => ({
PATHS_LOGS_DIR: '/var/log/unraid-api',
PM2_HOME: '/var/log/.pm2',
PM2_PATH: '/path/to/pm2',
ECOSYSTEM_PATH: '/path/to/ecosystem.config.json',
SUPPRESS_LOGS: false,
LOG_LEVEL: 'info',
}));
describe('PM2Service', () => {
let pm2Service: PM2Service;
let logService: LogService;
const mockMkdir = vi.mocked(fs.mkdir);
beforeEach(() => {
vi.clearAllMocks();
logService = {
trace: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
log: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
} as unknown as LogService;
pm2Service = new PM2Service(logService);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('ensurePm2Dependencies', () => {
it('should create logs directory and log that PM2 will handle its own directory', async () => {
mockMkdir.mockResolvedValue(undefined);
await pm2Service.ensurePm2Dependencies();
expect(mockMkdir).toHaveBeenCalledWith('/var/log/unraid-api', { recursive: true });
expect(mockMkdir).toHaveBeenCalledTimes(1); // Only logs directory, not PM2_HOME
expect(logService.trace).toHaveBeenCalledWith(
'PM2_HOME will be created at /var/log/.pm2 when PM2 daemon starts'
);
});
it('should log error but not throw when logs directory creation fails', async () => {
mockMkdir.mockRejectedValue(new Error('Disk full'));
await expect(pm2Service.ensurePm2Dependencies()).resolves.not.toThrow();
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining('Failed to fully ensure PM2 dependencies: Disk full')
);
});
it('should handle mkdir with recursive flag for nested logs path', async () => {
mockMkdir.mockResolvedValue(undefined);
await pm2Service.ensurePm2Dependencies();
expect(mockMkdir).toHaveBeenCalledWith('/var/log/unraid-api', { recursive: true });
expect(mockMkdir).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -1,133 +0,0 @@
import { Injectable } from '@nestjs/common';
import { mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import type { Options, Result, ResultPromise } from 'execa';
import { execa, ExecaError } from 'execa';
import { fileExists } from '@app/core/utils/files/file-exists.js';
import { PATHS_LOGS_DIR, PM2_HOME, PM2_PATH } from '@app/environment.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
type CmdContext = Options & {
/** A tag for logging & debugging purposes. Should represent the operation being performed. */
tag: string;
/** Default: false.
*
* When true, results will not be automatically handled and logged.
* The caller must handle desired effects, such as logging, error handling, etc.
*/
raw?: boolean;
};
@Injectable()
export class PM2Service {
constructor(private readonly logger: LogService) {}
// Type Overload: if raw is true, return an execa ResultPromise (which is a Promise with extra properties)
/**
* Executes a PM2 command with the specified context and arguments.
* Handles logging automatically (stdout -> trace, stderr -> error), unless the `raw` flag is
* set to true, in which case the caller must handle desired effects.
*
* @param context - Execa Options for command execution, such as a unique tag for logging
* and whether the result should be handled raw.
* @param args - The arguments to pass to the PM2 command.
* @returns ResultPromise\<@param context\> When raw is true
* @returns Promise\<Result\> When raw is false
*/
run<T extends CmdContext>(context: T & { raw: true }, ...args: string[]): ResultPromise<T>;
run(context: CmdContext & { raw?: false }, ...args: string[]): Promise<Result>;
async run(context: CmdContext, ...args: string[]) {
const { tag, raw, ...execOptions } = context;
// Default to true to match execa's default behavior
execOptions.extendEnv ??= true;
execOptions.shell ??= 'bash';
// Ensure /usr/local/bin is in PATH for Node.js
const currentPath = execOptions.env?.PATH || process.env.PATH || '/usr/bin:/bin:/usr/sbin:/sbin';
const needsPathUpdate = !currentPath.includes('/usr/local/bin');
const finalPath = needsPathUpdate ? `/usr/local/bin:${currentPath}` : currentPath;
// Always ensure PM2_HOME is set in the environment for every PM2 command
execOptions.env = {
...execOptions.env,
PM2_HOME,
...(needsPathUpdate && { PATH: finalPath }),
};
const runCommand = () => execa(PM2_PATH, [...args], execOptions satisfies Options);
if (raw) {
return runCommand();
}
return runCommand()
.then((result) => {
this.logger.trace(result.stdout);
return result;
})
.catch((result: Result) => {
this.logger.error(`PM2 error occurred from tag "${tag}": ${result.stdout}\n`);
return result;
});
}
/**
* Deletes the PM2 dump file.
*
* This method removes the PM2 dump file located at `~/.pm2/dump.pm2` by default.
* It logs a message indicating that the PM2 dump has been cleared.
*
* @returns A promise that resolves once the dump file is removed.
*/
async deleteDump(dumpFile = join(PM2_HOME, 'dump.pm2')) {
await rm(dumpFile, { force: true });
this.logger.trace('PM2 dump cleared.');
}
async forceKillPm2Daemon() {
try {
// Find all PM2 daemon processes and kill them
const pids = (await execa('pgrep', ['-i', 'PM2'])).stdout.split('\n').filter(Boolean);
if (pids.length > 0) {
await execa('kill', ['-9', ...pids]);
this.logger.trace(`Killed PM2 daemon processes: ${pids.join(', ')}`);
}
} catch (err) {
if (err instanceof ExecaError && err.exitCode === 1) {
this.logger.trace('No PM2 daemon processes found.');
} else {
this.logger.error(`Error force killing PM2 daemon: ${err}`);
}
}
}
async deletePm2Home() {
if ((await fileExists(PM2_HOME)) && (await fileExists(join(PM2_HOME, 'pm2.log')))) {
await rm(PM2_HOME, { recursive: true, force: true });
this.logger.trace('PM2 home directory cleared.');
} else {
this.logger.trace('PM2 home directory does not exist.');
}
}
/**
* Ensures that the dependencies necessary for PM2 to start and operate are present.
* Creates PM2_HOME directory with proper permissions if it doesn't exist.
*/
async ensurePm2Dependencies() {
try {
// Create logs directory
await mkdir(PATHS_LOGS_DIR, { recursive: true });
// PM2 automatically creates and manages its home directory when the daemon starts
this.logger.trace(`PM2_HOME will be created at ${PM2_HOME} when PM2 daemon starts`);
} catch (error) {
// Log error but don't throw - let PM2 fail with its own error messages if the setup is incomplete
this.logger.error(
`Failed to fully ensure PM2 dependencies: ${error instanceof Error ? error.message : error}. PM2 may encounter issues during operation.`
);
}
}
}

View File

@@ -33,9 +33,9 @@ export class ReportCommand extends CommandRunner {
async report(): Promise<string | void> {
try {
// Check if API is running
const { isUnraidApiRunning } = await import('@app/core/utils/pm2/unraid-api-running.js');
const { isUnraidApiRunning } = await import('@app/core/utils/process/unraid-api-running.js');
const apiRunning = await isUnraidApiRunning().catch((err) => {
this.logger.debug('failed to get PM2 state with error: ' + err);
this.logger.debug('failed to check nodemon state with error: ' + err);
return false;
});

View File

@@ -2,9 +2,9 @@ import { Command, CommandRunner, Option } from 'nest-commander';
import type { LogLevel } from '@app/core/log.js';
import { levels } from '@app/core/log.js';
import { ECOSYSTEM_PATH, LOG_LEVEL } from '@app/environment.js';
import { LOG_LEVEL } from '@app/environment.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js';
export interface LogLevelOptions {
logLevel?: LogLevel;
@@ -22,7 +22,7 @@ export function parseLogLevelOption(val: string, allowedLevels: string[] = [...l
export class RestartCommand extends CommandRunner {
constructor(
private readonly logger: LogService,
private readonly pm2: PM2Service
private readonly nodemon: NodemonService
) {
super();
}
@@ -30,23 +30,9 @@ export class RestartCommand extends CommandRunner {
async run(_?: string[], options: LogLevelOptions = {}): Promise<void> {
try {
this.logger.info('Restarting the Unraid API...');
const env = { LOG_LEVEL: options.logLevel };
const { stderr, stdout } = await this.pm2.run(
{ tag: 'PM2 Restart', raw: true, extendEnv: true, env },
'restart',
ECOSYSTEM_PATH,
'--update-env',
'--mini-list'
);
if (stderr) {
this.logger.error(stderr.toString());
process.exit(1);
} else if (stdout) {
this.logger.info(stdout.toString());
} else {
this.logger.info('Unraid API restarted');
}
const env = { LOG_LEVEL: options.logLevel?.toUpperCase() };
await this.nodemon.restart({ env });
this.logger.info('Unraid API restarted');
} catch (error) {
if (error instanceof Error) {
this.logger.error(error.message);

View File

@@ -3,46 +3,23 @@ import { Command, CommandRunner, Option } from 'nest-commander';
import type { LogLevel } from '@app/core/log.js';
import type { LogLevelOptions } from '@app/unraid-api/cli/restart.command.js';
import { levels } from '@app/core/log.js';
import { ECOSYSTEM_PATH, LOG_LEVEL } from '@app/environment.js';
import { LOG_LEVEL } from '@app/environment.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js';
import { parseLogLevelOption } from '@app/unraid-api/cli/restart.command.js';
@Command({ name: 'start', description: 'Start the Unraid API' })
export class StartCommand extends CommandRunner {
constructor(
private readonly logger: LogService,
private readonly pm2: PM2Service
private readonly nodemon: NodemonService
) {
super();
}
async cleanupPM2State() {
await this.pm2.ensurePm2Dependencies();
await this.pm2.run({ tag: 'PM2 Stop' }, 'stop', ECOSYSTEM_PATH);
await this.pm2.run({ tag: 'PM2 Update' }, 'update');
await this.pm2.deleteDump();
await this.pm2.run({ tag: 'PM2 Delete' }, 'delete', ECOSYSTEM_PATH);
}
async run(_: string[], options: LogLevelOptions): Promise<void> {
this.logger.info('Starting the Unraid API');
await this.cleanupPM2State();
const env = { LOG_LEVEL: options.logLevel };
const { stderr, stdout } = await this.pm2.run(
{ tag: 'PM2 Start', raw: true, extendEnv: true, env },
'start',
ECOSYSTEM_PATH,
'--update-env',
'--mini-list'
);
if (stdout) {
this.logger.log(stdout.toString());
}
if (stderr) {
this.logger.error(stderr.toString());
process.exit(1);
}
await this.nodemon.start({ env: { LOG_LEVEL: options.logLevel?.toUpperCase() } });
}
@Option({

Some files were not shown because too many files have changed in this diff Show More