Compare commits

...

39 Commits

Author SHA1 Message Date
Eli Bosley
771014b005 chore: fix timezone on pushed PRs to actually redirect correctly 2025-09-15 12:23:11 -04:00
Eli Bosley
31a255c928 fix: false positive on verify_install script being external shell (#1704)
- Introduced a new test script for shell detection logic in
`verify_install.sh`.
- Updated the `package.json` to include a new test command for shell
detection.
- Enhanced the `verify_install.sh` script to accurately check the
current shell interpreter using `/proc` and fallback methods.

This improves the testing framework and ensures better validation of
shell detection functionality.

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

- Bug Fixes
- Installer now detects the actual interpreter more robustly and
strictly requires Bash; non-Bash environments produce clear error
messages and abort installation.
- Tests
- Added automated shell-detection tests to validate behavior across
environments.
- Test suite expanded to run the new shell-detection checks as part of
the standard test command.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-09-15 12:17:50 -04:00
Eli Bosley
167857a323 chore: fix dev environment 2025-09-15 11:38:04 -04:00
renovate[bot]
b80988aaab fix(deps): update dependency filenamify to v7 (#1703)
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
| [filenamify](https://redirect.github.com/sindresorhus/filenamify) |
[`6.0.0` ->
`7.0.0`](https://renovatebot.com/diffs/npm/filenamify/6.0.0/7.0.0) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/filenamify/7.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/filenamify/6.0.0/7.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>sindresorhus/filenamify (filenamify)</summary>

###
[`v7.0.0`](https://redirect.github.com/sindresorhus/filenamify/releases/tag/v7.0.0)

[Compare
Source](https://redirect.github.com/sindresorhus/filenamify/compare/v6.0.0...v7.0.0)

##### Breaking

- Require Node.js 20
[`cc39041`](https://redirect.github.com/sindresorhus/filenamify/commit/cc39041)
- Fix Unicode normalization
[`ea8b961`](https://redirect.github.com/sindresorhus/filenamify/commit/ea8b961)
- Previously, it used the incorrect `NFD` Unicode normalization. It now
uses the correct `NFC` normalization. This could be a breaking change if
you stored the filenames and then compare, as they won't match. You
could run a `NFC` normalization on the stored once to fix that.

##### Improvements

- Use grapheme-safe truncation
[`98169ce`](https://redirect.github.com/sindresorhus/filenamify/commit/98169ce)
- Fix trailing spaces and periods handling
[`43eea4d`](https://redirect.github.com/sindresorhus/filenamify/commit/43eea4d)

***

</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:eyJjcmVhdGVkSW5WZXIiOiI0MS45Ny4xMCIsInVwZGF0ZWRJblZlciI6IjQxLjk3LjEwIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 11:24:31 -04:00
Eli Bosley
fe4a6451f1 chore: update pnpm lock 2025-09-15 11:24:31 -04:00
renovate[bot]
9a86c615da fix(deps): pin dependency conventional-changelog-conventionalcommits to 9.1.0 (#1697)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
|
[conventional-changelog-conventionalcommits](https://redirect.github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-conventionalcommits#readme)
([source](https://redirect.github.com/conventional-changelog/conventional-changelog/tree/HEAD/packages/conventional-changelog-conventionalcommits))
| dependencies | pin | [`^9.1.0` ->
`9.1.0`](https://renovatebot.com/diffs/npm/conventional-changelog-conventionalcommits/9.1.0/9.1.0)
|

Add the preset `:preserveSemverRanges` to your config if you don't want
to pin your dependencies.

---

### 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 is behind base branch, 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:eyJjcmVhdGVkSW5WZXIiOiI0MS45Ny4xMCIsInVwZGF0ZWRJblZlciI6IjQxLjk3LjEwIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 11:24:31 -04:00
renovate[bot]
25ff8992a5 chore(deps): update dependency type-fest to v5 (#1701)
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
| [type-fest](https://redirect.github.com/sindresorhus/type-fest) |
[`4.41.0` ->
`5.0.0`](https://renovatebot.com/diffs/npm/type-fest/4.41.0/5.0.0) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/type-fest/5.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/type-fest/4.41.0/5.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>sindresorhus/type-fest (type-fest)</summary>

###
[`v5.0.0`](https://redirect.github.com/sindresorhus/type-fest/releases/tag/v5.0.0)

[Compare
Source](https://redirect.github.com/sindresorhus/type-fest/compare/v4.41.0...v5.0.0)

##### Breaking

- This package is now pure ESM. **Please [read
this](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c).**
- Require TypeScript 5.9
[`b5b0214`](https://redirect.github.com/sindresorhus/type-fest/commit/b5b0214)
- Require Node.js 20
[`cc2b0f2`](https://redirect.github.com/sindresorhus/type-fest/commit/cc2b0f2)
- Reminder: `type-fest` requires `strict: true` in your tsconfig.
- `StringKeyOf`: Rename to
[`KeyAsString`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/key-as-string.d.ts)
[`e492c9c`](https://redirect.github.com/sindresorhus/type-fest/commit/e492c9c)
-
[`ArrayTail`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/array-tail.d.ts):
Enable
[`preserveReadonly`](68469722a9/source/array-tail.d.ts (L8-L30))
by default and remove the option
[`b34b1d8`](https://redirect.github.com/sindresorhus/type-fest/commit/b34b1d8)
-
[`CamelCase`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/camel-case.d.ts)
/
[`CamelCasedProperties`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/camel-cased-properties.d.ts)
/
[`CamelCasedPropertiesDeep`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/camel-cased-properties-deep.d.ts)
/
[`PascalCase`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/pascal-case.d.ts)
/
[`PascalCasedProperties`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/pascal-cased-properties.d.ts)
/
[`PascalCasedPropertiesDeep`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/pascal-cased-properties-deep.d.ts):
Disable `preserveConsecutiveUppercase` by default
[`8226c1b`](https://redirect.github.com/sindresorhus/type-fest/commit/8226c1b)
  - This aligns it with the general JavaScript naming convention.
-
[`PartialDeep`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/partial-deep.d.ts):
Disable `allowUndefinedInNonTupleArrays` by default
[`b3c4524`](https://redirect.github.com/sindresorhus/type-fest/commit/b3c4524)
-
[`Split`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/split.d.ts):
Enable `strictLiteralChecks` by default
[`544a846`](https://redirect.github.com/sindresorhus/type-fest/commit/544a846)
-
[`Paths`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/paths.d.ts):
Default `maxRecursionDepth` 5 (was 10)
[`2ab5dec`](https://redirect.github.com/sindresorhus/type-fest/commit/2ab5dec)
-
[`ObservableLike`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/globals/observable-like.d.ts):
Move to sub-export
[`2a1072e`](https://redirect.github.com/sindresorhus/type-fest/commit/2a1072e)
- Deprecate `If*` types in favor of a single
[`If`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/if.d.ts)
[`4c2151a`](https://redirect.github.com/sindresorhus/type-fest/commit/4c2151a)

##### New types

-
[`Alphanumeric`](fc14f87e7f/source/characters.d.ts)
— Single alphanumeric character (`A–Z`, `a–z`, `0–9`).
[`484e030`](https://redirect.github.com/sindresorhus/type-fest/commit/484e030)
-
[`AllExtend`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/all-extend.d.ts)
— Evaluates to `true` if every element of a tuple/array extends `U`.
[`c8c6d55`](https://redirect.github.com/sindresorhus/type-fest/commit/c8c6d55)
-
[`ConditionalSimplify`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/conditional-simplify.d.ts)
— Simplifies a type’s unions/intersections with opt-in controls.
[`b7a4771`](https://redirect.github.com/sindresorhus/type-fest/commit/b7a4771)
-
[`ConditionalSimplifyDeep`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/conditional-simplify-deep.d.ts)
— Deep version of `ConditionalSimplify` that recurses into objects.
[`b7a4771`](https://redirect.github.com/sindresorhus/type-fest/commit/b7a4771)
-
[`DigitCharacter`](fc14f87e7f/source/characters.d.ts)
— Single ASCII digit character (`0–9`).
[`484e030`](https://redirect.github.com/sindresorhus/type-fest/commit/484e030)
-
[`ExcludeStrict`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/exclude-strict.d.ts)
— Non-distributive, stricter variant of `Exclude<T, U>`.
[`e6f62a2`](https://redirect.github.com/sindresorhus/type-fest/commit/e6f62a2)
-
[`ExtendsStrict`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/extends-strict.d.ts)
— Non-distributive `A extends B` check.
[`d71242a`](https://redirect.github.com/sindresorhus/type-fest/commit/d71242a)
-
[`ExtractStrict`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/extract-strict.d.ts)
— Non-distributive, stricter variant of `Extract<T, U>`.
[`98d24fa`](https://redirect.github.com/sindresorhus/type-fest/commit/98d24fa)
-
[`IsLowercase`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/is-lowercase.d.ts)
— Evaluates to `true` if a string literal is all lowercase.
[`afe132c`](https://redirect.github.com/sindresorhus/type-fest/commit/afe132c)
-
[`IsNullable`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/is-nullable.d.ts)
— Evaluates to `true` if `T` includes `null`.
[`5067e25`](https://redirect.github.com/sindresorhus/type-fest/commit/5067e25)
-
[`IsOptional`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/is-optional.d.ts)
— Evaluates to `true` if `T` includes `undefined`.
[`5067e25`](https://redirect.github.com/sindresorhus/type-fest/commit/5067e25)
-
[`IsOptionalKeyOf`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/is-optional-key-of.d.ts)
— Evaluates to `true` if property `K` of `T` is optional.
[`93728b5`](https://redirect.github.com/sindresorhus/type-fest/commit/93728b5)
-
[`IsReadonlyKeyOf`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/is-readonly-key-of.d.ts)
— Evaluates to `true` if property `K` of `T` is readonly.
[`93728b5`](https://redirect.github.com/sindresorhus/type-fest/commit/93728b5)
-
[`IsRequiredKeyOf`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/is-required-key-of.d.ts)
— Evaluates to `true` if property `K` of `T` is required.
[`93728b5`](https://redirect.github.com/sindresorhus/type-fest/commit/93728b5)
-
[`IsUnion`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/is-union.d.ts)
— Evaluates to `true` if `T` is a union type.
[`b3d92ed`](https://redirect.github.com/sindresorhus/type-fest/commit/b3d92ed)
-
[`IsUndefined`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/is-undefined.d.ts)
— Evaluates to `true` if the type is exactly `undefined`.
[`f7bc576`](https://redirect.github.com/sindresorhus/type-fest/commit/f7bc576)
-
[`IsUppercase`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/is-uppercase.d.ts)
— Evaluates to `true` if a string literal is all uppercase.
[`afe132c`](https://redirect.github.com/sindresorhus/type-fest/commit/afe132c)
-
[`LowercaseLetter`](fc14f87e7f/source/characters.d.ts)
— Single lowercase Latin letter (`a–z`).
[`484e030`](https://redirect.github.com/sindresorhus/type-fest/commit/484e030)
-
[`RemovePrefix`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/remove-prefix.d.ts)
— Removes a specified prefix from a string literal.
[`18a1c04`](https://redirect.github.com/sindresorhus/type-fest/commit/18a1c04)
-
[`UppercaseLetter`](fc14f87e7f/source/characters.d.ts)
— Single uppercase Latin letter (`A–Z`).
[`484e030`](https://redirect.github.com/sindresorhus/type-fest/commit/484e030)

##### Improvements

-
[`Jsonify`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/jsonify.d.ts):
Handle `unknown` as `JsonValue`
[`642bb13`](https://redirect.github.com/sindresorhus/type-fest/commit/642bb13)
-
[`SetRequired`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/set-required.d.ts)
/
[`SetOptional`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/set-optional.d.ts)
/
[`SetReadonly`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/set-readonly.d.ts):
Handle functions with properties
[`a5e45d4`](https://redirect.github.com/sindresorhus/type-fest/commit/a5e45d4)
-
[`Schema`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/schema.d.ts):
Preserve arrays/remove extraneous unions
[`8a96def`](https://redirect.github.com/sindresorhus/type-fest/commit/8a96def);
drop `undefined` for `recurseIntoArrays`
[`1cb955b`](https://redirect.github.com/sindresorhus/type-fest/commit/1cb955b)
-
[`ReadonlyKeysOf`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/readonly-keys-of.d.ts)
/
[`WritableKeysOf`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/writable-keys-of.d.ts):
Add `object` constraint
[`a6efbe0`](https://redirect.github.com/sindresorhus/type-fest/commit/a6efbe0)
-
[`TsConfigJson`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/tsconfig-json.d.ts):
Add TypeScript 5.9 fields
[`d2bda94`](https://redirect.github.com/sindresorhus/type-fest/commit/d2bda94)

##### Fixes

-
[`Or`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/or.d.ts):
Fix with `boolean`, `never`, `any`
[`42d6106`](https://redirect.github.com/sindresorhus/type-fest/commit/42d6106)
-
[`And`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/and.d.ts):
Fix with `boolean`, `never`, `any`
[`b38ac60`](https://redirect.github.com/sindresorhus/type-fest/commit/b38ac60)
-
[`IsStringLiteral`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/is-literal.d.ts):
Fix uncollapsed unions, and tagged types
[`eb37799`](https://redirect.github.com/sindresorhus/type-fest/commit/eb37799)
/
[`d1b35c7`](https://redirect.github.com/sindresorhus/type-fest/commit/d1b35c7)
-
[`Paths`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/paths.d.ts):
Fix behavior with index signatures
[`9926e5d`](https://redirect.github.com/sindresorhus/type-fest/commit/9926e5d)
-
[`ConditionalKeys`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/conditional-keys.d.ts):
Fix behavior with arrays and unions
[`4d7cc50`](https://redirect.github.com/sindresorhus/type-fest/commit/4d7cc50)
-
[`RequiredDeep`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/required-deep.d.ts):
Fix with `undefined`
[`bfcdbc4`](https://redirect.github.com/sindresorhus/type-fest/commit/bfcdbc4)
-
[`Split`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/split.d.ts):
Fix template strings ending with interpolation
[`853b881`](https://redirect.github.com/sindresorhus/type-fest/commit/853b881)
-
[`ArrayTail`](https://redirect.github.com/sindresorhus/type-fest/blob/main/source/array-tail.d.ts):
Fix fix non-tuple arrays
[`f3aabd8`](https://redirect.github.com/sindresorhus/type-fest/commit/f3aabd8)
- Fix `UnionMin` and `UnionMax`
[`d52d5e7`](https://redirect.github.com/sindresorhus/type-fest/commit/d52d5e7)

##### Meta

Huge thanks to all the contributors to this release, especially
[@&#8203;som-sm](https://redirect.github.com/som-sm) 🙌

***

</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:eyJjcmVhdGVkSW5WZXIiOiI0MS45Ny4xMCIsInVwZGF0ZWRJblZlciI6IjQxLjk3LjEwIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 11:24:31 -04:00
renovate[bot]
45fb53d040 chore(deps): update actions/setup-node action to v5 (#1680)
This PR contains the following updates:

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

---

### Release Notes

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

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

[Compare
Source](https://redirect.github.com/actions/setup-node/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.

🔕 **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:eyJjcmVhdGVkSW5WZXIiOiI0MS45Ny4xMCIsInVwZGF0ZWRJblZlciI6IjQxLjk3LjEwIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Eli Bosley <ekbosley@gmail.com>
2025-09-15 11:24:31 -04:00
Eli Bosley
c855caa9b2 fix: improve vue mount speed by 10x
- Refactored teleport container management to be lazily created,
improving performance by avoiding unnecessary DOM manipulations.
- Updated `useTeleport` to dynamically determine the correct teleport
target based on mounted components.
- Removed the `ensureTeleportContainer` import from various components,
streamlining the mounting process.
- Adjusted the dropdown menu component to utilize a computed property
for teleport target management.
- Enhanced the component registry to support a unified app architecture,
replacing legacy mounting functions with a more efficient approach.

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

* **New Features**
  * Support bundles may include the GraphQL API log when present.
  * OS version data loads lazily when the header dropdown first opens.
* Many UI components now load on demand via a unified mounting approach.

* **Bug Fixes**
  * Dropdowns and modals consistently stack above other UI elements.
  * Server status layout fixes improve small-screen alignment.

* **Breaking Changes**
* Teleport/mounting APIs and public teleport helper were
consolidated/removed; integrations may need update.

* **Tests**
* Extensive new unit tests added for mounting, teleport, modals, and
REST log handling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-09-15 11:24:31 -04:00
renovate[bot]
ba4a43aec8 fix(deps): update graphqlcodegenerator monorepo (major) (#1689) 2025-09-15 11:24:24 -04:00
github-actions[bot]
c4ca761dfc chore(main): release 4.22.1 (#1698)
🤖 I have created a release *beep* *boop*
---


## [4.22.1](https://github.com/unraid/api/compare/v4.22.0...v4.22.1)
(2025-09-12)


### Bug Fixes

* set input color in SSO field rather than inside of the main.css
([01d353f](01d353fa08))

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

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-12 15:41:12 -04:00
Eli Bosley
01d353fa08 fix: set input color in SSO field rather than inside of the main.css 2025-09-12 15:36:10 -04:00
Eli Bosley
4a07953457 refactor: move global input text color for SSO button to SsoButton component 2025-09-12 15:33:28 -04:00
github-actions[bot]
0b20e3ea9f chore(main): release 4.22.0 (#1692)
🤖 I have created a release *beep* *boop*
---


## [4.22.0](https://github.com/unraid/api/compare/v4.21.0...v4.22.0)
(2025-09-12)


### Features

* improved update ui
([#1691](https://github.com/unraid/api/issues/1691))
([a59b363](a59b363ebc))


### Bug Fixes

* **deps:** update dependency camelcase-keys to v10
([#1687](https://github.com/unraid/api/issues/1687))
([95faeaa](95faeaa2f3))
* **deps:** update dependency p-retry to v7
([#1608](https://github.com/unraid/api/issues/1608))
([c782cf0](c782cf0e87))
* **deps:** update dependency uuid to v13
([#1688](https://github.com/unraid/api/issues/1688))
([2fef10c](2fef10c94a))
* **deps:** update dependency vue-sonner to v2
([#1475](https://github.com/unraid/api/issues/1475))
([f95ca9c](f95ca9c9cb))
* display settings fix for languages on less than 7.2-beta.2.3
([#1696](https://github.com/unraid/api/issues/1696))
([03dae7c](03dae7ce66))
* hide reset help option when sso is being checked
([#1695](https://github.com/unraid/api/issues/1695))
([222ced7](222ced7518))
* progressFrame white on black
([0990b89](0990b898bd))

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

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-12 14:59:21 -04:00
Eli Bosley
3f4af09db5 chore(deps): update conventional commit (#1693)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Updated changelog tooling dependencies and CI to fetch full Git
history; added conventional-changelog-conventionalcommits.
* **Tests**
* Added comprehensive tests for changelog output, header/tag handling,
fallback behavior, and compatibility with the updated changelog API.
* **Refactor**
* Reworked changelog generation to use the newer changelog API, improve
tag-aware headers, and support deriving PR-style changelogs with
graceful fallbacks.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-12 14:24:49 -04:00
Eli Bosley
222ced7518 fix: hide reset help option when sso is being checked (#1695)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- Enhancements
- Improved SSO sign-in experience on the login screen. The “Forgot your
password?” link is now automatically hidden while SSO status is being
checked or a provider is loading, and restored once ready. This reduces
confusion and visual flicker during authentication, ensuring a cleaner,
more stable layout throughout the process.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-12 13:32:37 -04:00
Eli Bosley
03dae7ce66 fix: display settings fix for languages on less than 7.2-beta.2.3 (#1696)
…less
2025-09-12 13:32:26 -04:00
Eli Bosley
0990b898bd fix: progressFrame white on black 2025-09-12 08:50:47 -04:00
renovate[bot]
95faeaa2f3 fix(deps): update dependency camelcase-keys to v10 (#1687)
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
|
[camelcase-keys](https://redirect.github.com/sindresorhus/camelcase-keys)
| [`9.1.3` ->
`10.0.0`](https://renovatebot.com/diffs/npm/camelcase-keys/9.1.3/10.0.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/camelcase-keys/10.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/camelcase-keys/9.1.3/10.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>sindresorhus/camelcase-keys (camelcase-keys)</summary>

###
[`v10.0.0`](https://redirect.github.com/sindresorhus/camelcase-keys/releases/tag/v10.0.0)

[Compare
Source](https://redirect.github.com/sindresorhus/camelcase-keys/compare/v9.1.3...v10.0.0)

##### Breaking

- Require Node.js 20
[`2cc9388`](https://redirect.github.com/sindresorhus/camelcase-keys/commit/2cc9388)

##### Fixes

- Fix handling of circular references
[`3936f15`](https://redirect.github.com/sindresorhus/camelcase-keys/commit/3936f15)
- Fix TypeScript interface compatibility with stricter constraints
[`c89299a`](https://redirect.github.com/sindresorhus/camelcase-keys/commit/c89299a)
- Fix TypeScript types for union types in arrays
[`26e186e`](https://redirect.github.com/sindresorhus/camelcase-keys/commit/26e186e)

***

</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:eyJjcmVhdGVkSW5WZXIiOiI0MS45Ny4xMCIsInVwZGF0ZWRJblZlciI6IjQxLjk3LjEwIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-11 22:25:23 -04:00
renovate[bot]
b49ef5a762 chore(deps): update dependency @faker-js/faker to v10 (#1619)
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
| [@faker-js/faker](https://fakerjs.dev)
([source](https://redirect.github.com/faker-js/faker)) | [`9.9.0` ->
`10.0.0`](https://renovatebot.com/diffs/npm/@faker-js%2ffaker/9.9.0/10.0.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@faker-js%2ffaker/10.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@faker-js%2ffaker/9.9.0/10.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>faker-js/faker (@&#8203;faker-js/faker)</summary>

###
[`v10.0.0`](https://redirect.github.com/faker-js/faker/blob/HEAD/CHANGELOG.md#1000-2025-08-21)

[Compare
Source](https://redirect.github.com/faker-js/faker/compare/v9.9.0...v10.0.0)

##### New Locales

- **locale:** extended list of colors in Polish
([#&#8203;3586](https://redirect.github.com/faker-js/faker/issues/3586))
([9940d54](9940d54f75))

##### Features

- **locales:** add animal vocabulary(bear, bird, cat, rabbit, pet\_name)
in Korean
([#&#8203;3535](https://redirect.github.com/faker-js/faker/issues/3535))
([0d2143c](0d2143c75d))

##### Changed Locales

- **locale:** remove invalid credit card issuer patterns
([#&#8203;3568](https://redirect.github.com/faker-js/faker/issues/3568))
([9783d95](9783d95a8e))

</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:eyJjcmVhdGVkSW5WZXIiOiI0MS44Mi43IiwidXBkYXRlZEluVmVyIjoiNDEuODIuNyIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-11 22:21:51 -04:00
renovate[bot]
c782cf0e87 fix(deps): update dependency p-retry to v7 (#1608)
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
| [p-retry](https://redirect.github.com/sindresorhus/p-retry) | [`6.2.1`
-> `7.0.0`](https://renovatebot.com/diffs/npm/p-retry/6.2.1/7.0.0) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/p-retry/7.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/p-retry/6.2.1/7.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>sindresorhus/p-retry (p-retry)</summary>

###
[`v7.0.0`](https://redirect.github.com/sindresorhus/p-retry/releases/tag/v7.0.0)

[Compare
Source](https://redirect.github.com/sindresorhus/p-retry/compare/v6.2.1...v7.0.0)

##### Breaking

- Require Node.js 20
[`3bdb53a`](https://redirect.github.com/sindresorhus/p-retry/commit/3bdb53a)
- `onFailedAttempt` and `shouldRetry` now receive a `context` object
instead of a decorated `error`
[`bff36bb`](https://redirect.github.com/sindresorhus/p-retry/commit/bff36bb)
- You must now must access the error as `object.error` instead of
`object`.
  - The use of `.attemptNumber` and `.retriesLeft` did not change.
- Remove the `forever` option
([#&#8203;79](https://redirect.github.com/sindresorhus/p-retry/issues/79))
[`6a89827`](https://redirect.github.com/sindresorhus/p-retry/commit/6a89827)
- Many use-cases can use `{retries: Infinity}` instead for infinite
retries.

##### Improvements

- Rewrite the package to not depend on the `retry` package
([#&#8203;79](https://redirect.github.com/sindresorhus/p-retry/issues/79))
[`6a89827`](https://redirect.github.com/sindresorhus/p-retry/commit/6a89827)
  - This is a full rewrite, so test carefully.
- Add
[`makeRetriable`](https://redirect.github.com/sindresorhus/p-retry#makeretriablefunction-options)
method
[`1a81c1e`](https://redirect.github.com/sindresorhus/p-retry/commit/1a81c1e)

***

</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:eyJjcmVhdGVkSW5WZXIiOiI0MS44MS4yIiwidXBkYXRlZEluVmVyIjoiNDEuODEuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-11 22:21:23 -04:00
renovate[bot]
f95ca9c9cb fix(deps): update dependency vue-sonner to v2 (#1475)
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
| [vue-sonner](https://redirect.github.com/xiaoluoboding/vue-sonner) |
[`1.3.2` ->
`2.0.8`](https://renovatebot.com/diffs/npm/vue-sonner/1.3.2/2.0.8) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/vue-sonner/2.0.8?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vue-sonner/1.3.2/2.0.8?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>xiaoluoboding/vue-sonner (vue-sonner)</summary>

###
[`v2.0.8`](https://redirect.github.com/xiaoluoboding/vue-sonner/blob/HEAD/CHANGELOG.md#208-2025-08-18)

[Compare
Source](https://redirect.github.com/xiaoluoboding/vue-sonner/compare/v2.0.7...v2.0.8)

##### Bug Fixes

- fixed the type for nuxt 4
([e60b0bd](e60b0bd56f))

###
[`v2.0.7`](https://redirect.github.com/xiaoluoboding/vue-sonner/blob/HEAD/CHANGELOG.md#207-2025-08-17)

[Compare
Source](https://redirect.github.com/xiaoluoboding/vue-sonner/compare/v2.0.2...v2.0.7)

##### Bug Fixes

- custom component not trigger on dismiss function
([d5a69c6](d5a69c6ae1))
- fixed the build error
([5ec4bca](5ec4bca24f))
- improve expanded state management in Toaster component
([5acca24](5acca24250))
- postcss-calc will change the style
([f0d6add](f0d6add116))

##### Features

- add a new example
([cb08aef](cb08aef616))
- add example for close all
([70ef6e2](70ef6e2b7f))

###
[`v2.0.2`](https://redirect.github.com/xiaoluoboding/vue-sonner/blob/HEAD/CHANGELOG.md#202-2025-07-17)

[Compare
Source](https://redirect.github.com/xiaoluoboding/vue-sonner/compare/v2.0.1...v2.0.2)

###
[`v2.0.1`](https://redirect.github.com/xiaoluoboding/vue-sonner/blob/HEAD/CHANGELOG.md#201-2025-06-23)

[Compare
Source](https://redirect.github.com/xiaoluoboding/vue-sonner/compare/v2.0.0...v2.0.1)

##### Bug Fixes

- fixed multiple position bug
([9b07801](9b07801f5f))
- **theme:** make theme='system' reactive with watchEffect
([3b57e90](3b57e90498))
- **tsconfig:** add tsconfig.includes files
([e0d469b](e0d469b84e))

##### Features

- add do not generate css logic
([61458fb](61458fb7aa))

###
[`v2.0.0`](https://redirect.github.com/xiaoluoboding/vue-sonner/blob/HEAD/CHANGELOG.md#200-2025-05-21)

[Compare
Source](https://redirect.github.com/xiaoluoboding/vue-sonner/compare/v1.3.2...v2.0.0)

##### Bug Fixes

- add packages path
([35490b3](35490b3fb5))
- add packages path
([c7424e9](c7424e9070))
- fixed for nuxt module
([261eaf0](261eaf0be2))
- fixed for nuxt module
([29751cf](29751cfd5b))
- format
([9033f2b](9033f2b935))

####
[1.3.2](https://redirect.github.com/xiaoluoboding/vue-sonner/compare/v1.3.0...v1.3.2)
(2025-04-12)

##### Bug Fixes

- improve CSS insertion logic to handle document loading state
([6b22d24](6b22d2458b))

</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:eyJjcmVhdGVkSW5WZXIiOiI0MS4xNy4yIiwidXBkYXRlZEluVmVyIjoiNDEuNzEuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Eli Bosley <ekbosley@gmail.com>
2025-09-11 22:14:46 -04:00
Eli Bosley
a59b363ebc feat: improved update ui (#1691)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Global awaitable confirmation modal for notification actions.
  * “Ignore this release” toggle that persists to the server when used.
* New test pages and standalone test controls for the update modal and
theme switching.

* **Refactor**
* Update modal rebuilt with a responsive layout, unified “Update
Available” title, revised action logic, and centralized modal plumbing.

* **Style**
* OS Update highlight block, improved spacing, refreshed iconography,
and tooltips clarifying actions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-11 21:35:13 -04:00
renovate[bot]
2fef10c94a fix(deps): update dependency uuid to v13 (#1688)
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
| [uuid](https://redirect.github.com/uuidjs/uuid) | [`11.1.0` ->
`13.0.0`](https://renovatebot.com/diffs/npm/uuid/11.1.0/13.0.0) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/uuid/13.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/uuid/11.1.0/13.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>uuidjs/uuid (uuid)</summary>

###
[`v13.0.0`](https://redirect.github.com/uuidjs/uuid/blob/HEAD/CHANGELOG.md#1300-2025-09-08)

[Compare
Source](https://redirect.github.com/uuidjs/uuid/compare/v12.0.0...v13.0.0)

##### ⚠ BREAKING CHANGES

- make browser exports the default
([#&#8203;901](https://redirect.github.com/uuidjs/uuid/issues/901))

##### Bug Fixes

- make browser exports the default
([#&#8203;901](https://redirect.github.com/uuidjs/uuid/issues/901))
([bce9d72](bce9d72a3a))

###
[`v12.0.0`](https://redirect.github.com/uuidjs/uuid/blob/HEAD/CHANGELOG.md#1200-2025-09-05)

[Compare
Source](https://redirect.github.com/uuidjs/uuid/compare/v11.1.0...v12.0.0)

##### ⚠ BREAKING CHANGES

- update to typescript\@&#8203;5.2
([#&#8203;887](https://redirect.github.com/uuidjs/uuid/issues/887))
- remove CommonJS support
([#&#8203;886](https://redirect.github.com/uuidjs/uuid/issues/886))
- drop node\@&#8203;16 support
([#&#8203;883](https://redirect.github.com/uuidjs/uuid/issues/883))

##### Features

- add node\@&#8203;24 to ci matrix
([#&#8203;879](https://redirect.github.com/uuidjs/uuid/issues/879))
([42b6178](42b6178aa2))
- drop node\@&#8203;16 support
([#&#8203;883](https://redirect.github.com/uuidjs/uuid/issues/883))
([0f38cf1](0f38cf1036))
- remove CommonJS support
([#&#8203;886](https://redirect.github.com/uuidjs/uuid/issues/886))
([ae786e2](ae786e2726))
- update to typescript\@&#8203;5.2
([#&#8203;887](https://redirect.github.com/uuidjs/uuid/issues/887))
([c7ee405](c7ee40598e))

##### Bug Fixes

- improve v4() performance
([#&#8203;894](https://redirect.github.com/uuidjs/uuid/issues/894))
([5fd974c](5fd974c127))
- restore node: prefix
([#&#8203;889](https://redirect.github.com/uuidjs/uuid/issues/889))
([e1f42a3](e1f42a3545))

</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:eyJjcmVhdGVkSW5WZXIiOiI0MS45Ny4xMCIsInVwZGF0ZWRJblZlciI6IjQxLjk3LjEwIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-11 21:20:48 -04:00
Eli Bosley
1c73a4af42 chore: rename .ce.vue files to .standalone.vue (#1690)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- Documentation
- Updated contributor guide to use “standalone” naming for web
components.
- Refactor
- Migrated app and component references from legacy variants to
standalone components.
- Unified component registry and updated global component typings to
standalone names.
- Tests
- Updated test suites to target standalone components; no behavior
changes.

No user-facing changes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-10 16:36:11 -04:00
github-actions[bot]
88a924c84f chore(main): release 4.21.0 (#1684)
🤖 I have created a release *beep* *boop*
---


## [4.21.0](https://github.com/unraid/api/compare/v4.20.4...v4.21.0)
(2025-09-10)


### Features

* add zsh shell detection to install script
([#1539](https://github.com/unraid/api/issues/1539))
([50ea2a3](50ea2a3ffb))
* **api:** determine if docker container has update
([#1582](https://github.com/unraid/api/issues/1582))
([e57d81e](e57d81e073))


### Bug Fixes

* white on white login text
([ae4d3ec](ae4d3ecbc4))

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

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-10 15:14:10 -04:00
Eli Bosley
ae4d3ecbc4 fix: white on white login text 2025-09-10 14:59:10 -04:00
Eli Bosley
c569043ab5 chore: rclone initialization version check (#1683)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Improvements**
* Enforces a minimum RClone version (1.70.0) with clearer startup/log
messages for missing, too-old, or unparseable versions.
* Adjusted initialization timing to a later bootstrap phase for more
reliable startup.

* **Tests**
* Expanded and hardened tests: broader API endpoint coverage, enhanced
HTTP error scenarios, refined request assertions, and comprehensive
RClone version-detection tests (newer/older, missing, malformed,
beta/RC).

* **Chores**
* Simplified permissions configuration by replacing detailed rules with
an empty permissions object and removing a top-level flag.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-09-10 11:00:59 -04:00
Eli Bosley
50ea2a3ffb feat: add zsh shell detection to install script (#1539)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Installer now detects when the environment is using Zsh and halts with
clear error messages and guidance so users can move Zsh configuration to
interactive-only files.

* **Bug Fixes**
* Prevents running the installer under unsupported shell setups,
improving installation reliability and avoiding misconfigured runs.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-09-10 11:00:41 -04:00
Eli Bosley
b518131406 chore(docs): improve build:watch mode to be easier to use 2025-09-10 10:27:05 -04:00
Pujit Mehrotra
e57d81e073 feat(api): determine if docker container has update (#1582)
- Add a new utility class, `AsyncMutex` in `unraid-shared ->
processing.ts`, for ergonomically de-duplicating async operations.
- Add an `@OmitIf` decorator for omitting graphql queries, mutations, or
field resolvers from the runtime graphql schema.
- Add feature-flagging system
  - `FeatureFlags` export from `consts.ts`
  - `@UseFeatureFlag` decorator built upon `OmitIf`
- `checkFeatureFlag` for constructing & throwing a `ForbiddenError` if
the given feature flag evaluates to `false`.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Expose disk spinning state, per-container "update available" and
"rebuild ready" indicators, a structured per-container update-status
list, and a mutation to refresh Docker digests. Periodic and
post-startup digest refreshes added (feature-flag gated).

* **Chores**
  * Cron scheduling refactor and scheduler centralization.
  * Build now bundles a PHP wrapper asset.
  * Added feature-flag env var and .gitignore entry for local keys.

* **Documentation**
  * Added developer guide for feature flags.

* **Tests**
  * New concurrency, parser, decorator, config, and mutex test suites.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-09 16:25:32 -04:00
Eli Bosley
88baddd6c0 chore: add previous build cleanup scripts (#1682)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- Chores
- Added automated cleanup of preview builds older than seven days to
reduce storage usage; runs on non-release pushes and won’t fail the
build if cleanup issues occur.
- Introduced a tool to optionally remove all pull request preview builds
with confirmation and clear summaries.
- Updated CI behavior to cancel in-progress runs only for pull requests;
pushes and releases are no longer auto-canceled.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-09 12:24:52 -04:00
github-actions[bot]
abc22bdb87 chore(main): release 4.20.4 (#1681)
🤖 I have created a release *beep* *boop*
---


## [4.20.4](https://github.com/unraid/api/compare/v4.20.3...v4.20.4)
(2025-09-09)


### Bug Fixes

* staging PR plugin fixes + UI issues on 7.2 beta
([b79b44e](b79b44e95c))

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

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-09 10:53:45 -04:00
Eli Bosley
6ed2f5ce8e chore: add comment when PR is merged 2025-09-09 10:42:57 -04:00
Eli Bosley
b79b44e95c fix: staging PR plugin fixes + UI issues on 7.2 beta 2025-09-09 10:39:48 -04:00
Eli Bosley
ca22285a26 chore: fix invalid user profile test (#1678)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* New Features
  * No user-facing changes in this release.
* Chores
* Streamlined release automation to run after successful build and test
stages on main, improving reliability of release tagging and downstream
usage.
  * Simplified job dependencies for related build pipelines.
* Tests
* Updated User Profile tests to align with revised DOM structure for the
description area; assertions unchanged and no functional impact for
users.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-09 10:29:47 -04:00
github-actions[bot]
838be2c52e chore(main): release 4.20.3 (#1677)
🤖 I have created a release *beep* *boop*
---


## [4.20.3](https://github.com/unraid/api/compare/v4.20.2...v4.20.3)
(2025-09-09)


### Bug Fixes

* header background color issues fixed on 7.2 - thanks Nick!
([73c1100](73c1100d0b))

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

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-09 09:31:14 -04:00
Eli Bosley
73c1100d0b fix: header background color issues fixed on 7.2 - thanks Nick! 2025-09-09 09:29:37 -04:00
github-actions[bot]
434e331384 chore(main): release 4.20.2 (#1676)
🤖 I have created a release *beep* *boop*
---


## [4.20.2](https://github.com/unraid/api/compare/v4.20.1...v4.20.2)
(2025-09-09)


### Bug Fixes

* trigger deployment
([a27453f](a27453fda8))

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

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-09 08:46:54 -04:00
146 changed files with 10312 additions and 3453 deletions

View File

@@ -1,123 +1,3 @@
{
"permissions": {
"allow": [
"# Development Commands",
"Bash(pnpm install)",
"Bash(pnpm dev)",
"Bash(pnpm build)",
"Bash(pnpm test)",
"Bash(pnpm test:*)",
"Bash(pnpm lint)",
"Bash(pnpm lint:fix)",
"Bash(pnpm type-check)",
"Bash(pnpm codegen)",
"Bash(pnpm storybook)",
"Bash(pnpm --filter * dev)",
"Bash(pnpm --filter * build)",
"Bash(pnpm --filter * test)",
"Bash(pnpm --filter * lint)",
"Bash(pnpm --filter * codegen)",
"# Git Commands (read-only)",
"Bash(git status)",
"Bash(git diff)",
"Bash(git log)",
"Bash(git branch)",
"Bash(git remote -v)",
"# Search Commands",
"Bash(rg *)",
"# File System (read-only)",
"Bash(ls)",
"Bash(ls -la)",
"Bash(pwd)",
"Bash(find . -name)",
"Bash(find . -type)",
"# Node/NPM Commands",
"Bash(node --version)",
"Bash(pnpm --version)",
"Bash(npx --version)",
"# Environment Commands",
"Bash(echo $*)",
"Bash(which *)",
"# Process Commands",
"Bash(ps aux | grep)",
"Bash(lsof -i)",
"# Documentation Domains",
"WebFetch(domain:tailwindcss.com)",
"WebFetch(domain:github.com)",
"WebFetch(domain:reka-ui.com)",
"WebFetch(domain:nodejs.org)",
"WebFetch(domain:pnpm.io)",
"WebFetch(domain:vitejs.dev)",
"WebFetch(domain:nuxt.com)",
"WebFetch(domain:nestjs.com)",
"# IDE Integration",
"mcp__ide__getDiagnostics",
"# Browser MCP (for testing)",
"mcp__browsermcp__browser_navigate",
"mcp__browsermcp__browser_click",
"mcp__browsermcp__browser_screenshot"
],
"deny": [
"# Dangerous Commands",
"Bash(rm -rf)",
"Bash(chmod 777)",
"Bash(curl)",
"Bash(wget)",
"Bash(ssh)",
"Bash(scp)",
"Bash(sudo)",
"Bash(su)",
"Bash(pkill)",
"Bash(kill)",
"Bash(killall)",
"Bash(python)",
"Bash(python3)",
"Bash(pip)",
"Bash(npm)",
"Bash(yarn)",
"Bash(apt)",
"Bash(brew)",
"Bash(systemctl)",
"Bash(service)",
"Bash(docker)",
"Bash(docker-compose)",
"# File Modification (use Edit/Write tools instead)",
"Bash(sed)",
"Bash(awk)",
"Bash(perl)",
"Bash(echo > *)",
"Bash(echo >> *)",
"Bash(cat > *)",
"Bash(cat >> *)",
"Bash(tee)",
"# Git Write Commands (require explicit user action)",
"Bash(git add)",
"Bash(git commit)",
"Bash(git push)",
"Bash(git pull)",
"Bash(git merge)",
"Bash(git rebase)",
"Bash(git checkout)",
"Bash(git reset)",
"Bash(git clean)",
"# Package Management Write Commands",
"Bash(pnpm add)",
"Bash(pnpm remove)",
"Bash(pnpm update)",
"Bash(pnpm upgrade)"
]
},
"enableAllProjectMcpServers": false
"permissions": {}
}

View File

@@ -51,21 +51,16 @@ jobs:
with:
fetch-depth: 0
- name: Install Node
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Install Node
uses: actions/setup-node@v5
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: Get API Version
id: vars
@@ -76,14 +71,6 @@ jobs:
API_VERSION=$([[ -n "$IS_TAGGED" ]] && echo "$PACKAGE_LOCK_VERSION" || echo "${PACKAGE_LOCK_VERSION}+${GIT_SHA}")
echo "API_VERSION=${API_VERSION}" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: |
cd ${{ github.workspace }}
@@ -183,3 +170,40 @@ jobs:
```
${{ inputs.BASE_URL }}/tag/${{ inputs.TAG }}/dynamix.unraid.net.plg
```
- name: Clean up old preview builds
if: inputs.RELEASE_CREATED == 'false' && github.event_name == 'push'
continue-on-error: true
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
run: |
echo "🧹 Cleaning up old preview builds (keeping last 7 days)..."
# Calculate cutoff date (7 days ago)
CUTOFF_DATE=$(date -d "7 days ago" +"%Y.%m.%d")
echo "Deleting builds older than: ${CUTOFF_DATE}"
# List and delete old timestamped .txz files
OLD_FILES=$(aws s3 ls "s3://${{ secrets.CF_BUCKET_PREVIEW }}/unraid-api/" \
--endpoint-url ${{ secrets.CF_ENDPOINT }} --recursive | \
grep -E "dynamix\.unraid\.net-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]{4}\.txz" | \
awk '{print $4}' || true)
DELETED_COUNT=0
if [ -n "$OLD_FILES" ]; then
while IFS= read -r file; do
if [[ $file =~ ([0-9]{4}\.[0-9]{2}\.[0-9]{2})\.[0-9]{4}\.txz ]]; then
FILE_DATE="${BASH_REMATCH[1]}"
if [[ "$FILE_DATE" < "$CUTOFF_DATE" ]]; then
echo "Deleting old build: $(basename "$file")"
aws s3 rm "s3://${{ secrets.CF_BUCKET_PREVIEW }}/${file}" \
--endpoint-url ${{ secrets.CF_ENDPOINT }} || true
((DELETED_COUNT++))
fi
fi
done <<< "$OLD_FILES"
fi
echo "✅ Deleted ${DELETED_COUNT} old builds"

View File

@@ -22,16 +22,17 @@ jobs:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22.18.0'
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
with:

View File

@@ -6,29 +6,15 @@ on:
branches:
- main
permissions:
contents: write
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
release-please:
name: Release Please
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v5
# Only run release-please on pushes to main
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
- id: release
uses: googleapis/release-please-action@v4
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
outputs:
releases_created: ${{ steps.release.outputs.releases_created || 'false' }}
tag_name: ${{ steps.release.outputs.tag_name || '' }}
test-api:
name: Test API
defaults:
@@ -38,36 +24,25 @@ jobs:
steps:
- name: Checkout repo
uses: actions/checkout@v5
- name: Install Node
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
with:
packages: bash procps python3 libvirt-dev jq zstd git build-essential libvirt-daemon-system php-cli
version: 1.0
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
name: Setup pnpm cache
- name: Install Node
uses: actions/setup-node@v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
with:
packages: bash procps python3 libvirt-dev jq zstd git build-essential libvirt-daemon-system php-cli
version: 1.0
- name: PNPM Install
run: pnpm install --frozen-lockfile
@@ -191,29 +166,16 @@ jobs:
- name: Checkout repo
uses: actions/checkout@v5
- name: Install Node
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
name: Setup pnpm cache
- name: Install Node
uses: actions/setup-node@v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
@@ -244,7 +206,7 @@ jobs:
id: buildnumber
uses: onyxmueller/build-tag-number@v1
with:
token: ${{secrets.github_token}}
token: ${{secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN}}
prefix: ${{steps.vars.outputs.PACKAGE_LOCK_VERSION}}
- name: Build
@@ -268,29 +230,16 @@ jobs:
- name: Checkout repo
uses: actions/checkout@v5
- name: Install Node
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
name: Setup pnpm cache
- name: Install Node
uses: actions/setup-node@v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
@@ -334,29 +283,16 @@ jobs:
echo VITE_UNRAID_NET=${{ secrets.VITE_UNRAID_NET }} >> .env
echo VITE_CALLBACK_KEY=${{ secrets.VITE_CALLBACK_KEY }} >> .env
- name: Install Node
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
name: Setup pnpm cache
- name: Install Node
uses: actions/setup-node@v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: PNPM Install
run: |
@@ -386,10 +322,32 @@ jobs:
name: unraid-wc-rich
path: web/dist
release-please:
name: Release Please
runs-on: ubuntu-latest
# Only run on pushes to main AND after tests pass
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- test-api
- build-api
- build-web
- build-unraid-ui-webcomponents
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v5
- id: release
uses: googleapis/release-please-action@v4
outputs:
releases_created: ${{ steps.release.outputs.releases_created || 'false' }}
tag_name: ${{ steps.release.outputs.tag_name || '' }}
build-plugin-staging-pr:
name: Build and Deploy Plugin
needs:
- release-please
- build-api
- build-web
- build-unraid-ui-webcomponents
@@ -413,9 +371,6 @@ jobs:
needs:
- release-please
- build-api
- build-web
- build-unraid-ui-webcomponents
- test-api
uses: ./.github/workflows/build-plugin.yml
with:
RELEASE_CREATED: true

View File

@@ -1,4 +1,9 @@
name: Push Staging Plugin on PR Close
name: Replace PR Plugin with Staging Redirect on Merge
# This workflow runs when a PR is merged and replaces the PR-specific plugin
# with a redirect version that points to the main staging URL.
# This ensures users who installed the PR version will automatically
# update to the staging version on their next update check.
on:
pull_request:
@@ -17,18 +22,13 @@ on:
default: true
jobs:
push-staging:
push-staging-redirect:
if: (github.event_name == 'pull_request' && github.event.pull_request.merged == true) || (github.event_name == 'workflow_dispatch' && inputs.pr_merged == true)
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
steps:
- name: Set Timezone
uses: szenius/set-timezone@v2.0
with:
timezoneLinux: "America/Los_Angeles"
- name: Set PR number
id: pr_number
run: |
@@ -45,11 +45,12 @@ jobs:
name: unraid-plugin-.*
path: connect-files
pr: ${{ steps.pr_number.outputs.pr_number }}
workflow: main.yml
workflow_conclusion: success
workflow_search: true
search_artifacts: true
if_no_artifact_found: fail
- name: Update Downloaded Staging Plugin to New Date
- name: Update Downloaded Plugin to Redirect to Staging
run: |
# Find the .plg file in the downloaded artifact
plgfile=$(find connect-files -name "*.plg" -type f | head -1)
@@ -60,23 +61,82 @@ jobs:
fi
echo "Found plugin file: $plgfile"
version=$(date +"%Y.%m.%d.%H%M")
sed -i -E "s#(<!ENTITY version \").*(\">)#\1${version}\2#g" "${plgfile}" || exit 1
# Get current version and bump it with current timestamp
current_version=$(grep '<!ENTITY version' "${plgfile}" | sed -E 's/.*"(.*)".*/\1/')
echo "Current version: ${current_version}"
# Create new version with current timestamp (ensures it's newer)
new_version=$(date +"%Y.%m.%d.%H%M")
echo "New redirect version: ${new_version}"
# Update version to trigger update
sed -i -E "s#(<!ENTITY version \").*(\">)#\1${new_version}\2#g" "${plgfile}" || exit 1
# Change the plugin url to point to staging
# Change the plugin url to point to staging - users will switch to staging on next update
url="https://preview.dl.unraid.net/unraid-api/dynamix.unraid.net.plg"
sed -i -E "s#(<!ENTITY plugin_url \").*?(\">)#\1${url}\2#g" "${plgfile}" || exit 1
cat "${plgfile}"
echo "Modified plugin to redirect to: ${url}"
echo "Version bumped from ${current_version} to ${new_version}"
mkdir -p pr-release
mv "${plgfile}" pr-release/dynamix.unraid.net.plg
- name: Upload to Cloudflare
uses: jakejarvis/s3-sync-action@v0.5.1
- name: Clean up old PR artifacts from Cloudflare
env:
AWS_S3_ENDPOINT: ${{ secrets.CF_ENDPOINT }}
AWS_S3_BUCKET: ${{ secrets.CF_BUCKET_PREVIEW }}
AWS_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}
AWS_REGION: "auto"
SOURCE_DIR: pr-release
DEST_DIR: unraid-api/tag/PR${{ steps.pr_number.outputs.pr_number }}
AWS_DEFAULT_REGION: auto
run: |
# Delete all existing files in the PR directory first (txz, plg, etc.)
aws s3 rm s3://${{ secrets.CF_BUCKET_PREVIEW }}/unraid-api/tag/PR${{ steps.pr_number.outputs.pr_number }}/ \
--recursive \
--endpoint-url ${{ secrets.CF_ENDPOINT }}
echo "✅ Cleaned up old PR artifacts"
- name: Upload PR Redirect Plugin to Cloudflare
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
run: |
# Upload only the redirect plugin file
aws s3 cp pr-release/dynamix.unraid.net.plg \
s3://${{ secrets.CF_BUCKET_PREVIEW }}/unraid-api/tag/PR${{ steps.pr_number.outputs.pr_number }}/dynamix.unraid.net.plg \
--endpoint-url ${{ secrets.CF_ENDPOINT }} \
--content-encoding none \
--acl public-read
echo "✅ Uploaded redirect plugin"
- name: Output redirect information
run: |
echo "✅ PR plugin replaced with staging redirect version"
echo "PR URL remains: https://preview.dl.unraid.net/unraid-api/tag/PR${{ steps.pr_number.outputs.pr_number }}/dynamix.unraid.net.plg"
echo "Redirects users to staging: https://preview.dl.unraid.net/unraid-api/dynamix.unraid.net.plg"
echo "Users updating from this PR version will automatically switch to staging"
- name: Comment on PR about staging redirect
if: github.event_name == 'pull_request'
uses: thollander/actions-comment-pull-request@v3
with:
comment-tag: pr-closed-staging
mode: recreate
message: |
## 🔄 PR Merged - Plugin Redirected to Staging
This PR has been merged and the preview plugin has been updated to redirect to the staging version.
**For users testing this PR:**
- Your plugin will automatically update to the staging version on the next update check
- The staging version includes all merged changes from this PR
- No manual intervention required
**Staging URL:**
```
https://preview.dl.unraid.net/unraid-api/dynamix.unraid.net.plg
```
Thank you for testing! 🚀

View File

@@ -28,9 +28,14 @@ jobs:
with:
latest: true
prerelease: false
- uses: actions/setup-node@v4
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
node-version: '22.18.0'
run_install: false
- uses: actions/setup-node@v5
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
- run: |
cat << 'EOF' > release-notes.txt
${{ steps.release-info.outputs.body }}

View File

@@ -1,71 +0,0 @@
name: Test Libvirt
on:
push:
branches:
- main
paths:
- "libvirt/**"
pull_request:
paths:
- "libvirt/**"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./libvirt
steps:
- uses: actions/checkout@v5
with:
submodules: recursive
- uses: actions/setup-python@v5
with:
python-version: "3.13.7"
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
with:
packages: libvirt-dev
version: 1.0
- name: Set Node.js
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10.15.0
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('libvirt/package.json') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: pnpm install
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
- name: test
run: pnpm run test

View File

@@ -1 +1 @@
{".":"4.20.1"}
{".":"4.22.1"}

View File

@@ -3,4 +3,3 @@
@import './unraid-theme.css';
@import './theme-variants.css';
@import './base-utilities.css';
@import './sonner.css';

View File

@@ -1,708 +0,0 @@
/**------------------------------------------------------------------------------------------------
* SONNER.CSS
* This is a copy of Sonner's `style.css` as of commit a5b77c2df08d5c05aa923170176168102855533d
*
* This was necessary because I couldn't find a simple way to include Sonner's styles in vite's
* css build output. They wouldn't show up even though the toaster was included, and vue-sonner
* currently doesn't export its stylesheet (it appears to be inlined, but styles weren't applied
* to the unraid-toaster component for some reason).
*------------------------------------------------------------------------------------------------**/
:where(html[dir='ltr']),
:where([data-sonner-toaster][dir='ltr']) {
--toast-icon-margin-start: -3px;
--toast-icon-margin-end: 4px;
--toast-svg-margin-start: -1px;
--toast-svg-margin-end: 0px;
--toast-button-margin-start: auto;
--toast-button-margin-end: 0;
--toast-close-button-start: 0;
--toast-close-button-end: unset;
--toast-close-button-transform: translate(-35%, -35%);
}
:where(html[dir='rtl']),
:where([data-sonner-toaster][dir='rtl']) {
--toast-icon-margin-start: 4px;
--toast-icon-margin-end: -3px;
--toast-svg-margin-start: 0px;
--toast-svg-margin-end: -1px;
--toast-button-margin-start: 0;
--toast-button-margin-end: auto;
--toast-close-button-start: unset;
--toast-close-button-end: 0;
--toast-close-button-transform: translate(35%, -35%);
}
:where([data-sonner-toaster]) {
position: fixed;
width: var(--width);
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial,
Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
--gray1: hsl(0, 0%, 99%);
--gray2: hsl(0, 0%, 97.3%);
--gray3: hsl(0, 0%, 95.1%);
--gray4: hsl(0, 0%, 93%);
--gray5: hsl(0, 0%, 90.9%);
--gray6: hsl(0, 0%, 88.7%);
--gray7: hsl(0, 0%, 85.8%);
--gray8: hsl(0, 0%, 78%);
--gray9: hsl(0, 0%, 56.1%);
--gray10: hsl(0, 0%, 52.3%);
--gray11: hsl(0, 0%, 43.5%);
--gray12: hsl(0, 0%, 9%);
--border-radius: 8px;
box-sizing: border-box;
padding: 0;
margin: 0;
list-style: none;
outline: none;
z-index: 999999999;
transition: transform 400ms ease;
}
:where([data-sonner-toaster][data-lifted='true']) {
transform: translateY(-10px);
}
@media (hover: none) and (pointer: coarse) {
:where([data-sonner-toaster][data-lifted='true']) {
transform: none;
}
}
:where([data-sonner-toaster][data-x-position='right']) {
right: max(var(--offset), env(safe-area-inset-right));
}
:where([data-sonner-toaster][data-x-position='left']) {
left: max(var(--offset), env(safe-area-inset-left));
}
:where([data-sonner-toaster][data-x-position='center']) {
left: 50%;
transform: translateX(-50%);
}
:where([data-sonner-toaster][data-y-position='top']) {
top: max(var(--offset), env(safe-area-inset-top));
}
:where([data-sonner-toaster][data-y-position='bottom']) {
bottom: max(var(--offset), env(safe-area-inset-bottom));
}
:where([data-sonner-toast]) {
--y: translateY(100%);
--lift-amount: calc(var(--lift) * var(--gap));
z-index: var(--z-index);
position: absolute;
opacity: 0;
transform: var(--y);
filter: blur(0);
/* https://stackoverflow.com/questions/48124372/pointermove-event-not-working-with-touch-why-not */
touch-action: none;
transition: transform 400ms, opacity 400ms, height 400ms, box-shadow 200ms;
box-sizing: border-box;
outline: none;
overflow-wrap: anywhere;
}
:where([data-sonner-toast][data-styled='true']) {
padding: 16px;
background: var(--normal-bg);
border: 1px solid var(--normal-border);
color: var(--normal-text);
border-radius: var(--border-radius);
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);
width: var(--width);
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
}
:where([data-sonner-toast]:focus-visible) {
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1), 0 0 0 2px rgba(0, 0, 0, 0.2);
}
:where([data-sonner-toast][data-y-position='top']) {
top: 0;
--y: translateY(-100%);
--lift: 1;
--lift-amount: calc(1 * var(--gap));
}
:where([data-sonner-toast][data-y-position='bottom']) {
bottom: 0;
--y: translateY(100%);
--lift: -1;
--lift-amount: calc(var(--lift) * var(--gap));
}
:where([data-sonner-toast]) :where([data-description]) {
font-weight: 400;
line-height: 1.4;
color: inherit;
}
:where([data-sonner-toast]) :where([data-title]) {
font-weight: 500;
line-height: 1.5;
color: inherit;
}
:where([data-sonner-toast]) :where([data-icon]) {
display: flex;
height: 16px;
width: 16px;
position: relative;
justify-content: flex-start;
align-items: center;
flex-shrink: 0;
margin-left: var(--toast-icon-margin-start);
margin-right: var(--toast-icon-margin-end);
}
:where([data-sonner-toast][data-promise='true']) :where([data-icon]) > svg {
opacity: 0;
transform: scale(0.8);
transform-origin: center;
animation: sonner-fade-in 300ms ease forwards;
}
:where([data-sonner-toast]) :where([data-icon]) > * {
flex-shrink: 0;
}
:where([data-sonner-toast]) :where([data-icon]) svg {
margin-left: var(--toast-svg-margin-start);
margin-right: var(--toast-svg-margin-end);
}
:where([data-sonner-toast]) :where([data-content]) {
display: flex;
flex-direction: column;
gap: 2px;
}
[data-sonner-toast][data-styled='true'] [data-button] {
border-radius: 4px;
padding-left: 8px;
padding-right: 8px;
height: 24px;
font-size: 12px;
color: var(--normal-bg);
background: var(--normal-text);
margin-left: var(--toast-button-margin-start);
margin-right: var(--toast-button-margin-end);
border: none;
cursor: pointer;
outline: none;
display: flex;
align-items: center;
flex-shrink: 0;
transition: opacity 400ms, box-shadow 200ms;
}
:where([data-sonner-toast]) :where([data-button]):focus-visible {
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.4);
}
:where([data-sonner-toast]) :where([data-button]):first-of-type {
margin-left: var(--toast-button-margin-start);
margin-right: var(--toast-button-margin-end);
}
:where([data-sonner-toast]) :where([data-cancel]) {
color: var(--normal-text);
background: rgba(0, 0, 0, 0.08);
}
:where([data-sonner-toast][data-theme='dark']) :where([data-cancel]) {
background: rgba(255, 255, 255, 0.3);
}
[data-sonner-toast] [data-close-button] {
position: absolute;
left: var(--toast-close-button-start);
right: var(--toast-close-button-end);
top: 0;
height: 20px;
width: 20px;
min-width: inherit !important;
margin: 0 !important;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
color: hsl(var(--foreground));
border: 1px solid hsl(var(--border));
transform: var(--toast-close-button-transform);
border-radius: 50%;
cursor: pointer;
z-index: 1;
transition: opacity 100ms, background 200ms, border-color 200ms;
}
[data-sonner-toast] [data-close-button] {
background: hsl(var(--background));
}
:where([data-sonner-toast]) :where([data-close-button]):focus-visible {
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1), 0 0 0 2px rgba(0, 0, 0, 0.2);
}
:where([data-sonner-toast]) :where([data-disabled='true']) {
cursor: not-allowed;
}
[data-sonner-toast]:hover [data-close-button]:hover {
background: hsl(var(--muted));
border-color: hsl(var(--border));
}
/* Leave a ghost div to avoid setting hover to false when swiping out */
:where([data-sonner-toast][data-swiping='true'])::before {
content: '';
position: absolute;
left: 0;
right: 0;
height: 100%;
z-index: -1;
}
:where([data-sonner-toast][data-y-position='top'][data-swiping='true'])::before {
/* y 50% needed to distribute height additional height evenly */
bottom: 50%;
transform: scaleY(3) translateY(50%);
}
:where([data-sonner-toast][data-y-position='bottom'][data-swiping='true'])::before {
/* y -50% needed to distribute height additional height evenly */
top: 50%;
transform: scaleY(3) translateY(-50%);
}
/* Leave a ghost div to avoid setting hover to false when transitioning out */
:where([data-sonner-toast][data-swiping='false'][data-removed='true'])::before {
content: '';
position: absolute;
inset: 0;
transform: scaleY(2);
}
/* Needed to avoid setting hover to false when inbetween toasts */
:where([data-sonner-toast])::after {
content: '';
position: absolute;
left: 0;
height: calc(var(--gap) + 1px);
bottom: 100%;
width: 100%;
}
:where([data-sonner-toast][data-mounted='true']) {
--y: translateY(0);
opacity: 1;
}
:where([data-sonner-toast][data-expanded='false'][data-front='false']) {
--scale: var(--toasts-before) * 0.05 + 1;
--y: translateY(calc(var(--lift-amount) * var(--toasts-before))) scale(calc(-1 * var(--scale)));
height: var(--front-toast-height);
}
:where([data-sonner-toast]) > * {
transition: opacity 400ms;
}
:where([data-sonner-toast][data-expanded='false'][data-front='false'][data-styled='true']) > * {
opacity: 0;
}
:where([data-sonner-toast][data-visible='false']) {
opacity: 0;
pointer-events: none;
}
:where([data-sonner-toast][data-mounted='true'][data-expanded='true']) {
--y: translateY(calc(var(--lift) * var(--offset)));
height: var(--initial-height);
}
:where([data-sonner-toast][data-removed='true'][data-front='true'][data-swipe-out='false']) {
--y: translateY(calc(var(--lift) * -100%));
opacity: 0;
}
:where([data-sonner-toast][data-removed='true'][data-front='false'][data-swipe-out='false'][data-expanded='true']) {
--y: translateY(calc(var(--lift) * var(--offset) + var(--lift) * -100%));
opacity: 0;
}
:where([data-sonner-toast][data-removed='true'][data-front='false'][data-swipe-out='false'][data-expanded='false']) {
--y: translateY(40%);
opacity: 0;
transition: transform 500ms, opacity 200ms;
}
/* Bump up the height to make sure hover state doesn't get set to false */
:where([data-sonner-toast][data-removed='true'][data-front='false'])::before {
height: calc(var(--initial-height) + 20%);
}
[data-sonner-toast][data-swiping='true'] {
transform: var(--y) translateY(var(--swipe-amount, 0px));
transition: none;
}
[data-sonner-toast][data-swiped='true'] {
user-select: none;
}
[data-sonner-toast][data-swipe-out='true'][data-y-position='bottom'],
[data-sonner-toast][data-swipe-out='true'][data-y-position='top'] {
animation: swipe-out 200ms ease-out forwards;
}
@keyframes swipe-out {
from {
transform: translateY(calc(var(--lift) * var(--offset) + var(--swipe-amount)));
opacity: 1;
}
to {
transform: translateY(calc(var(--lift) * var(--offset) + var(--swipe-amount) + var(--lift) * -100%));
opacity: 0;
}
}
@media (max-width: 600px) {
[data-sonner-toaster] {
position: fixed;
--mobile-offset: 16px;
right: var(--mobile-offset);
left: var(--mobile-offset);
width: 100%;
}
[data-sonner-toaster][dir='rtl'] {
left: calc(var(--mobile-offset) * -1);
}
[data-sonner-toaster] [data-sonner-toast] {
left: 0;
right: 0;
width: calc(100% - var(--mobile-offset) * 2);
}
[data-sonner-toaster][data-x-position='left'] {
left: var(--mobile-offset);
}
[data-sonner-toaster][data-y-position='bottom'] {
bottom: 20px;
}
[data-sonner-toaster][data-y-position='top'] {
top: 20px;
}
[data-sonner-toaster][data-x-position='center'] {
left: var(--mobile-offset);
right: var(--mobile-offset);
transform: none;
}
}
[data-sonner-toaster][data-theme='light'] {
--normal-bg: hsl(var(--background));
--normal-border: hsl(var(--border));
--normal-text: hsl(var(--foreground));
--success-bg: hsl(var(--background));
--success-border: hsl(var(--border));
--success-text: hsl(140, 100%, 27%);
--info-bg: hsl(var(--background));
--info-border: hsl(var(--border));
--info-text: hsl(210, 92%, 45%);
--warning-bg: hsl(var(--background));
--warning-border: hsl(var(--border));
--warning-text: hsl(31, 92%, 45%);
--error-bg: hsl(var(--background));
--error-border: hsl(var(--border));
--error-text: hsl(360, 100%, 45%);
/* Old colors, preserved for reference
--success-bg: hsl(143, 85%, 96%);
--success-border: hsl(145, 92%, 91%);
--success-text: hsl(140, 100%, 27%);
--info-bg: hsl(208, 100%, 97%);
--info-border: hsl(221, 91%, 91%);
--info-text: hsl(210, 92%, 45%);
--warning-bg: hsl(49, 100%, 97%);
--warning-border: hsl(49, 91%, 91%);
--warning-text: hsl(31, 92%, 45%);
--error-bg: hsl(359, 100%, 97%);
--error-border: hsl(359, 100%, 94%);
--error-text: hsl(360, 100%, 45%); */
}
[data-sonner-toaster][data-theme='light'] [data-sonner-toast][data-invert='true'] {
--normal-bg: hsl(0 0% 3.9%);
--normal-border: hsl(0 0% 14.9%);
--normal-text: hsl(0 0% 98%);
}
[data-sonner-toaster][data-theme='dark'] [data-sonner-toast][data-invert='true'] {
--normal-bg: hsl(0 0% 100%);
--normal-border: hsl(0 0% 89.8%);
--normal-text: hsl(0 0% 3.9%);
}
[data-sonner-toaster][data-theme='dark'] {
--normal-bg: hsl(var(--background));
--normal-border: hsl(var(--border));
--normal-text: hsl(var(--foreground));
--success-bg: hsl(var(--background));
--success-border: hsl(var(--border));
--success-text: hsl(150, 86%, 65%);
--info-bg: hsl(var(--background));
--info-border: hsl(var(--border));
--info-text: hsl(216, 87%, 65%);
--warning-bg: hsl(var(--background));
--warning-border: hsl(var(--border));
--warning-text: hsl(46, 87%, 65%);
--error-bg: hsl(var(--background));
--error-border: hsl(var(--border));
--error-text: hsl(358, 100%, 81%);
/* Old colors, preserved for reference
--success-bg: hsl(150, 100%, 6%);
--success-border: hsl(147, 100%, 12%);
--success-text: hsl(150, 86%, 65%);
--info-bg: hsl(215, 100%, 6%);
--info-border: hsl(223, 100%, 12%);
--info-text: hsl(216, 87%, 65%);
--warning-bg: hsl(64, 100%, 6%);
--warning-border: hsl(60, 100%, 12%);
--warning-text: hsl(46, 87%, 65%);
--error-bg: hsl(358, 76%, 10%);
--error-border: hsl(357, 89%, 16%);
--error-text: hsl(358, 100%, 81%); */
}
[data-rich-colors='true'][data-sonner-toast][data-type='success'] {
background: var(--success-bg);
border-color: var(--success-border);
color: var(--success-text);
}
[data-rich-colors='true'][data-sonner-toast][data-type='success'] [data-close-button] {
background: var(--success-bg);
border-color: var(--success-border);
color: var(--success-text);
}
[data-rich-colors='true'][data-sonner-toast][data-type='info'] {
background: var(--info-bg);
border-color: var(--info-border);
color: var(--info-text);
}
[data-rich-colors='true'][data-sonner-toast][data-type='info'] [data-close-button] {
background: var(--info-bg);
border-color: var(--info-border);
color: var(--info-text);
}
[data-rich-colors='true'][data-sonner-toast][data-type='warning'] {
background: var(--warning-bg);
border-color: var(--warning-border);
color: var(--warning-text);
}
[data-rich-colors='true'][data-sonner-toast][data-type='warning'] [data-close-button] {
background: var(--warning-bg);
border-color: var(--warning-border);
color: var(--warning-text);
}
[data-rich-colors='true'][data-sonner-toast][data-type='error'] {
background: var(--error-bg);
border-color: var(--error-border);
color: var(--error-text);
}
[data-rich-colors='true'][data-sonner-toast][data-type='error'] [data-close-button] {
background: var(--error-bg);
border-color: var(--error-border);
color: var(--error-text);
}
.sonner-loading-wrapper {
--size: 16px;
height: var(--size);
width: var(--size);
position: absolute;
inset: 0;
z-index: 10;
}
.sonner-loading-wrapper[data-visible='false'] {
transform-origin: center;
animation: sonner-fade-out 0.2s ease forwards;
}
.sonner-spinner {
position: relative;
top: 50%;
left: 50%;
height: var(--size);
width: var(--size);
}
.sonner-loading-bar {
animation: sonner-spin 1.2s linear infinite;
background: hsl(var(--muted-foreground));
border-radius: 6px;
height: 8%;
left: -10%;
position: absolute;
top: -3.9%;
width: 24%;
}
.sonner-loading-bar:nth-child(1) {
animation-delay: -1.2s;
transform: rotate(0.0001deg) translate(146%);
}
.sonner-loading-bar:nth-child(2) {
animation-delay: -1.1s;
transform: rotate(30deg) translate(146%);
}
.sonner-loading-bar:nth-child(3) {
animation-delay: -1s;
transform: rotate(60deg) translate(146%);
}
.sonner-loading-bar:nth-child(4) {
animation-delay: -0.9s;
transform: rotate(90deg) translate(146%);
}
.sonner-loading-bar:nth-child(5) {
animation-delay: -0.8s;
transform: rotate(120deg) translate(146%);
}
.sonner-loading-bar:nth-child(6) {
animation-delay: -0.7s;
transform: rotate(150deg) translate(146%);
}
.sonner-loading-bar:nth-child(7) {
animation-delay: -0.6s;
transform: rotate(180deg) translate(146%);
}
.sonner-loading-bar:nth-child(8) {
animation-delay: -0.5s;
transform: rotate(210deg) translate(146%);
}
.sonner-loading-bar:nth-child(9) {
animation-delay: -0.4s;
transform: rotate(240deg) translate(146%);
}
.sonner-loading-bar:nth-child(10) {
animation-delay: -0.3s;
transform: rotate(270deg) translate(146%);
}
.sonner-loading-bar:nth-child(11) {
animation-delay: -0.2s;
transform: rotate(300deg) translate(146%);
}
.sonner-loading-bar:nth-child(12) {
animation-delay: -0.1s;
transform: rotate(330deg) translate(146%);
}
@keyframes sonner-fade-in {
0% {
opacity: 0;
transform: scale(0.8);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes sonner-fade-out {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.8);
}
}
@keyframes sonner-spin {
0% {
opacity: 1;
}
100% {
opacity: 0.15;
}
}
@media (prefers-reduced-motion) {
[data-sonner-toast],
[data-sonner-toast] > *,
.sonner-loading-bar {
transition: none !important;
animation: none !important;
}
}
.sonner-loader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
transform-origin: center;
transition: opacity 200ms, transform 200ms;
}
.sonner-loader[data-visible='false'] {
opacity: 0;
transform: scale(0.8) translate(-50%, -50%);
}
/* Override Unraid webgui docker icon styles on sonner containers */
[data-sonner-toast] [data-icon]:before,
[data-sonner-toast] .fa-docker:before {
font-family: inherit !important;
content: '' !important;
}

View File

@@ -75,18 +75,19 @@
/*
* 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
*/
.has-custom-header-text {
:root.has-custom-header-text {
--header-text-primary: var(--custom-header-text-primary);
--color-header-text-primary: var(--custom-header-text-primary);
}
.has-custom-header-meta {
:root.has-custom-header-meta {
--header-text-secondary: var(--custom-header-text-secondary);
--color-header-text-secondary: var(--custom-header-text-secondary);
}
.has-custom-header-bg {
:root.has-custom-header-bg {
--header-background-color: var(--custom-header-background-color);
--color-header-background: var(--custom-header-background-color);
--header-gradient-start: var(--custom-header-gradient-start);

View File

@@ -7,7 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
This is the Unraid API monorepo containing multiple packages that provide API functionality for Unraid servers. It uses pnpm workspaces with the following structure:
- `/api` - Core NestJS API server with GraphQL
- `/web` - Nuxt.js frontend application
- `/web` - Vue 3 frontend application
- `/unraid-ui` - Vue 3 component library
- `/plugin` - Unraid plugin package (.plg)
- `/packages` - Shared packages and API plugins
@@ -128,9 +128,6 @@ Enables GraphQL playground at `http://tower.local/graphql`
- **Use Mocks Correctly**: Mocks should be used as nouns, not verbs.
#### Vue Component Testing
- This is a Nuxt.js app but we are testing with vitest outside of the Nuxt environment
- Nuxt is currently set to auto import so some vue files may need compute or ref imported
- Use pnpm when running terminal commands and stay within the web directory
- Tests are located under `web/__test__`, run with `pnpm test`
- Use `mount` from Vue Test Utils for component testing

View File

@@ -31,3 +31,4 @@ BYPASS_CORS_CHECKS=true
CHOKIDAR_USEPOLLING=true
LOG_TRANSPORT=console
LOG_LEVEL=trace
ENABLE_NEXT_DOCKER_RELEASE=true

3
api/.gitignore vendored
View File

@@ -93,3 +93,6 @@ dev/local-session
# local OIDC config for testing - contains secrets
dev/configs/oidc.local.json
# local api keys
dev/keys/*

View File

@@ -1,5 +1,64 @@
# Changelog
## [4.22.1](https://github.com/unraid/api/compare/v4.22.0...v4.22.1) (2025-09-12)
### Bug Fixes
* set input color in SSO field rather than inside of the main.css ([01d353f](https://github.com/unraid/api/commit/01d353fa08a3df688b37a495a204605138f7f71d))
## [4.22.0](https://github.com/unraid/api/compare/v4.21.0...v4.22.0) (2025-09-12)
### Features
* improved update ui ([#1691](https://github.com/unraid/api/issues/1691)) ([a59b363](https://github.com/unraid/api/commit/a59b363ebc1e660f854c55d50fc02c823c2fd0cc))
### Bug Fixes
* **deps:** update dependency camelcase-keys to v10 ([#1687](https://github.com/unraid/api/issues/1687)) ([95faeaa](https://github.com/unraid/api/commit/95faeaa2f39bf7bd16502698d7530aaa590b286d))
* **deps:** update dependency p-retry to v7 ([#1608](https://github.com/unraid/api/issues/1608)) ([c782cf0](https://github.com/unraid/api/commit/c782cf0e8710c6690050376feefda3edb30dd549))
* **deps:** update dependency uuid to v13 ([#1688](https://github.com/unraid/api/issues/1688)) ([2fef10c](https://github.com/unraid/api/commit/2fef10c94aae910e95d9f5bcacf7289e2cca6ed9))
* **deps:** update dependency vue-sonner to v2 ([#1475](https://github.com/unraid/api/issues/1475)) ([f95ca9c](https://github.com/unraid/api/commit/f95ca9c9cb69725dcf3bb4bcbd0b558a2074e311))
* display settings fix for languages on less than 7.2-beta.2.3 ([#1696](https://github.com/unraid/api/issues/1696)) ([03dae7c](https://github.com/unraid/api/commit/03dae7ce66b3409593eeee90cd5b56e2a920ca44))
* hide reset help option when sso is being checked ([#1695](https://github.com/unraid/api/issues/1695)) ([222ced7](https://github.com/unraid/api/commit/222ced7518d40c207198a3b8548f0e024bc865b0))
* progressFrame white on black ([0990b89](https://github.com/unraid/api/commit/0990b898bd02c231153157c20d5142e5fd4513cd))
## [4.21.0](https://github.com/unraid/api/compare/v4.20.4...v4.21.0) (2025-09-10)
### Features
* add zsh shell detection to install script ([#1539](https://github.com/unraid/api/issues/1539)) ([50ea2a3](https://github.com/unraid/api/commit/50ea2a3ffb82b30152fb85e0fb9b0d178d596efe))
* **api:** determine if docker container has update ([#1582](https://github.com/unraid/api/issues/1582)) ([e57d81e](https://github.com/unraid/api/commit/e57d81e0735772758bb85e0b3c89dce15c56635e))
### Bug Fixes
* white on white login text ([ae4d3ec](https://github.com/unraid/api/commit/ae4d3ecbc417454ae3c6e02018f8e4c49bbfc902))
## [4.20.4](https://github.com/unraid/api/compare/v4.20.3...v4.20.4) (2025-09-09)
### Bug Fixes
* staging PR plugin fixes + UI issues on 7.2 beta ([b79b44e](https://github.com/unraid/api/commit/b79b44e95c65a124313814ab55b0d0a745a799c7))
## [4.20.3](https://github.com/unraid/api/compare/v4.20.2...v4.20.3) (2025-09-09)
### Bug Fixes
* header background color issues fixed on 7.2 - thanks Nick! ([73c1100](https://github.com/unraid/api/commit/73c1100d0ba396fe4342f8ce7561017ab821e68b))
## [4.20.2](https://github.com/unraid/api/compare/v4.20.1...v4.20.2) (2025-09-09)
### Bug Fixes
* trigger deployment ([a27453f](https://github.com/unraid/api/commit/a27453fda81e4eeb07f257e60516bebbbc27cf7a))
## [4.20.1](https://github.com/unraid/api/compare/v4.20.0...v4.20.1) (2025-09-09)

View File

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

View File

@@ -0,0 +1,247 @@
# Feature Flags
Feature flags allow you to conditionally enable or disable functionality in the Unraid API. This is useful for gradually rolling out new features, A/B testing, or keeping experimental code behind flags during development.
## Setting Up Feature Flags
### 1. Define the Feature Flag
Feature flags are defined as environment variables and collected in `src/consts.ts`:
```typescript
// src/environment.ts
export const ENABLE_MY_NEW_FEATURE = process.env.ENABLE_MY_NEW_FEATURE === 'true';
// src/consts.ts
export const FeatureFlags = Object.freeze({
ENABLE_NEXT_DOCKER_RELEASE,
ENABLE_MY_NEW_FEATURE, // Add your new flag here
});
```
### 2. Set the Environment Variable
Set the environment variable when running the API:
```bash
ENABLE_MY_NEW_FEATURE=true unraid-api start
```
Or add it to your `.env` file:
```env
ENABLE_MY_NEW_FEATURE=true
```
## Using Feature Flags in GraphQL
### Method 1: @UseFeatureFlag Decorator (Schema-Level)
The `@UseFeatureFlag` decorator conditionally includes or excludes GraphQL fields, queries, and mutations from the schema based on feature flags. When a feature flag is disabled, the field won't appear in the GraphQL schema at all.
```typescript
import { UseFeatureFlag } from '@app/unraid-api/decorators/use-feature-flag.decorator.js';
import { Query, Mutation, ResolveField } from '@nestjs/graphql';
@Resolver()
export class MyResolver {
// Conditionally include a query
@UseFeatureFlag('ENABLE_MY_NEW_FEATURE')
@Query(() => String)
async experimentalQuery() {
return 'This query only exists when ENABLE_MY_NEW_FEATURE is true';
}
// Conditionally include a mutation
@UseFeatureFlag('ENABLE_MY_NEW_FEATURE')
@Mutation(() => Boolean)
async experimentalMutation() {
return true;
}
// Conditionally include a field resolver
@UseFeatureFlag('ENABLE_MY_NEW_FEATURE')
@ResolveField(() => String)
async experimentalField() {
return 'This field only exists when the flag is enabled';
}
}
```
**Benefits:**
- Clean schema - disabled features don't appear in GraphQL introspection
- No runtime overhead for disabled features
- Clear feature boundaries
**Use when:**
- You want to completely hide features from the GraphQL schema
- The feature is experimental or in beta
- You're doing a gradual rollout
### Method 2: checkFeatureFlag Function (Runtime)
The `checkFeatureFlag` function provides runtime feature flag checking within resolver methods. It throws a `ForbiddenException` if the feature is disabled.
```typescript
import { checkFeatureFlag } from '@app/unraid-api/utils/feature-flag.helper.js';
import { FeatureFlags } from '@app/consts.js';
import { Query, ResolveField } from '@nestjs/graphql';
@Resolver()
export class MyResolver {
@Query(() => String)
async myQuery(
@Args('useNewAlgorithm', { nullable: true }) useNewAlgorithm?: boolean
) {
// Conditionally use new logic based on feature flag
if (useNewAlgorithm) {
checkFeatureFlag(FeatureFlags, 'ENABLE_MY_NEW_FEATURE');
return this.newAlgorithm();
}
return this.oldAlgorithm();
}
@ResolveField(() => String)
async dataField() {
// Check flag at the start of the method
checkFeatureFlag(FeatureFlags, 'ENABLE_MY_NEW_FEATURE');
// Feature-specific logic here
return this.computeExperimentalData();
}
}
```
**Benefits:**
- More granular control within methods
- Can conditionally execute parts of a method
- Useful for A/B testing scenarios
- Good for gradual migration strategies
**Use when:**
- You need conditional logic within a method
- The field should exist but behavior changes based on the flag
- You're migrating from old to new implementation gradually
## Feature Flag Patterns
### Pattern 1: Complete Feature Toggle
Hide an entire feature behind a flag:
```typescript
@UseFeatureFlag('ENABLE_DOCKER_TEMPLATES')
@Resolver(() => DockerTemplate)
export class DockerTemplateResolver {
// All resolvers in this class are toggled by the flag
}
```
### Pattern 2: Gradual Migration
Migrate from old to new implementation:
```typescript
@Query(() => [Container])
async getContainers(@Args('version') version?: string) {
if (version === 'v2') {
checkFeatureFlag(FeatureFlags, 'ENABLE_CONTAINERS_V2');
return this.getContainersV2();
}
return this.getContainersV1();
}
```
### Pattern 3: Beta Features
Mark features as beta:
```typescript
@UseFeatureFlag('ENABLE_BETA_FEATURES')
@ResolveField(() => BetaMetrics, {
description: 'BETA: Advanced metrics (requires ENABLE_BETA_FEATURES flag)'
})
async betaMetrics() {
return this.computeBetaMetrics();
}
```
### Pattern 4: Performance Optimizations
Toggle expensive operations:
```typescript
@ResolveField(() => Statistics)
async statistics() {
const basicStats = await this.getBasicStats();
try {
checkFeatureFlag(FeatureFlags, 'ENABLE_ADVANCED_ANALYTICS');
const advancedStats = await this.getAdvancedStats();
return { ...basicStats, ...advancedStats };
} catch {
// Feature disabled, return only basic stats
return basicStats;
}
}
```
## Testing with Feature Flags
When writing tests for feature-flagged code, create a mock to control feature flag values:
```typescript
import { vi } from 'vitest';
// Mock the entire consts module
vi.mock('@app/consts.js', async () => {
const actual = await vi.importActual('@app/consts.js');
return {
...actual,
FeatureFlags: {
ENABLE_MY_NEW_FEATURE: true, // Set your test value
ENABLE_NEXT_DOCKER_RELEASE: false,
}
};
});
describe('MyResolver', () => {
it('should execute new logic when feature is enabled', async () => {
// Test new behavior with mocked flag
});
});
```
## Best Practices
1. **Naming Convention**: Use `ENABLE_` prefix for boolean feature flags
2. **Environment Variables**: Always use uppercase with underscores
3. **Documentation**: Document what each feature flag controls
4. **Cleanup**: Remove feature flags once features are stable and fully rolled out
5. **Default State**: New features should default to `false` (disabled)
6. **Granularity**: Keep feature flags focused on a single feature or capability
7. **Testing**: Always test both enabled and disabled states
## Common Use Cases
- **Experimental Features**: Hide unstable features in production
- **Gradual Rollouts**: Enable features for specific environments first
- **A/B Testing**: Toggle between different implementations
- **Performance**: Disable expensive operations when not needed
- **Breaking Changes**: Provide migration path with both old and new behavior
- **Debug Features**: Enable additional logging or debugging tools
## Checking Active Feature Flags
To see which feature flags are currently active:
```typescript
// Log all feature flags on startup
console.log('Active Feature Flags:', FeatureFlags);
```
Or check via GraphQL introspection to see which fields are available based on current flags.

View File

@@ -139,6 +139,9 @@ type ArrayDisk implements Node {
"""ata | nvme | usb | (others)"""
transport: String
color: ArrayDiskFsColor
"""Whether the disk is currently spinning"""
isSpinning: Boolean
}
interface Node {
@@ -346,6 +349,9 @@ type Disk implements Node {
"""The partitions on the disk"""
partitions: [DiskPartition!]!
"""Whether the disk is spinning or not"""
isSpinning: Boolean!
}
"""The type of interface the disk uses to connect to the system"""
@@ -1044,6 +1050,19 @@ enum ThemeName {
white
}
type ExplicitStatusItem {
name: String!
updateStatus: UpdateStatus!
}
"""Update status of a container."""
enum UpdateStatus {
UP_TO_DATE
UPDATE_AVAILABLE
REBUILD_READY
UNKNOWN
}
type ContainerPort {
ip: String
privatePort: Port
@@ -1083,6 +1102,8 @@ type DockerContainer implements Node {
networkSettings: JSON
mounts: [JSON!]
autoStart: Boolean!
isUpdateAvailable: Boolean
isRebuildReady: Boolean
}
enum ContainerState {
@@ -1113,6 +1134,7 @@ type Docker implements Node {
containers(skipCache: Boolean! = false): [DockerContainer!]!
networks(skipCache: Boolean! = false): [DockerNetwork!]!
organizer: ResolvedOrganizerV1!
containerUpdateStatuses: [ExplicitStatusItem!]!
}
type ResolvedOrganizerView {
@@ -2413,6 +2435,7 @@ type Mutation {
setDockerFolderChildren(folderId: String, childrenIds: [String!]!): ResolvedOrganizerV1!
deleteDockerEntries(entryIds: [String!]!): ResolvedOrganizerV1!
moveDockerEntriesToFolder(sourceEntryIds: [String!]!, destinationFolderId: String!): ResolvedOrganizerV1!
refreshDockerDigests: Boolean!
"""Initiates a flash drive backup using a configured remote."""
initiateFlashBackup(input: InitiateFlashBackupInput!): FlashBackupStatus!

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/api",
"version": "4.20.1",
"version": "4.22.1",
"main": "src/cli/index.ts",
"type": "module",
"corepack": {
@@ -56,7 +56,7 @@
"@as-integrations/fastify": "2.1.1",
"@fastify/cookie": "11.0.2",
"@fastify/helmet": "13.0.1",
"@graphql-codegen/client-preset": "4.8.3",
"@graphql-codegen/client-preset": "5.0.0",
"@graphql-tools/load-files": "7.0.1",
"@graphql-tools/merge": "9.1.1",
"@graphql-tools/schema": "10.0.25",
@@ -84,7 +84,7 @@
"bytes": "3.1.2",
"cache-manager": "7.2.0",
"cacheable-lookup": "7.0.0",
"camelcase-keys": "9.1.3",
"camelcase-keys": "10.0.0",
"casbin": "5.38.0",
"change-case": "5.4.4",
"chokidar": "4.0.3",
@@ -94,7 +94,7 @@
"command-exists": "1.2.9",
"convert": "5.12.0",
"cookie": "1.0.2",
"cron": "4.3.3",
"cron": "4.3.0",
"cross-fetch": "4.1.0",
"diff": "8.0.2",
"dockerode": "4.0.7",
@@ -103,7 +103,7 @@
"execa": "9.6.0",
"exit-hook": "4.0.0",
"fastify": "5.5.0",
"filenamify": "6.0.0",
"filenamify": "7.0.0",
"fs-extra": "11.3.1",
"glob": "11.0.3",
"global-agent": "3.0.0",
@@ -127,7 +127,7 @@
"node-cache": "5.1.2",
"node-window-polyfill": "1.0.4",
"openid-client": "6.6.4",
"p-retry": "6.2.1",
"p-retry": "7.0.0",
"passport-custom": "1.1.1",
"passport-http-header-strategy": "1.1.0",
"path-type": "6.0.0",
@@ -141,7 +141,7 @@
"strftime": "0.10.3",
"systeminformation": "5.27.8",
"undici": "7.15.0",
"uuid": "11.1.0",
"uuid": "13.0.0",
"ws": "8.18.3",
"zen-observable-ts": "1.1.0",
"zod": "3.25.76"
@@ -156,14 +156,14 @@
},
"devDependencies": {
"@eslint/js": "9.34.0",
"@graphql-codegen/add": "5.0.3",
"@graphql-codegen/cli": "5.0.7",
"@graphql-codegen/fragment-matcher": "5.1.0",
"@graphql-codegen/add": "6.0.0",
"@graphql-codegen/cli": "6.0.0",
"@graphql-codegen/fragment-matcher": "6.0.0",
"@graphql-codegen/import-types-preset": "3.0.1",
"@graphql-codegen/typed-document-node": "5.1.2",
"@graphql-codegen/typescript": "4.1.6",
"@graphql-codegen/typescript-operations": "4.6.1",
"@graphql-codegen/typescript-resolvers": "4.5.1",
"@graphql-codegen/typed-document-node": "6.0.0",
"@graphql-codegen/typescript": "5.0.0",
"@graphql-codegen/typescript-operations": "5.0.0",
"@graphql-codegen/typescript-resolvers": "5.0.0",
"@graphql-typed-document-node/core": "3.2.0",
"@ianvs/prettier-plugin-sort-imports": "4.6.3",
"@nestjs/testing": "11.1.6",
@@ -205,7 +205,7 @@
"rollup-plugin-node-externals": "8.1.0",
"supertest": "7.1.4",
"tsx": "4.20.5",
"type-fest": "4.41.0",
"type-fest": "5.0.0",
"typescript": "5.9.2",
"typescript-eslint": "8.41.0",
"unplugin-swc": "1.5.7",

View File

@@ -12,7 +12,22 @@ import {
UpdateRCloneRemoteDto,
} from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
vi.mock('got');
vi.mock('got', () => {
const mockPost = vi.fn();
const gotMock = {
post: mockPost,
};
return {
default: gotMock,
HTTPError: class HTTPError extends Error {
response?: any;
constructor(response?: any) {
super('HTTP Error');
this.response = response;
}
},
};
});
vi.mock('execa');
vi.mock('p-retry');
vi.mock('node:fs', () => ({
@@ -60,7 +75,7 @@ vi.mock('@nestjs/common', async (importOriginal) => {
describe('RCloneApiService', () => {
let service: RCloneApiService;
let mockGot: any;
let mockGotPost: any;
let mockExeca: any;
let mockPRetry: any;
let mockExistsSync: any;
@@ -68,19 +83,19 @@ describe('RCloneApiService', () => {
beforeEach(async () => {
vi.clearAllMocks();
const { default: got } = await import('got');
const got = await import('got');
const { execa } = await import('execa');
const pRetry = await import('p-retry');
const { existsSync } = await import('node:fs');
const { fileExists } = await import('@app/core/utils/files/file-exists.js');
mockGot = vi.mocked(got);
mockGotPost = vi.mocked(got.default.post);
mockExeca = vi.mocked(execa);
mockPRetry = vi.mocked(pRetry.default);
mockExistsSync = vi.mocked(existsSync);
// Mock successful RClone API response for socket check
mockGot.post = vi.fn().mockResolvedValue({ body: { pid: 12345 } });
mockGotPost.mockResolvedValue({ body: { pid: 12345 } });
// Mock RClone binary exists check
vi.mocked(fileExists).mockResolvedValue(true);
@@ -97,10 +112,10 @@ describe('RCloneApiService', () => {
mockPRetry.mockResolvedValue(undefined);
service = new RCloneApiService();
await service.onModuleInit();
await service.onApplicationBootstrap();
// Reset the mock after initialization to prepare for test-specific responses
mockGot.post.mockClear();
mockGotPost.mockClear();
});
describe('getProviders', () => {
@@ -109,15 +124,15 @@ describe('RCloneApiService', () => {
{ name: 'aws', prefix: 's3', description: 'Amazon S3' },
{ name: 'google', prefix: 'drive', description: 'Google Drive' },
];
mockGot.post.mockResolvedValue({
mockGotPost.mockResolvedValue({
body: { providers: mockProviders },
});
const result = await service.getProviders();
expect(result).toEqual(mockProviders);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/config/providers',
expect(mockGotPost).toHaveBeenCalledWith(
expect.stringMatching(/\/config\/providers$/),
expect.objectContaining({
json: {},
responseType: 'json',
@@ -130,7 +145,7 @@ describe('RCloneApiService', () => {
});
it('should return empty array when no providers', async () => {
mockGot.post.mockResolvedValue({ body: {} });
mockGotPost.mockResolvedValue({ body: {} });
const result = await service.getProviders();
@@ -141,15 +156,15 @@ describe('RCloneApiService', () => {
describe('listRemotes', () => {
it('should return list of remotes', async () => {
const mockRemotes = ['backup-s3', 'drive-storage'];
mockGot.post.mockResolvedValue({
mockGotPost.mockResolvedValue({
body: { remotes: mockRemotes },
});
const result = await service.listRemotes();
expect(result).toEqual(mockRemotes);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/config/listremotes',
expect(mockGotPost).toHaveBeenCalledWith(
expect.stringMatching(/\/config\/listremotes$/),
expect.objectContaining({
json: {},
responseType: 'json',
@@ -162,7 +177,7 @@ describe('RCloneApiService', () => {
});
it('should return empty array when no remotes', async () => {
mockGot.post.mockResolvedValue({ body: {} });
mockGotPost.mockResolvedValue({ body: {} });
const result = await service.listRemotes();
@@ -174,13 +189,13 @@ describe('RCloneApiService', () => {
it('should return remote details', async () => {
const input: GetRCloneRemoteDetailsDto = { name: 'test-remote' };
const mockConfig = { type: 's3', provider: 'AWS' };
mockGot.post.mockResolvedValue({ body: mockConfig });
mockGotPost.mockResolvedValue({ body: mockConfig });
const result = await service.getRemoteDetails(input);
expect(result).toEqual(mockConfig);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/config/get',
expect(mockGotPost).toHaveBeenCalledWith(
expect.stringMatching(/\/config\/get$/),
expect.objectContaining({
json: { name: 'test-remote' },
responseType: 'json',
@@ -197,7 +212,7 @@ describe('RCloneApiService', () => {
it('should return remote configuration', async () => {
const input: GetRCloneRemoteConfigDto = { name: 'test-remote' };
const mockConfig = { type: 's3', access_key_id: 'AKIA...' };
mockGot.post.mockResolvedValue({ body: mockConfig });
mockGotPost.mockResolvedValue({ body: mockConfig });
const result = await service.getRemoteConfig(input);
@@ -213,13 +228,13 @@ describe('RCloneApiService', () => {
parameters: { access_key_id: 'AKIA...', secret_access_key: 'secret' },
};
const mockResponse = { success: true };
mockGot.post.mockResolvedValue({ body: mockResponse });
mockGotPost.mockResolvedValue({ body: mockResponse });
const result = await service.createRemote(input);
expect(result).toEqual(mockResponse);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/config/create',
expect(mockGotPost).toHaveBeenCalledWith(
expect.stringMatching(/\/config\/create$/),
expect.objectContaining({
json: {
name: 'new-remote',
@@ -243,13 +258,13 @@ describe('RCloneApiService', () => {
parameters: { access_key_id: 'NEW_AKIA...' },
};
const mockResponse = { success: true };
mockGot.post.mockResolvedValue({ body: mockResponse });
mockGotPost.mockResolvedValue({ body: mockResponse });
const result = await service.updateRemote(input);
expect(result).toEqual(mockResponse);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/config/update',
expect(mockGotPost).toHaveBeenCalledWith(
expect.stringMatching(/\/config\/update$/),
expect.objectContaining({
json: {
name: 'existing-remote',
@@ -269,13 +284,13 @@ describe('RCloneApiService', () => {
it('should delete a remote', async () => {
const input: DeleteRCloneRemoteDto = { name: 'remote-to-delete' };
const mockResponse = { success: true };
mockGot.post.mockResolvedValue({ body: mockResponse });
mockGotPost.mockResolvedValue({ body: mockResponse });
const result = await service.deleteRemote(input);
expect(result).toEqual(mockResponse);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/config/delete',
expect(mockGotPost).toHaveBeenCalledWith(
expect.stringMatching(/\/config\/delete$/),
expect.objectContaining({
json: { name: 'remote-to-delete' },
responseType: 'json',
@@ -296,13 +311,13 @@ describe('RCloneApiService', () => {
options: { delete_on: 'dst' },
};
const mockResponse = { jobid: 'job-123' };
mockGot.post.mockResolvedValue({ body: mockResponse });
mockGotPost.mockResolvedValue({ body: mockResponse });
const result = await service.startBackup(input);
expect(result).toEqual(mockResponse);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/sync/copy',
expect(mockGotPost).toHaveBeenCalledWith(
expect.stringMatching(/\/sync\/copy$/),
expect.objectContaining({
json: {
srcFs: '/source/path',
@@ -323,13 +338,13 @@ describe('RCloneApiService', () => {
it('should return job status', async () => {
const input: GetRCloneJobStatusDto = { jobId: 'job-123' };
const mockStatus = { status: 'running', progress: 0.5 };
mockGot.post.mockResolvedValue({ body: mockStatus });
mockGotPost.mockResolvedValue({ body: mockStatus });
const result = await service.getJobStatus(input);
expect(result).toEqual(mockStatus);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/job/status',
expect(mockGotPost).toHaveBeenCalledWith(
expect.stringMatching(/\/job\/status$/),
expect.objectContaining({
json: { jobid: 'job-123' },
responseType: 'json',
@@ -348,13 +363,13 @@ describe('RCloneApiService', () => {
{ id: 'job-1', status: 'running' },
{ id: 'job-2', status: 'finished' },
];
mockGot.post.mockResolvedValue({ body: mockJobs });
mockGotPost.mockResolvedValue({ body: mockJobs });
const result = await service.listRunningJobs();
expect(result).toEqual(mockJobs);
expect(mockGot.post).toHaveBeenCalledWith(
'http://unix:/tmp/rclone.sock:/job/list',
expect(mockGotPost).toHaveBeenCalledWith(
expect.stringMatching(/\/job\/list$/),
expect.objectContaining({
json: {},
responseType: 'json',
@@ -378,7 +393,7 @@ describe('RCloneApiService', () => {
},
};
Object.setPrototypeOf(httpError, HTTPError.prototype);
mockGot.post.mockRejectedValue(httpError);
mockGotPost.mockRejectedValue(httpError);
await expect(service.getProviders()).rejects.toThrow(
'Rclone API Error (config/providers, HTTP 500): Rclone Error: Internal server error'
@@ -395,7 +410,7 @@ describe('RCloneApiService', () => {
},
};
Object.setPrototypeOf(httpError, HTTPError.prototype);
mockGot.post.mockRejectedValue(httpError);
mockGotPost.mockRejectedValue(httpError);
await expect(service.getProviders()).rejects.toThrow(
'Rclone API Error (config/providers, HTTP 404): Failed to process error response body. Raw body:'
@@ -412,7 +427,7 @@ describe('RCloneApiService', () => {
},
};
Object.setPrototypeOf(httpError, HTTPError.prototype);
mockGot.post.mockRejectedValue(httpError);
mockGotPost.mockRejectedValue(httpError);
await expect(service.getProviders()).rejects.toThrow(
'Rclone API Error (config/providers, HTTP 400): Failed to process error response body. Raw body: invalid json'
@@ -421,17 +436,108 @@ describe('RCloneApiService', () => {
it('should handle non-HTTP errors', async () => {
const networkError = new Error('Network connection failed');
mockGot.post.mockRejectedValue(networkError);
mockGotPost.mockRejectedValue(networkError);
await expect(service.getProviders()).rejects.toThrow('Network connection failed');
});
it('should handle unknown errors', async () => {
mockGot.post.mockRejectedValue('unknown error');
mockGotPost.mockRejectedValue('unknown error');
await expect(service.getProviders()).rejects.toThrow(
'Unknown error calling RClone API (config/providers) with params {}: unknown error'
);
});
});
describe('checkRcloneBinaryExists', () => {
beforeEach(() => {
// Create a new service instance without initializing for these tests
service = new RCloneApiService();
});
it('should return true when rclone version is 1.70.0', async () => {
mockExeca.mockResolvedValueOnce({
stdout: 'rclone v1.70.0\n- os/version: darwin 14.0 (64 bit)\n- os/kernel: 23.0.0 (arm64)',
stderr: '',
} as any);
const result = await (service as any).checkRcloneBinaryExists();
expect(result).toBe(true);
});
it('should return true when rclone version is newer than 1.70.0', async () => {
mockExeca.mockResolvedValueOnce({
stdout: 'rclone v1.75.2\n- os/version: darwin 14.0 (64 bit)\n- os/kernel: 23.0.0 (arm64)',
stderr: '',
} as any);
const result = await (service as any).checkRcloneBinaryExists();
expect(result).toBe(true);
});
it('should return false when rclone version is older than 1.70.0', async () => {
mockExeca.mockResolvedValueOnce({
stdout: 'rclone v1.69.0\n- os/version: darwin 14.0 (64 bit)\n- os/kernel: 23.0.0 (arm64)',
stderr: '',
} as any);
const result = await (service as any).checkRcloneBinaryExists();
expect(result).toBe(false);
});
it('should return false when rclone version is much older', async () => {
mockExeca.mockResolvedValueOnce({
stdout: 'rclone v1.50.0\n- os/version: darwin 14.0 (64 bit)\n- os/kernel: 23.0.0 (arm64)',
stderr: '',
} as any);
const result = await (service as any).checkRcloneBinaryExists();
expect(result).toBe(false);
});
it('should return false when version cannot be parsed', async () => {
mockExeca.mockResolvedValueOnce({
stdout: 'rclone unknown version format',
stderr: '',
} as any);
const result = await (service as any).checkRcloneBinaryExists();
expect(result).toBe(false);
});
it('should return false when rclone binary is not found', async () => {
const error = new Error('Command not found') as any;
error.code = 'ENOENT';
mockExeca.mockRejectedValueOnce(error);
const result = await (service as any).checkRcloneBinaryExists();
expect(result).toBe(false);
});
it('should return false and log error for other exceptions', async () => {
mockExeca.mockRejectedValueOnce(new Error('Some other error'));
const result = await (service as any).checkRcloneBinaryExists();
expect(result).toBe(false);
});
it('should handle beta/rc versions correctly', async () => {
mockExeca.mockResolvedValueOnce({
stdout: 'rclone v1.70.0-beta.1\n- os/version: darwin 14.0 (64 bit)\n- os/kernel: 23.0.0 (arm64)',
stderr: '',
} as any);
const result = await (service as any).checkRcloneBinaryExists();
expect(result).toBe(true);
});
});
});

View File

@@ -2,7 +2,7 @@ import { join } from 'path';
import type { JSONWebKeySet } from 'jose';
import { PORT } from '@app/environment.js';
import { ENABLE_NEXT_DOCKER_RELEASE, PORT } from '@app/environment.js';
export const getInternalApiAddress = (isHttp = true, nginxPort = 80) => {
const envPort = PORT;
@@ -79,3 +79,14 @@ export const KEYSERVER_VALIDATION_ENDPOINT = 'https://keys.lime-technology.com/v
/** Set the max retries for the GraphQL Client */
export const MAX_RETRIES_FOR_LINEAR_BACKOFF = 100;
/**
* Feature flags are used to conditionally enable or disable functionality in the Unraid API.
*
* Keys are human readable feature flag names -- will be used to construct error messages.
*
* Values are boolean/truthy values.
*/
export const FeatureFlags = Object.freeze({
ENABLE_NEXT_DOCKER_RELEASE,
});

View File

@@ -110,3 +110,6 @@ export const PATHS_CONFIG_MODULES =
export const PATHS_LOCAL_SESSION_FILE =
process.env.PATHS_LOCAL_SESSION_FILE ?? '/var/run/unraid-api/local-session';
/** feature flag for the upcoming docker release */
export const ENABLE_NEXT_DOCKER_RELEASE = process.env.ENABLE_NEXT_DOCKER_RELEASE === 'true';

View File

@@ -14,6 +14,7 @@ import { AuthModule } from '@app/unraid-api/auth/auth.module.js';
import { AuthenticationGuard } from '@app/unraid-api/auth/authentication.guard.js';
import { LegacyConfigModule } from '@app/unraid-api/config/legacy-config.module.js';
import { CronModule } from '@app/unraid-api/cron/cron.module.js';
import { JobModule } from '@app/unraid-api/cron/job.module.js';
import { GraphModule } from '@app/unraid-api/graph/graph.module.js';
import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js';
import { RestModule } from '@app/unraid-api/rest/rest.module.js';
@@ -24,7 +25,7 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u
GlobalDepsModule,
LegacyConfigModule,
PubSubModule,
ScheduleModule.forRoot(),
JobModule,
LoggerModule.forRoot({
pinoHttp: {
logger: apiLogger,

View File

@@ -241,6 +241,8 @@ export type ArrayDisk = Node & {
id: Scalars['PrefixedID']['output'];
/** Array slot number. Parity1 is always 0 and Parity2 is always 29. Array slots will be 1 - 28. Cache slots are 30 - 53. Flash is 54. */
idx: Scalars['Int']['output'];
/** Whether the disk is currently spinning */
isSpinning?: Maybe<Scalars['Boolean']['output']>;
name?: Maybe<Scalars['String']['output']>;
/** Number of unrecoverable errors reported by the device I/O drivers. Missing data due to unrecoverable array read errors is filled in on-the-fly using parity reconstruct (and we attempt to write this data back to the sector(s) which failed). Any unrecoverable write error results in disabling the disk. */
numErrors?: Maybe<Scalars['BigInt']['output']>;
@@ -607,6 +609,8 @@ export type Disk = Node & {
id: Scalars['PrefixedID']['output'];
/** The interface type of the disk */
interfaceType: DiskInterfaceType;
/** Whether the disk is spinning or not */
isSpinning: Scalars['Boolean']['output'];
/** The model name of the disk */
name: Scalars['String']['output'];
/** The partitions on the disk */
@@ -674,6 +678,7 @@ export enum DiskSmartStatus {
export type Docker = Node & {
__typename?: 'Docker';
containerUpdateStatuses: Array<ExplicitStatusItem>;
containers: Array<DockerContainer>;
id: Scalars['PrefixedID']['output'];
networks: Array<DockerNetwork>;
@@ -699,6 +704,8 @@ export type DockerContainer = Node & {
id: Scalars['PrefixedID']['output'];
image: Scalars['String']['output'];
imageId: Scalars['String']['output'];
isRebuildReady?: Maybe<Scalars['Boolean']['output']>;
isUpdateAvailable?: Maybe<Scalars['Boolean']['output']>;
labels?: Maybe<Scalars['JSON']['output']>;
mounts?: Maybe<Array<Scalars['JSON']['output']>>;
names: Array<Scalars['String']['output']>;
@@ -770,6 +777,12 @@ export type EnableDynamicRemoteAccessInput = {
url: AccessUrlInput;
};
export type ExplicitStatusItem = {
__typename?: 'ExplicitStatusItem';
name: Scalars['String']['output'];
updateStatus: UpdateStatus;
};
export type Flash = Node & {
__typename?: 'Flash';
guid: Scalars['String']['output'];
@@ -1225,6 +1238,7 @@ export type Mutation = {
rclone: RCloneMutations;
/** Reads each notification to recompute & update the overview. */
recalculateOverview: NotificationOverview;
refreshDockerDigests: Scalars['Boolean']['output'];
/** Remove one or more plugins from the API. Returns false if restart was triggered automatically, true if manual restart is required. */
removePlugin: Scalars['Boolean']['output'];
setDockerFolderChildren: ResolvedOrganizerV1;
@@ -2260,6 +2274,14 @@ export type UpdateSettingsResponse = {
warnings?: Maybe<Array<Scalars['String']['output']>>;
};
/** Update status of a container. */
export enum UpdateStatus {
REBUILD_READY = 'REBUILD_READY',
UNKNOWN = 'UNKNOWN',
UPDATE_AVAILABLE = 'UPDATE_AVAILABLE',
UP_TO_DATE = 'UP_TO_DATE'
}
export type Uptime = {
__typename?: 'Uptime';
timestamp?: Maybe<Scalars['String']['output']>;

View File

@@ -1,11 +1,11 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { JobModule } from '@app/unraid-api/cron/job.module.js';
import { LogRotateService } from '@app/unraid-api/cron/log-rotate.service.js';
import { WriteFlashFileService } from '@app/unraid-api/cron/write-flash-file.service.js';
@Module({
imports: [],
imports: [JobModule],
providers: [WriteFlashFileService, LogRotateService],
})
export class CronModule {}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
/**
* Sets up common dependencies for initializing jobs (e.g. scheduler registry, cron jobs).
*
* Simplifies testing setup & application dependency tree by ensuring `forRoot` is called only once.
*/
@Module({
imports: [ScheduleModule.forRoot()],
exports: [ScheduleModule],
})
export class JobModule {}

View File

@@ -0,0 +1,172 @@
import { Reflector } from '@nestjs/core';
import { Field, Mutation, ObjectType, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { OMIT_IF_METADATA_KEY, OmitIf } from '@app/unraid-api/decorators/omit-if.decorator.js';
describe('OmitIf Decorator', () => {
let reflector: Reflector;
beforeEach(() => {
reflector = new Reflector();
});
describe('OmitIf', () => {
it('should set metadata when condition is true', () => {
class TestResolver {
@OmitIf(true)
testMethod() {
return 'test';
}
}
const instance = new TestResolver();
const metadata = reflector.get(OMIT_IF_METADATA_KEY, instance.testMethod);
expect(metadata).toBe(true);
});
it('should not set metadata when condition is false', () => {
class TestResolver {
@OmitIf(false)
testMethod() {
return 'test';
}
}
const instance = new TestResolver();
const metadata = reflector.get(OMIT_IF_METADATA_KEY, instance.testMethod);
expect(metadata).toBeUndefined();
});
it('should evaluate function conditions', () => {
const mockCondition = vi.fn(() => true);
class TestResolver {
@OmitIf(mockCondition)
testMethod() {
return 'test';
}
}
expect(mockCondition).toHaveBeenCalledOnce();
const instance = new TestResolver();
const metadata = reflector.get(OMIT_IF_METADATA_KEY, instance.testMethod);
expect(metadata).toBe(true);
});
it('should evaluate function conditions that return false', () => {
const mockCondition = vi.fn(() => false);
class TestResolver {
@OmitIf(mockCondition)
testMethod() {
return 'test';
}
}
expect(mockCondition).toHaveBeenCalledOnce();
const instance = new TestResolver();
const metadata = reflector.get(OMIT_IF_METADATA_KEY, instance.testMethod);
expect(metadata).toBeUndefined();
});
it('should work with environment variables', () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
class TestResolver {
@OmitIf(process.env.NODE_ENV === 'production')
testMethod() {
return 'test';
}
}
const instance = new TestResolver();
const metadata = reflector.get(OMIT_IF_METADATA_KEY, instance.testMethod);
expect(metadata).toBe(true);
process.env.NODE_ENV = originalEnv;
});
});
describe('Integration with NestJS GraphQL decorators', () => {
it('should work with @Query decorator', () => {
@Resolver()
class TestResolver {
@OmitIf(true)
@Query(() => String)
omittedQuery() {
return 'test';
}
@OmitIf(false)
@Query(() => String)
includedQuery() {
return 'test';
}
}
const instance = new TestResolver();
const omittedMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.omittedQuery);
const includedMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.includedQuery);
expect(omittedMetadata).toBe(true);
expect(includedMetadata).toBeUndefined();
});
it('should work with @Mutation decorator', () => {
@Resolver()
class TestResolver {
@OmitIf(true)
@Mutation(() => String)
omittedMutation() {
return 'test';
}
@OmitIf(false)
@Mutation(() => String)
includedMutation() {
return 'test';
}
}
const instance = new TestResolver();
const omittedMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.omittedMutation);
const includedMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.includedMutation);
expect(omittedMetadata).toBe(true);
expect(includedMetadata).toBeUndefined();
});
it('should work with @ResolveField decorator', () => {
@ObjectType()
class TestType {
@Field()
id: string = '';
}
@Resolver(() => TestType)
class TestResolver {
@OmitIf(true)
@ResolveField(() => String)
omittedField() {
return 'test';
}
@OmitIf(false)
@ResolveField(() => String)
includedField() {
return 'test';
}
}
const instance = new TestResolver();
const omittedMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.omittedField);
const includedMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.includedField);
expect(omittedMetadata).toBe(true);
expect(includedMetadata).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,80 @@
import { SetMetadata } from '@nestjs/common';
import { Extensions } from '@nestjs/graphql';
import { MapperKind, mapSchema } from '@graphql-tools/utils';
import { GraphQLFieldConfig, GraphQLSchema } from 'graphql';
export const OMIT_IF_METADATA_KEY = 'omitIf';
/**
* Decorator that conditionally omits a GraphQL field/query/mutation based on a condition.
* The field will only be omitted from the schema when the condition evaluates to true.
*
* @param condition - If the condition evaluates to true, the field will be omitted from the schema
* @returns A decorator that wraps the target field/query/mutation
*
* @example
* ```typescript
* @OmitIf(process.env.NODE_ENV === 'production')
* @Query(() => String)
* async debugQuery() {
* return 'This query is omitted in production';
* }
* ```
*/
export function OmitIf(condition: boolean | (() => boolean)): MethodDecorator & PropertyDecorator {
const shouldOmit = typeof condition === 'function' ? condition() : condition;
return (target: object, propertyKey?: string | symbol, descriptor?: PropertyDescriptor) => {
if (shouldOmit) {
SetMetadata(OMIT_IF_METADATA_KEY, true)(
target,
propertyKey as string,
descriptor as PropertyDescriptor
);
Extensions({ omitIf: true })(
target,
propertyKey as string,
descriptor as PropertyDescriptor
);
}
return descriptor;
};
}
/**
* Schema transformer that omits fields/queries/mutations based on the OmitIf decorator.
* @param schema - The GraphQL schema to transform
* @returns The transformed GraphQL schema
*/
export function omitIfSchemaTransformer(schema: GraphQLSchema): GraphQLSchema {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (
fieldConfig: GraphQLFieldConfig<any, any>,
fieldName: string,
typeName: string
) => {
const extensions = fieldConfig.extensions || {};
if (extensions.omitIf === true) {
return null;
}
return fieldConfig;
},
[MapperKind.ROOT_FIELD]: (
fieldConfig: GraphQLFieldConfig<any, any>,
fieldName: string,
typeName: string
) => {
const extensions = fieldConfig.extensions || {};
if (extensions.omitIf === true) {
return null;
}
return fieldConfig;
},
});
}

View File

@@ -0,0 +1,317 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
// fixme: types don't sync with mocks, and there's no override to simplify testing.
import { Reflector } from '@nestjs/core';
import { Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { OMIT_IF_METADATA_KEY } from '@app/unraid-api/decorators/omit-if.decorator.js';
import { UseFeatureFlag } from '@app/unraid-api/decorators/use-feature-flag.decorator.js';
// Mock the FeatureFlags
vi.mock('@app/consts.js', () => ({
FeatureFlags: Object.freeze({
ENABLE_NEXT_DOCKER_RELEASE: false,
ENABLE_EXPERIMENTAL_FEATURE: true,
ENABLE_DEBUG_MODE: false,
ENABLE_BETA_FEATURES: true,
}),
}));
describe('UseFeatureFlag Decorator', () => {
let reflector: Reflector;
beforeEach(() => {
reflector = new Reflector();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Basic functionality', () => {
it('should omit field when feature flag is false', () => {
@Resolver()
class TestResolver {
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@Query(() => String)
testQuery() {
return 'test';
}
}
const instance = new TestResolver();
const metadata = reflector.get(OMIT_IF_METADATA_KEY, instance.testQuery);
expect(metadata).toBe(true); // Should be omitted because flag is false
});
it('should include field when feature flag is true', () => {
@Resolver()
class TestResolver {
@UseFeatureFlag('ENABLE_EXPERIMENTAL_FEATURE')
@Query(() => String)
testQuery() {
return 'test';
}
}
const instance = new TestResolver();
const metadata = reflector.get(OMIT_IF_METADATA_KEY, instance.testQuery);
expect(metadata).toBeUndefined(); // Should not be omitted because flag is true
});
});
describe('With different decorator types', () => {
it('should work with @Query decorator', () => {
@Resolver()
class TestResolver {
@UseFeatureFlag('ENABLE_DEBUG_MODE')
@Query(() => String)
debugQuery() {
return 'debug';
}
@UseFeatureFlag('ENABLE_BETA_FEATURES')
@Query(() => String)
betaQuery() {
return 'beta';
}
}
const instance = new TestResolver();
const debugMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.debugQuery);
const betaMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.betaQuery);
expect(debugMetadata).toBe(true); // ENABLE_DEBUG_MODE is false
expect(betaMetadata).toBeUndefined(); // ENABLE_BETA_FEATURES is true
});
it('should work with @Mutation decorator', () => {
@Resolver()
class TestResolver {
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@Mutation(() => String)
dockerMutation() {
return 'docker';
}
@UseFeatureFlag('ENABLE_EXPERIMENTAL_FEATURE')
@Mutation(() => String)
experimentalMutation() {
return 'experimental';
}
}
const instance = new TestResolver();
const dockerMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.dockerMutation);
const experimentalMetadata = reflector.get(
OMIT_IF_METADATA_KEY,
instance.experimentalMutation
);
expect(dockerMetadata).toBe(true); // ENABLE_NEXT_DOCKER_RELEASE is false
expect(experimentalMetadata).toBeUndefined(); // ENABLE_EXPERIMENTAL_FEATURE is true
});
it('should work with @ResolveField decorator', () => {
@Resolver()
class TestResolver {
@UseFeatureFlag('ENABLE_DEBUG_MODE')
@ResolveField(() => String)
debugField() {
return 'debug';
}
@UseFeatureFlag('ENABLE_BETA_FEATURES')
@ResolveField(() => String)
betaField() {
return 'beta';
}
}
const instance = new TestResolver();
const debugMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.debugField);
const betaMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.betaField);
expect(debugMetadata).toBe(true); // ENABLE_DEBUG_MODE is false
expect(betaMetadata).toBeUndefined(); // ENABLE_BETA_FEATURES is true
});
});
describe('Multiple decorators on same class', () => {
it('should handle multiple feature flags independently', () => {
@Resolver()
class TestResolver {
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@Query(() => String)
dockerQuery() {
return 'docker';
}
@UseFeatureFlag('ENABLE_EXPERIMENTAL_FEATURE')
@Query(() => String)
experimentalQuery() {
return 'experimental';
}
@UseFeatureFlag('ENABLE_DEBUG_MODE')
@Query(() => String)
debugQuery() {
return 'debug';
}
@UseFeatureFlag('ENABLE_BETA_FEATURES')
@Query(() => String)
betaQuery() {
return 'beta';
}
}
const instance = new TestResolver();
expect(reflector.get(OMIT_IF_METADATA_KEY, instance.dockerQuery)).toBe(true);
expect(reflector.get(OMIT_IF_METADATA_KEY, instance.experimentalQuery)).toBeUndefined();
expect(reflector.get(OMIT_IF_METADATA_KEY, instance.debugQuery)).toBe(true);
expect(reflector.get(OMIT_IF_METADATA_KEY, instance.betaQuery)).toBeUndefined();
});
});
describe('Type safety', () => {
it('should only accept valid feature flag keys', () => {
// This test verifies TypeScript compile-time type safety
// The following would cause a TypeScript error if uncommented:
// @UseFeatureFlag('INVALID_FLAG')
@Resolver()
class TestResolver {
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@Query(() => String)
validQuery() {
return 'valid';
}
}
const instance = new TestResolver();
expect(instance.validQuery).toBeDefined();
});
});
describe('Integration scenarios', () => {
it('should work correctly with other decorators', () => {
const customDecorator = (
target: any,
propertyKey: string | symbol,
descriptor: PropertyDescriptor
) => {
Reflect.defineMetadata('custom', true, target, propertyKey);
return descriptor;
};
@Resolver()
class TestResolver {
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@customDecorator
@Query(() => String)
multiDecoratorQuery() {
return 'multi';
}
}
const instance = new TestResolver();
const omitMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.multiDecoratorQuery);
const customMetadata = Reflect.getMetadata('custom', instance, 'multiDecoratorQuery');
expect(omitMetadata).toBe(true);
expect(customMetadata).toBe(true);
});
it('should maintain correct decorator order', () => {
const orderTracker: string[] = [];
const trackingDecorator = (name: string) => {
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
orderTracker.push(name);
return descriptor;
};
};
@Resolver()
class TestResolver {
@trackingDecorator('first')
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@trackingDecorator('last')
@Query(() => String)
orderedQuery() {
return 'ordered';
}
}
// Decorators are applied bottom-up
expect(orderTracker).toEqual(['last', 'first']);
});
});
describe('Real-world usage patterns', () => {
it('should work with Docker resolver pattern', () => {
@Resolver()
class DockerResolver {
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@Mutation(() => String)
async createDockerFolder(name: string) {
return `Created folder: ${name}`;
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@Mutation(() => String)
async deleteDockerEntries(entryIds: string[]) {
return `Deleted entries: ${entryIds.join(', ')}`;
}
@Query(() => String)
async getDockerInfo() {
return 'Docker info';
}
}
const instance = new DockerResolver();
// Feature flag is false, so these should be omitted
expect(reflector.get(OMIT_IF_METADATA_KEY, instance.createDockerFolder)).toBe(true);
expect(reflector.get(OMIT_IF_METADATA_KEY, instance.deleteDockerEntries)).toBe(true);
// No feature flag, so this should not be omitted
expect(reflector.get(OMIT_IF_METADATA_KEY, instance.getDockerInfo)).toBeUndefined();
});
it('should handle mixed feature flags in same resolver', () => {
@Resolver()
class MixedResolver {
@UseFeatureFlag('ENABLE_EXPERIMENTAL_FEATURE')
@Query(() => String)
experimentalQuery() {
return 'experimental';
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@Query(() => String)
dockerQuery() {
return 'docker';
}
@UseFeatureFlag('ENABLE_BETA_FEATURES')
@Mutation(() => String)
betaMutation() {
return 'beta';
}
}
const instance = new MixedResolver();
expect(reflector.get(OMIT_IF_METADATA_KEY, instance.experimentalQuery)).toBeUndefined();
expect(reflector.get(OMIT_IF_METADATA_KEY, instance.dockerQuery)).toBe(true);
expect(reflector.get(OMIT_IF_METADATA_KEY, instance.betaMutation)).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,22 @@
import { FeatureFlags } from '@app/consts.js';
import { OmitIf } from '@app/unraid-api/decorators/omit-if.decorator.js';
/**
* Decorator that conditionally includes a GraphQL field/query/mutation based on a feature flag.
* The field will only be included in the schema when the feature flag is enabled.
*
* @param flagKey - The key of the feature flag in FeatureFlags
* @returns A decorator that wraps OmitIf
*
* @example
* ```typescript
* @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
* @Mutation(() => String)
* async experimentalMutation() {
* return 'This mutation is only available when ENABLE_NEXT_DOCKER_RELEASE is true';
* }
* ```
*/
export function UseFeatureFlag(flagKey: keyof typeof FeatureFlags): MethodDecorator & PropertyDecorator {
return OmitIf(!FeatureFlags[flagKey]);
}

View File

@@ -12,6 +12,7 @@ import { NoUnusedVariablesRule } from 'graphql';
import { ENVIRONMENT } from '@app/environment.js';
import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js';
import { omitIfSchemaTransformer } from '@app/unraid-api/decorators/omit-if.decorator.js';
// Import enum registrations to ensure they're registered with GraphQL
import '@app/unraid-api/graph/auth/auth-action.enum.js';
@@ -64,7 +65,12 @@ import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
},
// Only add transform when not in test environment to avoid GraphQL version conflicts
transformSchema:
process.env.NODE_ENV === 'test' ? undefined : usePermissionsSchemaTransformer,
process.env.NODE_ENV === 'test'
? undefined
: (schema) => {
const schemaWithPermissions = usePermissionsSchemaTransformer(schema);
return omitIfSchemaTransformer(schemaWithPermissions);
},
validationRules: [NoUnusedVariablesRule],
};
},

View File

@@ -0,0 +1,47 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { SchedulerRegistry, Timeout } from '@nestjs/schedule';
import { CronJob } from 'cron';
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js';
@Injectable()
export class ContainerStatusJob implements OnApplicationBootstrap {
private readonly logger = new Logger(ContainerStatusJob.name);
constructor(
private readonly dockerManifestService: DockerManifestService,
private readonly schedulerRegistry: SchedulerRegistry,
private readonly dockerConfigService: DockerConfigService
) {}
/**
* Initialize cron job for refreshing the update status for all containers on a user-configurable schedule.
*/
onApplicationBootstrap() {
if (!this.dockerConfigService.enabled()) return;
const cronExpression = this.dockerConfigService.getConfig().updateCheckCronSchedule;
const cronJob = CronJob.from({
cronTime: cronExpression,
onTick: () => {
this.dockerManifestService.refreshDigests().catch((error) => {
this.logger.warn(error, 'Failed to refresh container update status');
});
},
start: true,
});
this.schedulerRegistry.addCronJob(ContainerStatusJob.name, cronJob);
this.logger.verbose(
`Initialized cron job for refreshing container update status: ${ContainerStatusJob.name}`
);
}
/**
* Refresh container digests 5 seconds after application start.
*/
@Timeout(5_000)
async refreshContainerDigestsAfterStartup() {
if (!this.dockerConfigService.enabled()) return;
await this.dockerManifestService.refreshDigests();
}
}

View File

@@ -0,0 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class DockerConfig {
@Field(() => String)
updateCheckCronSchedule!: string;
}

View File

@@ -0,0 +1,195 @@
import { ConfigService } from '@nestjs/config';
import { CronExpression } from '@nestjs/schedule';
import { Test, TestingModule } from '@nestjs/testing';
import { ValidationError } from 'class-validator';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AppError } from '@app/core/errors/app-error.js';
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
vi.mock('cron', () => ({
validateCronExpression: vi.fn(),
}));
vi.mock('@app/unraid-api/graph/resolvers/validation.utils.js', () => ({
validateObject: vi.fn(),
}));
describe('DockerConfigService - validate', () => {
let service: DockerConfigService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DockerConfigService,
{
provide: ConfigService,
useValue: {
get: vi.fn(),
},
},
],
}).compile();
service = module.get<DockerConfigService>(DockerConfigService);
vi.clearAllMocks();
});
describe('validate', () => {
it('should validate and return docker config for valid cron expression', async () => {
const inputConfig = { updateCheckCronSchedule: '0 6 * * *' };
const validatedConfig = { updateCheckCronSchedule: '0 6 * * *' };
const { validateObject } = await import(
'@app/unraid-api/graph/resolvers/validation.utils.js'
);
const { validateCronExpression } = await import('cron');
vi.mocked(validateObject).mockResolvedValue(validatedConfig);
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
const result = await service.validate(inputConfig);
expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig);
expect(validateCronExpression).toHaveBeenCalledWith('0 6 * * *');
expect(result).toBe(validatedConfig);
});
it('should validate and return docker config for predefined cron expression', async () => {
const inputConfig = { updateCheckCronSchedule: CronExpression.EVERY_DAY_AT_6AM };
const validatedConfig = { updateCheckCronSchedule: CronExpression.EVERY_DAY_AT_6AM };
const { validateObject } = await import(
'@app/unraid-api/graph/resolvers/validation.utils.js'
);
const { validateCronExpression } = await import('cron');
vi.mocked(validateObject).mockResolvedValue(validatedConfig);
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
const result = await service.validate(inputConfig);
expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig);
expect(validateCronExpression).toHaveBeenCalledWith(CronExpression.EVERY_DAY_AT_6AM);
expect(result).toBe(validatedConfig);
});
it('should throw AppError for invalid cron expression', async () => {
const inputConfig = { updateCheckCronSchedule: 'invalid-cron' };
const validatedConfig = { updateCheckCronSchedule: 'invalid-cron' };
const { validateObject } = await import(
'@app/unraid-api/graph/resolvers/validation.utils.js'
);
const { validateCronExpression } = await import('cron');
vi.mocked(validateObject).mockResolvedValue(validatedConfig);
vi.mocked(validateCronExpression).mockReturnValue({ valid: false });
await expect(service.validate(inputConfig)).rejects.toThrow(
new AppError('Cron expression not supported: invalid-cron')
);
expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig);
expect(validateCronExpression).toHaveBeenCalledWith('invalid-cron');
});
it('should throw AppError for empty cron expression', async () => {
const inputConfig = { updateCheckCronSchedule: '' };
const validatedConfig = { updateCheckCronSchedule: '' };
const { validateObject } = await import(
'@app/unraid-api/graph/resolvers/validation.utils.js'
);
const { validateCronExpression } = await import('cron');
vi.mocked(validateObject).mockResolvedValue(validatedConfig);
vi.mocked(validateCronExpression).mockReturnValue({ valid: false });
await expect(service.validate(inputConfig)).rejects.toThrow(
new AppError('Cron expression not supported: ')
);
expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig);
expect(validateCronExpression).toHaveBeenCalledWith('');
});
it('should throw AppError for malformed cron expression', async () => {
const inputConfig = { updateCheckCronSchedule: '* * * *' };
const validatedConfig = { updateCheckCronSchedule: '* * * *' };
const { validateObject } = await import(
'@app/unraid-api/graph/resolvers/validation.utils.js'
);
const { validateCronExpression } = await import('cron');
vi.mocked(validateObject).mockResolvedValue(validatedConfig);
vi.mocked(validateCronExpression).mockReturnValue({ valid: false });
await expect(service.validate(inputConfig)).rejects.toThrow(
new AppError('Cron expression not supported: * * * *')
);
expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig);
expect(validateCronExpression).toHaveBeenCalledWith('* * * *');
});
it('should propagate validation errors from validateObject', async () => {
const inputConfig = { updateCheckCronSchedule: '0 6 * * *' };
const validationError = new ValidationError();
validationError.property = 'updateCheckCronSchedule';
const { validateObject } = await import(
'@app/unraid-api/graph/resolvers/validation.utils.js'
);
vi.mocked(validateObject).mockRejectedValue(validationError);
await expect(service.validate(inputConfig)).rejects.toThrow();
expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig);
});
it('should handle complex valid cron expressions', async () => {
const inputConfig = { updateCheckCronSchedule: '0 0,12 * * 1-5' };
const validatedConfig = { updateCheckCronSchedule: '0 0,12 * * 1-5' };
const { validateObject } = await import(
'@app/unraid-api/graph/resolvers/validation.utils.js'
);
const { validateCronExpression } = await import('cron');
vi.mocked(validateObject).mockResolvedValue(validatedConfig);
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
const result = await service.validate(inputConfig);
expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig);
expect(validateCronExpression).toHaveBeenCalledWith('0 0,12 * * 1-5');
expect(result).toBe(validatedConfig);
});
it('should handle input with extra properties', async () => {
const inputConfig = {
updateCheckCronSchedule: '0 6 * * *',
extraProperty: 'should be ignored',
};
const validatedConfig = { updateCheckCronSchedule: '0 6 * * *' };
const { validateObject } = await import(
'@app/unraid-api/graph/resolvers/validation.utils.js'
);
const { validateCronExpression } = await import('cron');
vi.mocked(validateObject).mockResolvedValue(validatedConfig);
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
const result = await service.validate(inputConfig);
expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig);
expect(validateCronExpression).toHaveBeenCalledWith('0 6 * * *');
expect(result).toBe(validatedConfig);
});
});
});

View File

@@ -1,59 +1,45 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CronExpression } from '@nestjs/schedule';
import { ConfigFilePersister } from '@unraid/shared/services/config-file.js';
import { validateCronExpression } from 'cron';
import { FeatureFlags } from '@app/consts.js';
import { AppError } from '@app/core/errors/app-error.js';
import { DockerConfig } from '@app/unraid-api/graph/resolvers/docker/docker-config.model.js';
import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js';
import {
DEFAULT_ORGANIZER_ROOT_ID,
DEFAULT_ORGANIZER_VIEW_ID,
} from '@app/unraid-api/organizer/organizer.js';
import { OrganizerV1 } from '@app/unraid-api/organizer/organizer.model.js';
import { validateOrganizerIntegrity } from '@app/unraid-api/organizer/organizer.validation.js';
@Injectable()
export class DockerConfigService extends ConfigFilePersister<OrganizerV1> {
export class DockerConfigService extends ConfigFilePersister<DockerConfig> {
constructor(configService: ConfigService) {
super(configService);
}
enabled(): boolean {
return FeatureFlags.ENABLE_NEXT_DOCKER_RELEASE;
}
configKey(): string {
return 'dockerOrganizer';
return 'docker';
}
fileName(): string {
return 'docker.organizer.json';
return 'docker.config.json';
}
defaultConfig(): OrganizerV1 {
defaultConfig(): DockerConfig {
return {
version: 1,
resources: {},
views: {
default: {
id: DEFAULT_ORGANIZER_VIEW_ID,
name: 'Default',
root: DEFAULT_ORGANIZER_ROOT_ID,
entries: {
root: {
type: 'folder',
id: DEFAULT_ORGANIZER_ROOT_ID,
name: 'Root',
children: [],
},
},
},
},
updateCheckCronSchedule: CronExpression.EVERY_DAY_AT_6AM,
};
}
async validate(config: object): Promise<OrganizerV1> {
const organizer = await validateObject(OrganizerV1, config);
const { isValid, errors } = await validateOrganizerIntegrity(organizer);
if (!isValid) {
throw new AppError(`Docker organizer validation failed: ${JSON.stringify(errors, null, 2)}`);
async validate(config: object): Promise<DockerConfig> {
const dockerConfig = await validateObject(DockerConfig, config);
const cronExpression = validateCronExpression(dockerConfig.updateCheckCronSchedule);
if (!cronExpression.valid) {
throw new AppError(`Cron expression not supported: ${dockerConfig.updateCheckCronSchedule}`);
}
return organizer;
return dockerConfig;
}
}

View File

@@ -0,0 +1,51 @@
import { Logger } from '@nestjs/common';
import { Mutation, Parent, ResolveField, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import { AuthAction, UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { AppError } from '@app/core/errors/app-error.js';
import { UseFeatureFlag } from '@app/unraid-api/decorators/use-feature-flag.decorator.js';
import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js';
import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
@Resolver(() => DockerContainer)
export class DockerContainerResolver {
private readonly logger = new Logger(DockerContainerResolver.name);
constructor(private readonly dockerManifestService: DockerManifestService) {}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
})
@ResolveField(() => Boolean, { nullable: true })
public async isUpdateAvailable(@Parent() container: DockerContainer) {
try {
return await this.dockerManifestService.isUpdateAvailableCached(container.image);
} catch (error) {
this.logger.error(error);
throw new AppError('Failed to read cached update status. See graphql-api.log for details.');
}
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
})
@ResolveField(() => Boolean, { nullable: true })
public async isRebuildReady(@Parent() container: DockerContainer) {
return this.dockerManifestService.isRebuildReady(container.hostConfig?.networkMode);
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
})
@Mutation(() => Boolean)
public async refreshDockerDigests() {
return this.dockerManifestService.refreshDigests();
}
}

View File

@@ -0,0 +1,62 @@
import { Injectable } from '@nestjs/common';
import { AsyncMutex } from '@unraid/shared/util/processing.js';
import { docker } from '@app/core/utils/index.js';
import {
CachedStatusEntry,
DockerPhpService,
} from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js';
@Injectable()
export class DockerManifestService {
constructor(private readonly dockerPhpService: DockerPhpService) {}
private readonly refreshDigestsMutex = new AsyncMutex(() => {
return this.dockerPhpService.refreshDigestsViaPhp();
});
/**
* Recomputes local/remote docker container digests and writes them to /var/lib/docker/unraid-update-status.json
* @param mutex - Optional mutex to use for the operation. If not provided, a default mutex will be used.
* @param dockerUpdatePath - Optional path to the DockerUpdate.php file. If not provided, the default path will be used.
* @returns True if the digests were refreshed, false if the operation failed
*/
async refreshDigests(mutex = this.refreshDigestsMutex, dockerUpdatePath?: string) {
return mutex.do(() => {
return this.dockerPhpService.refreshDigestsViaPhp(dockerUpdatePath);
});
}
/**
* Checks if an update is available for a given container image.
* @param imageRef - The image reference to check, e.g. "unraid/baseimage:latest". If no tag is provided, "latest" is assumed, following the webgui's implementation.
* @param cacheData read from /var/lib/docker/unraid-update-status.json by default
* @returns True if an update is available, false if not, or null if the status is unknown
*/
async isUpdateAvailableCached(imageRef: string, cacheData?: Record<string, CachedStatusEntry>) {
let taggedRef = imageRef;
if (!taggedRef.includes(':')) taggedRef += ':latest';
cacheData ??= await this.dockerPhpService.readCachedUpdateStatus();
const containerData = cacheData[taggedRef];
if (!containerData) return null;
return containerData.status?.toLowerCase() === 'true';
}
/**
* Checks if a container is rebuild ready.
* @param networkMode - The network mode of the container, e.g. "container:unraid/baseimage:latest".
* @returns True if the container is rebuild ready, false if not
*/
async isRebuildReady(networkMode?: string) {
if (!networkMode || !networkMode.startsWith('container:')) return false;
const target = networkMode.slice('container:'.length);
try {
await docker.getContainer(target).inspect();
return false;
} catch {
return true; // unresolved target -> ':???' equivalent
}
}
}

View File

@@ -0,0 +1,130 @@
import { Injectable, Logger } from '@nestjs/common';
import { readFile } from 'fs/promises';
import { z } from 'zod';
import { phpLoader } from '@app/core/utils/plugins/php-loader.js';
import {
ExplicitStatusItem,
UpdateStatus,
} from '@app/unraid-api/graph/resolvers/docker/docker-update-status.model.js';
import { parseDockerPushCalls } from '@app/unraid-api/graph/resolvers/docker/utils/docker-push-parser.js';
type StatusItem = { name: string; updateStatus: 0 | 1 | 2 | 3 };
/**
* These types reflect the structure of the /var/lib/docker/unraid-update-status.json file,
* which is not controlled by the Unraid API.
*/
const CachedStatusEntrySchema = z.object({
/** sha256 digest - "sha256:..." */
local: z.string(),
/** sha256 digest - "sha256:..." */
remote: z.string(),
/** whether update is available (true), not available (false), or unknown (null) */
status: z.enum(['true', 'false']).nullable(),
});
const CachedStatusSchema = z.record(z.string(), CachedStatusEntrySchema);
export type CachedStatusEntry = z.infer<typeof CachedStatusEntrySchema>;
@Injectable()
export class DockerPhpService {
private readonly logger = new Logger(DockerPhpService.name);
constructor() {}
/**
* Reads JSON from a file containing cached update status.
* If the file does not exist, an empty object is returned.
* @param cacheFile
* @returns
*/
async readCachedUpdateStatus(
cacheFile = '/var/lib/docker/unraid-update-status.json'
): Promise<Record<string, CachedStatusEntry>> {
try {
const cache = await readFile(cacheFile, 'utf8');
const cacheData = JSON.parse(cache);
const { success, data } = CachedStatusSchema.safeParse(cacheData);
if (success) return data;
this.logger.warn(cacheData, 'Invalid cached update status');
return {};
} catch (error) {
this.logger.warn(error, 'Failed to read cached update status');
return {};
}
}
/**----------------------
* Refresh Container Digests
*------------------------**/
/**
* Recomputes local/remote digests by triggering `DockerTemplates->getAllInfo(true)` via DockerUpdate.php
* @param dockerUpdatePath - Path to the DockerUpdate.php file
* @returns True if the digests were refreshed, false if the file is not found or the operation failed
*/
async refreshDigestsViaPhp(
dockerUpdatePath = '/usr/local/emhttp/plugins/dynamix.docker.manager/include/DockerUpdate.php'
) {
try {
await phpLoader({
file: dockerUpdatePath,
method: 'GET',
});
return true;
} catch {
// ignore; offline may keep remote as 'undef'
return false;
}
}
/**----------------------
* Parse Container Statuses
*------------------------**/
private parseStatusesFromDockerPush(js: string): ExplicitStatusItem[] {
const matches = parseDockerPushCalls(js);
return matches.map(({ name, updateStatus }) => ({
name,
updateStatus: this.updateStatusToString(updateStatus as StatusItem['updateStatus']),
}));
}
private updateStatusToString(updateStatus: 0): UpdateStatus.UP_TO_DATE;
private updateStatusToString(updateStatus: 1): UpdateStatus.UPDATE_AVAILABLE;
private updateStatusToString(updateStatus: 2): UpdateStatus.REBUILD_READY;
private updateStatusToString(updateStatus: 3): UpdateStatus.UNKNOWN;
// prettier-ignore
private updateStatusToString(updateStatus: StatusItem['updateStatus']): ExplicitStatusItem['updateStatus'];
private updateStatusToString(
updateStatus: StatusItem['updateStatus']
): ExplicitStatusItem['updateStatus'] {
switch (updateStatus) {
case 0:
return UpdateStatus.UP_TO_DATE;
case 1:
return UpdateStatus.UPDATE_AVAILABLE;
case 2:
return UpdateStatus.REBUILD_READY;
default:
return UpdateStatus.UNKNOWN;
}
}
/**
* Gets the update statuses for all containers by triggering `DockerTemplates->getAllInfo(true)` via DockerContainers.php
* @param dockerContainersPath - Path to the DockerContainers.php file
* @returns The update statuses for all containers
*/
async getContainerUpdateStatuses(
dockerContainersPath = '/usr/local/emhttp/plugins/dynamix.docker.manager/include/DockerContainers.php'
): Promise<ExplicitStatusItem[]> {
const stdout = await phpLoader({
file: dockerContainersPath,
method: 'GET',
});
const parts = stdout.split('\0'); // [html, "docker.push(...)", busyFlag]
const js = parts[1] || '';
return this.parseStatusesFromDockerPush(js);
}
}

View File

@@ -0,0 +1,25 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
/**
* Note that these values propagate down to API consumers, so be aware of breaking changes.
*/
export enum UpdateStatus {
UP_TO_DATE = 'UP_TO_DATE',
UPDATE_AVAILABLE = 'UPDATE_AVAILABLE',
REBUILD_READY = 'REBUILD_READY',
UNKNOWN = 'UNKNOWN',
}
registerEnumType(UpdateStatus, {
name: 'UpdateStatus',
description: 'Update status of a container.',
});
@ObjectType()
export class ExplicitStatusItem {
@Field(() => String)
name!: string;
@Field(() => UpdateStatus)
updateStatus!: UpdateStatus;
}

View File

@@ -1,15 +1,16 @@
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { describe, expect, it, vi } from 'vitest';
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
import { DockerEventService } from '@app/unraid-api/graph/resolvers/docker/docker-event.service.js';
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/docker-organizer.service.js';
import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js';
import { DockerModule } from '@app/unraid-api/graph/resolvers/docker/docker.module.js';
import { DockerMutationsResolver } from '@app/unraid-api/graph/resolvers/docker/docker.mutations.resolver.js';
import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
import { DockerOrganizerConfigService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer-config.service.js';
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js';
describe('DockerModule', () => {
it('should compile the module', async () => {
@@ -18,6 +19,8 @@ describe('DockerModule', () => {
})
.overrideProvider(DockerService)
.useValue({ getDockerClient: vi.fn() })
.overrideProvider(DockerOrganizerConfigService)
.useValue({ getConfig: vi.fn() })
.overrideProvider(DockerConfigService)
.useValue({ getConfig: vi.fn() })
.compile();
@@ -61,6 +64,7 @@ describe('DockerModule', () => {
DockerResolver,
{ provide: DockerService, useValue: {} },
{ provide: DockerOrganizerService, useValue: {} },
{ provide: DockerPhpService, useValue: { getContainerUpdateStatuses: vi.fn() } },
],
}).compile();

View File

@@ -1,22 +1,36 @@
import { Module } from '@nestjs/common';
import { JobModule } from '@app/unraid-api/cron/job.module.js';
import { ContainerStatusJob } from '@app/unraid-api/graph/resolvers/docker/container-status.job.js';
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/docker-organizer.service.js';
import { DockerContainerResolver } from '@app/unraid-api/graph/resolvers/docker/docker-container.resolver.js';
import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js';
import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js';
import { DockerMutationsResolver } from '@app/unraid-api/graph/resolvers/docker/docker.mutations.resolver.js';
import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
import { DockerOrganizerConfigService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer-config.service.js';
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js';
@Module({
imports: [JobModule],
providers: [
// Services
DockerService,
DockerConfigService,
DockerOrganizerConfigService,
DockerOrganizerService,
DockerManifestService,
DockerPhpService,
DockerConfigService,
// DockerEventService,
// Jobs
ContainerStatusJob,
// Resolvers
DockerResolver,
DockerMutationsResolver,
DockerContainerResolver,
],
exports: [DockerService],
})

View File

@@ -3,10 +3,11 @@ import { Test } from '@nestjs/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/docker-organizer.service.js';
import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js';
import { ContainerState, DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js';
describe('DockerResolver', () => {
let resolver: DockerResolver;
@@ -26,7 +27,13 @@ describe('DockerResolver', () => {
{
provide: DockerOrganizerService,
useValue: {
getResolvedOrganizer: vi.fn(),
resolveOrganizer: vi.fn(),
},
},
{
provide: DockerPhpService,
useValue: {
getContainerUpdateStatuses: vi.fn(),
},
},
],

View File

@@ -3,21 +3,25 @@ import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/docker-organizer.service.js';
import { UseFeatureFlag } from '@app/unraid-api/decorators/use-feature-flag.decorator.js';
import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js';
import { ExplicitStatusItem } from '@app/unraid-api/graph/resolvers/docker/docker-update-status.model.js';
import {
Docker,
DockerContainer,
DockerNetwork,
} from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js';
import { DEFAULT_ORGANIZER_ROOT_ID } from '@app/unraid-api/organizer/organizer.js';
import { OrganizerV1, ResolvedOrganizerV1 } from '@app/unraid-api/organizer/organizer.model.js';
import { ResolvedOrganizerV1 } from '@app/unraid-api/organizer/organizer.model.js';
@Resolver(() => Docker)
export class DockerResolver {
constructor(
private readonly dockerService: DockerService,
private readonly dockerOrganizerService: DockerOrganizerService
private readonly dockerOrganizerService: DockerOrganizerService,
private readonly dockerPhpService: DockerPhpService
) {}
@UsePermissions({
@@ -53,6 +57,7 @@ export class DockerResolver {
return this.dockerService.getNetworks({ skipCache });
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
@@ -62,6 +67,7 @@ export class DockerResolver {
return this.dockerOrganizerService.resolveOrganizer();
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
@@ -80,6 +86,7 @@ export class DockerResolver {
return this.dockerOrganizerService.resolveOrganizer(organizer);
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
@@ -96,6 +103,7 @@ export class DockerResolver {
return this.dockerOrganizerService.resolveOrganizer(organizer);
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
@@ -108,6 +116,7 @@ export class DockerResolver {
return this.dockerOrganizerService.resolveOrganizer(organizer);
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
@@ -123,4 +132,14 @@ export class DockerResolver {
});
return this.dockerOrganizerService.resolveOrganizer(organizer);
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
})
@ResolveField(() => [ExplicitStatusItem])
public async containerUpdateStatuses() {
return this.dockerPhpService.getContainerUpdateStatuses();
}
}

View File

@@ -0,0 +1,64 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ConfigFilePersister } from '@unraid/shared/services/config-file.js';
import { FeatureFlags } from '@app/consts.js';
import { AppError } from '@app/core/errors/app-error.js';
import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js';
import {
DEFAULT_ORGANIZER_ROOT_ID,
DEFAULT_ORGANIZER_VIEW_ID,
} from '@app/unraid-api/organizer/organizer.js';
import { OrganizerV1 } from '@app/unraid-api/organizer/organizer.model.js';
import { validateOrganizerIntegrity } from '@app/unraid-api/organizer/organizer.validation.js';
@Injectable()
export class DockerOrganizerConfigService extends ConfigFilePersister<OrganizerV1> {
constructor(configService: ConfigService) {
super(configService);
}
enabled(): boolean {
return FeatureFlags.ENABLE_NEXT_DOCKER_RELEASE;
}
configKey(): string {
return 'dockerOrganizer';
}
fileName(): string {
return 'docker.organizer.json';
}
defaultConfig(): OrganizerV1 {
return {
version: 1,
resources: {},
views: {
default: {
id: DEFAULT_ORGANIZER_VIEW_ID,
name: 'Default',
root: DEFAULT_ORGANIZER_ROOT_ID,
entries: {
root: {
type: 'folder',
id: DEFAULT_ORGANIZER_ROOT_ID,
name: 'Root',
children: [],
},
},
},
},
};
}
async validate(config: object): Promise<OrganizerV1> {
const organizer = await validateObject(OrganizerV1, config);
const { isValid, errors } = await validateOrganizerIntegrity(organizer);
if (!isValid) {
throw new AppError(`Docker organizer validation failed: ${JSON.stringify(errors, null, 2)}`);
}
return organizer;
}
}

View File

@@ -2,17 +2,17 @@ import { Test } from '@nestjs/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
import {
containerToResource,
DockerOrganizerService,
} from '@app/unraid-api/graph/resolvers/docker/docker-organizer.service.js';
import {
ContainerPortType,
ContainerState,
DockerContainer,
} from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
import { DockerOrganizerConfigService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer-config.service.js';
import {
containerToResource,
DockerOrganizerService,
} from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js';
import { OrganizerV1 } from '@app/unraid-api/organizer/organizer.model.js';
describe('containerToResource', () => {
@@ -138,7 +138,7 @@ describe('containerToResource', () => {
describe('DockerOrganizerService', () => {
let service: DockerOrganizerService;
let configService: DockerConfigService;
let configService: DockerOrganizerConfigService;
let dockerService: DockerService;
const mockOrganizer: OrganizerV1 = {
@@ -178,7 +178,7 @@ describe('DockerOrganizerService', () => {
providers: [
DockerOrganizerService,
{
provide: DockerConfigService,
provide: DockerOrganizerConfigService,
useValue: {
getConfig: vi.fn().mockImplementation(() => structuredClone(mockOrganizer)),
validate: vi.fn().mockImplementation((config) => Promise.resolve(config)),
@@ -220,7 +220,7 @@ describe('DockerOrganizerService', () => {
}).compile();
service = moduleRef.get<DockerOrganizerService>(DockerOrganizerService);
configService = moduleRef.get<DockerConfigService>(DockerConfigService);
configService = moduleRef.get<DockerOrganizerConfigService>(DockerOrganizerConfigService);
dockerService = moduleRef.get<DockerService>(DockerService);
});

View File

@@ -3,9 +3,9 @@ import { Injectable, Logger } from '@nestjs/common';
import type { ContainerListOptions } from 'dockerode';
import { AppError } from '@app/core/errors/app-error.js';
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
import { DockerOrganizerConfigService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer-config.service.js';
import {
addMissingResourcesToView,
createFolderInView,
@@ -47,7 +47,7 @@ export function containerListToResourcesObject(containers: DockerContainer[]): O
export class DockerOrganizerService {
private readonly logger = new Logger(DockerOrganizerService.name);
constructor(
private readonly dockerConfigService: DockerConfigService,
private readonly dockerConfigService: DockerOrganizerConfigService,
private readonly dockerService: DockerService
) {}

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest';
import type { DockerPushMatch } from '@app/unraid-api/graph/resolvers/docker/utils/docker-push-parser.js';
import { parseDockerPushCalls } from '@app/unraid-api/graph/resolvers/docker/utils/docker-push-parser.js';
describe('parseDockerPushCalls', () => {
it('should extract name and update status from valid docker.push call', () => {
const jsCode = "docker.push({name:'nginx',update:1});";
const result = parseDockerPushCalls(jsCode);
expect(result).toEqual([{ name: 'nginx', updateStatus: 1 }]);
});
it('should handle multiple docker.push calls in same string', () => {
const jsCode = `
docker.push({name:'nginx',update:1});
docker.push({name:'mysql',update:0});
docker.push({name:'redis',update:2});
`;
const result = parseDockerPushCalls(jsCode);
expect(result).toEqual([
{ name: 'nginx', updateStatus: 1 },
{ name: 'mysql', updateStatus: 0 },
{ name: 'redis', updateStatus: 2 },
]);
});
it('should handle docker.push calls with additional properties', () => {
const jsCode =
"docker.push({id:'123',name:'nginx',version:'latest',update:3,status:'running'});";
const result = parseDockerPushCalls(jsCode);
expect(result).toEqual([{ name: 'nginx', updateStatus: 3 }]);
});
it('should handle different property order', () => {
const jsCode = "docker.push({update:2,name:'postgres',id:'456'});";
const result = parseDockerPushCalls(jsCode);
expect(result).toEqual([{ name: 'postgres', updateStatus: 2 }]);
});
it('should handle container names with special characters', () => {
const jsCode = "docker.push({name:'my-app_v2.0',update:1});";
const result = parseDockerPushCalls(jsCode);
expect(result).toEqual([{ name: 'my-app_v2.0', updateStatus: 1 }]);
});
it('should handle whitespace variations', () => {
const jsCode = "docker.push({ name: 'nginx' , update: 1 });";
const result = parseDockerPushCalls(jsCode);
expect(result).toEqual([{ name: 'nginx', updateStatus: 1 }]);
});
it('should return empty array for empty string', () => {
const result = parseDockerPushCalls('');
expect(result).toEqual([]);
});
it('should return empty array when no docker.push calls found', () => {
const jsCode = "console.log('no docker calls here');";
const result = parseDockerPushCalls(jsCode);
expect(result).toEqual([]);
});
it('should ignore malformed docker.push calls', () => {
const jsCode = `
docker.push({name:'valid',update:1});
docker.push({name:'missing-update'});
docker.push({update:2});
docker.push({name:'another-valid',update:0});
`;
const result = parseDockerPushCalls(jsCode);
expect(result).toEqual([
{ name: 'valid', updateStatus: 1 },
{ name: 'another-valid', updateStatus: 0 },
]);
});
it('should handle all valid update status values', () => {
const jsCode = `
docker.push({name:'container0',update:0});
docker.push({name:'container1',update:1});
docker.push({name:'container2',update:2});
docker.push({name:'container3',update:3});
`;
const result = parseDockerPushCalls(jsCode);
expect(result).toEqual([
{ name: 'container0', updateStatus: 0 },
{ name: 'container1', updateStatus: 1 },
{ name: 'container2', updateStatus: 2 },
{ name: 'container3', updateStatus: 3 },
]);
});
it('should handle real-world example with HTML and multiple containers', () => {
const jsCode = `
<div>some html</div>
docker.push({id:'abc123',name:'plex',version:'1.32',update:1,autostart:true});
docker.push({id:'def456',name:'nextcloud',version:'latest',update:0,ports:'80:8080'});
<script>more content</script>
docker.push({id:'ghi789',name:'homeassistant',update:2});
`;
const result = parseDockerPushCalls(jsCode);
expect(result).toEqual([
{ name: 'plex', updateStatus: 1 },
{ name: 'nextcloud', updateStatus: 0 },
{ name: 'homeassistant', updateStatus: 2 },
]);
});
it('should handle nested braces in other properties', () => {
const jsCode = 'docker.push({config:\'{"nested":"value"}\',name:\'test\',update:1});';
const result = parseDockerPushCalls(jsCode);
expect(result).toEqual([{ name: 'test', updateStatus: 1 }]);
});
});

View File

@@ -0,0 +1,24 @@
export interface DockerPushMatch {
name: string;
updateStatus: number;
}
export function parseDockerPushCalls(jsCode: string): DockerPushMatch[] {
const dockerPushRegex = /docker\.push\(\{[^}]*(?:(?:[^{}]|{[^}]*})*)\}\);/g;
const matches: DockerPushMatch[] = [];
for (const match of jsCode.matchAll(dockerPushRegex)) {
const objectContent = match[0];
const nameMatch = objectContent.match(/name\s*:\s*'([^']+)'/);
const updateMatch = objectContent.match(/update\s*:\s*(\d)/);
if (nameMatch && updateMatch) {
const name = nameMatch[1];
const updateStatus = Number(updateMatch[1]);
matches.push({ name, updateStatus });
}
}
return matches;
}

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
import crypto from 'crypto';
import { ChildProcess } from 'node:child_process';
import { mkdir, rm, writeFile } from 'node:fs/promises';
@@ -7,6 +7,7 @@ import { dirname, join } from 'node:path';
import { execa } from 'execa';
import got, { HTTPError } from 'got';
import pRetry from 'p-retry';
import semver from 'semver';
import { sanitizeParams } from '@app/core/log.js';
import { fileExists } from '@app/core/utils/files/file-exists.js';
@@ -25,7 +26,7 @@ import {
import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js';
@Injectable()
export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
export class RCloneApiService implements OnApplicationBootstrap, OnModuleDestroy {
private isInitialized: boolean = false;
private readonly logger = new Logger(RCloneApiService.name);
private rcloneSocketPath: string = '';
@@ -44,7 +45,7 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
return this.isInitialized;
}
async onModuleInit(): Promise<void> {
async onApplicationBootstrap(): Promise<void> {
// RClone startup disabled - early return
if (ENVIRONMENT === 'production') {
this.logger.debug('RClone startup is disabled');
@@ -239,12 +240,41 @@ export class RCloneApiService implements OnModuleInit, OnModuleDestroy {
}
/**
* Checks if the RClone binary is available on the system
* Checks if the RClone binary is available on the system and meets minimum version requirements
*/
private async checkRcloneBinaryExists(): Promise<boolean> {
try {
await execa('rclone', ['version']);
this.logger.debug('RClone binary is available on the system.');
const result = await execa('rclone', ['version']);
const versionOutput = result.stdout.trim();
// Extract raw version string (format: "rclone vX.XX.X" or "rclone vX.XX.X-beta.X")
const versionMatch = versionOutput.match(/rclone v([\d.\-\w]+)/);
if (!versionMatch) {
this.logger.error('Unable to parse RClone version from output');
return false;
}
const rawVersion = versionMatch[1];
// Use semver.coerce to get base semver from prerelease versions
const coercedVersion = semver.coerce(rawVersion);
if (!coercedVersion) {
this.logger.error(`Failed to parse RClone version: raw="${rawVersion}"`);
return false;
}
const minimumVersion = '1.70.0';
if (!semver.gte(coercedVersion, minimumVersion)) {
this.logger.error(
`RClone version ${rawVersion} (coerced: ${coercedVersion}) is too old. Minimum required version is ${minimumVersion}`
);
return false;
}
this.logger.debug(
`RClone binary is available on the system (version ${rawVersion}, coerced: ${coercedVersion}).`
);
return true;
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {

View File

@@ -0,0 +1,350 @@
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

@@ -4,6 +4,7 @@ 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 {
@@ -31,6 +32,8 @@ export class RestService {
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) {
@@ -41,16 +44,62 @@ export class RestService {
const logPathExists = Boolean(await stat(logPath).catch(() => null));
if (logPathExists) {
try {
await execa('tar', ['-czf', zipToWrite, logPath]);
// 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');
throw new Error(
'Failed to create log zip - tar file not found after successful command'
);
}
} catch (error) {
throw new Error('Failed to create logs');
// 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');

View File

@@ -1,6 +1,9 @@
import { readFile } from 'node:fs/promises';
import { FileModification } from '@app/unraid-api/unraid-file-modifier/file-modification.js';
import {
FileModification,
ShouldApplyWithReason,
} from '@app/unraid-api/unraid-file-modifier/file-modification.js';
export default class DisplaySettingsModification extends FileModification {
id: string = 'display-settings';
@@ -34,4 +37,15 @@ export default class DisplaySettingsModification extends FileModification {
return this.createPatchWithDiff(overridePath ?? this.filePath, fileContent, newContent);
}
async shouldApply(): Promise<ShouldApplyWithReason> {
const superShouldApply = await super.shouldApply();
if (!superShouldApply.shouldApply) {
return superShouldApply;
}
return {
shouldApply: true,
reason: 'Display settings modification needed for Unraid version <= 7.2.0-beta.2.3',
};
}
}

View File

@@ -0,0 +1,28 @@
import { ForbiddenException } from '@nestjs/common';
/**
* Checks if a feature flag is enabled and throws an exception if disabled.
* Use this at the beginning of resolver methods for immediate feature flag checks.
*
* @example
* ```typescript
* @ResolveField(() => String)
* async organizer() {
* checkFeatureFlag(FeatureFlags, 'ENABLE_NEXT_DOCKER_RELEASE');
* return this.dockerOrganizerService.resolveOrganizer();
* }
* ```
*
* @param flags - The feature flag object containing boolean/truthy values
* @param key - The key within the feature flag object to check
* @throws ForbiddenException if the feature flag is disabled
*/
export function checkFeatureFlag<T extends Record<string, any>>(flags: T, key: keyof T): void {
const isEnabled = Boolean(flags[key]);
if (!isEnabled) {
throw new ForbiddenException(
`Feature "${String(key)}" is currently disabled. This functionality is not available at this time.`
);
}
}

View File

@@ -1,3 +1,6 @@
import { existsSync, readFileSync } from 'node:fs';
import { basename, join } from 'node:path';
import type { ViteUserConfig } from 'vitest/config';
import { viteCommonjs } from '@originjs/vite-plugin-commonjs';
import nodeResolve from '@rollup/plugin-node-resolve';
@@ -70,6 +73,29 @@ export default defineConfig(({ mode }): ViteUserConfig => {
},
},
}),
// Copy PHP files to assets directory
{
name: 'copy-php-files',
buildStart() {
const phpFiles = ['src/core/utils/plugins/wrapper.php'];
phpFiles.forEach((file) => this.addWatchFile(file));
},
async generateBundle() {
const phpFiles = ['src/core/utils/plugins/wrapper.php'];
phpFiles.forEach((file) => {
if (!existsSync(file)) {
this.warn(`[copy-php-files] PHP file ${file} does not exist`);
return;
}
const content = readFileSync(file);
this.emitFile({
type: 'asset',
fileName: join('assets', basename(file)),
source: content,
});
});
},
},
],
define: {
// Allows vite to preserve process.env variables and not hardcode them

View File

@@ -1,10 +1,10 @@
{
"name": "unraid-monorepo",
"private": true,
"version": "4.20.1",
"version": "4.22.1",
"scripts": {
"build": "pnpm -r build",
"build:watch": " pnpm -r --parallel build:watch",
"build:watch": "pnpm -r --parallel --filter '!@unraid/ui' build:watch",
"codegen": "pnpm -r codegen",
"dev": "pnpm -r dev",
"unraid:deploy": "pnpm -r unraid:deploy",
@@ -63,8 +63,17 @@
"pre-commit": "pnpm lint-staged"
},
"lint-staged": {
"*.{js,jsx,ts,tsx,vue}": [
"pnpm lint:fix"
"api/**/*.{js,ts}": [
"pnpm --filter api lint:fix"
],
"web/**/*.{js,ts,tsx,vue}": [
"pnpm --filter web lint:fix"
],
"unraid-ui/**/*.{js,ts,tsx,vue}": [
"pnpm --filter @unraid/ui lint:fix"
],
"packages/**/*.{js,ts}": [
"eslint --fix"
]
},
"packageManager": "pnpm@10.15.0"

View File

@@ -25,8 +25,8 @@
"description": "Unraid Connect plugin for Unraid API",
"devDependencies": {
"@apollo/client": "3.14.0",
"@faker-js/faker": "9.9.0",
"@graphql-codegen/cli": "5.0.7",
"@faker-js/faker": "10.0.0",
"@graphql-codegen/cli": "6.0.0",
"@graphql-typed-document-node/core": "3.2.0",
"@ianvs/prettier-plugin-sort-imports": "4.6.3",
"@jsonforms/core": "3.6.0",
@@ -43,7 +43,7 @@
"@types/lodash-es": "4.17.12",
"@types/node": "22.18.0",
"@types/ws": "8.18.1",
"camelcase-keys": "9.1.3",
"camelcase-keys": "10.0.0",
"class-transformer": "0.5.1",
"class-validator": "0.14.2",
"execa": "9.6.0",
@@ -60,7 +60,7 @@
"prettier": "3.6.2",
"rimraf": "6.0.1",
"rxjs": "7.8.2",
"type-fest": "4.41.0",
"type-fest": "5.0.0",
"typescript": "5.9.2",
"undici": "7.15.0",
"vitest": "3.2.4",
@@ -84,7 +84,7 @@
"@nestjs/graphql": "13.1.0",
"@nestjs/schedule": "6.0.0",
"@runonflux/nat-upnp": "1.0.2",
"camelcase-keys": "9.1.3",
"camelcase-keys": "10.0.0",
"class-transformer": "0.5.1",
"class-validator": "0.14.2",
"execa": "9.6.0",

View File

@@ -46,7 +46,7 @@
"nest-authz": "2.17.0",
"pify": "6.1.0",
"rimraf": "6.0.1",
"type-fest": "4.41.0",
"type-fest": "5.0.0",
"typescript": "5.9.2",
"vitest": "3.2.4",
"ws": "8.18.3"

View File

@@ -0,0 +1,295 @@
import { describe, it, expect, vi } from 'vitest';
import { AsyncMutex } from '../processing.js';
describe('AsyncMutex', () => {
describe('constructor-based operation', () => {
it('should execute the default operation when do() is called without parameters', async () => {
const mockOperation = vi.fn().mockResolvedValue('result');
const mutex = new AsyncMutex(mockOperation);
const result = await mutex.do();
expect(result).toBe('result');
expect(mockOperation).toHaveBeenCalledTimes(1);
});
it('should return the same promise when multiple calls are made concurrently', async () => {
let resolveOperation: (value: string) => void;
const operationPromise = new Promise<string>((resolve) => {
resolveOperation = resolve;
});
const mockOperation = vi.fn().mockReturnValue(operationPromise);
const mutex = new AsyncMutex(mockOperation);
const promise1 = mutex.do();
const promise2 = mutex.do();
const promise3 = mutex.do();
expect(mockOperation).toHaveBeenCalledTimes(1);
expect(promise1).toBe(promise2);
expect(promise2).toBe(promise3);
resolveOperation!('result');
const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
expect(result1).toBe('result');
expect(result2).toBe('result');
expect(result3).toBe('result');
});
it('should allow new operations after the first completes', async () => {
const mockOperation = vi.fn()
.mockResolvedValueOnce('first')
.mockResolvedValueOnce('second');
const mutex = new AsyncMutex(mockOperation);
const result1 = await mutex.do();
expect(result1).toBe('first');
expect(mockOperation).toHaveBeenCalledTimes(1);
const result2 = await mutex.do();
expect(result2).toBe('second');
expect(mockOperation).toHaveBeenCalledTimes(2);
});
it('should handle errors in the default operation', async () => {
const error = new Error('Operation failed');
const mockOperation = vi.fn().mockRejectedValue(error);
const mutex = new AsyncMutex(mockOperation);
await expect(mutex.do()).rejects.toThrow(error);
expect(mockOperation).toHaveBeenCalledTimes(1);
const secondOperation = vi.fn().mockResolvedValue('success');
const mutex2 = new AsyncMutex(secondOperation);
const result = await mutex2.do();
expect(result).toBe('success');
});
});
describe('per-call operation', () => {
it('should execute the provided operation', async () => {
const mutex = new AsyncMutex<number>();
const mockOperation = vi.fn().mockResolvedValue(42);
const result = await mutex.do(mockOperation);
expect(result).toBe(42);
expect(mockOperation).toHaveBeenCalledTimes(1);
});
it('should return the same promise for concurrent calls with same operation type', async () => {
const mutex = new AsyncMutex();
let resolveOperation: (value: string) => void;
const operationPromise = new Promise<string>((resolve) => {
resolveOperation = resolve;
});
const mockOperation = vi.fn().mockReturnValue(operationPromise);
const promise1 = mutex.do(mockOperation);
const promise2 = mutex.do(mockOperation);
const promise3 = mutex.do(mockOperation);
expect(mockOperation).toHaveBeenCalledTimes(1);
expect(promise1).toBe(promise2);
expect(promise2).toBe(promise3);
resolveOperation!('shared-result');
const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
expect(result1).toBe('shared-result');
expect(result2).toBe('shared-result');
expect(result3).toBe('shared-result');
});
it('should allow different operations with different types', async () => {
const mutex = new AsyncMutex();
const stringOp = vi.fn().mockResolvedValue('string-result');
const numberOp = vi.fn().mockResolvedValue(123);
const stringResult = await mutex.do(stringOp);
const numberResult = await mutex.do(numberOp);
expect(stringResult).toBe('string-result');
expect(numberResult).toBe(123);
expect(stringOp).toHaveBeenCalledTimes(1);
expect(numberOp).toHaveBeenCalledTimes(1);
});
it('should handle errors in per-call operations', async () => {
const mutex = new AsyncMutex();
const error = new Error('Operation failed');
const failingOp = vi.fn().mockRejectedValue(error);
await expect(mutex.do(failingOp)).rejects.toThrow(error);
expect(failingOp).toHaveBeenCalledTimes(1);
const successOp = vi.fn().mockResolvedValue('success');
const result = await mutex.do(successOp);
expect(result).toBe('success');
expect(successOp).toHaveBeenCalledTimes(1);
});
it('should throw an error when no operation is provided and no default is set', async () => {
const mutex = new AsyncMutex();
await expect(mutex.do()).rejects.toThrow('No operation provided and no default operation set');
});
});
describe('mixed usage', () => {
it('should allow overriding default operation with per-call operation', async () => {
const defaultOp = vi.fn().mockResolvedValue('default');
const mutex = new AsyncMutex(defaultOp);
const customOp = vi.fn().mockResolvedValue('custom');
const customResult = await mutex.do(customOp);
expect(customResult).toBe('custom');
expect(customOp).toHaveBeenCalledTimes(1);
expect(defaultOp).not.toHaveBeenCalled();
const defaultResult = await mutex.do();
expect(defaultResult).toBe('default');
expect(defaultOp).toHaveBeenCalledTimes(1);
});
it('should share lock between default and custom operations', async () => {
let resolveDefault: (value: string) => void;
const defaultPromise = new Promise<string>((resolve) => {
resolveDefault = resolve;
});
const defaultOp = vi.fn().mockReturnValue(defaultPromise);
const mutex = new AsyncMutex(defaultOp);
const customOp = vi.fn().mockResolvedValue('custom');
const defaultCall = mutex.do();
const customCall = mutex.do(customOp);
expect(defaultOp).toHaveBeenCalledTimes(1);
expect(customOp).not.toHaveBeenCalled();
expect(customCall).toBe(defaultCall);
resolveDefault!('default');
const [defaultResult, customResult] = await Promise.all([defaultCall, customCall]);
expect(defaultResult).toBe('default');
expect(customResult).toBe('default');
});
});
describe('timing and concurrency', () => {
it('should handle sequential slow operations', async () => {
const mutex = new AsyncMutex();
let callCount = 0;
const slowOp = vi.fn().mockImplementation(() => {
return new Promise((resolve) => {
const currentCall = ++callCount;
setTimeout(() => resolve(`result-${currentCall}`), 100);
});
});
const result1 = await mutex.do(slowOp);
expect(result1).toBe('result-1');
const result2 = await mutex.do(slowOp);
expect(result2).toBe('result-2');
expect(slowOp).toHaveBeenCalledTimes(2);
});
it('should deduplicate concurrent slow operations', async () => {
const mutex = new AsyncMutex();
let resolveOperation: (value: string) => void;
const slowOp = vi.fn().mockImplementation(() => {
return new Promise<string>((resolve) => {
resolveOperation = resolve;
});
});
const promises = [
mutex.do(slowOp),
mutex.do(slowOp),
mutex.do(slowOp),
mutex.do(slowOp),
mutex.do(slowOp)
];
expect(slowOp).toHaveBeenCalledTimes(1);
resolveOperation!('shared-slow-result');
const results = await Promise.all(promises);
expect(results).toEqual([
'shared-slow-result',
'shared-slow-result',
'shared-slow-result',
'shared-slow-result',
'shared-slow-result'
]);
});
it('should properly clean up after operation completes', async () => {
const mutex = new AsyncMutex();
const op1 = vi.fn().mockResolvedValue('first');
const op2 = vi.fn().mockResolvedValue('second');
await mutex.do(op1);
expect(op1).toHaveBeenCalledTimes(1);
await mutex.do(op2);
expect(op2).toHaveBeenCalledTimes(1);
});
it('should handle multiple rapid sequences of operations', async () => {
const mutex = new AsyncMutex();
const results: string[] = [];
for (let i = 0; i < 5; i++) {
const op = vi.fn().mockResolvedValue(`result-${i}`);
const result = await mutex.do(op);
results.push(result as string);
}
expect(results).toEqual(['result-0', 'result-1', 'result-2', 'result-3', 'result-4']);
});
});
describe('edge cases', () => {
it('should handle operations that return undefined', async () => {
const mutex = new AsyncMutex<undefined>();
const op = vi.fn().mockResolvedValue(undefined);
const result = await mutex.do(op);
expect(result).toBeUndefined();
expect(op).toHaveBeenCalledTimes(1);
});
it('should handle operations that return null', async () => {
const mutex = new AsyncMutex<null>();
const op = vi.fn().mockResolvedValue(null);
const result = await mutex.do(op);
expect(result).toBeNull();
expect(op).toHaveBeenCalledTimes(1);
});
it('should handle nested operations correctly', async () => {
const mutex = new AsyncMutex<string>();
const innerOp = vi.fn().mockResolvedValue('inner');
const outerOp = vi.fn().mockImplementation(async () => {
return 'outer';
});
const result = await mutex.do(outerOp);
expect(result).toBe('outer');
expect(outerOp).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -31,3 +31,119 @@ export function makeSafeRunner(onError: (error: unknown) => void) {
}
};
}
type AsyncOperation<T> = () => Promise<T>;
/**
* A mutex for asynchronous operations that ensures only one operation runs at a time.
*
* When multiple callers attempt to execute operations simultaneously, they will all
* receive the same promise from the currently running operation, effectively deduplicating
* concurrent calls. This is useful for expensive operations like API calls, file operations,
* or database queries that should not be executed multiple times concurrently.
*
* @template T - The default return type for operations when using a default operation
*
* @example
* // Basic usage with explicit operations
* const mutex = new AsyncMutex();
*
* // Multiple concurrent calls will deduplicate
* const [result1, result2, result3] = await Promise.all([
* mutex.do(() => fetch('/api/data')),
* mutex.do(() => fetch('/api/data')), // Same request, will get same promise
* mutex.do(() => fetch('/api/data')) // Same request, will get same promise
* ]);
* // Only one fetch actually happens
*
* @example
* // Usage with a default operation
* const dataLoader = new AsyncMutex(() =>
* fetch('/api/expensive-data').then(res => res.json())
* );
*
* const data1 = await dataLoader.do(); // Executes the fetch
* const data2 = await dataLoader.do(); // If first promise is finished, a new fetch is executed
*/
export class AsyncMutex<T = unknown> {
private currentOperation: Promise<T> | null = null;
private defaultOperation?: AsyncOperation<T>;
/**
* Creates a new AsyncMutex instance.
*
* @param operation - Optional default operation to execute when calling `do()` without arguments.
* This is useful when you have a specific operation that should be deduplicated.
*
* @example
* // Without default operation (shared mutex)
* const mutex = new AsyncMutex();
* const promise1 = mutex.do(() => someAsyncWork());
* const promise2 = mutex.do(() => someOtherAsyncWork());
*
* // Both promises will be the same
* expect(await promise1).toBe(await promise2);
*
* // After the first operation completes, new operations can run
* await promise1;
* const newPromise = mutex.do(() => someOtherAsyncWork()); // This will execute
*
* @example
* // With default operation (deduplicating a specific operation)
* const dataMutex = new AsyncMutex(() => loadExpensiveData());
* await dataMutex.do(); // Executes loadExpensiveData()
*/
constructor(operation?: AsyncOperation<T>) {
this.defaultOperation = operation;
}
/**
* Executes the provided operation, ensuring only one runs at a time.
*
* If an operation is already running, all subsequent calls will receive
* the same promise from the currently running operation. This effectively
* deduplicates concurrent calls to the same expensive operation.
*
* @param operation - Optional operation to execute. If not provided, uses the default operation.
* @returns Promise that resolves with the result of the operation
* @throws Error if no operation is provided and no default operation was set
*
* @example
* const mutex = new AsyncMutex();
*
* // These will all return the same promise
* const promise1 = mutex.do(() => fetch('/api/data'));
* const promise2 = mutex.do(() => fetch('/api/other')); // Still gets first promise!
* const promise3 = mutex.do(() => fetch('/api/another')); // Still gets first promise!
*
* // After the first operation completes, new operations can run
* await promise1;
* const newPromise = mutex.do(() => fetch('/api/new')); // This will execute
*/
do(operation?: AsyncOperation<T>): Promise<T> {
if (this.currentOperation) {
return this.currentOperation;
}
const op = operation ?? this.defaultOperation;
if (!op) {
return Promise.reject(
new Error("No operation provided and no default operation set")
);
}
const safeOp = () => {
try {
return op();
} catch (error) {
return Promise.reject(error);
}
};
const promise = safeOp().finally(() => {
if (this.currentOperation === promise) {
this.currentOperation = null;
}
});
this.currentOperation = promise;
return promise;
}
}

View File

@@ -0,0 +1,181 @@
import { describe, it, expect, beforeAll } from "vitest";
import { execSync } from "child_process";
import { getStagingChangelogFromGit } from "./changelog.js";
describe.sequential("getStagingChangelogFromGit", () => {
let currentCommitMessage: string | null = null;
beforeAll(() => {
// Get the current commit message to validate it appears in changelog
try {
currentCommitMessage = execSync('git log -1 --pretty=%s', { encoding: 'utf8' }).trim();
} catch (e) {
// Ignore if we can't get commit
}
});
it("should generate changelog header with version", { timeout: 20000 }, async () => {
const result = await getStagingChangelogFromGit({
pluginVersion: "99.99.99",
tag: undefined as any,
});
expect(result).toBeDefined();
expect(typeof result).toBe("string");
// Should contain version header
expect(result).toContain("99.99.99");
// Should have markdown header formatting
expect(result).toMatch(/##\s+/);
});
it("should generate changelog with tag parameter", { timeout: 20000 }, async () => {
// When tag is provided, it should generate changelog with tag in header
const result = await getStagingChangelogFromGit({
pluginVersion: "99.99.99",
tag: "test-tag-99",
});
expect(result).toBeDefined();
expect(typeof result).toBe("string");
expect(result).toContain("test-tag-99");
// Should have a version header
expect(result).toMatch(/##\s+/);
// IMPORTANT: Verify that actual commits are included in the changelog
// This ensures the gitRawCommitsOpts is working correctly
// The changelog should include commits if there are any between origin/main and HEAD
// We check for common changelog patterns that indicate actual content
if (result.length > 100) {
// If we have a substantial changelog, it should contain commit information
expect(
result.includes("### Features") ||
result.includes("### Bug Fixes") ||
result.includes("### ") ||
result.includes("* ") // Commit entries typically start with asterisk
).toBe(true);
}
});
it("should handle error gracefully and return tag", { timeout: 20000 }, async () => {
// The function catches errors and returns the tag
// An empty version might not cause an error, so let's just verify
// the function completes without throwing
const result = await getStagingChangelogFromGit({
pluginVersion: "test-version",
tag: "fallback-tag",
});
expect(result).toBeDefined();
expect(typeof result).toBe("string");
// Should either return a changelog or the fallback tag
expect(result.length).toBeGreaterThan(0);
});
it("should use conventional-changelog v7 API correctly", { timeout: 20000 }, async () => {
// This test validates that the v7 API is being called correctly
// by checking that the function executes without throwing
let error: any = null;
try {
await getStagingChangelogFromGit({
pluginVersion: "99.99.99",
tag: undefined as any,
});
} catch (e) {
error = e;
}
// The v7 API should work without errors
expect(error).toBeNull();
});
it("should validate changelog structure", { timeout: 20000 }, async () => {
// Create a changelog with high version number to avoid conflicts
const result = await getStagingChangelogFromGit({
pluginVersion: "999.0.0",
tag: "v999-test",
});
expect(result).toBeDefined();
expect(typeof result).toBe("string");
// Verify basic markdown structure
if (result.length > 50) {
// Should have tag in header when tag is provided
expect(result).toMatch(/##\s+\[?v999-test/);
// Should be valid markdown with proper line breaks
expect(result).toMatch(/\n/);
}
});
it("should include actual commits when using gitRawCommitsOpts with tag", { timeout: 20000 }, async () => {
// This test ensures that gitRawCommitsOpts is working correctly
// and actually fetching commits between origin/main and HEAD
const result = await getStagingChangelogFromGit({
pluginVersion: "99.99.99",
tag: "CI-TEST",
});
expect(result).toBeDefined();
expect(typeof result).toBe("string");
// The header should contain the tag
expect(result).toContain("CI-TEST");
// Critical: The changelog should NOT be just the tag (error fallback)
expect(result).not.toBe("CI-TEST");
// The changelog should have a proper markdown header
expect(result).toMatch(/^##\s+/);
// Check if we're in a git repo with commits ahead of the base branch
let commitCount = 0;
try {
// Try to detect the base branch (same logic as in changelog.ts)
let baseBranch = "origin/main";
try {
const originHead = execSync("git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null", {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"]
}).trim();
if (originHead) {
baseBranch = originHead.replace("refs/remotes/", "");
}
} catch {
// Try common branches
const branches = ["origin/main", "origin/master", "origin/develop"];
for (const branch of branches) {
try {
execSync(`git rev-parse --verify ${branch} 2>/dev/null`, { stdio: "ignore" });
baseBranch = branch;
break;
} catch {
// Continue to next branch
}
}
}
commitCount = parseInt(execSync(`git rev-list --count ${baseBranch}..HEAD`, { encoding: "utf8" }).trim());
} catch {
// If we can't determine, we'll check for minimal content
}
// If there are commits on this branch, the changelog MUST include them
if (commitCount > 0) {
// The changelog must be more than just a header
// A minimal header is "## CI-TEST (2025-09-12)\n\n" which is ~30 chars
expect(result.length).toBeGreaterThan(50);
// Should have actual commit content
const hasCommitContent =
result.includes("### ") || // Section headers like ### Features
result.includes("* ") || // Commit bullet points
result.includes("- "); // Alternative bullet style
if (!hasCommitContent) {
throw new Error(`Expected changelog to contain commits but got only: ${result.substring(0, 100)}...`);
}
expect(hasCommitContent).toBe(true);
}
});
});

View File

@@ -1,40 +1,167 @@
import conventionalChangelog from "conventional-changelog";
import { ConventionalChangelog } from "conventional-changelog";
import { execSync } from "child_process";
import { PluginEnv } from "../cli/setup-plugin-environment";
import { PluginEnv } from "../cli/setup-plugin-environment.js";
/**
* Detects the base branch and finds the merge base for PR changelog generation
* Returns the merge-base commit to only show commits from the current PR
*/
function getMergeBase(): string | null {
try {
// First, find the base branch
let baseBranch: string | null = null;
// Try to get the default branch from origin/HEAD
try {
const originHead = execSync("git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null", {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"]
}).trim();
if (originHead) {
baseBranch = originHead.replace("refs/remotes/", "");
}
} catch {
// origin/HEAD not set, continue to next strategy
}
// Try common default branch names if origin/HEAD didn't work
if (!baseBranch) {
const commonBranches = ["origin/main", "origin/master", "origin/develop"];
for (const branch of commonBranches) {
try {
execSync(`git rev-parse --verify ${branch} 2>/dev/null`, { stdio: "ignore" });
baseBranch = branch;
break;
} catch {
// Branch doesn't exist, try next
}
}
}
if (!baseBranch) {
return null;
}
// Find the merge-base between the current branch and the base branch
// This gives us the commit where the PR branch diverged from main
try {
const mergeBase = execSync(`git merge-base ${baseBranch} HEAD`, {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"]
}).trim();
return mergeBase;
} catch {
// If merge-base fails, fall back to the base branch itself
return baseBranch;
}
} catch {
// Git command failed entirely, return null
return null;
}
}
/**
* Generate a simple changelog for PR builds
*/
function generatePRChangelog(tag: string, mergeBase: string): string | null {
try {
// Get commits from this PR only with conventional commit parsing
const commits = execSync(
`git log ${mergeBase}..HEAD --pretty=format:"%s|%h" --reverse`,
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
).trim();
if (!commits) {
return null;
}
const lines = commits.split('\n').filter(Boolean);
const features: string[] = [];
const fixes: string[] = [];
const other: string[] = [];
for (const line of lines) {
const [message, hash] = line.split('|');
const formatted = `* ${message} (${hash})`;
if (message.startsWith('feat')) {
features.push(formatted);
} else if (message.startsWith('fix')) {
fixes.push(formatted);
} else {
other.push(formatted);
}
}
let changelog = `## [${tag}](https://github.com/unraid/api/${tag})\n\n`;
if (features.length > 0) {
changelog += `### Features\n\n${features.join('\n')}\n\n`;
}
if (fixes.length > 0) {
changelog += `### Bug Fixes\n\n${fixes.join('\n')}\n\n`;
}
if (other.length > 0) {
changelog += `### Other Changes\n\n${other.join('\n')}\n\n`;
}
return changelog;
} catch {
return null;
}
}
export const getStagingChangelogFromGit = async ({
pluginVersion,
tag,
}: Pick<PluginEnv, "pluginVersion" | "tag">): Promise<string> => {
try {
const changelogStream = conventionalChangelog(
{
preset: "conventionalcommits",
},
{
version: pluginVersion,
},
tag
? {
from: "origin/main",
to: "HEAD",
}
: {},
undefined,
tag
? {
headerPartial: `## [${tag}](https://github.com/unraid/api/${tag})\n\n`,
}
: undefined
);
// For PR builds with a tag, try to generate a simple PR-specific changelog
if (tag) {
const mergeBase = getMergeBase();
if (mergeBase) {
const prChangelog = generatePRChangelog(tag, mergeBase);
if (prChangelog) {
return prChangelog;
}
}
}
// Fall back to conventional-changelog for non-PR builds or if PR detection fails
const options: any = {
releaseCount: 1,
};
if (tag) {
options.writerOpts = {
headerPartial: `## [${tag}](https://github.com/unraid/api/${tag})\n\n`,
};
}
const generator = new ConventionalChangelog()
.loadPreset("conventionalcommits")
.context({
version: tag || pluginVersion,
...(tag && {
linkCompare: false,
}),
})
.options(options);
let changelog = "";
for await (const chunk of changelogStream) {
for await (const chunk of generator.write()) {
changelog += chunk;
}
// Encode HTML entities using the 'he' library
return changelog ?? "";
return changelog || "";
} catch (err) {
console.log('Non-fatal error: Failed to get changelog from git:', err);
return tag;
// Return a properly formatted fallback with markdown header
if (tag) {
return `## [${tag}](https://github.com/unraid/api/${tag})\n\n`;
}
return `## ${pluginVersion}\n\n`;
}
};
};

View File

@@ -1,10 +1,11 @@
{
"name": "@unraid/connect-plugin",
"version": "4.20.1",
"version": "4.22.1",
"private": true,
"dependencies": {
"commander": "14.0.0",
"conventional-changelog": "6.0.0",
"conventional-changelog": "7.1.1",
"conventional-changelog-conventionalcommits": "9.1.0",
"date-fns": "4.1.0",
"glob": "11.0.3",
"html-sloppy-escaper": "0.1.0",
@@ -32,8 +33,9 @@
"env:validate": "test -f .env || (echo 'Error: .env file missing. Run npm run env:init first' && exit 1)",
"env:clean": "rm -f .env",
"// Testing": "",
"test": "vitest && pnpm run test:extractor",
"test:extractor": "bash ./tests/test-extractor.sh"
"test": "vitest && pnpm run test:extractor && pnpm run test:shell-detection",
"test:extractor": "bash ./tests/test-extractor.sh",
"test:shell-detection": "bash ./tests/test-shell-detection.sh"
},
"devDependencies": {
"http-server": "14.1.1",

View File

@@ -2,6 +2,41 @@
# Unraid API Installation Verification Script
# Checks that critical files are installed correctly
# Function to check for non-bash shells
check_shell() {
# This script runs with #!/bin/bash shebang
# On Unraid, users may configure bash to load other shells through .bashrc
# We need to check if the interpreter running this script is actually bash
# Use readlink on /proc to find the actual interpreter, not the script name
local current_shell
# Get the actual interpreter from /proc
if [ -e "/proc/$$/exe" ]; then
current_shell=$(readlink "/proc/$$/exe")
else
# Fallback to checking the current process if /proc isn't available
# Note: This may return the script name on some systems
current_shell=$(ps -o comm= -p $$)
fi
# Remove any path and get just the shell name
current_shell=$(basename "$current_shell")
if [[ "$current_shell" != "bash" ]]; then
echo "Unsupported shell detected: $current_shell" >&2
echo "Unraid scripts require bash but your system is configured to use $current_shell for scripts." >&2
echo "This can cause infinite loops or unexpected behavior when Unraid scripts execute." >&2
echo "Please configure $current_shell to only activate for interactive shells." >&2
echo "Add this check to your ~/.bashrc or /etc/profile before starting $current_shell:" >&2
echo " [[ \$- == *i* ]] && exec $current_shell" >&2
echo "This ensures $current_shell only starts for interactive sessions, not scripts." >&2
exit 1
fi
}
# Run shell check first
check_shell
echo "Performing comprehensive installation verification..."
# Define critical files to check (POSIX-compliant, no arrays)

View File

@@ -0,0 +1,159 @@
#!/bin/bash
# Test script for shell detection logic in verify_install.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VERIFY_SCRIPT="$SCRIPT_DIR/../source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Test counter
TESTS_RUN=0
TESTS_PASSED=0
# Helper function to run a test
run_test() {
local test_name="$1"
local test_cmd="$2"
local expected_result="$3"
TESTS_RUN=$((TESTS_RUN + 1))
echo -n "Testing: $test_name ... "
# Run the test and capture exit code
set +e
output=$($test_cmd 2>&1)
result=$?
set -e
if [ "$result" -eq "$expected_result" ]; then
echo -e "${GREEN}PASS${NC}"
TESTS_PASSED=$((TESTS_PASSED + 1))
else
echo -e "${RED}FAIL${NC}"
echo " Expected exit code: $expected_result, Got: $result"
echo " Output: $output"
fi
}
# Extract just the check_shell function from verify_install.sh
extract_check_shell() {
cat << 'EOF'
#!/bin/bash
check_shell() {
# This script runs with #!/bin/bash shebang
# On Unraid, users may configure bash to load other shells through .bashrc
# We need to check if the interpreter running this script is actually bash
# Use readlink on /proc to find the actual interpreter, not the script name
local current_shell
# Get the actual interpreter from /proc
if [ -e "/proc/$$/exe" ]; then
current_shell=$(readlink "/proc/$$/exe")
else
# Fallback to checking the current process if /proc isn't available
# Note: This may return the script name on some systems
current_shell=$(ps -o comm= -p $$)
fi
# Remove any path and get just the shell name
current_shell=$(basename "$current_shell")
if [[ "$current_shell" != "bash" ]]; then
echo "Unsupported shell detected: $current_shell" >&2
echo "Unraid scripts require bash but your system is configured to use $current_shell for scripts." >&2
echo "This can cause infinite loops or unexpected behavior when Unraid scripts execute." >&2
echo "Please configure $current_shell to only activate for interactive shells." >&2
echo "Add this check to your ~/.bashrc or /etc/profile before starting $current_shell:" >&2
echo " [[ \$- == *i* ]] && exec $current_shell" >&2
echo "This ensures $current_shell only starts for interactive sessions, not scripts." >&2
exit 1
fi
}
check_shell
EOF
}
echo "=== Shell Detection Tests ==="
echo
# Test 1: Running with bash should succeed
echo "Test 1: Direct bash execution"
TEMP_SCRIPT=$(mktemp)
extract_check_shell > "$TEMP_SCRIPT"
chmod +x "$TEMP_SCRIPT"
run_test "Bash interpreter (should pass)" "bash $TEMP_SCRIPT" 0
rm -f "$TEMP_SCRIPT"
# Test 2: Check that the actual verify_install.sh script works with bash
echo "Test 2: Verify install script with bash"
if [ -f "$VERIFY_SCRIPT" ]; then
# Create a modified version that only runs check_shell
TEMP_VERIFY=$(mktemp)
sed -n '1,/^check_shell$/p' "$VERIFY_SCRIPT" > "$TEMP_VERIFY"
echo "exit 0" >> "$TEMP_VERIFY"
chmod +x "$TEMP_VERIFY"
run_test "Verify install script shell check" "bash $TEMP_VERIFY" 0
rm -f "$TEMP_VERIFY"
else
echo -e "${YELLOW}SKIP${NC} - verify_install.sh not found"
fi
# Test 3: Simulate non-bash shell (if available)
echo "Test 3: Non-bash shell simulation"
if command -v sh >/dev/null 2>&1 && [ "$(readlink -f "$(command -v sh)")" != "$(readlink -f "$(command -v bash)")" ]; then
TEMP_SCRIPT=$(mktemp)
# Create a test that will fail if sh is detected
cat << 'EOF' > "$TEMP_SCRIPT"
#!/bin/sh
# This simulates what would happen if a non-bash shell was detected
current_shell=$(basename "$(readlink -f /proc/$$/exe 2>/dev/null || echo sh)")
if [ "$current_shell" != "bash" ]; then
echo "Detected non-bash shell: $current_shell" >&2
exit 1
fi
exit 0
EOF
chmod +x "$TEMP_SCRIPT"
run_test "Non-bash shell detection" "sh $TEMP_SCRIPT" 1
rm -f "$TEMP_SCRIPT"
else
echo -e "${YELLOW}SKIP${NC} - sh not available or is symlinked to bash"
fi
# Test 4: Check /proc availability (informational only, not a failure)
echo "Test 4: /proc filesystem check"
if [ -e "/proc/$$/exe" ]; then
echo -e "${GREEN}INFO${NC} - /proc filesystem is available"
else
echo -e "${YELLOW}INFO${NC} - /proc filesystem not available, fallback to ps will be used"
fi
# Test 5: Verify the script name is not detected as shell
echo "Test 5: Script name not detected as shell"
TEMP_SCRIPT=$(mktemp -t verify_install.XXXXXX)
extract_check_shell > "$TEMP_SCRIPT"
chmod +x "$TEMP_SCRIPT"
# This should pass because it's still bash, even though the script is named verify_install
run_test "Script named verify_install (should still pass)" "bash $TEMP_SCRIPT" 0
rm -f "$TEMP_SCRIPT"
echo
echo "=== Test Summary ==="
echo "Tests run: $TESTS_RUN"
echo "Tests passed: $TESTS_PASSED"
echo "Tests failed: $((TESTS_RUN - TESTS_PASSED))"
if [ "$TESTS_PASSED" -eq "$TESTS_RUN" ]; then
echo -e "${GREEN}All tests passed!${NC}"
exit 0
else
echo -e "${RED}Some tests failed${NC}"
exit 1
fi

4601
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

154
readme.md
View File

@@ -1,5 +1,6 @@
<!-- Adapted from: https://github.com/othneildrew/Best-README-Template -->
<!-- Improved compatibility of back to top link: See: https://github.com/othneildrew/Best-README-Template/pull/73 -->
<a id="readme-top"></a>
<!-- PROJECT SHIELDS -->
@@ -91,9 +92,10 @@
</details>
<!-- ABOUT THE PROJECT -->
## About The Project
<!-- [![Product Name Screen Shot][product-screenshot]](https://unraid.net)
<!-- [![Product Name Screen Shot][product-screenshot]](https://unraid.net)
<p align="right">(<a href="#readme-top">back to top</a>)</p> -->
@@ -108,6 +110,7 @@
<p align="right">(<a href="#readme-top">back to top</a>)</p>
<!-- GETTING STARTED -->
## Getting Started
This section will guide you through the steps necessary to get the monorepo projects running and
@@ -117,13 +120,32 @@ communicating with each other.
Make sure the following software is installed before proceeding.
* Bash
* Docker (for macOS folks, Orbstack works too)
* [Node.js (v22)][Node-url]
* [Just](https://github.com/casey/just) (optional)
* libvirt (macOS folks can run `brew install libvirt`)
* rclone (for development)
* An [Unraid][Unraid-url] server for development
- Bash
- Docker (for macOS folks, Orbstack works too)
- [Node.js (v22)][Node-url]
- [pnpm](https://pnpm.io/) (v9.0+) - Install with `npm install -g pnpm`
- [Just](https://github.com/casey/just) (optional)
- libvirt (macOS folks can run `brew install libvirt`)
- rclone (v1.70+) - **Important:** Version 1.70 or higher is required
- jq - JSON processor for scripts
- An [Unraid][Unraid-url] server for development
#### Ubuntu/WSL Users
For Ubuntu or WSL users, note that the default Ubuntu repositories may have older versions of rclone. You'll need rclone v1.70 or higher, which can be obtained from the [rclone releases page](https://github.com/rclone/rclone/releases).
#### Verify Prerequisites
After installation, verify your dependencies:
```sh
# Verify installations and versions
node --version # Should be v22.x
pnpm --version # Should be v9.0+
rclone version # Should be v1.70+
jq --version # Should be installed
docker --version # Should be installed
```
#### Alternative: Using Nix Flake
@@ -154,25 +176,86 @@ Once you have your key pair, add your public SSH key to your Unraid server:
cd api
```
If using Nix, enter the development environment:
```sh
nix develop
```
2. Run the monorepo setup command.
If using Nix, enter the development environment:
```sh
pnpm install
nix develop
```
3. Run the build watcher to build the components and serve a local plugin file that can be installed on your Unraid server.
2. Install dependencies and verify they're correctly installed:
```sh
pnpm build:watch
# Install all monorepo dependencies
pnpm install
# The install script will automatically check for required dependencies
# and their versions (rclone v1.70+, jq, pnpm, etc.)
```
Navigate to Plugins->Install and install the local plugin file that is output to the console.
3. Build the project:
```sh
# Build individual packages first (from root directory)
cd api && pnpm build && cd ..
cd web && pnpm build && cd ..
# Then build the plugin if needed
cd plugin && pnpm build && cd ..
```
Note: The packages must be built in order as the plugin depends on the API build artifacts.
### Development Modes
The project supports two development modes:
#### Mode 1: Build Watcher with Local Plugin
This mode builds the plugin continuously and serves it locally for installation on your Unraid server:
```sh
# From the root directory (api/)
pnpm build:watch
```
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.
#### Mode 2: Development Servers
For active development with hot-reload:
```sh
# From the root directory - runs all dev servers concurrently
pnpm dev
```
Or run individual development servers:
```sh
# API server (GraphQL backend at http://localhost:3001)
cd api && pnpm dev
# 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:
```sh
# Replace SERVER_IP with your Unraid server's IP address
pnpm unraid:deploy SERVER_IP
```
> [!TIP]
> View other workflows (local dev, etc.) in the [Developer Workflows](./api/docs/developer/workflows.md)
@@ -180,6 +263,7 @@ Once you have your key pair, add your public SSH key to your Unraid server:
<p align="right">(<a href="#readme-top">back to top</a>)</p>
<!-- USAGE EXAMPLES -->
## Usage
See [How to Use the API](./api/docs/public/how-to-use-the-api.md).
@@ -201,6 +285,7 @@ See the [open issues](https://github.com/unraid/api/issues) for a full list of p
<p align="right">(<a href="#readme-top">back to top</a>)</p> -->
<!-- CONTRIBUTING -->
## Contributing
For a complete guide on contributing to the project, including our code of conduct and development process, please see our [Contributing Guide](./CONTRIBUTING.md). Please read this before contributing.
@@ -209,28 +294,30 @@ For a complete guide on contributing to the project, including our code of condu
For more information about development workflows, repository organization, and other technical details, please refer to the developer documentation inside this repository:
* [Development Guide](./api/docs/developer/development.md) - Setup, building, and debugging instructions
* [Development Workflows](./api/docs/developer/workflows.md) - Detailed workflows for local development, building, and deployment
* [Repository Organization](./api/docs/developer/repo-organization.md) - High-level architecture and project structure
- [Development Guide](./api/docs/developer/development.md) - Setup, building, and debugging instructions
- [Development Workflows](./api/docs/developer/workflows.md) - Detailed workflows for local development, building, and deployment
- [Repository Organization](./api/docs/developer/repo-organization.md) - High-level architecture and project structure
### Work Intent Process
Before starting development work on this project, you must submit a Work Intent and have it approved by a core developer. This helps prevent duplicate work and ensures changes align with the project's goals.
1. **Create a Work Intent**
* Go to [Issues → New Issue → Work Intent](https://github.com/unraid/api/issues/new?template=work_intent.md)
* Fill out the brief template describing what you want to work on
* The issue will be automatically labeled as `work-intent` and `unapproved`
- Go to [Issues → New Issue → Work Intent](https://github.com/unraid/api/issues/new?template=work_intent.md)
- Fill out the brief template describing what you want to work on
- The issue will be automatically labeled as `work-intent` and `unapproved`
2. **Wait for Approval**
* A core developer will review your Work Intent
* They may ask questions or suggest changes
* Once approved, the `unapproved` label will be removed
- A core developer will review your Work Intent
- They may ask questions or suggest changes
- Once approved, the `unapproved` label will be removed
3. **Begin Development**
* Only start coding after your Work Intent is approved
* Follow the approach outlined in your approved Work Intent
* Reference the Work Intent in your future PR
- Only start coding after your Work Intent is approved
- Follow the approach outlined in your approved Work Intent
- Reference the Work Intent in your future PR
---
@@ -254,14 +341,16 @@ Don't forget to give the project a star! Thanks again!
</a>
<!-- Community & Acknowledgements -->
## Community
🌐 [Forums](https://forums.unraid.net/)
💬 [Discord](https://discord.unraid.net/)
💬 [Discord](https://discord.unraid.net/)
<p align="right">(<a href="#readme-top">back to top</a>)</p>
<!-- CONTACT -->
## Contact
[@UnraidOfficial](https://twitter.com/UnraidOfficial) - <contact@unraid.net>
@@ -272,6 +361,7 @@ Project Link: [https://github.com/unraid/api](https://github.com/unraid/api)
<!-- MARKDOWN LINKS & IMAGES -->
<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->
[contributors-shield]: https://img.shields.io/github/contributors/unraid/api.svg?style=for-the-badge
[contributors-url]: https://github.com/unraid/api/graphs/contributors
[forks-shield]: https://img.shields.io/github/forks/unraid/api.svg?style=for-the-badge

153
scripts/cleanup-old-builds.sh Executable file
View File

@@ -0,0 +1,153 @@
#!/bin/bash
# Script to clean up old timestamped builds from Cloudflare R2
# This will remove old .txz files with the pattern dynamix.unraid.net-YYYY.MM.DD.HHMM.txz
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${YELLOW}🧹 Cloudflare Old Build Cleanup Script${NC}"
echo "This will delete old timestamped .txz builds from the preview bucket"
echo ""
# Check for required environment variables
if [ -z "$CF_ACCESS_KEY_ID" ] || [ -z "$CF_SECRET_ACCESS_KEY" ] || [ -z "$CF_ENDPOINT" ] || [ -z "$CF_BUCKET_PREVIEW" ]; then
echo -e "${RED}❌ Error: Missing required environment variables${NC}"
echo "Please set the following environment variables:"
echo " - CF_ACCESS_KEY_ID"
echo " - CF_SECRET_ACCESS_KEY"
echo " - CF_ENDPOINT"
echo " - CF_BUCKET_PREVIEW"
exit 1
fi
# Configure AWS CLI for Cloudflare R2
export AWS_ACCESS_KEY_ID="$CF_ACCESS_KEY_ID"
export AWS_SECRET_ACCESS_KEY="$CF_SECRET_ACCESS_KEY"
export AWS_DEFAULT_REGION="auto"
echo "Endpoint: $CF_ENDPOINT"
echo "Bucket: $CF_BUCKET_PREVIEW"
echo ""
# Optional: specify number of days to keep (default: 7)
KEEP_DAYS=${1:-7}
echo -e "${BLUE}Keeping builds from the last ${KEEP_DAYS} days${NC}"
echo ""
# Calculate cutoff date
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
CUTOFF_DATE=$(date -v -${KEEP_DAYS}d +"%Y.%m.%d")
else
# Linux
CUTOFF_DATE=$(date -d "${KEEP_DAYS} days ago" +"%Y.%m.%d")
fi
echo "Cutoff date: ${CUTOFF_DATE} (will delete builds older than this)"
echo ""
# List all timestamped TXZ files in the unraid-api directory
echo -e "${YELLOW}📋 Scanning for old builds...${NC}"
# Get all .txz files matching the pattern
ALL_FILES=$(aws s3 ls "s3://${CF_BUCKET_PREVIEW}/unraid-api/" --endpoint-url "$CF_ENDPOINT" --recursive | \
grep -E "dynamix\.unraid\.net-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]{4}\.txz" | \
awk '{print $4}' || true)
if [ -z "$ALL_FILES" ]; then
echo -e "${GREEN}✅ No timestamped builds found${NC}"
exit 0
fi
# Filter files older than cutoff
OLD_FILES=""
KEEP_FILES=""
TOTAL_COUNT=0
OLD_COUNT=0
while IFS= read -r file; do
((TOTAL_COUNT++))
# Extract date from filename (format: YYYY.MM.DD.HHMM)
if [[ $file =~ ([0-9]{4}\.[0-9]{2}\.[0-9]{2})\.[0-9]{4}\.txz ]]; then
FILE_DATE="${BASH_REMATCH[1]}"
# Compare dates (string comparison works for YYYY.MM.DD format)
if [[ "$FILE_DATE" < "$CUTOFF_DATE" ]]; then
OLD_FILES="${OLD_FILES}${file}\n"
((OLD_COUNT++))
else
KEEP_FILES="${KEEP_FILES}${file}\n"
fi
fi
done <<< "$ALL_FILES"
echo "Found ${TOTAL_COUNT} total timestamped builds"
echo "Will delete ${OLD_COUNT} old builds"
echo "Will keep $((TOTAL_COUNT - OLD_COUNT)) recent builds"
echo ""
if [ "$OLD_COUNT" -eq 0 ]; then
echo -e "${GREEN}✅ No old builds to delete${NC}"
exit 0
fi
# Show sample of files to be deleted
echo -e "${YELLOW}Sample of files to be deleted:${NC}"
echo -e "$OLD_FILES" | head -5
if [ "$OLD_COUNT" -gt 5 ]; then
echo "... and $((OLD_COUNT - 5)) more"
fi
echo ""
# Confirmation prompt
read -p "Are you sure you want to delete these ${OLD_COUNT} old builds? (yes/no): " -r
echo ""
if [[ ! $REPLY =~ ^[Yy]es$ ]]; then
echo -e "${YELLOW}⚠️ Cleanup cancelled${NC}"
exit 0
fi
# Delete old files
DELETED=0
FAILED=0
echo -e "${YELLOW}🗑️ Deleting old builds...${NC}"
while IFS= read -r file; do
if [ -n "$file" ]; then
echo -n "Deleting $(basename "$file")... "
if aws s3 rm "s3://${CF_BUCKET_PREVIEW}/${file}" \
--endpoint-url "$CF_ENDPOINT" \
>/dev/null 2>&1; then
echo -e "${GREEN}${NC}"
((DELETED++))
else
echo -e "${RED}${NC}"
((FAILED++))
fi
fi
done <<< "$(echo -e "$OLD_FILES")"
echo ""
echo -e "${GREEN}🎉 Cleanup complete!${NC}"
echo " - Deleted: $DELETED old build(s)"
if [ $FAILED -gt 0 ]; then
echo -e " - Failed: ${RED}$FAILED${NC} build(s)"
fi
# Show remaining recent builds
echo ""
echo -e "${BLUE}📦 Recent builds kept:${NC}"
echo -e "$KEEP_FILES" | head -5
KEEP_COUNT=$(echo -e "$KEEP_FILES" | grep -c . || echo 0)
if [ "$KEEP_COUNT" -gt 5 ]; then
echo "... and $((KEEP_COUNT - 5)) more"
fi

107
scripts/cleanup-pr-builds.sh Executable file
View File

@@ -0,0 +1,107 @@
#!/bin/bash
# Script to delete all PR builds from Cloudflare R2
# This will remove all artifacts under unraid-api/tag/PR* paths
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${YELLOW}🧹 Cloudflare PR Build Cleanup Script${NC}"
echo "This will delete all PR builds from the preview bucket"
echo ""
# Check for required environment variables
if [ -z "$CF_ACCESS_KEY_ID" ] || [ -z "$CF_SECRET_ACCESS_KEY" ] || [ -z "$CF_ENDPOINT" ] || [ -z "$CF_BUCKET_PREVIEW" ]; then
echo -e "${RED}❌ Error: Missing required environment variables${NC}"
echo "Please set the following environment variables:"
echo " - CF_ACCESS_KEY_ID"
echo " - CF_SECRET_ACCESS_KEY"
echo " - CF_ENDPOINT"
echo " - CF_BUCKET_PREVIEW"
echo ""
echo "You can source them from your .env file or export them manually:"
echo " export CF_ACCESS_KEY_ID='your-key-id'"
echo " export CF_SECRET_ACCESS_KEY='your-secret-key'"
echo " export CF_ENDPOINT='your-endpoint'"
echo " export CF_BUCKET_PREVIEW='your-bucket'"
exit 1
fi
# Configure AWS CLI for Cloudflare R2
export AWS_ACCESS_KEY_ID="$CF_ACCESS_KEY_ID"
export AWS_SECRET_ACCESS_KEY="$CF_SECRET_ACCESS_KEY"
export AWS_DEFAULT_REGION="auto"
echo "Endpoint: $CF_ENDPOINT"
echo "Bucket: $CF_BUCKET_PREVIEW"
echo ""
# List all PR directories
echo -e "${YELLOW}📋 Listing all PR builds...${NC}"
PR_DIRS=$(aws s3 ls "s3://${CF_BUCKET_PREVIEW}/unraid-api/tag/" --endpoint-url "$CF_ENDPOINT" 2>/dev/null | grep "PRE PR" | awk '{print $2}' || true)
if [ -z "$PR_DIRS" ]; then
echo -e "${GREEN}✅ No PR builds found to clean up${NC}"
exit 0
fi
# Count PR builds
PR_COUNT=$(echo "$PR_DIRS" | wc -l | tr -d ' ')
echo -e "Found ${YELLOW}${PR_COUNT}${NC} PR build(s):"
echo "$PR_DIRS"
echo ""
# Confirmation prompt
read -p "Are you sure you want to delete ALL these PR builds? (yes/no): " -r
echo ""
if [[ ! $REPLY =~ ^[Yy]es$ ]]; then
echo -e "${YELLOW}⚠️ Cleanup cancelled${NC}"
exit 0
fi
# Delete each PR directory
DELETED=0
FAILED=0
for PR_DIR in $PR_DIRS; do
PR_NUM=${PR_DIR%/} # Remove trailing slash
echo -n "Deleting $PR_NUM... "
if aws s3 rm "s3://${CF_BUCKET_PREVIEW}/unraid-api/tag/${PR_NUM}" \
--recursive \
--endpoint-url "$CF_ENDPOINT" \
>/dev/null 2>&1; then
echo -e "${GREEN}${NC}"
((DELETED++))
else
echo -e "${RED}${NC}"
((FAILED++))
fi
done
echo ""
echo -e "${GREEN}🎉 Cleanup complete!${NC}"
echo " - Deleted: $DELETED PR build(s)"
if [ $FAILED -gt 0 ]; then
echo -e " - Failed: ${RED}$FAILED${NC} PR build(s)"
fi
# Optional: List remaining items to verify
echo ""
echo -e "${YELLOW}📋 Verifying cleanup...${NC}"
REMAINING=$(aws s3 ls "s3://${CF_BUCKET_PREVIEW}/unraid-api/tag/" --endpoint-url "$CF_ENDPOINT" 2>/dev/null | grep -c "PRE PR" || true)
# Ensure REMAINING is a valid number
REMAINING=${REMAINING:-0}
echo "Remaining PR builds: $REMAINING"
if [ "$REMAINING" -eq 0 ]; then
echo -e "${GREEN}✅ All PR builds successfully removed${NC}"
else
echo -e "${YELLOW}⚠️ Some PR builds may still exist${NC}"
fi

View File

@@ -104,6 +104,7 @@ eslint.configs.recommended, ...tseslint.configs.recommended, // TypeScript Files
parser: tseslint.parser,
parserOptions: {
...commonLanguageOptions,
tsconfigRootDir: import.meta.dirname,
ecmaFeatures: {
jsx: true,
},
@@ -128,6 +129,7 @@ eslint.configs.recommended, ...tseslint.configs.recommended, // TypeScript Files
parserOptions: {
...commonLanguageOptions,
parser: tseslint.parser,
tsconfigRootDir: import.meta.dirname,
ecmaFeatures: {
jsx: true,
},

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/ui",
"version": "4.20.1",
"version": "4.22.1",
"private": true,
"license": "GPL-2.0-or-later",
"type": "module",
@@ -66,7 +66,7 @@
"shadcn-vue": "2.2.0",
"tailwind-merge": "2.6.0",
"tw-animate-css": "1.3.7",
"vue-sonner": "1.3.2"
"vue-sonner": "2.0.8"
},
"devDependencies": {
"@eslint/js": "9.34.0",

View File

@@ -51,7 +51,7 @@ const classes = computed(() => {
});
const needsBrandGradientBackground = computed(() => {
return ['outline-solid', 'outline-primary'].includes(props.variant ?? '');
return ['outline', 'outline-solid', 'outline-primary'].includes(props.variant ?? '');
});
const isLink = computed(() => Boolean(props.href));

View File

@@ -1,8 +1,16 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import { Toaster as Sonner, toast, type ToasterProps } from 'vue-sonner';
import 'vue-sonner/style.css';
const props = defineProps<ToasterProps>();
// Accept theme as a prop, default to 'light' if not provided
interface Props extends ToasterProps {
theme?: 'light' | 'dark' | 'system';
}
const props = withDefaults(defineProps<Props>(), {
theme: 'light',
});
onMounted(() => {
globalThis.toast = toast;
@@ -27,3 +35,17 @@ onMounted(() => {
}"
/>
</template>
<style>
/* Override styles for Unraid environment */
[data-sonner-toast] [data-close-button] {
min-width: inherit !important;
}
/* Override Unraid webgui docker icon styles on sonner containers */
[data-sonner-toast] [data-icon]:before,
[data-sonner-toast] .fa-docker:before {
font-family: inherit !important;
content: '' !important;
}
</style>

View File

@@ -32,7 +32,7 @@ const { teleportTarget } = useTeleport();
v-bind="forwarded"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-muted z-50 min-w-32 overflow-hidden rounded-lg border p-1 shadow-md',
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-muted z-[103] min-w-32 overflow-hidden rounded-lg border p-1 shadow-md',
props.class
)
"

View File

@@ -0,0 +1,169 @@
import useTeleport from '@/composables/useTeleport';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock Vue's computed while preserving other exports
vi.mock('vue', async () => ({
...(await vi.importActual('vue')),
computed: vi.fn((fn) => {
const result = { value: fn() };
return result;
}),
}));
describe('useTeleport', () => {
beforeEach(() => {
// Clear the DOM before each test
document.body.innerHTML = '';
document.head.innerHTML = '';
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should return teleportTarget computed property', () => {
const { teleportTarget } = useTeleport();
expect(teleportTarget).toBeDefined();
expect(teleportTarget).toHaveProperty('value');
});
it('should return #modals when element with id="modals" exists', () => {
// Create element with id="modals"
const modalsDiv = document.createElement('div');
modalsDiv.id = 'modals';
document.body.appendChild(modalsDiv);
const { teleportTarget } = useTeleport();
expect(teleportTarget.value).toBe('#modals');
});
it('should prioritize #modals id over mounted unraid-modals', () => {
// Create both elements
const modalsDiv = document.createElement('div');
modalsDiv.id = 'modals';
document.body.appendChild(modalsDiv);
const unraidModals = document.createElement('unraid-modals');
unraidModals.setAttribute('data-vue-mounted', 'true');
document.body.appendChild(unraidModals);
const { teleportTarget } = useTeleport();
expect(teleportTarget.value).toBe('#modals');
});
it('should return mounted unraid-modals with inner #modals div', () => {
// Create mounted unraid-modals with inner modals div
const unraidModals = document.createElement('unraid-modals');
unraidModals.setAttribute('data-vue-mounted', 'true');
const innerModals = document.createElement('div');
innerModals.id = 'modals';
unraidModals.appendChild(innerModals);
document.body.appendChild(unraidModals);
const { teleportTarget } = useTeleport();
expect(teleportTarget.value).toBe('#modals');
});
it('should add id to mounted unraid-modals when no inner modals div exists', () => {
// Create mounted unraid-modals without inner div
const unraidModals = document.createElement('unraid-modals');
unraidModals.setAttribute('data-vue-mounted', 'true');
document.body.appendChild(unraidModals);
const { teleportTarget } = useTeleport();
expect(unraidModals.id).toBe('unraid-modals-teleport-target');
expect(teleportTarget.value).toBe('#unraid-modals-teleport-target');
});
it('should use existing id of mounted unraid-modals if present', () => {
// Create mounted unraid-modals with existing id
const unraidModals = document.createElement('unraid-modals');
unraidModals.setAttribute('data-vue-mounted', 'true');
unraidModals.id = 'custom-modals-id';
document.body.appendChild(unraidModals);
const { teleportTarget } = useTeleport();
expect(teleportTarget.value).toBe('#custom-modals-id');
});
it('should ignore unmounted unraid-modals elements', () => {
// Create unmounted unraid-modals (without data-vue-mounted attribute)
const unraidModals = document.createElement('unraid-modals');
document.body.appendChild(unraidModals);
const { teleportTarget } = useTeleport();
expect(teleportTarget.value).toBe('body');
});
it('should ignore unraid-modals with data-vue-mounted="false"', () => {
// Create unraid-modals with data-vue-mounted="false"
const unraidModals = document.createElement('unraid-modals');
unraidModals.setAttribute('data-vue-mounted', 'false');
document.body.appendChild(unraidModals);
const { teleportTarget } = useTeleport();
expect(teleportTarget.value).toBe('body');
});
it('should return body as fallback when no suitable target exists', () => {
// No elements in DOM
const { teleportTarget } = useTeleport();
expect(teleportTarget.value).toBe('body');
});
it('should handle multiple unraid-modals elements correctly', () => {
// Create multiple unraid-modals, only one mounted
const unmountedModals1 = document.createElement('unraid-modals');
document.body.appendChild(unmountedModals1);
const mountedModals = document.createElement('unraid-modals');
mountedModals.setAttribute('data-vue-mounted', 'true');
mountedModals.id = 'mounted-modals';
document.body.appendChild(mountedModals);
const unmountedModals2 = document.createElement('unraid-modals');
document.body.appendChild(unmountedModals2);
const { teleportTarget } = useTeleport();
expect(teleportTarget.value).toBe('#mounted-modals');
});
it('should handle nested modal elements correctly', () => {
// Create nested structure
const container = document.createElement('div');
const unraidModals = document.createElement('unraid-modals');
unraidModals.setAttribute('data-vue-mounted', 'true');
const innerDiv = document.createElement('div');
const innerModals = document.createElement('div');
innerModals.id = 'modals';
innerDiv.appendChild(innerModals);
unraidModals.appendChild(innerDiv);
container.appendChild(unraidModals);
document.body.appendChild(container);
const { teleportTarget } = useTeleport();
expect(teleportTarget.value).toBe('#modals');
});
it('should be reactive to DOM changes', () => {
const { teleportTarget } = useTeleport();
// Initially should be body
expect(teleportTarget.value).toBe('body');
// Add modals element
const modalsDiv = document.createElement('div');
modalsDiv.id = 'modals';
document.body.appendChild(modalsDiv);
// Recreate the composable to test updated DOM state
const { teleportTarget: newTeleportTarget } = useTeleport();
expect(newTeleportTarget.value).toBe('#modals');
});
});

View File

@@ -1,12 +1,31 @@
import { ensureTeleportContainer } from '@/helpers/ensure-teleport-container';
import { onMounted, ref } from 'vue';
import { computed } from 'vue';
const useTeleport = () => {
const teleportTarget = ref<string | HTMLElement>('body');
// Computed property that finds the correct teleport target
const teleportTarget = computed(() => {
// #modals should be unique (id), but let's be defensive
const modalsElement = document.getElementById('modals');
if (modalsElement) return `#modals`;
onMounted(() => {
const container = ensureTeleportContainer();
teleportTarget.value = container;
// Find only mounted unraid-modals components (data-vue-mounted="true")
// This ensures we don't target unmounted or duplicate elements
const mountedModals = document.querySelector('unraid-modals[data-vue-mounted="true"]');
if (mountedModals) {
// Check if it has the inner #modals div
const innerModals = mountedModals.querySelector('#modals');
if (innerModals && innerModals.id) {
return `#${innerModals.id}`;
}
// Use the mounted component itself as fallback
// Add a unique identifier if it doesn't have one
if (!mountedModals.id) {
mountedModals.id = 'unraid-modals-teleport-target';
}
return `#${mountedModals.id}`;
}
// Final fallback to body - modals component not mounted yet
return 'body';
});
return {

View File

@@ -1,23 +0,0 @@
/**
* Ensures the teleport container exists in the DOM.
* This is used by both the standalone mount script and unraid-ui components
* to ensure modals and other teleported content have a target.
*/
export function ensureTeleportContainer(): HTMLElement {
const containerId = 'unraid-teleport-container';
// Check if container already exists
let container = document.getElementById(containerId);
// If it doesn't exist, create it
if (!container) {
container = document.createElement('div');
container.id = containerId;
container.style.position = 'relative';
container.classList.add('unapi');
container.style.zIndex = '999999'; // Very high z-index to ensure it's always on top
document.body.appendChild(container);
}
return container;
}

View File

@@ -15,6 +15,3 @@ export * from '@/lib/utils';
export { default as useTeleport } from '@/composables/useTeleport';
export { useToast } from '@/composables/useToast';
export type { ToastInstance } from '@/composables/useToast';
// Helpers
export { ensureTeleportContainer } from '@/helpers/ensure-teleport-container';

View File

@@ -51,10 +51,6 @@
"exclude": [
"node_modules",
"**/*.copy.vue",
"**/*copy.vue",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.test.tsx",
"**/*.spec.tsx"
"**/*copy.vue"
]
}

View File

@@ -19,6 +19,17 @@ export default function createConfig() {
dts({
insertTypesEntry: true,
include: ['src/**/*.ts', 'src/**/*.vue'],
exclude: [
'src/**/*.test.ts',
'src/**/*.spec.ts',
'src/**/*.test.tsx',
'src/**/*.spec.tsx',
'src/**/*.test.vue',
'src/**/*.spec.vue',
'src/**/*.stories.*',
'src/**/*.stories.{ts,tsx,vue}',
'src/**/__tests__/**',
],
outDir: 'dist',
rollupTypes: true,
copyDtsFiles: true,
@@ -75,6 +86,9 @@ export default function createConfig() {
'@/theme': resolve(__dirname, './src/theme'),
},
},
optimizeDeps: {
include: ['ajv', 'ajv-errors'],
},
test: {
environment: 'happy-dom',
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],

View File

@@ -4,7 +4,7 @@
For legacy compatibility, Unraid ships web components to the webgui. These components
are written as Vue and turned into web components as a build step. By convention,
Vue components that are built as top-level web components are suffixed with `*.ce.vue`
Vue components that are built as top-level web components are suffixed with `*.standalone.vue`
for "**c**ustom **e**lement", which comes from the tool used for compilation: `nuxt-custom-elements`.
Note: `nuxt-custom-elements` is currently pinned to a specific version because

View File

@@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ComposerTranslation } from 'vue-i18n';
import WelcomeModal from '~/components/Activation/WelcomeModal.ce.vue';
import WelcomeModal from '~/components/Activation/WelcomeModal.standalone.vue';
vi.mock('@unraid/ui', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
@@ -76,7 +76,7 @@ vi.mock('~/store/theme', () => ({
useThemeStore: () => mockThemeStore,
}));
describe('Activation/WelcomeModal.ce.vue', () => {
describe('Activation/WelcomeModal.standalone.vue', () => {
let mockSetProperty: ReturnType<typeof vi.fn>;
let mockQuerySelector: ReturnType<typeof vi.fn>;

View File

@@ -11,7 +11,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ServerconnectPluginInstalled } from '~/types/server';
import Auth from '~/components/Auth.ce.vue';
import Auth from '~/components/Auth.standalone.vue';
import { useServerStore } from '~/store/server';
vi.mock('vue-i18n', () => ({

View File

@@ -12,7 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MockInstance } from 'vitest';
import ColorSwitcher from '~/components/ColorSwitcher.ce.vue';
import ColorSwitcher from '~/components/ColorSwitcher.standalone.vue';
import { useThemeStore } from '~/store/theme';
// Explicitly mock @unraid/ui to ensure we use the actual components

View File

@@ -8,7 +8,7 @@ import { mount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import DowngradeOs from '~/components/DowngradeOs.ce.vue';
import DowngradeOs from '~/components/DowngradeOs.standalone.vue';
import { useServerStore } from '~/store/server';
vi.mock('crypto-js/aes', () => ({

View File

@@ -8,7 +8,7 @@ import { BrandButton } from '@unraid/ui';
import { createTestingPinia } from '@pinia/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import DownloadApiLogs from '~/components/DownloadApiLogs.ce.vue';
import DownloadApiLogs from '~/components/DownloadApiLogs.standalone.vue';
vi.mock('~/helpers/urls', () => ({
CONNECT_FORUMS: new URL('http://mock-forums.local'),

View File

@@ -14,7 +14,7 @@ import type { VueWrapper } from '@vue/test-utils';
import type { Error as CustomApiError } from '~/store/errors';
import type { ServerUpdateOsResponse } from '~/types/server';
import HeaderOsVersion from '~/components/HeaderOsVersion.ce.vue';
import HeaderOsVersion from '~/components/HeaderOsVersion.standalone.vue';
import { useErrorsStore } from '~/store/errors';
import { useServerStore } from '~/store/server';
@@ -156,4 +156,28 @@ describe('HeaderOsVersion', () => {
expect(findUpdateStatusComponent()).toBeNull();
});
it('removes logo class from logo wrapper on mount', async () => {
// Create a mock logo element
const logoElement = document.createElement('div');
logoElement.classList.add('logo');
document.body.appendChild(logoElement);
// Mount component
const newWrapper = mount(HeaderOsVersion, {
global: {
plugins: [testingPinia],
},
});
// Wait for nextTick to allow onMounted to complete
await nextTick();
await nextTick(); // Double nextTick since onMounted uses nextTick internally
expect(logoElement.classList.contains('logo')).toBe(false);
// Cleanup
newWrapper.unmount();
document.body.removeChild(logoElement);
});
});

View File

@@ -0,0 +1,232 @@
import { nextTick } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import { mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { VueWrapper } from '@vue/test-utils';
import type { Pinia } from 'pinia';
import Modals from '~/components/Modals.standalone.vue';
import { useCallbackActionsStore } from '~/store/callbackActions';
import { useTrialStore } from '~/store/trial';
import { useUpdateOsStore } from '~/store/updateOs';
// Mock child components
vi.mock('~/components/Activation/ActivationModal.vue', () => ({
default: {
name: 'ActivationModal',
props: ['t'],
template: '<div>ActivationModal</div>',
},
}));
vi.mock('~/components/UpdateOs/ChangelogModal.vue', () => ({
default: {
name: 'UpdateOsChangelogModal',
props: ['t', 'open'],
template: '<div v-if="open">ChangelogModal</div>',
},
}));
vi.mock('~/components/UpdateOs/CheckUpdateResponseModal.vue', () => ({
default: {
name: 'UpdateOsCheckUpdateResponseModal',
props: ['t', 'open'],
template: '<div v-if="open">CheckUpdateResponseModal</div>',
},
}));
vi.mock('~/components/UserProfile/CallbackFeedback.vue', () => ({
default: {
name: 'UpcCallbackFeedback',
props: ['t', 'open'],
template: '<div v-if="open">CallbackFeedback</div>',
},
}));
vi.mock('~/components/UserProfile/Trial.vue', () => ({
default: {
name: 'UpcTrial',
props: ['t', 'open'],
template: '<div v-if="open">Trial</div>',
},
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}));
describe('Modals.standalone.vue', () => {
let wrapper: VueWrapper;
let pinia: Pinia;
beforeEach(() => {
pinia = createPinia();
setActivePinia(pinia);
wrapper = mount(Modals, {
global: {
plugins: [pinia],
},
});
});
afterEach(() => {
wrapper?.unmount();
vi.clearAllMocks();
});
it('should render modals container with correct id and ref', () => {
const modalsDiv = wrapper.find('#modals');
expect(modalsDiv.exists()).toBe(true);
expect(modalsDiv.attributes('class')).toContain('relative');
expect(modalsDiv.attributes('class')).toContain('z-[999999]');
});
it('should render all modal components', () => {
expect(wrapper.findComponent({ name: 'UpcCallbackFeedback' }).exists()).toBe(true);
expect(wrapper.findComponent({ name: 'UpcTrial' }).exists()).toBe(true);
expect(wrapper.findComponent({ name: 'UpdateOsCheckUpdateResponseModal' }).exists()).toBe(true);
expect(wrapper.findComponent({ name: 'UpdateOsChangelogModal' }).exists()).toBe(true);
expect(wrapper.findComponent({ name: 'ActivationModal' }).exists()).toBe(true);
});
it('should pass correct props to CallbackFeedback based on store state', async () => {
const callbackStore = useCallbackActionsStore();
callbackStore.callbackStatus = 'loading';
await nextTick();
const callbackFeedback = wrapper.findComponent({ name: 'UpcCallbackFeedback' });
expect(callbackFeedback.props('open')).toBe(true);
callbackStore.callbackStatus = 'ready';
await nextTick();
expect(callbackFeedback.props('open')).toBe(false);
});
it('should pass correct props to Trial modal based on store state', async () => {
const trialStore = useTrialStore();
// trialModalVisible is computed based on trialStatus
trialStore.trialStatus = 'trialStart';
await nextTick();
const trialModal = wrapper.findComponent({ name: 'UpcTrial' });
expect(trialModal.props('open')).toBe(true);
trialStore.trialStatus = 'ready';
await nextTick();
expect(trialModal.props('open')).toBe(false);
});
it('should pass correct props to UpdateOs modal based on store state', async () => {
const updateOsStore = useUpdateOsStore();
updateOsStore.setModalOpen(true);
await nextTick();
const updateOsModal = wrapper.findComponent({ name: 'UpdateOsCheckUpdateResponseModal' });
expect(updateOsModal.props('open')).toBe(true);
updateOsStore.setModalOpen(false);
await nextTick();
expect(updateOsModal.props('open')).toBe(false);
});
it('should pass correct props to Changelog modal based on store state', async () => {
const updateOsStore = useUpdateOsStore();
// changelogModalVisible is computed based on releaseForUpdate
updateOsStore.setReleaseForUpdate({
version: '6.13.0',
name: 'Unraid 6.13.0',
date: '2024-01-01',
isNewer: true,
isEligible: true,
changelog: null,
sha256: null,
});
await nextTick();
const changelogModal = wrapper.findComponent({ name: 'UpdateOsChangelogModal' });
expect(changelogModal.props('open')).toBe(true);
updateOsStore.setReleaseForUpdate(null);
await nextTick();
expect(changelogModal.props('open')).toBe(false);
});
it('should pass translation function to all modals', () => {
const components = [
'UpcCallbackFeedback',
'UpcTrial',
'UpdateOsCheckUpdateResponseModal',
'UpdateOsChangelogModal',
'ActivationModal',
];
components.forEach((componentName) => {
const component = wrapper.findComponent({ name: componentName });
expect(component.props('t')).toBeDefined();
expect(typeof component.props('t')).toBe('function');
});
});
it('should use computed properties for reactive store access', async () => {
// Test that computed properties react to store changes
const callbackStore = useCallbackActionsStore();
const trialStore = useTrialStore();
const updateOsStore = useUpdateOsStore();
// Initially all should be closed/default
expect(wrapper.findComponent({ name: 'UpcCallbackFeedback' }).props('open')).toBe(false);
expect(wrapper.findComponent({ name: 'UpcTrial' }).props('open')).toBe(false);
expect(wrapper.findComponent({ name: 'UpdateOsCheckUpdateResponseModal' }).props('open')).toBe(
false
);
expect(wrapper.findComponent({ name: 'UpdateOsChangelogModal' }).props('open')).toBe(false);
// Update all stores using proper methods
callbackStore.callbackStatus = 'loading';
trialStore.trialStatus = 'trialStart';
updateOsStore.setModalOpen(true);
updateOsStore.setReleaseForUpdate({
version: '6.13.0',
name: 'Unraid 6.13.0',
date: '2024-01-01',
isNewer: true,
isEligible: true,
changelog: null,
sha256: null,
});
await nextTick();
// All should be open now
expect(wrapper.findComponent({ name: 'UpcCallbackFeedback' }).props('open')).toBe(true);
expect(wrapper.findComponent({ name: 'UpcTrial' }).props('open')).toBe(true);
expect(wrapper.findComponent({ name: 'UpdateOsCheckUpdateResponseModal' }).props('open')).toBe(true);
expect(wrapper.findComponent({ name: 'UpdateOsChangelogModal' }).props('open')).toBe(true);
});
it('should render modals container even when all modals are closed', () => {
const callbackStore = useCallbackActionsStore();
const trialStore = useTrialStore();
const updateOsStore = useUpdateOsStore();
// Set all modals to closed state
callbackStore.callbackStatus = 'ready';
trialStore.trialStatus = 'ready';
updateOsStore.setModalOpen(false);
updateOsStore.setReleaseForUpdate(null);
const modalsDiv = wrapper.find('#modals');
expect(modalsDiv.exists()).toBe(true);
// Container should still exist
expect(wrapper.findComponent({ name: 'ActivationModal' }).exists()).toBe(true);
});
});

View File

@@ -12,7 +12,7 @@ import type { VueWrapper } from '@vue/test-utils';
import type { ServerconnectPluginInstalled } from '~/types/server';
import type { Pinia } from 'pinia';
import Registration from '~/components/Registration.ce.vue';
import Registration from '~/components/Registration.standalone.vue';
import { usePurchaseStore } from '~/store/purchase';
import { useReplaceRenewStore } from '~/store/replaceRenew';
import { useServerStore } from '~/store/server';
@@ -116,7 +116,7 @@ vi.mock('vue-i18n', () => ({
useI18n: () => ({ t }),
}));
describe('Registration.ce.vue', () => {
describe('Registration.standalone.vue', () => {
let wrapper: VueWrapper<unknown>;
let pinia: Pinia;
let serverStore: ReturnType<typeof useServerStore>;

View File

@@ -46,14 +46,14 @@ const mockLocation = {
vi.stubGlobal('location', mockLocation);
describe('ThemeSwitcher.ce.vue', () => {
describe('ThemeSwitcher.standalone.vue', () => {
let consoleDebugSpy: MockInstance;
let consoleLogSpy: MockInstance;
let consoleErrorSpy: MockInstance;
let ThemeSwitcher: unknown;
beforeEach(async () => {
ThemeSwitcher = (await import('~/components/ThemeSwitcher.ce.vue')).default;
ThemeSwitcher = (await import('~/components/ThemeSwitcher.standalone.vue')).default;
vi.useFakeTimers();
vi.clearAllMocks();

View File

@@ -8,7 +8,7 @@ import { mount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import UpdateOs from '~/components/UpdateOs.ce.vue';
import UpdateOs from '~/components/UpdateOs.standalone.vue';
vi.mock('@unraid/ui', () => ({
PageContainer: { template: '<div><slot /></div>' },
@@ -61,7 +61,7 @@ const UpdateOsThirdPartyDriversStub = {
props: ['t'],
};
describe('UpdateOs.ce.vue', () => {
describe('UpdateOs.standalone.vue', () => {
beforeEach(() => {
vi.clearAllMocks();
mockRebootType.value = '';

View File

@@ -11,7 +11,7 @@ import type { VueWrapper } from '@vue/test-utils';
import type { Server, ServerconnectPluginInstalled, ServerState } from '~/types/server';
import type { Pinia } from 'pinia';
import UserProfile from '~/components/UserProfile.ce.vue';
import UserProfile from '~/components/UserProfile.standalone.vue';
import { useServerStore } from '~/store/server';
import { useThemeStore } from '~/store/theme';
@@ -101,7 +101,7 @@ const stubs = {
UpcDropdownTrigger: { template: '<button data-testid="dropdown-trigger"></button>' },
};
describe('UserProfile.ce.vue', () => {
describe('UserProfile.standalone.vue', () => {
let wrapper: VueWrapper<InstanceType<typeof UserProfile>>;
let pinia: Pinia;
let serverStore: ReturnType<typeof useServerStore>;
@@ -322,8 +322,8 @@ describe('UserProfile.ce.vue', () => {
themeStore.theme!.descriptionShow = true;
await wrapper.vm.$nextTick();
// Look for the description in a span element
let descriptionElement = wrapper.find('span.text-center.md\\:text-right');
// Look for the description in a span element with v-html directive
let descriptionElement = wrapper.find('span.hidden.text-center.text-base');
expect(descriptionElement.exists()).toBe(true);
expect(descriptionElement.html()).toContain(initialServerData.description);
@@ -331,13 +331,13 @@ describe('UserProfile.ce.vue', () => {
await wrapper.vm.$nextTick();
// When descriptionShow is false, the element should not exist
descriptionElement = wrapper.find('span.text-center.md\\:text-right');
descriptionElement = wrapper.find('span.hidden.text-center.text-base');
expect(descriptionElement.exists()).toBe(false);
themeStore.theme!.descriptionShow = true;
await wrapper.vm.$nextTick();
descriptionElement = wrapper.find('span.text-center.md\\:text-right');
descriptionElement = wrapper.find('span.hidden.text-center.text-base');
expect(descriptionElement.exists()).toBe(true);
expect(descriptionElement.html()).toContain(initialServerData.description);
});

View File

@@ -93,7 +93,7 @@ const WanIpCheckStub = defineComponent({
},
});
describe('WanIpCheck.ce.vue', () => {
describe('WanIpCheck.standalone.vue', () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -63,7 +63,7 @@ function calculateTitle(partnerName: string | null) {
: 'Welcome to Unraid!';
}
describe('WelcomeModal.ce.vue', () => {
describe('WelcomeModal.standalone.vue', () => {
beforeEach(() => {
vi.clearAllMocks();
mockPartnerName.mockReturnValue(null);

View File

@@ -0,0 +1,184 @@
import { describe, expect, it, vi } from 'vitest';
// Mock Vue's defineAsyncComponent
vi.mock('vue', () => ({
defineAsyncComponent: vi.fn((loader) => ({ loader, __asyncComponent: true })),
}));
// Mock CSS imports
vi.mock('~/assets/main.css', () => ({}));
vi.mock('@unraid/ui/styles', () => ({}));
// Mock all component imports
vi.mock('@/components/HeaderOsVersion.standalone.vue', () => ({ default: 'HeaderOsVersion' }));
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' }));
vi.mock('../CallbackHandler.standalone.vue', () => ({ default: 'CallbackHandler' }));
vi.mock('../Logs/LogViewer.standalone.vue', () => ({ default: 'LogViewer' }));
vi.mock('../SsoButton.standalone.vue', () => ({ default: 'SsoButton' }));
vi.mock('../Activation/WelcomeModal.standalone.vue', () => ({ default: 'WelcomeModal' }));
vi.mock('../UpdateOs.standalone.vue', () => ({ default: 'UpdateOs' }));
vi.mock('../DowngradeOs.standalone.vue', () => ({ default: 'DowngradeOs' }));
vi.mock('../DevSettings.vue', () => ({ default: 'DevSettings' }));
vi.mock('../ApiKeyPage.standalone.vue', () => ({ default: 'ApiKeyPage' }));
vi.mock('../ApiKeyAuthorize.standalone.vue', () => ({ default: 'ApiKeyAuthorize' }));
vi.mock('../DevModalTest.standalone.vue', () => ({ default: 'DevModalTest' }));
vi.mock('../LayoutViews/Detail/DetailTest.standalone.vue', () => ({ default: 'DetailTest' }));
vi.mock('@/components/ThemeSwitcher.standalone.vue', () => ({ default: 'ThemeSwitcher' }));
vi.mock('../ColorSwitcher.standalone.vue', () => ({ default: 'ColorSwitcher' }));
vi.mock('@/components/UnraidToaster.vue', () => ({ default: 'UnraidToaster' }));
vi.mock('../UpdateOs/TestUpdateModal.standalone.vue', () => ({ default: 'TestUpdateModal' }));
vi.mock('../TestThemeSwitcher.standalone.vue', () => ({ default: 'TestThemeSwitcher' }));
describe('component-registry', () => {
it('should export ComponentMapping type', async () => {
const module = await import('~/components/Wrapper/component-registry');
expect(module).toBeDefined();
});
it('should export componentMappings array', async () => {
const { componentMappings } = await import('~/components/Wrapper/component-registry');
expect(Array.isArray(componentMappings)).toBe(true);
expect(componentMappings.length).toBeGreaterThan(0);
});
it('should have required properties for each component mapping', async () => {
const { componentMappings } = await import('~/components/Wrapper/component-registry');
componentMappings.forEach((mapping) => {
expect(mapping).toHaveProperty('selector');
expect(mapping).toHaveProperty('appId');
expect(mapping).toHaveProperty('component');
// Check selector is string or array
expect(typeof mapping.selector === 'string' || Array.isArray(mapping.selector)).toBe(true);
// Check appId is string
expect(typeof mapping.appId).toBe('string');
// Check component exists and is an object
expect(mapping.component).toBeDefined();
expect(typeof mapping.component).toBe('object');
});
});
it('should have priority components listed first', async () => {
const { componentMappings } = await import('~/components/Wrapper/component-registry');
// Priority components should be first
expect(componentMappings[0].appId).toBe('header-os-version');
expect(componentMappings[1].appId).toBe('user-profile');
});
it('should support multiple selectors for modals', async () => {
const { componentMappings } = await import('~/components/Wrapper/component-registry');
const modalsMapping = componentMappings.find((m) => m.appId === 'modals');
expect(Array.isArray(modalsMapping?.selector)).toBe(true);
expect(modalsMapping?.selector).toContain('unraid-modals');
expect(modalsMapping?.selector).toContain('#modals');
expect(modalsMapping?.selector).toContain('modals-direct');
});
it('should support multiple selectors for api key components', async () => {
const { componentMappings } = await import('~/components/Wrapper/component-registry');
const apiKeyMapping = componentMappings.find((m) => m.appId === 'apikey-page');
expect(Array.isArray(apiKeyMapping?.selector)).toBe(true);
expect(apiKeyMapping?.selector).toContain('unraid-apikey-page');
expect(apiKeyMapping?.selector).toContain('unraid-api-key-manager');
});
it('should support multiple selectors for toaster', async () => {
const { componentMappings } = await import('~/components/Wrapper/component-registry');
const toasterMapping = componentMappings.find((m) => m.appId === 'toaster');
expect(Array.isArray(toasterMapping?.selector)).toBe(true);
expect(toasterMapping?.selector).toContain('unraid-toaster');
expect(toasterMapping?.selector).toContain('uui-toaster');
});
it('should have unique appIds', async () => {
const { componentMappings } = await import('~/components/Wrapper/component-registry');
const appIds = componentMappings.map((m) => m.appId);
const uniqueAppIds = new Set(appIds);
expect(appIds.length).toBe(uniqueAppIds.size);
});
it('should define all components as async components', async () => {
const { componentMappings } = await import('~/components/Wrapper/component-registry');
componentMappings.forEach((mapping) => {
expect(mapping.component).toBeDefined();
expect(typeof mapping.component).toBe('object');
});
});
it('should have at least the core component mappings', async () => {
const { componentMappings } = await import('~/components/Wrapper/component-registry');
// Just ensure we have a reasonable number of components, not an exact count
expect(componentMappings.length).toBeGreaterThan(10);
});
it('should include all expected components', async () => {
const { componentMappings } = await import('~/components/Wrapper/component-registry');
const expectedAppIds = [
'header-os-version',
'user-profile',
'auth',
'connect-settings',
'download-api-logs',
'modals',
'registration',
'wan-ip-check',
'callback-handler',
'log-viewer',
'sso-button',
'welcome-modal',
'update-os',
'downgrade-os',
'dev-settings',
'apikey-page',
'apikey-authorize',
'dev-modal-test',
'detail-test',
'theme-switcher',
'color-switcher',
'toaster',
'test-update-modal',
'test-theme-switcher',
];
const actualAppIds = componentMappings.map((m) => m.appId);
expectedAppIds.forEach((appId) => {
expect(actualAppIds).toContain(appId);
});
});
it('should properly format selectors', async () => {
const { componentMappings } = await import('~/components/Wrapper/component-registry');
componentMappings.forEach((mapping) => {
if (typeof mapping.selector === 'string') {
// Single selectors should be non-empty strings
expect(mapping.selector.length).toBeGreaterThan(0);
} else if (Array.isArray(mapping.selector)) {
// Array selectors should have at least one item
expect(mapping.selector.length).toBeGreaterThan(0);
// Each selector in array should be non-empty string
mapping.selector.forEach((sel) => {
expect(typeof sel).toBe('string');
expect(sel.length).toBeGreaterThan(0);
});
}
});
});
});

View File

@@ -2,31 +2,33 @@ import { defineComponent, h } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ComponentMapping } from '~/components/Wrapper/component-registry';
import type { MockInstance } from 'vitest';
import type { App as VueApp } from 'vue';
// Extend HTMLElement to include Vue's internal properties (matching the source file)
interface HTMLElementWithVue extends HTMLElement {
__vueParentComponent?: {
appContext?: {
app?: VueApp;
};
};
}
// We'll manually mock createApp only in specific tests that need it
vi.mock('vue', async () => {
const actual = await vi.importActual<typeof import('vue')>('vue');
return {
...actual,
};
});
const mockEnsureTeleportContainer = vi.fn();
vi.mock('@unraid/ui', () => ({
ensureTeleportContainer: mockEnsureTeleportContainer,
// Mock @nuxt/ui components
vi.mock('@nuxt/ui/components/App.vue', () => ({
default: defineComponent({
name: 'UApp',
setup(_, { slots }) {
return () => h('div', { class: 'u-app' }, slots.default?.());
},
}),
}));
vi.mock('@nuxt/ui/vue-plugin', () => ({
default: {
install: vi.fn(),
},
}));
// Mock component registry
const mockComponentMappings: ComponentMapping[] = [];
vi.mock('~/components/Wrapper/component-registry', () => ({
componentMappings: mockComponentMappings,
}));
// Mock dependencies
const mockI18n = {
global: {},
install: vi.fn(),
@@ -60,26 +62,22 @@ vi.mock('~/helpers/i18n-utils', () => ({
createHtmlEntityDecoder: vi.fn(() => (str: string) => str),
}));
// CSS is now bundled separately by Vite, no inline imports
describe('mount-engine', () => {
let mountVueApp: typeof import('~/components/Wrapper/mount-engine').mountVueApp;
let unmountVueApp: typeof import('~/components/Wrapper/mount-engine').unmountVueApp;
let getMountedApp: typeof import('~/components/Wrapper/mount-engine').getMountedApp;
let autoMountComponent: typeof import('~/components/Wrapper/mount-engine').autoMountComponent;
let mountUnifiedApp: typeof import('~/components/Wrapper/mount-engine').mountUnifiedApp;
let autoMountAllComponents: typeof import('~/components/Wrapper/mount-engine').autoMountAllComponents;
let TestComponent: ReturnType<typeof defineComponent>;
let consoleWarnSpy: MockInstance;
let consoleErrorSpy: MockInstance;
let consoleInfoSpy: MockInstance;
let consoleDebugSpy: MockInstance;
let testContainer: HTMLDivElement;
beforeEach(async () => {
// Clear component mappings
mockComponentMappings.length = 0;
// Import fresh module
vi.resetModules();
const module = await import('~/components/Wrapper/mount-engine');
mountVueApp = module.mountVueApp;
unmountVueApp = module.unmountVueApp;
getMountedApp = module.getMountedApp;
autoMountComponent = module.autoMountComponent;
mountUnifiedApp = module.mountUnifiedApp;
autoMountAllComponents = module.autoMountAllComponents;
TestComponent = defineComponent({
name: 'TestComponent',
@@ -96,526 +94,319 @@ describe('mount-engine', () => {
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
consoleDebugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
testContainer = document.createElement('div');
testContainer.id = 'test-container';
document.body.appendChild(testContainer);
vi.clearAllMocks();
// Clear mounted apps from previous tests
if (window.mountedApps) {
window.mountedApps.clear();
}
// Clean up DOM
document.body.innerHTML = '';
});
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
if (window.mountedApps) {
window.mountedApps.clear();
// Clean up global references
if (window.__unifiedApp) {
delete window.__unifiedApp;
}
if (window.__mountedComponents) {
delete window.__mountedComponents;
}
});
describe('mountVueApp', () => {
it('should mount a Vue app to a single element', () => {
describe('mountUnifiedApp', () => {
it('should create and mount a unified app with shared context', async () => {
// Add a component mapping
const element = document.createElement('div');
element.id = 'app';
element.id = 'test-app';
document.body.appendChild(element);
const app = mountVueApp({
mockComponentMappings.push({
selector: '#test-app',
appId: 'test-app',
component: TestComponent,
selector: '#app',
});
const app = mountUnifiedApp();
expect(app).toBeTruthy();
expect(element.querySelector('.test-component')).toBeTruthy();
expect(element.textContent).toBe('Hello');
expect(mockEnsureTeleportContainer).toHaveBeenCalled();
});
expect(mockI18n.install).toHaveBeenCalled();
expect(mockGlobalPinia.install).toHaveBeenCalled();
it('should mount with custom props', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
props: { message: 'Custom Message' },
// Wait for async component to render
await vi.waitFor(() => {
expect(element.querySelector('.test-component')).toBeTruthy();
});
expect(app).toBeTruthy();
expect(element.textContent).toBe('Custom Message');
// Check that component was rendered
expect(element.textContent).toContain('Hello');
expect(element.getAttribute('data-vue-mounted')).toBe('true');
expect(element.classList.contains('unapi')).toBe(true);
});
it('should parse props from element attributes', () => {
it('should parse props from element attributes', async () => {
const element = document.createElement('div');
element.id = 'app';
element.id = 'test-app';
element.setAttribute('message', 'Attribute Message');
document.body.appendChild(element);
const app = mountVueApp({
mockComponentMappings.push({
selector: '#test-app',
appId: 'test-app',
component: TestComponent,
selector: '#app',
});
expect(app).toBeTruthy();
expect(element.textContent).toBe('Attribute Message');
mountUnifiedApp();
// Wait for async component to render
await vi.waitFor(() => {
expect(element.textContent).toContain('Attribute Message');
});
});
it('should parse JSON props from attributes', () => {
it('should handle JSON props from attributes', () => {
const element = document.createElement('div');
element.id = 'app';
element.id = 'test-app';
element.setAttribute('message', '{"text": "JSON Message"}');
document.body.appendChild(element);
const app = mountVueApp({
mockComponentMappings.push({
selector: '#test-app',
appId: 'test-app',
component: TestComponent,
selector: '#app',
});
expect(app).toBeTruthy();
mountUnifiedApp();
// The component receives the parsed JSON object
expect(element.getAttribute('message')).toBe('{"text": "JSON Message"}');
});
it('should handle HTML-encoded JSON in attributes', () => {
const element = document.createElement('div');
element.id = 'app';
element.id = 'test-app';
element.setAttribute('message', '{&quot;text&quot;: &quot;Encoded&quot;}');
document.body.appendChild(element);
const app = mountVueApp({
mockComponentMappings.push({
selector: '#test-app',
appId: 'test-app',
component: TestComponent,
selector: '#app',
});
expect(app).toBeTruthy();
mountUnifiedApp();
expect(element.getAttribute('message')).toBe('{&quot;text&quot;: &quot;Encoded&quot;}');
});
it('should mount to multiple elements', () => {
it('should handle multiple selector aliases', async () => {
const element1 = document.createElement('div');
element1.className = 'multi-mount';
element1.id = 'app1';
document.body.appendChild(element1);
const element2 = document.createElement('div');
element2.className = 'multi-mount';
element2.className = 'app-alt';
document.body.appendChild(element2);
const app = mountVueApp({
// Component with multiple selector aliases - only first match mounts
mockComponentMappings.push({
selector: ['#app1', '.app-alt'],
appId: 'multi-selector',
component: TestComponent,
selector: '.multi-mount',
});
expect(app).toBeTruthy();
expect(element1.querySelector('.test-component')).toBeTruthy();
expect(element2.querySelector('.test-component')).toBeTruthy();
mountUnifiedApp();
// Wait for async component to render
await vi.waitFor(() => {
expect(element1.querySelector('.test-component')).toBeTruthy();
});
// Only the first matching element should be mounted
expect(element1.getAttribute('data-vue-mounted')).toBe('true');
// Second element should not be mounted (first match wins)
expect(element2.querySelector('.test-component')).toBeFalsy();
expect(element2.getAttribute('data-vue-mounted')).toBeNull();
});
it('should use shadow root when specified', () => {
it('should handle async component loaders', async () => {
const element = document.createElement('div');
element.id = 'shadow-app';
element.id = 'async-app';
document.body.appendChild(element);
const app = mountVueApp({
mockComponentMappings.push({
selector: '#async-app',
appId: 'async-app',
component: TestComponent,
selector: '#shadow-app',
useShadowRoot: true,
});
expect(app).toBeTruthy();
expect(element.shadowRoot).toBeTruthy();
expect(element.shadowRoot?.querySelector('#app')).toBeTruthy();
expect(element.shadowRoot?.querySelector('.test-component')).toBeTruthy();
mountUnifiedApp();
// Wait for component to mount
await vi.waitFor(() => {
expect(element.querySelector('.test-component')).toBeTruthy();
});
});
it('should clean up existing Vue attributes', () => {
it('should skip already mounted elements', () => {
const element = document.createElement('div');
element.id = 'app';
element.id = 'already-mounted';
element.setAttribute('data-vue-mounted', 'true');
element.setAttribute('data-v-app', '');
element.setAttribute('data-server-rendered', 'true');
element.setAttribute('data-v-123', '');
document.body.appendChild(element);
mountVueApp({
mockComponentMappings.push({
selector: '#already-mounted',
appId: 'already-mounted',
component: TestComponent,
selector: '#app',
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[VueMountApp] Element #app has Vue attributes but no content, cleaning up'
);
mountUnifiedApp();
// Should not mount to already mounted element
expect(element.querySelector('.test-component')).toBeFalsy();
});
it('should handle elements with problematic child nodes', () => {
const element = document.createElement('div');
element.id = 'app';
element.appendChild(document.createTextNode(' '));
element.appendChild(document.createComment('test comment'));
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
});
expect(app).toBeTruthy();
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[VueMountApp] Cleaning up problematic nodes in #app before mounting'
);
});
it('should return null when no elements found', () => {
const app = mountVueApp({
component: TestComponent,
it('should handle missing elements gracefully', () => {
mockComponentMappings.push({
selector: '#non-existent',
appId: 'non-existent',
component: TestComponent,
});
expect(app).toBeNull();
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[VueMountApp] No elements found for any selector: #non-existent'
const app = mountUnifiedApp();
// Should still create the app successfully
expect(app).toBeTruthy();
// No errors should be thrown
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('should error on invalid component mapping', async () => {
const element = document.createElement('div');
element.id = 'invalid-app';
document.body.appendChild(element);
// Invalid mapping - no component
mockComponentMappings.push({
selector: '#invalid-app',
appId: 'invalid-app',
} as ComponentMapping);
mountUnifiedApp();
// Should log error for missing component
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[UnifiedMount] No component defined for invalid-app'
);
});
it('should add unapi class to mounted elements', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#app',
});
expect(element.classList.contains('unapi')).toBe(true);
expect(element.getAttribute('data-vue-mounted')).toBe('true');
});
it('should skip disconnected elements during multi-mount', () => {
const element1 = document.createElement('div');
element1.className = 'multi';
document.body.appendChild(element1);
const element2 = document.createElement('div');
element2.className = 'multi';
// This element is NOT added to the document
// Create a third element and manually add it to element1 to simulate DOM issues
const orphanedChild = document.createElement('span');
element1.appendChild(orphanedChild);
// Now remove element1 from DOM temporarily to trigger the warning
element1.remove();
// Add element1 back
document.body.appendChild(element1);
// Create elements matching the selector
document.body.innerHTML = '';
const validElement = document.createElement('div');
validElement.className = 'multi';
document.body.appendChild(validElement);
const disconnectedElement = document.createElement('div');
disconnectedElement.className = 'multi';
const container = document.createElement('div');
container.appendChild(disconnectedElement);
// Now disconnectedElement has a parent but that parent is not in the document
const app = mountVueApp({
component: TestComponent,
selector: '.multi',
});
expect(app).toBeTruthy();
// The app should mount only to the connected element
expect(validElement.querySelector('.test-component')).toBeTruthy();
});
});
describe('unmountVueApp', () => {
it('should unmount a mounted app', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
appId: 'test-app',
});
expect(app).toBeTruthy();
expect(getMountedApp('test-app')).toBe(app);
const result = unmountVueApp('test-app');
expect(result).toBe(true);
expect(getMountedApp('test-app')).toBeUndefined();
});
it('should clean up data attributes on unmount', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#app',
appId: 'test-app',
});
expect(element.getAttribute('data-vue-mounted')).toBe('true');
expect(element.classList.contains('unapi')).toBe(true);
unmountVueApp('test-app');
// Component should not be rendered without a valid component
expect(element.querySelector('.test-component')).toBeFalsy();
expect(element.getAttribute('data-vue-mounted')).toBeNull();
});
it('should unmount cloned apps', () => {
it('should create hidden root element if not exists', () => {
mountUnifiedApp();
const rootElement = document.getElementById('unraid-unified-root');
expect(rootElement).toBeTruthy();
expect(rootElement?.style.display).toBe('none');
});
it('should reuse existing root element', () => {
// Create root element first
const existingRoot = document.createElement('div');
existingRoot.id = 'unraid-unified-root';
document.body.appendChild(existingRoot);
mountUnifiedApp();
const rootElement = document.getElementById('unraid-unified-root');
expect(rootElement).toBe(existingRoot);
});
it('should wrap components in UApp for Nuxt UI support', async () => {
const element = document.createElement('div');
element.id = 'wrapped-app';
document.body.appendChild(element);
mockComponentMappings.push({
selector: '#wrapped-app',
appId: 'wrapped-app',
component: TestComponent,
});
mountUnifiedApp();
// Wait for async component to render
await vi.waitFor(() => {
expect(element.querySelector('.u-app')).toBeTruthy();
});
// Check that UApp wrapper is present
expect(element.querySelector('.u-app .test-component')).toBeTruthy();
});
it('should share app context across all components', async () => {
const element1 = document.createElement('div');
element1.className = 'multi';
element1.id = 'app1';
document.body.appendChild(element1);
const element2 = document.createElement('div');
element2.className = 'multi';
element2.id = 'app2';
document.body.appendChild(element2);
mountVueApp({
component: TestComponent,
selector: '.multi',
appId: 'multi-app',
});
const result = unmountVueApp('multi-app');
expect(result).toBe(true);
});
it('should remove shadow root containers', () => {
const element = document.createElement('div');
element.id = 'shadow-app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#shadow-app',
appId: 'shadow-app',
useShadowRoot: true,
});
expect(element.shadowRoot?.querySelector('#app')).toBeTruthy();
unmountVueApp('shadow-app');
expect(element.shadowRoot?.querySelector('#app')).toBeFalsy();
});
it('should warn when unmounting non-existent app', () => {
const result = unmountVueApp('non-existent');
expect(result).toBe(false);
expect(consoleWarnSpy).toHaveBeenCalledWith('[VueMountApp] No app found with id: non-existent');
});
it('should handle unmount errors gracefully', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
const app = mountVueApp({
component: TestComponent,
selector: '#app',
appId: 'test-app',
});
// Force an error by corrupting the app
if (app) {
(app as { unmount: () => void }).unmount = () => {
throw new Error('Unmount error');
};
}
const result = unmountVueApp('test-app');
expect(result).toBe(true);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[VueMountApp] Error unmounting app test-app:',
expect.any(Error)
mockComponentMappings.push(
{
selector: '#app1',
appId: 'app1',
component: TestComponent,
},
{
selector: '#app2',
appId: 'app2',
component: TestComponent,
}
);
});
});
describe('getMountedApp', () => {
it('should return mounted app by id', () => {
const element = document.createElement('div');
element.id = 'app';
document.body.appendChild(element);
mountUnifiedApp();
const app = mountVueApp({
component: TestComponent,
selector: '#app',
appId: 'test-app',
// Wait for async components to render
await vi.waitFor(() => {
expect(element1.querySelector('.test-component')).toBeTruthy();
expect(element2.querySelector('.test-component')).toBeTruthy();
});
expect(getMountedApp('test-app')).toBe(app);
});
it('should return undefined for non-existent app', () => {
expect(getMountedApp('non-existent')).toBeUndefined();
// Only one Pinia instance should be installed
expect(mockGlobalPinia.install).toHaveBeenCalledTimes(1);
// Only one i18n instance should be installed
expect(mockI18n.install).toHaveBeenCalledTimes(1);
});
});
describe('autoMountComponent', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should auto-mount when DOM is ready', async () => {
describe('autoMountAllComponents', () => {
it('should call mountUnifiedApp', async () => {
const element = document.createElement('div');
element.id = 'auto-app';
document.body.appendChild(element);
autoMountComponent(TestComponent, '#auto-app');
await vi.runAllTimersAsync();
expect(element.querySelector('.test-component')).toBeTruthy();
});
it('should wait for DOMContentLoaded if document is loading', async () => {
Object.defineProperty(document, 'readyState', {
value: 'loading',
writable: true,
});
const element = document.createElement('div');
element.id = 'auto-app';
document.body.appendChild(element);
autoMountComponent(TestComponent, '#auto-app');
expect(element.querySelector('.test-component')).toBeFalsy();
Object.defineProperty(document, 'readyState', {
value: 'complete',
writable: true,
});
document.dispatchEvent(new Event('DOMContentLoaded'));
await vi.runAllTimersAsync();
expect(element.querySelector('.test-component')).toBeTruthy();
});
it('should skip auto-mount for already mounted modals', async () => {
const element1 = document.createElement('div');
element1.id = 'modals';
document.body.appendChild(element1);
mountVueApp({
mockComponentMappings.push({
selector: '#auto-app',
appId: 'auto-app',
component: TestComponent,
selector: '#modals',
appId: 'modals',
});
autoMountComponent(TestComponent, '#modals');
await vi.runAllTimersAsync();
autoMountAllComponents();
expect(consoleDebugSpy).toHaveBeenCalledWith(
'[VueMountApp] Component already mounted as modals for selector #modals, returning existing instance'
);
});
it('should mount immediately for all selectors', async () => {
const element = document.createElement('div');
element.id = 'unraid-connect-settings';
document.body.appendChild(element);
autoMountComponent(TestComponent, '#unraid-connect-settings');
// Component should mount immediately without delay
await vi.runAllTimersAsync();
expect(element.querySelector('.test-component')).toBeTruthy();
});
it('should mount even when element is hidden', async () => {
const element = document.createElement('div');
element.id = 'hidden-app';
element.style.display = 'none';
document.body.appendChild(element);
autoMountComponent(TestComponent, '#hidden-app');
await vi.runAllTimersAsync();
// Hidden elements should still mount successfully
expect(element.querySelector('.test-component')).toBeTruthy();
expect(consoleWarnSpy).not.toHaveBeenCalledWith(
expect.stringContaining('No valid DOM elements found')
);
});
it('should handle nextSibling errors with retry', async () => {
const element = document.createElement('div');
element.id = 'error-app';
element.setAttribute('data-vue-mounted', 'true');
element.setAttribute('data-v-app', '');
document.body.appendChild(element);
// Simulate the element having Vue instance references which cause nextSibling errors
const mockVueInstance = { appContext: { app: {} as VueApp } };
(element as HTMLElementWithVue).__vueParentComponent = mockVueInstance;
// Add an invalid child that will trigger cleanup
const textNode = document.createTextNode(' ');
element.appendChild(textNode);
autoMountComponent(TestComponent, '#error-app');
await vi.runAllTimersAsync();
// Should detect and clean up existing Vue state
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'[VueMountApp] Element #error-app has Vue attributes but no content, cleaning up'
)
);
// Should successfully mount after cleanup
expect(element.querySelector('.test-component')).toBeTruthy();
});
it('should pass options to mountVueApp', async () => {
const element = document.createElement('div');
element.id = 'options-app';
document.body.appendChild(element);
autoMountComponent(TestComponent, '#options-app', {
props: { message: 'Auto Mount Message' },
useShadowRoot: true,
// Wait for async component to render
await vi.waitFor(() => {
expect(element.querySelector('.test-component')).toBeTruthy();
});
await vi.runAllTimersAsync();
expect(element.shadowRoot).toBeTruthy();
expect(element.shadowRoot?.textContent).toContain('Auto Mount Message');
});
});
describe('i18n setup', () => {
it('should setup i18n with default locale', () => {
const element = document.createElement('div');
element.id = 'i18n-app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#i18n-app',
});
mountUnifiedApp();
expect(mockI18n.install).toHaveBeenCalled();
});
@@ -627,14 +418,7 @@ describe('mount-engine', () => {
JSON.stringify(localeData)
);
const element = document.createElement('div');
element.id = 'i18n-app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#i18n-app',
});
mountUnifiedApp();
delete (window as unknown as Record<string, unknown>).LOCALE_DATA;
});
@@ -642,14 +426,7 @@ describe('mount-engine', () => {
it('should handle locale data parsing errors', () => {
(window as unknown as Record<string, unknown>).LOCALE_DATA = 'invalid json';
const element = document.createElement('div');
element.id = 'i18n-app';
document.body.appendChild(element);
mountVueApp({
component: TestComponent,
selector: '#i18n-app',
});
mountUnifiedApp();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[VueMountApp] error parsing messages',
@@ -660,60 +437,28 @@ describe('mount-engine', () => {
});
});
describe('error recovery', () => {
it('should attempt recovery from nextSibling error', async () => {
vi.useFakeTimers();
describe('global exposure', () => {
it('should expose unified app globally', () => {
const app = mountUnifiedApp();
expect(window.__unifiedApp).toBe(app);
});
it('should expose mounted components globally', () => {
const element = document.createElement('div');
element.id = 'recovery-app';
element.id = 'global-app';
document.body.appendChild(element);
// Create a mock Vue app that throws on first mount attempt
let mountAttempt = 0;
const mockApp = {
use: vi.fn().mockReturnThis(),
provide: vi.fn().mockReturnThis(),
mount: vi.fn().mockImplementation(() => {
mountAttempt++;
if (mountAttempt === 1) {
const error = new TypeError('Cannot read property nextSibling of null');
throw error;
}
return mockApp;
}),
unmount: vi.fn(),
version: '3.0.0',
config: { globalProperties: {} },
};
// Mock createApp using module mock
const vueModule = await import('vue');
vi.spyOn(vueModule, 'createApp').mockReturnValue(mockApp as never);
mountVueApp({
mockComponentMappings.push({
selector: '#global-app',
appId: 'global-app',
component: TestComponent,
selector: '#recovery-app',
appId: 'recovery-app',
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
'[VueMountApp] Attempting recovery from nextSibling error for #recovery-app'
);
mountUnifiedApp();
await vi.advanceTimersByTimeAsync(10);
expect(consoleInfoSpy).toHaveBeenCalledWith(
'[VueMountApp] Successfully recovered from nextSibling error for #recovery-app'
);
vi.useRealTimers();
});
});
describe('global exposure', () => {
it('should expose mountedApps globally', () => {
expect(window.mountedApps).toBeDefined();
expect(window.mountedApps).toBeInstanceOf(Map);
expect(window.__mountedComponents).toBeDefined();
expect(Array.isArray(window.__mountedComponents)).toBe(true);
expect(window.__mountedComponents!.length).toBe(1);
});
it('should expose globalPinia globally', () => {
@@ -721,4 +466,23 @@ describe('mount-engine', () => {
expect(window.globalPinia).toBe(mockGlobalPinia);
});
});
describe('performance debugging', () => {
it('should not log timing by default', () => {
const element = document.createElement('div');
element.id = 'perf-app';
document.body.appendChild(element);
mockComponentMappings.push({
selector: '#perf-app',
appId: 'perf-app',
component: TestComponent,
});
mountUnifiedApp();
// Should not log timing information when PERF_DEBUG is false
expect(consoleWarnSpy).not.toHaveBeenCalledWith(expect.stringContaining('[UnifiedMount] Mounted'));
});
});
});

View File

@@ -1,55 +1,55 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock all the component imports
vi.mock('~/components/Auth.ce.vue', () => ({
vi.mock('~/components/Auth.standalone.vue', () => ({
default: { name: 'MockAuth', template: '<div>Auth</div>' },
}));
vi.mock('~/components/ConnectSettings/ConnectSettings.ce.vue', () => ({
vi.mock('~/components/ConnectSettings/ConnectSettings.standalone.vue', () => ({
default: { name: 'MockConnectSettings', template: '<div>ConnectSettings</div>' },
}));
vi.mock('~/components/DownloadApiLogs.ce.vue', () => ({
vi.mock('~/components/DownloadApiLogs.standalone.vue', () => ({
default: { name: 'MockDownloadApiLogs', template: '<div>DownloadApiLogs</div>' },
}));
vi.mock('~/components/HeaderOsVersion.ce.vue', () => ({
vi.mock('~/components/HeaderOsVersion.standalone.vue', () => ({
default: { name: 'MockHeaderOsVersion', template: '<div>HeaderOsVersion</div>' },
}));
vi.mock('~/components/Modals.ce.vue', () => ({
vi.mock('~/components/Modals.standalone.vue', () => ({
default: { name: 'MockModals', template: '<div>Modals</div>' },
}));
vi.mock('~/components/UserProfile.ce.vue', () => ({
vi.mock('~/components/UserProfile.standalone.vue', () => ({
default: { name: 'MockUserProfile', template: '<div>UserProfile</div>' },
}));
vi.mock('~/components/UpdateOs.ce.vue', () => ({
vi.mock('~/components/UpdateOs.standalone.vue', () => ({
default: { name: 'MockUpdateOs', template: '<div>UpdateOs</div>' },
}));
vi.mock('~/components/DowngradeOs.ce.vue', () => ({
vi.mock('~/components/DowngradeOs.standalone.vue', () => ({
default: { name: 'MockDowngradeOs', template: '<div>DowngradeOs</div>' },
}));
vi.mock('~/components/Registration.ce.vue', () => ({
vi.mock('~/components/Registration.standalone.vue', () => ({
default: { name: 'MockRegistration', template: '<div>Registration</div>' },
}));
vi.mock('~/components/WanIpCheck.ce.vue', () => ({
vi.mock('~/components/WanIpCheck.standalone.vue', () => ({
default: { name: 'MockWanIpCheck', template: '<div>WanIpCheck</div>' },
}));
vi.mock('~/components/Activation/WelcomeModal.ce.vue', () => ({
vi.mock('~/components/Activation/WelcomeModal.standalone.vue', () => ({
default: { name: 'MockWelcomeModal', template: '<div>WelcomeModal</div>' },
}));
vi.mock('~/components/SsoButton.ce.vue', () => ({
vi.mock('~/components/SsoButton.standalone.vue', () => ({
default: { name: 'MockSsoButton', template: '<div>SsoButton</div>' },
}));
vi.mock('~/components/Logs/LogViewer.ce.vue', () => ({
vi.mock('~/components/Logs/LogViewer.standalone.vue', () => ({
default: { name: 'MockLogViewer', template: '<div>LogViewer</div>' },
}));
vi.mock('~/components/ThemeSwitcher.ce.vue', () => ({
vi.mock('~/components/ThemeSwitcher.standalone.vue', () => ({
default: { name: 'MockThemeSwitcher', template: '<div>ThemeSwitcher</div>' },
}));
vi.mock('~/components/ApiKeyPage.ce.vue', () => ({
vi.mock('~/components/ApiKeyPage.standalone.vue', () => ({
default: { name: 'MockApiKeyPage', template: '<div>ApiKeyPage</div>' },
}));
vi.mock('~/components/DevModalTest.ce.vue', () => ({
vi.mock('~/components/DevModalTest.standalone.vue', () => ({
default: { name: 'MockDevModalTest', template: '<div>DevModalTest</div>' },
}));
vi.mock('~/components/ApiKeyAuthorize.ce.vue', () => ({
vi.mock('~/components/ApiKeyAuthorize.standalone.vue', () => ({
default: { name: 'MockApiKeyAuthorize', template: '<div>ApiKeyAuthorize</div>' },
}));
vi.mock('~/components/UnraidToaster.vue', () => ({
@@ -57,16 +57,12 @@ vi.mock('~/components/UnraidToaster.vue', () => ({
}));
// Mock mount-engine module
const mockAutoMountComponent = vi.fn();
const mockAutoMountAllComponents = vi.fn();
const mockMountVueApp = vi.fn();
const mockGetMountedApp = vi.fn();
const mockMountUnifiedApp = vi.fn();
vi.mock('~/components/Wrapper/mount-engine', () => ({
autoMountComponent: mockAutoMountComponent,
autoMountAllComponents: mockAutoMountAllComponents,
mountVueApp: mockMountVueApp,
getMountedApp: mockGetMountedApp,
mountUnifiedApp: mockMountUnifiedApp,
}));
// Mock theme initializer
@@ -104,10 +100,7 @@ vi.mock('graphql', () => ({
}));
// Mock @unraid/ui
const mockEnsureTeleportContainer = vi.fn();
vi.mock('@unraid/ui', () => ({
ensureTeleportContainer: mockEnsureTeleportContainer,
}));
vi.mock('@unraid/ui', () => ({}));
describe('component-registry', () => {
beforeEach(() => {
@@ -151,10 +144,12 @@ describe('component-registry', () => {
expect(mockInitializeTheme).toHaveBeenCalled();
});
it('should ensure teleport container exists', async () => {
it('should mount unified app with components', async () => {
await import('~/components/Wrapper/auto-mount');
expect(mockEnsureTeleportContainer).toHaveBeenCalled();
// The unified app architecture no longer requires teleport container setup per component
// Instead it uses a unified approach
expect(mockAutoMountAllComponents).toHaveBeenCalled();
});
});
@@ -188,26 +183,29 @@ describe('component-registry', () => {
it('should expose utility functions globally', async () => {
await import('~/components/Wrapper/auto-mount');
expect(window.mountVueApp).toBe(mockMountVueApp);
expect(window.getMountedApp).toBe(mockGetMountedApp);
expect(window.autoMountComponent).toBe(mockAutoMountComponent);
// With unified app architecture, these are exposed instead:
expect(window.apolloClient).toBe(mockApolloClient);
expect(window.gql).toBe(mockParse);
expect(window.graphqlParse).toBe(mockParse);
// The unified app itself is exposed via window.__unifiedApp after mounting
});
it('should expose mountVueApp function globally', async () => {
it('should not expose legacy mount functions', async () => {
await import('~/components/Wrapper/auto-mount');
// Check that mountVueApp is exposed
expect(typeof window.mountVueApp).toBe('function');
// Note: Dynamic mount functions are no longer created automatically
// They would be created via mountVueApp calls
// These functions are no longer exposed in the unified app architecture
expect(window.mountVueApp).toBeUndefined();
expect(window.getMountedApp).toBeUndefined();
expect(window.autoMountComponent).toBeUndefined();
});
it('should expose autoMountComponent function globally', async () => {
it('should expose apollo client and graphql utilities', async () => {
await import('~/components/Wrapper/auto-mount');
// Check that autoMountComponent is exposed
expect(typeof window.autoMountComponent).toBe('function');
// Check that Apollo client and GraphQL utilities are exposed
expect(window.apolloClient).toBeDefined();
expect(typeof window.gql).toBe('function');
expect(typeof window.graphqlParse).toBe('function');
});
});
});

86
web/auto-imports.d.ts vendored
View File

@@ -6,57 +6,57 @@
// biome-ignore lint: disable
export {}
declare global {
const avatarGroupInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useAvatarGroup.js')['avatarGroupInjectionKey']
const defineLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineLocale.js')['defineLocale']
const defineShortcuts: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.js')['defineShortcuts']
const extendLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineLocale.js')['extendLocale']
const extractShortcuts: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.js')['extractShortcuts']
const fieldGroupInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFieldGroup.js')['fieldGroupInjectionKey']
const formBusInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formBusInjectionKey']
const formFieldInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formFieldInjectionKey']
const formInputsInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formInputsInjectionKey']
const formLoadingInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formLoadingInjectionKey']
const formOptionsInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formOptionsInjectionKey']
const inputIdInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['inputIdInjectionKey']
const kbdKeysMap: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.js')['kbdKeysMap']
const localeContextInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useLocale.js')['localeContextInjectionKey']
const portalTargetInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/usePortal.js')['portalTargetInjectionKey']
const useAppConfig: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/vue/composables/useAppConfig.js')['useAppConfig']
const useAvatarGroup: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useAvatarGroup.js')['useAvatarGroup']
const useComponentIcons: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.js')['useComponentIcons']
const useContentSearch: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useContentSearch.js')['useContentSearch']
const useFieldGroup: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFieldGroup.js')['useFieldGroup']
const useFileUpload: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.js')['useFileUpload']
const useFormField: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['useFormField']
const useKbd: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.js')['useKbd']
const useLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useLocale.js')['useLocale']
const useOverlay: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.js')['useOverlay']
const usePortal: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/usePortal.js')['usePortal']
const useResizable: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.js')['useResizable']
const useScrollspy: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useScrollspy.js')['useScrollspy']
const useToast: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useToast.js')['useToast']
const avatarGroupInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useAvatarGroup.js')['avatarGroupInjectionKey']
const defineLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineLocale.js')['defineLocale']
const defineShortcuts: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.js')['defineShortcuts']
const extendLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineLocale.js')['extendLocale']
const extractShortcuts: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.js')['extractShortcuts']
const fieldGroupInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFieldGroup.js')['fieldGroupInjectionKey']
const formBusInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formBusInjectionKey']
const formFieldInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formFieldInjectionKey']
const formInputsInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formInputsInjectionKey']
const formLoadingInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formLoadingInjectionKey']
const formOptionsInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formOptionsInjectionKey']
const inputIdInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['inputIdInjectionKey']
const kbdKeysMap: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.js')['kbdKeysMap']
const localeContextInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useLocale.js')['localeContextInjectionKey']
const portalTargetInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/usePortal.js')['portalTargetInjectionKey']
const useAppConfig: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/vue/composables/useAppConfig.js')['useAppConfig']
const useAvatarGroup: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useAvatarGroup.js')['useAvatarGroup']
const useComponentIcons: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.js')['useComponentIcons']
const useContentSearch: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useContentSearch.js')['useContentSearch']
const useFieldGroup: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFieldGroup.js')['useFieldGroup']
const useFileUpload: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.js')['useFileUpload']
const useFormField: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['useFormField']
const useKbd: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.js')['useKbd']
const useLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useLocale.js')['useLocale']
const useOverlay: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.js')['useOverlay']
const usePortal: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/usePortal.js')['usePortal']
const useResizable: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.js')['useResizable']
const useScrollspy: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useScrollspy.js')['useScrollspy']
const useToast: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useToast.js')['useToast']
}
// for type re-export
declare global {
// @ts-ignore
export type { ShortcutConfig, ShortcutsConfig, ShortcutsOptions } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.d'
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.d')
export type { ShortcutConfig, ShortcutsConfig, ShortcutsOptions } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.d'
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.d')
// @ts-ignore
export type { UseComponentIconsProps } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.d'
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.d')
export type { UseComponentIconsProps } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.d'
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.d')
// @ts-ignore
export type { UseFileUploadOptions } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.d'
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.d')
export type { UseFileUploadOptions } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.d'
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.d')
// @ts-ignore
export type { KbdKey, KbdKeySpecific } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.d'
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.d')
export type { KbdKey, KbdKeySpecific } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.d'
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.d')
// @ts-ignore
export type { OverlayOptions, Overlay } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.d'
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.d')
export type { OverlayOptions, Overlay } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.d'
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.d')
// @ts-ignore
export type { UseResizableProps, UseResizableReturn } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.d'
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.d')
export type { UseResizableProps, UseResizableReturn } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.d'
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.d')
// @ts-ignore
export type { Toast } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useToast.d'
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useToast.d')
export type { Toast } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useToast.d'
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/composables/useToast.d')
}

69
web/components.d.ts vendored
View File

@@ -12,17 +12,17 @@ declare module 'vue' {
ActivationPartnerLogo: typeof import('./src/components/Activation/ActivationPartnerLogo.vue')['default']
ActivationPartnerLogoImg: typeof import('./src/components/Activation/ActivationPartnerLogoImg.vue')['default']
ActivationSteps: typeof import('./src/components/Activation/ActivationSteps.vue')['default']
'ApiKeyAuthorize.ce': typeof import('./src/components/ApiKeyAuthorize.ce.vue')['default']
'ApiKeyAuthorize.standalone': typeof import('./src/components/ApiKeyAuthorize.standalone.vue')['default']
ApiKeyCreate: typeof import('./src/components/ApiKey/ApiKeyCreate.vue')['default']
ApiKeyManager: typeof import('./src/components/ApiKey/ApiKeyManager.vue')['default']
'ApiKeyPage.ce': typeof import('./src/components/ApiKeyPage.ce.vue')['default']
'Auth.ce': typeof import('./src/components/Auth.ce.vue')['default']
'ApiKeyPage.standalone': typeof import('./src/components/ApiKeyPage.standalone.vue')['default']
'Auth.standalone': typeof import('./src/components/Auth.standalone.vue')['default']
Avatar: typeof import('./src/components/Brand/Avatar.vue')['default']
Beta: typeof import('./src/components/UserProfile/Beta.vue')['default']
CallbackButton: typeof import('./src/components/UpdateOs/CallbackButton.vue')['default']
CallbackFeedback: typeof import('./src/components/UserProfile/CallbackFeedback.vue')['default']
CallbackFeedbackStatus: typeof import('./src/components/UserProfile/CallbackFeedbackStatus.vue')['default']
'CallbackHandler.ce': typeof import('./src/components/CallbackHandler.ce.vue')['default']
'CallbackHandler.standalone': typeof import('./src/components/CallbackHandler.standalone.vue')['default']
Card: typeof import('./src/components/LayoutViews/Card/Card.vue')['default']
CardGrid: typeof import('./src/components/LayoutViews/Card/CardGrid.vue')['default']
CardGroupHeader: typeof import('./src/components/LayoutViews/Card/CardGroupHeader.vue')['default']
@@ -30,20 +30,21 @@ declare module 'vue' {
CardItem: typeof import('./src/components/LayoutViews/Card/CardItem.vue')['default']
ChangelogModal: typeof import('./src/components/UpdateOs/ChangelogModal.vue')['default']
CheckUpdateResponseModal: typeof import('./src/components/UpdateOs/CheckUpdateResponseModal.vue')['default']
'ColorSwitcher.ce': typeof import('./src/components/ColorSwitcher.ce.vue')['default']
'ConnectSettings.ce': typeof import('./src/components/ConnectSettings/ConnectSettings.ce.vue')['default']
'ColorSwitcher.standalone': typeof import('./src/components/ColorSwitcher.standalone.vue')['default']
ConfirmDialog: typeof import('./src/components/ConfirmDialog.vue')['default']
'ConnectSettings.standalone': typeof import('./src/components/ConnectSettings/ConnectSettings.standalone.vue')['default']
Console: typeof import('./src/components/Docker/Console.vue')['default']
Detail: typeof import('./src/components/LayoutViews/Detail/Detail.vue')['default']
DetailContentHeader: typeof import('./src/components/LayoutViews/Detail/DetailContentHeader.vue')['default']
DetailLeftNavigation: typeof import('./src/components/LayoutViews/Detail/DetailLeftNavigation.vue')['default']
DetailRightContent: typeof import('./src/components/LayoutViews/Detail/DetailRightContent.vue')['default']
'DetailTest.ce': typeof import('./src/components/LayoutViews/Detail/DetailTest.ce.vue')['default']
'DetailTest.standalone': typeof import('./src/components/LayoutViews/Detail/DetailTest.standalone.vue')['default']
DeveloperAuthorizationLink: typeof import('./src/components/ApiKey/DeveloperAuthorizationLink.vue')['default']
'DevModalTest.ce': typeof import('./src/components/DevModalTest.ce.vue')['default']
'DevModalTest.standalone': typeof import('./src/components/DevModalTest.standalone.vue')['default']
DevSettings: typeof import('./src/components/DevSettings.vue')['default']
Downgrade: typeof import('./src/components/UpdateOs/Downgrade.vue')['default']
'DowngradeOs.ce': typeof import('./src/components/DowngradeOs.ce.vue')['default']
'DownloadApiLogs.ce': typeof import('./src/components/DownloadApiLogs.ce.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']
@@ -57,7 +58,7 @@ declare module 'vue' {
FileViewer: typeof import('./src/components/FileViewer.vue')['default']
FilteredLogModal: typeof import('./src/components/Logs/FilteredLogModal.vue')['default']
HeaderContent: typeof import('./src/components/Docker/HeaderContent.vue')['default']
'HeaderOsVersion.ce': typeof import('./src/components/HeaderOsVersion.ce.vue')['default']
'HeaderOsVersion.standalone': typeof import('./src/components/HeaderOsVersion.standalone.vue')['default']
IgnoredRelease: typeof import('./src/components/UpdateOs/IgnoredRelease.vue')['default']
Indicator: typeof import('./src/components/Notifications/Indicator.vue')['default']
Item: typeof import('./src/components/Notifications/Item.vue')['default']
@@ -68,11 +69,11 @@ declare module 'vue' {
LogFilterInput: typeof import('./src/components/Logs/LogFilterInput.vue')['default']
Logo: typeof import('./src/components/Brand/Logo.vue')['default']
Logs: typeof import('./src/components/Docker/Logs.vue')['default']
'LogViewer.ce': typeof import('./src/components/Logs/LogViewer.ce.vue')['default']
'LogViewer.standalone': typeof import('./src/components/Logs/LogViewer.standalone.vue')['default']
LogViewerToolbar: typeof import('./src/components/Logs/LogViewerToolbar.vue')['default']
Mark: typeof import('./src/components/Brand/Mark.vue')['default']
Modal: typeof import('./src/components/Modal.vue')['default']
'Modals.ce': typeof import('./src/components/Modals.ce.vue')['default']
'Modals.standalone': typeof import('./src/components/Modals.standalone.vue')['default']
OidcDebugButton: typeof import('./src/components/Logs/OidcDebugButton.vue')['default']
OidcDebugLogs: typeof import('./src/components/ConnectSettings/OidcDebugLogs.vue')['default']
Overview: typeof import('./src/components/Docker/Overview.vue')['default']
@@ -81,7 +82,7 @@ declare module 'vue' {
RawChangelogRenderer: typeof import('./src/components/UpdateOs/RawChangelogRenderer.vue')['default']
RCloneConfig: typeof import('./src/components/RClone/RCloneConfig.vue')['default']
RCloneOverview: typeof import('./src/components/RClone/RCloneOverview.vue')['default']
'Registration.ce': typeof import('./src/components/Registration.ce.vue')['default']
'Registration.standalone': typeof import('./src/components/Registration.standalone.vue')['default']
ReleaseNotesModal: typeof import('./src/components/ReleaseNotesModal.vue')['default']
RemoteItem: typeof import('./src/components/RClone/RemoteItem.vue')['default']
ReplaceCheck: typeof import('./src/components/Registration/ReplaceCheck.vue')['default']
@@ -93,35 +94,37 @@ declare module 'vue' {
ServerStatus: typeof import('./src/components/UserProfile/ServerStatus.vue')['default']
Sidebar: typeof import('./src/components/Notifications/Sidebar.vue')['default']
SingleLogViewer: typeof import('./src/components/Logs/SingleLogViewer.vue')['default']
'SsoButton.ce': typeof import('./src/components/SsoButton.ce.vue')['default']
'SsoButton.standalone': typeof import('./src/components/SsoButton.standalone.vue')['default']
SsoButtons: typeof import('./src/components/sso/SsoButtons.vue')['default']
SsoProviderButton: typeof import('./src/components/sso/SsoProviderButton.vue')['default']
Status: typeof import('./src/components/UpdateOs/Status.vue')['default']
'ThemeSwitcher.ce': typeof import('./src/components/ThemeSwitcher.ce.vue')['default']
'TestThemeSwitcher.standalone': typeof import('./src/components/TestThemeSwitcher.standalone.vue')['default']
'TestUpdateModal.standalone': typeof import('./src/components/UpdateOs/TestUpdateModal.standalone.vue')['default']
'ThemeSwitcher.standalone': typeof import('./src/components/ThemeSwitcher.standalone.vue')['default']
ThirdPartyDrivers: typeof import('./src/components/UpdateOs/ThirdPartyDrivers.vue')['default']
Trial: typeof import('./src/components/UserProfile/Trial.vue')['default']
UBadge: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Badge.vue')['default']
UButton: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Button.vue')['default']
UCard: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Card.vue')['default']
UCheckbox: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Checkbox.vue')['default']
UDrawer: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Drawer.vue')['default']
UDropdownMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/DropdownMenu.vue')['default']
UFormField: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/FormField.vue')['default']
UIcon: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default']
UInput: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Input.vue')['default']
UNavigationMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/NavigationMenu.vue')['default']
UBadge: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Badge.vue')['default']
UButton: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Button.vue')['default']
UCard: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Card.vue')['default']
UCheckbox: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Checkbox.vue')['default']
UDrawer: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Drawer.vue')['default']
UDropdownMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/DropdownMenu.vue')['default']
UFormField: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/FormField.vue')['default']
UIcon: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default']
UInput: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Input.vue')['default']
UNavigationMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/NavigationMenu.vue')['default']
UnraidToaster: typeof import('./src/components/UnraidToaster.vue')['default']
Update: typeof import('./src/components/UpdateOs/Update.vue')['default']
UpdateExpiration: typeof import('./src/components/Registration/UpdateExpiration.vue')['default']
UpdateExpirationAction: typeof import('./src/components/Registration/UpdateExpirationAction.vue')['default']
UpdateIneligible: typeof import('./src/components/UpdateOs/UpdateIneligible.vue')['default']
'UpdateOs.ce': typeof import('./src/components/UpdateOs.ce.vue')['default']
'UpdateOs.standalone': typeof import('./src/components/UpdateOs.standalone.vue')['default']
UptimeExpire: typeof import('./src/components/UserProfile/UptimeExpire.vue')['default']
USelectMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default']
'UserProfile.ce': typeof import('./src/components/UserProfile.ce.vue')['default']
USwitch: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Switch.vue')['default']
UTabs: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Tabs.vue')['default']
'WanIpCheck.ce': typeof import('./src/components/WanIpCheck.ce.vue')['default']
'WelcomeModal.ce': typeof import('./src/components/Activation/WelcomeModal.ce.vue')['default']
USelectMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default']
'UserProfile.standalone': typeof import('./src/components/UserProfile.standalone.vue')['default']
USwitch: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Switch.vue')['default']
UTabs: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Tabs.vue')['default']
'WanIpCheck.standalone': typeof import('./src/components/WanIpCheck.standalone.vue')['default']
'WelcomeModal.standalone': typeof import('./src/components/Activation/WelcomeModal.standalone.vue')['default']
}
}

View File

@@ -103,6 +103,7 @@ export default [
parser: tseslint.parser,
parserOptions: {
...commonLanguageOptions,
tsconfigRootDir: import.meta.dirname,
ecmaFeatures: {
jsx: true,
},
@@ -128,6 +129,7 @@ export default [
parserOptions: {
...commonLanguageOptions,
parser: tseslint.parser,
tsconfigRootDir: import.meta.dirname,
ecmaFeatures: {
jsx: true,
},

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/web",
"version": "4.20.1",
"version": "4.22.1",
"private": true,
"type": "module",
"license": "GPL-2.0-or-later",
@@ -38,9 +38,9 @@
},
"devDependencies": {
"@eslint/js": "9.34.0",
"@graphql-codegen/cli": "5.0.7",
"@graphql-codegen/client-preset": "4.8.3",
"@graphql-codegen/introspection": "4.0.3",
"@graphql-codegen/cli": "6.0.0",
"@graphql-codegen/client-preset": "5.0.0",
"@graphql-codegen/introspection": "5.0.0",
"@graphql-typed-document-node/core": "3.2.0",
"@ianvs/prettier-plugin-sort-imports": "4.6.3",
"@pinia/testing": "1.0.2",

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