Compare commits

...

41 Commits

Author SHA1 Message Date
Pujit Mehrotra
73135b8328 fix: unraid-connect plugin not loaded when connect is installed (#1856)
Previously, api plugins could only be installed as `peerDependencies` in
the api. This change allows them to be listed as `dependencies` as well.
This makes plugin loading (eg loading Connect) more robust.

Tests:

- [x] Re-logging on 7.3.0-beta.0.5
2025-12-19 15:06:52 -05:00
github-actions[bot]
e42d619b6d chore(main): release 4.29.1 (#1854)
🤖 I have created a release *beep* *boop*
---


## [4.29.1](https://github.com/unraid/api/compare/v4.29.0...v4.29.1)
(2025-12-19)


### Bug Fixes

* revert replace docker overview table with web component (7.3+)
([#1853](https://github.com/unraid/api/issues/1853))
([560db88](560db880cc))

---
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-12-19 12:19:44 -05:00
Eli Bosley
560db880cc fix: revert replace docker overview table with web component (7.3+) (#1853)
Reverts unraid/api#1764
2025-12-19 12:12:41 -05:00
github-actions[bot]
d6055f102b chore(main): release 4.29.0 (#1849)
🤖 I have created a release *beep* *boop*
---


## [4.29.0](https://github.com/unraid/api/compare/v4.28.2...v4.29.0)
(2025-12-19)


### Features

* replace docker overview table with web component (7.3+)
([#1764](https://github.com/unraid/api/issues/1764))
([277ac42](277ac42046))


### Bug Fixes

* handle race condition between guid loading and license check
([#1847](https://github.com/unraid/api/issues/1847))
([8b155d1](8b155d1f1c))
* resolve issue with "Continue" button when updating
([#1852](https://github.com/unraid/api/issues/1852))
([d099e75](d099e7521d))
* update myservers config references to connect config references
([#1810](https://github.com/unraid/api/issues/1810))
([e1e3ea7](e1e3ea7eb6))

---
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-12-19 11:53:48 -05:00
Eli Bosley
d099e7521d fix: resolve issue with "Continue" button when updating (#1852)
- Replaced BrandLoading with BrandButton in UpdateOs component for
better user interaction.
- Updated test cases to reflect changes in rendering logic, ensuring the
account button is displayed when no reboot is pending.
- Added functionality to navigate to account update when the button is
clicked.
- Introduced WEBGUI_REDIRECT URL for handling update installations in
the store logic.
2025-12-19 11:44:19 -05:00
Pujit Mehrotra
bb9b539732 chore: fix local plugin builds & docs (#1851)
Raised by [MitchellThompkins](https://github.com/MitchellThompkins) in
#1848

- Documents how to use Docker to build a local Connect plugin
- Local Plugin flow will now build workspace packages before proceeding
with plugin infra + build
- Removes recommendation to run `pnpm build:watch` from root, as this
race conditions and build cache issues.
- Makes `pnpm dev` from root parallel, preventing servers from blocking
each other.

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

## Summary by CodeRabbit

* **Documentation**
* Updated development workflow documentation to emphasize Docker-based
plugin builds
* Restructured development modes into three workflows: local Docker
builds, direct deployment, and development servers
  * Updated build and deployment instructions

* **Chores**
  * Modified dev script for parallel execution
  * Refactored build scripts with improved dependency handling

<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-12-18 16:33:37 -05:00
Pujit Mehrotra
0e44e73bf7 chore(web): mv predev call to prebuild step (#1850)
Fixes #1848

## Background

The `build:dev` script is used for the `unraid:deploy` workflow, and it
implicitly triggered the `predev` script to build the `unraid-ui`
package as needed.

`web` builds depend on `unraid-ui`. In the past, `unraid-ui` was built
during `pnpm install` via a `prepare` step in its `package.json`.
However, this approach doesn't ensure that `web` builds correctly; stale
`unraid-ui` builds could cause false-positives.

So, instead of doing that, we call `predev` from `prebuild`, ensuring
that both local builds and the `unraid:deploy` workflow lazily get the
correct build of `unraid-ui`.
2025-12-18 11:50:17 -05:00
Pujit Mehrotra
277ac42046 feat: replace docker overview table with web component (7.3+) (#1764)
## Summary

Introduces a new Vue-based Docker container management interface
replacing the legacy webgui table.

### Container Management
- Start, stop, pause, resume, and remove containers via GraphQL
mutations
- Bulk actions for managing multiple containers at once
- Container update detection with one-click updates
- Real-time container statistics (CPU, memory, I/O)

### Organization & Navigation
- Folder-based container organization with drag-and-drop support
- Accessible reordering via keyboard controls
- Customizable column visibility with persistent preferences
- Column resizing and reordering
- Filtering and search across container properties

### Auto-start Configuration
- Dedicated autostart view with delay configuration
- Drag-and-drop reordering of start/stop sequences

### Logs & Console
- Integrated log viewer with filtering and download
- Persistent console sessions with shell selection
- Slideover panel for quick access

### Networking
- Port conflict detection and alerts
- Tailscale integration for container networking status
- LAN IP and port information display

### Additional Features
- Orphaned container detection and cleanup
- Template mapping management
- Critical notifications system
- WebUI visit links with Tailscale support

<sub>PR Summary by Claude Opus 4.5</sub>
2025-12-18 11:11:05 -05:00
Pujit Mehrotra
e1e3ea7eb6 fix: update myservers config references to connect config references (#1810)
`myservers.cfg` no longer gets written to or read (except for migration
purposes), so it'd be better to read from the new values instead of
continuing to use the old ones @elibosley @Squidly271 .

unless i'm missing something! see #1805

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

* **New Features**
* Switches to a centralized remote-access configuration with a legacy
fallback and richer client-side handling.
* Optional GraphQL submission path for applying remote settings when
available.

* **Bug Fixes**
* Normalized boolean and port handling to prevent incorrect values
reaching the UI.
* Improved error handling and UI state restoration during save/apply
flows.

<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-12-18 10:34:06 -05:00
Pujit Mehrotra
8b155d1f1c fix: handle race condition between guid loading and license check (#1847)
On errors, a `console.error` message should be emitted from the browser
console, tagged `[ReplaceCheck.check]`.

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

## Summary by CodeRabbit

* **New Features**
* Added retry capability for license eligibility checks with a
contextual "Retry" button that appears in error states.

* **Bug Fixes**
* Fixed license status initialization to correctly default to ready
state.
* Enhanced error messaging with specific messages for different failure
scenarios (missing credentials, access denied, server errors).
* Improved status display handling to prevent potential runtime errors.

* **Localization**
  * Added "Retry" text translation.

* **Tests**
  * Updated and added tests for reset functionality and error handling.

<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-12-18 08:51:01 -05:00
github-actions[bot]
d13a1f6174 chore(main): release 4.28.2 (#1845)
🤖 I have created a release *beep* *boop*
---


## [4.28.2](https://github.com/unraid/api/compare/v4.28.1...v4.28.2)
(2025-12-16)


### Bug Fixes

* **api:** timeout on startup on 7.0 and 6.12
([#1844](https://github.com/unraid/api/issues/1844))
([e243ae8](e243ae836e))

---
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-12-16 11:47:31 -05:00
Eli Bosley
e243ae836e fix(api): timeout on startup on 7.0 and 6.12 (#1844)
Updated the total startup budget, bootstrap reserved time, and maximum
operation timeout values to enhance API startup reliability. The total
startup budget is now set to 30 seconds, with 20 seconds reserved for
bootstrap and a maximum operation timeout of 5 seconds.
2025-12-16 11:37:42 -05:00
github-actions[bot]
01a63fd86b chore(main): release 4.28.1 (#1843)
🤖 I have created a release *beep* *boop*
---


## [4.28.1](https://github.com/unraid/api/compare/v4.28.0...v4.28.1)
(2025-12-16)


### Bug Fixes

* empty commit to release as 4.28.1
([df78608](df78608457))

---
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-12-16 11:02:11 -05:00
Eli Bosley
df78608457 fix: empty commit to release as 4.28.1 2025-12-16 10:35:12 -05:00
github-actions[bot]
ca3bee4ad5 chore(main): release 4.28.0 (#1807)
🤖 I have created a release *beep* *boop*
---


## [4.28.0](https://github.com/unraid/api/compare/v4.27.2...v4.28.0)
(2025-12-15)


### Features

* when cancelling OS upgrade, delete any plugin files that were d…
([#1823](https://github.com/unraid/api/issues/1823))
([74df938](74df938e45))


### Bug Fixes

* change keyfile watcher to poll instead of inotify on FAT32
([#1820](https://github.com/unraid/api/issues/1820))
([23a7120](23a71207dd))
* enhance dark mode support in theme handling
([#1808](https://github.com/unraid/api/issues/1808))
([d6e2939](d6e29395c8))
* improve API startup reliability with timeout budget tracking
([#1824](https://github.com/unraid/api/issues/1824))
([51f025b](51f025b105))
* PHP Warnings in Management Settings
([#1805](https://github.com/unraid/api/issues/1805))
([832e9d0](832e9d04f2))
* update @unraid/shared-callbacks to version 3.0.0
([#1831](https://github.com/unraid/api/issues/1831))
([73b2ce3](73b2ce360c))

---
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-12-15 16:35:33 -05:00
Jandrop
024ae69343 fix(ups): convert estimatedRuntime from minutes to seconds (#1822)
## Summary

Fixes the `estimatedRuntime` field in the UPS GraphQL query to return
values in **seconds** as documented, instead of **minutes**.

## Problem

The `TIMELEFT` value from `apcupsd` is returned in minutes (e.g., `6.0`
for 6 minutes), but the GraphQL schema documentation states:

> Estimated runtime remaining on battery power. **Unit: seconds**.
Example: 3600 means 1 hour of runtime remaining

Currently, the API returns `6` (minutes) instead of `360` (seconds).

## Solution

Convert the `TIMELEFT` value from minutes to seconds by multiplying by
60:

```typescript
// Before
estimatedRuntime: parseInt(upsData.TIMELEFT || '3600', 10),

// After
estimatedRuntime: Math.round(parseFloat(upsData.TIMELEFT || '60') * 60),
```

## Testing

1. Query `upsDevices` before the fix → `estimatedRuntime: 6` (incorrect
- minutes)
2. Query `upsDevices` after the fix → `estimatedRuntime: 360` (correct -
seconds)

Tested on Unraid server with APC UPS connected via apcupsd.

## Related Issues

Fixes #1821

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

* **Bug Fixes**
* Corrected UPS battery runtime calculation to interpret provider
TIMELEFT as minutes, convert to seconds, and use a sensible default when
missing—improves displayed battery runtime accuracy.
* **Tests**
* Updated UPS test fixtures to match the minute-based TIMELEFT format
used by the UPS provider.

<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-12-15 16:28:33 -05:00
Pujit Mehrotra
99ce88bfdc fix(plg): explicitly stop an existing api before installation (#1841)
Necessary for "clean" upgrades to api orchestration (eg changing how the
api is daemonized).

Prior to this, `rc.unraid-api start` would also restart a running api,
which sufficed for application updates, but is insufficient for
orchestration updates.

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved update reliability by ensuring services are properly stopped
before system modifications occur.

<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-12-15 16:27:51 -05:00
Eli Bosley
73b2ce360c fix: update @unraid/shared-callbacks to version 3.0.0 (#1831)
…on and pnpm-lock.yaml

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

* **New Features**
* Added a standalone redirect page that shows "Redirecting..." and
navigates automatically.

* **Improvements**
* Redirect preserves hash callback data, validates targets, and logs the
computed redirect.
  * Purchase callback origin changed to a different account host.
* Date/time formatting now tolerates missing or empty server formats
with safe fallbacks.
  * Redirect page included in backup/restore.

* **Tests**
  * Added tests covering date/time formatting fallbacks.

* **Chores**
  * Dependency @unraid/shared-callbacks upgraded.
  * Removed multiple demo/debug pages and related test UIs.

<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-12-15 16:20:18 -05:00
Eli Bosley
d6e29395c8 fix: enhance dark mode support in theme handling (#1808)
- Added PHP logic to determine if the current theme is dark and set a
CSS variable accordingly.
- Introduced a new function to retrieve the dark mode state from the CSS
variable in JavaScript.
- Updated the theme store to initialize dark mode based on the CSS
variable, ensuring consistent theme application across the application.

This improves user experience by ensuring the correct theme is applied
based on user preferences.

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

* **New Features**
* Server-persisted theme mutation and client action to fetch/apply
themes

* **Improvements**
* Safer theme parsing and multi-source initialization (CSS var, storage,
cookie, server)
* Robust dark-mode detection and propagation across document, modals and
teleport containers
* Responsive banner/header gradient handling with tunable CSS variables
and fallbacks

* **Tests**
* Expanded tests for theme flows, dark-mode detection, banner gradients
and manifest robustness

<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-12-15 12:52:47 -05:00
Eli Bosley
317e0fa307 Revert "feat!(api): swap daemonizer to nodemon instead of PM2" (#1836)
Reverts unraid/api#1798
2025-12-12 18:32:35 -05:00
renovate[bot]
331c913329 chore(deps): update actions/checkout action to v6 (#1832)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/checkout](https://redirect.github.com/actions/checkout) |
action | major | `v5` -> `v6` |

---

### Release Notes

<details>
<summary>actions/checkout (actions/checkout)</summary>

### [`v6`](https://redirect.github.com/actions/checkout/compare/v5...v6)

[Compare
Source](https://redirect.github.com/actions/checkout/compare/v5...v6)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/unraid/api).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi40Mi4yIiwidXBkYXRlZEluVmVyIjoiNDIuNDIuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-12 15:26:20 -05:00
renovate[bot]
abf3461348 chore(deps): update actions/setup-node action to v6 (#1833)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/setup-node](https://redirect.github.com/actions/setup-node) |
action | major | `v5` -> `v6` |
| [actions/setup-node](https://redirect.github.com/actions/setup-node) |
action | major | `v4` -> `v6` |

---

### Release Notes

<details>
<summary>actions/setup-node (actions/setup-node)</summary>

###
[`v6`](https://redirect.github.com/actions/setup-node/compare/v5...v6)

[Compare
Source](https://redirect.github.com/actions/setup-node/compare/v5...v6)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/unraid/api).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi40Mi4yIiwidXBkYXRlZEluVmVyIjoiNDIuNDIuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-12 15:26:09 -05:00
renovate[bot]
079a09ec90 chore(deps): update github artifact actions (major) (#1834)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
|
[actions/download-artifact](https://redirect.github.com/actions/download-artifact)
| action | major | `v5` -> `v7` |
|
[actions/upload-artifact](https://redirect.github.com/actions/upload-artifact)
| action | major | `v4` -> `v6` |

---

### Release Notes

<details>
<summary>actions/download-artifact (actions/download-artifact)</summary>

###
[`v7`](https://redirect.github.com/actions/download-artifact/compare/v6...v7)

[Compare
Source](https://redirect.github.com/actions/download-artifact/compare/v6...v7)

###
[`v6`](https://redirect.github.com/actions/download-artifact/compare/v5...v6)

[Compare
Source](https://redirect.github.com/actions/download-artifact/compare/v5...v6)

</details>

<details>
<summary>actions/upload-artifact (actions/upload-artifact)</summary>

###
[`v6`](https://redirect.github.com/actions/upload-artifact/compare/v5...v6)

[Compare
Source](https://redirect.github.com/actions/upload-artifact/compare/v5...v6)

###
[`v5`](https://redirect.github.com/actions/upload-artifact/compare/v4...v5)

[Compare
Source](https://redirect.github.com/actions/upload-artifact/compare/v4...v5)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/unraid/api).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi40Mi4yIiwidXBkYXRlZEluVmVyIjoiNDIuNDIuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-12 15:25:51 -05:00
renovate[bot]
e4223ab5a1 chore(deps): update github/codeql-action action to v4 (#1835)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
|
[github/codeql-action](https://redirect.github.com/github/codeql-action)
| action | major | `v3` -> `v4` |

---

### Release Notes

<details>
<summary>github/codeql-action (github/codeql-action)</summary>

###
[`v4`](https://redirect.github.com/github/codeql-action/compare/v3...v4)

[Compare
Source](https://redirect.github.com/github/codeql-action/compare/v3...v4)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/unraid/api).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi40Mi4yIiwidXBkYXRlZEluVmVyIjoiNDIuNDIuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-12 15:25:41 -05:00
Eli Bosley
6f54206a4a feat!(api): swap daemonizer to nodemon instead of PM2 (#1798)
## Summary
- ensure the API release build copies nodemon.json into the packaged
artifacts so nodemon-managed deployments have the config available

## Testing
- pnpm --filter @unraid/api lint:fix

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

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

* **New Features**
  * Switch to Nodemon for process management and updated CLI to use it.
  * Added boot-time diagnostic logging and direct log-file writing.
  * New per-package CPU telemetry and topology exposure.

* **Bug Fixes**
  * More reliable process health detection and lifecycle handling.
  * Improved log handling and startup robustness.

* **Chores**
  * Removed PM2-related components and tests; migrated to Nodemon.
  * Consolidated pub/sub channel usage and bumped internal version.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Pujit Mehrotra <pujit@lime-technology.com>
2025-12-11 15:42:05 -05:00
Eli Bosley
e35bcc72f1 chore: Handle build number generation on forks (#1829)
## Summary
- guard build number generation to the main repository and allow
failures without stopping the workflow
- add a fallback build number derived from the GitHub run number when
the tag-based number cannot be created

## Testing
- not run (workflow-only change)


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

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

## Summary by CodeRabbit

* **Chores**
* Improved build pipeline reliability with enhanced fallback mechanisms
to ensure consistent artifact generation.

<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-12-09 17:34:45 -05:00
ljm42
74df938e45 feat: when cancelling OS upgrade, delete any plugin files that were d… (#1823)
…ownloaded as part of the upgrade

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved cleanup of temporary plugin configuration files during update
cancellation operations.

<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-12-08 14:18:34 -07:00
Pujit Mehrotra
51f025b105 fix: improve API startup reliability with timeout budget tracking (#1824)
## Summary

- Add startup budget tracking to prevent silent hangs during API boot
- Add timeout wrappers around startup operations with graceful
degradation
- Add detailed logging for startup progress and failures

## Background

A user reported being unable to start their array on v7.2-beta.1 due to
the API failing to start. The root cause was a leftover
`dynamix.my.servers` folder from a previously uninstalled Connect
plugin. The API would hang during startup with no error messages, and
PM2 would eventually kill it after 15 seconds with no diagnostic
information.

**Original syslog:**
```
Aug 2 11:55:48 Vault root: Starting Unraid API service...
Aug 2 11:55:48 Vault root: Backup file not found at '/boot/config/plugins/dynamix.my.servers/node_modules-for-v4.12.0.tar.xz'. Skipping restore.
Aug 2 11:55:52 Vault root: Starting the Unraid API
[API never completes - PM2 times out waiting for 'ready' signal]
```

## Solution

### Startup Budget Tracking

Instead of fixed timeouts per operation (which could exceed PM2's
15-second limit in aggregate), we now track a total startup budget:

- **Total budget:** 13 seconds (2 seconds before PM2's 15-second kill
timeout)
- **Bootstrap reserve:** 8 seconds reserved for NestJS bootstrap
- **Per-operation max:** 2 seconds for pre-bootstrap operations

The `StartupBudget` class dynamically calculates timeouts based on
remaining time, ensuring we never exceed PM2's limit and always provide
clear timeout messages.

### Graceful Degradation

Non-critical operations now fail gracefully with warnings instead of
crashing:
- `loadStateFiles()` - continues with default state
- `loadRegistrationKey()` - continues without registration key  
- `loadDynamixConfig()` - continues with default config
- `StateManager` - continues without file watching
- `setupRegistrationKeyWatch()` - continues without key watching

Critical operations still fail fast:
- Config directory creation
- NestJS server bootstrap

### Improved Logging

Each startup phase now logs its completion, making it easy to identify
where hangs occur:
```
Config directory ready
Emhttp state loaded
Registration key loaded
Dynamix config loaded
State manager initialized
Registration key watch active
Bootstrapping NestJS server (budget: 11234ms)...
Startup complete in 1766ms
```

## Test plan

- [x] Verify API starts normally with all startup logs visible
- [x] Verify startup completes within PM2's 15-second timeout
- [ ] Test with missing/corrupted config files to verify graceful
degradation
- [ ] Verify timeout messages appear before PM2 kills the process

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 14:59:30 -05:00
Pujit Mehrotra
23a71207dd fix: change keyfile watcher to poll instead of inotify on FAT32 (#1820)
## Summary

- Fixed GraphQL registration state not updating when license keys are
installed/upgraded
- Root cause: /boot/config is on FAT32 which doesn't support inotify -
the file watcher was silently failing

  ## Changes

  - Enable polling for key file watcher (required for FAT32 filesystem)
- Add retry logic to reload var.ini after key changes to handle emhttpd
update timing variation

  ## Test plan

  - Unit tests for retry logic (will run in CI)
- Manual test on Unraid: install/upgrade license key, verify GraphQL
returns updated state within ~8 seconds

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

* **Tests**
* Added a comprehensive test suite covering retry behavior, exponential
backoff timing, and various registration-change scenarios.

* **Refactor**
* Switched registration key monitoring to a polling-based watcher with
an exponential-backoff retry for config reloads; added event logging and
improved retry/stopping behavior to make state updates more reliable and
observable.

<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-12-08 11:50:04 -05:00
Squidly271
832e9d04f2 fix: PHP Warnings in Management Settings (#1805)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Enhanced remote access configuration handling to gracefully manage
missing or undefined parameter values.
* Improved overall system stability through safer default handling of
optional settings that may not be present.

<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-24 12:51:17 -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
158 changed files with 4205 additions and 3493 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

View File

@@ -32,13 +32,13 @@ jobs:
name: Build API
runs-on: ubuntu-latest
outputs:
build_number: ${{ steps.buildnumber.outputs.build_number }}
build_number: ${{ steps.buildnumber.outputs.build_number || steps.fallback_buildnumber.outputs.build_number }}
defaults:
run:
working-directory: api
steps:
- name: Checkout repo
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
fetch-depth: 0
@@ -49,7 +49,7 @@ jobs:
run_install: false
- name: Install Node
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
@@ -81,18 +81,25 @@ jobs:
- name: Generate build number
id: buildnumber
if: github.repository == 'unraid/api'
continue-on-error: true
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: Generate fallback build number
id: fallback_buildnumber
if: steps.buildnumber.outcome != 'success'
run: echo "build_number=${GITHUB_RUN_NUMBER}" >> $GITHUB_OUTPUT
- 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
uses: actions/upload-artifact@v6
with:
name: unraid-api
path: ${{ github.workspace }}/api/deploy/unraid-api.tgz
@@ -105,7 +112,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
@@ -115,7 +122,7 @@ jobs:
run_install: false
- name: Install Node
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
@@ -138,7 +145,7 @@ jobs:
run: pnpm run build:wc
- name: Upload Artifact to Github
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: unraid-wc-ui
path: unraid-ui/dist-wc/
@@ -151,7 +158,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
@@ -169,7 +176,7 @@ jobs:
run_install: false
- name: Install Node
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
@@ -194,7 +201,7 @@ jobs:
run: pnpm run build
- name: Upload build to Github artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: unraid-wc-rich
path: web/dist

View File

@@ -56,7 +56,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
@@ -67,7 +67,7 @@ jobs:
run_install: false
- name: Install Node
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
@@ -78,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
@@ -87,19 +101,19 @@ jobs:
pnpm install --frozen-lockfile --filter @unraid/connect-plugin
- name: Download Unraid UI Components
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
name: unraid-wc-ui
path: ${{ github.workspace }}/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/uui
merge-multiple: true
- name: Download Unraid Web Components
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
pattern: unraid-wc-rich
path: ${{ github.workspace }}/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone
merge-multiple: true
- name: Download Unraid API
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
name: unraid-api
path: ${{ github.workspace }}/plugin/api/
@@ -128,7 +142,7 @@ jobs:
fi
- name: Upload to GHA
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: unraid-plugin-${{ github.run_id }}-${{ inputs.RELEASE_TAG }}
path: plugin/deploy/

View File

@@ -24,17 +24,17 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
config-file: ./.github/codeql/codeql-config.yml
queries: +security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4

View File

@@ -20,7 +20,7 @@ jobs:
name: Deploy Storybook
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -28,7 +28,7 @@ jobs:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: 'pnpm'

View File

@@ -31,14 +31,14 @@ jobs:
release_notes: ${{ steps.generate_notes.outputs.release_notes }}
steps:
- name: Checkout repo
uses: actions/checkout@v5
uses: actions/checkout@v6
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
uses: actions/setup-node@v6
with:
node-version: '20'

View File

@@ -23,7 +23,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -33,7 +33,7 @@ jobs:
run_install: false
- name: Install Node
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
@@ -177,7 +177,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0

View File

@@ -31,14 +31,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v5
uses: actions/checkout@v6
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
uses: actions/setup-node@v6
with:
node-version: '20'
@@ -167,7 +167,7 @@ jobs:
release_notes: ${{ needs.generate-release-notes.outputs.release_notes }}
steps:
- name: Checkout repo
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ inputs.target_commitish || github.ref }}
fetch-depth: 0

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install Apollo Rover CLI
run: |

View File

@@ -28,7 +28,7 @@ jobs:
with:
latest: true
prerelease: false
- uses: actions/setup-node@v5
- uses: actions/setup-node@v6
with:
node-version: 22.19.0
- run: |

View File

@@ -1 +1 @@
{".":"4.26.2"}
{".":"4.29.1"}

View File

@@ -63,15 +63,6 @@
*/
.unapi {
--color-alpha: #1c1b1b;
--color-beta: #f2f2f2;
--color-gamma: #999999;
--color-gamma-opaque: rgba(153, 153, 153, 0.5);
--color-customgradient-start: rgba(242, 242, 242, 0);
--color-customgradient-end: rgba(242, 242, 242, 0.85);
--shadow-beta: 0 25px 50px -12px rgba(242, 242, 242, 0.15);
--ring-offset-shadow: 0 0 var(--color-beta);
--ring-shadow: 0 0 var(--color-beta);
}
.unapi button:not(:disabled),

View File

@@ -6,92 +6,63 @@
/* Default/White Theme */
.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);
--color-border: #383735;
--color-alpha: #ff8c2f;
--color-beta: #1c1b1b;
--color-gamma: #ffffff;
--color-gamma-opaque: rgba(255, 255, 255, 0.3);
--color-header-gradient-start: color-mix(in srgb, var(--header-background-color) 0%, transparent);
--color-header-gradient-end: color-mix(in srgb, var(--header-background-color) 100%, transparent);
--shadow-beta: 0 25px 50px -12px color-mix(in srgb, var(--color-beta) 15%, transparent);
--ring-offset-shadow: 0 0 var(--color-beta);
--ring-shadow: 0 0 var(--color-beta);
}
/* 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);
--color-border: #e0e0e0;
--color-alpha: #ff8c2f;
--color-beta: #f2f2f2;
--color-gamma: #1c1b1b;
--color-gamma-opaque: rgba(28, 27, 27, 0.3);
--color-header-gradient-start: color-mix(in srgb, var(--header-background-color) 0%, transparent);
--color-header-gradient-end: color-mix(in srgb, var(--header-background-color) 100%, transparent);
--shadow-beta: 0 25px 50px -12px color-mix(in srgb, var(--color-beta) 15%, transparent);
--ring-offset-shadow: 0 0 var(--color-beta);
--ring-shadow: 0 0 var(--color-beta);
}
/* 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,
.Theme--gray.dark {
--color-border: #383735;
--color-alpha: #ff8c2f;
--color-beta: #383735;
--color-gamma: #ffffff;
--color-gamma-opaque: rgba(255, 255, 255, 0.3);
--color-header-gradient-start: color-mix(in srgb, var(--header-background-color) 0%, transparent);
--color-header-gradient-end: color-mix(in srgb, var(--header-background-color) 100%, transparent);
--shadow-beta: 0 25px 50px -12px color-mix(in srgb, var(--color-beta) 15%, transparent);
--ring-offset-shadow: 0 0 var(--color-beta);
--ring-shadow: 0 0 var(--color-beta);
}
/* 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);
--color-border: #5a8bb8;
--color-alpha: #ff8c2f;
--color-beta: #e7f2f8;
--color-gamma: #336699;
--color-gamma-opaque: rgba(51, 102, 153, 0.3);
--color-header-gradient-start: color-mix(in srgb, var(--header-background-color) 0%, transparent);
--color-header-gradient-end: color-mix(in srgb, var(--header-background-color) 100%, transparent);
--shadow-beta: 0 25px 50px -12px color-mix(in srgb, var(--color-beta) 15%, transparent);
--ring-offset-shadow: 0 0 var(--color-beta);
--ring-shadow: 0 0 var(--color-beta);
}
/* Dark Mode Overrides */
.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,
.has-custom-header-bg.Theme--black,
.has-custom-header-bg.Theme--black.dark,
.has-custom-header-bg.Theme--white,
.has-custom-header-bg.Theme--white.dark,
.has-custom-header-bg.Theme--gray,
.has-custom-header-bg.Theme--azure {
--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

@@ -1,5 +1,85 @@
# Changelog
## [4.29.1](https://github.com/unraid/api/compare/v4.29.0...v4.29.1) (2025-12-19)
### Bug Fixes
* revert replace docker overview table with web component (7.3+) ([#1853](https://github.com/unraid/api/issues/1853)) ([560db88](https://github.com/unraid/api/commit/560db880cc138324f9ff8753f7209b683a84c045))
## [4.29.0](https://github.com/unraid/api/compare/v4.28.2...v4.29.0) (2025-12-19)
### Features
* replace docker overview table with web component (7.3+) ([#1764](https://github.com/unraid/api/issues/1764)) ([277ac42](https://github.com/unraid/api/commit/277ac420464379e7ee6739c4530271caf7717503))
### Bug Fixes
* handle race condition between guid loading and license check ([#1847](https://github.com/unraid/api/issues/1847)) ([8b155d1](https://github.com/unraid/api/commit/8b155d1f1c99bb19efbc9614e000d852e9f0c12d))
* resolve issue with "Continue" button when updating ([#1852](https://github.com/unraid/api/issues/1852)) ([d099e75](https://github.com/unraid/api/commit/d099e7521d2062bb9cf84f340e46b169dd2492c5))
* update myservers config references to connect config references ([#1810](https://github.com/unraid/api/issues/1810)) ([e1e3ea7](https://github.com/unraid/api/commit/e1e3ea7eb68cc6840f67a8aec937fd3740e75b28))
## [4.28.2](https://github.com/unraid/api/compare/v4.28.1...v4.28.2) (2025-12-16)
### Bug Fixes
* **api:** timeout on startup on 7.0 and 6.12 ([#1844](https://github.com/unraid/api/issues/1844)) ([e243ae8](https://github.com/unraid/api/commit/e243ae836ec1a7fde37dceeb106cc693b20ec82b))
## [4.28.1](https://github.com/unraid/api/compare/v4.28.0...v4.28.1) (2025-12-16)
### Bug Fixes
* empty commit to release as 4.28.1 ([df78608](https://github.com/unraid/api/commit/df786084572eefb82e086c15939b50cc08b9db10))
## [4.28.0](https://github.com/unraid/api/compare/v4.27.2...v4.28.0) (2025-12-15)
### Features
* when cancelling OS upgrade, delete any plugin files that were d… ([#1823](https://github.com/unraid/api/issues/1823)) ([74df938](https://github.com/unraid/api/commit/74df938e450def2ee3e2864d4b928f53a68e9eb8))
### Bug Fixes
* change keyfile watcher to poll instead of inotify on FAT32 ([#1820](https://github.com/unraid/api/issues/1820)) ([23a7120](https://github.com/unraid/api/commit/23a71207ddde221867562b722f4e65a5fc4dd744))
* enhance dark mode support in theme handling ([#1808](https://github.com/unraid/api/issues/1808)) ([d6e2939](https://github.com/unraid/api/commit/d6e29395c8a8b0215d4f5945775de7fa358d06ec))
* improve API startup reliability with timeout budget tracking ([#1824](https://github.com/unraid/api/issues/1824)) ([51f025b](https://github.com/unraid/api/commit/51f025b105487b178048afaabf46b260c4a7f9c1))
* PHP Warnings in Management Settings ([#1805](https://github.com/unraid/api/issues/1805)) ([832e9d0](https://github.com/unraid/api/commit/832e9d04f207d3ec612c98500a2ffc86659264e5))
* **plg:** explicitly stop an existing api before installation ([#1841](https://github.com/unraid/api/issues/1841)) ([99ce88b](https://github.com/unraid/api/commit/99ce88bfdc0a7f020c42f2fe0c6a0f4e32ac8f5a))
* update @unraid/shared-callbacks to version 3.0.0 ([#1831](https://github.com/unraid/api/issues/1831)) ([73b2ce3](https://github.com/unraid/api/commit/73b2ce360c66cd9bedc138a5f8306af04b6bde77))
* **ups:** convert estimatedRuntime from minutes to seconds ([#1822](https://github.com/unraid/api/issues/1822)) ([024ae69](https://github.com/unraid/api/commit/024ae69343bad5a3cbc19f80e357082e9b2efc1e))
## [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)

View File

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

View File

@@ -62,15 +62,18 @@ To build all packages in the monorepo:
pnpm build
```
### Watch Mode Building
### Plugin Building (Docker Required)
For continuous building during development:
The plugin build requires Docker. This command automatically builds all dependencies (API, web) before starting Docker:
```bash
pnpm build:watch
cd plugin
pnpm run docker:build-and-run
# Then inside the container:
pnpm build
```
This is useful when you want to see your changes reflected without manually rebuilding. This will also allow you to install a local plugin to test your changes.
This serves the plugin at `http://YOUR_IP:5858/` for installation on your Unraid server.
### Package-Specific Building

View File

@@ -7,7 +7,7 @@
"cwd": "/usr/local/unraid-api",
"exec_mode": "fork",
"wait_ready": true,
"listen_timeout": 15000,
"listen_timeout": 30000,
"max_restarts": 10,
"min_uptime": 10000,
"watch": false,

View File

@@ -944,6 +944,23 @@ input UpdateApiKeyInput {
permissions: [AddPermissionInput!]
}
"""Customization related mutations"""
type CustomizationMutations {
"""Update the UI theme (writes dynamix.cfg)"""
setTheme(
"""Theme to apply"""
theme: ThemeName!
): Theme!
}
"""The theme name"""
enum ThemeName {
azure
black
gray
white
}
"""
Parity check related mutations, WIP, response types and functionaliy will change
"""
@@ -1042,14 +1059,6 @@ type Theme {
headerSecondaryTextColor: String
}
"""The theme name"""
enum ThemeName {
azure
black
gray
white
}
type ExplicitStatusItem {
name: String!
updateStatus: UpdateStatus!
@@ -2449,6 +2458,7 @@ type Mutation {
vm: VmMutations!
parityCheck: ParityCheckMutations!
apiKey: ApiKeyMutations!
customization: CustomizationMutations!
rclone: RCloneMutations!
createDockerFolder(name: String!, parentId: String, childrenIds: [String!]): ResolvedOrganizerV1!
setDockerFolderChildren(folderId: String, childrenIds: [String!]!): ResolvedOrganizerV1!

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/api",
"version": "4.26.2",
"version": "4.29.1",
"main": "src/cli/index.ts",
"type": "module",
"corepack": {

View File

@@ -83,6 +83,10 @@ try {
if (parsedPackageJson.dependencies?.[dep]) {
delete parsedPackageJson.dependencies[dep];
}
// Also strip from peerDependencies (npm doesn't understand workspace: protocol)
if (parsedPackageJson.peerDependencies?.[dep]) {
delete parsedPackageJson.peerDependencies[dep];
}
});
}

View File

@@ -0,0 +1,151 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { StateFileKey } from '@app/store/types.js';
import { RegistrationType } from '@app/unraid-api/graph/resolvers/registration/registration.model.js';
// Mock the store module
vi.mock('@app/store/index.js', () => ({
store: {
dispatch: vi.fn(),
},
getters: {
emhttp: vi.fn(),
},
}));
// Mock the emhttp module
vi.mock('@app/store/modules/emhttp.js', () => ({
loadSingleStateFile: vi.fn((key) => ({ type: 'emhttp/load-single-state-file', payload: key })),
}));
// Mock the registration module
vi.mock('@app/store/modules/registration.js', () => ({
loadRegistrationKey: vi.fn(() => ({ type: 'registration/load-registration-key' })),
}));
// Mock the logger
vi.mock('@app/core/log.js', () => ({
keyServerLogger: {
info: vi.fn(),
debug: vi.fn(),
},
}));
describe('reloadVarIniWithRetry', () => {
let store: { dispatch: ReturnType<typeof vi.fn> };
let getters: { emhttp: ReturnType<typeof vi.fn> };
let loadSingleStateFile: ReturnType<typeof vi.fn>;
beforeEach(async () => {
vi.useFakeTimers();
const storeModule = await import('@app/store/index.js');
const emhttpModule = await import('@app/store/modules/emhttp.js');
store = storeModule.store as unknown as typeof store;
getters = storeModule.getters as unknown as typeof getters;
loadSingleStateFile = emhttpModule.loadSingleStateFile as unknown as typeof loadSingleStateFile;
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it('returns early when registration state changes on first retry', async () => {
// Initial state is TRIAL
getters.emhttp
.mockReturnValueOnce({ var: { regTy: RegistrationType.TRIAL } }) // First call (beforeState)
.mockReturnValueOnce({ var: { regTy: RegistrationType.UNLEASHED } }); // After first reload
const { reloadVarIniWithRetry } = await import('@app/store/watch/registration-watch.js');
const promise = reloadVarIniWithRetry();
// Advance past the first delay (500ms)
await vi.advanceTimersByTimeAsync(500);
await promise;
// Should only dispatch once since state changed
expect(store.dispatch).toHaveBeenCalledTimes(1);
expect(loadSingleStateFile).toHaveBeenCalledWith(StateFileKey.var);
});
it('retries up to maxRetries when state does not change', async () => {
// State never changes
getters.emhttp.mockReturnValue({ var: { regTy: RegistrationType.TRIAL } });
const { reloadVarIniWithRetry } = await import('@app/store/watch/registration-watch.js');
const promise = reloadVarIniWithRetry(3);
// Advance through all retries: 500ms, 1000ms, 2000ms
await vi.advanceTimersByTimeAsync(500);
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(2000);
await promise;
// Should dispatch 3 times (maxRetries)
expect(store.dispatch).toHaveBeenCalledTimes(3);
});
it('stops retrying when state changes on second attempt', async () => {
getters.emhttp
.mockReturnValueOnce({ var: { regTy: RegistrationType.TRIAL } }) // beforeState
.mockReturnValueOnce({ var: { regTy: RegistrationType.TRIAL } }) // After first reload (no change)
.mockReturnValueOnce({ var: { regTy: RegistrationType.UNLEASHED } }); // After second reload (changed!)
const { reloadVarIniWithRetry } = await import('@app/store/watch/registration-watch.js');
const promise = reloadVarIniWithRetry(3);
// First retry
await vi.advanceTimersByTimeAsync(500);
// Second retry
await vi.advanceTimersByTimeAsync(1000);
await promise;
// Should dispatch twice - stopped after state changed
expect(store.dispatch).toHaveBeenCalledTimes(2);
});
it('handles undefined regTy gracefully', async () => {
getters.emhttp.mockReturnValue({ var: {} });
const { reloadVarIniWithRetry } = await import('@app/store/watch/registration-watch.js');
const promise = reloadVarIniWithRetry(1);
await vi.advanceTimersByTimeAsync(500);
await promise;
// Should still dispatch even with undefined regTy
expect(store.dispatch).toHaveBeenCalledTimes(1);
});
it('uses exponential backoff delays', async () => {
getters.emhttp.mockReturnValue({ var: { regTy: RegistrationType.TRIAL } });
const { reloadVarIniWithRetry } = await import('@app/store/watch/registration-watch.js');
const promise = reloadVarIniWithRetry(3);
// At 0ms, no dispatch yet
expect(store.dispatch).toHaveBeenCalledTimes(0);
// At 500ms, first dispatch
await vi.advanceTimersByTimeAsync(500);
expect(store.dispatch).toHaveBeenCalledTimes(1);
// At 1500ms (500 + 1000), second dispatch
await vi.advanceTimersByTimeAsync(1000);
expect(store.dispatch).toHaveBeenCalledTimes(2);
// At 3500ms (500 + 1000 + 2000), third dispatch
await vi.advanceTimersByTimeAsync(2000);
expect(store.dispatch).toHaveBeenCalledTimes(3);
await promise;
});
});

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

@@ -0,0 +1,231 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TimeoutBudget } from '@app/core/utils/misc/timeout-budget.js';
describe('TimeoutBudget', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('constructor', () => {
it('initializes with the given budget', () => {
const budget = new TimeoutBudget(10000);
expect(budget.remaining()).toBe(10000);
expect(budget.elapsed()).toBe(0);
});
});
describe('remaining', () => {
it('returns full budget immediately after construction', () => {
const budget = new TimeoutBudget(5000);
expect(budget.remaining()).toBe(5000);
});
it('decreases as time passes', () => {
const budget = new TimeoutBudget(5000);
vi.advanceTimersByTime(1000);
expect(budget.remaining()).toBe(4000);
vi.advanceTimersByTime(2000);
expect(budget.remaining()).toBe(2000);
});
it('never returns negative values', () => {
const budget = new TimeoutBudget(1000);
vi.advanceTimersByTime(5000); // Well past the budget
expect(budget.remaining()).toBe(0);
});
it('returns zero when budget is exactly exhausted', () => {
const budget = new TimeoutBudget(1000);
vi.advanceTimersByTime(1000);
expect(budget.remaining()).toBe(0);
});
});
describe('elapsed', () => {
it('returns zero immediately after construction', () => {
const budget = new TimeoutBudget(5000);
expect(budget.elapsed()).toBe(0);
});
it('increases as time passes', () => {
const budget = new TimeoutBudget(5000);
vi.advanceTimersByTime(1000);
expect(budget.elapsed()).toBe(1000);
vi.advanceTimersByTime(500);
expect(budget.elapsed()).toBe(1500);
});
it('continues increasing past the budget limit', () => {
const budget = new TimeoutBudget(1000);
vi.advanceTimersByTime(2000);
expect(budget.elapsed()).toBe(2000);
});
});
describe('getTimeout', () => {
it('returns maxMs when plenty of budget remains', () => {
const budget = new TimeoutBudget(10000);
expect(budget.getTimeout(2000)).toBe(2000);
});
it('returns maxMs when budget minus reserve is sufficient', () => {
const budget = new TimeoutBudget(10000);
expect(budget.getTimeout(2000, 5000)).toBe(2000);
});
it('caps timeout to available budget minus reserve', () => {
const budget = new TimeoutBudget(10000);
vi.advanceTimersByTime(5000); // 5000ms remaining
// Want 2000ms but reserve 4000ms, only 1000ms available
expect(budget.getTimeout(2000, 4000)).toBe(1000);
});
it('caps timeout to remaining budget when no reserve', () => {
const budget = new TimeoutBudget(1000);
vi.advanceTimersByTime(800); // 200ms remaining
expect(budget.getTimeout(500)).toBe(200);
});
it('returns minimum of 100ms even when budget is exhausted', () => {
const budget = new TimeoutBudget(1000);
vi.advanceTimersByTime(2000); // Budget exhausted
expect(budget.getTimeout(500)).toBe(100);
});
it('returns minimum of 100ms when reserve exceeds remaining', () => {
const budget = new TimeoutBudget(5000);
vi.advanceTimersByTime(4000); // 1000ms remaining
// Reserve 2000ms but only 1000ms remaining
expect(budget.getTimeout(500, 2000)).toBe(100);
});
it('uses default reserve of 0 when not specified', () => {
const budget = new TimeoutBudget(1000);
vi.advanceTimersByTime(500); // 500ms remaining
expect(budget.getTimeout(1000)).toBe(500); // Capped to remaining
});
});
describe('hasTimeFor', () => {
it('returns true when enough time remains', () => {
const budget = new TimeoutBudget(5000);
expect(budget.hasTimeFor(3000)).toBe(true);
});
it('returns true when exactly enough time remains', () => {
const budget = new TimeoutBudget(5000);
expect(budget.hasTimeFor(5000)).toBe(true);
});
it('returns false when not enough time remains', () => {
const budget = new TimeoutBudget(5000);
expect(budget.hasTimeFor(6000)).toBe(false);
});
it('accounts for elapsed time', () => {
const budget = new TimeoutBudget(5000);
vi.advanceTimersByTime(3000); // 2000ms remaining
expect(budget.hasTimeFor(2000)).toBe(true);
expect(budget.hasTimeFor(3000)).toBe(false);
});
it('returns false when budget is exhausted', () => {
const budget = new TimeoutBudget(1000);
vi.advanceTimersByTime(2000);
expect(budget.hasTimeFor(1)).toBe(false);
});
it('returns true for zero required time', () => {
const budget = new TimeoutBudget(1000);
vi.advanceTimersByTime(2000); // Budget exhausted
expect(budget.hasTimeFor(0)).toBe(true);
});
});
describe('integration scenarios', () => {
it('simulates a typical startup sequence', () => {
const budget = new TimeoutBudget(13000); // 13 second budget
const BOOTSTRAP_RESERVE = 8000;
const MAX_OP_TIMEOUT = 2000;
// First operation - should get full 2000ms
const op1Timeout = budget.getTimeout(MAX_OP_TIMEOUT, BOOTSTRAP_RESERVE);
expect(op1Timeout).toBe(2000);
// Simulate operation taking 500ms
vi.advanceTimersByTime(500);
// Second operation - still have plenty of budget
const op2Timeout = budget.getTimeout(MAX_OP_TIMEOUT, BOOTSTRAP_RESERVE);
expect(op2Timeout).toBe(2000);
// Simulate operation taking 1000ms
vi.advanceTimersByTime(1000);
// Third operation
const op3Timeout = budget.getTimeout(MAX_OP_TIMEOUT, BOOTSTRAP_RESERVE);
expect(op3Timeout).toBe(2000);
// Simulate slow operation taking 2000ms
vi.advanceTimersByTime(2000);
// Now 3500ms elapsed, 9500ms remaining
// After reserve, only 1500ms available - less than max
const op4Timeout = budget.getTimeout(MAX_OP_TIMEOUT, BOOTSTRAP_RESERVE);
expect(op4Timeout).toBe(1500);
// Simulate operation completing
vi.advanceTimersByTime(1000);
// Bootstrap phase - use all remaining time
const bootstrapTimeout = budget.remaining();
expect(bootstrapTimeout).toBe(8500);
expect(budget.hasTimeFor(8000)).toBe(true);
});
it('handles worst-case scenario where all operations timeout', () => {
const budget = new TimeoutBudget(13000);
const BOOTSTRAP_RESERVE = 8000;
const MAX_OP_TIMEOUT = 2000;
// Each operation times out at its limit
// Available for operations: 13000 - 8000 = 5000ms
// Op 1: gets 2000ms, times out
budget.getTimeout(MAX_OP_TIMEOUT, BOOTSTRAP_RESERVE);
vi.advanceTimersByTime(2000);
// Op 2: gets 2000ms, times out
budget.getTimeout(MAX_OP_TIMEOUT, BOOTSTRAP_RESERVE);
vi.advanceTimersByTime(2000);
// Op 3: only 1000ms available (5000 - 4000), times out
const op3Timeout = budget.getTimeout(MAX_OP_TIMEOUT, BOOTSTRAP_RESERVE);
expect(op3Timeout).toBe(1000);
vi.advanceTimersByTime(1000);
// Bootstrap: should still have 8000ms
expect(budget.remaining()).toBe(8000);
});
});
});

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { withTimeout } from '@app/core/utils/misc/with-timeout.js';
describe('withTimeout', () => {
it('resolves when promise completes before timeout', async () => {
const promise = Promise.resolve('success');
const result = await withTimeout(promise, 1000, 'testOp');
expect(result).toBe('success');
});
it('resolves with correct value for delayed promise within timeout', async () => {
const promise = new Promise<number>((resolve) => setTimeout(() => resolve(42), 50));
const result = await withTimeout(promise, 1000, 'testOp');
expect(result).toBe(42);
});
it('rejects when promise takes longer than timeout', async () => {
const promise = new Promise<string>((resolve) => setTimeout(() => resolve('late'), 500));
await expect(withTimeout(promise, 50, 'slowOp')).rejects.toThrow('slowOp timed out after 50ms');
});
it('includes operation name in timeout error message', async () => {
const promise = new Promise<void>(() => {}); // Never resolves
await expect(withTimeout(promise, 10, 'myCustomOperation')).rejects.toThrow(
'myCustomOperation timed out after 10ms'
);
});
it('propagates rejection from the original promise', async () => {
const promise = Promise.reject(new Error('original error'));
await expect(withTimeout(promise, 1000, 'testOp')).rejects.toThrow('original error');
});
it('resolves immediately for already-resolved promises', async () => {
const promise = Promise.resolve('immediate');
const start = Date.now();
const result = await withTimeout(promise, 1000, 'testOp');
const elapsed = Date.now() - start;
expect(result).toBe('immediate');
expect(elapsed).toBeLessThan(50); // Should be nearly instant
});
it('works with zero timeout (immediately times out for pending promises)', async () => {
const promise = new Promise<void>(() => {}); // Never resolves
await expect(withTimeout(promise, 0, 'zeroTimeout')).rejects.toThrow(
'zeroTimeout timed out after 0ms'
);
});
it('preserves the type of the resolved value', async () => {
interface TestType {
id: number;
name: string;
}
const testObj: TestType = { id: 1, name: 'test' };
const promise = Promise.resolve(testObj);
const result = await withTimeout(promise, 1000, 'testOp');
expect(result.id).toBe(1);
expect(result.name).toBe('test');
});
});

View File

@@ -0,0 +1,70 @@
/**
* Tracks remaining time budget to ensure we don't exceed external timeouts (e.g., PM2's listen_timeout).
*
* This class helps coordinate multiple async operations by:
* - Tracking elapsed time from construction
* - Calculating dynamic timeouts based on remaining budget
* - Reserving time for critical operations (like server bootstrap)
*
* @example
* ```typescript
* const budget = new TimeoutBudget(15000); // 15 second total budget
*
* // Each operation gets a timeout capped by remaining budget
* await withTimeout(loadConfig(), budget.getTimeout(2000, 8000), 'loadConfig');
* await withTimeout(loadState(), budget.getTimeout(2000, 8000), 'loadState');
*
* // Bootstrap gets all remaining time
* await withTimeout(bootstrap(), budget.remaining(), 'bootstrap');
*
* console.log(`Completed in ${budget.elapsed()}ms`);
* ```
*/
export class TimeoutBudget {
private startTime: number;
private budgetMs: number;
/**
* Creates a new startup budget tracker.
* @param budgetMs Total time budget in milliseconds
*/
constructor(budgetMs: number) {
this.startTime = Date.now();
this.budgetMs = budgetMs;
}
/**
* Returns remaining time in milliseconds.
* Never returns negative values.
*/
remaining(): number {
return Math.max(0, this.budgetMs - (Date.now() - this.startTime));
}
/**
* Returns elapsed time in milliseconds since construction.
*/
elapsed(): number {
return Date.now() - this.startTime;
}
/**
* Returns timeout for an operation, capped by remaining budget.
*
* @param maxMs Maximum timeout for this operation
* @param reserveMs Time to reserve for future operations (e.g., server bootstrap)
* @returns Timeout in milliseconds (minimum 100ms to avoid instant failures)
*/
getTimeout(maxMs: number, reserveMs: number = 0): number {
const available = this.remaining() - reserveMs;
return Math.max(100, Math.min(maxMs, available));
}
/**
* Checks if there's enough time remaining for an operation.
* @param requiredMs Time required in milliseconds
*/
hasTimeFor(requiredMs: number): boolean {
return this.remaining() >= requiredMs;
}
}

View File

@@ -0,0 +1,25 @@
/**
* Wraps a promise with a timeout to prevent hangs.
* If the operation takes longer than timeoutMs, it rejects with a timeout error.
*
* @param promise The promise to wrap with a timeout
* @param timeoutMs Maximum time in milliseconds before timing out
* @param operationName Name of the operation for the error message
* @returns The result of the promise if it completes in time
* @throws Error if the operation times out
*/
export const withTimeout = <T>(
promise: Promise<T>,
timeoutMs: number,
operationName: string
): Promise<T> => {
return Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error(`${operationName} timed out after ${timeoutMs}ms`)),
timeoutMs
)
),
]);
};

View File

@@ -15,6 +15,8 @@ import { WebSocket } from 'ws';
import { logger } from '@app/core/log.js';
import { fileExistsSync } from '@app/core/utils/files/file-exists.js';
import { TimeoutBudget } from '@app/core/utils/misc/timeout-budget.js';
import { withTimeout } from '@app/core/utils/misc/with-timeout.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';
@@ -28,13 +30,23 @@ import { StateManager } from '@app/store/watch/state-watch.js';
let server: NestFastifyApplication<RawServerDefault> | null = null;
// PM2 listen_timeout is 15 seconds (ecosystem.config.json)
// We use 13 seconds as our total budget to ensure our timeout triggers before PM2 kills us
const TOTAL_STARTUP_BUDGET_MS = 30_000;
// Reserve time for the NestJS bootstrap (the most critical and time-consuming operation)
const BOOTSTRAP_RESERVED_MS = 20_000;
// Maximum time for any single pre-bootstrap operation
const MAX_OPERATION_TIMEOUT_MS = 5_000;
const unlinkUnixPort = () => {
if (isNaN(parseInt(PORT, 10))) {
if (fileExistsSync(PORT)) unlinkSync(PORT);
}
};
export const viteNodeApp = async () => {
export const viteNodeApp = async (): Promise<NestFastifyApplication<RawServerDefault>> => {
const budget = new TimeoutBudget(TOTAL_STARTUP_BUDGET_MS);
try {
await import('json-bigint-patch');
environment.IS_MAIN_PROCESS = true;
@@ -42,15 +54,15 @@ export const viteNodeApp = async () => {
/**------------------------------------------------------------------------
* Attaching getServerIdentifier to globalThis
* getServerIdentifier is tightly coupled to the deprecated redux store,
* getServerIdentifier is tightly coupled to the deprecated redux store,
* which we don't want to share with other packages or plugins.
*
*
* At the same time, we need to use it in @unraid/shared as a building block,
* where it's used & available outside of NestJS's DI context.
*
* Attaching to globalThis is a temporary solution to avoid refactoring
*
* Attaching to globalThis is a temporary solution to avoid refactoring
* config sync & management outside of NestJS's DI context.
*
*
* Plugin authors should import getServerIdentifier from @unraid/shared instead,
* to avoid breaking changes to their code.
*------------------------------------------------------------------------**/
@@ -58,7 +70,18 @@ export const viteNodeApp = async () => {
logger.info('ENV %o', envVars);
logger.info('PATHS %o', store.getState().paths);
await mkdir(PATHS_CONFIG_MODULES, { recursive: true });
// Note: we use logger.info for checkpoints instead of a lower log level
// to ensure emission during an unraid server's boot,
// where the log level will be set to INFO by default.
// Create config directory
try {
await mkdir(PATHS_CONFIG_MODULES, { recursive: true });
logger.info('Config directory ready');
} catch (error) {
logger.error(error, 'Failed to create config directory');
throw error;
}
const cacheable = new CacheableLookup();
@@ -68,29 +91,73 @@ export const viteNodeApp = async () => {
cacheable.install(https.globalAgent);
// Load emhttp state into store
await store.dispatch(loadStateFiles());
try {
const timeout = budget.getTimeout(MAX_OPERATION_TIMEOUT_MS, BOOTSTRAP_RESERVED_MS);
await withTimeout(store.dispatch(loadStateFiles()), timeout, 'loadStateFiles');
logger.info('Emhttp state loaded');
} catch (error) {
logger.error(error, 'Failed to load emhttp state files');
logger.warn('Continuing with default state');
}
// Load initial registration key into store
await store.dispatch(loadRegistrationKey());
try {
const timeout = budget.getTimeout(MAX_OPERATION_TIMEOUT_MS, BOOTSTRAP_RESERVED_MS);
await withTimeout(store.dispatch(loadRegistrationKey()), timeout, 'loadRegistrationKey');
logger.info('Registration key loaded');
} catch (error) {
logger.error(error, 'Failed to load registration key');
logger.warn('Continuing without registration key');
}
// Load my dynamix config file into store
loadDynamixConfig();
try {
loadDynamixConfig();
logger.info('Dynamix config loaded');
} catch (error) {
logger.error(error, 'Failed to load dynamix config');
logger.warn('Continuing with default dynamix config');
}
// Start listening to file updates
StateManager.getInstance();
try {
StateManager.getInstance();
logger.info('State manager initialized');
} catch (error) {
logger.error(error, 'Failed to initialize state manager');
logger.warn('Continuing without state watching');
}
// Start listening to key file changes
setupRegistrationKeyWatch();
try {
setupRegistrationKeyWatch();
logger.info('Registration key watch active');
} catch (error) {
logger.error(error, 'Failed to setup registration key watch');
logger.warn('Continuing without key file watching');
}
// If port is unix socket, delete old socket before starting http server
unlinkUnixPort();
startMiddlewareListeners();
// Start webserver
const { bootstrapNestServer } = await import('@app/unraid-api/main.js');
server = await bootstrapNestServer();
// Start webserver - use all remaining budget
try {
const bootstrapTimeout = budget.remaining();
if (bootstrapTimeout < 1000) {
logger.warn(
`Insufficient startup budget remaining (${bootstrapTimeout}ms) for NestJS bootstrap`
);
}
logger.info('Bootstrapping NestJS server (budget: %dms)...', bootstrapTimeout);
const { bootstrapNestServer } = await import('@app/unraid-api/main.js');
server = await withTimeout(bootstrapNestServer(), bootstrapTimeout, 'bootstrapNestServer');
logger.info('Startup complete in %dms', budget.elapsed());
} catch (error) {
logger.error(error, 'Failed to start NestJS server');
throw error; // This is critical - must rethrow to trigger graceful exit
}
asyncExitHook(
async (signal) => {
@@ -103,8 +170,10 @@ export const viteNodeApp = async () => {
gracefulExit();
},
{ wait: 9999 }
{ wait: 10_000 }
);
return server;
} catch (error: unknown) {
if (error instanceof Error) {
logger.error(error, 'API-ERROR');
@@ -115,8 +184,9 @@ export const viteNodeApp = async () => {
await server?.close?.();
}
shutdownApiEvent();
// Kill application
// Kill application - gracefulExit calls process.exit but TS doesn't know it never returns
gracefulExit(1);
throw new Error('Unreachable');
}
};

View File

@@ -1,17 +1,51 @@
import { watch } from 'chokidar';
import { CHOKIDAR_USEPOLLING } from '@app/environment.js';
import { store } from '@app/store/index.js';
import { keyServerLogger } from '@app/core/log.js';
import { getters, store } from '@app/store/index.js';
import { loadSingleStateFile } from '@app/store/modules/emhttp.js';
import { loadRegistrationKey } from '@app/store/modules/registration.js';
import { StateFileKey } from '@app/store/types.js';
/**
* Reloads var.ini with retry logic to handle timing issues with emhttpd.
* When a key file changes, emhttpd needs time to process it and update var.ini.
* This function retries loading var.ini until the registration state changes
* or max retries are exhausted.
*/
export const reloadVarIniWithRetry = async (maxRetries = 3): Promise<void> => {
const beforeState = getters.emhttp().var?.regTy;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const delay = 500 * Math.pow(2, attempt); // 500ms, 1s, 2s
await new Promise((resolve) => setTimeout(resolve, delay));
await store.dispatch(loadSingleStateFile(StateFileKey.var));
const afterState = getters.emhttp().var?.regTy;
if (beforeState !== afterState) {
keyServerLogger.info('Registration state updated: %s -> %s', beforeState, afterState);
return;
}
keyServerLogger.debug('Retry %d: var.ini regTy still %s', attempt + 1, afterState);
}
keyServerLogger.debug('var.ini regTy unchanged after %d retries (may be expected)', maxRetries);
};
export const setupRegistrationKeyWatch = () => {
// IMPORTANT: /boot/config is on FAT32 flash drive which does NOT support inotify
// Must use polling to detect file changes on FAT32 filesystems
watch('/boot/config', {
persistent: true,
ignoreInitial: true,
ignored: (path: string) => !path.endsWith('.key'),
usePolling: CHOKIDAR_USEPOLLING === true,
}).on('all', async () => {
// Load updated key into store
usePolling: true, // Required for FAT32 - inotify doesn't work
interval: 5000, // Poll every 5 seconds (balance between responsiveness and CPU usage)
}).on('all', async (event, path) => {
keyServerLogger.info('Key file %s: %s', event, path);
await store.dispatch(loadRegistrationKey());
// Reload var.ini to get updated registration metadata from emhttpd
await reloadVarIniWithRetry();
});
};

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

@@ -6,6 +6,7 @@ import type { ApiConfig } from '@unraid/shared/services/api-config.js';
import { ConfigFilePersister } from '@unraid/shared/services/config-file.js';
import { csvStringToArray } from '@unraid/shared/util/data.js';
import { isConnectPluginInstalled } from '@app/connect-plugin-cleanup.js';
import { API_VERSION, PATHS_CONFIG_MODULES } from '@app/environment.js';
export { type ApiConfig };
@@ -29,6 +30,13 @@ export const loadApiConfig = async () => {
const apiHandler = new ApiConfigPersistence(new ConfigService()).getFileHandler();
const diskConfig: Partial<ApiConfig> = await apiHandler.loadConfig();
// Hack: cleanup stale connect plugin entry if necessary
if (!isConnectPluginInstalled()) {
diskConfig.plugins = diskConfig.plugins?.filter(
(plugin) => plugin !== 'unraid-api-plugin-connect'
);
await apiHandler.writeConfigFile(diskConfig as ApiConfig);
}
return {
...defaultConfig,

View File

@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { CustomizationMutationsResolver } from '@app/unraid-api/graph/resolvers/customization/customization.mutations.resolver.js';
import { CustomizationResolver } from '@app/unraid-api/graph/resolvers/customization/customization.resolver.js';
import { CustomizationService } from '@app/unraid-api/graph/resolvers/customization/customization.service.js';
@Module({
providers: [CustomizationService, CustomizationResolver],
providers: [CustomizationService, CustomizationResolver, CustomizationMutationsResolver],
exports: [CustomizationService],
})
export class CustomizationModule {}

View File

@@ -0,0 +1,25 @@
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { CustomizationService } from '@app/unraid-api/graph/resolvers/customization/customization.service.js';
import { Theme, ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js';
import { CustomizationMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js';
@Resolver(() => CustomizationMutations)
export class CustomizationMutationsResolver {
constructor(private readonly customizationService: CustomizationService) {}
@ResolveField(() => Theme, { description: 'Update the UI theme (writes dynamix.cfg)' })
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.CUSTOMIZATIONS,
})
async setTheme(
@Args('theme', { type: () => ThemeName, description: 'Theme to apply' })
theme: ThemeName
): Promise<Theme> {
return this.customizationService.setTheme(theme);
}
}

View File

@@ -9,7 +9,9 @@ import * as ini from 'ini';
import { emcmd } from '@app/core/utils/clients/emcmd.js';
import { fileExists } from '@app/core/utils/files/file-exists.js';
import { loadDynamixConfigFromDiskSync } from '@app/store/actions/load-dynamix-config-file.js';
import { getters, store } from '@app/store/index.js';
import { updateDynamixConfig } from '@app/store/modules/dynamix.js';
import {
ActivationCode,
PublicPartnerInfo,
@@ -466,4 +468,16 @@ export class CustomizationService implements OnModuleInit {
showHeaderDescription: descriptionShow === 'yes',
};
}
public async setTheme(theme: ThemeName): Promise<Theme> {
this.logger.log(`Updating theme to ${theme}`);
await this.updateCfgFile(this.configFile, 'display', { theme });
// Refresh in-memory store so subsequent reads get the new theme without a restart
const paths = getters.paths();
const updatedConfig = loadDynamixConfigFromDiskSync(paths['dynamix-config']);
store.dispatch(updateDynamixConfig(updatedConfig));
return this.getTheme();
}
}

View File

@@ -24,6 +24,11 @@ export class VmMutations {}
})
export class ApiKeyMutations {}
@ObjectType({
description: 'Customization related mutations',
})
export class CustomizationMutations {}
@ObjectType({
description: 'Parity check related mutations, WIP, response types and functionaliy will change',
})
@@ -54,6 +59,9 @@ export class RootMutations {
@Field(() => ApiKeyMutations, { description: 'API Key related mutations' })
apiKey: ApiKeyMutations = new ApiKeyMutations();
@Field(() => CustomizationMutations, { description: 'Customization related mutations' })
customization: CustomizationMutations = new CustomizationMutations();
@Field(() => ParityCheckMutations, { description: 'Parity check related mutations' })
parityCheck: ParityCheckMutations = new ParityCheckMutations();

View File

@@ -3,6 +3,7 @@ import { Mutation, Resolver } from '@nestjs/graphql';
import {
ApiKeyMutations,
ArrayMutations,
CustomizationMutations,
DockerMutations,
ParityCheckMutations,
RCloneMutations,
@@ -37,6 +38,11 @@ export class RootMutationsResolver {
return new ApiKeyMutations();
}
@Mutation(() => CustomizationMutations, { name: 'customization' })
customization(): CustomizationMutations {
return new CustomizationMutations();
}
@Mutation(() => RCloneMutations, { name: 'rclone' })
rclone(): RCloneMutations {
return new RCloneMutations();

View File

@@ -1,4 +1,5 @@
import { forwardRef, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { UserSettingsModule } from '@unraid/shared/services/user-settings.js';
@@ -7,7 +8,7 @@ import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/core/
import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/core/oidc-validation.service.js';
@Module({
imports: [UserSettingsModule, forwardRef(() => OidcClientModule)],
imports: [ConfigModule, UserSettingsModule, forwardRef(() => OidcClientModule)],
providers: [OidcConfigPersistence, OidcValidationService],
exports: [OidcConfigPersistence, OidcValidationService],
})

View File

@@ -22,7 +22,7 @@ describe('UPSResolver', () => {
MODEL: 'Test UPS',
STATUS: 'Online',
BCHARGE: '100',
TIMELEFT: '3600',
TIMELEFT: '60', // 60 minutes (apcupsd format)
LINEV: '120.5',
OUTPUTV: '120.5',
LOADPCT: '25',

View File

@@ -21,7 +21,8 @@ export class UPSResolver {
status: upsData.STATUS || 'Online',
battery: {
chargeLevel: parseInt(upsData.BCHARGE || '100', 10),
estimatedRuntime: parseInt(upsData.TIMELEFT || '3600', 10),
// Convert TIMELEFT from minutes (apcupsd format) to seconds
estimatedRuntime: Math.round(parseFloat(upsData.TIMELEFT || '60') * 60),
health: 'Good',
},
power: {

View File

@@ -21,9 +21,19 @@ describe('PluginManagementService', () => {
if (key === 'api.plugins') {
return configStore ?? defaultValue ?? [];
}
if (key === 'api') {
return { plugins: configStore ?? defaultValue ?? [] };
}
return defaultValue;
}),
set: vi.fn((key: string, value: unknown) => {
if (key === 'api' && typeof value === 'object' && value !== null) {
// @ts-expect-error - value is an object
if (Array.isArray(value.plugins)) {
// @ts-expect-error - value is an object
configStore = [...value.plugins];
}
}
if (key === 'api.plugins' && Array.isArray(value)) {
configStore = [...value];
}

View File

@@ -56,8 +56,7 @@ export class PluginManagementService {
}
pluginSet.add(plugin);
});
// @ts-expect-error - This is a valid config key
this.configService.set<string[]>('api.plugins', Array.from(pluginSet));
this.updatePluginsConfig(Array.from(pluginSet));
return added;
}
@@ -71,11 +70,15 @@ export class PluginManagementService {
const pluginSet = new Set(this.plugins);
const removed = plugins.filter((plugin) => pluginSet.delete(plugin));
const pluginsArray = Array.from(pluginSet);
// @ts-expect-error - This is a valid config key
this.configService.set('api.plugins', pluginsArray);
this.updatePluginsConfig(pluginsArray);
return removed;
}
private updatePluginsConfig(plugins: string[]) {
const apiConfig = this.configService.get<ApiConfig>('api');
this.configService.set('api', { ...apiConfig, plugins });
}
/**
* Install bundle / unbundled plugins using npm or direct with the config.
*

View File

@@ -91,13 +91,9 @@ export class PluginService {
return name;
})
);
const { peerDependencies } = getPackageJson();
// All api plugins must be installed as peer dependencies of the unraid-api package
if (!peerDependencies) {
PluginService.logger.warn('Unraid-API peer dependencies not found; skipping plugins.');
return [];
}
const pluginTuples = Object.entries(peerDependencies).filter(
const { peerDependencies = {}, dependencies = {} } = getPackageJson();
const allDependencies = { ...peerDependencies, ...dependencies };
const pluginTuples = Object.entries(allDependencies).filter(
(entry): entry is [string, string] => {
const [pkgName, version] = entry;
return pluginNames.has(pkgName) && typeof version === 'string';

View File

@@ -1,52 +0,0 @@
import { Test } from '@nestjs/testing';
import { describe, expect, it, vi } from 'vitest';
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
import { RestService } from '@app/unraid-api/rest/rest.service.js';
// Mock external dependencies
vi.mock('@app/store/index.js', () => ({
getters: {
paths: vi.fn(() => ({
'log-base': '/tmp/logs',
})),
},
}));
vi.mock('execa', () => ({
execa: vi.fn().mockResolvedValue({ stdout: 'mocked output' }),
}));
describe('RestService Dependencies', () => {
it('should resolve ApiReportService dependency successfully', async () => {
const mockApiReportService = {
generateReport: vi.fn().mockResolvedValue({ timestamp: new Date().toISOString() }),
};
const module = await Test.createTestingModule({
providers: [
RestService,
{
provide: ApiReportService,
useValue: mockApiReportService,
},
],
}).compile();
const restService = module.get<RestService>(RestService);
expect(restService).toBeDefined();
expect(restService).toBeInstanceOf(RestService);
await module.close();
});
it('should fail gracefully when ApiReportService is missing', async () => {
// This test ensures we get a clear error when dependencies are missing
await expect(
Test.createTestingModule({
providers: [RestService],
}).compile()
).rejects.toThrow(/ApiReportService/);
});
});

View File

@@ -1,84 +0,0 @@
import { CacheModule } from '@nestjs/cache-manager';
import { Test } from '@nestjs/testing';
import { CANONICAL_INTERNAL_CLIENT_TOKEN } from '@unraid/shared';
import { describe, expect, it, vi } from 'vitest';
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
import { RestModule } from '@app/unraid-api/rest/rest.module.js';
import { RestService } from '@app/unraid-api/rest/rest.service.js';
// Mock external dependencies that cause issues in tests
vi.mock('@app/store/index.js', () => ({
store: {
getState: vi.fn(() => ({
paths: {
'log-base': '/tmp/logs',
'auth-keys': '/tmp/auth-keys',
config: '/tmp/config',
},
emhttp: {},
dynamix: { notify: { path: '/tmp/notifications' } },
registration: {},
})),
subscribe: vi.fn(() => vi.fn()), // Return unsubscribe function
},
getters: {
paths: vi.fn(() => ({
'log-base': '/tmp/logs',
'auth-keys': '/tmp/auth-keys',
config: '/tmp/config',
})),
dynamix: vi.fn(() => ({ notify: { path: '/tmp/notifications' } })),
emhttp: vi.fn(() => ({})),
registration: vi.fn(() => ({})),
},
}));
vi.mock('@app/core/log.js', () => ({
levels: ['trace', 'debug', 'info', 'warn', 'error', 'fatal'],
apiLogger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
pluginLogger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
fatal: vi.fn(),
},
}));
vi.mock('execa', () => ({
execa: vi.fn().mockResolvedValue({ stdout: 'mocked output' }),
}));
describe('RestModule Integration', () => {
it('should compile with RestService having access to ApiReportService', async () => {
const module = await Test.createTestingModule({
imports: [CacheModule.register({ isGlobal: true }), RestModule],
})
// Override services that have complex dependencies for testing
.overrideProvider(CANONICAL_INTERNAL_CLIENT_TOKEN)
.useValue({ getClient: vi.fn() })
.overrideProvider(LogService)
.useValue({ error: vi.fn(), debug: vi.fn() })
.compile();
const restService = module.get<RestService>(RestService);
const apiReportService = module.get<ApiReportService>(ApiReportService);
expect(restService).toBeDefined();
expect(apiReportService).toBeDefined();
// Verify RestService has the injected ApiReportService
expect(restService['apiReportService']).toBeDefined();
await module.close();
}, 10000);
});

View File

@@ -1,132 +0,0 @@
import { Test } from '@nestjs/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
import { RestService } from '@app/unraid-api/rest/rest.service.js';
const mockWriteFile = vi.fn();
vi.mock('node:fs/promises', () => ({
writeFile: (...args: any[]) => mockWriteFile(...args),
stat: vi.fn(),
}));
// Mock ApiReportService
const mockApiReportService = {
generateReport: vi.fn(),
};
describe('RestService', () => {
let restService: RestService;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [RestService, { provide: ApiReportService, useValue: mockApiReportService }],
}).compile();
restService = module.get<RestService>(RestService);
// Clear mocks
vi.clearAllMocks();
});
describe('saveApiReport', () => {
it('should generate report using ApiReportService and save to file', async () => {
const mockReport = {
timestamp: '2023-01-01T00:00:00.000Z',
connectionStatus: {
running: 'yes' as const,
},
system: {
id: 'test-uuid',
name: 'Test Server',
version: '6.12.0',
machineId: 'REDACTED',
manufacturer: 'Test Manufacturer',
model: 'Test Model',
},
connect: {
installed: true,
dynamicRemoteAccess: {
enabledType: 'STATIC',
runningType: 'STATIC',
error: null,
},
},
config: {
valid: true,
error: null,
},
services: {
cloud: { name: 'cloud', online: true },
minigraph: { name: 'minigraph', online: false },
allServices: [],
},
remote: {
apikey: 'REDACTED',
localApiKey: 'REDACTED',
accesstoken: 'REDACTED',
idtoken: 'REDACTED',
refreshtoken: 'REDACTED',
ssoSubIds: 'REDACTED',
allowedOrigins: 'REDACTED',
email: 'REDACTED',
},
};
const reportPath = '/tmp/test-report.json';
mockApiReportService.generateReport.mockResolvedValue(mockReport);
mockWriteFile.mockResolvedValue(undefined);
await restService.saveApiReport(reportPath);
// Verify ApiReportService was called (defaults to API running)
expect(mockApiReportService.generateReport).toHaveBeenCalledWith();
// Verify file was written with correct content
expect(mockWriteFile).toHaveBeenCalledWith(
reportPath,
JSON.stringify(mockReport, null, 2),
'utf-8'
);
});
it('should handle ApiReportService errors gracefully', async () => {
const reportPath = '/tmp/test-report.json';
const error = new Error('Report generation failed');
mockApiReportService.generateReport.mockRejectedValue(error);
// Should not throw error
await restService.saveApiReport(reportPath);
// Verify ApiReportService was called
expect(mockApiReportService.generateReport).toHaveBeenCalled();
// Verify file write was not called due to error
expect(mockWriteFile).not.toHaveBeenCalled();
});
it('should handle file write errors gracefully', async () => {
const mockReport = {
timestamp: '2023-01-01T00:00:00.000Z',
system: { name: 'Test' },
};
const reportPath = '/tmp/test-report.json';
mockApiReportService.generateReport.mockResolvedValue(mockReport);
mockWriteFile.mockRejectedValue(new Error('File write failed'));
// Should not throw error
await restService.saveApiReport(reportPath);
// Verify both service and file operations were attempted
expect(mockApiReportService.generateReport).toHaveBeenCalled();
expect(mockWriteFile).toHaveBeenCalledWith(
reportPath,
JSON.stringify(mockReport, null, 2),
'utf-8'
);
});
});
});

View File

@@ -34,7 +34,6 @@ describe('RestController', () => {
{
provide: RestService,
useValue: {
getLogs: vi.fn(),
getCustomizationStream: vi.fn(),
},
},

View File

@@ -29,21 +29,6 @@ export class RestController {
return 'OK';
}
@Get('/graphql/api/logs')
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.LOGS,
})
async getLogs(@Res() res: FastifyReply) {
try {
const logStream = await this.restService.getLogs();
return res.status(200).type('application/x-gtar').send(logStream);
} catch (error: unknown) {
this.logger.error(error);
return res.status(500).send(`Error: Failed to get logs`);
}
}
@Get('/graphql/api/customizations/:type')
@UsePermissions({
action: AuthAction.READ_ANY,

View File

@@ -1,13 +1,12 @@
import { Module } from '@nestjs/common';
import { CliServicesModule } from '@app/unraid-api/cli/cli-services.module.js';
import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js';
import { SsoModule } from '@app/unraid-api/graph/resolvers/sso/sso.module.js';
import { RestController } from '@app/unraid-api/rest/rest.controller.js';
import { RestService } from '@app/unraid-api/rest/rest.service.js';
@Module({
imports: [RCloneModule, CliServicesModule, SsoModule],
imports: [RCloneModule, SsoModule],
controllers: [RestController],
providers: [RestService],
})

View File

@@ -1,33 +1,88 @@
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
import { Test, TestingModule } from '@nestjs/testing';
import type { ReadStream } from 'node:fs';
import { createReadStream } from 'node:fs';
import { Readable } from 'node:stream';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
import {
getBannerPathIfPresent,
getCasePathIfPresent,
} from '@app/core/utils/images/image-file-helpers.js';
import { RestService } from '@app/unraid-api/rest/rest.service.js';
vi.mock('node:fs');
vi.mock('@app/core/utils/images/image-file-helpers.js', () => ({
getBannerPathIfPresent: vi.fn(),
getCasePathIfPresent: vi.fn(),
}));
describe('RestService', () => {
let service: RestService;
beforeEach(async () => {
const mockApiReportService = {
generateReport: vi.fn(),
};
vi.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
RestService,
{
provide: ApiReportService,
useValue: mockApiReportService,
},
],
providers: [RestService],
}).compile();
service = module.get<RestService>(RestService);
});
it('should be defined', () => {
expect(service).toBeDefined();
describe('getCustomizationPath', () => {
it('returns banner path when present', async () => {
const mockBannerPath = '/path/to/banner.png';
vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockBannerPath);
await expect(service.getCustomizationPath('banner')).resolves.toBe(mockBannerPath);
});
it('returns case path when present', async () => {
const mockCasePath = '/path/to/case.png';
vi.mocked(getCasePathIfPresent).mockResolvedValue(mockCasePath);
await expect(service.getCustomizationPath('case')).resolves.toBe(mockCasePath);
});
it('returns null when no path is available', async () => {
vi.mocked(getBannerPathIfPresent).mockResolvedValue(null);
vi.mocked(getCasePathIfPresent).mockResolvedValue(null);
await expect(service.getCustomizationPath('banner')).resolves.toBeNull();
await expect(service.getCustomizationPath('case')).resolves.toBeNull();
});
});
describe('getCustomizationStream', () => {
it('returns read stream for banner', async () => {
const mockPath = '/path/to/banner.png';
const mockStream: ReadStream = Readable.from([]) as ReadStream;
vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockPath);
vi.mocked(createReadStream).mockReturnValue(mockStream);
await expect(service.getCustomizationStream('banner')).resolves.toBe(mockStream);
expect(createReadStream).toHaveBeenCalledWith(mockPath);
});
it('returns read stream for case', async () => {
const mockPath = '/path/to/case.png';
const mockStream: ReadStream = Readable.from([]) as ReadStream;
vi.mocked(getCasePathIfPresent).mockResolvedValue(mockPath);
vi.mocked(createReadStream).mockReturnValue(mockStream);
await expect(service.getCustomizationStream('case')).resolves.toBe(mockStream);
expect(createReadStream).toHaveBeenCalledWith(mockPath);
});
it('throws when no customization is available', async () => {
vi.mocked(getBannerPathIfPresent).mockResolvedValue(null);
vi.mocked(getCasePathIfPresent).mockResolvedValue(null);
await expect(service.getCustomizationStream('banner')).rejects.toThrow('No banner found');
await expect(service.getCustomizationStream('case')).rejects.toThrow('No case found');
});
});
});

View File

@@ -1,350 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import type { ReadStream, Stats } from 'node:fs';
import { createReadStream } from 'node:fs';
import { stat, writeFile } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { execa, ExecaError } from 'execa';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ApiReportData } from '@app/unraid-api/cli/api-report.service.js';
import {
getBannerPathIfPresent,
getCasePathIfPresent,
} from '@app/core/utils/images/image-file-helpers.js';
import { getters } from '@app/store/index.js';
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
import { RestService } from '@app/unraid-api/rest/rest.service.js';
vi.mock('node:fs');
vi.mock('node:fs/promises');
vi.mock('execa');
vi.mock('@app/store/index.js');
vi.mock('@app/core/utils/images/image-file-helpers.js', () => ({
getBannerPathIfPresent: vi.fn(),
getCasePathIfPresent: vi.fn(),
}));
describe('RestService', () => {
let service: RestService;
let apiReportService: ApiReportService;
beforeEach(async () => {
vi.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
RestService,
{
provide: ApiReportService,
useValue: {
generateReport: vi.fn(),
},
},
],
}).compile();
service = module.get<RestService>(RestService);
apiReportService = module.get<ApiReportService>(ApiReportService);
});
describe('getLogs', () => {
const mockLogPath = '/usr/local/emhttp/logs/unraid-api';
const mockGraphqlApiLog = '/var/log/graphql-api.log';
const mockZipPath = '/usr/local/emhttp/logs/unraid-api.tar.gz';
beforeEach(() => {
vi.mocked(getters).paths = vi.fn().mockReturnValue({
'log-base': mockLogPath,
});
// Mock saveApiReport to avoid side effects
vi.spyOn(service as any, 'saveApiReport').mockResolvedValue(undefined);
});
it('should create and return log archive successfully', async () => {
const mockStream: ReadStream = Readable.from([]) as ReadStream;
vi.mocked(stat).mockImplementation((path) => {
if (path === mockLogPath || path === mockZipPath) {
return Promise.resolve({ isFile: () => true } as unknown as Stats);
}
return Promise.reject(new Error('File not found'));
});
vi.mocked(execa).mockResolvedValue({
stdout: '',
stderr: '',
exitCode: 0,
} as any);
vi.mocked(createReadStream).mockReturnValue(mockStream);
const result = await service.getLogs();
expect(execa).toHaveBeenCalledWith('tar', ['-czf', mockZipPath, mockLogPath], {
timeout: 60000,
reject: true,
});
expect(createReadStream).toHaveBeenCalledWith(mockZipPath);
expect(result).toBe(mockStream);
});
it('should include graphql-api.log when it exists', async () => {
vi.mocked(stat).mockImplementation((path) => {
if (path === mockLogPath || path === mockGraphqlApiLog || path === mockZipPath) {
return Promise.resolve({ isFile: () => true } as unknown as Stats);
}
return Promise.reject(new Error('File not found'));
});
vi.mocked(execa).mockResolvedValue({
stdout: '',
stderr: '',
exitCode: 0,
} as any);
vi.mocked(createReadStream).mockReturnValue(Readable.from([]) as ReadStream);
await service.getLogs();
expect(execa).toHaveBeenCalledWith(
'tar',
['-czf', mockZipPath, mockLogPath, mockGraphqlApiLog],
{
timeout: 60000,
reject: true,
}
);
});
it('should handle timeout errors with detailed message', async () => {
vi.mocked(stat).mockImplementation((path) => {
if (path === mockLogPath) {
return Promise.resolve({ isFile: () => true } as unknown as Stats);
}
return Promise.reject(new Error('File not found'));
});
const timeoutError = new Error('Command timed out') as ExecaError;
timeoutError.timedOut = true;
timeoutError.command =
'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api';
timeoutError.exitCode = undefined;
timeoutError.stderr = '';
timeoutError.stdout = '';
vi.mocked(execa).mockRejectedValue(timeoutError);
await expect(service.getLogs()).rejects.toThrow('Tar command timed out after 60 seconds');
});
it('should handle command failure with exit code and stderr', async () => {
vi.mocked(stat).mockImplementation((path) => {
if (path === mockLogPath) {
return Promise.resolve({ isFile: () => true } as unknown as Stats);
}
return Promise.reject(new Error('File not found'));
});
const execError = new Error('Command failed') as ExecaError;
execError.exitCode = 1;
execError.command =
'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api';
execError.stderr = 'tar: Cannot create archive';
execError.stdout = '';
execError.shortMessage = 'Command failed with exit code 1';
vi.mocked(execa).mockRejectedValue(execError);
await expect(service.getLogs()).rejects.toThrow('Tar command failed with exit code 1');
await expect(service.getLogs()).rejects.toThrow('tar: Cannot create archive');
});
it('should handle case when tar succeeds but zip file is not created', async () => {
vi.mocked(stat).mockImplementation((path) => {
if (path === mockLogPath) {
return Promise.resolve({ isFile: () => true } as unknown as Stats);
}
// Zip file doesn't exist after tar command
return Promise.reject(new Error('File not found'));
});
vi.mocked(execa).mockResolvedValue({
stdout: '',
stderr: '',
exitCode: 0,
} as any);
await expect(service.getLogs()).rejects.toThrow(
'Failed to create log zip - tar file not found after successful command'
);
});
it('should throw error when log path does not exist', async () => {
vi.mocked(stat).mockRejectedValue(new Error('File not found'));
await expect(service.getLogs()).rejects.toThrow('No logs to download');
});
it('should handle generic errors', async () => {
vi.mocked(stat).mockImplementation((path) => {
if (path === mockLogPath) {
return Promise.resolve({ isFile: () => true } as unknown as Stats);
}
return Promise.reject(new Error('File not found'));
});
const genericError = new Error('Unexpected error');
vi.mocked(execa).mockRejectedValue(genericError);
await expect(service.getLogs()).rejects.toThrow(
'Failed to create logs archive: Unexpected error'
);
});
it('should handle errors with stdout in addition to stderr', async () => {
vi.mocked(stat).mockImplementation((path) => {
if (path === mockLogPath) {
return Promise.resolve({ isFile: () => true } as unknown as Stats);
}
return Promise.reject(new Error('File not found'));
});
const execError = new Error('Command failed') as ExecaError;
execError.exitCode = 1;
execError.command =
'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api';
execError.stderr = 'tar: Error';
execError.stdout = 'Processing archive...';
execError.shortMessage = 'Command failed with exit code 1';
vi.mocked(execa).mockRejectedValue(execError);
await expect(service.getLogs()).rejects.toThrow('Stdout: Processing archive');
});
});
describe('saveApiReport', () => {
it('should generate and save API report', async () => {
const mockReport: ApiReportData = {
timestamp: new Date().toISOString(),
connectionStatus: { running: 'yes' },
system: {
name: 'Test Server',
version: '6.12.0',
machineId: 'test-machine-id',
},
connect: {
installed: false,
},
config: {
valid: true,
},
services: {
cloud: null,
minigraph: null,
allServices: [],
},
};
const mockPath = '/test/report.json';
vi.mocked(apiReportService.generateReport).mockResolvedValue(mockReport);
vi.mocked(writeFile).mockResolvedValue(undefined);
await service.saveApiReport(mockPath);
expect(apiReportService.generateReport).toHaveBeenCalled();
expect(writeFile).toHaveBeenCalledWith(
mockPath,
JSON.stringify(mockReport, null, 2),
'utf-8'
);
});
it('should handle errors when generating report', async () => {
const mockPath = '/test/report.json';
vi.mocked(apiReportService.generateReport).mockRejectedValue(
new Error('Report generation failed')
);
// Should not throw, just log warning
await expect(service.saveApiReport(mockPath)).resolves.toBeUndefined();
expect(apiReportService.generateReport).toHaveBeenCalled();
});
});
describe('getCustomizationPath', () => {
it('should return banner path when type is banner', async () => {
const mockBannerPath = '/path/to/banner.png';
vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockBannerPath);
const result = await service.getCustomizationPath('banner');
expect(getBannerPathIfPresent).toHaveBeenCalled();
expect(result).toBe(mockBannerPath);
});
it('should return case path when type is case', async () => {
const mockCasePath = '/path/to/case.png';
vi.mocked(getCasePathIfPresent).mockResolvedValue(mockCasePath);
const result = await service.getCustomizationPath('case');
expect(getCasePathIfPresent).toHaveBeenCalled();
expect(result).toBe(mockCasePath);
});
it('should return null when no banner found', async () => {
vi.mocked(getBannerPathIfPresent).mockResolvedValue(null);
const result = await service.getCustomizationPath('banner');
expect(result).toBeNull();
});
it('should return null when no case found', async () => {
vi.mocked(getCasePathIfPresent).mockResolvedValue(null);
const result = await service.getCustomizationPath('case');
expect(result).toBeNull();
});
});
describe('getCustomizationStream', () => {
it('should return read stream for banner', async () => {
const mockPath = '/path/to/banner.png';
const mockStream: ReadStream = Readable.from([]) as ReadStream;
vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockPath);
vi.mocked(createReadStream).mockReturnValue(mockStream);
const result = await service.getCustomizationStream('banner');
expect(getBannerPathIfPresent).toHaveBeenCalled();
expect(createReadStream).toHaveBeenCalledWith(mockPath);
expect(result).toBe(mockStream);
});
it('should return read stream for case', async () => {
const mockPath = '/path/to/case.png';
const mockStream: ReadStream = Readable.from([]) as ReadStream;
vi.mocked(getCasePathIfPresent).mockResolvedValue(mockPath);
vi.mocked(createReadStream).mockReturnValue(mockStream);
const result = await service.getCustomizationStream('case');
expect(getCasePathIfPresent).toHaveBeenCalled();
expect(createReadStream).toHaveBeenCalledWith(mockPath);
expect(result).toBe(mockStream);
});
it('should throw error when no banner found', async () => {
vi.mocked(getBannerPathIfPresent).mockResolvedValue(null);
await expect(service.getCustomizationStream('banner')).rejects.toThrow('No banner found');
});
it('should throw error when no case found', async () => {
vi.mocked(getCasePathIfPresent).mockResolvedValue(null);
await expect(service.getCustomizationStream('case')).rejects.toThrow('No case found');
});
});
});

View File

@@ -1,111 +1,14 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import type { ReadStream } from 'node:fs';
import { createReadStream } from 'node:fs';
import { stat, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { ExecaError } from 'execa';
import { execa } from 'execa';
import {
getBannerPathIfPresent,
getCasePathIfPresent,
} from '@app/core/utils/images/image-file-helpers.js';
import { getters } from '@app/store/index.js';
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
@Injectable()
export class RestService {
protected logger = new Logger(RestService.name);
constructor(private readonly apiReportService: ApiReportService) {}
async saveApiReport(pathToReport: string) {
try {
const apiReport = await this.apiReportService.generateReport();
this.logger.debug('Report object %o', apiReport);
await writeFile(pathToReport, JSON.stringify(apiReport, null, 2), 'utf-8');
} catch (error) {
this.logger.warn('Could not generate report for zip with error %o', error);
}
}
async getLogs(): Promise<ReadStream> {
const logPath = getters.paths()['log-base'];
const graphqlApiLog = '/var/log/graphql-api.log';
try {
await this.saveApiReport(join(logPath, 'report.json'));
} catch (error) {
this.logger.warn('Could not generate report for zip with error %o', error);
}
const zipToWrite = join(logPath, '../unraid-api.tar.gz');
const logPathExists = Boolean(await stat(logPath).catch(() => null));
if (logPathExists) {
try {
// Build tar command arguments
const tarArgs = ['-czf', zipToWrite, logPath];
// Check if graphql-api.log exists and add it to the archive
const graphqlLogExists = Boolean(await stat(graphqlApiLog).catch(() => null));
if (graphqlLogExists) {
tarArgs.push(graphqlApiLog);
this.logger.debug('Including graphql-api.log in archive');
}
// Execute tar with timeout and capture output
await execa('tar', tarArgs, {
timeout: 60000, // 60 seconds timeout for tar operation
reject: true, // Throw on non-zero exit (default behavior)
});
const tarFileExists = Boolean(await stat(zipToWrite).catch(() => null));
if (tarFileExists) {
return createReadStream(zipToWrite);
} else {
throw new Error(
'Failed to create log zip - tar file not found after successful command'
);
}
} catch (error) {
// Build detailed error message with execa's built-in error info
let errorMessage = 'Failed to create logs archive';
if (error && typeof error === 'object' && 'command' in error) {
const execaError = error as ExecaError;
if (execaError.timedOut) {
errorMessage = `Tar command timed out after 60 seconds. Command: ${execaError.command}`;
} else if (execaError.exitCode !== undefined) {
errorMessage = `Tar command failed with exit code ${execaError.exitCode}. Command: ${execaError.command}`;
}
// Add stderr/stdout if available
if (execaError.stderr) {
errorMessage += `. Stderr: ${execaError.stderr}`;
}
if (execaError.stdout) {
errorMessage += `. Stdout: ${execaError.stdout}`;
}
// Include the short message from execa
if (execaError.shortMessage) {
errorMessage += `. Details: ${execaError.shortMessage}`;
}
} else if (error instanceof Error) {
errorMessage += `: ${error.message}`;
}
this.logger.error(errorMessage, error);
throw new Error(errorMessage);
}
} else {
throw new Error('No logs to download');
}
}
async getCustomizationPath(type: 'banner' | 'case'): Promise<string | null> {
switch (type) {
case 'banner':

View File

@@ -1,13 +1,13 @@
{
"name": "unraid-monorepo",
"private": true,
"version": "4.26.2",
"version": "4.29.1",
"scripts": {
"build": "pnpm -r build",
"build:watch": "pnpm -r --parallel --filter '!@unraid/ui' build:watch",
"codegen": "pnpm -r codegen",
"i18n:extract": "pnpm --filter @unraid/api i18n:extract && pnpm --filter @unraid/web i18n:extract",
"dev": "pnpm -r dev",
"dev": "pnpm -r --parallel dev",
"unraid:deploy": "pnpm -r unraid:deploy",
"test": "pnpm -r test",
"test:watch": "pnpm -r --parallel test:watch",

View File

@@ -1,8 +1,5 @@
import { Inject, Logger, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { existsSync } from 'node:fs';
import { execa } from 'execa';
import { ConnectConfigPersister } from './config/config.persistence.js';
import { configFeature } from './config/connect.config.js';
@@ -30,64 +27,4 @@ class ConnectPluginModule {
}
}
/**
* Fallback module keeps the export shape intact but only warns operators.
* This makes `ApiModule` safe to import even when the plugin is absent.
*/
@Module({})
export class DisabledConnectPluginModule {
logger = new Logger(DisabledConnectPluginModule.name);
async onModuleInit() {
const removalCommand = 'unraid-api plugins remove -b unraid-api-plugin-connect';
this.logger.warn(
'Connect plugin is not installed, but is listed as an API plugin. Attempting `%s` automatically.',
removalCommand
);
try {
const { stdout, stderr } = await execa('unraid-api', [
'plugins',
'remove',
'-b',
'unraid-api-plugin-connect',
]);
if (stdout?.trim()) {
this.logger.debug(stdout.trim());
}
if (stderr?.trim()) {
this.logger.debug(stderr.trim());
}
this.logger.log(
'Successfully completed `%s` to prune the stale connect plugin entry.',
removalCommand
);
} catch (error) {
const message =
error instanceof Error
? error.message
: 'Unknown error while removing stale connect plugin entry.';
this.logger.error('Failed to run `%s`: %s', removalCommand, message);
}
}
}
/**
* Local filesystem and env checks stay synchronous so we can branch at module load.
*/
const isConnectPluginInstalled = () => {
if (process.env.SKIP_CONNECT_PLUGIN_CHECK === 'true') {
return true;
}
return existsSync('/boot/config/plugins/dynamix.unraid.net.plg');
};
/**
* Downstream code always imports `ApiModule`. We swap the implementation based on availability,
* avoiding dynamic module plumbing while keeping the DI graph predictable.
* Set `SKIP_CONNECT_PLUGIN_CHECK=true` in development to force the connected path.
*/
export const ApiModule = isConnectPluginInstalled() ? ConnectPluginModule : DisabledConnectPluginModule;
export const ApiModule = ConnectPluginModule;

View File

@@ -18,7 +18,8 @@
"dist"
],
"scripts": {
"build": "rimraf dist && tsc --project tsconfig.build.json",
"build": "pnpm clean && tsc --project tsconfig.build.json",
"clean": "rimraf dist",
"prepare": "npm run build",
"test": "vitest run",
"test:watch": "vitest",

View File

@@ -4,50 +4,32 @@ Tool for building and testing Unraid plugins locally as well as packaging them f
## Development Workflow
### 1. Watch for Changes
### 1. Build the Plugin
The watch command will automatically sync changes from the API, UI components, and web app into the plugin source:
```bash
# Start watching all components
pnpm run watch:all
# Or run individual watchers:
pnpm run api:watch # Watch API changes
pnpm run ui:watch # Watch Unraid UI component changes
pnpm run wc:watch # Watch web component changes
```
This will copy:
- API files to `./source/dynamix.unraid.net/usr/local/unraid-api`
- UI components to `./source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components`
- Web components to the same UI directory
### 2. Build the Plugin
> **Note:** Building the plugin requires Docker.
Once your changes are ready, build the plugin package:
```bash
# Build using Docker - on non-Linux systems
# Start Docker container (builds dependencies automatically)
pnpm run docker:build-and-run
# Or build with the build script
pnpm run build:validate
# Inside the container, build the plugin
pnpm build
```
This will create the plugin files in `./deploy/release/`
This will:
### 3. Serve and Install
1. Build the API release (`api/deploy/release/`)
2. Build the web standalone components (`web/dist/`)
3. Start Docker container with HTTP server on port 5858
4. Build the plugin package (when you run `pnpm build`)
Start a local HTTP server to serve the plugin files:
The plugin files will be created in `./deploy/` and served automatically.
```bash
# Serve the plugin files
pnpm run http-server
```
### 2. Install on Unraid
Then install the plugin on your Unraid development machine by visiting:
Install the plugin on your Unraid development machine by visiting:
`http://SERVER_NAME.local/Plugins`
@@ -59,8 +41,7 @@ Replace `SERVER_NAME` with your development machine's hostname.
## Development Tips
- Run watchers in a separate terminal while developing
- The http-server includes CORS headers for local development
- The HTTP server includes CORS headers for local development
- Check the Unraid system log for plugin installation issues
## Environment Setup
@@ -81,22 +62,10 @@ Replace `SERVER_NAME` with your development machine's hostname.
### Build Commands
- `build` - Build the plugin package
- `build:validate` - Build with environment validation
- `build` - Build the plugin package (run inside Docker container)
- `docker:build` - Build the Docker container
- `docker:run` - Run the builder in Docker
- `docker:build-and-run` - Build and run in Docker
### Watch Commands
- `watch:all` - Watch all component changes
- `api:watch` - Watch API changes
- `ui:watch` - Watch UI component changes
- `wc:watch` - Watch web component changes
### Server Commands
- `http-server` - Serve the plugin files locally
- `docker:build-and-run` - Build dependencies and start Docker container
### Environment Commands

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/connect-plugin",
"version": "4.26.2",
"version": "4.29.1",
"private": true,
"dependencies": {
"commander": "14.0.0",

View File

@@ -206,6 +206,7 @@ FILES_TO_BACKUP=(
"/usr/local/emhttp/plugins/dynamix.my.servers/include/reboot-details.php"
"/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php"
"/usr/local/emhttp/update.htm"
"/usr/local/emhttp/redirect.htm"
"/usr/local/emhttp/logging.htm"
"/etc/nginx/nginx.conf"
"/etc/rc.d/rc.nginx"
@@ -349,6 +350,7 @@ exit 0
"/usr/local/emhttp/plugins/dynamix.my.servers/include/reboot-details.php"
"/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php"
"/usr/local/emhttp/update.htm"
"/usr/local/emhttp/redirect.htm"
"/usr/local/emhttp/logging.htm"
"/etc/nginx/nginx.conf"
"/etc/rc.d/rc.nginx"
@@ -478,6 +480,12 @@ if [ "$SKIP_API_INSTALL" = false ]; then
fi
done
# Stop the API service before mutating /usr/local/unraid-api to avoid upgrade races
if [ -x "/etc/rc.d/rc.unraid-api" ]; then
echo "Stopping Unraid API service before upgrade..."
/etc/rc.d/rc.unraid-api stop || echo "Warning: Failed to stop Unraid API service"
fi
# Remove existing node_modules directory
echo "Cleaning up existing node_modules directory..."
if [ -d "/usr/local/unraid-api/node_modules" ]; then

View File

@@ -33,6 +33,23 @@ if [ ! -d "$WEB_DIST_DIR" ]; then
mkdir -p "$WEB_DIST_DIR"
fi
# Build dependencies before starting Docker (always rebuild to prevent staleness)
echo "Building dependencies..."
echo "Building API release..."
if ! (cd .. && pnpm --filter @unraid/api build:release); then
echo "Error: API build failed. Aborting."
exit 1
fi
echo "Building web standalone..."
if ! (cd .. && pnpm --filter @unraid/web build); then
echo "Error: Web build failed. Aborting."
exit 1
fi
echo "Dependencies built successfully."
# Stop any running plugin-builder container first
echo "Stopping any running plugin-builder containers..."
docker ps -q --filter "name=${CONTAINER_NAME}" | xargs -r docker stop

View File

@@ -15,12 +15,29 @@ Tag="globe"
*/
require_once "$docroot/plugins/dynamix.my.servers/include/state.php";
require_once "$docroot/plugins/dynamix.my.servers/include/api-config.php";
require_once "$docroot/plugins/dynamix.my.servers/include/connect-config.php";
require_once "$docroot/webGui/include/Wrappers.php";
$serverState = new ServerState();
$keyfile = $serverState->keyfileBase64;
$myServersFlashCfg = $serverState->myServersFlashCfg;
$connectConfig = ConnectConfig::getConfig();
$legacyRemoteCfg = $serverState->myServersFlashCfg['remote'] ?? [];
$remoteDynamicRemoteAccessType = $connectConfig['dynamicRemoteAccessType'] ?? ($legacyRemoteCfg['dynamicRemoteAccessType'] ?? null);
$remoteWanAccessRaw = $connectConfig['wanaccess'] ?? ($legacyRemoteCfg['wanaccess'] ?? null);
$remoteUpnpEnabledRaw = $connectConfig['upnpEnabled'] ?? ($legacyRemoteCfg['upnpEnabled'] ?? null);
$remoteWanPortRaw = $connectConfig['wanport'] ?? ($legacyRemoteCfg['wanport'] ?? null);
$wanaccessEnabled = filter_var($remoteWanAccessRaw, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($wanaccessEnabled === null) {
$wanaccessEnabled = false;
}
$upnpEnabled = filter_var($remoteUpnpEnabledRaw, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($upnpEnabled === null) {
$upnpEnabled = false;
}
$remoteWanPort = is_numeric($remoteWanPortRaw) ? (int)$remoteWanPortRaw : 0;
$showT2Fa = (file_exists('/boot/config/plugins/dynamix.my.servers/showT2Fa'));
@@ -37,9 +54,7 @@ $passwd_result = exec('/usr/bin/passwd --status root');
$boolWebUIAuth = $isRegistered && (($passwd_result !== false) && (substr($passwd_result, 0, 6) == 'root P'));
// Helper to determine the current value for the remote access input
$dynamicRemoteAccessType = $myServersFlashCfg['remote']['dynamicRemoteAccessType'];
$upnpEnabled = $myServersFlashCfg['remote']['upnpEnabled'] === 'yes';
$wanaccessEnabled = $myServersFlashCfg['remote']['wanaccess'] === 'yes';
$dynamicRemoteAccessType = $remoteDynamicRemoteAccessType ?? null;
$currentRemoteAccessValue = 'OFF';
if ($dynamicRemoteAccessType === 'STATIC') {
@@ -59,6 +74,12 @@ if ($dynamicRemoteAccessType === 'STATIC') {
$enableRemoteT2fa = $showT2Fa && $currentRemoteAccessValue !== 'OFF' && $hasMyUnraidNetCert;
$enableLocalT2fa = $showT2Fa && $var['USE_SSL'] === 'auto' && $hasMyUnraidNetCert;
$shade="shade-".($display['theme']??'unk');
$wanAccessOriginal = $remoteWanAccessRaw;
if (is_bool($wanAccessOriginal)) {
$wanAccessOriginal = $wanAccessOriginal ? 'yes' : 'no';
} elseif (!is_string($wanAccessOriginal)) {
$wanAccessOriginal = '';
}
?>
<style>
div.shade-white{background-color:#ededed;margin-top:10px;padding:8px 0 3px 0}
@@ -68,13 +89,18 @@ div.shade-gray{background-color:#121510;margin-top:10px;padding:8px 0 3px 0}
</style>
<script>
const hasMyUnraidNetCert = <?=($hasMyUnraidNetCert ? 'true' : 'false')?>;
const wanAccessOrg = "<?=$myServersFlashCfg['remote']['wanaccess']?>";
const wanAccessOrg = "<?=$wanAccessOriginal?>";
function registerServer(button) {
const $remoteAccessInput = $('#remoteAccess');
const $remoteAccessManualPort = $('#wanport');
const parsePort = (val) => {
const parsed = parseInt(val, 10);
return isNaN(parsed) ? null : parsed;
};
let computedRemoteAccessConfig = null;
switch ($remoteAccessInput.val()) {
case 'ALWAYS_MANUAL':
@@ -119,19 +145,64 @@ function registerServer(button) {
break;
}
const enableLocalT2fa = <?=($enableLocalT2fa ? 'true' : 'false')?>;
const enableRemoteT2fa = $remoteAccessInput.val() !== 'OFF' && hasMyUnraidNetCert;
const enableLocalT2fa = <?=($enableLocalT2fa ? 'true' : 'false')?>;
const enableRemoteT2fa = $remoteAccessInput.val() !== 'OFF' && hasMyUnraidNetCert;
var postobj = {
"#cfg": "/boot/config/plugins/dynamix.my.servers/myservers.cfg",
...(computedRemoteAccessConfig ? computedRemoteAccessConfig : {}),
// only allow 'yes' value when fields are enabled
"local_2Fa": enableLocalT2fa ? $('#local2fa').val() : 'no',
"remote_2Fa": enableRemoteT2fa ? $('#remote2fa').val() : 'no',
};
const postobj = {
"#cfg": "/boot/config/plugins/dynamix.my.servers/myservers.cfg",
...(computedRemoteAccessConfig ? computedRemoteAccessConfig : {}),
// only allow 'yes' value when fields are enabled
"local_2Fa": enableLocalT2fa ? $('#local2fa').val() : 'no',
"remote_2Fa": enableRemoteT2fa ? $('#remote2fa').val() : 'no',
};
$(button).prop("disabled", true).html("_(Applying)_ <i class=\"fa fa-spinner fa-spin\" aria-hidden=\"true\"></i>");
$.post('/webGui/include/Dispatcher.php', postobj, function(data2) {
const buildConnectSettingsInput = () => {
const selection = $remoteAccessInput.val();
switch (selection) {
case 'ALWAYS_MANUAL':
return { accessType: 'ALWAYS', forwardType: 'STATIC', port: parsePort($remoteAccessManualPort.val()) };
case 'ALWAYS_UPNP':
return { accessType: 'ALWAYS', forwardType: 'UPNP', port: null };
case 'DYNAMIC_UPNP':
return { accessType: 'DYNAMIC', forwardType: 'UPNP', port: null };
case 'DYNAMIC_MANUAL':
return { accessType: 'DYNAMIC', forwardType: 'STATIC', port: parsePort($remoteAccessManualPort.val()) };
default:
return { accessType: 'DISABLED', forwardType: 'STATIC', port: null };
}
};
const $button = $(button);
const originalLabel = $button.html();
$button.prop("disabled", true).html("_(Applying)_ <i class=\"fa fa-spinner fa-spin\" aria-hidden=\"true\"></i>");
const saveLegacyConfig = new Promise((resolve, reject) => {
$.post('/webGui/include/Dispatcher.php', postobj).done(resolve).fail(reject);
});
const apolloClient = window.apolloClient;
const gql = window.gql || window.graphqlParse;
const mutations = [saveLegacyConfig];
if (apolloClient && gql) {
const updateConnectSettingsMutation = gql(`
mutation UpdateConnectSettings($input: ConnectSettingsInput!) {
updateApiSettings(input: $input) {
accessType
forwardType
port
}
}
`);
mutations.push(
apolloClient.mutate({
mutation: updateConnectSettingsMutation,
variables: { input: buildConnectSettingsInput() },
})
);
}
Promise.all(mutations).then(function() {
<?if(!$isRegistered):?>
swal({
title: "",
@@ -150,7 +221,22 @@ function registerServer(button) {
button.form.submit();
}, delay);
<?endif?>
});
}).catch(function(error) {
let message = "_(Sorry, an error occurred)_";
if (error && error.responseJSON && error.responseJSON.error) {
message = error.responseJSON.error;
} else if (error && error.message) {
message = error.message;
}
$button.prop("disabled", false).html(originalLabel);
swal({
title: "Oops",
text: message,
type: "error",
html: true,
confirmButtonText: "_(Ok)_"
});
});
}
@@ -196,7 +282,7 @@ function dnsCheckServer(button) {
} else {
swal({
title: "Oops",
text: "<?=sprintf(_("The Unraid server is unreachable from outside your network. Be sure you have configured your router to forward port") . " <strong style='font-weight: bold'>%u/TCP</strong> " . _("to the Unraid server at") . " <strong style='font-weight: bold'>%s</strong> " . _("port") . " <strong style='font-weight: bold'>%u</strong>", $myServersFlashCfg['remote']['wanport'], htmlspecialchars($eth0['IPADDR:0']??''), $var['PORTSSL']??443)?>",
text: "<?=sprintf(_("The Unraid server is unreachable from outside your network. Be sure you have configured your router to forward port") . " <strong style='font-weight: bold'>%u/TCP</strong> " . _("to the Unraid server at") . " <strong style='font-weight: bold'>%s</strong> " . _("port") . " <strong style='font-weight: bold'>%u</strong>", $remoteWanPort, htmlspecialchars($eth0['IPADDR:0']??''), $var['PORTSSL']??443)?>",
type: "error",
html: true,
confirmButtonText: "_(Ok)_"

View File

@@ -1,5 +1,11 @@
<?php
$docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
if (!class_exists('ThemeHelper')) {
$themeHelperPath = $docroot . '/plugins/dynamix/include/ThemeHelper.php';
if (is_readable($themeHelperPath)) {
require_once $themeHelperPath;
}
}
class WebComponentsExtractor
{
@@ -38,7 +44,12 @@ class WebComponentsExtractor
public function getManifestContents(string $manifestPath): array
{
$contents = @file_get_contents($manifestPath);
return $contents ? json_decode($contents, true) : [];
if (!$contents) {
return [];
}
$decoded = json_decode($contents, true);
return is_array($decoded) ? $decoded : [];
}
private function processManifestFiles(): string
@@ -148,22 +159,190 @@ class WebComponentsExtractor
return $files;
}
private function normalizeHex(?string $color): ?string
{
if (!is_string($color) || trim($color) === '') {
return null;
}
$color = trim($color);
if ($color[0] !== '#') {
$color = '#' . ltrim($color, '#');
}
$hex = substr($color, 1);
if (strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if (!ctype_xdigit($hex) || strlen($hex) !== 6) {
return null;
}
return '#' . strtolower($hex);
}
private function hexToRgba(string $hex, float $alpha): string
{
$hex = ltrim($hex, '#');
if (strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
$r = hexdec(substr($hex, 0, 2));
$g = hexdec(substr($hex, 2, 2));
$b = hexdec(substr($hex, 4, 2));
return sprintf('rgba(%d, %d, %d, %.3f)', $r, $g, $b, max(0, min(1, $alpha)));
}
/**
* Attempt to build CSS variables from PHP display data (server-rendered settings).
*
* @return array{vars: array<string,string>, classes: string[], diagnostics: array}|null
*/
private function getDisplayThemeVars(): ?array
{
if (!isset($GLOBALS['display']) || !is_array($GLOBALS['display'])) {
return null;
}
$display = $GLOBALS['display'];
$vars = [];
$textPrimary = $this->normalizeHex($display['header'] ?? null);
if ($textPrimary) {
$vars['--header-text-primary'] = $textPrimary;
}
$textSecondary = $this->normalizeHex($display['headermetacolor'] ?? null);
if ($textSecondary) {
$vars['--header-text-secondary'] = $textSecondary;
}
$theme = strtolower(trim($display['theme'] ?? ''));
$darkThemes = ['gray', 'black'];
$isDarkMode = in_array($theme, $darkThemes, true);
$vars['--theme-dark-mode'] = $isDarkMode ? '1' : '0';
$vars['--theme-name'] = $theme ?: 'white';
if ($theme === 'white') {
if (!$textPrimary) {
$vars['--header-text-primary'] = 'var(--inverse-text-color, #ffffff)';
}
if (!$textSecondary) {
$vars['--header-text-secondary'] = 'var(--alt-text-color, #999999)';
}
}
// Unraid WebGUI stores banner enablement as a non-empty `display['banner']` value
// (typically the banner file name/path).
$shouldShowBanner = !empty($display['banner']);
$bgColor = $this->normalizeHex($display['background'] ?? null);
if ($bgColor) {
$vars['--header-background-color'] = $bgColor;
// Only set gradient variables if banner image is enabled
if ($shouldShowBanner) {
$vars['--header-gradient-start'] = $this->hexToRgba($bgColor, 0);
$vars['--header-gradient-end'] = $this->hexToRgba($bgColor, 1);
}
}
$shouldShowBannerGradient = ($display['showBannerGradient'] ?? '') === 'yes';
if ($shouldShowBanner && $shouldShowBannerGradient) {
// If the user didn't set a custom background color, prefer existing theme defaults instead of falling back to black.
if (!isset($vars['--header-gradient-start'])) {
$vars['--header-gradient-start'] = 'var(--color-header-gradient-start, rgba(242, 242, 242, 0))';
}
if (!isset($vars['--header-gradient-end'])) {
$vars['--header-gradient-end'] = 'var(--color-header-gradient-end, rgba(242, 242, 242, 1))';
}
$start = $vars['--header-gradient-start'];
$end = $vars['--header-gradient-end'];
// Keep compatibility with older CSS that expects these names.
$vars['--color-header-gradient-start'] = $start;
$vars['--color-header-gradient-end'] = $end;
$vars['--banner-gradient'] = sprintf(
'linear-gradient(90deg, %s 0, %s var(--banner-gradient-stop, 30%%))',
$start,
$end
);
}
if (empty($vars)) {
return null;
}
return [
'vars' => $vars,
'diagnostics' => [
'theme' => $display['theme'] ?? null,
],
];
}
private function renderThemeVars(array $cssVars, string $source, array $diagnostics = []): string
{
$cssRules = [];
foreach ($cssVars as $key => $value) {
if (!is_string($key) || !is_string($value) || $value === '') {
continue;
}
if (!preg_match('/^--[A-Za-z0-9_-]+$/', $key)) {
continue;
}
$safeKey = htmlspecialchars($key, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$safeValue = str_replace('</style>', '<\/style>', $value);
$cssRules[] = sprintf(
' %s: %s;',
$safeKey,
$safeValue
);
}
if (empty($cssRules)) {
return '';
}
return '<style id="unraid-theme-css-vars">
:root {
' . implode("\n", $cssRules) . '
}
</style>';
}
private function getThemeInitScript(): string
{
$displayTheme = $this->getDisplayThemeVars();
if ($displayTheme) {
return $this->renderThemeVars(
$displayTheme['vars'],
'display',
$displayTheme['diagnostics'] ?? []
);
}
return '';
}
private static bool $scriptsOutput = false;
public function getScriptTagHtml(): string
{
// Use a static flag to ensure scripts are only output once per request
static $scriptsOutput = false;
if ($scriptsOutput) {
if (self::$scriptsOutput) {
return '<!-- Resources already loaded -->';
}
try {
$scriptsOutput = true;
return $this->processManifestFiles();
self::$scriptsOutput = true;
$themeScript = $this->getThemeInitScript();
$manifestScripts = $this->processManifestFiles();
return $themeScript . "\n" . $manifestScripts;
} catch (\Exception $e) {
error_log("Error in WebComponentsExtractor::getScriptTagHtml: " . $e->getMessage());
$scriptsOutput = false; // Reset on error
self::$scriptsOutput = false; // Reset on error
return "";
}
}
public static function resetScriptsOutput(): void
{
self::$scriptsOutput = false;
}
}

View File

@@ -108,7 +108,7 @@ class UnraidOsCheck
* Note: CURLINFO_HEADER_OUT exposes request headers (not response headers) for curl_getinfo
* @return string|false $out The fetched content
*/
private function safe_http_get_contents(string $url, array $opts = [], array &$getinfo = NULL) {
private function safe_http_get_contents(string $url, array $opts = [], ?array &$getinfo = NULL) {
// Use system http_get_contents if it exists
if (function_exists('http_get_contents')) {
return http_get_contents($url, $opts, $getinfo);

View File

@@ -46,6 +46,11 @@ class UnraidUpdateCancel
$readmeContent .= "Unraid OS by [Lime Technology, Inc.](https://lime-technology.com).\n";
file_put_contents($readmeFile, $readmeContent);
// Delete plugin files that were downloaded during the OS upgrade
if (is_dir("/boot/config/plugins-nextboot")) {
shell_exec("rm -rf /boot/config/plugins-nextboot");
}
return ['success' => true]; // Upgrade handled successfully
} catch (\Throwable $th) {
return [

View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Redirect Page</title>
</head>
<body>
<div id="text" style="text-align: center; margin-top: calc(100vh - 75%); display: none; font-family: sans-serif;">
<h1>Redirecting...</h1>
<h2><a id="redirectButton" href="/Main">Click here if you are not redirected automatically</a></h2>
</div>
<script>
(function () {
function parseRedirectTarget(target) {
if (target && target !== '/') {
// Parse target and ensure it is a bare path with no query parameters.
// This keeps us on the same origin and avoids arbitrary redirects.
try {
const url = new URL(target, window.location.origin);
return url.pathname || '/Main';
} catch (_e) {
// If the target is malformed, fall back safely.
return '/Main';
}
}
return '/Main';
}
function getRedirectUrl() {
const search = new URLSearchParams(window.location.search);
const rawHash = window.location.hash || '';
const hashString = rawHash.charAt(0) === '#' ? rawHash.substring(1) : rawHash;
let hashData = '';
if (hashString.startsWith('data=')) {
hashData = hashString.slice('data='.length);
}
const targetRoute = parseRedirectTarget(search.get('target'));
const baseUrl = `${window.location.origin}${targetRoute}`;
// If the incoming URL already has a hash-based data payload, preserve it exactly.
if (hashData) {
return `${baseUrl}#data=${hashData}`;
}
// Fallback: accept legacy ?data= input and convert it to hash-based data.
const queryData = search.get('data');
if (queryData) {
const encoded = encodeURIComponent(queryData);
return `${baseUrl}#data=${encoded}`;
}
return baseUrl;
}
function showText() {
const textEl = document.getElementById('text');
if (textEl) {
textEl.style.display = 'block';
}
}
function startRedirect() {
setTimeout(showText, 750);
const redirectUrl = getRedirectUrl();
console.log('[redirect.htm] redirecting to:', redirectUrl);
const link = document.getElementById('redirectButton');
if (link) {
link.setAttribute('href', redirectUrl);
}
window.location.href = redirectUrl;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startRedirect);
} else {
startRedirect();
}
})();
</script>
</body>
</html>

View File

@@ -101,6 +101,29 @@ class ExtractorTest {
'file' => 'special\'file".css'
]
], JSON_PRETTY_PRINT));
// Create an invalid JSON manifest to ensure it is safely ignored
file_put_contents($this->componentDir . '/other/invalid.manifest.json', '{ invalid json ');
// Create an empty manifest file
file_put_contents($this->componentDir . '/other/empty.manifest.json', '');
// Create a manifest with unsupported file types to ensure they are ignored
file_put_contents($this->componentDir . '/other/unsupported.manifest.json', json_encode([
'image-entry' => [
'file' => 'logo.svg'
],
'font-entry' => [
'file' => 'font.woff2'
]
], JSON_PRETTY_PRINT));
// Create a manifest with invalid CSS list entries (only strings should be emitted)
file_put_contents($this->componentDir . '/other/css-list-invalid.manifest.json', json_encode([
'css-list-test' => [
'file' => 'css-list-test.js',
'css' => ['ok.css', '', null, 0, false]
]
], JSON_PRETTY_PRINT));
// Copy and modify the extractor for testing
$this->prepareExtractor();
@@ -120,14 +143,28 @@ class ExtractorTest {
file_put_contents($this->testDir . '/extractor.php', $extractorContent);
}
private function getExtractorOutput() {
private function getExtractorOutput($resetStatic = false) {
$_SERVER['DOCUMENT_ROOT'] = '/usr/local/emhttp';
require_once $this->testDir . '/extractor.php';
if ($resetStatic) {
$this->resetExtractor();
}
$extractor = WebComponentsExtractor::getInstance();
return $extractor->getScriptTagHtml();
}
private function getExtractorOutputWithDisplay(?array $display): string
{
if ($display === null) {
unset($GLOBALS['display']);
} else {
$GLOBALS['display'] = $display;
}
return $this->getExtractorOutput(true);
}
private function runTests() {
echo "\n";
echo "========================================\n";
@@ -298,6 +335,32 @@ class ExtractorTest {
"CSS from manifest has data-unraid attribute",
preg_match('/<link[^>]+id="unraid-[^"]*-css-[^"]+"[^>]+data-unraid="1"/', $output) > 0
);
$this->test(
"Ignores non-string/empty entries in css array",
preg_match_all('/id="unraid-other-css-list-test-css-[^"]+"/', $output, $matches) === 1 &&
isset($matches[0][0]) &&
strpos($matches[0][0], 'id="unraid-other-css-list-test-css-ok-css"') !== false
);
// Test: Manifest Format Robustness
echo "\nTest: Manifest Format Robustness\n";
echo "---------------------------------\n";
$this->testManifestContentsRobustness();
$this->test(
"Does not generate tags for unsupported file extensions",
strpos($output, 'logo.svg') === false &&
strpos($output, 'font.woff2') === false
);
// Test: CSS Variable Validation
echo "\nTest: CSS Variable Validation\n";
echo "------------------------------\n";
$this->testCssVariableValidation();
// Test: Display Variations / Theme CSS Vars
echo "\nTest: Display Variations\n";
echo "-------------------------\n";
$this->testDisplayVariations();
// Test: Duplicate Prevention
echo "\nTest: Duplicate Prevention\n";
@@ -317,6 +380,282 @@ class ExtractorTest {
);
}
private function testCssVariableValidation() {
$_SERVER['DOCUMENT_ROOT'] = '/usr/local/emhttp';
require_once $this->testDir . '/extractor.php';
$extractor = WebComponentsExtractor::getInstance();
$reflection = new ReflectionClass('WebComponentsExtractor');
$method = $reflection->getMethod('renderThemeVars');
$method->setAccessible(true);
// Test valid CSS variable names
$validVars = [
'--header-text-primary' => '#ffffff',
'--header-text-secondary' => '#cccccc',
'--header-background-color' => '#000000',
'--test-var' => 'value',
'--test_var' => 'value',
'--test123' => 'value',
'--A-Z_a-z0-9' => 'value',
];
$output = $method->invoke($extractor, $validVars, 'test');
$this->test(
"Accepts valid CSS variable names starting with --",
strpos($output, '--header-text-primary') !== false &&
strpos($output, '--test-var') !== false &&
strpos($output, '--test_var') !== false &&
strpos($output, '--test123') !== false
);
// Test invalid CSS variable names (should be rejected)
$invalidVars = [
'not-a-var' => 'value',
'-not-a-var' => 'value',
'--var with spaces' => 'value',
'--var<script>' => 'value',
'--var"quote' => 'value',
'--var\'quote' => 'value',
'--var;injection' => 'value',
'--var:colon' => 'value',
'--var.value' => 'value',
'--var/value' => 'value',
'--var\\backslash' => 'value',
'' => 'value',
'--' => 'value',
];
$output = $method->invoke($extractor, $invalidVars, 'test');
$this->test(
"Rejects CSS variable names without -- prefix",
strpos($output, 'not-a-var') === false &&
strpos($output, '-not-a-var') === false
);
$this->test(
"Rejects CSS variable names with spaces",
strpos($output, 'var with spaces') === false
);
$this->test(
"Rejects CSS variable names with script tags",
strpos($output, '<script>') === false &&
strpos($output, 'var<script>') === false
);
$this->test(
"Rejects CSS variable names with quotes",
strpos($output, 'var"quote') === false &&
strpos($output, "var'quote") === false
);
$this->test(
"Rejects CSS variable names with semicolons",
strpos($output, 'var;injection') === false
);
$this->test(
"Rejects CSS variable names with dots",
strpos($output, 'var.value') === false
);
$this->test(
"Rejects empty or minimal invalid keys",
strpos($output, ': --;') === false
);
// Test mixed valid and invalid (only valid should appear)
$mixedVars = [
'--valid-var' => 'value1',
'invalid-var' => 'value2',
'--another-valid' => 'value3',
'--invalid<script>' => 'value4',
];
$output = $method->invoke($extractor, $mixedVars, 'test');
$this->test(
"Accepts valid variables and rejects invalid ones in mixed input",
strpos($output, '--valid-var') !== false &&
strpos($output, '--another-valid') !== false &&
strpos($output, 'invalid-var') === false &&
strpos($output, '<script>') === false
);
// Test non-string keys (should be rejected)
$nonStringKeys = [
'--valid' => 'value',
123 => 'value',
true => 'value',
null => 'value',
];
$output = $method->invoke($extractor, $nonStringKeys, 'test');
$this->test(
"Rejects non-string keys",
strpos($output, '--valid') !== false &&
strpos($output, '123') === false
);
}
private function testDisplayVariations(): void
{
// No $display => no theme CSS vars injected
$output = $this->getExtractorOutputWithDisplay(null);
$this->test(
"No display data produces no theme CSS var style tag",
strpos($output, 'id="unraid-theme-css-vars"') === false
);
// Banner empty + gradient yes => gradient should be ignored (no banner image)
$output = $this->getExtractorOutputWithDisplay([
'theme' => 'azure',
'banner' => '',
'showBannerGradient' => 'yes',
'background' => '112233',
]);
$this->test(
"Banner disabled suppresses --banner-gradient",
strpos($output, '--banner-gradient:') === false
);
$this->test(
"Banner disabled suppresses header gradient start/end",
strpos($output, '--header-gradient-start:') === false &&
strpos($output, '--header-gradient-end:') === false
);
// Banner enabled + gradient yes + valid background => gradient vars and banner gradient
$output = $this->getExtractorOutputWithDisplay([
'theme' => 'azure',
'banner' => 'image',
'showBannerGradient' => 'yes',
'background' => '112233',
]);
$this->test(
"Injects theme vars style tag",
strpos($output, 'id="unraid-theme-css-vars"') !== false &&
strpos($output, ':root {') !== false
);
$this->test(
"Sets --theme-name from display theme",
strpos($output, '--theme-name: azure;') !== false
);
$this->test(
"Sets --theme-dark-mode for non-dark themes",
strpos($output, '--theme-dark-mode: 0;') !== false
);
$this->test(
"Normalizes and sets background color",
strpos($output, '--header-background-color: #112233;') !== false
);
$this->test(
"Derives header gradient start/end from background",
strpos($output, '--header-gradient-start: rgba(17, 34, 51, 0.000);') !== false &&
strpos($output, '--header-gradient-end: rgba(17, 34, 51, 1.000);') !== false
);
$this->test(
"Emits --banner-gradient with banner stop variable",
strpos($output, '--banner-gradient: linear-gradient(90deg,') !== false &&
strpos($output, 'var(--banner-gradient-stop, 30%)') !== false
);
// Banner enabled + gradient yes but no custom background => should use theme defaults (not black fallbacks)
$output = $this->getExtractorOutputWithDisplay([
'theme' => 'azure',
'banner' => 'image',
'showBannerGradient' => 'yes',
]);
$this->test(
"No custom background uses theme defaults for gradient vars",
strpos($output, '--header-gradient-start: var(--color-header-gradient-start') !== false &&
strpos($output, '--header-gradient-end: var(--color-header-gradient-end') !== false
);
$this->test(
"No custom background still emits --banner-gradient",
strpos($output, '--banner-gradient: linear-gradient(90deg,') !== false
);
// Banner enabled + gradient no => no --banner-gradient, but does set start/end for other CSS usage
$output = $this->getExtractorOutputWithDisplay([
'theme' => 'azure',
'banner' => 'image',
'showBannerGradient' => 'no',
'background' => '112233',
]);
$this->test(
"Gradient disabled suppresses --banner-gradient",
strpos($output, '--banner-gradient:') === false
);
$this->test(
"Banner enabled still emits header gradient start/end",
strpos($output, '--header-gradient-start:') !== false &&
strpos($output, '--header-gradient-end:') !== false
);
// Dark themes set --theme-dark-mode = 1
$output = $this->getExtractorOutputWithDisplay([
'theme' => 'black',
'banner' => 'image',
'showBannerGradient' => 'yes',
'background' => '112233',
]);
$this->test(
"Dark theme sets --theme-dark-mode to 1",
strpos($output, '--theme-dark-mode: 1;') !== false &&
strpos($output, '--theme-name: black;') !== false
);
// Hex normalization: 3-digit values expand and lower-case
$output = $this->getExtractorOutputWithDisplay([
'theme' => 'azure',
'banner' => 'image',
'showBannerGradient' => 'yes',
'background' => 'aBc',
'header' => 'FfF',
'headermetacolor' => '#0F0',
]);
$this->test(
"Normalizes 3-digit hex values",
strpos($output, '--header-background-color: #aabbcc;') !== false &&
strpos($output, '--header-text-primary: #ffffff;') !== false &&
strpos($output, '--header-text-secondary: #00ff00;') !== false
);
// Invalid background => should not emit background var
$output = $this->getExtractorOutputWithDisplay([
'theme' => 'azure',
'banner' => 'image',
'showBannerGradient' => 'yes',
'background' => 'not-a-hex',
]);
$this->test(
"Rejects invalid background color",
strpos($output, '--header-background-color:') === false
);
}
private function testManifestContentsRobustness(): void
{
$_SERVER['DOCUMENT_ROOT'] = '/usr/local/emhttp';
require_once $this->testDir . '/extractor.php';
$extractor = WebComponentsExtractor::getInstance();
$missing = $extractor->getManifestContents($this->componentDir . '/other/does-not-exist.manifest.json');
$this->test(
"Missing manifest returns an empty array",
is_array($missing) && $missing === []
);
$empty = $extractor->getManifestContents($this->componentDir . '/other/empty.manifest.json');
$this->test(
"Empty manifest returns an empty array",
is_array($empty) && $empty === []
);
$invalid = $extractor->getManifestContents($this->componentDir . '/other/invalid.manifest.json');
$this->test(
"Invalid JSON manifest returns an empty array",
is_array($invalid) && $invalid === []
);
$valid = $extractor->getManifestContents($this->componentDir . '/other/manifest.json');
$this->test(
"Valid manifest decodes to an array",
is_array($valid) && isset($valid['app-entry']) && isset($valid['app-styles'])
);
}
private function test($name, $condition) {
if ($condition) {
echo " " . self::GREEN . "" . self::NC . " " . $name . "\n";
@@ -352,6 +691,19 @@ class ExtractorTest {
return preg_replace('/[^a-zA-Z0-9-]/', '-', $input);
}
private function resetExtractor() {
// Reset singleton instance
if (class_exists('WebComponentsExtractor')) {
$reflection = new ReflectionClass('WebComponentsExtractor');
$instance = $reflection->getProperty('instance');
$instance->setAccessible(true);
$instance->setValue(null, null);
// Reset static flag
WebComponentsExtractor::resetScriptsOutput();
}
}
private function reportResults() {
echo "\n";
echo "========================================\n";

13
pnpm-lock.yaml generated
View File

@@ -1086,8 +1086,8 @@ importers:
specifier: 4.0.0-alpha.0
version: 4.0.0-alpha.0(@babel/parser@7.28.4)(@netlify/blobs@9.1.2)(change-case@5.4.4)(db0@0.3.2)(embla-carousel@8.6.0)(focus-trap@7.6.5)(ioredis@5.7.0)(jwt-decode@4.0.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vue-router@4.5.1(vue@3.5.20(typescript@5.9.2)))(vue@3.5.20(typescript@5.9.2))(zod@3.25.76)
'@unraid/shared-callbacks':
specifier: 1.1.1
version: 1.1.1(@vueuse/core@13.8.0(vue@3.5.20(typescript@5.9.2)))
specifier: 3.0.0
version: 3.0.0
'@unraid/ui':
specifier: link:../unraid-ui
version: link:../unraid-ui
@@ -5096,10 +5096,8 @@ packages:
cpu: [x64, arm64]
os: [linux, darwin]
'@unraid/shared-callbacks@1.1.1':
resolution: {integrity: sha512-14x5HFBOIVfUpQFAAhcRqIvj3AIsOyx90BdShXtddW55kiVtg+dDfsnlzExSYWhb35C6gYKZ0Sm9ZhF/YamGzg==}
peerDependencies:
'@vueuse/core': ^10.9.0 || ^13.0.0
'@unraid/shared-callbacks@3.0.0':
resolution: {integrity: sha512-O4AN5nsmnwUQ1utYhG2wS9L2NAFn3eOg5YHKq9h9EUa3n8xQeUOzeM6UV2xBg9YJGuF3wQsaEpfj1GyX/MIAGw==}
'@unraid/tailwind-rem-to-rem@2.0.0':
resolution: {integrity: sha512-zccpQx5fvEBkAB0JkRwwtyRrT9l26LsjkozLy44LGv0NdZGaxgscniIqJRM+OQj5pSpsWDzExebAtUKdE98Flg==}
@@ -17140,9 +17138,8 @@ snapshots:
- encoding
- supports-color
'@unraid/shared-callbacks@1.1.1(@vueuse/core@13.8.0(vue@3.5.20(typescript@5.9.2)))':
'@unraid/shared-callbacks@3.0.0':
dependencies:
'@vueuse/core': 13.8.0(vue@3.5.20(typescript@5.9.2))
crypto-js: 4.2.0
'@unraid/tailwind-rem-to-rem@2.0.0(tailwindcss@4.1.12)':

View File

@@ -210,22 +210,34 @@ Once you have your key pair, add your public SSH key to your Unraid server:
### Development Modes
The project supports two development modes:
#### Mode 1: Local Plugin Build (Docker)
#### Mode 1: Build Watcher with Local Plugin
This mode builds the plugin continuously and serves it locally for installation on your Unraid server:
Build and test a full plugin locally using Docker:
```sh
# From the root directory (api/)
pnpm build:watch
cd plugin
pnpm run docker:build-and-run
# Then inside the container:
pnpm build
```
This command will output a local plugin URL that you can install on your Unraid server by navigating to Plugins → Install Plugin. Be aware it will take a *while* to build the first time.
This builds all dependencies (API, web), starts a Docker container, and serves the plugin at `http://YOUR_IP:5858/`. Install it on your Unraid server via Plugins → Install Plugin.
#### Mode 2: Development Servers
#### Mode 2: Direct Deployment
For active development with hot-reload:
Deploy individual packages directly to an Unraid server for faster iteration:
```sh
# Deploy API changes
cd api && pnpm unraid:deploy <SERVER_IP>
# Deploy web changes
cd web && pnpm unraid:deploy <SERVER_IP>
```
#### Mode 3: Development Servers
For active development with hot-reload (no Unraid server needed):
```sh
# From the root directory - runs all dev servers concurrently
@@ -238,22 +250,11 @@ Or run individual development servers:
# API server (GraphQL backend at http://localhost:3001)
cd api && pnpm dev
# Web interface (Nuxt frontend at http://localhost:3000)
# Web interface (Nuxt frontend at http://localhost:3000)
cd web && pnpm dev
```
### Building the Full Plugin
To build the complete plugin package (.plg file):
```sh
# From the root directory (api/)
pnpm build:plugin
# The plugin will be created in plugin/dynamix.unraid.net.plg
```
To deploy the plugin to your Unraid server:
### Deploying to Unraid
```sh
# Replace SERVER_IP with your Unraid server's IP address

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/ui",
"version": "4.26.2",
"version": "4.29.1",
"private": true,
"license": "GPL-2.0-or-later",
"type": "module",

View File

@@ -1,12 +1,16 @@
import useTeleport from '@/composables/useTeleport';
import { mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent } from 'vue';
import { defineComponent, nextTick } from 'vue';
describe('useTeleport', () => {
beforeEach(() => {
// Reset modules before each test to ensure fresh state
vi.resetModules();
// Clear the DOM before each test
document.body.innerHTML = '';
document.documentElement.classList.remove('dark');
document.body.classList.remove('dark');
document.documentElement.style.removeProperty('--theme-dark-mode');
vi.clearAllMocks();
});
@@ -16,16 +20,19 @@ describe('useTeleport', () => {
if (virtualContainer) {
virtualContainer.remove();
}
// Reset the module to clear the virtualModalContainer variable
vi.resetModules();
document.documentElement.classList.remove('dark');
document.body.classList.remove('dark');
document.documentElement.style.removeProperty('--theme-dark-mode');
});
it('should return teleportTarget ref with correct value', () => {
it('should return teleportTarget ref with correct value', async () => {
const useTeleport = (await import('@/composables/useTeleport')).default;
const { teleportTarget } = useTeleport();
expect(teleportTarget.value).toBe('#unraid-api-modals-virtual');
});
it('should create virtual container element on mount with correct properties', () => {
it('should create virtual container element on mount with correct properties', async () => {
const useTeleport = (await import('@/composables/useTeleport')).default;
const TestComponent = defineComponent({
setup() {
const { teleportTarget } = useTeleport();
@@ -39,6 +46,7 @@ describe('useTeleport', () => {
// Mount the component
mount(TestComponent);
await nextTick();
// After mount, virtual container should be created with correct properties
const virtualContainer = document.getElementById('unraid-api-modals-virtual');
@@ -49,7 +57,8 @@ describe('useTeleport', () => {
expect(virtualContainer?.parentElement).toBe(document.body);
});
it('should reuse existing virtual container within same test', () => {
it('should reuse existing virtual container within same test', async () => {
const useTeleport = (await import('@/composables/useTeleport')).default;
// Manually create the container first
const manualContainer = document.createElement('div');
manualContainer.id = 'unraid-api-modals-virtual';
@@ -68,10 +77,128 @@ describe('useTeleport', () => {
// Mount component - should not create a new container
mount(TestComponent);
await nextTick();
// Should still have only one container
const containers = document.querySelectorAll('#unraid-api-modals-virtual');
expect(containers.length).toBe(1);
expect(containers[0]).toBe(manualContainer);
});
it('should apply dark class when dark mode is active via CSS variable', async () => {
const useTeleport = (await import('@/composables/useTeleport')).default;
const originalGetComputedStyle = window.getComputedStyle;
const getComputedStyleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
const style = originalGetComputedStyle(el);
if (el === document.documentElement) {
return {
...style,
getPropertyValue: (prop: string) => {
if (prop === '--theme-dark-mode') {
return '1';
}
return style.getPropertyValue(prop);
},
} as CSSStyleDeclaration;
}
return style;
});
const TestComponent = defineComponent({
setup() {
const { teleportTarget } = useTeleport();
return { teleportTarget };
},
template: '<div>{{ teleportTarget }}</div>',
});
const wrapper = mount(TestComponent);
await nextTick();
const virtualContainer = document.getElementById('unraid-api-modals-virtual');
expect(virtualContainer).toBeTruthy();
expect(virtualContainer?.classList.contains('dark')).toBe(true);
wrapper.unmount();
getComputedStyleSpy.mockRestore();
});
it('should not apply dark class when dark mode is inactive via CSS variable', async () => {
const useTeleport = (await import('@/composables/useTeleport')).default;
const originalGetComputedStyle = window.getComputedStyle;
const getComputedStyleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
const style = originalGetComputedStyle(el);
if (el === document.documentElement) {
return {
...style,
getPropertyValue: (prop: string) => {
if (prop === '--theme-dark-mode') {
return '0';
}
return style.getPropertyValue(prop);
},
} as CSSStyleDeclaration;
}
return style;
});
const TestComponent = defineComponent({
setup() {
const { teleportTarget } = useTeleport();
return { teleportTarget };
},
template: '<div>{{ teleportTarget }}</div>',
});
const wrapper = mount(TestComponent);
await nextTick();
const virtualContainer = document.getElementById('unraid-api-modals-virtual');
expect(virtualContainer).toBeTruthy();
expect(virtualContainer?.classList.contains('dark')).toBe(false);
wrapper.unmount();
getComputedStyleSpy.mockRestore();
});
it('should apply dark class when dark mode is active via documentElement class', async () => {
const useTeleport = (await import('@/composables/useTeleport')).default;
document.documentElement.classList.add('dark');
const originalGetComputedStyle = window.getComputedStyle;
const getComputedStyleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
const style = originalGetComputedStyle(el);
if (el === document.documentElement) {
return {
...style,
getPropertyValue: (prop: string) => {
if (prop === '--theme-dark-mode') {
return '';
}
return style.getPropertyValue(prop);
},
} as CSSStyleDeclaration;
}
return style;
});
const TestComponent = defineComponent({
setup() {
const { teleportTarget } = useTeleport();
return { teleportTarget };
},
template: '<div>{{ teleportTarget }}</div>',
});
const wrapper = mount(TestComponent);
await nextTick();
const virtualContainer = document.getElementById('unraid-api-modals-virtual');
expect(virtualContainer).toBeTruthy();
expect(virtualContainer?.classList.contains('dark')).toBe(true);
wrapper.unmount();
getComputedStyleSpy.mockRestore();
document.documentElement.classList.remove('dark');
});
});

View File

@@ -1,15 +1,24 @@
import { isDarkModeActive } from '@/lib/utils';
import { onMounted, ref } from 'vue';
let virtualModalContainer: HTMLDivElement | null = null;
const ensureVirtualContainer = () => {
if (!virtualModalContainer) {
virtualModalContainer = document.createElement('div');
virtualModalContainer.id = 'unraid-api-modals-virtual';
virtualModalContainer.className = 'unapi';
virtualModalContainer.style.position = 'relative';
virtualModalContainer.style.zIndex = '999999';
document.body.appendChild(virtualModalContainer);
const existing = document.getElementById('unraid-api-modals-virtual');
if (existing) {
virtualModalContainer = existing as HTMLDivElement;
} else {
virtualModalContainer = document.createElement('div');
virtualModalContainer.id = 'unraid-api-modals-virtual';
virtualModalContainer.className = 'unapi';
virtualModalContainer.style.position = 'relative';
virtualModalContainer.style.zIndex = '999999';
if (isDarkModeActive()) {
virtualModalContainer.classList.add('dark');
}
document.body.appendChild(virtualModalContainer);
}
}
return virtualModalContainer;
};

View File

@@ -0,0 +1,193 @@
import { isDarkModeActive } from '@/lib/utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
describe('isDarkModeActive', () => {
const originalGetComputedStyle = window.getComputedStyle;
const originalDocumentElement = document.documentElement;
const originalBody = document.body;
beforeEach(() => {
document.documentElement.classList.remove('dark');
document.body.classList.remove('dark');
document.documentElement.style.removeProperty('--theme-dark-mode');
document.querySelectorAll('.unapi').forEach((el) => el.classList.remove('dark'));
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
document.documentElement.classList.remove('dark');
document.body.classList.remove('dark');
document.documentElement.style.removeProperty('--theme-dark-mode');
document.querySelectorAll('.unapi').forEach((el) => el.classList.remove('dark'));
});
describe('CSS variable detection', () => {
it('should return true when CSS variable is set to "1"', () => {
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
const style = originalGetComputedStyle(el);
if (el === document.documentElement) {
return {
...style,
getPropertyValue: (prop: string) => {
if (prop === '--theme-dark-mode') {
return '1';
}
return style.getPropertyValue(prop);
},
} as CSSStyleDeclaration;
}
return style;
});
expect(isDarkModeActive()).toBe(true);
});
it('should return false when CSS variable is set to "0"', () => {
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
const style = originalGetComputedStyle(el);
if (el === document.documentElement) {
return {
...style,
getPropertyValue: (prop: string) => {
if (prop === '--theme-dark-mode') {
return '0';
}
return style.getPropertyValue(prop);
},
} as CSSStyleDeclaration;
}
return style;
});
expect(isDarkModeActive()).toBe(false);
});
it('should return false when CSS variable is explicitly "0" even if dark class exists', () => {
document.documentElement.classList.add('dark');
document.body.classList.add('dark');
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
const style = originalGetComputedStyle(el);
if (el === document.documentElement) {
return {
...style,
getPropertyValue: (prop: string) => {
if (prop === '--theme-dark-mode') {
return '0';
}
return style.getPropertyValue(prop);
},
} as CSSStyleDeclaration;
}
return style;
});
expect(isDarkModeActive()).toBe(false);
});
});
describe('ClassList detection fallback', () => {
it('should return true when documentElement has dark class and CSS variable is not set', () => {
document.documentElement.classList.add('dark');
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
const style = originalGetComputedStyle(el);
if (el === document.documentElement) {
return {
...style,
getPropertyValue: (prop: string) => {
if (prop === '--theme-dark-mode') {
return '';
}
return style.getPropertyValue(prop);
},
} as CSSStyleDeclaration;
}
return style;
});
expect(isDarkModeActive()).toBe(true);
});
it('should return true when body has dark class and CSS variable is not set', () => {
document.body.classList.add('dark');
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
const style = originalGetComputedStyle(el);
if (el === document.documentElement) {
return {
...style,
getPropertyValue: (prop: string) => {
if (prop === '--theme-dark-mode') {
return '';
}
return style.getPropertyValue(prop);
},
} as CSSStyleDeclaration;
}
return style;
});
expect(isDarkModeActive()).toBe(true);
});
it('should return true when .unapi.dark element exists and CSS variable is not set', () => {
const unapiElement = document.createElement('div');
unapiElement.className = 'unapi dark';
document.body.appendChild(unapiElement);
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
const style = originalGetComputedStyle(el);
if (el === document.documentElement) {
return {
...style,
getPropertyValue: (prop: string) => {
if (prop === '--theme-dark-mode') {
return '';
}
return style.getPropertyValue(prop);
},
} as CSSStyleDeclaration;
}
return style;
});
expect(isDarkModeActive()).toBe(true);
unapiElement.remove();
});
it('should return false when no dark indicators are present', () => {
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
const style = originalGetComputedStyle(el);
if (el === document.documentElement) {
return {
...style,
getPropertyValue: (prop: string) => {
if (prop === '--theme-dark-mode') {
return '';
}
return style.getPropertyValue(prop);
},
} as CSSStyleDeclaration;
}
return style;
});
expect(isDarkModeActive()).toBe(false);
});
});
describe('SSR/Node environment', () => {
it('should return false when document is undefined', () => {
const originalDocument = global.document;
// @ts-expect-error - intentionally removing document for SSR test
global.document = undefined;
expect(isDarkModeActive()).toBe(false);
global.document = originalDocument;
});
});
});

View File

@@ -54,3 +54,17 @@ export class Markdown {
return Markdown.instance.parse(markdownContent);
}
}
export const isDarkModeActive = (): boolean => {
if (typeof document === 'undefined') return false;
const cssVar = getComputedStyle(document.documentElement).getPropertyValue('--theme-dark-mode').trim();
if (cssVar === '1') return true;
if (cssVar === '0') return false;
if (document.documentElement.classList.contains('dark')) return true;
if (document.body?.classList.contains('dark')) return true;
if (document.querySelector('.unapi.dark')) return true;
return false;
};

View File

@@ -2,3 +2,4 @@ auto-imports.d.ts
components.d.ts
composables/gql/
src/composables/gql/
dist/

View File

@@ -22,6 +22,13 @@ vi.mock('@vue/apollo-composable', () => ({
onResult: vi.fn(),
onError: vi.fn(),
}),
useLazyQuery: () => ({
load: vi.fn(),
result: ref(null),
loading: ref(false),
onResult: vi.fn(),
onError: vi.fn(),
}),
}));
// Explicitly mock @unraid/ui to ensure we use the actual components
@@ -54,6 +61,11 @@ describe('ColorSwitcher', () => {
beforeEach(() => {
vi.useFakeTimers();
// Set CSS variables for theme store
document.documentElement.style.setProperty('--theme-dark-mode', '0');
document.documentElement.style.setProperty('--banner-gradient', '');
const pinia = createTestingPinia({ createSpy: vi.fn });
setActivePinia(pinia);
themeStore = useThemeStore();
@@ -69,8 +81,12 @@ describe('ColorSwitcher', () => {
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
document.body.removeChild(modalDiv);
consoleWarnSpy.mockRestore();
if (modalDiv && modalDiv.parentNode) {
modalDiv.parentNode.removeChild(modalDiv);
}
if (consoleWarnSpy) {
consoleWarnSpy.mockRestore();
}
});
it('renders all form elements correctly', () => {

View File

@@ -1,110 +0,0 @@
/**
* DownloadApiLogs Component Test Coverage
*/
import { mount } from '@vue/test-utils';
import { BrandButton } from '@unraid/ui';
import { createTestingPinia } from '@pinia/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import DownloadApiLogs from '~/components/DownloadApiLogs.standalone.vue';
import { createTestI18n, testTranslate } from '../utils/i18n';
vi.mock('~/helpers/urls', () => ({
CONNECT_FORUMS: new URL('http://mock-forums.local'),
CONTACT: new URL('http://mock-contact.local'),
DISCORD: new URL('http://mock-discord.local'),
WEBGUI_GRAPHQL: '/graphql',
}));
vi.mock('vue-i18n', async (importOriginal) => {
const actual = (await importOriginal()) as typeof import('vue-i18n');
return {
...actual,
useI18n: () => ({
t: testTranslate,
}),
};
});
describe('DownloadApiLogs', () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock global csrf_token
globalThis.csrf_token = 'mock-csrf-token';
});
it('provides a download button with the correct URL', () => {
const wrapper = mount(DownloadApiLogs, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
stubs: {
ArrowDownTrayIcon: true,
ArrowTopRightOnSquareIcon: true,
},
},
});
// Expected download URL
const expectedUrl = '/graphql/api/logs?csrf_token=mock-csrf-token';
// Find the download button
const downloadButton = wrapper.findComponent(BrandButton);
// Verify download button exists and has correct attributes
expect(downloadButton.exists()).toBe(true);
expect(downloadButton.attributes('href')).toBe(expectedUrl);
expect(downloadButton.attributes('download')).toBe('');
expect(downloadButton.attributes('target')).toBe('_blank');
expect(downloadButton.attributes('rel')).toBe('noopener noreferrer');
expect(downloadButton.text()).toContain(testTranslate('downloadApiLogs.downloadUnraidApiLogs'));
});
it('displays support links to documentation and help resources', () => {
const wrapper = mount(DownloadApiLogs, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
stubs: {
ArrowDownTrayIcon: true,
ArrowTopRightOnSquareIcon: true,
},
},
});
const links = wrapper.findAll('a');
expect(links.length).toBe(4);
expect(links[1].attributes('href')).toBe('http://mock-forums.local/');
expect(links[1].text()).toContain(testTranslate('downloadApiLogs.unraidConnectForums'));
expect(links[2].attributes('href')).toBe('http://mock-discord.local/');
expect(links[2].text()).toContain(testTranslate('downloadApiLogs.unraidDiscord'));
expect(links[3].attributes('href')).toBe('http://mock-contact.local/');
expect(links[3].text()).toContain(testTranslate('downloadApiLogs.unraidContactPage'));
links.slice(1).forEach((link) => {
expect(link.attributes('target')).toBe('_blank');
expect(link.attributes('rel')).toBe('noopener noreferrer');
});
});
it('displays instructions about log usage and privacy', () => {
const wrapper = mount(DownloadApiLogs, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
stubs: {
ArrowDownTrayIcon: true,
ArrowTopRightOnSquareIcon: true,
},
},
});
const text = wrapper.text();
expect(text).toContain(testTranslate('downloadApiLogs.thePrimaryMethodOfSupportFor'));
expect(text).toContain(testTranslate('downloadApiLogs.ifYouAreAskedToSupply'));
expect(text).toContain(testTranslate('downloadApiLogs.theLogsMayContainSensitiveInformation'));
});
});

View File

@@ -9,7 +9,7 @@ import { BrandButton } from '@unraid/ui';
import { createTestingPinia } from '@pinia/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ServerStateDataAction, ServerStateDataActionType } from '~/types/server';
import type { ServerStateDataAction } from '~/types/server';
import KeyActions from '~/components/KeyActions.vue';
import { createTestI18n } from '../utils/i18n';
@@ -34,7 +34,7 @@ describe('KeyActions', () => {
it('renders buttons from props when actions prop is provided', () => {
const actions: ServerStateDataAction[] = [
{ name: 'purchase' as ServerStateDataActionType, text: 'Custom Action 1', click: vi.fn() },
{ name: 'purchase', text: 'Custom Action 1', click: vi.fn() },
];
const wrapper = mount(KeyActions, {
@@ -68,9 +68,7 @@ describe('KeyActions', () => {
it('calls action click handler when button is clicked', async () => {
const click = vi.fn();
const actions: ServerStateDataAction[] = [
{ name: 'purchase' as ServerStateDataActionType, text: 'Clickable Action', click },
];
const actions: ServerStateDataAction[] = [{ name: 'purchase', text: 'Clickable Action', click }];
const wrapper = mount(KeyActions, {
props: {
@@ -89,7 +87,7 @@ describe('KeyActions', () => {
const click = vi.fn();
const actions: ServerStateDataAction[] = [
{
name: 'purchase' as ServerStateDataActionType,
name: 'purchase',
text: 'Disabled Action',
disabled: true,
click,
@@ -111,9 +109,9 @@ describe('KeyActions', () => {
it('filters actions using filterBy prop', () => {
const actions: ServerStateDataAction[] = [
{ name: 'purchase' as ServerStateDataActionType, text: 'Action 1', click: vi.fn() },
{ name: 'redeem' as ServerStateDataActionType, text: 'Action 2', click: vi.fn() },
{ name: 'upgrade' as ServerStateDataActionType, text: 'Action 3', click: vi.fn() },
{ name: 'purchase', text: 'Action 1', click: vi.fn() },
{ name: 'redeem', text: 'Action 2', click: vi.fn() },
{ name: 'upgrade', text: 'Action 3', click: vi.fn() },
];
const wrapper = mount(KeyActions, {
@@ -135,9 +133,9 @@ describe('KeyActions', () => {
it('filters out actions using filterOut prop', () => {
const actions: ServerStateDataAction[] = [
{ name: 'purchase' as ServerStateDataActionType, text: 'Action 1', click: vi.fn() },
{ name: 'redeem' as ServerStateDataActionType, text: 'Action 2', click: vi.fn() },
{ name: 'upgrade' as ServerStateDataActionType, text: 'Action 3', click: vi.fn() },
{ name: 'purchase', text: 'Action 1', click: vi.fn() },
{ name: 'redeem', text: 'Action 2', click: vi.fn() },
{ name: 'upgrade', text: 'Action 3', click: vi.fn() },
];
const wrapper = mount(KeyActions, {
@@ -158,9 +156,7 @@ describe('KeyActions', () => {
});
it('applies maxWidth styling when maxWidth prop is true', () => {
const actions: ServerStateDataAction[] = [
{ name: 'purchase' as ServerStateDataActionType, text: 'Action 1', click: vi.fn() },
];
const actions: ServerStateDataAction[] = [{ name: 'purchase', text: 'Action 1', click: vi.fn() }];
const wrapper = mount(KeyActions, {
props: {
@@ -180,7 +176,7 @@ describe('KeyActions', () => {
it('passes all required props to BrandButton component', () => {
const actions: ServerStateDataAction[] = [
{
name: 'purchase' as ServerStateDataActionType,
name: 'purchase',
text: 'Test Action',
title: 'Action Title',
href: '/test-link',

View File

@@ -13,7 +13,9 @@ import { createTestI18n } from '../utils/i18n';
vi.mock('@unraid/ui', () => ({
PageContainer: { template: '<div><slot /></div>' },
BrandLoading: { template: '<div data-testid="brand-loading-mock">Loading...</div>' },
BrandButton: {
template: '<button v-bind="$attrs" @click="$emit(\'click\')"><slot /></button>',
},
}));
const mockAccountStore = {
@@ -97,7 +99,7 @@ describe('UpdateOs.standalone.vue', () => {
});
describe('Initial Rendering and onBeforeMount Logic', () => {
it('shows loader and calls updateOs when path matches and rebootType is empty', async () => {
it('shows account button and does not auto-redirect when path matches and rebootType is empty', async () => {
window.location.pathname = '/Tools/Update';
mockRebootType.value = '';
@@ -105,7 +107,7 @@ describe('UpdateOs.standalone.vue', () => {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
stubs: {
// Rely on @unraid/ui mock for PageContainer & BrandLoading
// Rely on @unraid/ui mock for PageContainer & BrandButton
UpdateOsStatus: UpdateOsStatusStub,
UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub,
},
@@ -114,17 +116,9 @@ describe('UpdateOs.standalone.vue', () => {
await nextTick();
// When path matches and rebootType is empty, updateOs should be called
expect(mockAccountStore.updateOs).toHaveBeenCalledWith(true);
// Since v-show is used, both elements exist in DOM
expect(wrapper.find('[data-testid="brand-loading-mock"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
// The loader should be visible when showLoader is true
const loaderWrapper = wrapper.find('[data-testid="brand-loading-mock"]').element.parentElement;
expect(loaderWrapper?.style.display).not.toBe('none');
// The status should be hidden when showLoader is true
const statusWrapper = wrapper.find('[data-testid="update-os-status"]').element.parentElement;
expect(statusWrapper?.style.display).toBe('none');
expect(mockAccountStore.updateOs).not.toHaveBeenCalled();
expect(wrapper.find('[data-testid="update-os-account-button"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(false);
});
it('shows status and does not call updateOs when path does not match', async () => {
@@ -145,8 +139,7 @@ describe('UpdateOs.standalone.vue', () => {
await nextTick();
expect(mockAccountStore.updateOs).not.toHaveBeenCalled();
// Since v-show is used, both elements exist in DOM
expect(wrapper.find('[data-testid="brand-loading-mock"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="update-os-account-button"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
});
@@ -168,10 +161,30 @@ describe('UpdateOs.standalone.vue', () => {
await nextTick();
expect(mockAccountStore.updateOs).not.toHaveBeenCalled();
// Since v-show is used, both elements exist in DOM
expect(wrapper.find('[data-testid="brand-loading-mock"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="update-os-account-button"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
});
it('navigates to account update when the button is clicked', async () => {
window.location.pathname = '/Tools/Update';
mockRebootType.value = '';
const wrapper = mount(UpdateOs, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
stubs: {
UpdateOsStatus: UpdateOsStatusStub,
UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub,
},
},
});
await nextTick();
await wrapper.find('[data-testid="update-os-account-button"]').trigger('click');
expect(mockAccountStore.updateOs).toHaveBeenCalledWith(true);
});
});
describe('Rendering based on rebootType', () => {

View File

@@ -35,6 +35,13 @@ vi.mock('@vueuse/core', () => ({
isSupported: mockIsSupported,
};
},
useLocalStorage: <T>(key: string, initialValue: T) => {
const storage = new Map<string, T>();
if (!storage.has(key)) {
storage.set(key, initialValue);
}
return ref(storage.get(key) ?? initialValue);
},
}));
vi.mock('@unraid/ui', () => ({
@@ -46,6 +53,18 @@ vi.mock('@unraid/ui', () => ({
props: ['variant', 'size'],
},
cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
isDarkModeActive: vi.fn(() => {
if (typeof document === 'undefined') return false;
const cssVar = getComputedStyle(document.documentElement)
.getPropertyValue('--theme-dark-mode')
.trim();
if (cssVar === '1') return true;
if (cssVar === '0') return false;
if (document.documentElement.classList.contains('dark')) return true;
if (document.body?.classList.contains('dark')) return true;
if (document.querySelector('.unapi.dark')) return true;
return false;
}),
}));
const mockWatcher = vi.fn();
@@ -175,26 +194,33 @@ describe('UserProfile.standalone.vue', () => {
createSpy: vi.fn,
initialState: {
server: { ...initialServerData },
theme: {
theme: {
name: 'default',
banner: true,
bannerGradient: true,
descriptionShow: true,
textColor: '',
metaColor: '',
bgColor: '',
},
bannerGradient: 'linear-gradient(to right, #ff0000, #0000ff)',
},
},
stubActions: false,
});
setActivePinia(pinia);
serverStore = useServerStore();
// Set CSS variables directly on document element for theme store
document.documentElement.style.setProperty('--theme-dark-mode', '0');
document.documentElement.style.setProperty(
'--banner-gradient',
'linear-gradient(90deg, rgba(0, 0, 0, 0) 0, rgba(0, 0, 0, 0.7) var(--banner-gradient-stop, 30%))'
);
themeStore = useThemeStore();
// Set the theme using setTheme method
themeStore.setTheme({
name: 'white',
banner: true,
bannerGradient: true,
descriptionShow: true,
textColor: '',
metaColor: '',
bgColor: '',
});
// Override the setServer method to prevent console logging
vi.spyOn(serverStore, 'setServer').mockImplementation((server) => {
Object.assign(serverStore, server);
@@ -319,7 +345,7 @@ describe('UserProfile.standalone.vue', () => {
expect(themeStore.theme?.descriptionShow).toBe(true);
serverStore.description = initialServerData.description!;
themeStore.theme!.descriptionShow = true;
themeStore.setTheme({ ...themeStore.theme, descriptionShow: true });
await wrapper.vm.$nextTick();
// Look for the description in a span element with v-html directive
@@ -327,14 +353,14 @@ describe('UserProfile.standalone.vue', () => {
expect(descriptionElement.exists()).toBe(true);
expect(descriptionElement.html()).toContain(initialServerData.description);
themeStore.theme!.descriptionShow = false;
themeStore.setTheme({ ...themeStore.theme, descriptionShow: false });
await wrapper.vm.$nextTick();
// When descriptionShow is false, the element should not exist
descriptionElement = wrapper.find('span.hidden.text-center.text-base');
expect(descriptionElement.exists()).toBe(false);
themeStore.theme!.descriptionShow = true;
themeStore.setTheme({ ...themeStore.theme, descriptionShow: true });
await wrapper.vm.$nextTick();
descriptionElement = wrapper.find('span.hidden.text-center.text-base');
@@ -352,28 +378,34 @@ describe('UserProfile.standalone.vue', () => {
});
it('conditionally renders banner based on theme store', async () => {
const bannerSelector = 'div.absolute.z-0';
const bannerSelector = '.unraid-banner-gradient-layer';
themeStore.theme = {
...themeStore.theme!,
themeStore.setTheme({
...themeStore.theme,
banner: true,
bannerGradient: true,
};
});
await wrapper.vm.$nextTick();
expect(themeStore.bannerGradient).toContain('background-image: linear-gradient');
expect(themeStore.bannerGradient).toBe(true);
expect(wrapper.find(bannerSelector).exists()).toBe(true);
themeStore.theme!.bannerGradient = false;
themeStore.setTheme({
...themeStore.theme,
bannerGradient: false,
});
await wrapper.vm.$nextTick();
expect(themeStore.bannerGradient).toBeUndefined();
expect(themeStore.bannerGradient).toBe(false);
expect(wrapper.find(bannerSelector).exists()).toBe(false);
themeStore.theme!.bannerGradient = true;
themeStore.setTheme({
...themeStore.theme,
bannerGradient: true,
});
await wrapper.vm.$nextTick();
expect(themeStore.bannerGradient).toContain('background-image: linear-gradient');
expect(themeStore.bannerGradient).toBe(true);
expect(wrapper.find(bannerSelector).exists()).toBe(true);
});
});

View File

@@ -14,7 +14,6 @@ vi.mock('@/components/HeaderOsVersion.standalone.vue', () => ({ default: 'Header
vi.mock('@/components/UserProfile.standalone.vue', () => ({ default: 'UserProfile' }));
vi.mock('../Auth.standalone.vue', () => ({ default: 'Auth' }));
vi.mock('../ConnectSettings/ConnectSettings.standalone.vue', () => ({ default: 'ConnectSettings' }));
vi.mock('../DownloadApiLogs.standalone.vue', () => ({ default: 'DownloadApiLogs' }));
vi.mock('@/components/Modals.standalone.vue', () => ({ default: 'Modals' }));
vi.mock('../Registration.standalone.vue', () => ({ default: 'Registration' }));
vi.mock('../WanIpCheck.standalone.vue', () => ({ default: 'WanIpCheck' }));
@@ -135,7 +134,6 @@ describe('component-registry', () => {
'user-profile',
'auth',
'connect-settings',
'download-api-logs',
'modals',
'registration',
'wan-ip-check',

View File

@@ -21,6 +21,21 @@ vi.mock('@nuxt/ui/vue-plugin', () => ({
},
}));
vi.mock('@unraid/ui', () => ({
isDarkModeActive: vi.fn(() => {
if (typeof document === 'undefined') return false;
const cssVar = getComputedStyle(document.documentElement)
.getPropertyValue('--theme-dark-mode')
.trim();
if (cssVar === '1') return true;
if (cssVar === '0') return false;
if (document.documentElement.classList.contains('dark')) return true;
if (document.body?.classList.contains('dark')) return true;
if (document.querySelector('.unapi.dark')) return true;
return false;
}),
}));
// Mock component registry
const mockComponentMappings: ComponentMapping[] = [];
vi.mock('~/components/Wrapper/component-registry', () => ({

View File

@@ -16,9 +16,6 @@ vi.mock('~/components/Auth.standalone.vue', () => ({
vi.mock('~/components/ConnectSettings/ConnectSettings.standalone.vue', () => ({
default: { name: 'MockConnectSettings', template: '<div>ConnectSettings</div>' },
}));
vi.mock('~/components/DownloadApiLogs.standalone.vue', () => ({
default: { name: 'MockDownloadApiLogs', template: '<div>DownloadApiLogs</div>' },
}));
vi.mock('~/components/HeaderOsVersion.standalone.vue', () => ({
default: { name: 'MockHeaderOsVersion', template: '<div>HeaderOsVersion</div>' },
}));

View File

@@ -0,0 +1,58 @@
import { defineComponent } from 'vue';
import { mount } from '@vue/test-utils';
import dayjs from 'dayjs';
import { describe, expect, it } from 'vitest';
import type { ServerDateTimeFormat } from '~/types/server';
import useDateTimeHelper from '~/composables/dateTime';
import { testTranslate } from '../utils/i18n';
const formatDateWithComponent = (
dateTimeFormat: ServerDateTimeFormat | undefined,
hideMinutesSeconds: boolean,
providedDateTime: number
) => {
const wrapper = mount(
defineComponent({
setup() {
const { outputDateTimeFormatted } = useDateTimeHelper(
dateTimeFormat,
testTranslate,
hideMinutesSeconds,
providedDateTime
);
return { outputDateTimeFormatted };
},
template: '<div />',
})
);
const output = (wrapper.vm as unknown as { outputDateTimeFormatted: string | { value: string } })
.outputDateTimeFormatted;
return typeof output === 'string' ? output : output.value;
};
describe('useDateTimeHelper', () => {
it('falls back to default date format when server format is empty', () => {
const timestamp = new Date(2025, 0, 2, 3, 4, 5).getTime();
const formatted = formatDateWithComponent({ date: '', time: '' }, true, timestamp);
expect(formatted).toBe(dayjs(timestamp).format('dddd, MMMM D, YYYY'));
});
it('falls back to default date format when server format is unknown', () => {
const timestamp = new Date(2025, 0, 2, 3, 4, 5).getTime();
const formatted = formatDateWithComponent({ date: '%Q', time: '%Q' }, true, timestamp);
expect(formatted).toBe(dayjs(timestamp).format('dddd, MMMM D, YYYY'));
});
it('falls back to default time format when server time format is unknown', () => {
const timestamp = new Date(2025, 0, 2, 3, 4, 5).getTime();
const formatted = formatDateWithComponent({ date: '%c', time: '%Q' }, false, timestamp);
expect(formatted).toBe(dayjs(timestamp).format('ddd, D MMMM YYYY hh:mma'));
});
});

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import { createTestI18n } from '../utils/i18n';
describe('Trial Translation Keys', () => {
it('should load all trial-related translation keys', () => {
const i18n = createTestI18n();
const { t } = i18n.global;
const trialKeys = [
'registration.trialExpiration',
'server.actions.extendTrial',
'server.actions.startTrial',
'server.state.trial.humanReadable',
'server.state.trial.messageEligibleInsideRenewal',
'server.state.trial.messageEligibleOutsideRenewal',
'server.state.trial.messageIneligibleInsideRenewal',
'server.state.trial.messageIneligibleOutsideRenewal',
'server.state.trialExpired.heading',
'server.state.trialExpired.humanReadable',
'server.state.trialExpired.messageEligible',
'server.state.trialExpired.messageIneligible',
'userProfile.trial.trialKeyCreated',
'userProfile.trial.trialKeyCreationFailed',
'userProfile.trial.startingYourFreeDayTrial',
'userProfile.trial.extendingYourFreeTrialByDays',
'userProfile.trial.errorCreatiingATrialKeyPlease',
'userProfile.trial.pleaseKeepThisWindowOpen',
'userProfile.trial.pleaseWaitWhileThePageReloads',
'userProfile.uptimeExpire.trialKeyExpired',
'userProfile.uptimeExpire.trialKeyExpiredAt',
'userProfile.uptimeExpire.trialKeyExpiresAt',
'userProfile.uptimeExpire.trialKeyExpiresIn',
'userProfile.callbackFeedback.calculatingTrialExpiration',
'userProfile.callbackFeedback.installingExtendedTrial',
'userProfile.callbackFeedback.yourFreeTrialKeyProvidesAll',
'userProfile.callbackFeedback.yourTrialKeyHasBeenExtended',
'userProfile.dropdownTrigger.trialExpiredSeeOptionsBelow',
];
for (const key of trialKeys) {
const translation = t(key);
expect(translation).toBeTruthy();
expect(translation).not.toBe(key);
expect(typeof translation).toBe('string');
}
});
it('should translate trial expiration keys with parameters', () => {
const i18n = createTestI18n();
const { t } = i18n.global;
const testDate = '2024-01-15 10:30:00';
const testDuration = '5 days';
expect(t('userProfile.uptimeExpire.trialKeyExpired', [testDuration])).toContain(testDuration);
expect(t('userProfile.uptimeExpire.trialKeyExpiredAt', [testDate])).toContain(testDate);
expect(t('userProfile.uptimeExpire.trialKeyExpiresAt', [testDate])).toContain(testDate);
expect(t('userProfile.uptimeExpire.trialKeyExpiresIn', [testDuration])).toContain(testDuration);
});
it('should have all required trial state messages', () => {
const i18n = createTestI18n();
const { t } = i18n.global;
const stateMessages = [
'server.state.trial.messageEligibleInsideRenewal',
'server.state.trial.messageEligibleOutsideRenewal',
'server.state.trial.messageIneligibleInsideRenewal',
'server.state.trial.messageIneligibleOutsideRenewal',
'server.state.trialExpired.messageEligible',
'server.state.trialExpired.messageIneligible',
];
for (const key of stateMessages) {
const message = t(key);
expect(message).toBeTruthy();
expect(message.length).toBeGreaterThan(0);
expect(message).toMatch(/<p>/);
}
});
it('should have trial action translations', () => {
const i18n = createTestI18n();
const { t } = i18n.global;
expect(t('server.actions.extendTrial')).toBe('Extend Trial');
expect(t('server.actions.startTrial')).toBe('Start Free 30 Day Trial');
});
});

View File

@@ -94,5 +94,17 @@ vi.mock('@unraid/ui', () => ({
name: 'ResponsiveModalTitle',
template: '<div><slot /></div>',
},
isDarkModeActive: vi.fn(() => {
if (typeof document === 'undefined') return false;
const cssVar = getComputedStyle(document.documentElement)
.getPropertyValue('--theme-dark-mode')
.trim();
if (cssVar === '1') return true;
if (cssVar === '0') return false;
if (document.documentElement.classList.contains('dark')) return true;
if (document.body?.classList.contains('dark')) return true;
if (document.querySelector('.unapi.dark')) return true;
return false;
}),
// Add other UI components as needed
}));

View File

@@ -14,6 +14,10 @@ import { useServerStore } from '~/store/server';
vi.mock('@unraid/shared-callbacks', () => ({}));
vi.mock('@unraid/ui', () => ({
BrandLoading: {},
}));
vi.mock('~/composables/services/keyServer', () => ({
validateGuid: vi.fn(),
}));
@@ -62,7 +66,7 @@ describe('ReplaceRenew Store', () => {
expect(store.replaceStatus).toBe('ready');
});
it('should initialize with error state when guid is missing', () => {
it('should initialize with ready state even when guid is missing', () => {
vi.mocked(useServerStore).mockReturnValueOnce({
guid: undefined,
keyfile: mockKeyfile,
@@ -72,7 +76,8 @@ describe('ReplaceRenew Store', () => {
const newStore = useReplaceRenewStore();
expect(newStore.replaceStatus).toBe('error');
// Store now always initializes as 'ready' - errors are set when check() is called
expect(newStore.replaceStatus).toBe('ready');
});
});
@@ -138,6 +143,18 @@ describe('ReplaceRenew Store', () => {
expect(store.renewStatus).toBe('installing');
});
it('should reset all states with reset action', () => {
store.setReplaceStatus('error');
store.keyLinkedStatus = 'error';
store.error = { name: 'Error', message: 'Test error' };
store.reset();
expect(store.replaceStatus).toBe('ready');
expect(store.keyLinkedStatus).toBe('ready');
expect(store.error).toBeNull();
});
describe('check action', () => {
const mockResponse = {
hasNewerKeyfile: false,
@@ -326,8 +343,59 @@ describe('ReplaceRenew Store', () => {
await store.check();
expect(store.replaceStatus).toBe('error');
expect(store.keyLinkedStatus).toBe('error');
expect(console.error).toHaveBeenCalledWith('[ReplaceCheck.check]', testError);
expect(store.error).toEqual(testError);
expect(store.error).toEqual({ name: 'Error', message: 'Test error' });
});
it('should set error when guid is missing during check', async () => {
vi.mocked(useServerStore).mockReturnValue({
guid: '',
keyfile: mockKeyfile,
} as unknown as ReturnType<typeof useServerStore>);
setActivePinia(createPinia());
const testStore = useReplaceRenewStore();
await testStore.check();
expect(testStore.replaceStatus).toBe('error');
expect(testStore.keyLinkedStatus).toBe('error');
expect(testStore.error?.message).toBe('Flash GUID required to check replacement status');
});
it('should set error when keyfile is missing during check', async () => {
vi.mocked(useServerStore).mockReturnValue({
guid: mockGuid,
keyfile: '',
} as unknown as ReturnType<typeof useServerStore>);
setActivePinia(createPinia());
const testStore = useReplaceRenewStore();
await testStore.check();
expect(testStore.replaceStatus).toBe('error');
expect(testStore.keyLinkedStatus).toBe('error');
expect(testStore.error?.message).toBe('Keyfile required to check replacement status');
});
it('should provide descriptive error for 403 status', async () => {
const error403 = { response: { status: 403 }, message: 'Forbidden' };
vi.mocked(validateGuid).mockRejectedValueOnce(error403);
await store.check();
expect(store.error?.message).toBe('Access denied - license may be linked to another account');
});
it('should provide descriptive error for 500+ status', async () => {
const error500 = { response: { status: 500 }, message: 'Server Error' };
vi.mocked(validateGuid).mockRejectedValueOnce(error500);
await store.check();
expect(store.error?.message).toBe('Key server temporarily unavailable - please try again later');
});
});
});

View File

@@ -161,37 +161,45 @@ const getStore = () => {
},
serverPurchasePayload: {
get: () => ({
apiVersion: store.apiVersion,
connectPluginVersion: store.connectPluginVersion,
deviceCount: store.deviceCount,
email: store.email,
guid: store.guid,
keyTypeForPurchase: store.state === 'PLUS' ? 'Plus' : store.state === 'PRO' ? 'Pro' : 'Trial',
locale: store.locale,
osVersion: store.osVersion,
osVersionBranch: store.osVersionBranch,
registered: store.registered ?? false,
regExp: store.regExp,
regTy: store.regTy,
regUpdatesExpired: store.regUpdatesExpired,
state: store.state,
site: store.site,
}),
},
serverAccountPayload: {
get: () => ({
apiVersion: store.apiVersion,
caseModel: store.caseModel,
connectPluginVersion: store.connectPluginVersion,
deviceCount: store.deviceCount,
description: store.description,
deviceCount: store.deviceCount,
expireTime: store.expireTime,
flashProduct: store.flashProduct,
flashVendor: store.flashVendor,
guid: store.guid,
locale: store.locale,
name: store.name,
osVersion: store.osVersion,
osVersionBranch: store.osVersionBranch,
registered: store.registered ?? false,
regExp: store.regExp,
regGen: store.regGen,
regGuid: store.regGuid,
regTy: store.regTy,
regUpdatesExpired: store.regUpdatesExpired,
state: store.state,
wanFQDN: store.wanFQDN,
}),
},
serverAccountPayload: {
get: () => ({
deviceCount: store.deviceCount,
description: store.description,
expireTime: store.expireTime,
flashProduct: store.flashProduct,
flashVendor: store.flashVendor,
guid: store.guid,
keyfile: store.keyfile,
locale: store.locale,
name: store.name,
osVersion: store.osVersion,
osVersionBranch: store.osVersionBranch,
registered: store.registered ?? false,
regExp: store.regExp,
regGen: store.regGen,
regGuid: store.regGuid,
regTy: store.regTy,
regUpdatesExpired: store.regUpdatesExpired,
state: store.state,
wanFQDN: store.wanFQDN,
}),
@@ -549,49 +557,65 @@ describe('useServerStore', () => {
const store = getStore();
store.setServer({
apiVersion: '1.0.0',
connectPluginVersion: '2.0.0',
deviceCount: 6,
email: 'test@example.com',
description: 'Test Server',
expireTime: 123,
flashProduct: 'TestFlash',
flashVendor: 'TestVendor',
guid: '123456',
inIframe: false,
locale: 'en-US',
name: 'TestServer',
osVersion: '6.10.3',
osVersionBranch: 'stable',
registered: true,
regGen: 7,
regGuid: 'reg-guid-1',
regExp: 1234567890,
regTy: 'Plus',
state: 'PLUS' as ServerState,
site: 'local',
wanFQDN: 'test.myunraid.net',
});
const payload = store.serverPurchasePayload;
expect(payload.apiVersion).toBe('1.0.0');
expect(payload.connectPluginVersion).toBe('2.0.0');
expect(payload.description).toBe('Test Server');
expect(payload.deviceCount).toBe(6);
expect(payload.email).toBe('test@example.com');
expect(payload.expireTime).toBe(123);
expect(payload.flashProduct).toBe('TestFlash');
expect(payload.flashVendor).toBe('TestVendor');
expect(payload.guid).toBe('123456');
expect(payload.keyTypeForPurchase).toBe('Plus');
expect(payload.locale).toBe('en-US');
expect(payload.name).toBe('TestServer');
expect(payload.osVersion).toBe('6.10.3');
expect(payload.osVersionBranch).toBe('stable');
expect(payload.registered).toBe(true);
expect(payload.regExp).toBe(1234567890);
expect(payload.regGen).toBe(7);
expect(payload.regGuid).toBe('reg-guid-1');
expect(payload.regTy).toBe('Plus');
expect(payload.state).toBe('PLUS');
expect(payload.wanFQDN).toBe('test.myunraid.net');
});
it('should create serverAccountPayload correctly', () => {
const store = getStore();
store.setServer({
apiVersion: '1.0.0',
caseModel: 'TestCase',
connectPluginVersion: '2.0.0',
deviceCount: 6,
description: 'Test Server',
expireTime: 123,
flashProduct: 'TestFlash',
flashVendor: 'TestVendor',
guid: '123456',
keyfile: '/boot/config/Plus.key',
locale: 'en-US',
name: 'TestServer',
osVersion: '6.10.3',
osVersionBranch: 'stable',
registered: true,
regExp: 1234567890,
regGen: 7,
regGuid: 'reg-guid-1',
regTy: 'Plus',
state: 'PLUS' as ServerState,
wanFQDN: 'test.myunraid.net',
@@ -599,16 +623,23 @@ describe('useServerStore', () => {
const payload = store.serverAccountPayload;
expect(payload.apiVersion).toBe('1.0.0');
expect(payload.caseModel).toBe('TestCase');
expect(payload.connectPluginVersion).toBe('2.0.0');
expect(payload.deviceCount).toBe(6);
expect(payload.description).toBe('Test Server');
expect(payload.expireTime).toBe(123);
expect(payload.flashProduct).toBe('TestFlash');
expect(payload.flashVendor).toBe('TestVendor');
expect(payload.guid).toBe('123456');
expect(payload.keyfile).toBe('/boot/config/Plus.key');
expect(payload.locale).toBe('en-US');
expect(payload.name).toBe('TestServer');
expect(payload.osVersion).toBe('6.10.3');
expect(payload.osVersionBranch).toBe('stable');
expect(payload.registered).toBe(true);
expect(payload.regExp).toBe(1234567890);
expect(payload.regGen).toBe(7);
expect(payload.regGuid).toBe('reg-guid-1');
expect(payload.regTy).toBe('Plus');
expect(payload.regUpdatesExpired).toBe(true);
expect(payload.state).toBe('PLUS');
expect(payload.wanFQDN).toBe('test.myunraid.net');
});

View File

@@ -6,13 +6,10 @@ import { createApp, nextTick, ref } from 'vue';
import { setActivePinia } from 'pinia';
import { defaultColors } from '~/themes/default';
import hexToRgba from 'hex-to-rgba';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Theme } from '~/themes/types';
import { globalPinia } from '~/store/globalPinia';
import { THEME_STORAGE_KEY, useThemeStore } from '~/store/theme';
import { useThemeStore } from '~/store/theme';
vi.mock('@vue/apollo-composable', () => ({
useQuery: () => ({
@@ -21,17 +18,34 @@ vi.mock('@vue/apollo-composable', () => ({
onResult: vi.fn(),
onError: vi.fn(),
}),
useLazyQuery: () => ({
load: vi.fn(),
result: ref(null),
loading: ref(false),
onResult: vi.fn(),
onError: vi.fn(),
}),
}));
vi.mock('hex-to-rgba', () => ({
default: vi.fn((hex, opacity) => `rgba(mock-${hex}-${opacity})`),
vi.mock('@unraid/ui', () => ({
isDarkModeActive: vi.fn(() => {
if (typeof document === 'undefined') return false;
const cssVar = getComputedStyle(document.documentElement)
.getPropertyValue('--theme-dark-mode')
.trim();
if (cssVar === '1') return true;
if (cssVar === '0') return false;
if (document.documentElement.classList.contains('dark')) return true;
if (document.body?.classList.contains('dark')) return true;
if (document.querySelector('.unapi.dark')) return true;
return false;
}),
}));
describe('Theme Store', () => {
const originalAddClassFn = document.body.classList.add;
const originalRemoveClassFn = document.body.classList.remove;
const originalStyleCssText = document.body.style.cssText;
const originalDocumentElementSetProperty = document.documentElement.style.setProperty;
const originalDocumentElementAddClass = document.documentElement.classList.add;
const originalDocumentElementRemoveClass = document.documentElement.classList.remove;
@@ -49,9 +63,13 @@ describe('Theme Store', () => {
document.body.classList.add = vi.fn();
document.body.classList.remove = vi.fn();
document.body.style.cssText = '';
document.documentElement.style.setProperty = vi.fn();
document.documentElement.classList.add = vi.fn();
document.documentElement.classList.remove = vi.fn();
document.documentElement.style.removeProperty('--theme-dark-mode');
document.documentElement.style.removeProperty('--theme-name');
document.documentElement.classList.remove('dark');
document.body.classList.remove('dark');
document.querySelectorAll('.unapi').forEach((el) => el.classList.remove('dark'));
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0);
@@ -64,13 +82,18 @@ describe('Theme Store', () => {
afterEach(() => {
store?.$dispose();
store = undefined;
app?.unmount();
if (app) {
try {
app.unmount();
} catch {
// App was not mounted, ignore
}
}
app = undefined;
document.body.classList.add = originalAddClassFn;
document.body.classList.remove = originalRemoveClassFn;
document.body.style.cssText = originalStyleCssText;
document.documentElement.style.setProperty = originalDocumentElementSetProperty;
document.documentElement.classList.add = originalDocumentElementAddClass;
document.documentElement.classList.remove = originalDocumentElementRemoveClass;
vi.restoreAllMocks();
@@ -88,8 +111,6 @@ describe('Theme Store', () => {
it('should initialize with default theme', () => {
const store = createStore();
expect(typeof store.$persist).toBe('function');
expect(store.theme).toEqual({
name: 'white',
banner: false,
@@ -102,44 +123,39 @@ describe('Theme Store', () => {
expect(store.activeColorVariables).toEqual(defaultColors.white);
});
it('should compute darkMode correctly', () => {
it('should compute darkMode from CSS variable when set to 1', () => {
document.documentElement.style.setProperty('--theme-dark-mode', '1');
const store = createStore();
expect(store.darkMode).toBe(false);
store.setTheme({ ...store.theme, name: 'black' });
expect(store.darkMode).toBe(true);
});
store.setTheme({ ...store.theme, name: 'gray' });
expect(store.darkMode).toBe(true);
store.setTheme({ ...store.theme, name: 'white' });
it('should compute darkMode from CSS variable when set to 0', () => {
document.documentElement.style.setProperty('--theme-dark-mode', '0');
const store = createStore();
expect(store.darkMode).toBe(false);
});
it('should compute bannerGradient correctly', () => {
it('should compute bannerGradient from CSS variable when set', async () => {
document.documentElement.style.setProperty('--theme-dark-mode', '0');
// Set the gradient with the resolved value (not nested var()) since getComputedStyle resolves it
document.documentElement.style.setProperty(
'--banner-gradient',
'linear-gradient(90deg, rgba(0, 0, 0, 0) 0, rgba(0, 0, 0, 0.7) 30%)'
);
const store = createStore();
store.setTheme({ banner: true, bannerGradient: true });
await nextTick();
expect(store.theme.banner).toBe(true);
expect(store.theme.bannerGradient).toBe(true);
expect(store.darkMode).toBe(false);
expect(store.bannerGradient).toBe(true);
});
expect(store.bannerGradient).toBeUndefined();
store.setTheme({
...store.theme,
banner: true,
bannerGradient: true,
});
expect(store.bannerGradient).toMatchInlineSnapshot(
`"background-image: linear-gradient(90deg, rgba(0, 0, 0, 0) 0, var(--header-background-color) 90%);"`
);
store.setTheme({
...store.theme,
banner: true,
bannerGradient: true,
bgColor: '#123456',
});
expect(store.bannerGradient).toMatchInlineSnapshot(
`"background-image: linear-gradient(90deg, var(--header-gradient-start) 0, var(--header-gradient-end) 90%);"`
);
it('should return false when bannerGradient CSS variable is not set', () => {
document.documentElement.style.removeProperty('--banner-gradient');
const store = createStore();
expect(store.bannerGradient).toBe(false);
});
});
@@ -169,12 +185,16 @@ describe('Theme Store', () => {
await nextTick();
expect(document.body.classList.add).toHaveBeenCalledWith('dark');
expect(document.documentElement.classList.add).toHaveBeenCalledWith('dark');
expect(store.darkMode).toBe(true);
store.setTheme({ ...store.theme, name: 'white' });
await nextTick();
expect(document.body.classList.remove).toHaveBeenCalledWith('dark');
expect(document.documentElement.classList.remove).toHaveBeenCalledWith('dark');
expect(store.darkMode).toBe(false);
});
it('should update activeColorVariables when theme changes', async () => {
@@ -191,93 +211,80 @@ describe('Theme Store', () => {
await nextTick();
// activeColorVariables now contains the theme defaults from defaultColors
// Custom values are applied as CSS variables on the documentElement
// The white theme's --color-beta is a reference to var(--header-text-primary)
expect(store.activeColorVariables['--color-beta']).toBe('var(--header-text-primary)');
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
'--custom-header-text-primary',
'#333333'
);
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
'--custom-header-text-secondary',
'#666666'
);
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
'--custom-header-background-color',
'#ffffff'
);
});
it('should handle banner gradient correctly', async () => {
it('should apply dark mode classes when theme changes', async () => {
const store = createStore();
const mockHexToRgba = vi.mocked(hexToRgba);
mockHexToRgba.mockClear();
store.setTheme({
...store.theme,
banner: true,
bannerGradient: true,
bgColor: '#112233',
name: 'black',
});
await nextTick();
expect(mockHexToRgba).toHaveBeenCalledWith('#112233', 0);
expect(mockHexToRgba).toHaveBeenCalledWith('#112233', 0.7);
// Banner gradient values are now set as custom CSS variables on documentElement
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
'--custom-header-gradient-start',
'rgba(mock-#112233-0)'
);
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
'--custom-header-gradient-end',
'rgba(mock-#112233-0.7)'
);
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
'--banner-gradient',
'linear-gradient(90deg, rgba(mock-#112233-0) 0, rgba(mock-#112233-0.7) 90%)'
);
expect(document.documentElement.classList.add).toHaveBeenCalledWith('dark');
expect(document.body.classList.add).toHaveBeenCalledWith('dark');
expect(store.darkMode).toBe(true);
});
it('should hydrate theme from cache when available', () => {
const cachedTheme = {
name: 'black',
banner: true,
bannerGradient: false,
bgColor: '#222222',
descriptionShow: true,
metaColor: '#aaaaaa',
textColor: '#ffffff',
} satisfies Theme;
window.localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify({ theme: cachedTheme }));
it('should update darkMode reactively when theme changes', async () => {
const store = createStore();
expect(store.theme).toEqual(cachedTheme);
});
expect(store.darkMode).toBe(false);
it('should persist server theme responses to cache', async () => {
const store = createStore();
const serverTheme = {
store.setTheme({
...store.theme,
name: 'gray',
banner: false,
bannerGradient: false,
bgColor: '#111111',
descriptionShow: false,
metaColor: '#999999',
textColor: '#eeeeee',
} satisfies Theme;
});
store.setTheme(serverTheme, { source: 'server' });
await nextTick();
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toEqual(
JSON.stringify({ theme: serverTheme })
);
expect(store.darkMode).toBe(true);
store.setTheme({
...store.theme,
name: 'white',
});
await nextTick();
expect(store.darkMode).toBe(false);
});
it('should initialize dark mode from CSS variable on store creation', () => {
// Mock getComputedStyle to return dark mode
const originalGetComputedStyle = window.getComputedStyle;
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
const style = originalGetComputedStyle(el);
if (el === document.documentElement) {
return {
...style,
getPropertyValue: (prop: string) => {
if (prop === '--theme-dark-mode') {
return '1';
}
if (prop === '--theme-name') {
return 'black';
}
return style.getPropertyValue(prop);
},
} as CSSStyleDeclaration;
}
return style;
});
document.documentElement.style.setProperty('--theme-dark-mode', '1');
const store = createStore();
// Should have added dark class to documentElement and body
expect(document.documentElement.classList.add).toHaveBeenCalledWith('dark');
expect(document.body.classList.add).toHaveBeenCalledWith('dark');
expect(store.darkMode).toBe(true);
vi.restoreAllMocks();
});
});
});

View File

@@ -4,17 +4,41 @@
import { createPinia, setActivePinia } from 'pinia';
import { WEBGUI_REDIRECT } from '~/helpers/urls';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useUpdateOsStore } from '~/store/updateOs';
const mockSend = vi.fn();
vi.mock('@unraid/shared-callbacks', () => ({
useCallback: vi.fn(() => ({
send: vi.fn(),
send: mockSend,
watcher: vi.fn(),
})),
}));
vi.mock('~/composables/preventClose', () => ({
addPreventClose: vi.fn(),
removePreventClose: vi.fn(),
}));
vi.mock('~/store/account', () => ({
useAccountStore: () => ({
accountActionStatus: 'ready',
}),
}));
vi.mock('~/store/installKey', () => ({
useInstallKeyStore: () => ({
keyInstallStatus: 'ready',
}),
}));
vi.mock('~/store/updateOsActions', () => ({
useUpdateOsActionsStore: () => ({}),
}));
vi.mock('~/composables/services/webgui', () => {
return {
WebguiCheckForUpdate: vi.fn().mockResolvedValue({
@@ -104,6 +128,40 @@ describe('UpdateOs Store', () => {
expect(store.updateOsModalVisible).toBe(false);
});
it('should send update install through redirect.htm', () => {
const originalLocation = window.location;
Object.defineProperty(window, 'location', {
configurable: true,
value: {
...originalLocation,
origin: 'https://littlebox.tail45affd.ts.net',
href: 'https://littlebox.tail45affd.ts.net/Plugins',
},
});
store.fetchAndConfirmInstall('test-sha256');
const expectedUrl = new URL(WEBGUI_REDIRECT, window.location.origin).toString();
expect(mockSend).toHaveBeenCalledWith(
expectedUrl,
[
{
sha256: 'test-sha256',
type: 'updateOs',
},
],
undefined,
'forUpc'
);
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
});
});
it('should handle errors when checking for updates', async () => {
const { WebguiCheckForUpdate } = await import('~/composables/services/webgui');

View File

@@ -10,6 +10,7 @@ import type { ExternalUpdateOsAction } from '@unraid/shared-callbacks';
import type { Release } from '~/store/updateOsActions';
import { useUpdateOsActionsStore } from '~/store/updateOsActions';
import { testTranslate } from '../utils/i18n';
vi.mock('~/helpers/urls', () => ({
WEBGUI_TOOLS_UPDATE: 'https://webgui/tools/update',
@@ -48,20 +49,34 @@ vi.mock('~/store/account', () => ({
}),
}));
const mockServerStore = {
guid: 'test-guid',
keyfile: 'test-keyfile',
osVersion: '6.12.4',
osVersionBranch: 'stable',
regUpdatesExpired: false,
regTy: 'Plus',
locale: 'en_US' as string | undefined,
rebootType: '',
updateOsResponse: null as { date: string } | null,
};
vi.mock('~/store/server', () => ({
useServerStore: () => ({
guid: 'test-guid',
keyfile: 'test-keyfile',
osVersion: '6.12.4',
osVersionBranch: 'stable',
regUpdatesExpired: false,
rebootType: '',
}),
useServerStore: () => mockServerStore,
}));
const mockUpdateOsStore = {
available: '6.12.5',
availableWithRenewal: false,
};
vi.mock('~/store/updateOs', () => ({
useUpdateOsStore: () => ({
available: '6.12.5',
useUpdateOsStore: () => mockUpdateOsStore,
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: testTranslate,
}),
}));
@@ -70,6 +85,19 @@ describe('UpdateOsActions Store', () => {
beforeEach(() => {
setActivePinia(createPinia());
// Reset mocks to default values
mockServerStore.guid = 'test-guid';
mockServerStore.keyfile = 'test-keyfile';
mockServerStore.osVersion = '6.12.4';
mockServerStore.osVersionBranch = 'stable';
mockServerStore.regUpdatesExpired = false;
mockServerStore.regTy = 'Plus';
mockServerStore.locale = 'en_US';
mockServerStore.rebootType = '';
mockServerStore.updateOsResponse = null;
mockUpdateOsStore.available = '6.12.5';
mockUpdateOsStore.availableWithRenewal = false;
store = useUpdateOsActionsStore();
vi.clearAllMocks();
@@ -417,4 +445,106 @@ describe('UpdateOsActions Store', () => {
expect(store.status).toBe('updating');
});
});
describe('formattedReleaseDate', () => {
it('should return empty string when no release date is available', () => {
mockUpdateOsStore.availableWithRenewal = false;
mockServerStore.updateOsResponse = null;
store = useUpdateOsActionsStore();
expect(store.formattedReleaseDate).toBe('');
});
it('should format date correctly with locale from server store', () => {
mockUpdateOsStore.availableWithRenewal = true;
mockServerStore.updateOsResponse = { date: '2023-10-15' };
mockServerStore.locale = 'en_US';
store = useUpdateOsActionsStore();
const formatted = store.formattedReleaseDate;
expect(formatted).toBeTruthy();
expect(formatted).toContain('2023');
expect(formatted).toContain('October');
expect(formatted).toContain('15');
});
it('should normalize locale underscores to hyphens', () => {
mockUpdateOsStore.availableWithRenewal = true;
mockServerStore.updateOsResponse = { date: '2023-10-15' };
mockServerStore.locale = 'fr_FR';
store = useUpdateOsActionsStore();
const formatted = store.formattedReleaseDate;
expect(formatted).toBeTruthy();
expect(typeof formatted).toBe('string');
expect(formatted.length).toBeGreaterThan(0);
});
it('should fall back to navigator.language when locale is missing', () => {
const originalLanguage = navigator.language;
Object.defineProperty(navigator, 'language', {
value: 'de-DE',
configurable: true,
});
mockUpdateOsStore.availableWithRenewal = true;
mockServerStore.updateOsResponse = { date: '2023-10-15' };
mockServerStore.locale = undefined;
store = useUpdateOsActionsStore();
const formatted = store.formattedReleaseDate;
expect(formatted).toBeTruthy();
expect(typeof formatted).toBe('string');
Object.defineProperty(navigator, 'language', {
value: originalLanguage,
configurable: true,
});
});
it('should fall back to en-US when locale and navigator.language are missing', () => {
const originalLanguage = navigator.language;
Object.defineProperty(navigator, 'language', {
value: undefined,
configurable: true,
});
mockUpdateOsStore.availableWithRenewal = true;
mockServerStore.updateOsResponse = { date: '2023-10-15' };
mockServerStore.locale = undefined;
store = useUpdateOsActionsStore();
const formatted = store.formattedReleaseDate;
expect(formatted).toBeTruthy();
expect(formatted).toContain('2023');
expect(formatted).toContain('October');
expect(formatted).toContain('15');
Object.defineProperty(navigator, 'language', {
value: originalLanguage,
configurable: true,
});
});
it('should parse date correctly to avoid off-by-one errors', () => {
mockUpdateOsStore.availableWithRenewal = true;
mockServerStore.updateOsResponse = { date: '2023-01-01' };
mockServerStore.locale = 'en-US';
store = useUpdateOsActionsStore();
const formatted = store.formattedReleaseDate;
expect(formatted).toContain('January');
expect(formatted).toContain('1');
});
});
describe('ineligibleText', () => {
it('should return empty string when eligible', () => {
mockServerStore.guid = 'test-guid';
mockServerStore.keyfile = 'test-keyfile';
mockServerStore.osVersion = '6.12.4';
mockServerStore.regUpdatesExpired = false;
store = useUpdateOsActionsStore();
expect(store.ineligibleText).toBe('');
});
});
});

View File

@@ -35,30 +35,30 @@ type LocaleMessages = typeof enUS;
const localeMessages: Record<string, LocaleMessages> = {
en_US: enUS,
ar: ar as LocaleMessages,
bn: bn as LocaleMessages,
ca: ca as LocaleMessages,
cs: cs as LocaleMessages,
da: da as LocaleMessages,
de: de as LocaleMessages,
es: es as LocaleMessages,
fr: fr as LocaleMessages,
hi: hi as LocaleMessages,
hr: hr as LocaleMessages,
hu: hu as LocaleMessages,
it: it as LocaleMessages,
ja: ja as LocaleMessages,
ko: ko as LocaleMessages,
lv: lv as LocaleMessages,
nl: nl as LocaleMessages,
no: no as LocaleMessages,
pl: pl as LocaleMessages,
pt: pt as LocaleMessages,
ro: ro as LocaleMessages,
ru: ru as LocaleMessages,
sv: sv as LocaleMessages,
uk: uk as LocaleMessages,
zh: zh as LocaleMessages,
ar: ar as unknown as LocaleMessages,
bn: bn as unknown as LocaleMessages,
ca: ca as unknown as LocaleMessages,
cs: cs as unknown as LocaleMessages,
da: da as unknown as LocaleMessages,
de: de as unknown as LocaleMessages,
es: es as unknown as LocaleMessages,
fr: fr as unknown as LocaleMessages,
hi: hi as unknown as LocaleMessages,
hr: hr as unknown as LocaleMessages,
hu: hu as unknown as LocaleMessages,
it: it as unknown as LocaleMessages,
ja: ja as unknown as LocaleMessages,
ko: ko as unknown as LocaleMessages,
lv: lv as unknown as LocaleMessages,
nl: nl as unknown as LocaleMessages,
no: no as unknown as LocaleMessages,
pl: pl as unknown as LocaleMessages,
pt: pt as unknown as LocaleMessages,
ro: ro as unknown as LocaleMessages,
ru: ru as unknown as LocaleMessages,
sv: sv as unknown as LocaleMessages,
uk: uk as unknown as LocaleMessages,
zh: zh as unknown as LocaleMessages,
};
type AnyObject = Record<string, unknown>;

View File

@@ -138,7 +138,6 @@ if ($display['theme'] === 'black' || $display['theme'] === 'azure') {
<unraid-auth></connect-auth>
</div>
<div class="ComponentWrapper">
<unraid-download-api-logs></connect-download-api-logs>
</div>
<div class="ComponentWrapper">
<unraid-key-actions></connect-key-actions>

1
web/components.d.ts vendored
View File

@@ -47,7 +47,6 @@ declare module 'vue' {
'DevThemeSwitcher.standalone': typeof import('./src/components/DevThemeSwitcher.standalone.vue')['default']
Downgrade: typeof import('./src/components/UpdateOs/Downgrade.vue')['default']
'DowngradeOs.standalone': typeof import('./src/components/DowngradeOs.standalone.vue')['default']
'DownloadApiLogs.standalone': typeof import('./src/components/DownloadApiLogs.standalone.vue')['default']
DropdownConnectStatus: typeof import('./src/components/UserProfile/DropdownConnectStatus.vue')['default']
DropdownContent: typeof import('./src/components/UserProfile/DropdownContent.vue')['default']
DropdownError: typeof import('./src/components/UserProfile/DropdownError.vue')['default']

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/web",
"version": "4.26.2",
"version": "4.29.1",
"private": true,
"type": "module",
"license": "GPL-2.0-or-later",
@@ -11,8 +11,8 @@
"preview": "vite preview",
"serve": "NODE_ENV=production PORT=${PORT:-4321} vite preview --port ${PORT:-4321}",
"// Build": "",
"prebuild:dev": "pnpm predev",
"build:dev": "pnpm run build && pnpm run deploy-to-unraid:dev",
"prebuild": "pnpm predev",
"build": "NODE_ENV=production vite build && pnpm run manifest-ts",
"prebuild:watch": "pnpm predev",
"build:watch": "vite build --watch && pnpm run manifest-ts",
@@ -109,7 +109,7 @@
"@jsonforms/vue-vanilla": "3.6.0",
"@jsonforms/vue-vuetify": "3.6.0",
"@nuxt/ui": "4.0.0-alpha.0",
"@unraid/shared-callbacks": "1.1.1",
"@unraid/shared-callbacks": "3.0.0",
"@unraid/ui": "link:../unraid-ui",
"@vue/apollo-composable": "4.2.2",
"@vueuse/components": "13.8.0",

View File

@@ -157,6 +157,21 @@ iframe#progressFrame {
color-scheme: light;
}
/* Banner gradient tuning */
:root {
--banner-gradient-stop: 30%;
}
.unraid-banner-gradient-layer {
background-image: var(--banner-gradient);
}
@media (max-width: 768px) {
:root {
--banner-gradient-stop: 60%;
}
}
/* Header banner compatibility tweaks */
#header.image {
background-position: center center;
@@ -178,16 +193,8 @@ iframe#progressFrame {
background-position: left center, right center;
background-size: min(30%, 320px) 100%, min(30%, 320px) 100%;
background-image:
linear-gradient(
90deg,
var(--color-header-gradient-end, rgba(0, 0, 0, 0.7)) 0%,
var(--color-header-gradient-start, rgba(0, 0, 0, 0)) 100%
),
linear-gradient(
270deg,
var(--color-header-gradient-end, rgba(0, 0, 0, 0.7)) 0%,
var(--color-header-gradient-start, rgba(0, 0, 0, 0)) 100%
);
var(--banner-gradient),
linear-gradient(270deg, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 0%, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 10%, color-mix(in srgb, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 90%, transparent) 25%, color-mix(in srgb, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 60%, transparent) 40%, color-mix(in srgb, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 30%, transparent) 55%, var(--header-gradient-start, var(--color-header-gradient-start, rgba(0, 0, 0, 0))) 70%, var(--header-gradient-start, var(--color-header-gradient-start, rgba(0, 0, 0, 0))) var(--banner-gradient-stop, 30%));
z-index: 0;
}

View File

@@ -18,7 +18,6 @@ import {
updateConnectSettings,
} from '~/components/ConnectSettings/graphql/settings.query';
import OidcDebugLogs from '~/components/ConnectSettings/OidcDebugLogs.vue';
import DownloadApiLogs from '~/components/DownloadApiLogs.standalone.vue';
import { useServerStore } from '~/store/server';
// Disable automatic attribute inheritance
@@ -115,8 +114,6 @@ const onChange = ({ data }: { data: Record<string, unknown> }) => {
<Label>{{ t('connectSettings.accountStatusLabel') }}</Label>
<Auth />
</template>
<Label>{{ t('downloadApiLogs.downloadUnraidApiLogs') }}:</Label>
<DownloadApiLogs />
</SettingsGrid>
<!-- auto-generated settings form -->
<div class="mt-6 pl-3 [&_.vertical-layout]:space-y-6">

View File

@@ -0,0 +1,17 @@
import { graphql } from '~/composables/gql/gql';
export const SET_THEME_MUTATION = graphql(/* GraphQL */ `
mutation setTheme($theme: ThemeName!) {
customization {
setTheme(theme: $theme) {
name
showBannerImage
showBannerGradient
headerBackgroundColor
showHeaderDescription
headerPrimaryTextColor
headerSecondaryTextColor
}
}
}
`);

View File

@@ -1,67 +1,139 @@
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useMutation, useQuery } from '@vue/apollo-composable';
import { useThemeStore } from '~/store/theme';
import type { GetThemeQuery } from '~/composables/gql/graphql';
import { SET_THEME_MUTATION } from '~/components/DevThemeSwitcher.mutation';
import { ThemeName } from '~/composables/gql/graphql';
import { DARK_UI_THEMES, GET_THEME_QUERY, useThemeStore } from '~/store/theme';
const themeStore = useThemeStore();
const themeOptions = [
{ value: 'white', label: 'White' },
{ value: 'black', label: 'Black' },
{ value: 'gray', label: 'Gray' },
{ value: 'azure', label: 'Azure' },
] as const;
const themeOptions: Array<{ value: ThemeName; label: string }> = [
{ value: ThemeName.WHITE, label: 'White' },
{ value: ThemeName.BLACK, label: 'Black' },
{ value: ThemeName.GRAY, label: 'Gray' },
{ value: ThemeName.AZURE, label: 'Azure' },
];
const STORAGE_KEY_THEME = 'unraid:test:theme';
const THEME_COOKIE_KEY = 'unraid_dev_theme';
const { theme } = storeToRefs(themeStore);
const currentTheme = ref<string>(theme.value.name);
const themeValues = new Set<ThemeName>(themeOptions.map((option) => option.value));
const getCurrentTheme = (): string => {
const urlParams = new URLSearchParams(window.location.search);
const urlTheme = urlParams.get('theme');
const normalizeTheme = (value?: string | ThemeName | null): ThemeName | null => {
const normalized = (value ?? '').toString().toLowerCase();
return themeValues.has(normalized as ThemeName) ? (normalized as ThemeName) : null;
};
if (urlTheme && themeOptions.some((t) => t.value === urlTheme)) {
return urlTheme;
const readCookieTheme = (): string | null => {
if (typeof document === 'undefined') {
return null;
}
if (theme.value?.name) {
return theme.value.name;
const cookies = document.cookie?.split(';') ?? [];
for (const cookie of cookies) {
const [name, ...rest] = cookie.split('=');
if (name?.trim() === THEME_COOKIE_KEY) {
return decodeURIComponent(rest.join('=').trim());
}
}
return null;
};
const readLocalStorageTheme = (): string | null => {
try {
return window.localStorage?.getItem(STORAGE_KEY_THEME) || 'white';
return window.localStorage?.getItem(STORAGE_KEY_THEME) ?? null;
} catch {
return 'white';
return null;
}
};
const updateTheme = (themeName: string, skipUrlUpdate = false) => {
if (!skipUrlUpdate) {
const url = new URL(window.location.href);
url.searchParams.set('theme', themeName);
window.history.replaceState({}, '', url);
const readCssTheme = (): string | null => {
if (typeof window === 'undefined') {
return null;
}
return getComputedStyle(document.documentElement).getPropertyValue('--theme-name').trim() || null;
};
const resolveInitialTheme = async (): Promise<ThemeName> => {
const candidates = [readCssTheme(), readCookieTheme(), readLocalStorageTheme(), theme.value?.name];
for (const candidate of candidates) {
const normalized = normalizeTheme(candidate);
if (normalized) {
return normalized;
}
}
return ThemeName.WHITE;
};
const currentTheme = ref<ThemeName>(normalizeTheme(theme.value.name) ?? ThemeName.WHITE);
const isSaving = ref(false);
const isQueryLoading = ref(false);
const { onResult: onThemeResult, loading: queryLoading } = useQuery<GetThemeQuery>(
GET_THEME_QUERY,
null,
{ fetchPolicy: 'network-only' }
);
onThemeResult(({ data }) => {
const serverTheme = normalizeTheme(data?.publicTheme?.name);
if (serverTheme) {
void applyThemeSelection(serverTheme, { skipStore: false });
}
});
watch(
() => queryLoading.value,
(loading) => {
isQueryLoading.value = loading;
},
{ immediate: true }
);
const { mutate: setThemeMutation } = useMutation(SET_THEME_MUTATION);
const persistThemePreference = (themeName: ThemeName) => {
const expires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString();
document.cookie = `${THEME_COOKIE_KEY}=${encodeURIComponent(themeName)}; path=/; SameSite=Lax; expires=${expires}`;
try {
window.localStorage?.setItem(STORAGE_KEY_THEME, themeName);
} catch {
// ignore
}
};
themeStore.setTheme({ name: themeName });
themeStore.setCssVars();
const syncDomForTheme = (themeName: ThemeName) => {
const root = document.documentElement;
const isDark = DARK_UI_THEMES.includes(themeName as (typeof DARK_UI_THEMES)[number]);
const method: 'add' | 'remove' = isDark ? 'add' : 'remove';
root.style.setProperty('--theme-name', themeName);
root.style.setProperty('--theme-dark-mode', isDark ? '1' : '0');
root.setAttribute('data-theme', themeName);
root.classList[method]('dark');
document.body?.classList[method]('dark');
document.querySelectorAll('.unapi').forEach((el) => el.classList[method]('dark'));
};
const updateThemeCssLink = (themeName: ThemeName) => {
const linkId = 'dev-theme-css-link';
let themeLink = document.getElementById(linkId) as HTMLLinkElement | null;
const themeCssMap: Record<string, string> = {
azure: '/test-pages/unraid-assets/themes/azure.css',
black: '/test-pages/unraid-assets/themes/black.css',
gray: '/test-pages/unraid-assets/themes/gray.css',
white: '/test-pages/unraid-assets/themes/white.css',
const themeCssMap: Record<ThemeName, string> = {
[ThemeName.AZURE]: '/test-pages/unraid-assets/themes/azure.css',
[ThemeName.BLACK]: '/test-pages/unraid-assets/themes/black.css',
[ThemeName.GRAY]: '/test-pages/unraid-assets/themes/gray.css',
[ThemeName.WHITE]: '/test-pages/unraid-assets/themes/white.css',
};
const cssUrl = themeCssMap[themeName];
@@ -74,52 +146,74 @@ const updateTheme = (themeName: string, skipUrlUpdate = false) => {
document.head.appendChild(themeLink);
}
themeLink.href = cssUrl;
} else {
if (themeLink) {
themeLink.remove();
} else if (themeLink) {
themeLink.remove();
}
};
const applyThemeSelection = async (
themeName: string | null | undefined,
{ persist = false, skipStore = false }: { persist?: boolean; skipStore?: boolean } = {}
) => {
const normalized = normalizeTheme(themeName) ?? ThemeName.WHITE;
currentTheme.value = normalized;
persistThemePreference(normalized);
syncDomForTheme(normalized);
updateThemeCssLink(normalized);
if (!skipStore) {
themeStore.setTheme({ name: normalized });
}
if (persist) {
isSaving.value = true;
try {
await setThemeMutation({ theme: normalized });
} catch (error) {
console.warn('[DevThemeSwitcher] Failed to persist theme via GraphQL', error);
} finally {
isSaving.value = false;
}
}
};
const handleThemeChange = (event: Event) => {
const newTheme = (event.target as HTMLSelectElement).value;
if (newTheme === currentTheme.value) {
const newTheme = normalizeTheme((event.target as HTMLSelectElement).value);
if (!newTheme || newTheme === currentTheme.value) {
return;
}
currentTheme.value = newTheme;
updateTheme(newTheme);
void applyThemeSelection(newTheme, { persist: true });
};
onMounted(() => {
onMounted(async () => {
themeStore.setDevOverride(true);
const initialTheme = getCurrentTheme();
currentTheme.value = initialTheme;
const existingLink = document.getElementById('dev-theme-css-link') as HTMLLinkElement | null;
if (!existingLink || !existingLink.href) {
updateTheme(initialTheme, true);
} else {
themeStore.setTheme({ name: initialTheme });
themeStore.setCssVars();
}
const initialTheme = await resolveInitialTheme();
await applyThemeSelection(initialTheme);
});
watch(
() => theme.value.name,
(newName) => {
if (newName && newName !== currentTheme.value) {
currentTheme.value = newName;
const url = new URL(window.location.href);
url.searchParams.set('theme', newName);
window.history.replaceState({}, '', url);
const normalized = normalizeTheme(newName);
if (!normalized || normalized === currentTheme.value) {
return;
}
void applyThemeSelection(normalized, { skipStore: true });
}
);
</script>
<template>
<select :value="currentTheme" class="dev-theme-select" @change="handleThemeChange">
<select
:value="currentTheme"
class="dev-theme-select"
:disabled="isSaving || isQueryLoading"
@change="handleThemeChange"
>
<option v-for="option in themeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
@@ -147,4 +241,9 @@ watch(
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
}
.dev-theme-select:disabled {
opacity: 0.7;
cursor: not-allowed;
}
</style>

View File

@@ -1,76 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { ArrowDownTrayIcon, ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
import { BrandButton } from '@unraid/ui';
import { CONNECT_FORUMS, CONTACT, DISCORD, WEBGUI_GRAPHQL } from '~/helpers/urls';
const { t } = useI18n();
const joinPaths = (base: string, path: string) => {
const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
return `${normalizedBase}/${normalizedPath}`;
};
const downloadUrl = computed(() => {
const csrfToken = globalThis.csrf_token ?? '';
const downloadPath = joinPaths(WEBGUI_GRAPHQL, '/api/logs');
const params = new URLSearchParams({ csrf_token: csrfToken });
return `${downloadPath}?${params.toString()}`;
});
</script>
<template>
<div class="flex max-w-3xl flex-col gap-y-4 whitespace-normal">
<p class="text-start text-sm">
{{ t('downloadApiLogs.thePrimaryMethodOfSupportFor') }}
{{ t('downloadApiLogs.ifYouAreAskedToSupply') }}
{{ t('downloadApiLogs.theLogsMayContainSensitiveInformation') }}
</p>
<span class="flex flex-col gap-y-4">
<div class="flex">
<BrandButton
class="shrink-0 grow-0"
download
:external="true"
:href="downloadUrl"
:icon="ArrowDownTrayIcon"
size="12px"
:text="t('downloadApiLogs.downloadUnraidApiLogs')"
/>
</div>
<div class="flex flex-row items-baseline gap-2">
<a
:href="CONNECT_FORUMS.toString()"
target="_blank"
rel="noopener noreferrer"
class="inline-flex flex-row items-center justify-start gap-2 text-[#486dba] hover:text-[#3b5ea9] hover:underline focus:text-[#3b5ea9] focus:underline"
>
{{ t('downloadApiLogs.unraidConnectForums') }}
<ArrowTopRightOnSquareIcon class="w-4" />
</a>
<a
:href="DISCORD.toString()"
target="_blank"
rel="noopener noreferrer"
class="inline-flex flex-row items-center justify-start gap-2 text-[#486dba] hover:text-[#3b5ea9] hover:underline focus:text-[#3b5ea9] focus:underline"
>
{{ t('downloadApiLogs.unraidDiscord') }}
<ArrowTopRightOnSquareIcon class="w-4" />
</a>
<a
:href="CONTACT.toString()"
target="_blank"
rel="noopener noreferrer"
class="inline-flex flex-row items-center justify-start gap-2 text-[#486dba] hover:text-[#3b5ea9] hover:underline focus:text-[#3b5ea9] focus:underline"
>
{{ t('downloadApiLogs.unraidContactPage') }}
<ArrowTopRightOnSquareIcon class="w-4" />
</a>
</div>
</span>
</div>
</template>

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { ArrowTopRightOnSquareIcon, KeyIcon } from '@heroicons/vue/24/solid';
import { ArrowPathIcon, ArrowTopRightOnSquareIcon, KeyIcon } from '@heroicons/vue/24/solid';
import { Badge, BrandButton } from '@unraid/ui';
import { DOCS_REGISTRATION_REPLACE_KEY } from '~/helpers/urls';
@@ -11,20 +12,30 @@ import { useReplaceRenewStore } from '~/store/replaceRenew';
const { t } = useI18n();
const replaceRenewStore = useReplaceRenewStore();
const { replaceStatusOutput } = storeToRefs(replaceRenewStore);
const isError = computed(() => replaceStatusOutput.value?.variant === 'red');
const showButton = computed(() => !replaceStatusOutput.value || isError.value);
const handleCheck = () => {
if (isError.value) {
replaceRenewStore.reset();
}
replaceRenewStore.check(true);
};
</script>
<template>
<div class="flex flex-wrap items-center justify-between gap-2">
<BrandButton
v-if="!replaceStatusOutput"
:icon="KeyIcon"
:text="t('registration.replaceCheck.checkEligibility')"
v-if="showButton"
:icon="isError ? ArrowPathIcon : KeyIcon"
:text="isError ? t('common.retry') : t('registration.replaceCheck.checkEligibility')"
class="grow"
@click="replaceRenewStore.check"
@click="handleCheck"
/>
<Badge v-else :variant="replaceStatusOutput.variant" :icon="replaceStatusOutput.icon" size="md">
{{ t(replaceStatusOutput.text ?? 'Unknown') }}
<Badge v-else :variant="replaceStatusOutput?.variant" :icon="replaceStatusOutput?.icon" size="md">
{{ t(replaceStatusOutput?.text ?? 'Unknown') }}
</Badge>
<span class="inline-flex flex-wrap items-center justify-end gap-2">

View File

@@ -19,7 +19,8 @@ import { computed, onBeforeMount } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { BrandLoading, PageContainer } from '@unraid/ui';
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
import { BrandButton, PageContainer } from '@unraid/ui';
import { WEBGUI_TOOLS_UPDATE } from '~/helpers/urls';
import UpdateOsStatus from '~/components/UpdateOs/Status.vue';
@@ -47,25 +48,42 @@ const subtitle = computed(() => {
return '';
});
/** when we're not prompting for reboot /Tools/Update will automatically send the user to account.unraid.net/server/update-os */
const showLoader = computed(
() => window.location.pathname === WEBGUI_TOOLS_UPDATE && rebootType.value === ''
// Show a prompt to continue in the Account app when no reboot is pending.
const showRedirectPrompt = computed(
() =>
typeof window !== 'undefined' &&
window.location.pathname === WEBGUI_TOOLS_UPDATE &&
rebootType.value === ''
);
const openAccountUpdate = () => {
accountStore.updateOs(true);
};
onBeforeMount(() => {
if (showLoader.value) {
accountStore.updateOs(true);
}
serverStore.setRebootVersion(props.rebootVersion);
});
</script>
<template>
<PageContainer>
<div v-show="showLoader">
<BrandLoading class="mx-auto my-12 max-w-[160px]" />
<div
v-if="showRedirectPrompt"
class="mx-auto flex max-w-[720px] flex-col items-center gap-4 py-8 text-center"
>
<h1 class="text-2xl font-semibold">{{ t('updateOs.updateUnraidOs') }}</h1>
<p class="text-base leading-relaxed opacity-75">
{{ t('updateOs.update.receiveTheLatestAndGreatestFor') }}
</p>
<BrandButton
data-testid="update-os-account-button"
:icon-right="ArrowTopRightOnSquareIcon"
@click="openAccountUpdate"
>
{{ t('updateOs.update.viewAvailableUpdates') }}
</BrandButton>
</div>
<div v-show="!showLoader">
<div v-else>
<UpdateOsStatus
:show-update-check="true"
:title="t('updateOs.updateUnraidOs')"

View File

@@ -198,7 +198,7 @@ const navigateToRegistration = () => {
variant="yellow"
:icon="() => h(ExclamationTriangleIcon, { style: 'width: 16px; height: 16px;' })"
>
{{ t(rebootTypeText) }}
{{ rebootTypeText }}
</Badge>
</template>

View File

@@ -18,7 +18,7 @@ const { rebootTypeText } = storeToRefs(useUpdateOsActionsStore());
<div class="grid gap-y-4">
<h3 class="flex flex-row items-center gap-2 text-xl leading-normal font-semibold">
<ExclamationTriangleIcon class="w-5 shrink-0" />
{{ t(rebootTypeText) }}
{{ rebootTypeText }}
</h3>
<div class="text-base leading-relaxed whitespace-normal opacity-75">
<p>

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