Compare commits

...

22 Commits

Author SHA1 Message Date
renovate[bot]
c4863118b3 Merge 6034a14074 into d73953f8ff 2025-09-30 16:41:32 +00:00
renovate[bot]
6034a14074 fix(deps): update dependency zod to v4 2025-09-30 16:41:29 +00:00
Squidly271
d73953f8ff fix: Connect won't appear within Apps - Previous Apps (#1727)
Manual removal of the .plg is never necessary. plugin script will
automatically move the .plg to /config/plugins-removed

Manual removal results in PHP errors and possible indeterminate state

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

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

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


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


### Features

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


### Bug Fixes

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

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

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

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

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

## Summary by CodeRabbit

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

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

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

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

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

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

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

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

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

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

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


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


### Bug Fixes

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

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

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-23 16:06:18 -04:00
Eli Bosley
378cdb7f10 fix: issue with incorrect permissions on viewer / other roles 2025-09-23 15:46:22 -04:00
Eli Bosley
d9c561bfeb fix: enhance version comparison logic in installation script
- Added normalization for version strings to improve semantic versioning comparisons.
- Updated the version comparison function to handle leading 'v' and ignore build metadata, ensuring accurate version checks during installation.
2025-09-23 11:40:13 -04:00
Eli Bosley
9972a5f178 fix: cleanup leftover removed packages on upgrade (#1719)
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211428391025524

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

* **New Features**
* Adds API version awareness for Unraid Connect: detects server vs.
connector API versions, notifies users, and skips installation to avoid
downgrades.

* **Bug Fixes**
* Enhanced pre-install cleanup removing stale files and leftovers to
improve install/upgrade reliability and clearer status reporting.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-23 11:35:06 -04:00
Eli Bosley
a44473c1d1 chore(api): update API version and enhance installation script (#1685)
- Enhanced the installation script in `dynamix.unraid.net.plg` to
include version comparison logic, preventing downgrades if a newer API
version is already installed.
- Added functionality to notify users of version conflicts during
installation.

This update improves the robustness of the installation process and
ensures compatibility with existing API versions.

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

* **New Features**
* Version-aware installation for the Connect API to prevent downgrades
when the server API is newer.
  * Web GUI notification when a version conflict is detected.

* **Improvements**
* Clearer install messaging when API installation is skipped or
proceeds.
* Safer, guarded install flow that only performs cleanup and
installation when appropriate.
  * Preserves existing behavior for README updates when applicable.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-23 11:21:25 -04:00
github-actions[bot]
ed9a5c5ff9 chore(main): release 4.24.0 (#1717)
🤖 I have created a release *beep* *boop*
---


## [4.24.0](https://github.com/unraid/api/compare/v4.23.1...v4.24.0)
(2025-09-18)


### Features

* improve dom content loading by being more efficient about component
mounting ([#1716](https://github.com/unraid/api/issues/1716))
([d8b166e](d8b166e4b6))

---
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-18 12:59:58 -04:00
Eli Bosley
d8b166e4b6 feat: improve dom content loading by being more efficient about component mounting (#1716)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- New Features
  - Faster, more scalable component auto-mounting via batch processing.
- More robust prop parsing (handles JSON vs. strings and HTML entities).
  - Improved locale data initialization during setup.

- Bug Fixes
- Prevents duplicate mounts and improves handling of empty/irrelevant
attributes.

- Refactor
  - Consolidated mounting flow and removed legacy runtime debug globals.

- Tests
  - Removed outdated tests tied to previous global exposures.

- Chores
- Updated type declarations; global client is now optional for improved
flexibility.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-18 12:50:24 -04:00
github-actions[bot]
8b862ecef5 chore(main): release 4.23.1 (#1715)
🤖 I have created a release *beep* *boop*
---


## [4.23.1](https://github.com/unraid/api/compare/v4.23.0...v4.23.1)
(2025-09-17)


### Bug Fixes

* cleanup ini parser logic with better fallbacks
([#1713](https://github.com/unraid/api/issues/1713))
([1691362](16913627de))

---
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-17 14:46:58 -04:00
Eli Bosley
16913627de fix: cleanup ini parser logic with better fallbacks (#1713)
Added a new parser for INI boolean values, including functions to
convert INI boolean strings to JavaScript booleans and handle malformed
inputs. Introduced unit tests to validate the functionality of both
`iniBooleanToJsBoolean` and `iniBooleanOrAutoToJsBoolean`, covering
various valid, malformed, and edge case scenarios. Updated state parsers
to utilize the new functions for improved reliability.
2025-09-17 13:59:57 -04:00
Eli Bosley
6b2f331941 chore: remove deprecated API documentation files and workflow (#1714)
This commit deletes the outdated API documentation files and the
associated GitHub Actions workflow for updating the API documentation.
All content has been permanently moved to the Unraid Docs repository.
2025-09-17 13:23:42 -04:00
renovate[bot]
8f02d96464 chore(deps): update dependency @types/uuid to v11 (#1711)
Coming soon: The Renovate bot (GitHub App) will be renamed to Mend. PRs
from Renovate will soon appear from 'Mend'. Learn more
[here](https://redirect.github.com/renovatebot/renovate/discussions/37842).

This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
|
[@types/uuid](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/uuid)
([source](https://redirect.github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/uuid))
| [`10.0.0` ->
`11.0.0`](https://renovatebot.com/diffs/npm/@types%2fuuid/10.0.0/11.0.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@types%2fuuid/11.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@types%2fuuid/10.0.0/11.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### 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-17 09:41:08 -04:00
Eli Bosley
caff5a78ba chore: fix webhook for release 2025-09-16 13:57:44 -04:00
github-actions[bot]
810be7a679 chore(main): release 4.23.0 (#1707)
🤖 I have created a release *beep* *boop*
---


## [4.23.0](https://github.com/unraid/api/compare/v4.22.2...v4.23.0)
(2025-09-16)


### Features

* add unraid api status manager
([#1708](https://github.com/unraid/api/issues/1708))
([1d9ce0a](1d9ce0aa3d))


### Bug Fixes

* **logging:** remove colorized logs
([#1705](https://github.com/unraid/api/issues/1705))
([1d2c670](1d2c6701ce))
* no sizeRootFs unless queried
([#1710](https://github.com/unraid/api/issues/1710))
([9714b21](9714b21c5c))
* use virtual-modal-container
([#1709](https://github.com/unraid/api/issues/1709))
([44b4d77](44b4d77d80))

---
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-16 13:18:09 -04:00
62 changed files with 1963 additions and 2166 deletions

View File

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

View File

@@ -1,82 +0,0 @@
name: Update API Documentation
on:
push:
branches:
- main
paths:
- 'api/docs/**'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Add permissions for GITHUB_TOKEN
permissions:
contents: write
pull-requests: write
jobs:
create-docs-pr:
runs-on: ubuntu-latest
steps:
- name: Checkout source repository
uses: actions/checkout@v5
with:
path: source-repo
- name: Checkout docs repository
uses: actions/checkout@v5
with:
repository: unraid/docs
path: docs-repo
token: ${{ secrets.DOCS_PAT_UNRAID_BOT }}
- name: Copy and process docs
run: |
if [ ! -d "source-repo/api/docs" ]; then
echo "Source directory does not exist!"
exit 1
fi
# Remove old API docs but preserve other folders
rm -rf docs-repo/docs/API/
mkdir -p docs-repo/docs/API
# Copy all markdown files and maintain directory structure
cp -r source-repo/api/docs/public/. docs-repo/docs/API/
# Copy images to Docusaurus static directory
mkdir -p docs-repo/static/img/api
# Copy images from public/images if they exist
if [ -d "source-repo/api/docs/public/images" ]; then
cp -r source-repo/api/docs/public/images/. docs-repo/static/img/api/
fi
# Also copy any images from the parent docs/images directory
if [ -d "source-repo/api/docs/images" ]; then
cp -r source-repo/api/docs/images/. docs-repo/static/img/api/
fi
# Update image paths in markdown files
# Replace relative image paths with absolute paths pointing to /img/api/
find docs-repo/docs/API -name "*.md" -type f -exec sed -i 's|!\[\([^]]*\)\](\./images/\([^)]*\))|![\1](/img/api/\2)|g' {} \;
find docs-repo/docs/API -name "*.md" -type f -exec sed -i 's|!\[\([^]]*\)\](images/\([^)]*\))|![\1](/img/api/\2)|g' {} \;
find docs-repo/docs/API -name "*.md" -type f -exec sed -i 's|!\[\([^]]*\)\](../images/\([^)]*\))|![\1](/img/api/\2)|g' {} \;
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.DOCS_PAT_UNRAID_BOT }}
path: docs-repo
commit-message: 'docs: update API documentation'
title: 'Update API Documentation'
body: |
This PR updates the API documentation based on changes from the main repository.
Changes were automatically generated from api/docs/* directory.
@coderabbitai ignore
reviewers: ljm42, elibosley
branch: update-api-docs
base: main
delete-branch: true

View File

@@ -310,9 +310,6 @@ jobs:
- name: Type Check
run: pnpm run type-check
- name: Test
run: pnpm run test:ci
- name: Build
run: pnpm run build

View File

@@ -143,4 +143,3 @@ jobs:
${{ steps.release-info.outputs.body }}
embed-color: 16734296
embed-footer-text: "Unraid API • Automated Release"
embed-timestamp: true

View File

@@ -1 +1 @@
{".":"4.22.2"}
{".":"4.25.0"}

View File

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

View File

@@ -1,5 +1,56 @@
# Changelog
## [4.25.0](https://github.com/unraid/api/compare/v4.24.1...v4.25.0) (2025-09-26)
### Features
* add Tailwind scoping plugin and integrate into Vite config ([#1722](https://github.com/unraid/api/issues/1722)) ([b7afaf4](https://github.com/unraid/api/commit/b7afaf463243b073e1ab1083961a16a12ac6c4a3))
* notification filter controls pill buttons ([#1718](https://github.com/unraid/api/issues/1718)) ([661865f](https://github.com/unraid/api/commit/661865f97611cf802f239fde8232f3109281dde6))
### Bug Fixes
* enable auth guard for nested fields - thanks [@ingel81](https://github.com/ingel81) ([7bdeca8](https://github.com/unraid/api/commit/7bdeca8338a3901f15fde06fd7aede3b0c16e087))
* enhance user context validation in auth module ([#1726](https://github.com/unraid/api/issues/1726)) ([cd5eff1](https://github.com/unraid/api/commit/cd5eff11bcb4398581472966cb7ec124eac7ad0a))
## [4.24.1](https://github.com/unraid/api/compare/v4.24.0...v4.24.1) (2025-09-23)
### Bug Fixes
* cleanup leftover removed packages on upgrade ([#1719](https://github.com/unraid/api/issues/1719)) ([9972a5f](https://github.com/unraid/api/commit/9972a5f178f9a251e6c129d85c5f11cfd25e6281))
* enhance version comparison logic in installation script ([d9c561b](https://github.com/unraid/api/commit/d9c561bfebed0c553fe4bfa26b088ae71ca59755))
* issue with incorrect permissions on viewer / other roles ([378cdb7](https://github.com/unraid/api/commit/378cdb7f102f63128dd236c13f1a3745902d5a2c))
## [4.24.0](https://github.com/unraid/api/compare/v4.23.1...v4.24.0) (2025-09-18)
### Features
* improve dom content loading by being more efficient about component mounting ([#1716](https://github.com/unraid/api/issues/1716)) ([d8b166e](https://github.com/unraid/api/commit/d8b166e4b6a718e07783d9c8ac8393b50ec89ae3))
## [4.23.1](https://github.com/unraid/api/compare/v4.23.0...v4.23.1) (2025-09-17)
### Bug Fixes
* cleanup ini parser logic with better fallbacks ([#1713](https://github.com/unraid/api/issues/1713)) ([1691362](https://github.com/unraid/api/commit/16913627de9497a5d2f71edb710cec6e2eb9f890))
## [4.23.0](https://github.com/unraid/api/compare/v4.22.2...v4.23.0) (2025-09-16)
### Features
* add unraid api status manager ([#1708](https://github.com/unraid/api/issues/1708)) ([1d9ce0a](https://github.com/unraid/api/commit/1d9ce0aa3d067726c2c880929408c68f53e13e0d))
### Bug Fixes
* **logging:** remove colorized logs ([#1705](https://github.com/unraid/api/issues/1705)) ([1d2c670](https://github.com/unraid/api/commit/1d2c6701ce56b1d40afdb776065295e9273d08e9))
* no sizeRootFs unless queried ([#1710](https://github.com/unraid/api/issues/1710)) ([9714b21](https://github.com/unraid/api/commit/9714b21c5c07160b92a11512e8b703908adb0620))
* use virtual-modal-container ([#1709](https://github.com/unraid/api/issues/1709)) ([44b4d77](https://github.com/unraid/api/commit/44b4d77d803aa724968307cfa463f7c440791a10))
## [4.22.2](https://github.com/unraid/api/compare/v4.22.1...v4.22.2) (2025-09-15)

View File

@@ -1,4 +0,0 @@
{
"label": "Unraid API",
"position": 4
}

View File

@@ -1,100 +0,0 @@
# API Key Authorization Flow
This document describes the self-service API key creation flow for third-party applications.
## Overview
Applications can request API access to an Unraid server by redirecting users to a special authorization page where users can review requested permissions and create an API key with one click.
## Flow
1. **Application initiates request**: The app redirects the user to:
```
https://[unraid-server]/ApiKeyAuthorize?name=MyApp&scopes=docker:read,vm:*&redirect_uri=https://myapp.com/callback&state=abc123
```
2. **User authentication**: If not already logged in, the user is redirected to login first (standard Unraid auth)
3. **Consent screen**: User sees:
- Application name and description
- Requested permissions (with checkboxes to approve/deny specific scopes)
- API key name field (pre-filled)
- Authorize & Cancel buttons
4. **API key creation**: Upon authorization:
- API key is created with approved scopes
- Key is displayed to the user
- If `redirect_uri` is provided, user is redirected back with the key
5. **Callback**: App receives the API key:
```
https://myapp.com/callback?api_key=xxx&state=abc123
```
## Query Parameters
- `name` (required): Name of the requesting application
- `description` (optional): Description of the application
- `scopes` (required): Comma-separated list of requested scopes
- `redirect_uri` (optional): URL to redirect after authorization
- `state` (optional): Opaque value for maintaining state
## Scope Format
Scopes follow the pattern: `resource:action`
### Examples:
- `docker:read` - Read access to Docker
- `vm:*` - Full access to VMs
- `system:update` - Update access to system
- `role:viewer` - Viewer role access
- `role:admin` - Admin role access
### Available Resources:
- `docker`, `vm`, `system`, `share`, `user`, `network`, `disk`, etc.
### Available Actions:
- `create`, `read`, `update`, `delete` or `*` for all
## Security Considerations
1. **HTTPS required**: Redirect URIs must use HTTPS (except localhost for development)
2. **User consent**: Users explicitly approve each permission
3. **Session-based**: Uses existing Unraid authentication session
4. **One-time display**: API keys are shown once and must be saved securely
## Example Integration
```javascript
// JavaScript example
const unraidServer = 'tower.local';
const appName = 'My Docker Manager';
const scopes = 'docker:*,system:read';
const redirectUri = 'https://myapp.com/unraid/callback';
const state = generateRandomState();
// Store state for verification
sessionStorage.setItem('oauth_state', state);
// Redirect user to authorization page
window.location.href =
`https://${unraidServer}/ApiKeyAuthorize?` +
`name=${encodeURIComponent(appName)}&` +
`scopes=${encodeURIComponent(scopes)}&` +
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
`state=${encodeURIComponent(state)}`;
// Handle callback
const urlParams = new URLSearchParams(window.location.search);
const apiKey = urlParams.get('api_key');
const returnedState = urlParams.get('state');
if (returnedState === sessionStorage.getItem('oauth_state')) {
// Save API key securely
saveApiKey(apiKey);
}
```

View File

@@ -1,210 +0,0 @@
---
title: CLI Reference
description: Complete reference for all Unraid API CLI commands
sidebar_position: 4
---
# CLI Commands
:::info[Command Structure]
All commands follow the pattern: `unraid-api <command> [options]`
:::
## 🚀 Service Management
### Start
```bash
unraid-api start [--log-level <level>]
```
Starts the Unraid API service.
Options:
- `--log-level`: Set logging level (trace|debug|info|warn|error|fatal)
Alternative: You can also set the log level using the `LOG_LEVEL` environment variable:
```bash
LOG_LEVEL=trace unraid-api start
```
### Stop
```bash
unraid-api stop [--delete]
```
Stops the Unraid API service.
- `--delete`: Optional. Delete the PM2 home directory
### Restart
```bash
unraid-api restart [--log-level <level>]
```
Restarts the Unraid API service.
Options:
- `--log-level`: Set logging level (trace|debug|info|warn|error|fatal)
Alternative: You can also set the log level using the `LOG_LEVEL` environment variable:
```bash
LOG_LEVEL=trace unraid-api restart
```
### Logs
```bash
unraid-api logs [-l <lines>]
```
View the API logs.
- `-l, --lines`: Optional. Number of lines to tail (default: 100)
## ⚙️ Configuration Commands
### Config
```bash
unraid-api config
```
Displays current configuration values.
### Switch Environment
```bash
unraid-api switch-env [-e <environment>]
```
Switch between production and staging environments.
- `-e, --environment`: Optional. Target environment (production|staging)
### Developer Mode
:::tip Web GUI Management
You can also manage developer options through the web interface at **Settings****Management Access****Developer Options**
:::
```bash
unraid-api developer # Interactive prompt for tools
unraid-api developer --sandbox true # Enable GraphQL sandbox
unraid-api developer --sandbox false # Disable GraphQL sandbox
unraid-api developer --enable-modal # Enable modal testing tool
unraid-api developer --disable-modal # Disable modal testing tool
```
Configure developer features for the API:
- **GraphQL Sandbox**: Enable/disable Apollo GraphQL sandbox at `/graphql`
- **Modal Testing Tool**: Enable/disable UI modal testing in the Unraid menu
## API Key Management
:::tip Web GUI Management
You can also manage API keys through the web interface at **Settings****Management Access****API Keys**
:::
### API Key Commands
```bash
unraid-api apikey [options]
```
Create and manage API keys via CLI.
Options:
- `--name <name>`: Name of the key
- `--create`: Create a new key
- `-r, --roles <roles>`: Comma-separated list of roles
- `-p, --permissions <permissions>`: Comma-separated list of permissions
- `-d, --description <description>`: Description for the key
## SSO (Single Sign-On) Management
:::info OIDC Configuration
For OIDC/SSO provider configuration, see the web interface at **Settings****Management Access****API****OIDC** or refer to the [OIDC Provider Setup](./oidc-provider-setup.md) guide.
:::
### SSO Base Command
```bash
unraid-api sso
```
#### Add SSO User
```bash
unraid-api sso add-user
# or
unraid-api sso add
# or
unraid-api sso a
```
Add a new user for SSO authentication.
#### Remove SSO User
```bash
unraid-api sso remove-user
# or
unraid-api sso remove
# or
unraid-api sso r
```
Remove a user (or all users) from SSO.
#### List SSO Users
```bash
unraid-api sso list-users
# or
unraid-api sso list
# or
unraid-api sso l
```
List all configured SSO users.
#### Validate SSO Token
```bash
unraid-api sso validate-token <token>
# or
unraid-api sso validate
# or
unraid-api sso v
```
Validates an SSO token and returns its status.
## Report Generation
### Generate Report
```bash
unraid-api report [-r] [-j]
```
Generate a system report.
- `-r, --raw`: Display raw command output
- `-j, --json`: Display output in JSON format
## Notes
1. Most commands require appropriate permissions to modify system state
2. Some commands require the API to be running or stopped
3. Store API keys securely as they provide system access
4. SSO configuration changes may require a service restart

View File

@@ -1,255 +0,0 @@
---
title: Using the Unraid API
description: Learn how to interact with your Unraid server through the GraphQL API
sidebar_position: 2
---
# Using the Unraid API
:::tip[Quick Start]
The Unraid API provides a powerful GraphQL interface for managing your server. This guide covers authentication, common queries, and best practices.
:::
The Unraid API provides a GraphQL interface that allows you to interact with your Unraid server. This guide will help you get started with exploring and using the API.
## 🎮 Enabling the GraphQL Sandbox
### Web GUI Method (Recommended)
:::info[Preferred Method]
Using the Web GUI is the easiest way to enable the GraphQL sandbox.
:::
1. Navigate to **Settings****Management Access****Developer Options**
2. Enable the **GraphQL Sandbox** toggle
3. Access the GraphQL playground by navigating to:
```txt
http://YOUR_SERVER_IP/graphql
```
### CLI Method
Alternatively, you can enable developer mode using the CLI:
```bash
unraid-api developer --sandbox true
```
Or use the interactive mode:
```bash
unraid-api developer
```
## 🔑 Authentication
:::warning[Required for Most Operations]
Most queries and mutations require authentication. Always include appropriate credentials in your requests.
:::
You can authenticate using:
1. **API Keys** - For programmatic access
2. **Cookies** - Automatic when signed into the WebGUI
3. **SSO/OIDC** - When configured with external providers
### Managing API Keys
<tabs>
<tabItem value="gui" label="Web GUI (Recommended)" default>
Navigate to **Settings** → **Management Access** → **API Keys** in your Unraid web interface to:
- View existing API keys
- Create new API keys
- Manage permissions and roles
- Revoke or regenerate keys
</tabItem>
<tabItem value="cli" label="CLI Method">
You can also use the CLI to create an API key:
```bash
unraid-api apikey --create
```
Follow the prompts to set:
- Name
- Description
- Roles
- Permissions
</tabItem>
</tabs>
### Using API Keys
The generated API key should be included in your GraphQL requests as a header:
```json
{
"x-api-key": "YOUR_API_KEY"
}
```
## 📊 Available Schemas
The API provides access to various aspects of your Unraid server:
### System Information
- Query system details including CPU, memory, and OS information
- Monitor system status and health
- Access baseboard and hardware information
### Array Management
- Query array status and configuration
- Manage array operations (start/stop)
- Monitor disk status and health
- Perform parity checks
### Docker Management
- List and manage Docker containers
- Monitor container status
- Manage Docker networks
### Remote Access
- Configure and manage remote access settings
- Handle SSO configuration
- Manage allowed origins
### 💻 Example Queries
#### Check System Status
```graphql
query {
info {
os {
platform
distro
release
uptime
}
cpu {
manufacturer
brand
cores
threads
}
}
}
```
#### Monitor Array Status
```graphql
query {
array {
state
capacity {
disks {
free
used
total
}
}
disks {
name
size
status
temp
}
}
}
```
#### List Docker Containers
```graphql
query {
dockerContainers {
id
names
state
status
autoStart
}
}
```
## 🏗️ Schema Types
The API includes several core types:
### Base Types
- `Node`: Interface for objects with unique IDs - please see [Object Identification](https://graphql.org/learn/global-object-identification/)
- `JSON`: For complex JSON data
- `DateTime`: For timestamp values
- `Long`: For 64-bit integers
### Resource Types
- `Array`: Array and disk management
- `Docker`: Container and network management
- `Info`: System information
- `Config`: Server configuration
- `Connect`: Remote access settings
### Role-Based Access
Available roles:
- `admin`: Full access
- `connect`: Remote access features
- `guest`: Limited read access
## ✨ Best Practices
:::tip[Pro Tips]
1. Use the Apollo Sandbox to explore the schema and test queries
2. Start with small queries and gradually add fields as needed
3. Monitor your query complexity to maintain performance
4. Use appropriate roles and permissions for your API keys
5. Keep your API keys secure and rotate them periodically
:::
## ⏱️ Rate Limiting
:::caution[Rate Limits]
The API implements rate limiting to prevent abuse. Ensure your applications handle rate limit responses appropriately.
:::
## 🚨 Error Handling
The API returns standard GraphQL errors in the following format:
```json
{
"errors": [
{
"message": "Error description",
"locations": [...],
"path": [...]
}
]
}
```
## 📚 Additional Resources
:::info[Learn More]
- Use the Apollo Sandbox's schema explorer to browse all available types and fields
- Check the documentation tab in Apollo Sandbox for detailed field descriptions
- Monitor the API's health using `unraid-api status`
- Generate reports using `unraid-api report` for troubleshooting
For more information about specific commands and configuration options, refer to the [CLI documentation](/cli) or run `unraid-api --help`.
:::

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -1,94 +0,0 @@
---
title: Welcome to Unraid API
description: The official GraphQL API for Unraid Server management and automation
sidebar_position: 1
---
# Welcome to Unraid API
:::tip[What's New]
Starting with Unraid OS v7.2, the API comes built into the operating system - no plugin installation required!
:::
The Unraid API provides a GraphQL interface for programmatic interaction with your Unraid server. It enables automation, monitoring, and integration capabilities.
## 📦 Availability
### ✨ Native Integration (Unraid OS v7.2+)
Starting with Unraid OS v7.2, the API is integrated directly into the operating system:
- No plugin installation required
- Automatically available on system startup
- Deep system integration
- Access through **Settings****Management Access****API**
### 🔌 Plugin Installation (Pre-7.2 and Advanced Users)
For Unraid versions prior to v7.2 or to access newer API features:
1. Install the Unraid Connect Plugin from Community Apps
2. [Configure the plugin](./how-to-use-the-api.md#enabling-the-graphql-sandbox)
3. Access API functionality through the [GraphQL Sandbox](./how-to-use-the-api.md)
:::info Important Notes
- The Unraid Connect plugin provides the API for pre-7.2 versions
- You do NOT need to sign in to Unraid Connect to use the API locally
- Installing the plugin on 7.2+ gives you access to newer API features before they're included in OS releases
:::
## 📚 Documentation Sections
<cards>
<card title="CLI Commands" icon="terminal" href="./cli">
Complete reference for all CLI commands
</card>
<card title="Using the API" icon="code" href="./how-to-use-the-api">
Learn how to interact with the GraphQL API
</card>
<card title="OIDC Setup" icon="shield" href="./oidc-provider-setup">
Configure SSO authentication providers
</card>
<card title="Upcoming Features" icon="rocket" href="./upcoming-features">
See what's coming next
</card>
</cards>
## 🌟 Key Features
:::info[Core Capabilities]
The API provides:
- **GraphQL Interface**: Modern, flexible API with strong typing
- **Authentication**: Multiple methods including API keys, session cookies, and SSO/OIDC
- **Comprehensive Coverage**: Access to system information, array management, and Docker operations
- **Developer Tools**: Built-in GraphQL sandbox configurable via web interface or CLI
- **Role-Based Access**: Granular permission control
- **Web Management**: Manage API keys and settings through the web interface
:::
## 🚀 Get Started
<tabs>
<tabItem value="v72" label="Unraid OS v7.2+" default>
1. The API is already installed and running
2. Access settings at **Settings****Management Access****API**
3. Enable the GraphQL Sandbox for development
4. Create your first API key
5. Start making GraphQL queries!
</tabItem>
<tabItem value="older" label="Pre-7.2 Versions">
1. Install the Unraid Connect plugin from Community Apps
2. No Unraid Connect login required for local API access
3. Configure the plugin settings
4. Enable the GraphQL Sandbox
5. Start exploring the API!
</tabItem>
</tabs>
For detailed usage instructions, see the [CLI Commands](./cli) reference.

View File

@@ -0,0 +1 @@
# All Content Here has been permanently moved to [Unraid Docs](https://github.com/unraid/docs)

View File

@@ -1,420 +0,0 @@
---
title: OIDC Provider Setup
description: Configure OIDC (OpenID Connect) providers for SSO authentication in Unraid API
sidebar_position: 3
---
# OIDC Provider Setup
:::info[What is OIDC?]
OpenID Connect (OIDC) is an authentication protocol that allows users to sign in using their existing accounts from providers like Google, Microsoft, or your corporate identity provider. It enables Single Sign-On (SSO) for seamless and secure authentication.
:::
This guide walks you through configuring OIDC (OpenID Connect) providers for SSO authentication in the Unraid API using the web interface.
## 🚀 Quick Start
<details open>
<summary><strong>Getting to OIDC Settings</strong></summary>
1. Navigate to your Unraid server's web interface
2. Go to **Settings****Management Access****API****OIDC**
3. You'll see tabs for different providers - click the **+** button to add a new provider
</details>
### OIDC Providers Interface Overview
![Login Page with SSO Options](./images/sso-with-options.png)
*Login page showing traditional login form with SSO options - "Login With Unraid.net" and "Sign in with Google" buttons*
The interface includes:
- **Provider tabs**: Each configured provider (Unraid.net, Google, etc.) appears as a tab
- **Add Provider button**: Click the **+** button to add new providers
- **Authorization Mode dropdown**: Toggle between "simple" and "advanced" modes
- **Simple Authorization section**: Configure allowed email domains and specific addresses
- **Add Item buttons**: Click to add multiple authorization rules
## Understanding Authorization Modes
The interface provides two authorization modes:
### Simple Mode (Recommended)
Simple mode is the easiest way to configure authorization. You can:
- Allow specific email domains (e.g., @company.com)
- Allow specific email addresses
- Configure who can access your Unraid server with minimal setup
**When to use Simple Mode:**
- You want to allow all users from your company domain
- You have a small list of specific users
- You're new to OIDC configuration
<details>
<summary><strong>Advanced Mode</strong></summary>
Advanced mode provides granular control using claim-based rules. You can:
- Create complex authorization rules based on JWT claims
- Use operators like equals, contains, endsWith, startsWith
- Combine multiple conditions with OR/AND logic
- Choose whether ANY rule must pass (OR mode) or ALL rules must pass (AND mode)
**When to use Advanced Mode:**
- You need to check group memberships
- You want to verify multiple claims (e.g., email domain AND verified status)
- You have complex authorization requirements
- You need fine-grained control over how rules are evaluated
</details>
## Authorization Rules
![Authorization Rules Configuration](./images/advanced-rules.png)
*Advanced authorization rules showing JWT claim configuration with email endsWith operator for domain-based access control*
### Simple Mode Examples
#### Allow Company Domain
In Simple Authorization:
- **Allowed Email Domains**: Enter `company.com`
- This allows anyone with @company.com email
#### Allow Specific Users
- **Specific Email Addresses**: Add individual emails
- Click **Add Item** to add multiple addresses
<details>
<summary><strong>Advanced Mode Examples</strong></summary>
#### Authorization Rule Mode
When using multiple rules, you can choose how they're evaluated:
- **OR Mode** (default): User is authorized if ANY rule passes
- **AND Mode**: User is authorized only if ALL rules pass
#### Email Domain with Verification (AND Mode)
To require both email domain AND verification:
1. Set **Authorization Rule Mode** to `AND`
2. Add two rules:
- Rule 1:
- **Claim**: `email`
- **Operator**: `endsWith`
- **Value**: `@company.com`
- Rule 2:
- **Claim**: `email_verified`
- **Operator**: `equals`
- **Value**: `true`
This ensures users must have both a company email AND a verified email address.
#### Group-Based Access (OR Mode)
To allow access to multiple groups:
1. Set **Authorization Rule Mode** to `OR` (default)
2. Add rules for each group:
- **Claim**: `groups`
- **Operator**: `contains`
- **Value**: `admins`
Or add another rule:
- **Claim**: `groups`
- **Operator**: `contains`
- **Value**: `developers`
Users in either `admins` OR `developers` group will be authorized.
#### Multiple Domains
- **Claim**: `email`
- **Operator**: `endsWith`
- **Values**: Add multiple domains (e.g., `company.com`, `subsidiary.com`)
#### Complex Authorization (AND Mode)
For strict security requiring multiple conditions:
1. Set **Authorization Rule Mode** to `AND`
2. Add multiple rules that ALL must pass:
- Email must be from company domain
- Email must be verified
- User must be in specific group
- Account must have 2FA enabled (if claim available)
</details>
<details>
<summary><strong>Configuration Interface Details</strong></summary>
### Provider Tabs
- Each configured provider appears as a tab at the top
- Click a tab to switch between provider configurations
- The **+** button on the right adds a new provider
### Authorization Mode Dropdown
- **simple**: Best for email-based authorization (recommended for most users)
- **advanced**: For complex claim-based rules using JWT claims
### Simple Authorization Fields
When "simple" mode is selected, you'll see:
- **Allowed Email Domains**: Enter domains without @ (e.g., `company.com`)
- Helper text: "Users with emails ending in these domains can login"
- **Specific Email Addresses**: Add individual email addresses
- Helper text: "Only these exact email addresses can login"
- **Add Item** buttons to add multiple entries
### Advanced Authorization Fields
When "advanced" mode is selected, you'll see:
- **Authorization Rule Mode**: Choose `OR` (any rule passes) or `AND` (all rules must pass)
- **Authorization Rules**: Add multiple claim-based rules
- **For each rule**:
- **Claim**: The JWT claim to check
- **Operator**: How to compare (equals, contains, endsWith, startsWith)
- **Value**: What to match against
### Additional Interface Elements
- **Enable Developer Sandbox**: Toggle to enable GraphQL sandbox at `/graphql`
- The interface uses a dark theme for better visibility
- Field validation indicators help ensure correct configuration
</details>
### Required Redirect URI
:::caution[Important Configuration]
All providers must be configured with this exact redirect URI format:
:::
```bash
http://YOUR_UNRAID_IP/graphql/api/auth/oidc/callback
```
:::tip
Replace `YOUR_UNRAID_IP` with your actual server IP address (e.g., `192.168.1.100` or `tower.local`).
:::
### Issuer URL Format
The **Issuer URL** field accepts both formats, but **base URL is strongly recommended** for security:
- **Base URL** (recommended): `https://accounts.google.com`
- **Full discovery URL**: `https://accounts.google.com/.well-known/openid-configuration`
**⚠️ Security Note**: Always use the base URL format when possible. The system automatically appends `/.well-known/openid-configuration` for OIDC discovery. Using the full discovery URL directly disables important issuer validation checks and is not recommended by the OpenID Connect specification.
**Examples of correct base URLs:**
- Google: `https://accounts.google.com`
- Microsoft/Azure: `https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0`
- Keycloak: `https://keycloak.example.com/realms/YOUR_REALM`
- Authelia: `https://auth.yourdomain.com`
## ✅ Testing Your Configuration
![Login Page with SSO Buttons](./images/sso-with-options.png)
*Unraid login page displaying both traditional username/password authentication and SSO options with customized provider buttons*
1. Save your provider configuration
2. Log out (if logged in)
3. Navigate to the login page
4. Your configured provider button should appear
5. Click to test the login flow
## 🔧 Troubleshooting
### Common Issues
#### "Provider not found" error
- Ensure the Issuer URL is correct
- Check that the provider supports OIDC discovery (/.well-known/openid-configuration)
#### "Authorization failed"
- In Simple Mode: Check email domains are entered correctly (without @)
- In Advanced Mode:
- Verify claim names match exactly what your provider sends
- Check if Authorization Rule Mode is set correctly (OR vs AND)
- Ensure all required claims are present in the token
- Enable debug logging to see actual claims and rule evaluation
#### "Invalid redirect URI"
- Ensure the redirect URI in your provider matches exactly
- Include the correct port if using a non-standard configuration
- Verify the redirect URI protocol matches your server's configuration (HTTP or HTTPS)
#### Cannot see login button
- Check that at least one authorization rule is configured
- Verify the provider is enabled/saved
### Debug Mode
To troubleshoot issues:
1. Enable debug logging:
```bash
LOG_LEVEL=debug unraid-api start --debug
```
2. Check logs for:
- Received claims from provider
- Authorization rule evaluation
- Token validation errors
## 🔐 Security Best Practices
1. **Use Simple Mode for authorization** - Prevents overly accepting configurations and reduces misconfiguration risks
2. **Be specific with authorization** - Don't use overly broad rules
3. **Rotate secrets regularly** - Update client secrets periodically
4. **Test thoroughly** - Verify only intended users can access
## 💡 Need Help?
- Check provider's OIDC documentation
- Review Unraid API logs for detailed error messages
- Ensure your provider supports standard OIDC discovery
- Verify network connectivity between Unraid and provider
## 🏢 Provider-Specific Setup
### Unraid.net Provider
The Unraid.net provider is built-in and pre-configured. You only need to configure authorization rules in the interface.
**Configuration:**
- **Issuer URL**: Pre-configured (built-in provider)
- **Client ID/Secret**: Pre-configured (built-in provider)
- **Redirect URI**: `http://YOUR_UNRAID_IP/graphql/api/auth/oidc/callback`
:::tip[Redirect URI Protocol]
**Match the protocol to your server setup:** Use `http://` if accessing your Unraid server without SSL/TLS (typical for local network access). Use `https://` if you've configured SSL/TLS on your server. Some OIDC providers (like Google) require HTTPS and won't accept HTTP redirect URIs.
:::
Configure authorization rules using Simple Mode (allowed email domains/addresses) or Advanced Mode for complex requirements.
### Google
<details>
<summary><strong>📋 Setup Steps</strong></summary>
Set up OAuth 2.0 credentials in [Google Cloud Console](https://console.cloud.google.com/):
1. Go to **APIs & Services****Credentials**
2. Click **Create Credentials****OAuth client ID**
3. Choose **Web application** as the application type
4. Add your redirect URI to **Authorized redirect URIs**
5. Configure the OAuth consent screen if prompted
</details>
**Configuration:**
- **Issuer URL**: `https://accounts.google.com`
- **Client ID/Secret**: From your OAuth 2.0 client credentials
- **Required Scopes**: `openid`, `profile`, `email`
- **Redirect URI**: `http://YOUR_UNRAID_IP/graphql/api/auth/oidc/callback`
:::warning[Google Domain Requirements]
**Google requires valid domain names for OAuth redirect URIs.** Local IP addresses and `.local` domains are not accepted. To use Google OAuth with your Unraid server, you'll need:
- **Option 1: Reverse Proxy** - Set up a reverse proxy (like NGINX Proxy Manager or Traefik) with a valid domain name pointing to your Unraid API
- **Option 2: Tailscale** - Use Tailscale to get a valid `*.ts.net` domain that Google will accept
- **Option 3: Dynamic DNS** - Use a DDNS service to get a public domain name for your server
Remember to update your redirect URI in both Google Cloud Console and your Unraid OIDC configuration to use the valid domain.
:::
For Google Workspace domains, use Advanced Mode with the `hd` claim to restrict access to your organization's domain.
### Authelia
Configure OIDC client in your Authelia `configuration.yml` with client ID `unraid-api` and generate a hashed secret using the Authelia hash-password command.
**Configuration:**
- **Issuer URL**: `https://auth.yourdomain.com`
- **Client ID**: `unraid-api` (or as configured in Authelia)
- **Client Secret**: Your unhashed secret
- **Required Scopes**: `openid`, `profile`, `email`, `groups`
- **Redirect URI**: `http://YOUR_UNRAID_IP/graphql/api/auth/oidc/callback`
Use Advanced Mode with `groups` claim for group-based authorization.
### Microsoft/Azure AD
Register a new app in [Azure Portal](https://portal.azure.com/) under Azure Active Directory → App registrations. Note the Application ID, create a client secret, and note your tenant ID.
**Configuration:**
- **Issuer URL**: `https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0`
- **Client ID**: Your Application (client) ID
- **Client Secret**: Generated client secret
- **Required Scopes**: `openid`, `profile`, `email`
- **Redirect URI**: `http://YOUR_UNRAID_IP/graphql/api/auth/oidc/callback`
Authorization rules can be configured in the interface using email domains or advanced claims.
### Keycloak
Create a new confidential client in Keycloak Admin Console with `openid-connect` protocol and copy the client secret from the Credentials tab.
**Configuration:**
- **Issuer URL**: `https://keycloak.example.com/realms/YOUR_REALM`
- **Client ID**: `unraid-api` (or as configured in Keycloak)
- **Client Secret**: From Keycloak Credentials tab
- **Required Scopes**: `openid`, `profile`, `email`
- **Redirect URI**: `http://YOUR_UNRAID_IP/graphql/api/auth/oidc/callback`
For role-based authorization, use Advanced Mode with `realm_access.roles` or `resource_access` claims.
### Authentik
Create a new OAuth2/OpenID Provider in Authentik, then create an Application and link it to the provider.
**Configuration:**
- **Issuer URL**: `https://authentik.example.com/application/o/<application_slug>/`
- **Client ID**: From Authentik provider configuration
- **Client Secret**: From Authentik provider configuration
- **Required Scopes**: `openid`, `profile`, `email`
- **Redirect URI**: `http://YOUR_UNRAID_IP/graphql/api/auth/oidc/callback`
Authorization rules can be configured in the interface.
### Okta
Create a new OIDC Web Application in Okta Admin Console and assign appropriate users or groups.
**Configuration:**
- **Issuer URL**: `https://YOUR_DOMAIN.okta.com`
- **Client ID**: From Okta application configuration
- **Client Secret**: From Okta application configuration
- **Required Scopes**: `openid`, `profile`, `email`
- **Redirect URI**: `http://YOUR_UNRAID_IP/graphql/api/auth/oidc/callback`
Authorization rules can be configured in the interface using email domains or advanced claims.

View File

@@ -1,252 +0,0 @@
---
title: Programmatic API Key Management
description: Create, use, and delete API keys programmatically for automated workflows
sidebar_position: 4
---
# Programmatic API Key Management
This guide explains how to create, use, and delete API keys programmatically using the Unraid API CLI, enabling automated workflows and scripts.
## Overview
The `unraid-api apikey` command supports both interactive and non-interactive modes, making it suitable for:
- Automated deployment scripts
- CI/CD pipelines
- Temporary access provisioning
- Infrastructure as code workflows
:::tip[Quick Start]
Jump to the [Complete Workflow Example](#complete-workflow-example) to see everything in action.
:::
## Creating API Keys Programmatically
### Basic Creation with JSON Output
Use the `--json` flag to get machine-readable output:
```bash
unraid-api apikey --create --name "workflow key" --roles ADMIN --json
```
**Output:**
```json
{
"key": "your-generated-api-key-here",
"name": "workflow key",
"id": "generated-uuid"
}
```
### Advanced Creation with Permissions
```bash
unraid-api apikey --create \
--name "limited access key" \
--permissions "DOCKER:READ_ANY,ARRAY:READ_ANY" \
--description "Read-only access for monitoring" \
--json
```
### Handling Existing Keys
If a key with the same name exists, use `--overwrite`:
```bash
unraid-api apikey --create --name "existing key" --roles ADMIN --overwrite --json
```
:::warning[Key Replacement]
The `--overwrite` flag will permanently replace the existing key. The old key will be immediately invalidated.
:::
## Deleting API Keys Programmatically
### Non-Interactive Deletion
Delete a key by name without prompts:
```bash
unraid-api apikey --delete --name "workflow key"
```
**Output:**
```
Successfully deleted 1 API key
```
### JSON Output for Deletion
Use `--json` flag for machine-readable delete confirmation:
```bash
unraid-api apikey --delete --name "workflow key" --json
```
**Success Output:**
```json
{
"deleted": 1,
"keys": [
{
"id": "generated-uuid",
"name": "workflow key"
}
]
}
```
**Error Output:**
```json
{
"deleted": 0,
"error": "No API key found with name: nonexistent key"
}
```
### Error Handling
When the specified key doesn't exist:
```bash
unraid-api apikey --delete --name "nonexistent key"
# Output: No API keys found to delete
```
**JSON Error Output:**
```json
{
"deleted": 0,
"message": "No API keys found to delete"
}
```
## Complete Workflow Example
Here's a complete example for temporary access provisioning:
```bash
#!/bin/bash
set -e
# 1. Create temporary API key
echo "Creating temporary API key..."
KEY_DATA=$(unraid-api apikey --create \
--name "temp deployment key" \
--roles ADMIN \
--description "Temporary key for deployment $(date)" \
--json)
# 2. Extract the API key
API_KEY=$(echo "$KEY_DATA" | jq -r '.key')
echo "API key created successfully"
# 3. Use the key for operations
echo "Configuring services..."
curl -H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"provider": "azure", "clientId": "your-client-id"}' \
http://localhost:3001/graphql
# 4. Clean up (always runs, even on error)
trap 'echo "Cleaning up..."; unraid-api apikey --delete --name "temp deployment key"' EXIT
echo "Deployment completed successfully"
```
## Command Reference
### Create Command Options
| Flag | Description | Example |
| ----------------------- | ----------------------- | --------------------------------- |
| `--name <name>` | Key name (required) | `--name "my key"` |
| `--roles <roles>` | Comma-separated roles | `--roles ADMIN,VIEWER` |
| `--permissions <perms>` | Resource:action pairs | `--permissions "DOCKER:READ_ANY"` |
| `--description <desc>` | Key description | `--description "CI/CD key"` |
| `--overwrite` | Replace existing key | `--overwrite` |
| `--json` | Machine-readable output | `--json` |
### Available Roles
- `ADMIN` - Full system access
- `CONNECT` - Unraid Connect features
- `VIEWER` - Read-only access
- `GUEST` - Limited access
### Available Resources and Actions
**Resources:** `ACTIVATION_CODE`, `API_KEY`, `ARRAY`, `CLOUD`, `CONFIG`, `CONNECT`, `CONNECT__REMOTE_ACCESS`, `CUSTOMIZATIONS`, `DASHBOARD`, `DISK`, `DISPLAY`, `DOCKER`, `FLASH`, `INFO`, `LOGS`, `ME`, `NETWORK`, `NOTIFICATIONS`, `ONLINE`, `OS`, `OWNER`, `PERMISSION`, `REGISTRATION`, `SERVERS`, `SERVICES`, `SHARE`, `VARS`, `VMS`, `WELCOME`
**Actions:** `CREATE_ANY`, `CREATE_OWN`, `READ_ANY`, `READ_OWN`, `UPDATE_ANY`, `UPDATE_OWN`, `DELETE_ANY`, `DELETE_OWN`
### Delete Command Options
| Flag | Description | Example |
| --------------- | ------------------------ | ----------------- |
| `--delete` | Enable delete mode | `--delete` |
| `--name <name>` | Key to delete (optional) | `--name "my key"` |
**Note:** If `--name` is omitted, the command runs interactively.
## Best Practices
:::info[Security Best Practices]
**Minimal Permissions**
- Use specific permissions instead of ADMIN role when possible
- Example: `--permissions "DOCKER:READ_ANY"` instead of `--roles ADMIN`
**Key Lifecycle Management**
- Always clean up temporary keys after use
- Store API keys securely (environment variables, secrets management)
- Use descriptive names and descriptions for audit trails
:::
### Error Handling
- Check exit codes (`$?`) after each command
- Use `set -e` in bash scripts to fail fast
- Implement proper cleanup with `trap`
### Key Naming
- Use descriptive names that include purpose and date
- Names must contain only letters, numbers, and spaces
- Unicode letters are supported
## Troubleshooting
### Common Issues
:::note[Common Error Messages]
**"API key name must contain only letters, numbers, and spaces"**
- **Solution:** Remove special characters like hyphens, underscores, or symbols
**"API key with name 'x' already exists"**
- **Solution:** Use `--overwrite` flag or choose a different name
**"Please add at least one role or permission to the key"**
- **Solution:** Specify either `--roles` or `--permissions` (or both)
:::
### Debug Mode
For troubleshooting, run with debug logging:
```bash
LOG_LEVEL=debug unraid-api apikey --create --name "debug key" --roles ADMIN
```

View File

@@ -1,172 +0,0 @@
---
title: Roadmap & Features
description: Current status and upcoming features for the Unraid API
sidebar_position: 10
---
# Roadmap & Features
:::info Development Status
This roadmap outlines completed and planned features for the Unraid API. Features and timelines may change based on development priorities and community feedback.
:::
## Feature Status Legend
| Status | Description |
|--------|-------------|
| ✅ **Done** | Feature is complete and available |
| 🚧 **In Progress** | Currently under active development |
| 📅 **Planned** | Scheduled for future development |
| 💡 **Under Consideration** | Being evaluated for future inclusion |
## Core Infrastructure
### Completed Features ✅
| Feature | Available Since |
|---------|-----------------|
| **API Development Environment Improvements** | v4.0.0 |
| **Include API in Unraid OS** | Unraid v7.2-beta.1 |
| **Separate API from Connect Plugin** | Unraid v7.2-beta.1 |
### Upcoming Features 📅
| Feature | Target Timeline |
|---------|-----------------|
| **Make API Open Source** | Q1 2025 |
| **Developer Tools for Plugins** | Q2 2025 |
## Security & Authentication
### Completed Features ✅
| Feature | Available Since |
|---------|-----------------|
| **Permissions System Rewrite** | v4.0.0 |
| **OIDC/SSO Support** | Unraid v7.2-beta.1 |
### In Development 🚧
- **User Interface Component Library** - Enhanced security components for the UI
## User Interface Improvements
### Planned Features 📅
| Feature | Target Timeline | Description |
|---------|-----------------|-------------|
| **New Settings Pages** | Q2 2025 | Modernized settings interface with improved UX |
| **Custom Theme Creator** | Q2-Q3 2025 | Allow users to create and share custom themes |
| **New Connect Settings Interface** | Q1 2025 | Redesigned Unraid Connect configuration |
## Array Management
### Completed Features ✅
| Feature | Available Since |
|---------|-----------------|
| **Array Status Monitoring** | v4.0.0 |
### Planned Features 📅
| Feature | Target Timeline | Description |
|---------|-----------------|-------------|
| **Storage Pool Creation Interface** | Q2 2025 | Simplified pool creation workflow |
| **Storage Pool Status Interface** | Q2 2025 | Real-time pool health monitoring |
## Docker Integration
### Completed Features ✅
| Feature | Available Since |
|---------|-----------------|
| **Docker Container Status Monitoring** | v4.0.0 |
### Planned Features 📅
| Feature | Target Timeline | Description |
|---------|-----------------|-------------|
| **New Docker Status Interface Design** | Q3 2025 | Modern container management UI |
| **New Docker Status Interface** | Q3 2025 | Implementation of new design |
| **Docker Container Setup Interface** | Q3 2025 | Streamlined container deployment |
| **Docker Compose Support** | TBD | Native docker-compose.yml support |
## Share Management
### Completed Features ✅
| Feature | Available Since |
|---------|-----------------|
| **Array/Cache Share Status Monitoring** | v4.0.0 |
### Under Consideration 💡
- **Storage Share Creation & Settings** - Enhanced share configuration options
- **Storage Share Management Interface** - Unified share management dashboard
## Plugin System
### Planned Features 📅
| Feature | Target Timeline | Description |
|---------|-----------------|-------------|
| **New Plugins Interface** | Q3 2025 | Redesigned plugin management UI |
| **Plugin Management Interface** | TBD | Advanced plugin configuration |
| **Plugin Development Tools** | TBD | SDK and tooling for developers |
## Notifications
### Completed Features ✅
| Feature | Available Since |
|---------|-----------------|
| **Notifications System** | v4.0.0 |
| **Notifications Interface** | v4.0.0 |
---
## Recent Releases
:::info Full Release History
For a complete list of all releases, changelogs, and download links, visit the [Unraid API GitHub Releases](https://github.com/unraid/api/releases) page.
:::
### Unraid v7.2-beta.1 Highlights
- 🎉 **API included in Unraid OS** - Native integration
- 🔐 **OIDC/SSO Support** - Enterprise authentication
- 📦 **Standalone API** - Separated from Connect plugin
### v4.0.0 Highlights
- 🛡️ **Permissions System Rewrite** - Enhanced security
- 📊 **Comprehensive Monitoring** - Array, Docker, and Share status
- 🔔 **Notifications System** - Real-time alerts and notifications
- 🛠️ **Developer Environment** - Improved development tools
## Community Feedback
:::tip Have a Feature Request?
We value community input! Please submit feature requests and feedback through:
- [Unraid Forums](https://forums.unraid.net)
- [GitHub Issues](https://github.com/unraid/api/issues) - API is open source!
:::
## Version Support
| Unraid Version | API Version | Support Status |
|----------------|-------------|----------------|
| Unraid v7.2-beta.1+ | Latest | ✅ Active |
| 7.0 - 7.1.x | v4.x via Plugin | ⚠️ Limited |
| 6.12.x | v4.x via Plugin | ⚠️ Limited |
| < 6.12 | Not Supported | ❌ EOL |
:::warning Legacy Support
Versions prior to Unraid 7.2 require the API to be installed through the Unraid Connect plugin. Some features may not be available on older versions.
:::
:::tip Pre-release Versions
You can always install the Unraid Connect plugin to access pre-release versions of the API and get early access to new features before they're included in Unraid OS releases.
:::

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/api",
"version": "4.22.2",
"version": "4.25.0",
"main": "src/cli/index.ts",
"type": "module",
"corepack": {
@@ -144,7 +144,7 @@
"uuid": "13.0.0",
"ws": "8.18.3",
"zen-observable-ts": "1.1.0",
"zod": "3.25.76"
"zod": "4.1.11"
},
"peerDependencies": {
"unraid-api-plugin-connect": "workspace:*"
@@ -190,7 +190,7 @@
"@types/stoppable": "1.1.3",
"@types/strftime": "0.9.8",
"@types/supertest": "6.0.3",
"@types/uuid": "10.0.0",
"@types/uuid": "11.0.0",
"@types/ws": "8.18.1",
"@types/wtfnode": "0.10.0",
"@vitest/coverage-v8": "3.2.4",

View File

@@ -0,0 +1,178 @@
import { describe, expect, test } from 'vitest';
import {
iniBooleanOrAutoToJsBoolean,
iniBooleanToJsBoolean,
} from '@app/core/utils/parsers/ini-boolean-parser.js';
describe('iniBooleanToJsBoolean', () => {
describe('valid boolean values', () => {
test('returns false for "no"', () => {
expect(iniBooleanToJsBoolean('no')).toBe(false);
});
test('returns false for "false"', () => {
expect(iniBooleanToJsBoolean('false')).toBe(false);
});
test('returns true for "yes"', () => {
expect(iniBooleanToJsBoolean('yes')).toBe(true);
});
test('returns true for "true"', () => {
expect(iniBooleanToJsBoolean('true')).toBe(true);
});
});
describe('malformed values', () => {
test('handles "no*" as false', () => {
expect(iniBooleanToJsBoolean('no*')).toBe(false);
});
test('handles "yes*" as true', () => {
expect(iniBooleanToJsBoolean('yes*')).toBe(true);
});
test('handles "true*" as true', () => {
expect(iniBooleanToJsBoolean('true*')).toBe(true);
});
test('handles "false*" as false', () => {
expect(iniBooleanToJsBoolean('false*')).toBe(false);
});
test('returns undefined for "n0!" (cleans to "n" which is invalid)', () => {
expect(iniBooleanToJsBoolean('n0!')).toBe(undefined);
});
test('returns undefined for "y3s!" (cleans to "ys" which is invalid)', () => {
expect(iniBooleanToJsBoolean('y3s!')).toBe(undefined);
});
test('handles mixed case with extra chars "YES*" as true', () => {
expect(iniBooleanToJsBoolean('YES*')).toBe(true);
});
test('handles mixed case with extra chars "NO*" as false', () => {
expect(iniBooleanToJsBoolean('NO*')).toBe(false);
});
});
describe('default values', () => {
test('returns default value for invalid input when provided', () => {
expect(iniBooleanToJsBoolean('invalid', true)).toBe(true);
expect(iniBooleanToJsBoolean('invalid', false)).toBe(false);
});
test('returns default value for empty string when provided', () => {
expect(iniBooleanToJsBoolean('', true)).toBe(true);
expect(iniBooleanToJsBoolean('', false)).toBe(false);
});
});
describe('undefined fallback cases', () => {
test('returns undefined for invalid input without default', () => {
expect(iniBooleanToJsBoolean('invalid')).toBe(undefined);
});
test('returns undefined for empty string without default', () => {
expect(iniBooleanToJsBoolean('')).toBe(undefined);
});
test('returns undefined for numeric string without default', () => {
expect(iniBooleanToJsBoolean('123')).toBe(undefined);
});
});
});
describe('iniBooleanOrAutoToJsBoolean', () => {
describe('valid boolean values', () => {
test('returns false for "no"', () => {
expect(iniBooleanOrAutoToJsBoolean('no')).toBe(false);
});
test('returns false for "false"', () => {
expect(iniBooleanOrAutoToJsBoolean('false')).toBe(false);
});
test('returns true for "yes"', () => {
expect(iniBooleanOrAutoToJsBoolean('yes')).toBe(true);
});
test('returns true for "true"', () => {
expect(iniBooleanOrAutoToJsBoolean('true')).toBe(true);
});
});
describe('auto value', () => {
test('returns null for "auto"', () => {
expect(iniBooleanOrAutoToJsBoolean('auto')).toBe(null);
});
});
describe('malformed values', () => {
test('handles "no*" as false', () => {
expect(iniBooleanOrAutoToJsBoolean('no*')).toBe(false);
});
test('handles "yes*" as true', () => {
expect(iniBooleanOrAutoToJsBoolean('yes*')).toBe(true);
});
test('handles "auto*" as null', () => {
expect(iniBooleanOrAutoToJsBoolean('auto*')).toBe(null);
});
test('handles "true*" as true', () => {
expect(iniBooleanOrAutoToJsBoolean('true*')).toBe(true);
});
test('handles "false*" as false', () => {
expect(iniBooleanOrAutoToJsBoolean('false*')).toBe(false);
});
test('handles "n0!" as undefined fallback (cleans to "n" which is invalid)', () => {
expect(iniBooleanOrAutoToJsBoolean('n0!')).toBe(undefined);
});
test('handles "a1ut2o!" as null (removes non-alphabetic chars)', () => {
expect(iniBooleanOrAutoToJsBoolean('a1ut2o!')).toBe(null);
});
test('handles mixed case "AUTO*" as null', () => {
expect(iniBooleanOrAutoToJsBoolean('AUTO*')).toBe(null);
});
});
describe('fallback behavior', () => {
test('returns undefined for completely invalid input', () => {
expect(iniBooleanOrAutoToJsBoolean('invalid123')).toBe(undefined);
});
test('returns undefined for empty string', () => {
expect(iniBooleanOrAutoToJsBoolean('')).toBe(undefined);
});
test('returns undefined for numeric string', () => {
expect(iniBooleanOrAutoToJsBoolean('123')).toBe(undefined);
});
test('returns undefined for special characters only', () => {
expect(iniBooleanOrAutoToJsBoolean('!@#$')).toBe(undefined);
});
});
describe('edge cases', () => {
test('handles undefined gracefully', () => {
expect(iniBooleanOrAutoToJsBoolean(undefined as any)).toBe(undefined);
});
test('handles null gracefully', () => {
expect(iniBooleanOrAutoToJsBoolean(null as any)).toBe(undefined);
});
test('handles non-string input gracefully', () => {
expect(iniBooleanOrAutoToJsBoolean(123 as any)).toBe(undefined);
});
});
});

View File

@@ -0,0 +1,86 @@
import { type IniStringBoolean, type IniStringBooleanOrAuto } from '@app/core/types/ini.js';
/**
* Converts INI boolean string values to JavaScript boolean values.
* Handles malformed values by cleaning them of non-alphabetic characters.
*
* @param value - The string value to parse ("yes", "no", "true", "false", etc.)
* @returns boolean value or undefined if parsing fails
*/
export function iniBooleanToJsBoolean(value: string): boolean | undefined;
/**
* Converts INI boolean string values to JavaScript boolean values.
* Handles malformed values by cleaning them of non-alphabetic characters.
*
* @param value - The string value to parse ("yes", "no", "true", "false", etc.)
* @param defaultValue - Default value to return if parsing fails
* @returns boolean value or defaultValue if parsing fails (never undefined when defaultValue is provided)
*/
export function iniBooleanToJsBoolean(value: string, defaultValue: boolean): boolean;
export function iniBooleanToJsBoolean(value: string, defaultValue?: boolean): boolean | undefined {
if (value === 'no' || value === 'false') {
return false;
}
if (value === 'yes' || value === 'true') {
return true;
}
// Handle malformed values by cleaning them first
if (typeof value === 'string') {
const cleanValue = value.replace(/[^a-zA-Z]/g, '').toLowerCase();
if (cleanValue === 'no' || cleanValue === 'false') {
return false;
}
if (cleanValue === 'yes' || cleanValue === 'true') {
return true;
}
}
// Always return defaultValue when provided (even if undefined)
if (arguments.length >= 2) {
return defaultValue;
}
// Return undefined only when no default was provided
return undefined;
}
/**
* Converts INI boolean or auto string values to JavaScript boolean or null values.
* Handles malformed values by cleaning them of non-alphabetic characters.
*
* @param value - The string value to parse ("yes", "no", "auto", "true", "false", etc.)
* @returns boolean value for yes/no/true/false, null for auto, or undefined as fallback
*/
export const iniBooleanOrAutoToJsBoolean = (
value: IniStringBooleanOrAuto | string
): boolean | null | undefined => {
// Handle auto first
if (value === 'auto') {
return null;
}
// Try to parse as boolean
const boolResult = iniBooleanToJsBoolean(value as IniStringBoolean);
if (boolResult !== undefined) {
return boolResult;
}
// Handle malformed values like "auto*" by extracting the base value
if (typeof value === 'string') {
const cleanValue = value.replace(/[^a-zA-Z]/g, '').toLowerCase();
if (cleanValue === 'auto') {
return null;
}
if (cleanValue === 'no' || cleanValue === 'false') {
return false;
}
if (cleanValue === 'yes' || cleanValue === 'true') {
return true;
}
}
// Return undefined as fallback instead of throwing to prevent API crash
return undefined;
};

View File

@@ -1,6 +1,10 @@
import type { StateFileToIniParserMap } from '@app/store/types.js';
import { type IniStringBoolean, type IniStringBooleanOrAuto } from '@app/core/types/ini.js';
import { toNumber } from '@app/core/utils/index.js';
import {
iniBooleanOrAutoToJsBoolean,
iniBooleanToJsBoolean,
} from '@app/core/utils/parsers/ini-boolean-parser.js';
import { ArrayState } from '@app/unraid-api/graph/resolvers/array/array.model.js';
import { DiskFsType } from '@app/unraid-api/graph/resolvers/disks/disks.model.js';
import {
@@ -157,36 +161,6 @@ export type VarIni = {
useUpnp: IniStringBoolean;
};
const iniBooleanToJsBoolean = (value: string, defaultValue?: boolean) => {
if (value === 'no' || value === 'false') {
return false;
}
if (value === 'yes' || value === 'true') {
return true;
}
if (defaultValue !== undefined) {
return defaultValue;
}
throw new Error(`Value "${value}" is not false/true or no/yes.`);
};
const iniBooleanOrAutoToJsBoolean = (value: IniStringBooleanOrAuto) => {
try {
// Either it'll return true/false or throw
return iniBooleanToJsBoolean(value as IniStringBoolean);
} catch {
// Auto or null
if (value === 'auto') {
return null;
}
}
throw new Error(`Value "${value as string}" is not auto/no/yes.`);
};
const safeParseMdState = (mdState: string | undefined): ArrayState => {
if (!mdState || typeof mdState !== 'string') {
return ArrayState.STOPPED;
@@ -210,7 +184,7 @@ export const parse: StateFileToIniParserMap['var'] = (iniFile) => {
...iniFile,
defaultFsType: DiskFsType[iniFile.defaultFsType] || DiskFsType.XFS,
mdState: safeParseMdState(iniFile.mdState),
bindMgt: iniBooleanOrAutoToJsBoolean(iniFile.bindMgt),
bindMgt: iniBooleanOrAutoToJsBoolean(iniFile.bindMgt) ?? null,
cacheNumDevices: toNumber(iniFile.cacheNumDevices),
cacheSbNumDisks: toNumber(iniFile.cacheSbNumDisks),
configValid: iniBooleanToJsBoolean(iniFile.configValid, false),
@@ -221,8 +195,8 @@ export const parse: StateFileToIniParserMap['var'] = (iniFile) => {
fsCopyPrcnt: toNumber(iniFile.fsCopyPrcnt),
fsNumMounted: toNumber(iniFile.fsNumMounted),
fsNumUnmountable: toNumber(iniFile.fsNumUnmountable),
hideDotFiles: iniBooleanToJsBoolean(iniFile.hideDotFiles),
localMaster: iniBooleanToJsBoolean(iniFile.localMaster),
hideDotFiles: iniBooleanToJsBoolean(iniFile.hideDotFiles, false),
localMaster: iniBooleanToJsBoolean(iniFile.localMaster, false),
maxArraysz: toNumber(iniFile.maxArraysz),
maxCachesz: toNumber(iniFile.maxCachesz),
mdNumDisabled: toNumber(iniFile.mdNumDisabled),
@@ -254,34 +228,34 @@ export const parse: StateFileToIniParserMap['var'] = (iniFile) => {
regState:
RegistrationState[(iniFile.regCheck || iniFile.regTy || '').toUpperCase()] ??
RegistrationState.EGUID,
safeMode: iniBooleanToJsBoolean(iniFile.safeMode),
sbClean: iniBooleanToJsBoolean(iniFile.sbClean),
safeMode: iniBooleanToJsBoolean(iniFile.safeMode, false),
sbClean: iniBooleanToJsBoolean(iniFile.sbClean, false),
sbEvents: toNumber(iniFile.sbEvents),
sbNumDisks: toNumber(iniFile.sbNumDisks),
sbSynced: toNumber(iniFile.sbSynced),
sbSynced2: toNumber(iniFile.sbSynced2),
sbSyncErrs: toNumber(iniFile.sbSyncErrs),
shareAvahiEnabled: iniBooleanToJsBoolean(iniFile.shareAvahiEnabled),
shareCacheEnabled: iniBooleanToJsBoolean(iniFile.shareCacheEnabled),
shareAvahiEnabled: iniBooleanToJsBoolean(iniFile.shareAvahiEnabled, false),
shareCacheEnabled: iniBooleanToJsBoolean(iniFile.shareCacheEnabled, false),
shareCount: toNumber(iniFile.shareCount),
shareMoverActive: iniBooleanToJsBoolean(iniFile.shareMoverActive),
shareMoverLogging: iniBooleanToJsBoolean(iniFile.shareMoverLogging),
shareMoverActive: iniBooleanToJsBoolean(iniFile.shareMoverActive, false),
shareMoverLogging: iniBooleanToJsBoolean(iniFile.shareMoverLogging, false),
shareNfsCount: toNumber(iniFile.shareNfsCount),
shareNfsEnabled: iniBooleanToJsBoolean(iniFile.shareNfsEnabled),
shareNfsEnabled: iniBooleanToJsBoolean(iniFile.shareNfsEnabled, false),
shareSmbCount: toNumber(iniFile.shareSmbCount),
shareSmbEnabled: ['yes', 'ads'].includes(iniFile.shareSmbEnabled),
shareSmbMode: iniFile.shareSmbEnabled === 'ads' ? 'active-directory' : 'workgroup',
shutdownTimeout: toNumber(iniFile.shutdownTimeout),
spindownDelay: toNumber(iniFile.spindownDelay),
spinupGroups: iniBooleanToJsBoolean(iniFile.spinupGroups),
startArray: iniBooleanToJsBoolean(iniFile.startArray),
spinupGroups: iniBooleanToJsBoolean(iniFile.spinupGroups, false),
startArray: iniBooleanToJsBoolean(iniFile.startArray, false),
sysArraySlots: toNumber(iniFile.sysArraySlots),
sysCacheSlots: toNumber(iniFile.sysCacheSlots),
sysFlashSlots: toNumber(iniFile.sysFlashSlots),
useNtp: iniBooleanToJsBoolean(iniFile.useNtp),
useSsh: iniBooleanToJsBoolean(iniFile.useSsh),
useSsl: iniBooleanOrAutoToJsBoolean(iniFile.useSsl),
useTelnet: iniBooleanToJsBoolean(iniFile.useTelnet),
useUpnp: iniBooleanToJsBoolean(iniFile.useUpnp),
useNtp: iniBooleanToJsBoolean(iniFile.useNtp, false),
useSsh: iniBooleanToJsBoolean(iniFile.useSsh, false),
useSsl: iniBooleanOrAutoToJsBoolean(iniFile.useSsl) ?? null,
useTelnet: iniBooleanToJsBoolean(iniFile.useTelnet, false),
useUpnp: iniBooleanToJsBoolean(iniFile.useUpnp, false),
};
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,6 +49,7 @@ import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
extra,
};
},
fieldResolverEnhancers: ['guards'],
plugins: [
createDynamicIntrospectionPlugin(isSandboxEnabled),
createSandboxPlugin(),

View File

@@ -1,7 +1,7 @@
{
"name": "unraid-monorepo",
"private": true,
"version": "4.22.2",
"version": "4.25.0",
"scripts": {
"build": "pnpm -r build",
"build:watch": "pnpm -r --parallel --filter '!@unraid/ui' build:watch",

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/connect-plugin",
"version": "4.22.2",
"version": "4.25.0",
"private": true,
"dependencies": {
"commander": "14.0.0",
@@ -11,7 +11,7 @@
"html-sloppy-escaper": "0.1.0",
"semver": "7.7.2",
"tsx": "4.20.5",
"zod": "3.25.76",
"zod": "4.1.11",
"zx": "8.8.1"
},
"type": "module",

View File

@@ -304,13 +304,6 @@ exit 0
-d "Unraid Connect plugin has been marked for removal. Please reboot your server to complete the uninstallation." \
-i "warning"
# Remove the plugin file so it won't be installed on reboot
PLUGIN_FILE="/boot/config/plugins/${MAINNAME}.plg"
if [ -f "$PLUGIN_FILE" ]; then
echo "Removing plugin file: $PLUGIN_FILE"
rm -f "$PLUGIN_FILE"
fi
echo "Plugin marked for removal. Reboot required to complete uninstallation."
else
# Original removal method for older versions
@@ -409,42 +402,118 @@ exit 0
PKG_FILE="&source;" # Full path to the package file including .txz extension
PKG_URL="&txz_url;" # URL where package was downloaded from
PKG_NAME="&txz_name;" # Name of the package file
CONNECT_API_VERSION="&api_version;" # Version of API included with Connect
<![CDATA[
# Install the Slackware package
echo "Installing package..."
# Clean up any old package txz files if they don't match our current version
for txz_file in /boot/config/plugins/dynamix.my.servers/dynamix.unraid.net-*.txz; do
if [ -f "$txz_file" ] && [ "$txz_file" != "${PKG_FILE}" ]; then
echo "Removing old package file: $txz_file"
rm -f "$txz_file"
# Function to compare version numbers using PHP's version_compare
# Returns 0 if version1 > version2, 1 if version1 < version2, 2 if equal
compare_versions() {
local ver1="$1"
local ver2="$2"
# Normalize versions: drop leading 'v' and ignore build metadata (+...) for semver parity
local norm_ver1="${ver1#v}"
norm_ver1="${norm_ver1%%+*}"
local norm_ver2="${ver2#v}"
norm_ver2="${norm_ver2%%+*}"
if [ "$norm_ver1" = "$norm_ver2" ]; then
return 2
fi
done
# Remove existing node_modules directory
echo "Cleaning up existing node_modules directory..."
if [ -d "/usr/local/unraid-api/node_modules" ]; then
echo "Removing: /usr/local/unraid-api/node_modules"
rm -rf "/usr/local/unraid-api/node_modules"
# Use PHP's version_compare which handles semantic versioning properly
result=$(PHP_VER1="$norm_ver1" PHP_VER2="$norm_ver2" php -r "
\$v1 = getenv('PHP_VER1');
\$v2 = getenv('PHP_VER2');
\$cmp = version_compare(\$v1, \$v2);
if (\$cmp > 0) echo '0';
elseif (\$cmp < 0) echo '1';
else echo '2';
")
return $result
}
# Check if API is already installed and get its version
CURRENT_API_VERSION=""
if [ -f "/usr/local/share/dynamix.unraid.net/config/vendor_archive.json" ] && command -v jq >/dev/null 2>&1; then
CURRENT_API_VERSION=$(jq -r '.api_version' "/usr/local/share/dynamix.unraid.net/config/vendor_archive.json" 2>/dev/null)
fi
# Clear existing unraid-components directory contents to ensure clean installation
echo "Cleaning up existing unraid-components directory..."
DIR="/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components"
if [ -d "$DIR" ]; then
echo "Clearing contents of: $DIR"
rm -rf "$DIR"/*
# If we have both versions, compare them
SKIP_API_INSTALL=false
if [ -n "$CURRENT_API_VERSION" ] && [ "$CURRENT_API_VERSION" != "null" ] && [ -n "$CONNECT_API_VERSION" ]; then
echo "Current API version on server: $CURRENT_API_VERSION"
echo "Connect wants to install API version: $CONNECT_API_VERSION"
compare_versions "$CURRENT_API_VERSION" "$CONNECT_API_VERSION"
result=$?
if [ $result -eq 0 ]; then
echo "⚠️ WARNING: Server has a newer API version ($CURRENT_API_VERSION) than Connect ($CONNECT_API_VERSION)"
echo "Skipping API package installation to prevent downgrade"
# Send notification to user
/usr/local/emhttp/webGui/scripts/notify \
-e "Unraid Connect" \
-s "API Version Conflict Detected" \
-d "Your server has API version $CURRENT_API_VERSION installed, which is newer than the version included with Connect ($CONNECT_API_VERSION). The API installation has been skipped to prevent a downgrade. Connect remains installed but may have limited functionality." \
-i "warning"
SKIP_API_INSTALL=true
elif [ $result -eq 2 ]; then
echo "API versions match - proceeding with installation"
else
echo "Connect has a newer API version - proceeding with upgrade"
fi
fi
# Install the package using the explicit file path
upgradepkg --install-new --reinstall "${PKG_FILE}"
if [ $? -ne 0 ]; then
echo "⚠️ Package installation failed"
exit 1
fi
if [ "$SKIP_API_INSTALL" = false ]; then
# Install the Slackware package
echo "Installing package..."
# Clean up any old package txz files if they don't match our current version
for txz_file in /boot/config/plugins/dynamix.my.servers/dynamix.unraid.net-*.txz; do
if [ -f "$txz_file" ] && [ "$txz_file" != "${PKG_FILE}" ]; then
echo "Removing old package file: $txz_file"
rm -f "$txz_file"
fi
done
if [[ -n "$TAG" && "$TAG" != "" ]]; then
printf -v sedcmd 's@^\*\*Unraid Connect\*\*@**Unraid Connect (%s)**@' "$TAG"
sed -i "${sedcmd}" "/usr/local/emhttp/plugins/dynamix.unraid.net/README.md"
# Remove existing node_modules directory
echo "Cleaning up existing node_modules directory..."
if [ -d "/usr/local/unraid-api/node_modules" ]; then
echo "Removing: /usr/local/unraid-api/node_modules"
rm -rf "/usr/local/unraid-api/node_modules"
fi
# Clean up pkgtools removal logs left behind by prior uninstall operations
REMOVE_PKG_LOG_DIR="/var/log/pkgtools/removed_packages/dynamix.unraid.net"
if [ -d "$REMOVE_PKG_LOG_DIR" ]; then
echo "Cleaning up pkgtools removed_packages logs..."
find "$REMOVE_PKG_LOG_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
fi
# Clear existing unraid-components directory contents to ensure clean installation
echo "Cleaning up existing unraid-components directory..."
DIR="/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components"
if [ -d "$DIR" ]; then
echo "Clearing contents of: $DIR"
rm -rf "$DIR"/*
fi
# Install the package using the explicit file path
upgradepkg --install-new --reinstall "${PKG_FILE}"
if [ $? -ne 0 ]; then
echo "⚠️ Package installation failed"
exit 1
fi
if [[ -n "$TAG" && "$TAG" != "" ]]; then
printf -v sedcmd 's@^\*\*Unraid Connect\*\*@**Unraid Connect (%s)**@' "$TAG"
sed -i "${sedcmd}" "/usr/local/emhttp/plugins/dynamix.unraid.net/README.md"
fi
else
echo "API package installation skipped due to version conflict"
echo "Connect plugin remains installed but API was not modified"
fi
exit 0

View File

@@ -69,8 +69,45 @@ switch ($command) {
response_complete(200, array('result' => $output), $output);
break;
case 'restart':
$lockFilePath = '/var/run/unraid-api-restart.lock';
$lockHandle = @fopen($lockFilePath, 'c');
if ($lockHandle === false) {
response_complete(500, array('error' => 'Unable to open restart lock file'), 'Unable to open restart lock file');
}
// Use a lockfile to avoid concurrently running restart commands
$wouldBlock = null;
error_clear_last();
$acquiredLock = flock($lockHandle, LOCK_EX | LOCK_NB, $wouldBlock);
if (!$acquiredLock) {
if (!empty($wouldBlock)) {
fclose($lockHandle);
response_complete(200, array('success' => true, 'result' => 'Unraid API restart already in progress'), 'Restart already in progress');
}
$lastError = error_get_last();
$errorMessage = 'Unable to acquire restart lock';
if (!empty($lastError['message'])) {
$errorMessage .= ': ' . $lastError['message'];
}
fclose($lockHandle);
response_complete(500, array('error' => $errorMessage), $errorMessage);
}
$pid = getmypid();
if ($pid !== false) {
ftruncate($lockHandle, 0);
fwrite($lockHandle, (string)$pid);
fflush($lockHandle);
}
exec('/etc/rc.d/rc.unraid-api restart 2>&1', $output, $retval);
$output = implode(PHP_EOL, $output);
flock($lockHandle, LOCK_UN);
fclose($lockHandle);
response_complete(200, array('success' => ($retval === 0), 'result' => $output, 'error' => ($retval !== 0 ? $output : null)), $output);
break;
case 'status':
@@ -100,4 +137,4 @@ switch ($command) {
break;
}
exit;
?>
?>

View File

@@ -63,6 +63,9 @@ class WebComponentsExtractor
// Process each entry in the manifest
foreach ($manifest as $key => $entry) {
if ($key === 'ts') {
continue;
}
// Skip if not an array with a 'file' key
if (!is_array($entry) || !isset($entry['file']) || empty($entry['file'])) {
continue;

View File

@@ -14,6 +14,8 @@ class ExtractorTest {
private $passed = 0;
private $failed = 0;
private $verbose = false;
private $standaloneJsFile = 'standalone-apps-AbCdEf12.js';
private $standaloneCssFile = 'standalone-apps-ZyXwVuTs.css';
// Color codes for terminal output
const RED = "\033[0;31m";
@@ -46,13 +48,13 @@ class ExtractorTest {
// Create test manifest files
file_put_contents($this->componentDir . '/standalone-apps/standalone.manifest.json', json_encode([
'standalone-apps-RlN0czLV.css' => [
'file' => 'standalone-apps-RlN0czLV.css',
'src' => 'standalone-apps-RlN0czLV.css'
$this->standaloneCssFile => [
'file' => $this->standaloneCssFile,
'src' => $this->standaloneCssFile
],
'standalone-apps.js' => [
'file' => 'standalone-apps.js',
'src' => 'standalone-apps.js',
$this->standaloneJsFile => [
'file' => $this->standaloneJsFile,
'src' => $this->standaloneJsFile,
'css' => ['app-styles.css', 'theme.css']
],
'ts' => 1234567890
@@ -144,8 +146,8 @@ class ExtractorTest {
echo "Test: Script Tag Generation\n";
echo "----------------------------\n";
$this->test(
"Generates script tag for standalone-apps.js",
strpos($output, 'script id="unraid-standalone-apps-standalone-apps-js"') !== false
"Generates script tag for hashed standalone JS",
strpos($output, 'script id="unraid-standalone-apps-' . $this->sanitizeForExpectedId($this->standaloneJsFile) . '"') !== false
);
$this->test(
"Generates script tag for components.mjs",
@@ -160,8 +162,8 @@ class ExtractorTest {
echo "\nTest: CSS Link Generation\n";
echo "--------------------------\n";
$this->test(
"Generates link tag for standalone CSS",
strpos($output, 'link id="unraid-standalone-apps-standalone-apps-RlN0czLV-css"') !== false
"Generates link tag for hashed standalone CSS",
strpos($output, 'link id="unraid-standalone-apps-' . $this->sanitizeForExpectedId($this->standaloneCssFile) . '"') !== false
);
$this->test(
"Generates link tag for UI styles",
@@ -209,7 +211,7 @@ class ExtractorTest {
echo "------------------------\n";
$this->test(
"Correctly constructs standalone-apps path",
strpos($output, '/plugins/dynamix.my.servers/unraid-components/standalone-apps/standalone-apps.js') !== false
strpos($output, '/plugins/dynamix.my.servers/unraid-components/standalone-apps/' . $this->standaloneJsFile) !== false
);
$this->test(
"Correctly constructs ui-components path",
@@ -274,11 +276,11 @@ class ExtractorTest {
echo "--------------------------------\n";
$this->test(
"Loads CSS from JS entry css array (app-styles.css)",
strpos($output, 'id="unraid-standalone-apps-standalone-apps-js-css-app-styles-css"') !== false
strpos($output, 'id="unraid-standalone-apps-' . $this->sanitizeForExpectedId($this->standaloneJsFile) . '-css-app-styles-css"') !== false
);
$this->test(
"Loads CSS from JS entry css array (theme.css)",
strpos($output, 'id="unraid-standalone-apps-standalone-apps-js-css-theme-css"') !== false
strpos($output, 'id="unraid-standalone-apps-' . $this->sanitizeForExpectedId($this->standaloneJsFile) . '-css-theme-css"') !== false
);
$this->test(
"CSS from manifest has correct href path (app-styles.css)",
@@ -344,6 +346,11 @@ class ExtractorTest {
}
rmdir($dir);
}
private function sanitizeForExpectedId(string $input): string
{
return preg_replace('/[^a-zA-Z0-9-]/', '-', $input);
}
private function reportResults() {
echo "\n";
@@ -366,4 +373,4 @@ class ExtractorTest {
// Run tests
$test = new ExtractorTest();
exit($test->run());
exit($test->run());

66
pnpm-lock.yaml generated
View File

@@ -317,8 +317,8 @@ importers:
specifier: 1.1.0
version: 1.1.0
zod:
specifier: 3.25.76
version: 3.25.76
specifier: 4.1.11
version: 4.1.11
devDependencies:
'@eslint/js':
specifier: 9.34.0
@@ -426,8 +426,8 @@ importers:
specifier: 6.0.3
version: 6.0.3
'@types/uuid':
specifier: 10.0.0
version: 10.0.0
specifier: 11.0.0
version: 11.0.0
'@types/ws':
specifier: 8.18.1
version: 8.18.1
@@ -831,8 +831,8 @@ importers:
specifier: 4.20.5
version: 4.20.5
zod:
specifier: 3.25.76
version: 3.25.76
specifier: 4.1.11
version: 4.1.11
zx:
specifier: 8.8.1
version: 8.8.1
@@ -1081,7 +1081,7 @@ importers:
version: 3.6.0(@jsonforms/core@3.6.0)(@jsonforms/vue@3.6.0(@jsonforms/core@3.6.0)(vue@3.5.20(typescript@5.9.2)))(ajv@8.17.1)(dayjs@1.11.14)(lodash@4.17.21)(maska@2.1.11)(vue@3.5.20(typescript@5.9.2))(vuetify@3.9.6)
'@nuxt/ui':
specifier: 4.0.0-alpha.0
version: 4.0.0-alpha.0(@babel/parser@7.28.4)(@netlify/blobs@9.1.2)(change-case@5.4.4)(db0@0.3.2)(embla-carousel@8.6.0)(focus-trap@7.6.5)(ioredis@5.7.0)(jwt-decode@4.0.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vue-router@4.5.1(vue@3.5.20(typescript@5.9.2)))(vue@3.5.20(typescript@5.9.2))(zod@3.25.76)
version: 4.0.0-alpha.0(@babel/parser@7.28.4)(@netlify/blobs@9.1.2)(change-case@5.4.4)(db0@0.3.2)(embla-carousel@8.6.0)(focus-trap@7.6.5)(ioredis@5.7.0)(jwt-decode@4.0.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vue-router@4.5.1(vue@3.5.20(typescript@5.9.2)))(vue@3.5.20(typescript@5.9.2))(zod@4.1.11)
'@unraid/shared-callbacks':
specifier: 1.1.1
version: 1.1.1(@vueuse/core@13.8.0(vue@3.5.20(typescript@5.9.2)))
@@ -4965,8 +4965,9 @@ packages:
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@types/uuid@11.0.0':
resolution: {integrity: sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==}
deprecated: This is a stub types definition. uuid provides its own type definitions, so you do not need this installed.
'@types/validate-npm-package-name@4.0.2':
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
@@ -12389,8 +12390,8 @@ packages:
vue-component-type-helpers@3.0.6:
resolution: {integrity: sha512-6CRM8X7EJqWCJOiKPvSLQG+hJPb/Oy2gyJx3pLjUEhY7PuaCthQu3e0zAGI1lqUBobrrk9IT0K8sG2GsCluxoQ==}
vue-component-type-helpers@3.0.7:
resolution: {integrity: sha512-TvyUcFXmjZcXUvU+r1MOyn4/vv4iF+tPwg5Ig33l/FJ3myZkxeQpzzQMLMFWcQAjr6Xs7BRwVy/TwbmNZUA/4w==}
vue-component-type-helpers@3.1.0:
resolution: {integrity: sha512-cC1pYNRZkSS1iCvdlaMbbg2sjDwxX098FucEjtz9Yig73zYjWzQsnMe5M9H8dRNv55hAIDGUI29hF2BEUA4FMQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -12810,6 +12811,9 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.1.11:
resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==}
zx@8.8.1:
resolution: {integrity: sha512-qvsKBnvWHstHKCluKPlEgI/D3+mdiQyMoSSeFR8IX/aXzWIas5A297KxKgPJhuPXdrR6ma0Jzx43+GQ/8sqbrw==}
engines: {node: '>= 12.17.0'}
@@ -12819,27 +12823,27 @@ snapshots:
'@adobe/css-tools@4.4.3': {}
'@ai-sdk/gateway@1.0.15(zod@3.25.76)':
'@ai-sdk/gateway@1.0.15(zod@4.1.11)':
dependencies:
'@ai-sdk/provider': 2.0.0
'@ai-sdk/provider-utils': 3.0.7(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/provider-utils': 3.0.7(zod@4.1.11)
zod: 4.1.11
'@ai-sdk/provider-utils@3.0.7(zod@3.25.76)':
'@ai-sdk/provider-utils@3.0.7(zod@4.1.11)':
dependencies:
'@ai-sdk/provider': 2.0.0
'@standard-schema/spec': 1.0.0
eventsource-parser: 3.0.5
zod: 3.25.76
zod: 4.1.11
'@ai-sdk/provider@2.0.0':
dependencies:
json-schema: 0.4.0
'@ai-sdk/vue@2.0.26(vue@3.5.20(typescript@5.9.2))(zod@3.25.76)':
'@ai-sdk/vue@2.0.26(vue@3.5.20(typescript@5.9.2))(zod@4.1.11)':
dependencies:
'@ai-sdk/provider-utils': 3.0.7(zod@3.25.76)
ai: 5.0.26(zod@3.25.76)
'@ai-sdk/provider-utils': 3.0.7(zod@4.1.11)
ai: 5.0.26(zod@4.1.11)
swrv: 1.1.0(vue@3.5.20(typescript@5.9.2))
optionalDependencies:
vue: 3.5.20(typescript@5.9.2)
@@ -15621,9 +15625,9 @@ snapshots:
transitivePeerDependencies:
- magicast
'@nuxt/ui@4.0.0-alpha.0(@babel/parser@7.28.4)(@netlify/blobs@9.1.2)(change-case@5.4.4)(db0@0.3.2)(embla-carousel@8.6.0)(focus-trap@7.6.5)(ioredis@5.7.0)(jwt-decode@4.0.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vue-router@4.5.1(vue@3.5.20(typescript@5.9.2)))(vue@3.5.20(typescript@5.9.2))(zod@3.25.76)':
'@nuxt/ui@4.0.0-alpha.0(@babel/parser@7.28.4)(@netlify/blobs@9.1.2)(change-case@5.4.4)(db0@0.3.2)(embla-carousel@8.6.0)(focus-trap@7.6.5)(ioredis@5.7.0)(jwt-decode@4.0.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vue-router@4.5.1(vue@3.5.20(typescript@5.9.2)))(vue@3.5.20(typescript@5.9.2))(zod@4.1.11)':
dependencies:
'@ai-sdk/vue': 2.0.26(vue@3.5.20(typescript@5.9.2))(zod@3.25.76)
'@ai-sdk/vue': 2.0.26(vue@3.5.20(typescript@5.9.2))(zod@4.1.11)
'@iconify/vue': 5.0.0(vue@3.5.20(typescript@5.9.2))
'@internationalized/date': 3.8.2
'@internationalized/number': 3.6.5
@@ -15671,7 +15675,7 @@ snapshots:
vue-component-type-helpers: 3.0.6
optionalDependencies:
vue-router: 4.5.1(vue@3.5.20(typescript@5.9.2))
zod: 3.25.76
zod: 4.1.11
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
@@ -16455,7 +16459,7 @@ snapshots:
storybook: 9.1.3(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))
type-fest: 2.19.0
vue: 3.5.20(typescript@5.9.2)
vue-component-type-helpers: 3.0.7
vue-component-type-helpers: 3.1.0
'@swc/core-darwin-arm64@1.13.5':
optional: true
@@ -16938,7 +16942,9 @@ snapshots:
'@types/trusted-types@2.0.7':
optional: true
'@types/uuid@10.0.0': {}
'@types/uuid@11.0.0':
dependencies:
uuid: 13.0.0
'@types/validate-npm-package-name@4.0.2': {}
@@ -17845,13 +17851,13 @@ snapshots:
agent-base@7.1.3: {}
ai@5.0.26(zod@3.25.76):
ai@5.0.26(zod@4.1.11):
dependencies:
'@ai-sdk/gateway': 1.0.15(zod@3.25.76)
'@ai-sdk/gateway': 1.0.15(zod@4.1.11)
'@ai-sdk/provider': 2.0.0
'@ai-sdk/provider-utils': 3.0.7(zod@3.25.76)
'@ai-sdk/provider-utils': 3.0.7(zod@4.1.11)
'@opentelemetry/api': 1.9.0
zod: 3.25.76
zod: 4.1.11
ajv-errors@3.0.0(ajv@8.17.1):
dependencies:
@@ -25309,7 +25315,7 @@ snapshots:
vue-component-type-helpers@3.0.6: {}
vue-component-type-helpers@3.0.7: {}
vue-component-type-helpers@3.1.0: {}
vue-demi@0.14.10(vue@3.5.20(typescript@5.9.2)):
dependencies:
@@ -25833,4 +25839,6 @@ snapshots:
zod@3.25.76: {}
zod@4.1.11: {}
zx@8.8.1: {}

View File

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

View File

@@ -0,0 +1,171 @@
import { ref } from 'vue';
import { mount } from '@vue/test-utils';
import { DOCS } from '~/helpers/urls';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import ChangelogModal from '~/components/UpdateOs/ChangelogModal.vue';
vi.mock('@unraid/ui', () => ({
BrandButton: { template: '<button><slot /></button>' },
BrandLoading: { template: '<div class="brand-loading" />' },
cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
ResponsiveModal: { template: '<div><slot /></div>', props: ['open'] },
ResponsiveModalFooter: { template: '<div><slot /></div>' },
ResponsiveModalHeader: { template: '<div><slot /></div>' },
ResponsiveModalTitle: { template: '<div><slot /></div>' },
}));
vi.mock('@heroicons/vue/24/solid', () => ({
ArrowRightIcon: { template: '<svg />' },
ArrowTopRightOnSquareIcon: { template: '<svg />' },
KeyIcon: { template: '<svg />' },
ServerStackIcon: { template: '<svg />' },
}));
vi.mock('~/components/UpdateOs/RawChangelogRenderer.vue', () => ({
default: { template: '<div />', props: ['changelog', 'version', 'date', 't', 'changelogPretty'] },
}));
vi.mock('pinia', async () => {
const actual = await vi.importActual<typeof import('pinia')>('pinia');
const isActualStore = (candidate: unknown): candidate is Parameters<typeof actual.storeToRefs>[0] =>
Boolean(candidate && typeof candidate === 'object' && '$id' in candidate);
const isRefLike = (input: unknown): input is { value: unknown } =>
Boolean(input && typeof input === 'object' && 'value' in input);
return {
...actual,
storeToRefs: (store: unknown) => {
if (isActualStore(store)) {
return actual.storeToRefs(store);
}
if (!store || typeof store !== 'object') {
return {};
}
const refs: Record<string, unknown> = {};
for (const [key, value] of Object.entries(store)) {
if (isRefLike(value)) {
refs[key] = value;
}
}
return refs;
},
};
});
const mockRenew = vi.fn();
vi.mock('~/store/purchase', () => ({
usePurchaseStore: () => ({
renew: mockRenew,
}),
}));
const mockAvailableWithRenewal = ref(false);
const mockReleaseForUpdate = ref(null);
const mockChangelogModalVisible = ref(false);
const mockSetReleaseForUpdate = vi.fn();
const mockFetchAndConfirmInstall = vi.fn();
vi.mock('~/store/updateOs', () => ({
useUpdateOsStore: () => ({
availableWithRenewal: mockAvailableWithRenewal,
releaseForUpdate: mockReleaseForUpdate,
changelogModalVisible: mockChangelogModalVisible,
setReleaseForUpdate: mockSetReleaseForUpdate,
fetchAndConfirmInstall: mockFetchAndConfirmInstall,
}),
}));
const mockDarkMode = ref(false);
const mockTheme = ref({ name: 'default' });
vi.mock('~/store/theme', () => ({
useThemeStore: () => ({
darkMode: mockDarkMode,
theme: mockTheme,
}),
}));
describe('ChangelogModal iframeSrc', () => {
const mountWithChangelog = (changelogPretty: string | null) =>
mount(ChangelogModal, {
props: {
t: (key: string) => key,
open: true,
release: {
version: '6.12.0',
changelogPretty: changelogPretty ?? undefined,
changelog: 'Raw changelog markdown',
name: 'Unraid OS 6.12.0',
date: '2024-01-01',
},
},
});
beforeEach(() => {
mockRenew.mockClear();
mockAvailableWithRenewal.value = false;
mockReleaseForUpdate.value = null;
mockChangelogModalVisible.value = false;
mockSetReleaseForUpdate.mockClear();
mockFetchAndConfirmInstall.mockClear();
mockDarkMode.value = false;
mockTheme.value = { name: 'default' };
});
it('sanitizes absolute docs URLs to embed within DOCS origin', () => {
const entry = `${DOCS.origin}/go/release-notes/?foo=bar#section`;
const wrapper = mountWithChangelog(entry);
const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc;
expect(iframeSrc).toBeTruthy();
const iframeUrl = new URL(iframeSrc!);
expect(iframeUrl.origin).toBe(DOCS.origin);
expect(iframeUrl.pathname).toBe('/go/release-notes/');
expect(iframeUrl.searchParams.get('embed')).toBe('1');
expect(iframeUrl.searchParams.get('theme')).toBe('light');
expect(iframeUrl.searchParams.get('entry')).toBe('/go/release-notes/?foo=bar#section');
});
it('builds DOCS-relative URL when provided a path entry', () => {
const wrapper = mountWithChangelog('updates/6.12?tab=notes#overview');
const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc;
expect(iframeSrc).toBeTruthy();
const iframeUrl = new URL(iframeSrc!);
expect(iframeUrl.origin).toBe(DOCS.origin);
expect(iframeUrl.pathname).toBe('/updates/6.12');
expect(iframeUrl.searchParams.get('entry')).toBe('/updates/6.12?tab=notes#overview');
});
it('applies dark theme when current UI theme requires it', () => {
mockTheme.value = { name: 'azure' };
const wrapper = mountWithChangelog(`${DOCS.origin}/release/6.12`);
const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc;
expect(iframeSrc).toBeTruthy();
const iframeUrl = new URL(iframeSrc!);
expect(iframeUrl.searchParams.get('theme')).toBe('dark');
});
it('rejects non-docs origins and returns null', () => {
const wrapper = mountWithChangelog('https://example.com/bad');
const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc;
expect(iframeSrc).toBeNull();
});
it('rejects non-http(s) protocols', () => {
const wrapper = mountWithChangelog('javascript:alert(1)');
const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc;
expect(iframeSrc).toBeNull();
});
});

View File

@@ -0,0 +1,271 @@
import { nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ComposerTranslation } from 'vue-i18n';
import CheckUpdateResponseModal from '~/components/UpdateOs/CheckUpdateResponseModal.vue';
const translate: ComposerTranslation = ((key: string, params?: unknown) => {
if (Array.isArray(params) && params.length > 0) {
return params.reduce<string>(
(result, value, index) => result.replace(`{${index}}`, String(value)),
key
);
}
if (params && typeof params === 'object') {
return Object.entries(params as Record<string, unknown>).reduce<string>(
(result, [placeholder, value]) => result.replace(`{${placeholder}}`, String(value)),
key
);
}
if (typeof params === 'number') {
return key.replace('{0}', String(params));
}
return key;
}) as ComposerTranslation;
vi.mock('@unraid/ui', () => ({
BrandButton: {
name: 'BrandButton',
props: {
text: {
type: String,
default: undefined,
},
},
emits: ['click'],
template: '<button class="brand-button" @click="$emit(\'click\')"><slot>{{ text }}</slot></button>',
},
BrandLoading: { template: '<div class="brand-loading" />' },
Button: { template: '<button class="ui-button"><slot /></button>' },
cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
DialogDescription: { template: '<div class="dialog-description"><slot /></div>' },
Label: { template: '<label><slot /></label>' },
ResponsiveModal: {
name: 'ResponsiveModal',
props: ['open', 'dialogClass', 'sheetClass', 'showCloseButton'],
template: '<div class="responsive-modal"><slot /></div>',
},
ResponsiveModalFooter: { template: '<div class="responsive-modal-footer"><slot /></div>' },
ResponsiveModalHeader: { template: '<div class="responsive-modal-header"><slot /></div>' },
ResponsiveModalTitle: { template: '<div class="responsive-modal-title"><slot /></div>' },
Switch: { name: 'Switch', props: ['modelValue'], template: '<div class="switch" />' },
Tooltip: { template: '<div class="tooltip"><slot /></div>' },
TooltipTrigger: { template: '<div class="tooltip-trigger"><slot /></div>' },
TooltipContent: { template: '<div class="tooltip-content"><slot /></div>' },
TooltipProvider: { template: '<div class="tooltip-provider"><slot /></div>' },
}));
vi.mock('@heroicons/vue/24/solid', () => ({
ArrowTopRightOnSquareIcon: { template: '<svg />' },
CheckCircleIcon: { template: '<svg />' },
CogIcon: { template: '<svg />' },
EyeIcon: { template: '<svg />' },
IdentificationIcon: { template: '<svg />' },
KeyIcon: { template: '<svg />' },
}));
vi.mock('@heroicons/vue/24/outline', () => ({
ArrowDownTrayIcon: { template: '<svg />' },
}));
vi.mock('~/components/UpdateOs/IgnoredRelease.vue', () => ({
default: { template: '<div class="ignored-release" />', props: ['label'] },
}));
vi.mock('~/composables/dateTime', () => ({
default: () => ({
outputDateTimeFormatted: ref('2024-01-01'),
outputDateTimeReadableDiff: ref('today'),
}),
}));
vi.mock('pinia', async () => {
const actual = await vi.importActual<typeof import('pinia')>('pinia');
const isActualStore = (candidate: unknown): candidate is Parameters<typeof actual.storeToRefs>[0] =>
Boolean(candidate && typeof candidate === 'object' && '$id' in candidate);
const isRefLike = (input: unknown): input is { value: unknown } =>
Boolean(input && typeof input === 'object' && 'value' in input);
return {
...actual,
storeToRefs: (store: unknown) => {
if (isActualStore(store)) {
return actual.storeToRefs(store);
}
if (!store || typeof store !== 'object') {
return {};
}
const refs: Record<string, unknown> = {};
for (const [key, value] of Object.entries(store)) {
if (isRefLike(value)) {
refs[key] = value;
}
}
return refs;
},
};
});
const mockAccountUpdateOs = vi.fn();
vi.mock('~/store/account', () => ({
useAccountStore: () => ({
updateOs: mockAccountUpdateOs,
}),
}));
const mockRenew = vi.fn();
vi.mock('~/store/purchase', () => ({
usePurchaseStore: () => ({
renew: mockRenew,
}),
}));
const mockSetReleaseForUpdate = vi.fn();
const mockSetModalOpen = vi.fn();
const mockFetchAndConfirmInstall = vi.fn();
const available = ref<string | null>(null);
const availableWithRenewal = ref<string | null>(null);
const availableReleaseDate = ref<number | null>(null);
const availableRequiresAuth = ref(false);
const checkForUpdatesLoading = ref(false);
vi.mock('~/store/updateOs', () => ({
useUpdateOsStore: () => ({
available,
availableWithRenewal,
availableReleaseDate,
availableRequiresAuth,
checkForUpdatesLoading,
setReleaseForUpdate: mockSetReleaseForUpdate,
setModalOpen: mockSetModalOpen,
fetchAndConfirmInstall: mockFetchAndConfirmInstall,
}),
}));
const regExp = ref<number | null>(null);
const regUpdatesExpired = ref(false);
const dateTimeFormat = ref('YYYY-MM-DD');
const osVersion = ref<string | null>(null);
const updateOsIgnoredReleases = ref<string[]>([]);
const updateOsNotificationsEnabled = ref(true);
const updateOsResponse = ref<{ changelog?: string | null } | null>(null);
const mockUpdateOsIgnoreRelease = vi.fn();
vi.mock('~/store/server', () => ({
useServerStore: () => ({
regExp,
regUpdatesExpired,
dateTimeFormat,
osVersion,
updateOsIgnoredReleases,
updateOsNotificationsEnabled,
updateOsResponse,
updateOsIgnoreRelease: mockUpdateOsIgnoreRelease,
}),
}));
const mountModal = () =>
mount(CheckUpdateResponseModal, {
props: {
open: true,
t: translate,
},
});
describe('CheckUpdateResponseModal', () => {
beforeEach(() => {
available.value = null;
availableWithRenewal.value = null;
availableReleaseDate.value = null;
availableRequiresAuth.value = false;
checkForUpdatesLoading.value = false;
regExp.value = null;
regUpdatesExpired.value = false;
osVersion.value = null;
updateOsIgnoredReleases.value = [];
updateOsNotificationsEnabled.value = true;
updateOsResponse.value = null;
mockAccountUpdateOs.mockClear();
mockRenew.mockClear();
mockSetModalOpen.mockClear();
mockSetReleaseForUpdate.mockClear();
mockFetchAndConfirmInstall.mockClear();
mockUpdateOsIgnoreRelease.mockClear();
});
it('renders loading state while checking for updates', () => {
checkForUpdatesLoading.value = true;
const wrapper = mountModal();
expect(wrapper.find('.responsive-modal-title').text()).toBe('Checking for OS updates...');
expect(wrapper.find('.brand-loading').exists()).toBe(true);
expect(wrapper.find('.ui-button').text()).toBe('More Options');
});
it('shows up-to-date messaging when no updates are available', async () => {
osVersion.value = '6.12.3';
updateOsNotificationsEnabled.value = false;
const wrapper = mountModal();
await nextTick();
expect(wrapper.find('.responsive-modal-title').text()).toBe('Unraid OS is up-to-date');
expect(wrapper.text()).toContain('Current Version 6.12.3');
expect(wrapper.text()).toContain(
'Go to Settings > Notifications to enable automatic OS update notifications for future releases.'
);
expect(wrapper.find('.ui-button').text()).toBe('More Options');
expect(wrapper.text()).toContain('Enable update notifications');
});
it('displays update actions when a new release is available', async () => {
available.value = '6.13.0';
osVersion.value = '6.12.3';
updateOsResponse.value = { changelog: '### New release' };
const wrapper = mountModal();
await nextTick();
const actionButtons = wrapper.findAll('.brand-button');
const viewChangelogButton = actionButtons.find((button) =>
button.text().includes('View Changelog to Start Update')
);
expect(viewChangelogButton).toBeDefined();
await viewChangelogButton!.trigger('click');
expect(mockSetReleaseForUpdate).toHaveBeenCalledWith({ changelog: '### New release' });
});
it('includes renew option when update requires license renewal', async () => {
available.value = '6.14.0';
availableWithRenewal.value = '6.14.0';
updateOsResponse.value = { changelog: '### Renewal release' };
const wrapper = mountModal();
await nextTick();
const actionButtons = wrapper.findAll('.brand-button');
const labels = actionButtons.map((button) => button.text());
expect(labels).toContain('View Changelog');
expect(labels).toContain('Extend License');
await actionButtons.find((btn) => btn.text() === 'Extend License')?.trigger('click');
expect(mockRenew).toHaveBeenCalled();
});
});

View File

@@ -2,7 +2,7 @@
* ColorSwitcher Component Test Coverage
*/
import { nextTick } from 'vue';
import { nextTick, ref } from 'vue';
import { setActivePinia } from 'pinia';
import { mount } from '@vue/test-utils';
@@ -15,6 +15,15 @@ import type { MockInstance } from 'vitest';
import ColorSwitcher from '~/components/ColorSwitcher.standalone.vue';
import { useThemeStore } from '~/store/theme';
vi.mock('@vue/apollo-composable', () => ({
useQuery: () => ({
result: ref(null),
loading: ref(false),
onResult: vi.fn(),
onError: vi.fn(),
}),
}));
// Explicitly mock @unraid/ui to ensure we use the actual components
vi.mock('@unraid/ui', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;

View File

@@ -27,6 +27,8 @@ vi.mock('@vue/apollo-composable', () => ({
useQuery: () => ({
result: { value: {} },
loading: { value: false },
onResult: vi.fn(),
onError: vi.fn(),
}),
useLazyQuery: () => ({
result: { value: {} },

View File

@@ -30,6 +30,8 @@ vi.mock('@vue/apollo-composable', () => ({
useQuery: () => ({
result: { value: {} },
loading: { value: false },
onResult: vi.fn(),
onError: vi.fn(),
}),
useLazyQuery: () => ({
result: { value: {} },

View File

@@ -105,12 +105,7 @@ describe('mount-engine', () => {
vi.restoreAllMocks();
document.body.innerHTML = '';
// Clean up global references
if (window.__unifiedApp) {
delete window.__unifiedApp;
}
if (window.__mountedComponents) {
delete window.__mountedComponents;
}
// Clean up any window references if needed
});
describe('mountUnifiedApp', () => {
@@ -438,29 +433,6 @@ describe('mount-engine', () => {
});
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 = 'global-app';
document.body.appendChild(element);
mockComponentMappings.push({
selector: '#global-app',
appId: 'global-app',
component: TestComponent,
});
mountUnifiedApp();
expect(window.__mountedComponents).toBeDefined();
expect(Array.isArray(window.__mountedComponents)).toBe(true);
expect(window.__mountedComponents!.length).toBe(1);
});
it('should expose globalPinia globally', () => {
expect(window.globalPinia).toBeDefined();
expect(window.globalPinia).toBe(mockGlobalPinia);

View File

@@ -2,7 +2,7 @@
* Theme store test coverage
*/
import { nextTick } from 'vue';
import { nextTick, ref } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import { defaultColors } from '~/themes/default';
@@ -11,6 +11,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useThemeStore } from '~/store/theme';
vi.mock('@vue/apollo-composable', () => ({
useQuery: () => ({
result: ref(null),
loading: ref(false),
onResult: vi.fn(),
onError: vi.fn(),
}),
}));
vi.mock('hex-to-rgba', () => ({
default: vi.fn((hex, opacity) => `rgba(mock-${hex}-${opacity})`),
}));

View File

@@ -126,12 +126,18 @@ describe('UnraidApi Store', () => {
store.unraidApiStatus = 'offline';
await nextTick();
expect(mockErrorsStore.removeErrorByRef).toHaveBeenCalledWith('unraidApiOffline');
expect(mockErrorsStore.setError).toHaveBeenCalledWith({
heading: 'Warning: API is offline!',
message: 'The Unraid API is currently offline.',
ref: 'unraidApiOffline',
level: 'warning',
type: 'unraidApiState',
actions: [
expect.objectContaining({
text: 'Restart unraid-api',
}),
],
});
});
@@ -211,6 +217,28 @@ describe('UnraidApi Store', () => {
expect(store.unraidApiStatus).toBe('restarting');
});
it('should reuse existing restart promise when restart is already running', async () => {
const { WebguiUnraidApiCommand } = await import('~/composables/services/webgui');
const mockWebguiCommand = vi.mocked(WebguiUnraidApiCommand);
let resolveCommand: (() => void) | undefined;
const commandPromise = new Promise<void>((resolve) => {
resolveCommand = resolve;
});
mockWebguiCommand.mockReturnValueOnce(commandPromise);
store.unraidApiStatus = 'online';
const firstCallPromise = store.restartUnraidApiClient();
const secondCallPromise = store.restartUnraidApiClient();
expect(mockWebguiCommand).toHaveBeenCalledTimes(1);
resolveCommand?.();
await Promise.all([firstCallPromise, secondCallPromise]);
});
it('should handle error during restart', async () => {
const { WebguiUnraidApiCommand } = await import('~/composables/services/webgui');
const mockWebguiCommand = vi.mocked(WebguiUnraidApiCommand);

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/web",
"version": "4.22.2",
"version": "4.25.0",
"private": true,
"type": "module",
"license": "GPL-2.0-or-later",

View File

@@ -0,0 +1,165 @@
interface Container {
type: string;
parent?: Container;
}
interface Rule extends Container {
selector?: string;
selectors?: string[];
}
interface AtRule extends Container {
name: string;
params: string;
}
type PostcssPlugin = {
postcssPlugin: string;
Rule?(rule: Rule): void;
};
type PluginCreator<T> = {
(opts?: T): PostcssPlugin;
postcss?: boolean;
};
export interface ScopeOptions {
scope?: string;
layers?: string[];
includeRoot?: boolean;
}
const DEFAULT_SCOPE = '.unapi';
const DEFAULT_LAYERS = ['*'];
const DEFAULT_INCLUDE_ROOT = true;
const KEYFRAME_AT_RULES = new Set(['keyframes']);
const NON_SCOPED_AT_RULES = new Set(['font-face', 'page']);
const MERGE_WITH_SCOPE_PATTERNS: RegExp[] = [/^\.theme-/, /^\.has-custom-/, /^\.dark\b/];
function shouldScopeRule(rule: Rule, targetLayers: Set<string>, includeRootRules: boolean): boolean {
const hasSelectorString = typeof rule.selector === 'string' && rule.selector.length > 0;
const hasSelectorArray = Array.isArray(rule.selectors) && rule.selectors.length > 0;
// Skip rules without selectors (e.g. @font-face) or nested keyframe steps
if (!hasSelectorString && !hasSelectorArray) {
return false;
}
const directParent = rule.parent;
if (directParent?.type === 'atrule') {
const parentAtRule = directParent as AtRule;
const parentAtRuleName = parentAtRule.name.toLowerCase();
if (KEYFRAME_AT_RULES.has(parentAtRuleName) || parentAtRuleName.endsWith('keyframes')) {
return false;
}
if (NON_SCOPED_AT_RULES.has(parentAtRuleName)) {
return false;
}
}
const includeAllLayers = targetLayers.has('*');
// Traverse ancestors to find the enclosing @layer declaration
let current: Container | undefined = rule.parent ?? undefined;
while (current) {
if (current.type === 'atrule') {
const currentAtRule = current as AtRule;
if (currentAtRule.name === 'layer') {
const layerNames = currentAtRule.params
.split(',')
.map((name: string) => name.trim())
.filter(Boolean);
if (includeAllLayers) {
return true;
}
return layerNames.some((name) => targetLayers.has(name));
}
}
current = current.parent ?? undefined;
}
// If the rule is not inside any @layer, treat it as root-level CSS
return includeRootRules;
}
function hasScope(selector: string, scope: string): boolean {
return selector.includes(scope);
}
function prefixSelector(selector: string, scope: string): string {
const trimmed = selector.trim();
if (!trimmed) {
return selector;
}
if (hasScope(trimmed, scope)) {
return trimmed;
}
// Do not prefix :host selectors they are only valid at the top level
if (trimmed.startsWith(':host')) {
return trimmed;
}
if (trimmed === ':root') {
return scope;
}
if (trimmed.startsWith(':root')) {
return `${scope}${trimmed.slice(':root'.length)}`;
}
const firstToken = trimmed.split(/[\s>+~]/, 1)[0] ?? '';
const shouldMergeWithScope =
!firstToken.includes('\\:') && MERGE_WITH_SCOPE_PATTERNS.some((pattern) => pattern.test(firstToken));
if (shouldMergeWithScope) {
return `${scope}${trimmed}`;
}
return `${scope} ${trimmed}`;
}
export const scopeTailwindToUnapi: PluginCreator<ScopeOptions> = (options: ScopeOptions = {}) => {
const scope = options.scope ?? DEFAULT_SCOPE;
const layers = options.layers ?? DEFAULT_LAYERS;
const includeRootRules = options.includeRoot ?? DEFAULT_INCLUDE_ROOT;
const targetLayers = new Set<string>(layers);
return {
postcssPlugin: 'scope-tailwind-to-unapi',
Rule(rule: Rule) {
if (!shouldScopeRule(rule, targetLayers, includeRootRules)) {
return;
}
const hasSelectorArray = Array.isArray(rule.selectors);
let selectors: string[] = [];
if (hasSelectorArray && rule.selectors) {
selectors = rule.selectors;
} else if (rule.selector) {
selectors = [rule.selector];
}
if (!selectors.length) {
return;
}
const scopedSelectors = selectors.map((selector: string) => prefixSelector(selector, scope));
if (hasSelectorArray) {
rule.selectors = scopedSelectors;
} else {
rule.selector = scopedSelectors.join(', ');
}
},
};
};
scopeTailwindToUnapi.postcss = true;
export default scopeTailwindToUnapi;

View File

@@ -0,0 +1,86 @@
import { performance } from 'node:perf_hooks';
import { describe, expect, it } from 'vitest';
import scopeTailwindToUnapi from '../../postcss/scopeTailwindToUnapi';
type LayerAtRule = {
type: string;
name: string;
params: string;
parent?: LayerAtRule;
};
type MutableRule = {
type: string;
selector?: string;
selectors?: string[];
parent?: LayerAtRule;
};
function createRule(selectors: string[], layer = 'utilities'): MutableRule {
return {
type: 'rule',
selector: selectors.join(', '),
selectors: [...selectors],
parent: {
type: 'atrule',
name: 'layer',
params: layer,
},
};
}
describe('scopeTailwindToUnapi plugin', () => {
it('prefixes simple selectors with .unapi scope', () => {
const plugin = scopeTailwindToUnapi();
const rule = createRule(['.btn-primary']);
plugin.Rule?.(rule);
expect(rule.selectors).toEqual(['.unapi .btn-primary']);
});
it('merges variant class selectors into the scope', () => {
const plugin = scopeTailwindToUnapi();
const rule = createRule(['.dark .btn-secondary']);
plugin.Rule?.(rule);
expect(rule.selectors).toEqual(['.unapi.dark .btn-secondary']);
});
it('handles rules expressed with selector strings only', () => {
const plugin = scopeTailwindToUnapi();
const rule: MutableRule = {
type: 'rule',
selector: '.card',
parent: {
type: 'atrule',
name: 'layer',
params: 'components',
},
};
plugin.Rule?.(rule);
expect(rule.selector).toBe('.unapi .card');
});
it('processes large rule sets within the target budget', () => {
const plugin = scopeTailwindToUnapi();
const totalRules = 10_000;
const start = performance.now();
for (let index = 0; index < totalRules; index += 1) {
const rule = createRule([`.test-${index}`]);
plugin.Rule?.(rule);
}
const durationMs = performance.now() - start;
// Ensure we stay well under 1 second even on slower CI hosts.
expect(durationMs).toBeLessThan(1_000);
});
});

View File

@@ -9,7 +9,7 @@
/* Import theme and utilities only - no global preflight */
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
@import "@nuxt/ui";
/* @import "@nuxt/ui"; temporarily disabled */
@import 'tw-animate-css';
@import '../../../@tailwind-shared/index.css';

View File

@@ -325,7 +325,7 @@ defineExpose({ refreshLogContent });
</script>
<template>
<div class="flex h-full max-h-full flex-col overflow-hidden">
<div class="log-viewer flex h-full max-h-full flex-col overflow-hidden">
<div
class="bg-muted text-muted-foreground flex shrink-0 items-center justify-between px-4 py-2 text-xs"
>
@@ -412,9 +412,8 @@ defineExpose({ refreshLogContent });
<style scoped>
/* Define CSS variables for both light and dark themes */
:root {
.log-viewer {
/* Light theme colors (default) - adjusted for better readability */
--log-background: transparent;
--log-keyword-color: hsl(var(--destructive) / 0.9); /* Slightly dimmed */
--log-string-color: hsl(var(--primary) / 0.7); /* Dimmed primary color */
--log-comment-color: hsl(var(--muted-foreground));
@@ -431,7 +430,6 @@ defineExpose({ refreshLogContent });
/* Dark theme colors - use slightly different color combinations for better visibility */
.theme-dark {
--log-background: transparent;
--log-keyword-color: hsl(var(--destructive) / 0.9);
--log-string-color: hsl(var(--primary) / 0.9);
--log-comment-color: hsl(var(--muted-foreground) / 0.9);
@@ -446,11 +444,6 @@ defineExpose({ refreshLogContent });
--log-success-bg: hsl(120, 100%, 40% / 0.15);
}
/* Add some basic styling for the highlighted logs */
.hljs {
background: var(--log-background);
}
/* Style for error messages */
.hljs .hljs-keyword,
.hljs .hljs-selector-tag,

View File

@@ -4,7 +4,6 @@ import { useMutation, useQuery, useSubscription } from '@vue/apollo-composable';
import {
Button,
Select,
Sheet,
SheetContent,
SheetHeader,
@@ -46,9 +45,8 @@ const { mutate: recalculateOverview } = useMutation(resetOverview);
const { confirm } = useConfirm();
const importance = ref<Importance | undefined>(undefined);
const filterItems = [
{ type: 'label' as const, label: 'Notification Types' },
{ label: 'All Types', value: 'all' },
const filterOptions: Array<{ label: string; value?: Importance }> = [
{ label: 'All Types' },
{ label: 'Alert', value: Importance.ALERT },
{ label: 'Info', value: Importance.INFO },
{ label: 'Warning', value: Importance.WARNING },
@@ -99,8 +97,6 @@ onNotificationAdded(({ data }) => {
if (notif.timestamp) {
latestNotificationTimestamp.value = notif.timestamp;
}
// probably smart to leave this log outside the if-block for the initial release
console.log('incoming notification', notif);
if (!globalThis.toast) {
return;
}
@@ -203,32 +199,46 @@ const prepareToViewNotifications = () => {
</TabsContent>
</div>
<div class="mt-2 flex items-center justify-between gap-2 px-3">
<Select
:items="filterItems"
placeholder="Filter By"
class="h-8 px-3 text-sm"
@update:model-value="
(val: unknown) => {
const strVal = String(val);
importance = strVal === 'all' || !strVal ? undefined : (strVal as Importance);
}
"
/>
<TooltipProvider>
<Tooltip :delay-duration="0">
<TooltipTrigger as-child>
<a href="/Settings/Notifications">
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
<Settings class="h-4 w-4" />
</Button>
</a>
</TooltipTrigger>
<TooltipContent>
<p>Edit Notification Settings</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div class="mt-3 flex items-start justify-between gap-3 px-3">
<div class="flex min-w-0 flex-1 flex-col gap-2">
<div
class="border-border/60 bg-muted/60 flex flex-wrap items-center gap-1 rounded-xl border p-1"
role="group"
>
<Button
v-for="option in filterOptions"
:key="option.label"
variant="ghost"
size="sm"
class="h-8 rounded-lg border border-transparent px-3 text-xs font-medium transition-colors"
:class="
importance === option.value
? 'border-border bg-background text-foreground'
: 'text-muted-foreground hover:border-border/60 hover:bg-muted/40 hover:text-foreground'
"
:aria-pressed="importance === option.value"
@click="importance = option.value"
>
{{ option.label }}
</Button>
</div>
</div>
<div class="shrink-0">
<TooltipProvider>
<Tooltip :delay-duration="0">
<TooltipTrigger as-child>
<a href="/Settings/Notifications">
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
<Settings class="h-4 w-4" />
</Button>
</a>
</TooltipTrigger>
<TooltipContent>
<p>Edit Notification Settings</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<TabsContent value="unread" class="min-h-0 flex-1 flex-col">

View File

@@ -1,9 +1,8 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import {
ArrowLeftIcon,
ArrowRightIcon,
ArrowTopRightOnSquareIcon,
KeyIcon,
@@ -18,7 +17,7 @@ import {
ResponsiveModalHeader,
ResponsiveModalTitle,
} from '@unraid/ui';
import { allowedDocsOriginRegex, allowedDocsUrlRegex } from '~/helpers/urls';
import { DOCS } from '~/helpers/urls';
import type { ComposerTranslation } from 'vue-i18n';
@@ -87,93 +86,55 @@ const handleClose = () => {
};
// iframe navigation handling
const iframeRef = ref<HTMLIFrameElement | null>(null);
const hasNavigated = ref(false);
const currentIframeUrl = ref<string | null>(null);
const docsChangelogUrl = computed(() => {
return currentRelease.value?.changelogPretty ?? null;
});
const actualIframeSrc = ref<string | null>(docsChangelogUrl.value);
const iframeSrc = computed(() => {
if (!docsChangelogUrl.value) {
return null;
}
const showRawChangelog = computed<boolean>(() => {
return Boolean(!docsChangelogUrl.value && currentRelease.value?.changelog);
});
try {
const entryTarget = docsChangelogUrl.value.trim();
if (!entryTarget) {
return null;
}
const handleDocsPostMessages = (event: MessageEvent) => {
// Common checks for all iframe messages
if (
event.data &&
iframeRef.value &&
event.source === iframeRef.value.contentWindow &&
(allowedDocsOriginRegex.test(event.origin) || event.origin === 'http://localhost:3000')
) {
// Handle navigation events
if (event.data.type === 'unraid-docs-navigation') {
if (typeof event.data.url === 'string' && allowedDocsUrlRegex.test(event.data.url)) {
hasNavigated.value = event.data.url !== docsChangelogUrl.value;
currentIframeUrl.value = event.data.url;
let entryUrl: URL;
try {
entryUrl = new URL(entryTarget);
const protocol = entryUrl.protocol.toLowerCase();
if (protocol !== 'http:' && protocol !== 'https:') {
return null;
}
if (entryUrl.origin !== DOCS.origin) {
return null;
}
} catch (error) {
entryUrl = new URL(entryTarget, DOCS);
if (entryUrl.origin !== DOCS.origin) {
return null;
}
}
// Handle theme ready events
else if (event.data.type === 'theme-ready') {
sendThemeToIframe();
}
const entryValue = `${entryUrl.pathname}${entryUrl.search}${entryUrl.hash}`;
const url = new URL(entryValue, DOCS);
url.searchParams.set('embed', '1');
url.searchParams.set('theme', isDarkMode.value ? 'dark' : 'light');
url.searchParams.set('entry', entryValue);
return url.toString();
} catch (error) {
console.error('Failed to construct docs iframe URL:', error);
return null;
}
};
// Keep this function just for the watch handler
const sendThemeToIframe = () => {
if (iframeRef.value && iframeRef.value.contentWindow) {
try {
const message = { type: 'theme-update', theme: isDarkMode.value ? 'dark' : 'light' };
iframeRef.value.contentWindow.postMessage(message, '*');
} catch (error) {
console.error('Failed to send theme to iframe:', error);
}
}
};
// Attach event listener right away instead of waiting for mount
onMounted(() => {
// Set initial values only
window.addEventListener('message', handleDocsPostMessages);
currentIframeUrl.value = docsChangelogUrl.value;
});
onBeforeUnmount(() => {
window.removeEventListener('message', handleDocsPostMessages);
});
const revertToInitialChangelog = () => {
if (iframeRef.value && docsChangelogUrl.value) {
iframeRef.value.src = docsChangelogUrl.value;
hasNavigated.value = false;
currentIframeUrl.value = docsChangelogUrl.value;
}
};
watch(
docsChangelogUrl,
(newUrl) => {
currentIframeUrl.value = newUrl;
hasNavigated.value = false;
if (newUrl) {
actualIframeSrc.value = newUrl;
} else {
actualIframeSrc.value = null;
}
},
{ immediate: true }
);
// Only need to watch for theme changes
watch(isDarkMode, () => {
// The iframe will only pick up the message if it has sent theme-ready
sendThemeToIframe();
const showRawChangelog = computed<boolean>(() => {
return Boolean(!iframeSrc.value && currentRelease.value?.changelog);
});
</script>
@@ -196,13 +157,13 @@ watch(isDarkMode, () => {
<div class="flex-1 px-3">
<div class="flex flex-col gap-4 sm:min-w-[40rem]">
<!-- iframe for changelog if available -->
<div v-if="docsChangelogUrl" class="h-[calc(100vh-15rem)] w-full overflow-hidden sm:h-[45rem]">
<div v-if="iframeSrc" class="h-[calc(100vh-15rem)] w-full overflow-hidden sm:h-[45rem]">
<iframe
v-if="actualIframeSrc"
ref="iframeRef"
:src="actualIframeSrc"
:src="iframeSrc"
class="h-full w-full rounded-md border-0"
sandbox="allow-scripts allow-same-origin"
allow="fullscreen"
referrerpolicy="no-referrer"
title="Unraid Changelog"
/>
</div>
@@ -231,21 +192,12 @@ watch(isDarkMode, () => {
<ResponsiveModalFooter>
<div :class="cn('flex w-full flex-wrap justify-between gap-3 md:gap-4')">
<div :class="cn('flex flex-wrap justify-start gap-3 md:gap-4')">
<!-- Back to changelog button (when navigated away) -->
<BrandButton
v-if="hasNavigated && docsChangelogUrl"
variant="underline"
:icon="ArrowLeftIcon"
aria-label="Back to Changelog"
@click="revertToInitialChangelog"
/>
<!-- View on docs button -->
<BrandButton
v-if="currentIframeUrl || currentRelease?.changelogPretty"
v-if="docsChangelogUrl"
variant="underline"
:external="true"
:href="currentIframeUrl || currentRelease?.changelogPretty"
:href="docsChangelogUrl"
:icon-right="ArrowTopRightOnSquareIcon"
aria-label="View on Docs"
target="_blank"

View File

@@ -5,6 +5,7 @@ import { storeToRefs } from 'pinia';
import { ArrowDownTrayIcon } from '@heroicons/vue/24/outline';
import {
ArrowTopRightOnSquareIcon,
CheckCircleIcon,
CogIcon,
EyeIcon,
IdentificationIcon,
@@ -56,6 +57,7 @@ const {
regExp,
regUpdatesExpired,
dateTimeFormat,
osVersion,
updateOsIgnoredReleases,
updateOsNotificationsEnabled,
updateOsResponse,
@@ -162,7 +164,7 @@ const extraLinks = computed((): BrandButtonProps[] => {
return buttons;
});
const actionButtons = computed((): BrandButtonProps[] | null => {
const actionButtons = computed((): BrandButtonProps[] => {
// If ignoring release, show close button as primary action
if (ignoreThisRelease.value && (available.value || availableWithRenewal.value)) {
return [
@@ -173,13 +175,13 @@ const actionButtons = computed((): BrandButtonProps[] | null => {
];
}
// update not available or no action buttons default closing
if (!available.value && !availableWithRenewal.value) {
return null;
}
const buttons: BrandButtonProps[] = [];
// update not available or no action buttons default to empty array
if (!available.value && !availableWithRenewal.value) {
return buttons;
}
// update available but not stable branch - should link out to account update callback
// if availableWithRenewal.value is true, then we need to renew the license before we can update so don't show the verify button
if (availableRequiresAuth.value && !availableWithRenewal.value) {
@@ -219,6 +221,10 @@ const actionButtons = computed((): BrandButtonProps[] | null => {
return buttons;
});
const showNoUpdateContent = computed(() => {
return !checkForUpdatesLoading.value && !available.value && !availableWithRenewal.value;
});
const close = () => {
// close it
updateOsStore.setModalOpen(false);
@@ -231,12 +237,14 @@ const close = () => {
};
const renderMainSlot = computed(() => {
return !!(
checkForUpdatesLoading.value ||
available.value ||
availableWithRenewal.value ||
extraLinks.value?.length > 0 ||
updateOsIgnoredReleases.value.length > 0
return (
!!(
checkForUpdatesLoading.value ||
available.value ||
availableWithRenewal.value ||
extraLinks.value?.length > 0 ||
updateOsIgnoredReleases.value.length > 0
) || showNoUpdateContent.value
);
});
@@ -330,6 +338,22 @@ const modalWidth = computed(() => {
</div>
</div>
<div v-if="showNoUpdateContent" class="flex flex-col items-center gap-4 py-6 text-center">
<div class="bg-primary/10 flex items-center justify-center rounded-full p-4">
<CheckCircleIcon class="text-primary h-10 w-10" />
</div>
<div class="space-y-2">
<p v-if="osVersion" class="text-muted-foreground text-center text-sm font-semibold">
{{ t('Current Version {0}', [osVersion]) }}
</p>
<p
v-if="modalCopy?.description"
class="text-muted-foreground text-xs sm:text-sm"
v-html="modalCopy.description"
/>
</div>
</div>
<div
v-if="extraLinks.length > 0"
:class="cn('xs:!flex-row flex flex-col justify-center gap-2')"
@@ -370,14 +394,13 @@ const modalWidth = computed(() => {
:class="
cn(
'mx-auto flex w-full gap-2',
actionButtons ? 'xs:!flex-row flex-col-reverse justify-between' : 'justify-center'
actionButtons.length > 0
? 'xs:!flex-row flex-col-reverse justify-between'
: 'justify-center'
)
"
>
<div
v-if="actionButtons"
:class="cn('xs:!flex-row flex flex-col-reverse justify-start gap-3')"
>
<div :class="cn('xs:!flex-row mt-2 flex flex-col-reverse justify-start gap-3')">
<TooltipProvider>
<Tooltip :delay-duration="0">
<TooltipTrigger as-child>
@@ -403,7 +426,10 @@ const modalWidth = computed(() => {
</Tooltip>
</TooltipProvider>
</div>
<div v-if="actionButtons" :class="cn('xs:!flex-row flex flex-col justify-end gap-3')">
<div
v-if="actionButtons.length > 0"
:class="cn('xs:!flex-row flex flex-col justify-end gap-3')"
>
<template v-for="item in actionButtons" :key="item.text">
<TooltipProvider v-if="ignoreThisRelease && item.text === localizedCloseText">
<Tooltip :delay-duration="300">

View File

@@ -23,9 +23,6 @@ function initializeGlobalDependencies() {
});
// Expose utility functions on window for debugging/external use
// With unified app, these are no longer needed
// Access the unified app via window.__unifiedApp instead
// Expose Apollo client on window for global access
window.apolloClient = apolloClient;

View File

@@ -10,10 +10,9 @@ import { client } from '~/helpers/create-apollo-client';
import { createHtmlEntityDecoder } from '~/helpers/i18n-utils';
import en_US from '~/locales/en_US.json';
import type { App as VueApp } from 'vue';
// Import Pinia for use in Vue apps
import { globalPinia } from '~/store/globalPinia';
import { useThemeStore } from '~/store/theme';
// Ensure Apollo client is singleton
const apolloClient = (typeof window !== 'undefined' && window.apolloClient) || client;
@@ -22,7 +21,7 @@ const apolloClient = (typeof window !== 'undefined' && window.apolloClient) || c
declare global {
interface Window {
globalPinia: typeof globalPinia;
__unifiedApp?: VueApp;
LOCALE_DATA?: string;
}
}
@@ -38,7 +37,7 @@ function setupI18n() {
// Check for window locale data
if (typeof window !== 'undefined') {
const windowLocaleData = (window as unknown as { LOCALE_DATA?: string }).LOCALE_DATA || null;
const windowLocaleData = window.LOCALE_DATA || null;
if (windowLocaleData) {
try {
parsedMessages = JSON.parse(decodeURIComponent(windowLocaleData));
@@ -64,19 +63,26 @@ function setupI18n() {
// Helper function to parse props from HTML attributes
function parsePropsFromElement(element: Element): Record<string, unknown> {
// Early exit if no attributes
if (!element.hasAttributes()) return {};
const props: Record<string, unknown> = {};
// Pre-compile attribute skip list into a Set for O(1) lookup
const skipAttrs = new Set(['class', 'id', 'style', 'data-vue-mounted']);
for (const attr of element.attributes) {
const name = attr.name;
const value = attr.value;
// Skip Vue internal attributes and common HTML attributes
if (name.startsWith('data-v-') || name === 'class' || name === 'id' || name === 'style') {
if (skipAttrs.has(name) || name.startsWith('data-v-')) {
continue;
}
const value = attr.value;
const first = value.trimStart()[0];
// Try to parse JSON values (handles HTML-encoded JSON)
if (value.startsWith('{') || value.startsWith('[')) {
if (first === '{' || first === '[') {
try {
// Decode HTML entities first
const decoded = value
@@ -113,6 +119,8 @@ export function mountUnifiedApp() {
app.use(ui);
app.provide(DefaultApolloClient, apolloClient);
const themeStore = useThemeStore();
// Mount the app to establish context
let rootElement = document.getElementById('unraid-unified-root');
if (!rootElement) {
@@ -126,75 +134,98 @@ export function mountUnifiedApp() {
// Now render components to their locations using the shared context
const mountedComponents: Array<{ element: HTMLElement; unmount: () => void }> = [];
// Components are already in priority order in component-registry
// Batch all selector queries first to identify which components are needed
const componentsToMount: Array<{ mapping: (typeof componentMappings)[0]; element: HTMLElement }> = [];
// Build a map of all selectors to their mappings for quick lookup
const selectorToMapping = new Map<string, (typeof componentMappings)[0]>();
componentMappings.forEach((mapping) => {
const { selector, appId } = mapping;
const selectors = Array.isArray(selector) ? selector : [selector];
const selectors = Array.isArray(mapping.selector) ? mapping.selector : [mapping.selector];
selectors.forEach((sel) => selectorToMapping.set(sel, mapping));
});
// Find first matching element
for (const sel of selectors) {
const element = document.querySelector(sel) as HTMLElement;
if (element && !element.hasAttribute('data-vue-mounted')) {
// Get the async component from mapping
const component = mapping.component;
// Query all selectors at once
const allSelectors = Array.from(selectorToMapping.keys()).join(',');
// Skip if no component is defined
if (!component) {
console.error(`[UnifiedMount] No component defined for ${appId}`);
continue;
// Early exit if no selectors to query
if (!allSelectors) {
console.debug('[UnifiedMount] Mounted 0 components');
return app;
}
const foundElements = document.querySelectorAll(allSelectors);
const processedMappings = new Set<(typeof componentMappings)[0]>();
foundElements.forEach((element) => {
if (!element.hasAttribute('data-vue-mounted')) {
// Find which mapping this element belongs to
for (const [selector, mapping] of selectorToMapping) {
if (element.matches(selector) && !processedMappings.has(mapping)) {
componentsToMount.push({ mapping, element: element as HTMLElement });
processedMappings.add(mapping);
break;
}
// Parse props from element
const props = parsePropsFromElement(element);
// Wrap component in UApp for Nuxt UI support
const wrappedComponent = {
name: `${appId}-wrapper`,
setup() {
return () =>
h(
UApp,
{},
{
default: () => h(component, props),
}
);
},
};
// Create vnode with shared app context
const vnode = createVNode(wrappedComponent);
vnode.appContext = app._context; // Share the app context
// Clear the element and render the component into it
element.innerHTML = '';
render(vnode, element);
// Mark as mounted
element.setAttribute('data-vue-mounted', 'true');
element.classList.add('unapi');
// Store for cleanup
mountedComponents.push({
element,
unmount: () => render(null, element),
});
break;
}
}
});
// Store reference for debugging
if (typeof window !== 'undefined') {
window.__unifiedApp = app;
window.__mountedComponents = mountedComponents;
}
// Now mount only the components that exist
componentsToMount.forEach(({ mapping, element }) => {
const { appId } = mapping;
const component = mapping.component;
// Skip if no component is defined
if (!component) {
console.error(`[UnifiedMount] No component defined for ${appId}`);
return;
}
// Parse props from element
const props = parsePropsFromElement(element);
// Wrap component in UApp for Nuxt UI support
const wrappedComponent = {
name: `${appId}-wrapper`,
setup() {
return () =>
h(
UApp,
{},
{
default: () => h(component, props),
}
);
},
};
// Create vnode with shared app context
const vnode = createVNode(wrappedComponent);
vnode.appContext = app._context; // Share the app context
// Clear the element and render the component into it
element.replaceChildren();
render(vnode, element);
// Mark as mounted
element.setAttribute('data-vue-mounted', 'true');
element.classList.add('unapi');
// Store for cleanup
mountedComponents.push({
element,
unmount: () => render(null, element),
});
});
// Re-apply theme classes/variables now that new scoped roots exist
themeStore.setCssVars();
console.debug(`[UnifiedMount] Mounted ${mountedComponents.length} components`);
return app;
}
// Replace the old autoMountAllComponents with the new unified approach
export function autoMountAllComponents() {
mountUnifiedApp();
return mountUnifiedApp();
}

View File

@@ -5,6 +5,9 @@ import { OBJ_TO_STR } from '~/helpers/functions';
import type { BrandButtonProps } from '@unraid/ui';
import type { Server } from '~/types/server';
import type { UserProfileLink } from '~/types/userProfile';
export type ErrorAction = BrandButtonProps | UserProfileLink;
export type ErrorType =
| 'account'
@@ -17,7 +20,7 @@ export type ErrorType =
| 'unraidApiState';
export interface Error {
actions?: BrandButtonProps[];
actions?: ErrorAction[];
debugServer?: Server;
forumLink?: boolean;
heading: string; // if adding new errors be sure to add translations key value pairs

View File

@@ -1,10 +1,11 @@
import { computed, ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { useLazyQuery } from '@vue/apollo-composable';
import { useQuery } from '@vue/apollo-composable';
import { defaultColors } from '~/themes/default';
import hexToRgba from 'hex-to-rgba';
import type { GetThemeQuery } from '~/composables/gql/graphql';
import type { Theme, ThemeVariables } from '~/themes/types';
import { graphql } from '~/composables/gql/gql';
@@ -26,21 +27,69 @@ export const GET_THEME_QUERY = graphql(`
}
`);
const DEFAULT_THEME: Theme = {
name: 'white',
banner: false,
bannerGradient: false,
bgColor: '',
descriptionShow: false,
metaColor: '',
textColor: '',
};
const DYNAMIC_VAR_KEYS = [
'--custom-header-text-primary',
'--custom-header-text-secondary',
'--custom-header-background-color',
'--custom-header-gradient-start',
'--custom-header-gradient-end',
'--banner-gradient',
] as const;
type DynamicVarKey = (typeof DYNAMIC_VAR_KEYS)[number];
export const useThemeStore = defineStore('theme', () => {
// State
const theme = ref<Theme>({
name: 'white',
banner: false,
bannerGradient: false,
bgColor: '',
descriptionShow: false,
metaColor: '',
textColor: '',
});
const { load } = useLazyQuery(GET_THEME_QUERY);
const theme = ref<Theme>({ ...DEFAULT_THEME });
const activeColorVariables = ref<ThemeVariables>(defaultColors.white);
const hasServerTheme = ref(false);
const { result, onResult, onError } = useQuery<GetThemeQuery>(GET_THEME_QUERY, null, {
fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'cache-first',
});
const applyThemeFromQuery = (publicTheme?: GetThemeQuery['publicTheme'] | null) => {
if (!publicTheme) {
return;
}
hasServerTheme.value = true;
theme.value = {
name: publicTheme.name?.toLowerCase() ?? DEFAULT_THEME.name,
banner: publicTheme.showBannerImage ?? DEFAULT_THEME.banner,
bannerGradient: publicTheme.showBannerGradient ?? DEFAULT_THEME.bannerGradient,
bgColor: publicTheme.headerBackgroundColor ?? DEFAULT_THEME.bgColor,
descriptionShow: publicTheme.showHeaderDescription ?? DEFAULT_THEME.descriptionShow,
metaColor: publicTheme.headerSecondaryTextColor ?? DEFAULT_THEME.metaColor,
textColor: publicTheme.headerPrimaryTextColor ?? DEFAULT_THEME.textColor,
};
};
onResult(({ data }) => {
if (data?.publicTheme) {
applyThemeFromQuery(data.publicTheme);
}
});
if (result.value?.publicTheme) {
applyThemeFromQuery(result.value.publicTheme);
}
onError((err) => {
console.warn('Failed to load theme from server, keeping existing theme:', err);
});
// Getters
// Apply dark mode for gray and black themes
@@ -56,38 +105,17 @@ export const useThemeStore = defineStore('theme', () => {
const end = theme.value?.bgColor ? 'var(--header-gradient-end)' : 'var(--header-background-color)';
return `background-image: linear-gradient(90deg, ${start} 0, ${end} 90%);`;
});
// Actions
const setTheme = async (data?: Theme) => {
const setTheme = (data?: Partial<Theme>) => {
if (data) {
theme.value = data;
} else {
try {
const result = await load();
if (result && result.publicTheme) {
theme.value = {
name: result.publicTheme.name?.toLowerCase() || 'white',
banner: result.publicTheme.showBannerImage ?? false,
bannerGradient: result.publicTheme.showBannerGradient ?? false,
bgColor: result.publicTheme.headerBackgroundColor || '',
descriptionShow: result.publicTheme.showHeaderDescription ?? false,
metaColor: result.publicTheme.headerSecondaryTextColor || '',
textColor: result.publicTheme.headerPrimaryTextColor || '',
};
return;
}
} catch (error) {
console.warn('Failed to load theme from server, using default:', error);
if (hasServerTheme.value) {
return;
}
// Single fallback for both no data and error cases
theme.value = {
name: 'white',
banner: false,
bannerGradient: false,
bgColor: '',
descriptionShow: false,
metaColor: '',
textColor: '',
...theme.value,
...data,
};
}
};
@@ -109,7 +137,7 @@ export const useThemeStore = defineStore('theme', () => {
// Only set CSS variables for dynamic/user-configured values from GraphQL
// Static theme values are handled by Tailwind v4 theme classes in @tailwind-shared
const dynamicVars: Record<string, string> = {};
const dynamicVars: Partial<Record<DynamicVarKey, string>> = {};
// User-configured colors from webGUI @ /Settings/DisplaySettings
if (theme.value.textColor) {
@@ -141,19 +169,31 @@ export const useThemeStore = defineStore('theme', () => {
}
requestAnimationFrame(() => {
// Apply theme classes to documentElement for Tailwind v4
const root = document.documentElement;
const scopedTargets: HTMLElement[] = [
document.documentElement,
...Array.from(document.querySelectorAll<HTMLElement>('.unapi')),
];
// Remove all existing theme and custom classes
root.className = root.className
.split(' ')
.filter((c) => !c.startsWith('theme-') && c !== 'dark' && !c.startsWith('has-custom-'))
.join(' ');
const cleanClassList = (classList: string) =>
classList
.split(' ')
.filter((c) => !c.startsWith('theme-') && c !== 'dark' && !c.startsWith('has-custom-'))
.filter(Boolean)
.join(' ');
// Add new theme classes
[...themeClasses, ...customClasses].forEach((cls) => root.classList.add(cls));
// Apply theme and custom classes to html element and all .unapi roots
scopedTargets.forEach((target) => {
target.className = cleanClassList(target.className);
[...themeClasses, ...customClasses].forEach((cls) => target.classList.add(cls));
// Also apply dark class to body for compatibility
if (darkMode.value) {
target.classList.add('dark');
} else {
target.classList.remove('dark');
}
});
// Maintain dark mode flag on body for legacy components
if (darkMode.value) {
document.body.classList.add('dark');
} else {
@@ -162,12 +202,22 @@ export const useThemeStore = defineStore('theme', () => {
// Only apply dynamic CSS variables for custom user values
// All theme defaults are handled by classes in @tailwind-shared/theme-variants.css
if (Object.keys(dynamicVars).length > 0) {
// Apply to root element for global availability
Object.entries(dynamicVars).forEach(([key, value]) => {
document.documentElement.style.setProperty(key, value);
const activeDynamicKeys = Object.keys(dynamicVars) as DynamicVarKey[];
scopedTargets.forEach((target) => {
activeDynamicKeys.forEach((key) => {
const value = dynamicVars[key];
if (value !== undefined) {
target.style.setProperty(key, value);
}
});
}
DYNAMIC_VAR_KEYS.forEach((key) => {
if (!Object.prototype.hasOwnProperty.call(dynamicVars, key)) {
target.style.removeProperty(key);
}
});
});
// Store active variables for reference (from defaultColors for compatibility)
const customTheme = { ...defaultColors[selectedTheme] };

View File

@@ -16,35 +16,52 @@ export const useUnraidApiStore = defineStore('unraidApi', () => {
const errorsStore = useErrorsStore();
const serverStore = useServerStore();
const unraidApiClient = ref<ApolloClientType<NormalizedCacheObject> | null>(client);
let pendingRestartPromise: Promise<void> | null = null;
// const unraidApiErrors = ref<any[]>([]);
const unraidApiStatus = ref<'connecting' | 'offline' | 'online' | 'restarting'>('connecting');
const prioritizeCorsError = ref(false); // Ensures we don't overwrite this specific error message with a non-descriptive network error message
const offlineError = computed(() => {
if (unraidApiStatus.value === 'offline') {
return new Error('The Unraid API is currently offline.');
/**
* Can both start and restart the unraid-api depending on its current status
*/
const restartUnraidApiClient = () => {
if (pendingRestartPromise) {
return pendingRestartPromise;
}
});
// maintains an error in global store while api is offline
watch(
offlineError,
(error) => {
const errorId = 'unraidApiOffline';
if (error) {
errorsStore.setError({
heading: 'Warning: API is offline!',
message: error.message,
ref: errorId,
level: 'warning',
type: 'unraidApiState',
const command = unraidApiStatus.value === 'offline' ? 'start' : 'restart';
unraidApiStatus.value = 'restarting';
const restartTask = (async () => {
try {
await WebguiUnraidApiCommand({
csrf_token: serverStore.csrf,
command,
});
} catch (error) {
let errorMessage = 'Unknown error';
if (typeof error === 'string') {
errorMessage = error.toUpperCase();
} else if (error instanceof Error) {
errorMessage = error.message;
}
errorsStore.setError({
heading: 'Error: unraid-api restart',
message: errorMessage,
level: 'error',
ref: 'restartUnraidApiClient',
type: 'request',
});
} else {
errorsStore.removeErrorByRef(errorId);
}
},
{ immediate: true }
);
})();
pendingRestartPromise = restartTask.finally(() => {
pendingRestartPromise = null;
});
return pendingRestartPromise;
};
const unraidApiRestartAction = computed((): UserProfileLink | undefined => {
const { connectPluginInstalled, stateDataError } = serverStore;
@@ -59,6 +76,34 @@ export const useUnraidApiStore = defineStore('unraidApi', () => {
};
});
const offlineError = computed(() => {
if (unraidApiStatus.value === 'offline') {
return new Error('The Unraid API is currently offline.');
}
return undefined;
});
// maintains an error in global store while api is offline
watch(
[offlineError, unraidApiRestartAction],
([error, restartAction]) => {
const errorId = 'unraidApiOffline';
errorsStore.removeErrorByRef(errorId);
if (error) {
errorsStore.setError({
heading: 'Warning: API is offline!',
message: error.message,
ref: errorId,
level: 'warning',
type: 'unraidApiState',
actions: restartAction ? [restartAction] : undefined,
});
}
},
{ immediate: true }
);
/**
* Automatically called when an apiKey is unset in the serverStore
*/
@@ -74,33 +119,6 @@ export const useUnraidApiStore = defineStore('unraidApi', () => {
unraidApiClient.value = null;
unraidApiStatus.value = 'offline';
};
/**
* Can both start and restart the unraid-api depending on it's current status
*/
const restartUnraidApiClient = async () => {
const command = unraidApiStatus.value === 'offline' ? 'start' : 'restart';
unraidApiStatus.value = 'restarting';
try {
await WebguiUnraidApiCommand({
csrf_token: serverStore.csrf,
command,
});
} catch (error) {
let errorMessage = 'Unknown error';
if (typeof error === 'string') {
errorMessage = error.toUpperCase();
} else if (error instanceof Error) {
errorMessage = error.message;
}
errorsStore.setError({
heading: 'Error: unraid-api restart',
message: errorMessage,
level: 'error',
ref: 'restartUnraidApiClient',
type: 'request',
});
}
};
return {
unraidApiClient,

View File

@@ -1,5 +1,5 @@
import type { ApolloClient } from '@apollo/client/core';
import type { autoMountComponent, getMountedApp, mountVueApp } from '~/components/Wrapper/mount-engine';
import type { client as apolloClient } from '~/helpers/create-apollo-client';
import type { parse } from 'graphql';
import type { Component } from 'vue';
@@ -11,7 +11,7 @@ import type { Component } from 'vue';
declare global {
interface Window {
// Apollo GraphQL client and utilities
apolloClient: typeof apolloClient;
apolloClient?: ApolloClient<unknown>;
gql: typeof parse;
graphqlParse: typeof parse;

View File

@@ -8,6 +8,7 @@ import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
import removeConsole from 'vite-plugin-remove-console';
import scopeTailwindToUnapi from './postcss/scopeTailwindToUnapi';
import { serveStaticHtml } from './vite-plugin-serve-static';
const dropConsole = process.env.VITE_ALLOW_CONSOLE_LOGS !== 'true';
@@ -83,6 +84,12 @@ export default defineConfig({
: []),
],
css: {
postcss: {
plugins: [scopeTailwindToUnapi()],
},
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
@@ -121,12 +128,11 @@ export default defineConfig({
rollupOptions: {
output: {
format: 'es',
entryFileNames: 'standalone-apps.js',
entryFileNames: 'standalone-apps-[hash].js',
chunkFileNames: '[name]-[hash].js',
assetFileNames: (assetInfo) => {
// Keep CSS files with predictable names
if (assetInfo.name?.endsWith('.css')) {
return 'standalone-apps.css';
return 'standalone-apps-[hash][extname]';
}
return '[name]-[hash][extname]';
},

View File

@@ -1,7 +1,25 @@
import { ref } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, vi } from 'vitest';
vi.mock('@vue/apollo-composable', async () => {
const actual =
await vi.importActual<typeof import('@vue/apollo-composable')>('@vue/apollo-composable');
const useQueryMock = vi.fn(() => ({
result: ref(null),
loading: ref(false),
onResult: vi.fn(),
onError: vi.fn(),
}));
return {
...actual,
useQuery: useQueryMock,
};
});
// Mock WebSocket for test environment
if (!global.WebSocket) {
const mockWebSocket = vi.fn().mockImplementation(() => ({